feat: bulk select sessions in sidebar
Select toggle in header enters selection mode. Click toggles, shift-click ranges, parent auto-includes subtasks, Cmd/Ctrl+A selects visible, Esc exits, Cmd/Ctrl+Backspace deletes. Bulk bar: move to folder (existing or new inline), archive/delete via existing confirm + "Never ask" pref. Cross-scope forbidden — new scope clears previous. Also: web sidebar header folds toggle into action row (no separate chrome row, same position on toggle); sidebar min width bumped 250→280 so icons fit; minimal-mode hover reveal padding trimmed.
This commit is contained in:
@@ -29,7 +29,9 @@ interface SessionFoldersActions {
|
||||
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;
|
||||
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
|
||||
toggleFolderCollapse: (folderId: string) => void;
|
||||
cleanupSessions: (scopeKey: string, existingSessionIds: Set<string>) => void;
|
||||
getSessionFolderId: (scopeKey: string, sessionId: string) => string | null;
|
||||
@@ -381,6 +383,64 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
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 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;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface SessionMultiSelectState {
|
||||
enabled: boolean;
|
||||
selectedIds: Set<string>;
|
||||
scopeKey: string | null;
|
||||
anchorId: string | null;
|
||||
}
|
||||
|
||||
interface SessionMultiSelectActions {
|
||||
enable: () => void;
|
||||
disable: () => void;
|
||||
toggleMode: () => void;
|
||||
toggleSelected: (id: string, scope: string | null, descendants?: string[]) => void;
|
||||
setRange: (fromId: string | null, toId: string, orderedIds: string[], scope: string | null, descendantsById?: Map<string, string[]>) => void;
|
||||
replaceAll: (ids: string[], scope: string | null) => void;
|
||||
clear: () => void;
|
||||
removeMany: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
type SessionMultiSelectStore = SessionMultiSelectState & SessionMultiSelectActions;
|
||||
|
||||
const expandWithDescendants = (ids: Iterable<string>, descendantsById?: Map<string, string[]>): string[] => {
|
||||
if (!descendantsById) {
|
||||
return Array.from(ids);
|
||||
}
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of ids) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
}
|
||||
const descendants = descendantsById.get(id);
|
||||
if (!descendants) continue;
|
||||
for (const d of descendants) {
|
||||
if (!seen.has(d)) {
|
||||
seen.add(d);
|
||||
out.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export const useSessionMultiSelectStore = create<SessionMultiSelectStore>()((set, get) => ({
|
||||
enabled: false,
|
||||
selectedIds: new Set<string>(),
|
||||
scopeKey: null,
|
||||
anchorId: null,
|
||||
|
||||
enable: () => {
|
||||
if (get().enabled) return;
|
||||
set({ enabled: true });
|
||||
},
|
||||
|
||||
disable: () => {
|
||||
set({ enabled: false, selectedIds: new Set(), scopeKey: null, anchorId: null });
|
||||
},
|
||||
|
||||
toggleMode: () => {
|
||||
if (get().enabled) {
|
||||
set({ enabled: false, selectedIds: new Set(), scopeKey: null, anchorId: null });
|
||||
} else {
|
||||
set({ enabled: true });
|
||||
}
|
||||
},
|
||||
|
||||
toggleSelected: (id, scope, descendants) => {
|
||||
const state = get();
|
||||
const nextIds = new Set(state.selectedIds);
|
||||
let nextScope = state.scopeKey;
|
||||
let nextAnchor = state.anchorId;
|
||||
|
||||
const scopeChanged = scope !== null && state.scopeKey !== null && scope !== state.scopeKey;
|
||||
if (scopeChanged) {
|
||||
nextIds.clear();
|
||||
nextScope = scope;
|
||||
nextAnchor = null;
|
||||
} else if (nextScope === null && scope !== null) {
|
||||
nextScope = scope;
|
||||
}
|
||||
|
||||
const toToggle = descendants && descendants.length > 0 ? [id, ...descendants] : [id];
|
||||
const isCurrentlySelected = nextIds.has(id);
|
||||
if (isCurrentlySelected) {
|
||||
for (const targetId of toToggle) {
|
||||
nextIds.delete(targetId);
|
||||
}
|
||||
if (nextAnchor === id) {
|
||||
nextAnchor = null;
|
||||
}
|
||||
} else {
|
||||
for (const targetId of toToggle) {
|
||||
nextIds.add(targetId);
|
||||
}
|
||||
nextAnchor = id;
|
||||
}
|
||||
|
||||
if (nextIds.size === 0) {
|
||||
set({ selectedIds: nextIds, scopeKey: null, anchorId: null });
|
||||
} else {
|
||||
set({ selectedIds: nextIds, scopeKey: nextScope, anchorId: nextAnchor });
|
||||
}
|
||||
},
|
||||
|
||||
setRange: (fromId, toId, orderedIds, scope, descendantsById) => {
|
||||
const state = get();
|
||||
if (orderedIds.length === 0) return;
|
||||
const effectiveFrom = fromId && orderedIds.includes(fromId) ? fromId : orderedIds[0];
|
||||
const start = orderedIds.indexOf(effectiveFrom);
|
||||
const end = orderedIds.indexOf(toId);
|
||||
if (start < 0 || end < 0) return;
|
||||
const [lo, hi] = start <= end ? [start, end] : [end, start];
|
||||
const slice = orderedIds.slice(lo, hi + 1);
|
||||
const expanded = expandWithDescendants(slice, descendantsById);
|
||||
|
||||
const scopeChanged = scope !== null && state.scopeKey !== null && scope !== state.scopeKey;
|
||||
const baseIds = scopeChanged ? new Set<string>() : new Set(state.selectedIds);
|
||||
for (const id of expanded) {
|
||||
baseIds.add(id);
|
||||
}
|
||||
|
||||
set({
|
||||
selectedIds: baseIds,
|
||||
scopeKey: scope ?? state.scopeKey,
|
||||
anchorId: effectiveFrom,
|
||||
});
|
||||
},
|
||||
|
||||
replaceAll: (ids, scope) => {
|
||||
if (ids.length === 0) {
|
||||
set({ selectedIds: new Set(), scopeKey: null, anchorId: null });
|
||||
return;
|
||||
}
|
||||
set({ selectedIds: new Set(ids), scopeKey: scope, anchorId: ids[0] ?? null });
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
set({ selectedIds: new Set(), scopeKey: null, anchorId: null });
|
||||
},
|
||||
|
||||
removeMany: (ids) => {
|
||||
const state = get();
|
||||
if (state.selectedIds.size === 0 || ids.length === 0) return;
|
||||
const next = new Set(state.selectedIds);
|
||||
for (const id of ids) {
|
||||
next.delete(id);
|
||||
}
|
||||
if (next.size === state.selectedIds.size) return;
|
||||
if (next.size === 0) {
|
||||
set({ selectedIds: next, scopeKey: null, anchorId: null });
|
||||
} else {
|
||||
set({ selectedIds: next });
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user