468 lines · 14.9 KB · TypeScript
RawDownload
import { Debouncer } from "@tanstack/react-pacer";import type { PullRequestMergeMethod } from "@t3tools/contracts";import { create } from "zustand";import { normalizeProjectPathForComparison } from "./lib/projectPaths"; export const PERSISTED_STATE_KEY = "t3code:ui-state:v1";// Version 1 stored card visibility, not folder expansion.const THREAD_CHANGED_FILES_EXPANSION_VERSION = 2;const LEGACY_PERSISTED_STATE_KEYS = [  "t3code:renderer-state:v8",  "t3code:renderer-state:v7",  "t3code:renderer-state:v6",  "t3code:renderer-state:v5",  "t3code:renderer-state:v4",  "t3code:renderer-state:v3",  "codething:renderer-state:v4",  "codething:renderer-state:v3",  "codething:renderer-state:v2",  "codething:renderer-state:v1",] as const; export interface PersistedUiState {  projectExpandedById?: Record<string, boolean>;  projectOrder?: string[];  threadLastVisitedAtById?: Record<string, string>;  collapsedProjectCwds?: string[];  expandedProjectCwds?: string[];  projectOrderCwds?: string[];  defaultAdvertisedEndpointKey?: string | null;  sidebarProjectScopeKey?: string | null;  threadChangedFilesExpansionVersion?: number;  threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;  pullRequestMergeMethod?: string;} export interface UiProjectState {  projectExpandedById: Record<string, boolean>;  projectOrder: string[];  // Logical project key the sidebar list is scoped to, or null for "all  // projects". Lives here so routes that unmount the sidebar (Settings)  // cannot reset the filter.  sidebarProjectScopeKey: string | null;} export interface UiThreadState {  threadLastVisitedAtById: Record<string, string>;  threadChangedFilesExpandedById: Record<string, Record<string, boolean>>;} export interface UiEndpointState {  defaultAdvertisedEndpointKey: string | null;} export interface UiPullRequestState {  pullRequestMergeMethod: PullRequestMergeMethod;} export interface UiState  extends UiProjectState, UiThreadState, UiEndpointState, UiPullRequestState {} const initialState: UiState = {  projectExpandedById: {},  projectOrder: [],  sidebarProjectScopeKey: null,  threadLastVisitedAtById: {},  threadChangedFilesExpandedById: {},  defaultAdvertisedEndpointKey: null,  pullRequestMergeMethod: "merge",}; const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:";const LEGACY_PROJECT_EXPANSION_DEFAULT_KEY = "legacy-project-expansion-default";let legacyKeysCleanedUp = false; export function legacyProjectCwdPreferenceKey(cwd: string): string {  return `${LEGACY_PROJECT_CWD_PREFERENCE_PREFIX}${normalizeProjectPathForComparison(cwd)}`;} function sanitizeStringArray(value: unknown): string[] {  if (!Array.isArray(value)) {    return [];  }  return [    ...new Set(      value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0),    ),  ];} function sanitizeBooleanRecord(value: unknown): Record<string, boolean> {  if (!value || typeof value !== "object") {    return {};  }  return Object.fromEntries(    Object.entries(value).filter(      (entry): entry is [string, boolean] => entry[0].length > 0 && typeof entry[1] === "boolean",    ),  );} function sanitizeOptionalKey(value: unknown): string | null {  return typeof value === "string" && value.length > 0 ? value : null;} function sanitizeTimestampRecord(value: unknown): Record<string, string> {  if (!value || typeof value !== "object") {    return {};  }  return Object.fromEntries(    Object.entries(value).filter(      (entry): entry is [string, string] =>        entry[0].length > 0 &&        typeof entry[1] === "string" &&        entry[1].length > 0 &&        Number.isFinite(Date.parse(entry[1])),    ),  );} function isPullRequestMergeMethod(value: unknown): value is PullRequestMergeMethod {  return value === "merge" || value === "squash" || value === "rebase";} export function parsePersistedState(parsed: PersistedUiState): UiState {  const projectExpandedById =    parsed.projectExpandedById === undefined      ? (() => {          const migrated: Record<string, boolean> = {};          const collapsedProjectCwds = sanitizeStringArray(parsed.collapsedProjectCwds);          const expandedProjectCwds = sanitizeStringArray(parsed.expandedProjectCwds);          for (const cwd of collapsedProjectCwds) {            migrated[legacyProjectCwdPreferenceKey(cwd)] = false;          }          for (const cwd of expandedProjectCwds) {            migrated[legacyProjectCwdPreferenceKey(cwd)] = true;          }          if (!Array.isArray(parsed.collapsedProjectCwds) && expandedProjectCwds.length > 0) {            migrated[LEGACY_PROJECT_EXPANSION_DEFAULT_KEY] = false;          }          return migrated;        })()      : sanitizeBooleanRecord(parsed.projectExpandedById);  const projectOrder =    parsed.projectOrder === undefined      ? sanitizeStringArray(parsed.projectOrderCwds).map(legacyProjectCwdPreferenceKey)      : sanitizeStringArray(parsed.projectOrder);   return {    projectExpandedById,    projectOrder,    threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById),    threadChangedFilesExpandedById:      parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION        ? sanitizePersistedThreadChangedFilesExpanded(parsed.threadChangedFilesExpandedById)        : {},    defaultAdvertisedEndpointKey: sanitizeOptionalKey(parsed.defaultAdvertisedEndpointKey),    sidebarProjectScopeKey: sanitizeOptionalKey(parsed.sidebarProjectScopeKey),    pullRequestMergeMethod: isPullRequestMergeMethod(parsed.pullRequestMergeMethod)      ? parsed.pullRequestMergeMethod      : initialState.pullRequestMergeMethod,  };} function readPersistedState(): UiState {  if (typeof window === "undefined") {    return initialState;  }  try {    const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);    if (!raw) {      for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {        const legacyRaw = window.localStorage.getItem(legacyKey);        if (!legacyRaw) {          continue;        }        return parsePersistedState(JSON.parse(legacyRaw) as PersistedUiState);      }      return initialState;    }    return parsePersistedState(JSON.parse(raw) as PersistedUiState);  } catch {    return initialState;  }} function sanitizePersistedThreadChangedFilesExpanded(  value: PersistedUiState["threadChangedFilesExpandedById"],): Record<string, Record<string, boolean>> {  if (!value || typeof value !== "object") {    return {};  }   const nextState: Record<string, Record<string, boolean>> = {};  for (const [threadId, turns] of Object.entries(value)) {    if (!threadId || !turns || typeof turns !== "object") {      continue;    }     const nextTurns: Record<string, boolean> = {};    for (const [turnId, expanded] of Object.entries(turns)) {      if (turnId && typeof expanded === "boolean") {        nextTurns[turnId] = expanded;      }    }     if (Object.keys(nextTurns).length > 0) {      nextState[threadId] = nextTurns;    }  }   return nextState;} export function persistState(state: UiState): void {  if (typeof window === "undefined") {    return;  }  try {    const projectExpandedById = Object.fromEntries(      Object.entries(state.projectExpandedById).filter(        ([key]) => key !== LEGACY_PROJECT_EXPANSION_DEFAULT_KEY,      ),    );    window.localStorage.setItem(      PERSISTED_STATE_KEY,      JSON.stringify({        projectExpandedById,        projectOrder: state.projectOrder,        threadLastVisitedAtById: state.threadLastVisitedAtById,        defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey,        sidebarProjectScopeKey: state.sidebarProjectScopeKey,        threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION,        threadChangedFilesExpandedById: state.threadChangedFilesExpandedById,        pullRequestMergeMethod: state.pullRequestMergeMethod,      } satisfies PersistedUiState),    );    if (!legacyKeysCleanedUp) {      legacyKeysCleanedUp = true;      for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {        window.localStorage.removeItem(legacyKey);      }    }  } catch {    // Ignore quota/storage errors to avoid breaking chat UX.  }} const debouncedPersistState = new Debouncer(persistState, { wait: 500 }); export function markThreadVisited(state: UiState, threadId: string, visitedAt: string): UiState {  const visitedAtMs = Date.parse(visitedAt);  if (!Number.isFinite(visitedAtMs)) {    return state;  }  const previousVisitedAt = state.threadLastVisitedAtById[threadId];  const previousVisitedAtMs = previousVisitedAt ? Date.parse(previousVisitedAt) : NaN;  if (    Number.isFinite(previousVisitedAtMs) &&    Number.isFinite(visitedAtMs) &&    previousVisitedAtMs >= visitedAtMs  ) {    return state;  }  return {    ...state,    threadLastVisitedAtById: {      ...state.threadLastVisitedAtById,      [threadId]: visitedAt,    },  };} export function markThreadUnread(  state: UiState,  threadId: string,  latestTurnCompletedAt: string | null | undefined,): UiState {  if (!latestTurnCompletedAt) {    return state;  }  const latestTurnCompletedAtMs = Date.parse(latestTurnCompletedAt);  if (Number.isNaN(latestTurnCompletedAtMs)) {    return state;  }  const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();  if (state.threadLastVisitedAtById[threadId] === unreadVisitedAt) {    return state;  }  return {    ...state,    threadLastVisitedAtById: {      ...state.threadLastVisitedAtById,      [threadId]: unreadVisitedAt,    },  };} export function setThreadChangedFilesExpanded(  state: UiState,  threadId: string,  turnId: string,  expanded: boolean,): UiState {  const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {};  if (currentThreadState[turnId] === expanded) {    return state;  }   return {    ...state,    threadChangedFilesExpandedById: {      ...state.threadChangedFilesExpandedById,      [threadId]: {        ...currentThreadState,        [turnId]: expanded,      },    },  };} export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | null): UiState {  const nextKey = key && key.length > 0 ? key : null;  if (state.defaultAdvertisedEndpointKey === nextKey) {    return state;  }  return {    ...state,    defaultAdvertisedEndpointKey: nextKey,  };} export function setSidebarProjectScopeKey(state: UiState, projectKey: string | null): UiState {  const nextKey = sanitizeOptionalKey(projectKey);  if (state.sidebarProjectScopeKey === nextKey) {    return state;  }  return {    ...state,    sidebarProjectScopeKey: nextKey,  };} function setPullRequestMergeMethod(state: UiState, method: PullRequestMergeMethod): UiState {  return state.pullRequestMergeMethod === method    ? state    : { ...state, pullRequestMergeMethod: method };} export function resolveProjectExpanded(  projectExpandedById: Readonly<Record<string, boolean>>,  preferenceKeys: readonly string[],): boolean {  for (const key of preferenceKeys) {    const expanded = projectExpandedById[key];    if (expanded !== undefined) {      return expanded;    }  }  return projectExpandedById[LEGACY_PROJECT_EXPANSION_DEFAULT_KEY] ?? true;} export function setProjectExpanded(  state: UiState,  projectIds: string | readonly string[],  expanded: boolean,): UiState {  const ids = typeof projectIds === "string" ? [projectIds] : projectIds;  const nextEntries = ids.filter((projectId) => state.projectExpandedById[projectId] !== expanded);  if (nextEntries.length === 0) {    return state;  }  const projectExpandedById = { ...state.projectExpandedById };  for (const projectId of nextEntries) {    projectExpandedById[projectId] = expanded;  }  return {    ...state,    projectExpandedById,  };} export function reorderProjects(  state: UiState,  currentProjectOrder: readonly string[],  draggedProjectIds: readonly string[],  targetProjectIds: readonly string[],): UiState {  if (draggedProjectIds.length === 0) {    return state;  }  const draggedSet = new Set(draggedProjectIds);  const targetSet = new Set(targetProjectIds);  if (draggedProjectIds.every((id) => targetSet.has(id))) {    return state;  }   const originalTargetIndex = currentProjectOrder.findIndex((id) => targetSet.has(id));  if (originalTargetIndex < 0) {    return state;  }   const projectOrder = [...currentProjectOrder];   const removed: string[] = [];  let draggedBeforeTarget = 0;  for (let i = projectOrder.length - 1; i >= 0; i--) {    if (draggedSet.has(projectOrder[i]!)) {      removed.unshift(projectOrder.splice(i, 1)[0]!);      if (i < originalTargetIndex) {        draggedBeforeTarget++;      }    }  }  if (removed.length === 0) {    return state;  }   const insertIndex = originalTargetIndex - Math.max(0, draggedBeforeTarget - 1);  projectOrder.splice(insertIndex, 0, ...removed);  return {    ...state,    projectOrder,  };} interface UiStateStore extends UiState {  markThreadVisited: (threadId: string, visitedAt: string) => void;  markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;  setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;  setDefaultAdvertisedEndpointKey: (key: string | null) => void;  setSidebarProjectScopeKey: (projectKey: string | null) => void;  setPullRequestMergeMethod: (method: PullRequestMergeMethod) => void;  setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void;  reorderProjects: (    currentProjectOrder: readonly string[],    draggedProjectIds: readonly string[],    targetProjectIds: readonly string[],  ) => void;} export const useUiStateStore = create<UiStateStore>((set) => ({  ...readPersistedState(),  markThreadVisited: (threadId, visitedAt) =>    set((state) => markThreadVisited(state, threadId, visitedAt)),  markThreadUnread: (threadId, latestTurnCompletedAt) =>    set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)),  setThreadChangedFilesExpanded: (threadId, turnId, expanded) =>    set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)),  setDefaultAdvertisedEndpointKey: (key) =>    set((state) => setDefaultAdvertisedEndpointKey(state, key)),  setSidebarProjectScopeKey: (projectKey) =>    set((state) => setSidebarProjectScopeKey(state, projectKey)),  setPullRequestMergeMethod: (method) => set((state) => setPullRequestMergeMethod(state, method)),  setProjectExpanded: (projectIds, expanded) =>    set((state) => setProjectExpanded(state, projectIds, expanded)),  reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) =>    set((state) =>      reorderProjects(state, currentProjectOrder, draggedProjectIds, targetProjectIds),    ),})); useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); if (typeof window !== "undefined" && typeof window.addEventListener === "function") {  window.addEventListener("beforeunload", () => {    debouncedPersistState.flush();  });}