feat(sessions): add custom folder grouping for sessions (#460)
* feat(sessions): add custom folder grouping for sessions Allow users to create custom folders to organize chat sessions. Folders are stored in localStorage, scoped per project. Features: - Create/rename/delete folders from sidebar header - Move sessions to folders via context menu - Remove sessions from folders - Collapse/expand folder state persisted - Automatic cleanup when sessions are deleted Implementation: - useSessionFoldersStore: Zustand store with CRUD + localStorage persistence - SessionFolderItem: Component for folder header (name, count, actions) + body - SessionSidebar: Integration with context menu, folder rendering in session list * fix(sessions): remove sidebar header folder action * fix: remove folder when they becomes empty * refactor(sessions): switch folder scope to session directory and clean folder interactions * fix: move folder actions outside rename mode --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
62e5c3edfe
commit
6e63f23bb8
@@ -0,0 +1,322 @@
|
||||
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;
|
||||
}
|
||||
|
||||
type SessionFoldersMap = Record<string, SessionFolder[]>;
|
||||
|
||||
interface SessionFoldersState {
|
||||
foldersMap: SessionFoldersMap;
|
||||
collapsedFolderIds: Set<string>;
|
||||
}
|
||||
|
||||
interface SessionFoldersActions {
|
||||
getFoldersForScope: (scopeKey: string) => SessionFolder[];
|
||||
createFolder: (scopeKey: string, name: string) => 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)
|
||||
: [];
|
||||
folders.push({ id, name, sessionIds, createdAt });
|
||||
}
|
||||
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): SessionFolder => {
|
||||
const trimmed = name.trim() || 'New folder';
|
||||
const folder: SessionFolder = {
|
||||
id: createFolderId(),
|
||||
name: trimmed,
|
||||
sessionIds: [],
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
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;
|
||||
const nextFolders = scopeFolders.filter((folder) => folder.id !== folderId);
|
||||
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders };
|
||||
set({ foldersMap: nextMap });
|
||||
persistFolders(nextMap);
|
||||
|
||||
// Clean up collapsed state
|
||||
const collapsed = get().collapsedFolderIds;
|
||||
if (collapsed.has(folderId)) {
|
||||
const nextCollapsed = new Set(collapsed);
|
||||
nextCollapsed.delete(folderId);
|
||||
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 filteredFolders = nextFolders.filter((folder) => folder.sessionIds.length > 0);
|
||||
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: filteredFolders };
|
||||
const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, filteredFolders, 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 filteredFolders = nextFolders.filter((folder) => folder.sessionIds.length > 0);
|
||||
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: filteredFolders };
|
||||
const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, filteredFolders, 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 filteredFolders = nextFolders.filter((folder) => folder.sessionIds.length > 0);
|
||||
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: filteredFolders };
|
||||
const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, filteredFolders, 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' },
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user