Skip to content
Source
Sign inSign up

uiStateStore.ts

467 lines14.8 KB

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