refactor(ui): improve path normalization in FilesView
This commit is contained in:
@@ -27,7 +27,114 @@ type FilesViewTabsActions = {
|
||||
|
||||
export type FilesViewTabsStore = FilesViewTabsState & FilesViewTabsActions;
|
||||
|
||||
const normalizePath = (value: string): string => value.replace(/\\/g, '/');
|
||||
const normalizePath = (value: string): string => {
|
||||
if (!value) return '';
|
||||
|
||||
const raw = value.replace(/\\/g, '/');
|
||||
const hadUncPrefix = raw.startsWith('//');
|
||||
|
||||
let normalized = raw.replace(/\/+/g, '/');
|
||||
if (hadUncPrefix && !normalized.startsWith('//')) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
|
||||
const isUnixRoot = normalized === '/';
|
||||
const isWindowsDriveRoot = /^[A-Za-z]:\/$/.test(normalized);
|
||||
if (!isUnixRoot && !isWindowsDriveRoot) {
|
||||
normalized = normalized.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const toComparablePath = (value: string): string => {
|
||||
if (/^[A-Za-z]:\//.test(value)) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const isPathWithinRoot = (path: string, root: string): boolean => {
|
||||
const normalizedRoot = normalizePath(root);
|
||||
const normalizedPath = normalizePath(path);
|
||||
if (!normalizedRoot || !normalizedPath) return false;
|
||||
|
||||
const comparableRoot = toComparablePath(normalizedRoot);
|
||||
const comparablePath = toComparablePath(normalizedPath);
|
||||
return comparablePath === comparableRoot || comparablePath.startsWith(`${comparableRoot}/`);
|
||||
};
|
||||
|
||||
const sanitizeByRoot = (input: unknown): Record<string, RootTabsState> => {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const source = input as Record<string, unknown>;
|
||||
const next: Record<string, RootTabsState> = {};
|
||||
|
||||
for (const [rawRoot, rawState] of Object.entries(source)) {
|
||||
const root = normalizePath(rawRoot);
|
||||
if (!root || !rawState || typeof rawState !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const state = rawState as {
|
||||
openPaths?: unknown;
|
||||
selectedPath?: unknown;
|
||||
expandedPaths?: unknown;
|
||||
touchedAt?: unknown;
|
||||
};
|
||||
|
||||
const openPaths = Array.isArray(state.openPaths)
|
||||
? Array.from(new Set(state.openPaths
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.map((value) => normalizePath(value))
|
||||
.filter((value) => isPathWithinRoot(value, root))))
|
||||
: [];
|
||||
|
||||
const expandedPaths = Array.isArray(state.expandedPaths)
|
||||
? Array.from(new Set(state.expandedPaths
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.map((value) => normalizePath(value))
|
||||
.filter((value) => isPathWithinRoot(value, root))))
|
||||
: [];
|
||||
|
||||
const selectedPathCandidate = typeof state.selectedPath === 'string'
|
||||
? normalizePath(state.selectedPath)
|
||||
: null;
|
||||
|
||||
const selectedPath = selectedPathCandidate && isPathWithinRoot(selectedPathCandidate, root)
|
||||
? selectedPathCandidate
|
||||
: (openPaths[0] ?? null);
|
||||
|
||||
const touchedAt = typeof state.touchedAt === 'number' && Number.isFinite(state.touchedAt)
|
||||
? state.touchedAt
|
||||
: Date.now();
|
||||
|
||||
const existing = next[root];
|
||||
if (existing) {
|
||||
const mergedOpenPaths = Array.from(new Set([...existing.openPaths, ...openPaths]));
|
||||
const mergedExpandedPaths = Array.from(new Set([...existing.expandedPaths, ...expandedPaths]));
|
||||
const mergedSelectedPath = existing.selectedPath ?? selectedPath ?? (mergedOpenPaths[0] ?? null);
|
||||
next[root] = {
|
||||
openPaths: mergedOpenPaths,
|
||||
selectedPath: mergedSelectedPath,
|
||||
expandedPaths: mergedExpandedPaths,
|
||||
touchedAt: Math.max(existing.touchedAt, touchedAt),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
next[root] = {
|
||||
openPaths,
|
||||
selectedPath,
|
||||
expandedPaths,
|
||||
touchedAt,
|
||||
};
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const clampRoots = (byRoot: Record<string, RootTabsState>, maxRoots: number): Record<string, RootTabsState> => {
|
||||
const entries = Object.entries(byRoot);
|
||||
@@ -292,7 +399,18 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
|
||||
}),
|
||||
{
|
||||
name: 'files-view-tabs-store',
|
||||
version: 2,
|
||||
storage: createJSONStorage(() => getSafeStorage()),
|
||||
migrate: (persistedState) => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return { byRoot: {} };
|
||||
}
|
||||
|
||||
const rawByRoot = (persistedState as { byRoot?: unknown }).byRoot;
|
||||
return {
|
||||
byRoot: sanitizeByRoot(rawByRoot),
|
||||
};
|
||||
},
|
||||
partialize: (state) => ({ byRoot: state.byRoot }),
|
||||
}
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user