Files
openchamber/packages/ui/src/stores/useSessionFoldersStore.ts
T

337 lines
12 KiB
TypeScript
Raw Normal View History

import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { getSafeStorage } from './utils/safeStorage';
// --- 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;
}
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;
removeSessionFromFolder: (scopeKey: string, sessionId: string) => void;
toggleFolderCollapse: (folderId: string) => void;
cleanupSessions: (scopeKey: string, existingSessionIds: Set<string>) => void;
getSessionFolderId: (scopeKey: string, sessionId: string) => string | null;
}
type SessionFoldersStore = SessionFoldersState & SessionFoldersActions;
// --- Storage ---
const FOLDERS_STORAGE_KEY = 'oc.sessions.folders';
const COLLAPSED_STORAGE_KEY = 'oc.sessions.folderCollapse';
const safeStorage = getSafeStorage();
const readPersistedFolders = (): SessionFoldersMap => {
try {
const raw = safeStorage.getItem(FOLDERS_STORAGE_KEY);
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 = (): Set<string> => {
try {
const raw = safeStorage.getItem(COLLAPSED_STORAGE_KEY);
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 => {
try {
safeStorage.setItem(FOLDERS_STORAGE_KEY, JSON.stringify(foldersMap));
} catch {
// ignored
}
};
const persistCollapsed = (collapsedFolderIds: Set<string>): void => {
try {
safeStorage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(Array.from(collapsedFolderIds)));
} catch {
// ignored
}
};
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(),
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 });
persistFolders(nextMap);
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 });
persistFolders(nextMap);
},
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 };
set({ foldersMap: nextMap });
persistFolders(nextMap);
// Clean up collapsed state for all deleted folders
const collapsed = get().collapsedFolderIds;
const hasStale = Array.from(idsToDelete).some((id) => collapsed.has(id));
if (hasStale) {
const nextCollapsed = new Set(collapsed);
idsToDelete.forEach((id) => nextCollapsed.delete(id));
set({ collapsedFolderIds: nextCollapsed });
persistCollapsed(nextCollapsed);
}
},
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string): void => {
if (!scopeKey || !folderId || !sessionId) return;
const current = get().foldersMap;
const scopeFolders = current[scopeKey];
if (!scopeFolders) 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 });
persistFolders(nextMap);
if (nextCollapsed) {
persistCollapsed(nextCollapsed);
}
},
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 });
persistFolders(nextMap);
if (nextCollapsed) {
persistCollapsed(nextCollapsed);
}
},
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 });
persistCollapsed(next);
},
cleanupSessions: (scopeKey: string, existingSessionIds: Set<string>): void => {
if (!scopeKey) return;
const current = get().foldersMap;
const scopeFolders = current[scopeKey];
if (!scopeFolders || scopeFolders.length === 0) return;
let changed = false;
const nextFolders = scopeFolders.map((folder) => {
const filtered = folder.sessionIds.filter((id) => existingSessionIds.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 });
persistFolders(nextMap);
if (nextCollapsed) {
persistCollapsed(nextCollapsed);
}
},
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' },
),
);