1/**
2 * Zustand store for sidebar thread multi-selection state.
3 *
4 * Supports Cmd/Ctrl+Click (toggle individual), Shift+Click (range select),
5 * and bulk actions on the selected set.
6 */
7import { create } from "zustand";
8
9export interface ThreadSelectionState {
10 /** Currently selected scoped thread keys. */
11 selectedThreadKeys: ReadonlySet<string>;
12 /** The scoped thread key that anchors shift-click range selection. */
13 anchorThreadKey: string | null;
14}
15
16interface ThreadSelectionStore extends ThreadSelectionState {
17 /** Toggle a single scoped thread key in the selection (Cmd/Ctrl+Click). */
18 toggleThread: (threadKey: string) => void;
19 /**
20 * Select a range of threads (Shift+Click).
21 * Requires the ordered list of scoped thread keys within the same project
22 * so the store can compute which threads fall between anchor and target.
23 */
24 rangeSelectTo: (threadKey: string, orderedThreadKeys: readonly string[]) => void;
25 /** Clear all selection state. */
26 clearSelection: () => void;
27 /** Remove specific scoped thread keys from the selection (e.g. after deletion). */
28 removeFromSelection: (threadKeys: readonly string[]) => void;
29 /** Set the anchor thread without adding it to the selection (e.g. on plain-click navigate). */
30 setAnchor: (threadKey: string) => void;
31 /** Check if any threads are selected. */
32 hasSelection: () => boolean;
33}
34
35const EMPTY_SET = new Set<string>();
36
37/** Clear completed deletions and missing threads, retaining failed or unprocessed threads. */
38export function getThreadKeysToDeselectAfterDelete(
39 selectedThreadKeys: readonly string[],
40 deletedThreadKeys: ReadonlySet<string>,
41 hasThread: (threadKey: string) => boolean,
42): string[] {
43 return selectedThreadKeys.filter((key) => deletedThreadKeys.has(key) || !hasThread(key));
44}
45
46export const useThreadSelectionStore = create<ThreadSelectionStore>((set, get) => ({
47 selectedThreadKeys: EMPTY_SET,
48 anchorThreadKey: null,
49
50 toggleThread: (threadKey) => {
51 set((state) => {
52 const next = new Set(state.selectedThreadKeys);
53 if (next.has(threadKey)) {
54 next.delete(threadKey);
55 } else {
56 next.add(threadKey);
57 }
58 return {
59 selectedThreadKeys: next,
60 anchorThreadKey: next.has(threadKey) ? threadKey : state.anchorThreadKey,
61 };
62 });
63 },
64
65 rangeSelectTo: (threadKey, orderedThreadKeys) => {
66 set((state) => {
67 const anchor = state.anchorThreadKey;
68 if (anchor === null) {
69 // No anchor yet — treat as a single toggle
70 const next = new Set(state.selectedThreadKeys);
71 next.add(threadKey);
72 return { selectedThreadKeys: next, anchorThreadKey: threadKey };
73 }
74
75 const anchorIndex = orderedThreadKeys.indexOf(anchor);
76 const targetIndex = orderedThreadKeys.indexOf(threadKey);
77 if (anchorIndex === -1 || targetIndex === -1) {
78 // Anchor or target not in this list (different project?) — fallback to toggle
79 const next = new Set(state.selectedThreadKeys);
80 next.add(threadKey);
81 return { selectedThreadKeys: next, anchorThreadKey: threadKey };
82 }
83
84 const start = Math.min(anchorIndex, targetIndex);
85 const end = Math.max(anchorIndex, targetIndex);
86 const next = new Set(state.selectedThreadKeys);
87 for (let i = start; i <= end; i++) {
88 const key = orderedThreadKeys[i];
89 if (key !== undefined) {
90 next.add(key);
91 }
92 }
93 // Keep anchor stable so subsequent shift-clicks extend from the same point
94 return { selectedThreadKeys: next, anchorThreadKey: anchor };
95 });
96 },
97
98 clearSelection: () => {
99 const state = get();
100 if (state.selectedThreadKeys.size === 0 && state.anchorThreadKey === null) return;
101 set({ selectedThreadKeys: EMPTY_SET, anchorThreadKey: null });
102 },
103
104 setAnchor: (threadKey) => {
105 if (get().anchorThreadKey === threadKey) return;
106 set({ anchorThreadKey: threadKey });
107 },
108
109 removeFromSelection: (threadKeys) => {
110 set((state) => {
111 const toRemove = new Set(threadKeys);
112 let changed = false;
113 const next = new Set<string>();
114 for (const key of state.selectedThreadKeys) {
115 if (toRemove.has(key)) {
116 changed = true;
117 } else {
118 next.add(key);
119 }
120 }
121 if (!changed) return state;
122 const newAnchor =
123 state.anchorThreadKey !== null && toRemove.has(state.anchorThreadKey)
124 ? null
125 : state.anchorThreadKey;
126 return { selectedThreadKeys: next, anchorThreadKey: newAnchor };
127 });
128 },
129
130 hasSelection: () => get().selectedThreadKeys.size > 0,
131}));