2026-02-01 21:43:49 +02:00
|
|
|
import { create } from 'zustand';
|
2026-06-30 04:47:52 -04:00
|
|
|
import { devtools, persist } from 'zustand/middleware';
|
2026-02-01 21:43:49 +02:00
|
|
|
|
2026-06-30 04:47:52 -04:00
|
|
|
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
2026-07-21 20:52:20 +03:00
|
|
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
2026-02-01 21:43:49 +02:00
|
|
|
|
|
|
|
|
type RootTabsState = {
|
|
|
|
|
openPaths: string[];
|
|
|
|
|
selectedPath: string | null;
|
2026-02-08 22:24:25 -03:00
|
|
|
expandedPaths: string[];
|
2026-02-01 21:43:49 +02:00
|
|
|
touchedAt: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type FilesViewTabsState = {
|
|
|
|
|
byRoot: Record<string, RootTabsState>;
|
2026-07-21 20:52:20 +03:00
|
|
|
activeRuntimeKey: string;
|
|
|
|
|
runtimeSnapshots: Record<string, { byRoot: Record<string, RootTabsState>; updatedAt: number }>;
|
2026-02-01 21:43:49 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type FilesViewTabsActions = {
|
2026-06-12 18:24:07 +03:00
|
|
|
addOpenPath: (root: string, path: string, options?: { allowOutsideRoot?: boolean }) => void;
|
2026-02-01 21:43:49 +02:00
|
|
|
removeOpenPath: (root: string, path: string) => void;
|
|
|
|
|
removeOpenPathsByPrefix: (root: string, prefixPath: string) => void;
|
2026-06-26 12:47:01 +03:00
|
|
|
removeExpandedPathsByPrefix: (root: string, prefixPath: string) => void;
|
2026-06-12 18:24:07 +03:00
|
|
|
setSelectedPath: (root: string, path: string | null, options?: { allowOutsideRoot?: boolean }) => void;
|
2026-02-01 21:43:49 +02:00
|
|
|
ensureSelectedPath: (root: string) => void;
|
2026-02-08 22:24:25 -03:00
|
|
|
toggleExpandedPath: (root: string, path: string) => void;
|
2026-07-27 23:07:42 +03:00
|
|
|
collapseAllExpandedPaths: (root: string) => void;
|
2026-02-08 22:24:25 -03:00
|
|
|
expandPath: (root: string, path: string) => void;
|
|
|
|
|
expandPaths: (root: string, paths: string[]) => void;
|
2026-07-21 20:52:20 +03:00
|
|
|
resetForRuntimeSwitch: (runtimeKey: string) => void;
|
2026-02-01 21:43:49 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type FilesViewTabsStore = FilesViewTabsState & FilesViewTabsActions;
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
const MAX_ROOTS = 20;
|
|
|
|
|
const MAX_RUNTIME_SNAPSHOTS = 8;
|
|
|
|
|
const MAX_OPEN_PATHS_PER_ROOT = 50;
|
|
|
|
|
const MAX_EXPANDED_PATHS_PER_ROOT = 500;
|
|
|
|
|
const MAX_PATH_LENGTH = 4096;
|
|
|
|
|
const ROOT_TTL_MS = 90 * 24 * 60 * 60_000;
|
|
|
|
|
|
2026-02-09 11:50:21 +02:00
|
|
|
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))
|
2026-07-21 20:52:20 +03:00
|
|
|
.filter((value) => value.length <= MAX_PATH_LENGTH && isPathWithinRoot(value, root))))
|
|
|
|
|
.slice(-MAX_OPEN_PATHS_PER_ROOT)
|
2026-02-09 11:50:21 +02:00
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
const expandedPaths = Array.isArray(state.expandedPaths)
|
|
|
|
|
? Array.from(new Set(state.expandedPaths
|
|
|
|
|
.filter((value): value is string => typeof value === 'string')
|
|
|
|
|
.map((value) => normalizePath(value))
|
2026-07-21 20:52:20 +03:00
|
|
|
.filter((value) => value.length <= MAX_PATH_LENGTH && isPathWithinRoot(value, root))))
|
|
|
|
|
.slice(-MAX_EXPANDED_PATHS_PER_ROOT)
|
2026-02-09 11:50:21 +02:00
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
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();
|
2026-07-21 20:52:20 +03:00
|
|
|
if (Date.now() - touchedAt > ROOT_TTL_MS) continue;
|
2026-02-09 11:50:21 +02:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
return clampRoots(next, MAX_ROOTS);
|
2026-02-09 11:50:21 +02:00
|
|
|
};
|
2026-02-01 21:43:49 +02:00
|
|
|
|
|
|
|
|
const clampRoots = (byRoot: Record<string, RootTabsState>, maxRoots: number): Record<string, RootTabsState> => {
|
|
|
|
|
const entries = Object.entries(byRoot);
|
|
|
|
|
if (entries.length <= maxRoots) {
|
|
|
|
|
return byRoot;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
entries.sort((a, b) => (b[1]?.touchedAt ?? 0) - (a[1]?.touchedAt ?? 0));
|
|
|
|
|
const next: Record<string, RootTabsState> = {};
|
|
|
|
|
for (const [root, state] of entries.slice(0, maxRoots)) {
|
|
|
|
|
next[root] = state;
|
|
|
|
|
}
|
|
|
|
|
return next;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const touchRoot = (prev: RootTabsState | undefined): RootTabsState => {
|
|
|
|
|
if (prev) {
|
|
|
|
|
return { ...prev, touchedAt: Date.now() };
|
|
|
|
|
}
|
2026-02-08 22:24:25 -03:00
|
|
|
return { openPaths: [], selectedPath: null, expandedPaths: [], touchedAt: Date.now() };
|
2026-02-01 21:43:49 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
|
|
|
|
|
devtools(
|
|
|
|
|
persist(
|
|
|
|
|
(set, get) => ({
|
|
|
|
|
byRoot: {},
|
2026-07-21 20:52:20 +03:00
|
|
|
activeRuntimeKey: getRuntimeKey(),
|
|
|
|
|
runtimeSnapshots: {},
|
|
|
|
|
|
|
|
|
|
resetForRuntimeSwitch: (runtimeKey) => {
|
|
|
|
|
set((state) => {
|
|
|
|
|
const runtimeSnapshots = {
|
|
|
|
|
...state.runtimeSnapshots,
|
|
|
|
|
[state.activeRuntimeKey]: { byRoot: sanitizeByRoot(state.byRoot), updatedAt: Date.now() },
|
|
|
|
|
};
|
|
|
|
|
return {
|
|
|
|
|
activeRuntimeKey: runtimeKey,
|
|
|
|
|
runtimeSnapshots,
|
|
|
|
|
byRoot: sanitizeByRoot(runtimeSnapshots[runtimeKey]?.byRoot),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
},
|
2026-02-01 21:43:49 +02:00
|
|
|
|
2026-06-12 18:24:07 +03:00
|
|
|
addOpenPath: (root, path, options) => {
|
2026-02-01 21:43:49 +02:00
|
|
|
const normalizedRoot = normalizePath((root || '').trim());
|
|
|
|
|
const normalizedPath = normalizePath((path || '').trim());
|
2026-06-12 18:24:07 +03:00
|
|
|
if (!normalizedRoot || !normalizedPath || (!options?.allowOutsideRoot && !isPathWithinRoot(normalizedPath, normalizedRoot))) {
|
2026-02-01 21:43:49 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set((state) => {
|
|
|
|
|
const prev = state.byRoot[normalizedRoot];
|
|
|
|
|
const current = touchRoot(prev);
|
|
|
|
|
const exists = current.openPaths.includes(normalizedPath);
|
|
|
|
|
const nextOpenPaths = exists ? current.openPaths : [...current.openPaths, normalizedPath];
|
|
|
|
|
const nextSelectedPath = current.selectedPath ?? normalizedPath;
|
|
|
|
|
|
|
|
|
|
if (prev && exists && prev.selectedPath === nextSelectedPath) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
const byRoot = {
|
|
|
|
|
...state.byRoot,
|
|
|
|
|
[normalizedRoot]: {
|
|
|
|
|
...current,
|
|
|
|
|
openPaths: nextOpenPaths,
|
|
|
|
|
selectedPath: nextSelectedPath,
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-07-21 20:52:20 +03:00
|
|
|
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
|
2026-02-01 21:43:49 +02:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
removeOpenPath: (root, path) => {
|
|
|
|
|
const normalizedRoot = normalizePath((root || '').trim());
|
|
|
|
|
const normalizedPath = normalizePath((path || '').trim());
|
|
|
|
|
if (!normalizedRoot || !normalizedPath) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set((state) => {
|
|
|
|
|
const current = state.byRoot[normalizedRoot];
|
|
|
|
|
if (!current) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 03:56:53 -04:00
|
|
|
const comparablePath = toComparablePath(normalizedPath);
|
|
|
|
|
const isMatchingPath = (candidate: string) => toComparablePath(candidate) === comparablePath;
|
|
|
|
|
const selectedPathMatches = current.selectedPath ? isMatchingPath(current.selectedPath) : false;
|
|
|
|
|
if (!current.openPaths.some(isMatchingPath) && !selectedPathMatches) {
|
2026-02-01 21:43:49 +02:00
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 03:56:53 -04:00
|
|
|
const openPaths = current.openPaths.filter((p) => !isMatchingPath(p));
|
|
|
|
|
const selectedPath = selectedPathMatches ? (openPaths[0] ?? null) : current.selectedPath;
|
2026-02-01 21:43:49 +02:00
|
|
|
|
|
|
|
|
const byRoot = {
|
|
|
|
|
...state.byRoot,
|
|
|
|
|
[normalizedRoot]: {
|
|
|
|
|
...current,
|
|
|
|
|
openPaths,
|
|
|
|
|
selectedPath,
|
|
|
|
|
touchedAt: Date.now(),
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-07-21 20:52:20 +03:00
|
|
|
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
|
2026-02-01 21:43:49 +02:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
removeOpenPathsByPrefix: (root, prefixPath) => {
|
|
|
|
|
const normalizedRoot = normalizePath((root || '').trim());
|
|
|
|
|
const normalizedPrefix = normalizePath((prefixPath || '').trim());
|
|
|
|
|
if (!normalizedRoot || !normalizedPrefix) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set((state) => {
|
|
|
|
|
const current = state.byRoot[normalizedRoot];
|
|
|
|
|
if (!current) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 03:56:53 -04:00
|
|
|
const comparablePrefix = toComparablePath(normalizedPrefix);
|
|
|
|
|
const comparablePrefixWithSlash = comparablePrefix.endsWith('/') ? comparablePrefix : `${comparablePrefix}/`;
|
|
|
|
|
const isWithinPrefix = (candidate: string) => {
|
|
|
|
|
const comparablePath = toComparablePath(candidate);
|
|
|
|
|
return comparablePath === comparablePrefix || comparablePath.startsWith(comparablePrefixWithSlash);
|
|
|
|
|
};
|
|
|
|
|
const openPaths = current.openPaths.filter((p) => !isWithinPrefix(p));
|
2026-02-01 21:43:49 +02:00
|
|
|
if (openPaths.length === current.openPaths.length) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 03:56:53 -04:00
|
|
|
const selectedPath = current.selectedPath && isWithinPrefix(current.selectedPath)
|
2026-02-01 21:43:49 +02:00
|
|
|
? (openPaths[0] ?? null)
|
|
|
|
|
: current.selectedPath;
|
|
|
|
|
|
|
|
|
|
const byRoot = {
|
|
|
|
|
...state.byRoot,
|
|
|
|
|
[normalizedRoot]: {
|
|
|
|
|
...current,
|
|
|
|
|
openPaths,
|
|
|
|
|
selectedPath,
|
|
|
|
|
touchedAt: Date.now(),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
|
2026-02-01 21:43:49 +02:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-26 12:47:01 +03:00
|
|
|
removeExpandedPathsByPrefix: (root, prefixPath) => {
|
|
|
|
|
const normalizedRoot = normalizePath((root || '').trim());
|
|
|
|
|
const normalizedPrefix = normalizePath((prefixPath || '').trim());
|
|
|
|
|
if (!normalizedRoot || !normalizedPrefix) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set((state) => {
|
|
|
|
|
const current = state.byRoot[normalizedRoot];
|
|
|
|
|
if (!current) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const comparablePrefix = toComparablePath(normalizedPrefix);
|
|
|
|
|
const comparablePrefixWithSlash = comparablePrefix.endsWith('/') ? comparablePrefix : `${comparablePrefix}/`;
|
|
|
|
|
const expandedPaths = current.expandedPaths.filter((candidate) => {
|
|
|
|
|
const comparablePath = toComparablePath(candidate);
|
|
|
|
|
return comparablePath !== comparablePrefix && !comparablePath.startsWith(comparablePrefixWithSlash);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (expandedPaths.length === current.expandedPaths.length) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const byRoot = {
|
|
|
|
|
...state.byRoot,
|
|
|
|
|
[normalizedRoot]: {
|
|
|
|
|
...current,
|
|
|
|
|
expandedPaths,
|
|
|
|
|
touchedAt: Date.now(),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
|
2026-06-26 12:47:01 +03:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-12 18:24:07 +03:00
|
|
|
setSelectedPath: (root, path, options) => {
|
2026-02-01 21:43:49 +02:00
|
|
|
const normalizedRoot = normalizePath((root || '').trim());
|
|
|
|
|
const normalizedPath = path ? normalizePath(path.trim()) : null;
|
2026-06-12 18:24:07 +03:00
|
|
|
if (!normalizedRoot || (normalizedPath && !options?.allowOutsideRoot && !isPathWithinRoot(normalizedPath, normalizedRoot))) {
|
2026-02-01 21:43:49 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set((state) => {
|
|
|
|
|
const prev = state.byRoot[normalizedRoot];
|
|
|
|
|
const current = touchRoot(prev);
|
|
|
|
|
const openPaths = normalizedPath && !current.openPaths.includes(normalizedPath)
|
|
|
|
|
? [...current.openPaths, normalizedPath]
|
|
|
|
|
: current.openPaths;
|
|
|
|
|
|
|
|
|
|
if (prev && prev.selectedPath === normalizedPath && openPaths === prev.openPaths) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const byRoot = {
|
|
|
|
|
...state.byRoot,
|
|
|
|
|
[normalizedRoot]: {
|
|
|
|
|
...current,
|
|
|
|
|
openPaths,
|
|
|
|
|
selectedPath: normalizedPath,
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-07-21 20:52:20 +03:00
|
|
|
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
|
2026-02-01 21:43:49 +02:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
ensureSelectedPath: (root) => {
|
|
|
|
|
const normalizedRoot = normalizePath((root || '').trim());
|
|
|
|
|
if (!normalizedRoot) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const current = get().byRoot[normalizedRoot];
|
|
|
|
|
if (!current || current.selectedPath) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const first = current.openPaths[0] ?? null;
|
|
|
|
|
if (!first) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get().setSelectedPath(normalizedRoot, first);
|
|
|
|
|
},
|
2026-02-08 22:24:25 -03:00
|
|
|
|
|
|
|
|
toggleExpandedPath: (root, path) => {
|
|
|
|
|
const normalizedRoot = normalizePath((root || '').trim());
|
|
|
|
|
const normalizedPath = normalizePath((path || '').trim());
|
2026-05-12 03:56:27 -04:00
|
|
|
if (!normalizedRoot || !normalizedPath || !isPathWithinRoot(normalizedPath, normalizedRoot)) {
|
2026-02-08 22:24:25 -03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set((state) => {
|
|
|
|
|
const prev = state.byRoot[normalizedRoot];
|
|
|
|
|
const current = touchRoot(prev);
|
|
|
|
|
const isExpanded = current.expandedPaths.includes(normalizedPath);
|
|
|
|
|
const nextExpandedPaths = isExpanded
|
|
|
|
|
? current.expandedPaths.filter((p) => p !== normalizedPath)
|
|
|
|
|
: [...current.expandedPaths, normalizedPath];
|
|
|
|
|
|
|
|
|
|
if (prev && prev.expandedPaths === nextExpandedPaths && prev.selectedPath === current.selectedPath && prev.openPaths === current.openPaths) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const byRoot = {
|
|
|
|
|
...state.byRoot,
|
|
|
|
|
[normalizedRoot]: {
|
|
|
|
|
...current,
|
|
|
|
|
expandedPaths: nextExpandedPaths,
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-07-21 20:52:20 +03:00
|
|
|
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
|
2026-02-08 22:24:25 -03:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-07-27 23:07:42 +03:00
|
|
|
collapseAllExpandedPaths: (root) => {
|
|
|
|
|
const normalizedRoot = normalizePath((root || '').trim());
|
|
|
|
|
if (!normalizedRoot) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set((state) => {
|
|
|
|
|
const prev = state.byRoot[normalizedRoot];
|
|
|
|
|
if (!prev || prev.expandedPaths.length === 0) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const byRoot = {
|
|
|
|
|
...state.byRoot,
|
|
|
|
|
[normalizedRoot]: {
|
|
|
|
|
...touchRoot(prev),
|
|
|
|
|
expandedPaths: [],
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-02-08 22:24:25 -03:00
|
|
|
expandPath: (root, path) => {
|
|
|
|
|
const normalizedRoot = normalizePath((root || '').trim());
|
|
|
|
|
const normalizedPath = normalizePath((path || '').trim());
|
2026-05-12 03:56:27 -04:00
|
|
|
if (!normalizedRoot || !normalizedPath || !isPathWithinRoot(normalizedPath, normalizedRoot)) {
|
2026-02-08 22:24:25 -03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set((state) => {
|
|
|
|
|
const prev = state.byRoot[normalizedRoot];
|
|
|
|
|
const current = touchRoot(prev);
|
|
|
|
|
const isExpanded = current.expandedPaths.includes(normalizedPath);
|
|
|
|
|
|
|
|
|
|
if (isExpanded && prev) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const byRoot = {
|
|
|
|
|
...state.byRoot,
|
|
|
|
|
[normalizedRoot]: {
|
|
|
|
|
...current,
|
|
|
|
|
expandedPaths: [...current.expandedPaths, normalizedPath],
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-07-21 20:52:20 +03:00
|
|
|
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
|
2026-02-08 22:24:25 -03:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
expandPaths: (root, paths) => {
|
|
|
|
|
const normalizedRoot = normalizePath((root || '').trim());
|
|
|
|
|
if (!normalizedRoot || !paths || paths.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 03:56:27 -04:00
|
|
|
const normalizedPaths = paths
|
|
|
|
|
.map((p) => normalizePath((p || '').trim()))
|
|
|
|
|
.filter((p) => p && isPathWithinRoot(p, normalizedRoot));
|
|
|
|
|
if (normalizedPaths.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-08 22:24:25 -03:00
|
|
|
|
|
|
|
|
set((state) => {
|
|
|
|
|
const prev = state.byRoot[normalizedRoot];
|
|
|
|
|
const current = touchRoot(prev);
|
|
|
|
|
const existingPaths = new Set(current.expandedPaths);
|
|
|
|
|
const newPaths = normalizedPaths.filter((p) => !existingPaths.has(p));
|
|
|
|
|
|
|
|
|
|
if (newPaths.length === 0) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const byRoot = {
|
|
|
|
|
...state.byRoot,
|
|
|
|
|
[normalizedRoot]: {
|
|
|
|
|
...current,
|
|
|
|
|
expandedPaths: [...current.expandedPaths, ...newPaths],
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-07-21 20:52:20 +03:00
|
|
|
return { byRoot: clampRoots(byRoot, MAX_ROOTS) };
|
2026-02-08 22:24:25 -03:00
|
|
|
});
|
|
|
|
|
},
|
2026-02-01 21:43:49 +02:00
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
name: 'files-view-tabs-store',
|
2026-07-21 20:52:20 +03:00
|
|
|
version: 3,
|
2026-06-30 04:47:52 -04:00
|
|
|
storage: createDeferredSafeJSONStorage(),
|
2026-07-21 20:52:20 +03:00
|
|
|
migrate: (persistedState, version) => {
|
|
|
|
|
if (version < 3 || !persistedState || typeof persistedState !== 'object') {
|
|
|
|
|
return { byRoot: {}, activeRuntimeKey: getRuntimeKey(), runtimeSnapshots: {} };
|
2026-02-09 11:50:21 +02:00
|
|
|
}
|
2026-07-21 20:52:20 +03:00
|
|
|
return persistedState;
|
|
|
|
|
},
|
|
|
|
|
partialize: (state) => {
|
|
|
|
|
const currentSnapshots = {
|
|
|
|
|
...state.runtimeSnapshots,
|
|
|
|
|
[state.activeRuntimeKey]: { byRoot: sanitizeByRoot(state.byRoot), updatedAt: Date.now() },
|
|
|
|
|
};
|
|
|
|
|
const runtimeSnapshots = Object.fromEntries(Object.entries(currentSnapshots)
|
|
|
|
|
.sort(([, left], [, right]) => right.updatedAt - left.updatedAt)
|
|
|
|
|
.slice(0, MAX_RUNTIME_SNAPSHOTS)
|
|
|
|
|
.map(([runtimeKey, snapshot]) => [runtimeKey, {
|
|
|
|
|
byRoot: sanitizeByRoot(snapshot.byRoot),
|
|
|
|
|
updatedAt: snapshot.updatedAt,
|
|
|
|
|
}]));
|
|
|
|
|
return { activeRuntimeKey: state.activeRuntimeKey, runtimeSnapshots };
|
|
|
|
|
},
|
|
|
|
|
merge: (persistedState, currentState) => {
|
|
|
|
|
const persisted = persistedState && typeof persistedState === 'object'
|
|
|
|
|
? persistedState as Partial<FilesViewTabsState>
|
|
|
|
|
: {};
|
|
|
|
|
const runtimeSnapshots = persisted.runtimeSnapshots && typeof persisted.runtimeSnapshots === 'object'
|
|
|
|
|
? persisted.runtimeSnapshots
|
|
|
|
|
: {};
|
|
|
|
|
const activeRuntimeKey = getRuntimeKey();
|
2026-02-09 11:50:21 +02:00
|
|
|
return {
|
2026-07-21 20:52:20 +03:00
|
|
|
...currentState,
|
|
|
|
|
activeRuntimeKey,
|
|
|
|
|
runtimeSnapshots,
|
|
|
|
|
byRoot: sanitizeByRoot(runtimeSnapshots[activeRuntimeKey]?.byRoot),
|
2026-02-09 11:50:21 +02:00
|
|
|
};
|
|
|
|
|
},
|
2026-02-01 21:43:49 +02:00
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
{ name: 'files-view-tabs-store' }
|
|
|
|
|
)
|
|
|
|
|
);
|