fix: harden Windows editor path matching

This commit is contained in:
iliya 2026-05-16 18:46:10 +03:00
parent cf21001b18
commit c4aa130c2c
3 changed files with 82 additions and 10 deletions

View file

@ -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;

View file

@ -19,6 +19,7 @@ import {
isWindowsishPath,
joinPath,
lastSeparatorIndex,
normalizePathForComparison,
splitPath,
stripTrailingSeparators,
} from '@shared/utils/platformPath';
@ -42,6 +43,29 @@ function omitKey<V>(record: Record<string, V>, key: string): Record<string, V> {
return result;
}
function editorPathsEqual(left: string, right: string): boolean {
return normalizePathForComparison(left) === normalizePathForComparison(right);
}
function findMatchingPathKey<V>(record: Record<string, V>, filePath: string): string | null {
if (filePath in record) return filePath;
return Object.keys(record).find((key) => editorPathsEqual(key, filePath)) ?? null;
}
function getMatchingPathMapValue<V>(map: Map<string, V>, 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<V>(record: Record<string, V>, filePath: string): Record<string, V> {
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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
clearExternalChange: (filePath: string) => {
set((s) => ({
editorExternalChanges: omitKey(s.editorExternalChanges, filePath),
editorExternalChanges: omitMatchingPathKey(s.editorExternalChanges, filePath),
}));
},

View file

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