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({