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 return surface.id === surface.kind;
67 case "preview":
68 return surface.resourceId === null || typeof surface.resourceId === "string";
69 case "file":
70 return (
71 typeof surface.relativePath === "string" &&
72 (surface.revealLine === null || Number.isSafeInteger(surface.revealLine)) &&
73 Number.isSafeInteger(surface.revealRequestId) &&
74 (surface.attachment === undefined || isAttachment(surface.attachment))
75 );
76 case "device":
77 return (
78 (surface.title === undefined || typeof surface.title === "string") &&
79 (surface.target === undefined ||
80 (surface.target !== null &&
81 typeof surface.target.hostId === "string" &&
82 typeof surface.target.deviceId === "string" &&
83 typeof surface.target.name === "string" &&
84 (surface.target.platform === "ios" || surface.target.platform === "android")))
85 );
86 case "side-chat":
87 return typeof surface.threadId === "string" && surface.id === `side-chat:${surface.threadId}`;
88 case "pull-request":
89 return (
90 typeof surface.projectId === "string" &&
91 typeof surface.repository === "string" &&
92 Number.isSafeInteger(surface.number) &&
93 surface.number > 0 &&
94 (surface.environmentId === undefined || typeof surface.environmentId === "string") &&
95 (surface.host === undefined || typeof surface.host === "string") &&
96 (surface.url === undefined || typeof surface.url === "string")
97 );
98 default:
99 return false;
100 }
101};
102
103export const useClosedViewStore = create<ClosedViewStoreState>()(
104 persist(
105 (set) => ({
106 entries: [],
107 remember: (view) => {
108 const id = randomUUID();
109 set((state) => ({
110 entries: [
111 { ...view, id },
112 ...state.entries.filter((entry) => !sameTarget(entry, view)),
113 ].slice(0, 20),
114 }));
115 return id;
116 },
117 defer: (id) =>
118 set((state) => {
119 const entry = state.entries.find((entry) => entry.id === id);
120 return entry
121 ? { entries: [...state.entries.filter((entry) => entry.id !== id), entry] }
122 : state;
123 }),
124 remove: (id) =>
125 set((state) => ({ entries: state.entries.filter((entry) => entry.id !== id) })),
126 }),
127 {
128 name: "t3code:closed-views:v2",
129 storage: createJSONStorage(() =>
130 resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
131 ),
132 version: 2,
133 migrate: (persisted) => {
134 const entries = (persisted as Partial<Pick<ClosedViewStoreState, "entries">> | null)
135 ?.entries;
136 return {
137 entries: Array.isArray(entries)
138 ? entries.filter(isClosedViewEntry).filter(isPersistentView)
139 : [],
140 };
141 },
142 partialize: ({ entries }) => ({ entries: entries.filter(isPersistentView) }),
143 },
144 ),
145);