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,263 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
RiFolderLine,
|
||||||
|
RiFolderOpenLine,
|
||||||
|
RiArrowRightSLine,
|
||||||
|
RiArrowDownSLine,
|
||||||
|
RiPencilAiLine,
|
||||||
|
RiDeleteBinLine,
|
||||||
|
RiCheckLine,
|
||||||
|
RiCloseLine,
|
||||||
|
} from '@remixicon/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||||
|
|
||||||
|
interface SessionFolderItemProps<TSessionNode> {
|
||||||
|
folder: SessionFolder;
|
||||||
|
sessions: TSessionNode[];
|
||||||
|
isCollapsed: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onRename: (name: string) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
renderSessionNode: (
|
||||||
|
node: TSessionNode,
|
||||||
|
depth?: number,
|
||||||
|
groupDir?: string | null,
|
||||||
|
projectId?: string | null,
|
||||||
|
) => React.ReactNode;
|
||||||
|
groupDirectory?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
mobileVariant?: boolean;
|
||||||
|
isRenaming?: boolean;
|
||||||
|
renameDraft?: string;
|
||||||
|
onRenameDraftChange?: (value: string) => void;
|
||||||
|
onRenameSave?: () => void;
|
||||||
|
onRenameCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionFolderItemBase = <TSessionNode,>({
|
||||||
|
folder,
|
||||||
|
sessions,
|
||||||
|
isCollapsed,
|
||||||
|
onToggle,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
renderSessionNode,
|
||||||
|
groupDirectory,
|
||||||
|
projectId,
|
||||||
|
mobileVariant = false,
|
||||||
|
isRenaming = false,
|
||||||
|
renameDraft = '',
|
||||||
|
onRenameDraftChange,
|
||||||
|
onRenameSave,
|
||||||
|
onRenameCancel,
|
||||||
|
}: SessionFolderItemProps<TSessionNode>) => {
|
||||||
|
const [localRenaming, setLocalRenaming] = React.useState(false);
|
||||||
|
const [localDraft, setLocalDraft] = React.useState('');
|
||||||
|
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const renaming = isRenaming || localRenaming;
|
||||||
|
const draft = isRenaming ? renameDraft : localDraft;
|
||||||
|
|
||||||
|
const handleStartRename = React.useCallback(() => {
|
||||||
|
setLocalDraft(folder.name);
|
||||||
|
setLocalRenaming(true);
|
||||||
|
}, [folder.name]);
|
||||||
|
|
||||||
|
const handleSaveRename = React.useCallback(() => {
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (trimmed && trimmed !== folder.name) {
|
||||||
|
onRename(trimmed);
|
||||||
|
}
|
||||||
|
if (isRenaming && onRenameSave) {
|
||||||
|
onRenameSave();
|
||||||
|
}
|
||||||
|
setLocalRenaming(false);
|
||||||
|
setLocalDraft('');
|
||||||
|
}, [draft, folder.name, isRenaming, onRename, onRenameSave]);
|
||||||
|
|
||||||
|
const handleCancelRename = React.useCallback(() => {
|
||||||
|
if (isRenaming && onRenameCancel) {
|
||||||
|
onRenameCancel();
|
||||||
|
}
|
||||||
|
setLocalRenaming(false);
|
||||||
|
setLocalDraft('');
|
||||||
|
}, [isRenaming, onRenameCancel]);
|
||||||
|
|
||||||
|
const handleDraftChange = React.useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (isRenaming && onRenameDraftChange) {
|
||||||
|
onRenameDraftChange(value);
|
||||||
|
} else {
|
||||||
|
setLocalDraft(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isRenaming, onRenameDraftChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-focus rename when externally triggered (e.g. from session menu)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isRenaming) return;
|
||||||
|
const focusInput = () => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (!input) return;
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
};
|
||||||
|
const frameId = requestAnimationFrame(focusInput);
|
||||||
|
const timeoutId = window.setTimeout(focusInput, 0);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frameId);
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [isRenaming]);
|
||||||
|
|
||||||
|
const FolderIcon = isCollapsed ? RiFolderLine : RiFolderOpenLine;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="oc-folder">
|
||||||
|
{/* Folder header */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group/folder flex items-center justify-between gap-1.5 py-1 min-w-0 rounded-sm',
|
||||||
|
'hover:bg-interactive-hover/50 cursor-pointer',
|
||||||
|
)}
|
||||||
|
onClick={renaming ? undefined : onToggle}
|
||||||
|
role={renaming ? undefined : 'button'}
|
||||||
|
tabIndex={renaming ? undefined : 0}
|
||||||
|
onKeyDown={
|
||||||
|
renaming
|
||||||
|
? undefined
|
||||||
|
: (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
onToggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aria-label={isCollapsed ? `Expand folder ${folder.name}` : `Collapse folder ${folder.name}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex items-center gap-1.5 pl-1.5 flex-1">
|
||||||
|
<FolderIcon className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||||
|
|
||||||
|
{renaming ? (
|
||||||
|
<form
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-1"
|
||||||
|
data-keyboard-avoid="true"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
handleSaveRename();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => handleDraftChange(event.target.value)}
|
||||||
|
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
|
||||||
|
autoFocus
|
||||||
|
placeholder="Folder name"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleCancelRename();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === ' ' || event.key === 'Enter') {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<RiCheckLine className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleCancelRename();
|
||||||
|
}}
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<RiCloseLine className="size-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="min-w-0 flex items-center gap-1.5 flex-1">
|
||||||
|
<span className="typography-ui-label font-semibold text-muted-foreground truncate">
|
||||||
|
{folder.name}
|
||||||
|
</span>
|
||||||
|
<span className="typography-micro text-muted-foreground/70 flex-shrink-0">
|
||||||
|
• {sessions.length}
|
||||||
|
</span>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
{!renaming ? (
|
||||||
|
<div className="flex items-center gap-0.5 px-0.5">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-0.5 transition-opacity',
|
||||||
|
mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleStartRename();
|
||||||
|
}}
|
||||||
|
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||||
|
aria-label={`Rename folder ${folder.name}`}
|
||||||
|
>
|
||||||
|
<RiPencilAiLine className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||||
|
aria-label={`Delete folder ${folder.name}`}
|
||||||
|
>
|
||||||
|
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Folder body */}
|
||||||
|
{!isCollapsed ? (
|
||||||
|
<div className="pb-1 pl-2">
|
||||||
|
{sessions.length > 0 ? (
|
||||||
|
sessions.map((node) =>
|
||||||
|
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null),
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70">
|
||||||
|
Empty folder
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SessionFolderItem = React.memo(SessionFolderItemBase) as <TSessionNode>(
|
||||||
|
props: SessionFolderItemProps<TSessionNode>,
|
||||||
|
) => React.ReactElement;
|
||||||
@@ -26,6 +26,10 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSeparator,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
@@ -42,6 +46,7 @@ import {
|
|||||||
RiErrorWarningLine,
|
RiErrorWarningLine,
|
||||||
RiFileCopyLine,
|
RiFileCopyLine,
|
||||||
RiFolderAddLine,
|
RiFolderAddLine,
|
||||||
|
RiFolderLine,
|
||||||
RiGitBranchLine,
|
RiGitBranchLine,
|
||||||
RiGitPullRequestLine,
|
RiGitPullRequestLine,
|
||||||
RiGitRepositoryLine,
|
RiGitRepositoryLine,
|
||||||
@@ -80,6 +85,8 @@ import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
|
|||||||
import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog';
|
import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog';
|
||||||
import { ProjectNotesTodoPanel } from './ProjectNotesTodoPanel';
|
import { ProjectNotesTodoPanel } from './ProjectNotesTodoPanel';
|
||||||
import { BranchPickerDialog } from './BranchPickerDialog';
|
import { BranchPickerDialog } from './BranchPickerDialog';
|
||||||
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||||
|
import { SessionFolderItem } from './SessionFolderItem';
|
||||||
|
|
||||||
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
|
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
|
||||||
|
|
||||||
@@ -589,6 +596,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false);
|
const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false);
|
||||||
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
|
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
|
||||||
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
|
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
|
||||||
|
const [renamingFolderId, setRenamingFolderId] = React.useState<string | null>(null);
|
||||||
|
const [renameFolderDraft, setRenameFolderDraft] = React.useState('');
|
||||||
const [pinnedSessionIds, setPinnedSessionIds] = React.useState<Set<string>>(() => {
|
const [pinnedSessionIds, setPinnedSessionIds] = React.useState<Set<string>>(() => {
|
||||||
try {
|
try {
|
||||||
const raw = getSafeStorage().getItem(SESSION_PINNED_STORAGE_KEY);
|
const raw = getSafeStorage().getItem(SESSION_PINNED_STORAGE_KEY);
|
||||||
@@ -678,6 +687,19 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
|
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
|
||||||
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
|
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
|
||||||
|
|
||||||
|
// Session Folders store
|
||||||
|
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
|
||||||
|
const collapsedFolderIds = useSessionFoldersStore((state) => state.collapsedFolderIds);
|
||||||
|
const getFoldersForScope = useSessionFoldersStore((state) => state.getFoldersForScope);
|
||||||
|
const createFolder = useSessionFoldersStore((state) => state.createFolder);
|
||||||
|
const renameFolder = useSessionFoldersStore((state) => state.renameFolder);
|
||||||
|
const deleteFolder = useSessionFoldersStore((state) => state.deleteFolder);
|
||||||
|
const addSessionToFolder = useSessionFoldersStore((state) => state.addSessionToFolder);
|
||||||
|
const removeSessionFromFolder = useSessionFoldersStore((state) => state.removeSessionFromFolder);
|
||||||
|
const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse);
|
||||||
|
const cleanupSessions = useSessionFoldersStore((state) => state.cleanupSessions);
|
||||||
|
const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId);
|
||||||
|
|
||||||
const gitDirectories = useGitStore((state) => state.directories);
|
const gitDirectories = useGitStore((state) => state.directories);
|
||||||
|
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
const sessions = useSessionStore((state) => state.sessions);
|
||||||
@@ -1455,6 +1477,26 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
};
|
};
|
||||||
}, [normalizedProjects, projectGitBranchesKey]);
|
}, [normalizedProjects, projectGitBranchesKey]);
|
||||||
|
|
||||||
|
// Session Folders: cleanup stale session IDs when sessions are removed
|
||||||
|
React.useEffect(() => {
|
||||||
|
const idsByScope = new Map<string, Set<string>>();
|
||||||
|
sessions.forEach((session) => {
|
||||||
|
const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||||
|
if (!directory) return;
|
||||||
|
const existing = idsByScope.get(directory);
|
||||||
|
if (existing) {
|
||||||
|
existing.add(session.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
idsByScope.set(directory, new Set([session.id]));
|
||||||
|
});
|
||||||
|
|
||||||
|
const allScopeKeys = new Set([...Object.keys(foldersMap), ...idsByScope.keys()]);
|
||||||
|
allScopeKeys.forEach((scopeKey) => {
|
||||||
|
cleanupSessions(scopeKey, idsByScope.get(scopeKey) ?? new Set<string>());
|
||||||
|
});
|
||||||
|
}, [sessions, foldersMap, cleanupSessions]);
|
||||||
|
|
||||||
const getSessionsForProject = React.useCallback(
|
const getSessionsForProject = React.useCallback(
|
||||||
(project: { normalizedPath: string }) => {
|
(project: { normalizedPath: string }) => {
|
||||||
// In VS Code, only show sessions from the main project directory (skip worktrees)
|
// In VS Code, only show sessions from the main project directory (skip worktrees)
|
||||||
@@ -2070,7 +2112,15 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
<RiMore2Line className={mobileVariant ? 'h-4 w-4' : 'h-3.5 w-3.5'} />
|
<RiMore2Line className={mobileVariant ? 'h-4 w-4' : 'h-3.5 w-3.5'} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className="min-w-[180px]"
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (renamingFolderId) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingId(session.id);
|
setEditingId(session.id);
|
||||||
@@ -2122,6 +2172,71 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* Move to folder submenu */}
|
||||||
|
{sessionDirectory ? (() => {
|
||||||
|
const scopeFolders = getFoldersForScope(sessionDirectory);
|
||||||
|
const currentFolderId = getSessionFolderId(sessionDirectory, session.id);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger className="[&>svg]:mr-1">
|
||||||
|
<RiFolderLine className="h-4 w-4" />
|
||||||
|
Move to folder
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuSubContent className="min-w-[180px]">
|
||||||
|
{scopeFolders.length === 0 ? (
|
||||||
|
<DropdownMenuItem disabled className="text-muted-foreground">
|
||||||
|
No folders yet
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
scopeFolders.map((folder) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={folder.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (currentFolderId === folder.id) {
|
||||||
|
removeSessionFromFolder(sessionDirectory, session.id);
|
||||||
|
} else {
|
||||||
|
addSessionToFolder(sessionDirectory, folder.id, session.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{folder.name}</span>
|
||||||
|
{currentFolderId === folder.id ? (
|
||||||
|
<RiCheckLine className="ml-2 h-3.5 w-3.5 text-primary flex-shrink-0" />
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
const newFolder = createFolder(sessionDirectory, 'New folder');
|
||||||
|
addSessionToFolder(sessionDirectory, newFolder.id, session.id);
|
||||||
|
setRenamingFolderId(newFolder.id);
|
||||||
|
setRenameFolderDraft('New folder');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiAddLine className="mr-1 h-4 w-4" />
|
||||||
|
New folder...
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{currentFolderId ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
removeSessionFromFolder(sessionDirectory, session.id);
|
||||||
|
}}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<RiCloseLine className="mr-1 h-4 w-4" />
|
||||||
|
Remove from folder
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})() : null}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive [&>svg]:mr-1"
|
className="text-destructive focus:text-destructive [&>svg]:mr-1"
|
||||||
onClick={() => handleDeleteSession(session)}
|
onClick={() => handleDeleteSession(session)}
|
||||||
@@ -2166,6 +2281,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
copiedSessionId,
|
copiedSessionId,
|
||||||
mobileVariant,
|
mobileVariant,
|
||||||
openMenuSessionId,
|
openMenuSessionId,
|
||||||
|
renamingFolderId,
|
||||||
|
getFoldersForScope,
|
||||||
|
getSessionFolderId,
|
||||||
|
addSessionToFolder,
|
||||||
|
removeSessionFromFolder,
|
||||||
|
createFolder,
|
||||||
notifyOnSubtasks,
|
notifyOnSubtasks,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -2175,8 +2296,25 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const isExpanded = expandedSessionGroups.has(groupKey);
|
const isExpanded = expandedSessionGroups.has(groupKey);
|
||||||
const isCollapsed = collapsedGroups.has(groupKey);
|
const isCollapsed = collapsedGroups.has(groupKey);
|
||||||
const maxVisible = hideDirectoryControls ? 10 : 5;
|
const maxVisible = hideDirectoryControls ? 10 : 5;
|
||||||
const totalSessions = group.sessions.length;
|
|
||||||
const visibleSessions = isExpanded ? group.sessions : group.sessions.slice(0, maxVisible);
|
// --- Session Folders: split into foldered vs ungrouped ---
|
||||||
|
const folderScopeKey = normalizePath(group.directory ?? null);
|
||||||
|
const scopeFolders = folderScopeKey ? getFoldersForScope(folderScopeKey) : [];
|
||||||
|
const sessionIdsInFolders = new Set(scopeFolders.flatMap((f) => f.sessionIds));
|
||||||
|
const ungroupedSessions = group.sessions.filter((node) => !sessionIdsInFolders.has(node.session.id));
|
||||||
|
|
||||||
|
// Folders that have sessions in THIS group
|
||||||
|
const foldersWithSessions = scopeFolders
|
||||||
|
.map((folder) => {
|
||||||
|
const nodes = folder.sessionIds
|
||||||
|
.map((sid) => group.sessions.find((node) => node.session.id === sid))
|
||||||
|
.filter((n): n is SessionNode => Boolean(n));
|
||||||
|
return { folder, nodes };
|
||||||
|
})
|
||||||
|
.filter(({ nodes }) => nodes.length > 0);
|
||||||
|
|
||||||
|
const totalSessions = ungroupedSessions.length;
|
||||||
|
const visibleSessions = isExpanded ? ungroupedSessions : ungroupedSessions.slice(0, maxVisible);
|
||||||
const remainingCount = totalSessions - visibleSessions.length;
|
const remainingCount = totalSessions - visibleSessions.length;
|
||||||
const collectGroupSessions = (nodes: SessionNode[]): Session[] => {
|
const collectGroupSessions = (nodes: SessionNode[]): Session[] => {
|
||||||
const collected: Session[] = [];
|
const collected: Session[] = [];
|
||||||
@@ -2205,12 +2343,50 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
|
|
||||||
// VS Code sessions list uses a separate header (Agent Manager / New Session).
|
// VS Code sessions list uses a separate header (Agent Manager / New Session).
|
||||||
// When the caller requests a flat list (hideGroupLabel), omit the per-group header entirely.
|
// When the caller requests a flat list (hideGroupLabel), omit the per-group header entirely.
|
||||||
|
// Shared folder rendering helper (used in both branches)
|
||||||
|
const renderFolderItems = () =>
|
||||||
|
foldersWithSessions.map(({ folder, nodes }) => (
|
||||||
|
<SessionFolderItem
|
||||||
|
key={folder.id}
|
||||||
|
folder={folder}
|
||||||
|
sessions={nodes}
|
||||||
|
isCollapsed={collapsedFolderIds.has(folder.id)}
|
||||||
|
onToggle={() => toggleFolderCollapse(folder.id)}
|
||||||
|
onRename={(name) => {
|
||||||
|
if (folderScopeKey) renameFolder(folderScopeKey, folder.id, name);
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
if (folderScopeKey) deleteFolder(folderScopeKey, folder.id);
|
||||||
|
}}
|
||||||
|
renderSessionNode={renderSessionNode}
|
||||||
|
groupDirectory={group.directory}
|
||||||
|
projectId={projectId}
|
||||||
|
mobileVariant={mobileVariant}
|
||||||
|
isRenaming={renamingFolderId === folder.id}
|
||||||
|
renameDraft={renamingFolderId === folder.id ? renameFolderDraft : undefined}
|
||||||
|
onRenameDraftChange={(value) => setRenameFolderDraft(value)}
|
||||||
|
onRenameSave={() => {
|
||||||
|
const trimmed = renameFolderDraft.trim();
|
||||||
|
if (trimmed && folderScopeKey) {
|
||||||
|
renameFolder(folderScopeKey, folder.id, trimmed);
|
||||||
|
}
|
||||||
|
setRenamingFolderId(null);
|
||||||
|
setRenameFolderDraft('');
|
||||||
|
}}
|
||||||
|
onRenameCancel={() => {
|
||||||
|
setRenamingFolderId(null);
|
||||||
|
setRenameFolderDraft('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
if (hideGroupLabel) {
|
if (hideGroupLabel) {
|
||||||
return (
|
return (
|
||||||
<div className="oc-group">
|
<div className="oc-group">
|
||||||
<div className="oc-group-body pb-3">
|
<div className="oc-group-body pb-3">
|
||||||
|
{renderFolderItems()}
|
||||||
{visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))}
|
{visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))}
|
||||||
{totalSessions === 0 ? (
|
{totalSessions === 0 && foldersWithSessions.length === 0 ? (
|
||||||
<div className="py-1 text-left typography-micro text-muted-foreground">
|
<div className="py-1 text-left typography-micro text-muted-foreground">
|
||||||
No sessions in this workspace yet.
|
No sessions in this workspace yet.
|
||||||
</div>
|
</div>
|
||||||
@@ -2355,8 +2531,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{!isCollapsed ? (
|
{!isCollapsed ? (
|
||||||
<div className="oc-group-body pb-3">
|
<div className="oc-group-body pb-3">
|
||||||
|
{renderFolderItems()}
|
||||||
{visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))}
|
{visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId))}
|
||||||
{totalSessions === 0 ? (
|
{totalSessions === 0 && foldersWithSessions.length === 0 ? (
|
||||||
<div className="py-1 text-left typography-micro text-muted-foreground">
|
<div className="py-1 text-left typography-micro text-muted-foreground">
|
||||||
No sessions in this workspace yet.
|
No sessions in this workspace yet.
|
||||||
</div>
|
</div>
|
||||||
@@ -2398,6 +2575,13 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
mobileVariant,
|
mobileVariant,
|
||||||
setSessionSwitcherOpen,
|
setSessionSwitcherOpen,
|
||||||
openNewSessionDraft,
|
openNewSessionDraft,
|
||||||
|
getFoldersForScope,
|
||||||
|
collapsedFolderIds,
|
||||||
|
toggleFolderCollapse,
|
||||||
|
renameFolder,
|
||||||
|
deleteFolder,
|
||||||
|
renamingFolderId,
|
||||||
|
renameFolderDraft,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -209,15 +209,15 @@ function DropdownMenuSubTrigger({
|
|||||||
inset?: boolean
|
inset?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
data-slot="dropdown-menu-sub-trigger"
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover flex cursor-default items-center rounded-lg px-2 py-1 typography-ui-label outline-hidden select-none data-[inset]:pl-8",
|
"data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-lg px-2 py-1 typography-ui-label outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<RiArrowRightSLine className="ml-auto size-3.5" />
|
<RiArrowRightSLine className="ml-auto size-3.5" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
|||||||
@@ -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