Open History to see the last commit
1import { Debouncer } from "@tanstack/react-pacer";
2import type { PullRequestMergeMethod } from "@t3tools/contracts";
3import { create } from "zustand";
4import { normalizeProjectPathForComparison } from "@t3tools/client-runtime/state/projects";
5
6export const PERSISTED_STATE_KEY = "t3code:ui-state:v1";
7const THREAD_CHANGED_FILES_EXPANSION_VERSION = 2;
8const LEGACY_PERSISTED_STATE_KEYS = [
9 "t3code:renderer-state:v8",
10 "t3code:renderer-state:v7",
11 "t3code:renderer-state:v6",
12 "t3code:renderer-state:v5",
13 "t3code:renderer-state:v4",
14 "t3code:renderer-state:v3",
15 "codething:renderer-state:v4",
16 "codething:renderer-state:v3",
17 "codething:renderer-state:v2",
18 "codething:renderer-state:v1",
19] as const;
20
21export interface PersistedUiState {
22 projectExpandedById?: Record<string, boolean>;
23 projectOrder?: string[];
24 threadLastVisitedAtById?: Record<string, string>;
25 collapsedProjectCwds?: string[];
26 expandedProjectCwds?: string[];
27 projectOrderCwds?: string[];
28 defaultAdvertisedEndpointKey?: string | null;
29 sidebarProjectScopeKey?: string | null;
30 sidebarProjectScopeKeys?: string[];
31 threadChangedFilesExpansionVersion?: number;
32 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;
33 pullRequestMergeMethod?: string;
34}
35
36export interface UiProjectState {
37 projectExpandedById: Record<string, boolean>;
38 projectOrder: string[];
39 sidebarProjectScopeKeys: string[];
40}
41
42export interface UiThreadState {
43 threadLastVisitedAtById: Record<string, string>;
44 threadChangedFilesExpandedById: Record<string, Record<string, boolean>>;
45}
46
47export interface UiEndpointState {
48 defaultAdvertisedEndpointKey: string | null;
49}
50
51export interface UiPullRequestState {
52 pullRequestMergeMethod: PullRequestMergeMethod;
53}
54
55export interface UiState
56 extends UiProjectState, UiThreadState, UiEndpointState, UiPullRequestState {}
57
58const initialState: UiState = {
59 projectExpandedById: {},
60 projectOrder: [],
61 sidebarProjectScopeKeys: [],
62 threadLastVisitedAtById: {},
63 threadChangedFilesExpandedById: {},
64 defaultAdvertisedEndpointKey: null,
65 pullRequestMergeMethod: "merge",
66};
67
68const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:";
69const LEGACY_PROJECT_EXPANSION_DEFAULT_KEY = "legacy-project-expansion-default";
70let legacyKeysCleanedUp = false;
71
72export function legacyProjectCwdPreferenceKey(cwd: string): string {
73 return `${LEGACY_PROJECT_CWD_PREFERENCE_PREFIX}${normalizeProjectPathForComparison(cwd)}`;
74}
75
76function sanitizeStringArray(value: unknown): string[] {
77 if (!Array.isArray(value)) {
78 return [];
79 }
80 return [
81 ...new Set(
82 value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0),
83 ),
84 ];
85}
86
87function sanitizeBooleanRecord(value: unknown): Record<string, boolean> {
88 if (!value || typeof value !== "object") {
89 return {};
90 }
91 return Object.fromEntries(
92 Object.entries(value).filter(
93 (entry): entry is [string, boolean] => entry[0].length > 0 && typeof entry[1] === "boolean",
94 ),
95 );
96}
97
98function sanitizeOptionalKey(value: unknown): string | null {
99 return typeof value === "string" && value.length > 0 ? value : null;
100}
101
102function sanitizeTimestampRecord(value: unknown): Record<string, string> {
103 if (!value || typeof value !== "object") {
104 return {};
105 }
106 return Object.fromEntries(
107 Object.entries(value).filter(
108 (entry): entry is [string, string] =>
109 entry[0].length > 0 &&
110 typeof entry[1] === "string" &&
111 entry[1].length > 0 &&
112 Number.isFinite(Date.parse(entry[1])),
113 ),
114 );
115}
116
117function isPullRequestMergeMethod(value: unknown): value is PullRequestMergeMethod {
118 return value === "merge" || value === "squash" || value === "rebase";
119}
120
121export function parsePersistedState(parsed: PersistedUiState): UiState {
122 const projectExpandedById =
123 parsed.projectExpandedById === undefined
124 ? (() => {
125 const migrated: Record<string, boolean> = {};
126 const collapsedProjectCwds = sanitizeStringArray(parsed.collapsedProjectCwds);
127 const expandedProjectCwds = sanitizeStringArray(parsed.expandedProjectCwds);
128 for (const cwd of collapsedProjectCwds) {
129 migrated[legacyProjectCwdPreferenceKey(cwd)] = false;
130 }
131 for (const cwd of expandedProjectCwds) {
132 migrated[legacyProjectCwdPreferenceKey(cwd)] = true;
133 }
134 if (!Array.isArray(parsed.collapsedProjectCwds) && expandedProjectCwds.length > 0) {
135 migrated[LEGACY_PROJECT_EXPANSION_DEFAULT_KEY] = false;
136 }
137 return migrated;
138 })()
139 : sanitizeBooleanRecord(parsed.projectExpandedById);
140 const projectOrder =
141 parsed.projectOrder === undefined
142 ? sanitizeStringArray(parsed.projectOrderCwds).map(legacyProjectCwdPreferenceKey)
143 : sanitizeStringArray(parsed.projectOrder);
144
145 return {
146 projectExpandedById,
147 projectOrder,
148 threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById),
149 threadChangedFilesExpandedById:
150 parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION
151 ? sanitizePersistedThreadChangedFilesExpanded(parsed.threadChangedFilesExpandedById)
152 : {},
153 defaultAdvertisedEndpointKey: sanitizeOptionalKey(parsed.defaultAdvertisedEndpointKey),
154 sidebarProjectScopeKeys: sanitizeStringArray(
155 parsed.sidebarProjectScopeKeys ?? [parsed.sidebarProjectScopeKey],
156 ),
157 pullRequestMergeMethod: isPullRequestMergeMethod(parsed.pullRequestMergeMethod)
158 ? parsed.pullRequestMergeMethod
159 : initialState.pullRequestMergeMethod,
160 };
161}
162
163function readPersistedState(): UiState {
164 if (typeof window === "undefined") {
165 return initialState;
166 }
167 try {
168 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
169 if (!raw) {
170 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
171 const legacyRaw = window.localStorage.getItem(legacyKey);
172 if (!legacyRaw) {
173 continue;
174 }
175 return parsePersistedState(JSON.parse(legacyRaw) as PersistedUiState);
176 }
177 return initialState;
178 }
179 return parsePersistedState(JSON.parse(raw) as PersistedUiState);
180 } catch {
181 return initialState;
182 }
183}
184
185function sanitizePersistedThreadChangedFilesExpanded(
186 value: PersistedUiState["threadChangedFilesExpandedById"],
187): Record<string, Record<string, boolean>> {
188 if (!value || typeof value !== "object") {
189 return {};
190 }
191
192 const nextState: Record<string, Record<string, boolean>> = {};
193 for (const [threadId, turns] of Object.entries(value)) {
194 if (!threadId || !turns || typeof turns !== "object") {
195 continue;
196 }
197
198 const nextTurns: Record<string, boolean> = {};
199 for (const [turnId, expanded] of Object.entries(turns)) {
200 if (turnId && typeof expanded === "boolean") {
201 nextTurns[turnId] = expanded;
202 }
203 }
204
205 if (Object.keys(nextTurns).length > 0) {
206 nextState[threadId] = nextTurns;
207 }
208 }
209
210 return nextState;
211}
212
213export function persistState(state: UiState): void {
214 if (typeof window === "undefined") {
215 return;
216 }
217 try {
218 window.localStorage.setItem(
219 PERSISTED_STATE_KEY,
220 JSON.stringify({
221 projectExpandedById: state.projectExpandedById,
222 projectOrder: state.projectOrder,
223 threadLastVisitedAtById: state.threadLastVisitedAtById,
224 defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey,
225 sidebarProjectScopeKeys: state.sidebarProjectScopeKeys,
226 // Older clients read only this field.
227 sidebarProjectScopeKey:
228 state.sidebarProjectScopeKeys.length === 1
229 ? (state.sidebarProjectScopeKeys[0] ?? null)
230 : null,
231 threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION,
232 threadChangedFilesExpandedById: state.threadChangedFilesExpandedById,
233 pullRequestMergeMethod: state.pullRequestMergeMethod,
234 } satisfies PersistedUiState),
235 );
236 if (!legacyKeysCleanedUp) {
237 legacyKeysCleanedUp = true;
238 for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
239 window.localStorage.removeItem(legacyKey);
240 }
241 }
242 } catch {}
243}
244
245const debouncedPersistState = new Debouncer(persistState, { wait: 500 });
246
247export function markThreadVisited(state: UiState, threadId: string, visitedAt: string): UiState {
248 const visitedAtMs = Date.parse(visitedAt);
249 if (!Number.isFinite(visitedAtMs)) {
250 return state;
251 }
252 const previousVisitedAt = state.threadLastVisitedAtById[threadId];
253 const previousVisitedAtMs = previousVisitedAt ? Date.parse(previousVisitedAt) : NaN;
254 if (
255 Number.isFinite(previousVisitedAtMs) &&
256 Number.isFinite(visitedAtMs) &&
257 previousVisitedAtMs >= visitedAtMs
258 ) {
259 return state;
260 }
261 return {
262 ...state,
263 threadLastVisitedAtById: {
264 ...state.threadLastVisitedAtById,
265 [threadId]: visitedAt,
266 },
267 };
268}
269
270export function markThreadUnread(
271 state: UiState,
272 threadId: string,
273 latestTurnCompletedAt: string | null | undefined,
274): UiState {
275 if (!latestTurnCompletedAt) {
276 return state;
277 }
278 const latestTurnCompletedAtMs = Date.parse(latestTurnCompletedAt);
279 if (Number.isNaN(latestTurnCompletedAtMs)) {
280 return state;
281 }
282 const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();
283 if (state.threadLastVisitedAtById[threadId] === unreadVisitedAt) {
284 return state;
285 }
286 return {
287 ...state,
288 threadLastVisitedAtById: {
289 ...state.threadLastVisitedAtById,
290 [threadId]: unreadVisitedAt,
291 },
292 };
293}
294
295function setThreadChangedFilesExpanded(
296 state: UiState,
297 threadId: string,
298 turnId: string,
299 expanded: boolean,
300): UiState {
301 const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {};
302 if (currentThreadState[turnId] === expanded) {
303 return state;
304 }
305
306 return {
307 ...state,
308 threadChangedFilesExpandedById: {
309 ...state.threadChangedFilesExpandedById,
310 [threadId]: {
311 ...currentThreadState,
312 [turnId]: expanded,
313 },
314 },
315 };
316}
317
318function setDefaultAdvertisedEndpointKey(state: UiState, key: string | null): UiState {
319 const nextKey = key && key.length > 0 ? key : null;
320 if (state.defaultAdvertisedEndpointKey === nextKey) {
321 return state;
322 }
323 return {
324 ...state,
325 defaultAdvertisedEndpointKey: nextKey,
326 };
327}
328
329function setSidebarProjectScopeKeys(state: UiState, projectKeys: readonly string[]): UiState {
330 const nextKeys = sanitizeStringArray(projectKeys);
331 if (
332 nextKeys.length === state.sidebarProjectScopeKeys.length &&
333 nextKeys.every((key, index) => key === state.sidebarProjectScopeKeys[index])
334 ) {
335 return state;
336 }
337 return {
338 ...state,
339 sidebarProjectScopeKeys: nextKeys,
340 };
341}
342
343function setPullRequestMergeMethod(state: UiState, method: PullRequestMergeMethod): UiState {
344 return state.pullRequestMergeMethod === method
345 ? state
346 : { ...state, pullRequestMergeMethod: method };
347}
348
349export function resolveProjectExpanded(
350 projectExpandedById: Readonly<Record<string, boolean>>,
351 preferenceKeys: readonly string[],
352): boolean {
353 for (const key of preferenceKeys) {
354 const expanded = projectExpandedById[key];
355 if (expanded !== undefined) {
356 return expanded;
357 }
358 }
359 return projectExpandedById[LEGACY_PROJECT_EXPANSION_DEFAULT_KEY] ?? true;
360}
361
362export function setProjectExpanded(
363 state: UiState,
364 projectIds: string | readonly string[],
365 expanded: boolean,
366): UiState {
367 const ids = typeof projectIds === "string" ? [projectIds] : projectIds;
368 const nextEntries = ids.filter((projectId) => state.projectExpandedById[projectId] !== expanded);
369 if (nextEntries.length === 0) {
370 return state;
371 }
372 const projectExpandedById = { ...state.projectExpandedById };
373 for (const projectId of nextEntries) {
374 projectExpandedById[projectId] = expanded;
375 }
376 return {
377 ...state,
378 projectExpandedById,
379 };
380}
381
382export function reorderProjects(
383 state: UiState,
384 currentProjectOrder: readonly string[],
385 draggedProjectIds: readonly string[],
386 targetProjectIds: readonly string[],
387): UiState {
388 if (draggedProjectIds.length === 0) {
389 return state;
390 }
391 const draggedSet = new Set(draggedProjectIds);
392 const targetSet = new Set(targetProjectIds);
393 if (draggedProjectIds.every((id) => targetSet.has(id))) {
394 return state;
395 }
396
397 const originalTargetIndex = currentProjectOrder.findIndex((id) => targetSet.has(id));
398 if (originalTargetIndex < 0) {
399 return state;
400 }
401
402 const projectOrder = [...currentProjectOrder];
403
404 const removed: string[] = [];
405 let draggedBeforeTarget = 0;
406 for (let i = projectOrder.length - 1; i >= 0; i--) {
407 if (draggedSet.has(projectOrder[i]!)) {
408 removed.unshift(projectOrder.splice(i, 1)[0]!);
409 if (i < originalTargetIndex) {
410 draggedBeforeTarget++;
411 }
412 }
413 }
414 if (removed.length === 0) {
415 return state;
416 }
417
418 const insertIndex = originalTargetIndex - Math.max(0, draggedBeforeTarget - 1);
419 projectOrder.splice(insertIndex, 0, ...removed);
420 return {
421 ...state,
422 projectOrder,
423 };
424}
425
426interface UiStateStore extends UiState {
427 markThreadVisited: (threadId: string, visitedAt: string) => void;
428 markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;
429 setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;
430 setDefaultAdvertisedEndpointKey: (key: string | null) => void;
431 setSidebarProjectScopeKeys: (projectKeys: readonly string[]) => void;
432 setPullRequestMergeMethod: (method: PullRequestMergeMethod) => void;
433 setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void;
434 reorderProjects: (
435 currentProjectOrder: readonly string[],
436 draggedProjectIds: readonly string[],
437 targetProjectIds: readonly string[],
438 ) => void;
439}
440
441export const useUiStateStore = create<UiStateStore>((set) => ({
442 ...readPersistedState(),
443 markThreadVisited: (threadId, visitedAt) =>
444 set((state) => markThreadVisited(state, threadId, visitedAt)),
445 markThreadUnread: (threadId, latestTurnCompletedAt) =>
446 set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)),
447 setThreadChangedFilesExpanded: (threadId, turnId, expanded) =>
448 set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)),
449 setDefaultAdvertisedEndpointKey: (key) =>
450 set((state) => setDefaultAdvertisedEndpointKey(state, key)),
451 setSidebarProjectScopeKeys: (projectKeys) =>
452 set((state) => setSidebarProjectScopeKeys(state, projectKeys)),
453 setPullRequestMergeMethod: (method) => set((state) => setPullRequestMergeMethod(state, method)),
454 setProjectExpanded: (projectIds, expanded) =>
455 set((state) => setProjectExpanded(state, projectIds, expanded)),
456 reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) =>
457 set((state) =>
458 reorderProjects(state, currentProjectOrder, draggedProjectIds, targetProjectIds),
459 ),
460}));
461
462useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state));
463
464if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
465 window.addEventListener("beforeunload", () => {
466 debouncedPersistState.flush();
467 });
468}