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,
|
isPathPrefix,
|
||||||
joinPath,
|
joinPath,
|
||||||
lastSeparatorIndex,
|
lastSeparatorIndex,
|
||||||
|
normalizePathForComparison,
|
||||||
splitPath,
|
splitPath,
|
||||||
} from '@shared/utils/platformPath';
|
} from '@shared/utils/platformPath';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
|
@ -81,6 +82,17 @@ const INDENT_PX = 12;
|
||||||
const MAX_DEPTH = 12;
|
const MAX_DEPTH = 12;
|
||||||
const AUTO_EXPAND_DELAY_MS = 500;
|
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
|
// Component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -204,7 +216,7 @@ export const EditorFileTree = ({
|
||||||
// Scroll to file when selectedFilePath changes (e.g. from revealFileInEditor)
|
// Scroll to file when selectedFilePath changes (e.g. from revealFileInEditor)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedFilePath) return;
|
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) {
|
if (idx >= 0) {
|
||||||
virtualizer.scrollToIndex(idx, { align: 'center' });
|
virtualizer.scrollToIndex(idx, { align: 'center' });
|
||||||
}
|
}
|
||||||
|
|
@ -633,7 +645,7 @@ const DraggableTreeItem = React.memo(
|
||||||
onRenameCancel,
|
onRenameCancel,
|
||||||
}: DraggableTreeItemProps): React.ReactElement => {
|
}: DraggableTreeItemProps): React.ReactElement => {
|
||||||
const { node, depth, isExpanded } = item;
|
const { node, depth, isExpanded } = item;
|
||||||
const isSelected = activeNodePath === node.fullPath;
|
const isSelected = treePathsEqual(activeNodePath, node.fullPath);
|
||||||
const visualDepth = Math.min(depth, MAX_DEPTH);
|
const visualDepth = Math.min(depth, MAX_DEPTH);
|
||||||
const isSensitive = node.data?.isSensitive;
|
const isSensitive = node.data?.isSensitive;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
isWindowsishPath,
|
isWindowsishPath,
|
||||||
joinPath,
|
joinPath,
|
||||||
lastSeparatorIndex,
|
lastSeparatorIndex,
|
||||||
|
normalizePathForComparison,
|
||||||
splitPath,
|
splitPath,
|
||||||
stripTrailingSeparators,
|
stripTrailingSeparators,
|
||||||
} from '@shared/utils/platformPath';
|
} from '@shared/utils/platformPath';
|
||||||
|
|
@ -42,6 +43,29 @@ function omitKey<V>(record: Record<string, V>, key: string): Record<string, V> {
|
||||||
return result;
|
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.
|
* Cooldown map: filePath → timestamp of last successful save.
|
||||||
*
|
*
|
||||||
|
|
@ -617,7 +641,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
const { editorOpenTabs } = get();
|
const { editorOpenTabs } = get();
|
||||||
|
|
||||||
// Dedup: if file already open, just activate it
|
// 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) {
|
if (existing) {
|
||||||
set({ editorActiveTabId: existing.id });
|
set({ editorActiveTabId: existing.id });
|
||||||
return;
|
return;
|
||||||
|
|
@ -1216,26 +1240,27 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
const { editorOpenTabs, editorProjectPath, editorSaving } = get();
|
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)
|
// 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
|
// Ignore watcher events within cooldown after save
|
||||||
// (covers race: save completes → editorSaving cleared → watcher fires late)
|
// (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;
|
if (lastSaveTime && Date.now() - lastSaveTime < SAVE_COOLDOWN_MS) return;
|
||||||
|
|
||||||
// Ignore watcher events within cooldown after move
|
// 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;
|
if (lastMoveTime && Date.now() - lastMoveTime < MOVE_COOLDOWN_MS) return;
|
||||||
|
|
||||||
// Track changes for open files
|
// Track changes for open files
|
||||||
const isOpenFile = editorOpenTabs.some((t) => t.filePath === event.path);
|
if (openTab || event.type === 'delete') {
|
||||||
if (isOpenFile || event.type === 'delete') {
|
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
editorExternalChanges: {
|
editorExternalChanges: {
|
||||||
...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) => {
|
clearExternalChange: (filePath: string) => {
|
||||||
set((s) => ({
|
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');
|
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', () => {
|
it('detects language from file extension', () => {
|
||||||
store.getState().openFile('/project/data.json');
|
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', () => {
|
describe('closeEditor resets all state including Group 2+3', () => {
|
||||||
it('resets tabs, dirty, saving, errors', () => {
|
it('resets tabs, dirty, saving, errors', () => {
|
||||||
store.setState({
|
store.setState({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue