Improve OpenChamber responsiveness under large session workloads while fixing cache, synchronization, and persistence correctness across runtimes, projects, directories, and worktrees. - prioritize selected and visible sessions during bootstrap and defer non-critical enrichment work - reduce redundant message loading, event processing, store publication, and hidden sidebar work - prevent stale session and message requests from overwriting newer authoritative state - preserve existing data when authoritative fetches fail instead of treating failures as successful empty responses - scope session materialization, messages, drafts, queues, todos, pins, permissions, folders, tabs, Git state, and pull request data by runtime and directory identity - harden runtime switching, reconnect, cleanup, mutation reconciliation, and persisted-state ordering - preserve live subagent Task linkage when metadata arrives after an older message request or while streaming parts are suspended - coalesce overlapping tail refreshes without losing newer refresh demand - improve cold-session loading by moving deferrable work out of the critical bootstrap path - isolate URL authentication, mobile credentials, native secrets, and other runtime-owned state across endpoint changes - bound long-lived caches and remove avoidable allocations from event and rendering hot paths - limit virtualization to archive collections where it improves rendering without disrupting active sidebar layout - stabilize session folders, pin ordering, expanded state, and persisted sidebar behavior - open skill files through the same secure editor and outside-workspace grant flow used by file navigation, including worktree sessions - expand regression coverage for stale completions, runtime collisions, reconnect behavior, persistence races, authoritative empty results, and subagent refresh ordering - document the updated synchronization, cache ownership, performance, and runtime-isolation invariants
691 lines
25 KiB
TypeScript
691 lines
25 KiB
TypeScript
import { create } from 'zustand';
|
|
import { devtools } from 'zustand/middleware';
|
|
import { getDeferredSafeStorage, getSafeStorage } from './utils/safeStorage';
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
|
|
|
// --- Types ---
|
|
|
|
export interface SessionFolder {
|
|
id: string;
|
|
name: string;
|
|
sessionIds: string[];
|
|
createdAt: number;
|
|
/** If set, this folder is a sub-folder of the parent folder with this id */
|
|
parentId?: string | null;
|
|
}
|
|
|
|
export type SessionFoldersMap = Record<string, SessionFolder[]>;
|
|
|
|
interface SessionFoldersState {
|
|
foldersMap: SessionFoldersMap;
|
|
collapsedFolderIds: Set<string>;
|
|
}
|
|
|
|
interface SessionFoldersActions {
|
|
getFoldersForScope: (scopeKey: string) => SessionFolder[];
|
|
createFolder: (scopeKey: string, name: string, parentId?: string | null) => SessionFolder;
|
|
renameFolder: (scopeKey: string, folderId: string, name: string) => void;
|
|
deleteFolder: (scopeKey: string, folderId: string) => void;
|
|
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
|
|
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void;
|
|
removeSessionFromFolder: (scopeKey: string, sessionId: string) => void;
|
|
removeSessionEverywhere: (runtimeKey: string, sessionId: string) => void;
|
|
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
|
|
toggleFolderCollapse: (folderId: string) => void;
|
|
getSessionFolderId: (scopeKey: string, sessionId: string) => string | null;
|
|
resetForRuntimeSwitch: (runtimeKey: string) => void;
|
|
}
|
|
|
|
type SessionFoldersStore = SessionFoldersState & SessionFoldersActions;
|
|
|
|
// --- Storage ---
|
|
|
|
const FOLDERS_STORAGE_KEY = 'oc.sessions.folders';
|
|
const COLLAPSED_STORAGE_KEY = 'oc.sessions.folderCollapse';
|
|
const STORAGE_INDEX_KEY = 'oc.sessions.folders.v2.index';
|
|
const SESSION_FOLDERS_API_PATH = '/api/session-folders';
|
|
const DISK_WRITE_DEBOUNCE_MS = 250;
|
|
|
|
const safeStorage = getDeferredSafeStorage();
|
|
const immediateSafeStorage = getSafeStorage();
|
|
let diskWriteTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let diskHydrated = false;
|
|
let diskHydrationInFlight = false;
|
|
let persistFoldersTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let persistCollapsedTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let pendingFoldersMap: SessionFoldersMap | null = null;
|
|
let pendingCollapsedIds: Set<string> | null = null;
|
|
let pendingBrowserRuntimeKey: string | null = null;
|
|
let activeFolderRuntimeKey = getRuntimeKey();
|
|
let folderRuntimeGeneration = 0;
|
|
let folderMutationRevision = 0;
|
|
const lastDiskUpdatedAtByRuntime = new Map<string, number>();
|
|
|
|
type FolderStorageIndex = {
|
|
version: 2;
|
|
legacyClaimed: boolean;
|
|
runtimes: Array<{ runtimeKey: string; updatedAt: number }>;
|
|
};
|
|
|
|
const runtimeStorageKey = (base: string, runtimeKey: string) => `${base}.v2:${encodeURIComponent(runtimeKey)}`;
|
|
const readStorageIndex = (): FolderStorageIndex => {
|
|
try {
|
|
const parsed = JSON.parse(safeStorage.getItem(STORAGE_INDEX_KEY) ?? '') as Partial<FolderStorageIndex>;
|
|
return parsed.version === 2 && Array.isArray(parsed.runtimes)
|
|
? { version: 2, legacyClaimed: Boolean(parsed.legacyClaimed), runtimes: parsed.runtimes }
|
|
: { version: 2, legacyClaimed: false, runtimes: [] };
|
|
} catch {
|
|
return { version: 2, legacyClaimed: false, runtimes: [] };
|
|
}
|
|
};
|
|
|
|
const touchRuntimeStorage = (runtimeKey: string, updatedAt = Date.now(), targetStorage: Storage = safeStorage): void => {
|
|
const index = readStorageIndex();
|
|
const runtimes = [
|
|
{ runtimeKey, updatedAt },
|
|
...index.runtimes.filter((entry) => entry.runtimeKey !== runtimeKey),
|
|
];
|
|
targetStorage.setItem(STORAGE_INDEX_KEY, JSON.stringify({ version: 2, legacyClaimed: index.legacyClaimed, runtimes }));
|
|
};
|
|
|
|
const claimLegacyStorage = (runtimeKey: string): void => {
|
|
const index = readStorageIndex();
|
|
if (index.legacyClaimed) return;
|
|
const legacyFolders = safeStorage.getItem(FOLDERS_STORAGE_KEY);
|
|
const legacyCollapsed = safeStorage.getItem(COLLAPSED_STORAGE_KEY);
|
|
if (legacyFolders) safeStorage.setItem(runtimeStorageKey(FOLDERS_STORAGE_KEY, runtimeKey), legacyFolders);
|
|
if (legacyCollapsed) safeStorage.setItem(runtimeStorageKey(COLLAPSED_STORAGE_KEY, runtimeKey), legacyCollapsed);
|
|
const next = { ...index, legacyClaimed: true };
|
|
safeStorage.setItem(STORAGE_INDEX_KEY, JSON.stringify(next));
|
|
if (safeStorage.getItem(STORAGE_INDEX_KEY) === JSON.stringify(next)) {
|
|
safeStorage.removeItem(FOLDERS_STORAGE_KEY);
|
|
safeStorage.removeItem(COLLAPSED_STORAGE_KEY);
|
|
}
|
|
touchRuntimeStorage(runtimeKey, 0);
|
|
};
|
|
|
|
const isVSCodeWebview = (): boolean => {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
if (isVSCodeRuntime()) {
|
|
return true;
|
|
}
|
|
|
|
return (window as { __VSCODE_CONFIG__?: unknown }).__VSCODE_CONFIG__ !== undefined;
|
|
};
|
|
|
|
const schedulePersistToDisk = (foldersMap: SessionFoldersMap, collapsedFolderIds: Set<string>): void => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
if (isVSCodeWebview()) {
|
|
return;
|
|
}
|
|
|
|
if (diskWriteTimer) {
|
|
clearTimeout(diskWriteTimer);
|
|
}
|
|
|
|
const foldersSnapshot = JSON.parse(JSON.stringify(foldersMap)) as SessionFoldersMap;
|
|
const collapsedSnapshot = Array.from(collapsedFolderIds);
|
|
const runtimeKey = activeFolderRuntimeKey;
|
|
const generation = folderRuntimeGeneration;
|
|
|
|
diskWriteTimer = setTimeout(() => {
|
|
diskWriteTimer = null;
|
|
if (runtimeKey !== getRuntimeKey() || generation !== folderRuntimeGeneration) return;
|
|
const updatedAt = Math.max(Date.now(), (lastDiskUpdatedAtByRuntime.get(runtimeKey) ?? 0) + 1);
|
|
lastDiskUpdatedAtByRuntime.set(runtimeKey, updatedAt);
|
|
const payload = {
|
|
version: 1,
|
|
foldersMap: foldersSnapshot,
|
|
collapsedFolderIds: collapsedSnapshot,
|
|
updatedAt,
|
|
};
|
|
void runtimeFetch(SESSION_FOLDERS_API_PATH, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
}).catch(() => { /* best-effort */ });
|
|
}, DISK_WRITE_DEBOUNCE_MS);
|
|
};
|
|
|
|
const readPersistedFolders = (runtimeKey = activeFolderRuntimeKey): SessionFoldersMap => {
|
|
try {
|
|
claimLegacyStorage(runtimeKey);
|
|
const raw = safeStorage.getItem(runtimeStorageKey(FOLDERS_STORAGE_KEY, runtimeKey));
|
|
if (!raw) {
|
|
return {};
|
|
}
|
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return {};
|
|
}
|
|
const result: SessionFoldersMap = {};
|
|
for (const [scopeKey, value] of Object.entries(parsed)) {
|
|
if (!Array.isArray(value)) {
|
|
continue;
|
|
}
|
|
const folders: SessionFolder[] = [];
|
|
for (const entry of value) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
const candidate = entry as Record<string, unknown>;
|
|
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
|
const name = typeof candidate.name === 'string' ? candidate.name.trim() : '';
|
|
const createdAt = typeof candidate.createdAt === 'number' ? candidate.createdAt : 0;
|
|
if (!id || !name) continue;
|
|
const sessionIds = Array.isArray(candidate.sessionIds)
|
|
? (candidate.sessionIds as unknown[]).filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
|
|
: [];
|
|
const parentId = typeof candidate.parentId === 'string' ? candidate.parentId : null;
|
|
folders.push({ id, name, sessionIds, createdAt, parentId });
|
|
}
|
|
if (folders.length > 0) {
|
|
result[scopeKey] = folders;
|
|
}
|
|
}
|
|
return result;
|
|
} catch {
|
|
return {};
|
|
}
|
|
};
|
|
|
|
const readPersistedCollapsed = (runtimeKey = activeFolderRuntimeKey): Set<string> => {
|
|
try {
|
|
claimLegacyStorage(runtimeKey);
|
|
const raw = safeStorage.getItem(runtimeStorageKey(COLLAPSED_STORAGE_KEY, runtimeKey));
|
|
if (!raw) {
|
|
return new Set();
|
|
}
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (!Array.isArray(parsed)) {
|
|
return new Set();
|
|
}
|
|
return new Set(parsed.filter((v): v is string => typeof v === 'string'));
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
};
|
|
|
|
const persistFolders = (foldersMap: SessionFoldersMap): void => {
|
|
pendingFoldersMap = foldersMap;
|
|
pendingBrowserRuntimeKey = activeFolderRuntimeKey;
|
|
clearTimeout(persistFoldersTimer);
|
|
persistFoldersTimer = setTimeout(() => {
|
|
try {
|
|
const runtimeKey = pendingBrowserRuntimeKey ?? activeFolderRuntimeKey;
|
|
safeStorage.setItem(runtimeStorageKey(FOLDERS_STORAGE_KEY, runtimeKey), JSON.stringify(foldersMap));
|
|
touchRuntimeStorage(runtimeKey);
|
|
pendingFoldersMap = null;
|
|
} catch {
|
|
// ignored
|
|
}
|
|
}, 300);
|
|
};
|
|
|
|
const persistCollapsed = (collapsedFolderIds: Set<string>): void => {
|
|
pendingCollapsedIds = collapsedFolderIds;
|
|
pendingBrowserRuntimeKey = activeFolderRuntimeKey;
|
|
clearTimeout(persistCollapsedTimer);
|
|
persistCollapsedTimer = setTimeout(() => {
|
|
try {
|
|
const runtimeKey = pendingBrowserRuntimeKey ?? activeFolderRuntimeKey;
|
|
safeStorage.setItem(runtimeStorageKey(COLLAPSED_STORAGE_KEY, runtimeKey), JSON.stringify(Array.from(collapsedFolderIds)));
|
|
touchRuntimeStorage(runtimeKey);
|
|
pendingCollapsedIds = null;
|
|
} catch {
|
|
// ignored
|
|
}
|
|
}, 300);
|
|
};
|
|
|
|
const flushPendingBrowserPersistence = (): void => {
|
|
if (persistFoldersTimer) clearTimeout(persistFoldersTimer);
|
|
if (persistCollapsedTimer) clearTimeout(persistCollapsedTimer);
|
|
persistFoldersTimer = undefined;
|
|
persistCollapsedTimer = undefined;
|
|
|
|
const runtimeKey = pendingBrowserRuntimeKey ?? activeFolderRuntimeKey;
|
|
let wrote = false;
|
|
if (pendingFoldersMap !== null) {
|
|
const key = runtimeStorageKey(FOLDERS_STORAGE_KEY, runtimeKey);
|
|
const value = JSON.stringify(pendingFoldersMap);
|
|
safeStorage.setItem(key, value);
|
|
immediateSafeStorage.setItem(key, value);
|
|
pendingFoldersMap = null;
|
|
wrote = true;
|
|
}
|
|
if (pendingCollapsedIds !== null) {
|
|
const key = runtimeStorageKey(COLLAPSED_STORAGE_KEY, runtimeKey);
|
|
const value = JSON.stringify(Array.from(pendingCollapsedIds));
|
|
safeStorage.setItem(key, value);
|
|
immediateSafeStorage.setItem(key, value);
|
|
pendingCollapsedIds = null;
|
|
wrote = true;
|
|
}
|
|
if (wrote) {
|
|
const updatedAt = Date.now();
|
|
touchRuntimeStorage(runtimeKey, updatedAt);
|
|
touchRuntimeStorage(runtimeKey, updatedAt, immediateSafeStorage);
|
|
}
|
|
pendingBrowserRuntimeKey = null;
|
|
};
|
|
|
|
if (typeof window !== 'undefined') {
|
|
const flushPending = () => {
|
|
try { flushPendingBrowserPersistence(); } catch { /* ignored */ }
|
|
};
|
|
window.addEventListener('pagehide', flushPending, { capture: true });
|
|
window.addEventListener('beforeunload', flushPending, { capture: true });
|
|
if (typeof document !== 'undefined') {
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'hidden') flushPending();
|
|
});
|
|
document.addEventListener('freeze', flushPending);
|
|
}
|
|
}
|
|
|
|
const persistState = (foldersMap: SessionFoldersMap, collapsedFolderIds: Set<string>): void => {
|
|
folderMutationRevision += 1;
|
|
persistFolders(foldersMap);
|
|
persistCollapsed(collapsedFolderIds);
|
|
schedulePersistToDisk(foldersMap, collapsedFolderIds);
|
|
};
|
|
|
|
const createFolderId = (): string => {
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
return crypto.randomUUID();
|
|
}
|
|
return `folder_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
};
|
|
|
|
const syncCollapsedAfterFolderCleanup = (
|
|
prevFolders: SessionFolder[],
|
|
nextFolders: SessionFolder[],
|
|
collapsedFolderIds: Set<string>,
|
|
): Set<string> | null => {
|
|
const nextFolderIds = new Set(nextFolders.map((folder) => folder.id));
|
|
let nextCollapsed: Set<string> | null = null;
|
|
|
|
for (const folder of prevFolders) {
|
|
if (!nextFolderIds.has(folder.id) && collapsedFolderIds.has(folder.id)) {
|
|
if (!nextCollapsed) {
|
|
nextCollapsed = new Set(collapsedFolderIds);
|
|
}
|
|
nextCollapsed.delete(folder.id);
|
|
}
|
|
}
|
|
|
|
return nextCollapsed;
|
|
};
|
|
|
|
// --- Store ---
|
|
|
|
export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
|
devtools(
|
|
(set, get) => ({
|
|
foldersMap: readPersistedFolders(),
|
|
collapsedFolderIds: readPersistedCollapsed(),
|
|
|
|
resetForRuntimeSwitch: (runtimeKey: string): void => {
|
|
try { flushPendingBrowserPersistence(); } catch { /* deferred storage retains failed writes */ }
|
|
activeFolderRuntimeKey = runtimeKey;
|
|
folderRuntimeGeneration += 1;
|
|
folderMutationRevision = 0;
|
|
diskHydrated = false;
|
|
diskHydrationInFlight = false;
|
|
if (diskWriteTimer) clearTimeout(diskWriteTimer);
|
|
diskWriteTimer = null;
|
|
set({
|
|
foldersMap: readPersistedFolders(runtimeKey),
|
|
collapsedFolderIds: readPersistedCollapsed(runtimeKey),
|
|
});
|
|
queueMicrotask(() => void hydrateSessionFoldersFromDisk());
|
|
},
|
|
|
|
getFoldersForScope: (scopeKey: string): SessionFolder[] => {
|
|
if (!scopeKey) return [];
|
|
return get().foldersMap[scopeKey] ?? [];
|
|
},
|
|
|
|
createFolder: (scopeKey: string, name: string, parentId?: string | null): SessionFolder => {
|
|
const trimmed = name.trim() || 'New folder';
|
|
const folder: SessionFolder = {
|
|
id: createFolderId(),
|
|
name: trimmed,
|
|
sessionIds: [],
|
|
createdAt: Date.now(),
|
|
parentId: parentId ?? null,
|
|
};
|
|
const current = get().foldersMap;
|
|
const scopeFolders = current[scopeKey] ?? [];
|
|
const nextMap: SessionFoldersMap = {
|
|
...current,
|
|
[scopeKey]: [...scopeFolders, folder],
|
|
};
|
|
set({ foldersMap: nextMap });
|
|
persistState(nextMap, get().collapsedFolderIds);
|
|
return folder;
|
|
},
|
|
|
|
renameFolder: (scopeKey: string, folderId: string, name: string): void => {
|
|
const trimmed = name.trim();
|
|
if (!trimmed || !scopeKey) return;
|
|
const current = get().foldersMap;
|
|
const scopeFolders = current[scopeKey];
|
|
if (!scopeFolders) return;
|
|
const nextFolders = scopeFolders.map((folder) =>
|
|
folder.id === folderId ? { ...folder, name: trimmed } : folder,
|
|
);
|
|
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders };
|
|
set({ foldersMap: nextMap });
|
|
persistState(nextMap, get().collapsedFolderIds);
|
|
},
|
|
|
|
deleteFolder: (scopeKey: string, folderId: string): void => {
|
|
if (!scopeKey) return;
|
|
const current = get().foldersMap;
|
|
const scopeFolders = current[scopeKey];
|
|
if (!scopeFolders) return;
|
|
// Also delete all sub-folders of this folder
|
|
const idsToDelete = new Set<string>([folderId]);
|
|
let changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (const f of scopeFolders) {
|
|
if (f.parentId && idsToDelete.has(f.parentId) && !idsToDelete.has(f.id)) {
|
|
idsToDelete.add(f.id);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
const nextFolders = scopeFolders.filter((folder) => !idsToDelete.has(folder.id));
|
|
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders };
|
|
const collapsed = get().collapsedFolderIds;
|
|
const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, nextFolders, collapsed);
|
|
set(nextCollapsed
|
|
? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed }
|
|
: { foldersMap: nextMap });
|
|
persistState(nextMap, nextCollapsed ?? collapsed);
|
|
},
|
|
|
|
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string): void => {
|
|
if (!scopeKey || !folderId || !sessionId) return;
|
|
const current = get().foldersMap;
|
|
const scopeFolders = current[scopeKey];
|
|
if (!scopeFolders) return;
|
|
|
|
const targetFolder = scopeFolders.find((folder) => folder.id === folderId);
|
|
if (!targetFolder) return;
|
|
|
|
const sessionFolderCount = scopeFolders.reduce(
|
|
(count, folder) => count + (folder.sessionIds.includes(sessionId) ? 1 : 0),
|
|
0,
|
|
);
|
|
if (targetFolder.sessionIds.includes(sessionId) && sessionFolderCount === 1) {
|
|
return;
|
|
}
|
|
|
|
// Remove session from any existing folder first, then add to target
|
|
const nextFolders = scopeFolders.map((folder) => {
|
|
const withoutSession = folder.sessionIds.filter((id) => id !== sessionId);
|
|
if (folder.id === folderId) {
|
|
return { ...folder, sessionIds: [...withoutSession, sessionId] };
|
|
}
|
|
if (withoutSession.length !== folder.sessionIds.length) {
|
|
return { ...folder, sessionIds: withoutSession };
|
|
}
|
|
return folder;
|
|
});
|
|
|
|
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders };
|
|
const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, nextFolders, get().collapsedFolderIds);
|
|
|
|
set(nextCollapsed
|
|
? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed }
|
|
: { foldersMap: nextMap });
|
|
persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds);
|
|
},
|
|
|
|
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]): void => {
|
|
if (!scopeKey || !folderId || sessionIds.length === 0) return;
|
|
const current = get().foldersMap;
|
|
const scopeFolders = current[scopeKey];
|
|
if (!scopeFolders) return;
|
|
|
|
const idSet = new Set(sessionIds.filter((id) => typeof id === 'string' && id.length > 0));
|
|
if (idSet.size === 0) return;
|
|
|
|
const targetFolder = scopeFolders.find((folder) => folder.id === folderId);
|
|
if (!targetFolder) return;
|
|
|
|
let changed = false;
|
|
for (const folder of scopeFolders) {
|
|
for (const id of idSet) {
|
|
if (!folder.sessionIds.includes(id)) continue;
|
|
if (folder.id !== folderId || !targetFolder.sessionIds.includes(id)) {
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
if (changed) break;
|
|
}
|
|
if (!changed) {
|
|
for (const id of idSet) {
|
|
if (!targetFolder.sessionIds.includes(id)) {
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!changed) return;
|
|
|
|
const nextFolders = scopeFolders.map((folder) => {
|
|
const withoutSessions = folder.sessionIds.filter((id) => !idSet.has(id));
|
|
if (folder.id === folderId) {
|
|
return { ...folder, sessionIds: [...withoutSessions, ...idSet] };
|
|
}
|
|
if (withoutSessions.length !== folder.sessionIds.length) {
|
|
return { ...folder, sessionIds: withoutSessions };
|
|
}
|
|
return folder;
|
|
});
|
|
|
|
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders };
|
|
const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, nextFolders, get().collapsedFolderIds);
|
|
|
|
set(nextCollapsed
|
|
? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed }
|
|
: { foldersMap: nextMap });
|
|
persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds);
|
|
},
|
|
|
|
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]): void => {
|
|
if (!scopeKey || sessionIds.length === 0) return;
|
|
const current = get().foldersMap;
|
|
const scopeFolders = current[scopeKey];
|
|
if (!scopeFolders) return;
|
|
|
|
const idSet = new Set(sessionIds.filter((id) => typeof id === 'string' && id.length > 0));
|
|
if (idSet.size === 0) return;
|
|
|
|
let changed = false;
|
|
const nextFolders = scopeFolders.map((folder) => {
|
|
const filtered = folder.sessionIds.filter((id) => !idSet.has(id));
|
|
if (filtered.length !== folder.sessionIds.length) {
|
|
changed = true;
|
|
return { ...folder, sessionIds: filtered };
|
|
}
|
|
return folder;
|
|
});
|
|
|
|
if (!changed) return;
|
|
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders };
|
|
const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, nextFolders, get().collapsedFolderIds);
|
|
|
|
set(nextCollapsed
|
|
? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed }
|
|
: { foldersMap: nextMap });
|
|
persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds);
|
|
},
|
|
|
|
removeSessionFromFolder: (scopeKey: string, sessionId: string): void => {
|
|
if (!scopeKey || !sessionId) return;
|
|
const current = get().foldersMap;
|
|
const scopeFolders = current[scopeKey];
|
|
if (!scopeFolders) return;
|
|
|
|
let changed = false;
|
|
const nextFolders = scopeFolders.map((folder) => {
|
|
const filtered = folder.sessionIds.filter((id) => id !== sessionId);
|
|
if (filtered.length !== folder.sessionIds.length) {
|
|
changed = true;
|
|
return { ...folder, sessionIds: filtered };
|
|
}
|
|
return folder;
|
|
});
|
|
|
|
if (!changed) return;
|
|
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders };
|
|
const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, nextFolders, get().collapsedFolderIds);
|
|
|
|
set(nextCollapsed
|
|
? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed }
|
|
: { foldersMap: nextMap });
|
|
persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds);
|
|
},
|
|
|
|
removeSessionEverywhere: (runtimeKey: string, sessionId: string): void => {
|
|
if (!runtimeKey || runtimeKey !== activeFolderRuntimeKey || runtimeKey !== getRuntimeKey() || !sessionId) return;
|
|
const current = get().foldersMap;
|
|
let nextMap: SessionFoldersMap | null = null;
|
|
|
|
for (const [scopeKey, scopeFolders] of Object.entries(current)) {
|
|
let scopeChanged = false;
|
|
const nextFolders = scopeFolders.map((folder) => {
|
|
const sessionIds = folder.sessionIds.filter((id) => id !== sessionId);
|
|
if (sessionIds.length === folder.sessionIds.length) return folder;
|
|
scopeChanged = true;
|
|
return { ...folder, sessionIds };
|
|
});
|
|
if (!scopeChanged) continue;
|
|
nextMap ??= { ...current };
|
|
nextMap[scopeKey] = nextFolders;
|
|
}
|
|
|
|
if (!nextMap) return;
|
|
set({ foldersMap: nextMap });
|
|
persistState(nextMap, get().collapsedFolderIds);
|
|
},
|
|
|
|
toggleFolderCollapse: (folderId: string): void => {
|
|
const collapsed = get().collapsedFolderIds;
|
|
const next = new Set(collapsed);
|
|
if (next.has(folderId)) {
|
|
next.delete(folderId);
|
|
} else {
|
|
next.add(folderId);
|
|
}
|
|
set({ collapsedFolderIds: next });
|
|
persistState(get().foldersMap, next);
|
|
},
|
|
|
|
getSessionFolderId: (scopeKey: string, sessionId: string): string | null => {
|
|
if (!scopeKey || !sessionId) return null;
|
|
const scopeFolders = get().foldersMap[scopeKey];
|
|
if (!scopeFolders) return null;
|
|
for (const folder of scopeFolders) {
|
|
if (folder.sessionIds.includes(sessionId)) {
|
|
return folder.id;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
}),
|
|
{ name: 'session-folders-store' },
|
|
),
|
|
);
|
|
|
|
const hydrateSessionFoldersFromDisk = async (): Promise<void> => {
|
|
if (diskHydrated || diskHydrationInFlight || typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
if (isVSCodeWebview()) {
|
|
diskHydrated = true;
|
|
return;
|
|
}
|
|
|
|
diskHydrationInFlight = true;
|
|
const runtimeKey = activeFolderRuntimeKey;
|
|
const generation = folderRuntimeGeneration;
|
|
const baselineMutationRevision = folderMutationRevision;
|
|
let completed = false;
|
|
|
|
try {
|
|
const response = await runtimeFetch(SESSION_FOLDERS_API_PATH).catch(() => null);
|
|
if (!response || !response.ok) {
|
|
return;
|
|
}
|
|
|
|
const parsed = await response.json().catch(() => null) as {
|
|
exists?: boolean;
|
|
foldersMap?: SessionFoldersMap;
|
|
collapsedFolderIds?: string[];
|
|
updatedAt?: number;
|
|
} | null;
|
|
|
|
if (!parsed) {
|
|
return;
|
|
}
|
|
|
|
if (parsed.exists === false) {
|
|
completed = true;
|
|
return;
|
|
}
|
|
|
|
const diskFolders = parsed.foldersMap && typeof parsed.foldersMap === 'object'
|
|
? parsed.foldersMap
|
|
: {};
|
|
const diskCollapsed = Array.isArray(parsed.collapsedFolderIds)
|
|
? new Set(parsed.collapsedFolderIds.filter((value): value is string => typeof value === 'string'))
|
|
: new Set<string>();
|
|
|
|
if (generation !== folderRuntimeGeneration || runtimeKey !== getRuntimeKey()) return;
|
|
const browserUpdatedAt = readStorageIndex().runtimes.find((entry) => entry.runtimeKey === runtimeKey)?.updatedAt ?? 0;
|
|
const diskUpdatedAt = typeof parsed.updatedAt === 'number' && Number.isFinite(parsed.updatedAt) ? parsed.updatedAt : 0;
|
|
if (diskUpdatedAt > 0) {
|
|
lastDiskUpdatedAtByRuntime.set(runtimeKey, Math.max(lastDiskUpdatedAtByRuntime.get(runtimeKey) ?? 0, diskUpdatedAt));
|
|
}
|
|
const hasDiskAuthority = parsed.exists === true || diskUpdatedAt > 0;
|
|
if (hasDiskAuthority && folderMutationRevision === baselineMutationRevision && diskUpdatedAt >= browserUpdatedAt) {
|
|
useSessionFoldersStore.setState({ foldersMap: diskFolders, collapsedFolderIds: diskCollapsed });
|
|
persistFolders(diskFolders);
|
|
persistCollapsed(diskCollapsed);
|
|
}
|
|
completed = true;
|
|
} catch {
|
|
// ignored
|
|
} finally {
|
|
if (generation === folderRuntimeGeneration && runtimeKey === getRuntimeKey()) {
|
|
diskHydrationInFlight = false;
|
|
if (completed) diskHydrated = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
const bootstrapSessionFoldersDiskHydration = (): void => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
void hydrateSessionFoldersFromDisk();
|
|
};
|
|
|
|
bootstrapSessionFoldersDiskHydration();
|