| 1 | import { Debouncer } from "@tanstack/react-pacer"; |
| 2 | import type { PullRequestMergeMethod } from "@t3tools/contracts"; |
| 3 | import { create } from "zustand"; |
| 4 | import { normalizeProjectPathForComparison } from "./lib/projectPaths"; |
| 5 | |
| 6 | export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; |
| 7 | // Version 1 stored card visibility, not folder expansion. |
| 8 | const THREAD_CHANGED_FILES_EXPANSION_VERSION = 2; |
| 9 | const 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 | |
| 22 | export 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 | threadChangedFilesExpansionVersion?: number; |
| 32 | threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>; |
| 33 | pullRequestMergeMethod?: string; |
| 34 | } |
| 35 | |
| 36 | export interface UiProjectState { |
| 37 | projectExpandedById: Record<string, boolean>; |
| 38 | projectOrder: string[]; |
| 39 | // Logical project key the sidebar list is scoped to, or null for "all |
| 40 | // projects". Lives here so routes that unmount the sidebar (Settings) |
| 41 | // cannot reset the filter. |
| 42 | sidebarProjectScopeKey: string | null; |
| 43 | } |
| 44 | |
| 45 | export interface UiThreadState { |
| 46 | threadLastVisitedAtById: Record<string, string>; |
| 47 | threadChangedFilesExpandedById: Record<string, Record<string, boolean>>; |
| 48 | } |
| 49 | |
| 50 | export interface UiEndpointState { |
| 51 | defaultAdvertisedEndpointKey: string | null; |
| 52 | } |
| 53 | |
| 54 | export interface UiPullRequestState { |
| 55 | pullRequestMergeMethod: PullRequestMergeMethod; |
| 56 | } |
| 57 | |
| 58 | export interface UiState |
| 59 | extends UiProjectState, UiThreadState, UiEndpointState, UiPullRequestState {} |
| 60 | |
| 61 | const initialState: UiState = { |
| 62 | projectExpandedById: {}, |
| 63 | projectOrder: [], |
| 64 | sidebarProjectScopeKey: null, |
| 65 | threadLastVisitedAtById: {}, |
| 66 | threadChangedFilesExpandedById: {}, |
| 67 | defaultAdvertisedEndpointKey: null, |
| 68 | pullRequestMergeMethod: "merge", |
| 69 | }; |
| 70 | |
| 71 | const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:"; |
| 72 | const LEGACY_PROJECT_EXPANSION_DEFAULT_KEY = "legacy-project-expansion-default"; |
| 73 | let legacyKeysCleanedUp = false; |
| 74 | |
| 75 | export function legacyProjectCwdPreferenceKey(cwd: string): string { |
| 76 | return `${LEGACY_PROJECT_CWD_PREFERENCE_PREFIX}${normalizeProjectPathForComparison(cwd)}`; |
| 77 | } |
| 78 | |
| 79 | function sanitizeStringArray(value: unknown): string[] { |
| 80 | if (!Array.isArray(value)) { |
| 81 | return []; |
| 82 | } |
| 83 | return [ |
| 84 | ...new Set( |
| 85 | value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0), |
| 86 | ), |
| 87 | ]; |
| 88 | } |
| 89 | |
| 90 | function sanitizeBooleanRecord(value: unknown): Record<string, boolean> { |
| 91 | if (!value || typeof value !== "object") { |
| 92 | return {}; |
| 93 | } |
| 94 | return Object.fromEntries( |
| 95 | Object.entries(value).filter( |
| 96 | (entry): entry is [string, boolean] => entry[0].length > 0 && typeof entry[1] === "boolean", |
| 97 | ), |
| 98 | ); |
| 99 | } |
| 100 | |
| 101 | function sanitizeOptionalKey(value: unknown): string | null { |
| 102 | return typeof value === "string" && value.length > 0 ? value : null; |
| 103 | } |
| 104 | |
| 105 | function 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 | ); |
| 118 | } |
| 119 | |
| 120 | function isPullRequestMergeMethod(value: unknown): value is PullRequestMergeMethod { |
| 121 | return value === "merge" || value === "squash" || value === "rebase"; |
| 122 | } |
| 123 | |
| 124 | export function parsePersistedState(parsed: PersistedUiState): UiState { |
| 125 | const projectExpandedById = |
| 126 | parsed.projectExpandedById === undefined |
| 127 | ? (() => { |
| 128 | const migrated: Record<string, boolean> = {}; |
| 129 | const collapsedProjectCwds = sanitizeStringArray(parsed.collapsedProjectCwds); |
| 130 | const expandedProjectCwds = sanitizeStringArray(parsed.expandedProjectCwds); |
| 131 | for (const cwd of collapsedProjectCwds) { |
| 132 | migrated[legacyProjectCwdPreferenceKey(cwd)] = false; |
| 133 | } |
| 134 | for (const cwd of expandedProjectCwds) { |
| 135 | migrated[legacyProjectCwdPreferenceKey(cwd)] = true; |
| 136 | } |
| 137 | if (!Array.isArray(parsed.collapsedProjectCwds) && expandedProjectCwds.length > 0) { |
| 138 | migrated[LEGACY_PROJECT_EXPANSION_DEFAULT_KEY] = false; |
| 139 | } |
| 140 | return migrated; |
| 141 | })() |
| 142 | : sanitizeBooleanRecord(parsed.projectExpandedById); |
| 143 | const projectOrder = |
| 144 | parsed.projectOrder === undefined |
| 145 | ? sanitizeStringArray(parsed.projectOrderCwds).map(legacyProjectCwdPreferenceKey) |
| 146 | : sanitizeStringArray(parsed.projectOrder); |
| 147 | |
| 148 | return { |
| 149 | projectExpandedById, |
| 150 | projectOrder, |
| 151 | threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById), |
| 152 | threadChangedFilesExpandedById: |
| 153 | parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION |
| 154 | ? sanitizePersistedThreadChangedFilesExpanded(parsed.threadChangedFilesExpandedById) |
| 155 | : {}, |
| 156 | defaultAdvertisedEndpointKey: sanitizeOptionalKey(parsed.defaultAdvertisedEndpointKey), |
| 157 | sidebarProjectScopeKey: sanitizeOptionalKey(parsed.sidebarProjectScopeKey), |
| 158 | pullRequestMergeMethod: isPullRequestMergeMethod(parsed.pullRequestMergeMethod) |
| 159 | ? parsed.pullRequestMergeMethod |
| 160 | : initialState.pullRequestMergeMethod, |
| 161 | }; |
| 162 | } |
| 163 | |
| 164 | function readPersistedState(): UiState { |
| 165 | if (typeof window === "undefined") { |
| 166 | return initialState; |
| 167 | } |
| 168 | try { |
| 169 | const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); |
| 170 | if (!raw) { |
| 171 | for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { |
| 172 | const legacyRaw = window.localStorage.getItem(legacyKey); |
| 173 | if (!legacyRaw) { |
| 174 | continue; |
| 175 | } |
| 176 | return parsePersistedState(JSON.parse(legacyRaw) as PersistedUiState); |
| 177 | } |
| 178 | return initialState; |
| 179 | } |
| 180 | return parsePersistedState(JSON.parse(raw) as PersistedUiState); |
| 181 | } catch { |
| 182 | return initialState; |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | function sanitizePersistedThreadChangedFilesExpanded( |
| 187 | value: PersistedUiState["threadChangedFilesExpandedById"], |
| 188 | ): Record<string, Record<string, boolean>> { |
| 189 | if (!value || typeof value !== "object") { |
| 190 | return {}; |
| 191 | } |
| 192 | |
| 193 | const nextState: Record<string, Record<string, boolean>> = {}; |
| 194 | for (const [threadId, turns] of Object.entries(value)) { |
| 195 | if (!threadId || !turns || typeof turns !== "object") { |
| 196 | continue; |
| 197 | } |
| 198 | |
| 199 | const nextTurns: Record<string, boolean> = {}; |
| 200 | for (const [turnId, expanded] of Object.entries(turns)) { |
| 201 | if (turnId && typeof expanded === "boolean") { |
| 202 | nextTurns[turnId] = expanded; |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | if (Object.keys(nextTurns).length > 0) { |
| 207 | nextState[threadId] = nextTurns; |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | return nextState; |
| 212 | } |
| 213 | |
| 214 | export function persistState(state: UiState): void { |
| 215 | if (typeof window === "undefined") { |
| 216 | return; |
| 217 | } |
| 218 | try { |
| 219 | const projectExpandedById = Object.fromEntries( |
| 220 | Object.entries(state.projectExpandedById).filter( |
| 221 | ([key]) => key !== LEGACY_PROJECT_EXPANSION_DEFAULT_KEY, |
| 222 | ), |
| 223 | ); |
| 224 | window.localStorage.setItem( |
| 225 | PERSISTED_STATE_KEY, |
| 226 | JSON.stringify({ |
| 227 | projectExpandedById, |
| 228 | projectOrder: state.projectOrder, |
| 229 | threadLastVisitedAtById: state.threadLastVisitedAtById, |
| 230 | defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, |
| 231 | sidebarProjectScopeKey: state.sidebarProjectScopeKey, |
| 232 | threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, |
| 233 | threadChangedFilesExpandedById: state.threadChangedFilesExpandedById, |
| 234 | pullRequestMergeMethod: state.pullRequestMergeMethod, |
| 235 | } satisfies PersistedUiState), |
| 236 | ); |
| 237 | if (!legacyKeysCleanedUp) { |
| 238 | legacyKeysCleanedUp = true; |
| 239 | for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { |
| 240 | window.localStorage.removeItem(legacyKey); |
| 241 | } |
| 242 | } |
| 243 | } catch { |
| 244 | // Ignore quota/storage errors to avoid breaking chat UX. |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | const debouncedPersistState = new Debouncer(persistState, { wait: 500 }); |
| 249 | |
| 250 | export function markThreadVisited(state: UiState, threadId: string, visitedAt: string): UiState { |
| 251 | const visitedAtMs = Date.parse(visitedAt); |
| 252 | if (!Number.isFinite(visitedAtMs)) { |
| 253 | return state; |
| 254 | } |
| 255 | const previousVisitedAt = state.threadLastVisitedAtById[threadId]; |
| 256 | const previousVisitedAtMs = previousVisitedAt ? Date.parse(previousVisitedAt) : NaN; |
| 257 | if ( |
| 258 | Number.isFinite(previousVisitedAtMs) && |
| 259 | Number.isFinite(visitedAtMs) && |
| 260 | previousVisitedAtMs >= visitedAtMs |
| 261 | ) { |
| 262 | return state; |
| 263 | } |
| 264 | return { |
| 265 | ...state, |
| 266 | threadLastVisitedAtById: { |
| 267 | ...state.threadLastVisitedAtById, |
| 268 | [threadId]: visitedAt, |
| 269 | }, |
| 270 | }; |
| 271 | } |
| 272 | |
| 273 | export function markThreadUnread( |
| 274 | state: UiState, |
| 275 | threadId: string, |
| 276 | latestTurnCompletedAt: string | null | undefined, |
| 277 | ): UiState { |
| 278 | if (!latestTurnCompletedAt) { |
| 279 | return state; |
| 280 | } |
| 281 | const latestTurnCompletedAtMs = Date.parse(latestTurnCompletedAt); |
| 282 | if (Number.isNaN(latestTurnCompletedAtMs)) { |
| 283 | return state; |
| 284 | } |
| 285 | const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString(); |
| 286 | if (state.threadLastVisitedAtById[threadId] === unreadVisitedAt) { |
| 287 | return state; |
| 288 | } |
| 289 | return { |
| 290 | ...state, |
| 291 | threadLastVisitedAtById: { |
| 292 | ...state.threadLastVisitedAtById, |
| 293 | [threadId]: unreadVisitedAt, |
| 294 | }, |
| 295 | }; |
| 296 | } |
| 297 | |
| 298 | export function setThreadChangedFilesExpanded( |
| 299 | state: UiState, |
| 300 | threadId: string, |
| 301 | turnId: string, |
| 302 | expanded: boolean, |
| 303 | ): UiState { |
| 304 | const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {}; |
| 305 | if (currentThreadState[turnId] === expanded) { |
| 306 | return state; |
| 307 | } |
| 308 | |
| 309 | return { |
| 310 | ...state, |
| 311 | threadChangedFilesExpandedById: { |
| 312 | ...state.threadChangedFilesExpandedById, |
| 313 | [threadId]: { |
| 314 | ...currentThreadState, |
| 315 | [turnId]: expanded, |
| 316 | }, |
| 317 | }, |
| 318 | }; |
| 319 | } |
| 320 | |
| 321 | export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | null): UiState { |
| 322 | const nextKey = key && key.length > 0 ? key : null; |
| 323 | if (state.defaultAdvertisedEndpointKey === nextKey) { |
| 324 | return state; |
| 325 | } |
| 326 | return { |
| 327 | ...state, |
| 328 | defaultAdvertisedEndpointKey: nextKey, |
| 329 | }; |
| 330 | } |
| 331 | |
| 332 | export function setSidebarProjectScopeKey(state: UiState, projectKey: string | null): UiState { |
| 333 | const nextKey = sanitizeOptionalKey(projectKey); |
| 334 | if (state.sidebarProjectScopeKey === nextKey) { |
| 335 | return state; |
| 336 | } |
| 337 | return { |
| 338 | ...state, |
| 339 | sidebarProjectScopeKey: nextKey, |
| 340 | }; |
| 341 | } |
| 342 | |
| 343 | function setPullRequestMergeMethod(state: UiState, method: PullRequestMergeMethod): UiState { |
| 344 | return state.pullRequestMergeMethod === method |
| 345 | ? state |
| 346 | : { ...state, pullRequestMergeMethod: method }; |
| 347 | } |
| 348 | |
| 349 | export 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 | |
| 362 | export 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 | |
| 382 | export 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 | |
| 426 | interface 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 | setSidebarProjectScopeKey: (projectKey: string | null) => 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 | |
| 441 | export 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 | setSidebarProjectScopeKey: (projectKey) => |
| 452 | set((state) => setSidebarProjectScopeKey(state, projectKey)), |
| 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 | |
| 462 | useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); |
| 463 | |
| 464 | if (typeof window !== "undefined" && typeof window.addEventListener === "function") { |
| 465 | window.addEventListener("beforeunload", () => { |
| 466 | debouncedPersistState.flush(); |
| 467 | }); |
| 468 | } |