From 94fd9d125920442e8cab47dbe14cb9f508bfdf9f Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 16 May 2026 18:21:14 +0300 Subject: [PATCH 1/3] fix: centralize absolute path detection --- src/renderer/store/utils/pathResolution.ts | 16 +++--------- src/renderer/utils/claudeMdTracker.ts | 25 +++++------------- src/renderer/utils/contextTracker.ts | 30 ++++++++-------------- src/shared/utils/platformPath.ts | 12 +++++++++ test/shared/utils/platformPath.test.ts | 14 +++++++++- 5 files changed, 45 insertions(+), 52 deletions(-) diff --git a/src/renderer/store/utils/pathResolution.ts b/src/renderer/store/utils/pathResolution.ts index 8984f710..57771e0c 100644 --- a/src/renderer/store/utils/pathResolution.ts +++ b/src/renderer/store/utils/pathResolution.ts @@ -2,7 +2,7 @@ * Path resolution utilities for the store. */ -import { stripTrailingSeparators } from '@shared/utils/platformPath'; +import { isAbsoluteOrHomePath, stripTrailingSeparators } from '@shared/utils/platformPath'; /** * Resolves a relative path against a base path, handling various path formats. @@ -15,7 +15,7 @@ import { stripTrailingSeparators } from '@shared/utils/platformPath'; */ export function resolveFilePath(base: string, relativePath: string): string { // If already absolute, return as-is - if (isAbsolutePath(relativePath)) { + if (isAbsoluteOrHomePath(relativePath)) { return relativePath; } @@ -27,13 +27,7 @@ export function resolveFilePath(base: string, relativePath: string): string { cleanRelative = cleanRelative.slice(1); } - if (isAbsolutePath(cleanRelative)) { - return cleanRelative; - } - - // Tilde paths (~/) are home-relative absolute paths - pass through as-is - // The main process will expand ~ to the actual home directory - if (cleanRelative.startsWith('~/') || cleanRelative.startsWith('~\\') || cleanRelative === '~') { + if (isAbsoluteOrHomePath(cleanRelative)) { return cleanRelative; } @@ -69,10 +63,6 @@ export function resolveFilePath(base: string, relativePath: string): string { return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase; } -function isAbsolutePath(input: string): boolean { - return input.startsWith('/') || input.startsWith('\\\\') || /^[a-zA-Z]:[\\/]/.test(input); -} - function normalizeSeparators(input: string, separator: '/' | '\\'): string { let output = ''; let prevWasSeparator = false; diff --git a/src/renderer/utils/claudeMdTracker.ts b/src/renderer/utils/claudeMdTracker.ts index 661f0e95..7990671a 100644 --- a/src/renderer/utils/claudeMdTracker.ts +++ b/src/renderer/utils/claudeMdTracker.ts @@ -8,6 +8,7 @@ */ import { + isAbsoluteOrHomePath, isPathPrefix, lastSeparatorIndex, normalizePathForComparison, @@ -63,20 +64,6 @@ export function getDisplayName(path: string, _source: ClaudeMdSource): string { return path; } -/** - * Check if a path is absolute (starts with /). - */ -function isAbsolutePath(path: string): boolean { - return ( - path.startsWith('/') || - path.startsWith('~/') || - path.startsWith('~\\') || - path === '~' || - path.startsWith('\\\\') || - /^[a-zA-Z]:[\\/]/.test(path) - ); -} - /** * Join paths, handling various path formats properly. * Handles: @@ -87,7 +74,7 @@ function isAbsolutePath(path: string): boolean { * - Paths with @ prefix: @apps/foo/bar.tsx (strips @ then joins) */ function joinPaths(base: string, relative: string): string { - if (isAbsolutePath(relative)) { + if (isAbsoluteOrHomePath(relative)) { return relative; } @@ -99,7 +86,7 @@ function joinPaths(base: string, relative: string): string { if (cleanRelative.startsWith('@')) { cleanRelative = cleanRelative.slice(1); } - if (isAbsolutePath(cleanRelative)) { + if (isAbsoluteOrHomePath(cleanRelative)) { return cleanRelative; } @@ -239,7 +226,9 @@ export function extractUserMentionPaths( for (const ref of fileReferences) { if (ref.path) { // Convert to absolute if relative - const absolutePath = isAbsolutePath(ref.path) ? ref.path : joinPaths(projectRoot, ref.path); + const absolutePath = isAbsoluteOrHomePath(ref.path) + ? ref.path + : joinPaths(projectRoot, ref.path); paths.push(absolutePath); } } @@ -525,7 +514,7 @@ function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats const responseRefs = extractFileRefsFromResponses(aiGroup.responses); for (const ref of responseRefs) { if (ref.path) { - const absPath = isAbsolutePath(ref.path) ? ref.path : joinPaths(projectRoot, ref.path); + const absPath = isAbsoluteOrHomePath(ref.path) ? ref.path : joinPaths(projectRoot, ref.path); allFilePaths.push(absPath); } } diff --git a/src/renderer/utils/contextTracker.ts b/src/renderer/utils/contextTracker.ts index e9bd85f9..5e15b55e 100644 --- a/src/renderer/utils/contextTracker.ts +++ b/src/renderer/utils/contextTracker.ts @@ -9,7 +9,11 @@ * This builds on claudeMdTracker.ts and extends it to track all context sources. */ -import { normalizePathForComparison, stripTrailingSeparators } from '@shared/utils/platformPath'; +import { + isAbsoluteOrHomePath, + normalizePathForComparison, + stripTrailingSeparators, +} from '@shared/utils/platformPath'; import { estimateTokens } from '@shared/utils/tokenFormatting'; import { MAX_MENTIONED_FILE_TOKENS } from '../types/contextInjection'; @@ -447,20 +451,6 @@ interface ComputeContextStatsParams { directoryTokenData?: Record; } -/** - * Helper to check if a path is absolute. - */ -function isAbsolutePath(path: string): boolean { - return ( - path.startsWith('/') || - path.startsWith('~/') || - path.startsWith('~\\') || - path === '~' || - path.startsWith('\\\\') || - /^[a-zA-Z]:[\\/]/.test(path) - ); -} - /** * Helper to join paths, handling various path formats properly. * Handles: @@ -471,7 +461,7 @@ function isAbsolutePath(path: string): boolean { * - Paths with @ prefix: @apps/foo/bar.tsx (strips @ then joins) */ function joinPaths(base: string, relative: string): string { - if (isAbsolutePath(relative)) { + if (isAbsoluteOrHomePath(relative)) { return relative; } @@ -482,7 +472,7 @@ function joinPaths(base: string, relative: string): string { if (cleanRelative.startsWith('@')) { cleanRelative = cleanRelative.slice(1); } - if (isAbsolutePath(cleanRelative)) { + if (isAbsoluteOrHomePath(cleanRelative)) { return cleanRelative; } @@ -679,7 +669,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats { const responseRefs = extractFileRefsFromResponses(aiGroup.responses); for (const ref of responseRefs) { if (ref.path) { - const absPath = isAbsolutePath(ref.path) ? ref.path : joinPaths(projectRoot, ref.path); + const absPath = isAbsoluteOrHomePath(ref.path) ? ref.path : joinPaths(projectRoot, ref.path); allFilePaths.push(absPath); } } @@ -735,7 +725,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats { if (!fileRef.path) continue; // Convert to absolute path if needed - const absolutePath = isAbsolutePath(fileRef.path) + const absolutePath = isAbsoluteOrHomePath(fileRef.path) ? fileRef.path : joinPaths(projectRoot, fileRef.path); @@ -768,7 +758,7 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats { for (const fileRef of responseRefs) { if (!fileRef.path) continue; - const absolutePath = isAbsolutePath(fileRef.path) + const absolutePath = isAbsoluteOrHomePath(fileRef.path) ? fileRef.path : joinPaths(projectRoot, fileRef.path); diff --git a/src/shared/utils/platformPath.ts b/src/shared/utils/platformPath.ts index 03eacb2f..9f121087 100644 --- a/src/shared/utils/platformPath.ts +++ b/src/shared/utils/platformPath.ts @@ -22,6 +22,18 @@ export function isWindowsishPath(filePath: string): boolean { return /^[A-Za-z]:\//.test(p) || p.startsWith('//'); } +/** True for filesystem-absolute paths and home-relative `~` paths. */ +export function isAbsoluteOrHomePath(filePath: string): boolean { + return ( + filePath.startsWith('/') || + filePath.startsWith('~/') || + filePath.startsWith('~\\') || + filePath === '~' || + filePath.startsWith('\\\\') || + /^[A-Za-z]:[\\/]/.test(filePath) + ); +} + /** * Normalize for comparisons: * - Convert `\` → `/` diff --git a/test/shared/utils/platformPath.test.ts b/test/shared/utils/platformPath.test.ts index 19d168cc..94979695 100644 --- a/test/shared/utils/platformPath.test.ts +++ b/test/shared/utils/platformPath.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { getRelativePathWithinPrefix, isPathPrefix } from '../../../src/shared/utils/platformPath'; +import { + getRelativePathWithinPrefix, + isAbsoluteOrHomePath, + isPathPrefix, +} from '../../../src/shared/utils/platformPath'; describe('platformPath Windows containment', () => { it('matches Windows drive paths case-insensitively and preserves child path style', () => { @@ -35,4 +39,12 @@ describe('platformPath Windows containment', () => { expect(isPathPrefix('', '/Users/Alice/Repo/src/app.ts')).toBe(false); expect(getRelativePathWithinPrefix('', '/Users/Alice/Repo/src/app.ts')).toBe(null); }); + + it('detects absolute and home-relative paths across platforms', () => { + expect(isAbsoluteOrHomePath('/Users/Alice/Repo')).toBe(true); + expect(isAbsoluteOrHomePath('C:\\Users\\Alice\\Repo')).toBe(true); + expect(isAbsoluteOrHomePath('\\\\server\\share\\Repo')).toBe(true); + expect(isAbsoluteOrHomePath('~/Repo')).toBe(true); + expect(isAbsoluteOrHomePath('src/app.ts')).toBe(false); + }); }); From cf21001b18d3345898b18e80f2792ea6d736fafe Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 16 May 2026 18:27:20 +0300 Subject: [PATCH 2/3] test: cover home path detection variants --- test/shared/utils/platformPath.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/shared/utils/platformPath.test.ts b/test/shared/utils/platformPath.test.ts index 94979695..f5d7faff 100644 --- a/test/shared/utils/platformPath.test.ts +++ b/test/shared/utils/platformPath.test.ts @@ -44,7 +44,9 @@ describe('platformPath Windows containment', () => { expect(isAbsoluteOrHomePath('/Users/Alice/Repo')).toBe(true); expect(isAbsoluteOrHomePath('C:\\Users\\Alice\\Repo')).toBe(true); expect(isAbsoluteOrHomePath('\\\\server\\share\\Repo')).toBe(true); + expect(isAbsoluteOrHomePath('~')).toBe(true); expect(isAbsoluteOrHomePath('~/Repo')).toBe(true); + expect(isAbsoluteOrHomePath('~\\Repo')).toBe(true); expect(isAbsoluteOrHomePath('src/app.ts')).toBe(false); }); }); From c4aa130c2cd86a8ca36b86e85538de9dad5b30c6 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 16 May 2026 18:46:10 +0300 Subject: [PATCH 3/3] fix: harden Windows editor path matching --- .../components/team/editor/EditorFileTree.tsx | 16 +++++++- src/renderer/store/slices/editorSlice.ts | 41 +++++++++++++++---- test/renderer/store/editorSlice.test.ts | 35 ++++++++++++++++ 3 files changed, 82 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/team/editor/EditorFileTree.tsx b/src/renderer/components/team/editor/EditorFileTree.tsx index 3172ccab..b637abe9 100644 --- a/src/renderer/components/team/editor/EditorFileTree.tsx +++ b/src/renderer/components/team/editor/EditorFileTree.tsx @@ -32,6 +32,7 @@ import { isPathPrefix, joinPath, lastSeparatorIndex, + normalizePathForComparison, splitPath, } from '@shared/utils/platformPath'; import { useVirtualizer } from '@tanstack/react-virtual'; @@ -81,6 +82,17 @@ const INDENT_PX = 12; const MAX_DEPTH = 12; const AUTO_EXPAND_DELAY_MS = 500; +function treePathsEqual( + left: string | null | undefined, + right: string | null | undefined +): boolean { + return ( + typeof left === 'string' && + typeof right === 'string' && + normalizePathForComparison(left) === normalizePathForComparison(right) + ); +} + // ============================================================================= // Component // ============================================================================= @@ -204,7 +216,7 @@ export const EditorFileTree = ({ // Scroll to file when selectedFilePath changes (e.g. from revealFileInEditor) useEffect(() => { if (!selectedFilePath) return; - const idx = flatItems.findIndex((fi) => fi.node.fullPath === selectedFilePath); + const idx = flatItems.findIndex((fi) => treePathsEqual(fi.node.fullPath, selectedFilePath)); if (idx >= 0) { virtualizer.scrollToIndex(idx, { align: 'center' }); } @@ -633,7 +645,7 @@ const DraggableTreeItem = React.memo( onRenameCancel, }: DraggableTreeItemProps): React.ReactElement => { const { node, depth, isExpanded } = item; - const isSelected = activeNodePath === node.fullPath; + const isSelected = treePathsEqual(activeNodePath, node.fullPath); const visualDepth = Math.min(depth, MAX_DEPTH); const isSensitive = node.data?.isSensitive; diff --git a/src/renderer/store/slices/editorSlice.ts b/src/renderer/store/slices/editorSlice.ts index 28f9e210..b2e0df42 100644 --- a/src/renderer/store/slices/editorSlice.ts +++ b/src/renderer/store/slices/editorSlice.ts @@ -19,6 +19,7 @@ import { isWindowsishPath, joinPath, lastSeparatorIndex, + normalizePathForComparison, splitPath, stripTrailingSeparators, } from '@shared/utils/platformPath'; @@ -42,6 +43,29 @@ function omitKey(record: Record, key: string): Record { return result; } +function editorPathsEqual(left: string, right: string): boolean { + return normalizePathForComparison(left) === normalizePathForComparison(right); +} + +function findMatchingPathKey(record: Record, filePath: string): string | null { + if (filePath in record) return filePath; + return Object.keys(record).find((key) => editorPathsEqual(key, filePath)) ?? null; +} + +function getMatchingPathMapValue(map: Map, filePath: string): V | undefined { + const exact = map.get(filePath); + if (exact !== undefined) return exact; + for (const [key, value] of map) { + if (editorPathsEqual(key, filePath)) return value; + } + return undefined; +} + +function omitMatchingPathKey(record: Record, filePath: string): Record { + const key = findMatchingPathKey(record, filePath); + return key ? omitKey(record, key) : record; +} + /** * Cooldown map: filePath → timestamp of last successful save. * @@ -617,7 +641,7 @@ export const createEditorSlice: StateCreator = (s const { editorOpenTabs } = get(); // Dedup: if file already open, just activate it - const existing = editorOpenTabs.find((t) => t.filePath === filePath); + const existing = editorOpenTabs.find((t) => editorPathsEqual(t.filePath, filePath)); if (existing) { set({ editorActiveTabId: existing.id }); return; @@ -1216,26 +1240,27 @@ export const createEditorSlice: StateCreator = (s }, 2000); } const { editorOpenTabs, editorProjectPath, editorSaving } = get(); + const openTab = editorOpenTabs.find((tab) => editorPathsEqual(tab.filePath, event.path)); + const canonicalEventPath = openTab?.filePath ?? event.path; // Ignore watcher events for files we are currently saving (our own write) - if (editorSaving[event.path]) return; + if (findMatchingPathKey(editorSaving, event.path)) return; // Ignore watcher events within cooldown after save // (covers race: save completes → editorSaving cleared → watcher fires late) - const lastSaveTime = recentSaveTimestamps.get(event.path); + const lastSaveTime = getMatchingPathMapValue(recentSaveTimestamps, event.path); if (lastSaveTime && Date.now() - lastSaveTime < SAVE_COOLDOWN_MS) return; // Ignore watcher events within cooldown after move - const lastMoveTime = recentMoveTimestamps.get(event.path); + const lastMoveTime = getMatchingPathMapValue(recentMoveTimestamps, event.path); if (lastMoveTime && Date.now() - lastMoveTime < MOVE_COOLDOWN_MS) return; // Track changes for open files - const isOpenFile = editorOpenTabs.some((t) => t.filePath === event.path); - if (isOpenFile || event.type === 'delete') { + if (openTab || event.type === 'delete') { set((s) => ({ editorExternalChanges: { ...s.editorExternalChanges, - [event.path]: event.type, + [canonicalEventPath]: event.type, }, })); } @@ -1267,7 +1292,7 @@ export const createEditorSlice: StateCreator = (s clearExternalChange: (filePath: string) => { set((s) => ({ - editorExternalChanges: omitKey(s.editorExternalChanges, filePath), + editorExternalChanges: omitMatchingPathKey(s.editorExternalChanges, filePath), })); }, diff --git a/test/renderer/store/editorSlice.test.ts b/test/renderer/store/editorSlice.test.ts index eac85169..b008d099 100644 --- a/test/renderer/store/editorSlice.test.ts +++ b/test/renderer/store/editorSlice.test.ts @@ -295,6 +295,16 @@ describe('editorSlice', () => { expect(state.editorActiveTabId).toBe('/project/src/index.ts'); }); + it('deduplicates Windows tabs across drive case and separator differences', () => { + store.getState().openFile('C:\\Repo\\src\\index.ts'); + store.getState().openFile('c:/repo/src/index.ts'); + + const state = store.getState(); + expect(state.editorOpenTabs).toHaveLength(1); + expect(state.editorOpenTabs[0].filePath).toBe('C:\\Repo\\src\\index.ts'); + expect(state.editorActiveTabId).toBe('C:\\Repo\\src\\index.ts'); + }); + it('detects language from file extension', () => { store.getState().openFile('/project/data.json'); @@ -531,6 +541,31 @@ describe('editorSlice', () => { }); }); + describe('handleExternalFileChange', () => { + it('keys Windows watcher changes to the open tab path across separator differences', () => { + vi.useFakeTimers(); + try { + store.getState().openFile('C:\\Repo\\src\\index.ts'); + + store.getState().handleExternalFileChange({ + type: 'change', + path: 'c:/repo/src/index.ts', + }); + + expect(store.getState().editorExternalChanges).toEqual({ + 'C:\\Repo\\src\\index.ts': 'change', + }); + + store.getState().clearExternalChange('c:/repo/src/index.ts'); + + expect(store.getState().editorExternalChanges).toEqual({}); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + } + }); + }); + describe('closeEditor resets all state including Group 2+3', () => { it('resets tabs, dirty, saving, errors', () => { store.setState({