1import { scopedThreadKey } from "@t3tools/client-runtime/environment";
2import {
3 ChatFileAttachment,
4 INCOGNITO_BROWSER_PROFILE_ID,
5 PreviewSessionSnapshot,
6 ScopedThreadRef,
7} from "@t3tools/contracts";
8import * as Schema from "effect/Schema";
9import { create } from "zustand";
10import { createJSONStorage, persist } from "zustand/middleware";
11
12import { resolveStorage } from "./lib/storage";
13import { randomUUID } from "./lib/utils";
14import { type RightPanelSurface } from "./rightPanelStore";
15
16export type ClosedView =
17 | {
18 kind: "panel-tab";
19 threadRef: ScopedThreadRef;
20 surface: Exclude<RightPanelSurface, { kind: "terminal" }>;
21 }
22 | { kind: "browser"; threadRef: ScopedThreadRef; snapshot: PreviewSessionSnapshot };
23
24export type ClosedViewEntry = ClosedView & { id: string };
25
26interface ClosedViewStoreState {
27 entries: ClosedViewEntry[];
28 remember: (view: ClosedView) => string;
29 defer: (id: string) => void;
30 remove: (id: string) => void;
31}
32
33const sameTarget = (entry: ClosedViewEntry, view: ClosedView): boolean => {
34 if (
35 entry.kind !== view.kind ||
36 scopedThreadKey(entry.threadRef) !== scopedThreadKey(view.threadRef)
37 ) {
38 return false;
39 }
40 switch (entry.kind) {
41 case "panel-tab":
42 return view.kind === "panel-tab" && entry.surface.id === view.surface.id;
43 case "browser":
44 return view.kind === "browser" && entry.snapshot.tabId === view.snapshot.tabId;
45 }
46};
47
48const isPersistentView = (entry: ClosedViewEntry) =>
49 entry.kind !== "browser" || entry.snapshot.profileId !== INCOGNITO_BROWSER_PROFILE_ID;
50
51const isThreadRef = Schema.is(ScopedThreadRef);
52const isSnapshot = Schema.is(PreviewSessionSnapshot);
53const isAttachment = Schema.is(ChatFileAttachment);
54
55const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => {
56 const view = entry as ClosedViewEntry | null;
57 if (!view || typeof view.id !== "string" || !isThreadRef(view.threadRef)) return false;
58 if (view.kind === "browser") return isSnapshot(view.snapshot);
59 if (view.kind !== "panel-tab") return false;
60 const surface = view.surface;
61 if (!surface || typeof surface.id !== "string") return false;
62 switch (surface.kind) {
63 case "diff":
64 case "files":
65 case "pull-requests":
66 case "deployments":
67 return surface.id === surface.kind;
68 case "preview":
69 return surface.resourceId === null || typeof surface.resourceId === "string";
70 case "file":
71 return (
72 typeof surface.relativePath === "string" &&
73 (surface.revealLine === null || Number.isSafeInteger(surface.revealLine)) &&
74 Number.isSafeInteger(surface.revealRequestId) &&
75 (surface.attachment === undefined || isAttachment(surface.attachment))
76 );
77 case "device":
78 return (
79 (surface.title === undefined || typeof surface.title === "string") &&
80 (surface.target === undefined ||
81 (surface.target !== null &&
82 typeof surface.target.hostId === "string" &&
83 typeof surface.target.deviceId === "string" &&
84 typeof surface.target.name === "string" &&
85 (surface.target.platform === "ios" || surface.target.platform === "android")))
86 );
87 case "pull-request":
88 return (
89 typeof surface.projectId === "string" &&
90 typeof surface.repository === "string" &&
91 Number.isSafeInteger(surface.number) &&
92 surface.number > 0 &&
93 (surface.environmentId === undefined || typeof surface.environmentId === "string") &&
94 (surface.host === undefined || typeof surface.host === "string") &&
95 (surface.url === undefined || typeof surface.url === "string")
96 );
97 default:
98 return false;
99 }
100};
101
102export const useClosedViewStore = create<ClosedViewStoreState>()(
103 persist(
104 (set) => ({
105 entries: [],
106 remember: (view) => {
107 const id = randomUUID();
108 set((state) => ({
109 entries: [
110 { ...view, id },
111 ...state.entries.filter((entry) => !sameTarget(entry, view)),
112 ].slice(0, 20),
113 }));
114 return id;
115 },
116 defer: (id) =>
117 set((state) => {
118 const entry = state.entries.find((entry) => entry.id === id);
119 return entry
120 ? { entries: [...state.entries.filter((entry) => entry.id !== id), entry] }
121 : state;
122 }),
123 remove: (id) =>
124 set((state) => ({ entries: state.entries.filter((entry) => entry.id !== id) })),
125 }),
126 {
127 name: "t3code:closed-views:v2",
128 storage: createJSONStorage(() =>
129 resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
130 ),
131 version: 2,
132 migrate: (persisted) => {
133 const entries = (persisted as Partial<Pick<ClosedViewStoreState, "entries">> | null)
134 ?.entries;
135 return {
136 entries: Array.isArray(entries)
137 ? entries.filter(isClosedViewEntry).filter(isPersistentView)
138 : [],
139 };
140 },
141 partialize: ({ entries }) => ({ entries: entries.filter(isPersistentView) }),
142 },
143 ),
144);