fix: harden Windows editor path matching
This commit is contained in:
parent
cf21001b18
commit
c4aa130c2c
3 changed files with 82 additions and 10 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}));
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue