1import { create } from "zustand";
2
3import type { ScopedThreadRef } from "@t3tools/contracts";
4
5/**
6 * Field-wise ref equality. `scopedThreadKey` joins with `:`, so two distinct
7 * refs can collide when an id itself contains one; drops must never cross
8 * threads on that account.
9 */
10export function isSameSidebarThreadRef(a: ScopedThreadRef, b: ScopedThreadRef): boolean {
11 return a.environmentId === b.environmentId && a.threadId === b.threadId;
12}
13
14/**
15 * One sidebar row drop. Drops queue up instead of replacing each other, so a
16 * second drop onto the same thread before it opens keeps both files; each
17 * entry carries its own id so a stale navigation can only ever clear the drop
18 * that started it.
19 */
20export interface SidebarPendingFileDrop {
21 id: string;
22 threadRef: ScopedThreadRef;
23 files: File[];
24}
25
26interface SidebarPendingFileDropStoreState {
27 pending: SidebarPendingFileDrop[];
28 /**
29 * Appends a drop to the queue and returns its id, for later
30 * identity-checked cleanup.
31 */
32 queuePendingFileDrop: (entry: Omit<SidebarPendingFileDrop, "id">) => string;
33 /** Removes the single drop with this id, leaving newer drops untouched. */
34 clearPendingFileDrop: (id: string) => void;
35 /** Removes every queued drop aimed at this thread (e.g. it went missing). */
36 clearPendingFileDropsForThread: (threadRef: ScopedThreadRef) => void;
37 /**
38 * Returns every queued drop's files for `threadRef`, oldest first, and
39 * removes them; returns null (leaving state untouched) when none match.
40 */
41 consumePendingFileDrop: (threadRef: ScopedThreadRef) => File[] | null;
42}
43
44let nextPendingFileDropId = 0;
45
46export const useSidebarPendingFileDropStore = create<SidebarPendingFileDropStoreState>()(
47 (set, get) => ({
48 pending: [],
49 queuePendingFileDrop: (entry) => {
50 const id = `sidebar-file-drop-${(nextPendingFileDropId += 1)}`;
51 set((state) => ({ pending: [...state.pending, { ...entry, id }] }));
52 return id;
53 },
54 clearPendingFileDrop: (id) => {
55 set((state) => ({ pending: state.pending.filter((drop) => drop.id !== id) }));
56 },
57 clearPendingFileDropsForThread: (threadRef) => {
58 set((state) => ({
59 pending: state.pending.filter((drop) => !isSameSidebarThreadRef(drop.threadRef, threadRef)),
60 }));
61 },
62 consumePendingFileDrop: (threadRef) => {
63 const matches = get().pending.filter((drop) =>
64 isSameSidebarThreadRef(drop.threadRef, threadRef),
65 );
66 if (matches.length === 0) {
67 return null;
68 }
69 const matchedIds = new Set(matches.map((drop) => drop.id));
70 set((state) => ({ pending: state.pending.filter((drop) => !matchedIds.has(drop.id)) }));
71 return matches.flatMap((drop) => drop.files);
72 },
73 }),
74);