412 lines · 12.9 KB · TypeScript
RawDownload
import { Debouncer } from "@tanstack/react-pacer";import { create } from "zustand"; const PERSISTED_STATE_KEY = "t3code:ui-state:v1";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; interface PersistedUiState {  expandedProjectCwds?: string[];  projectOrderCwds?: string[];} export interface UiProjectState {  projectExpandedById: Record<string, boolean>;  projectOrder: string[];} export interface UiThreadState {  threadLastVisitedAtById: Record<string, string>;} export interface UiState extends UiProjectState, UiThreadState {} export interface SyncProjectInput {  key: string;  cwd: string;} export interface SyncThreadInput {  key: string;  seedVisitedAt?: string | undefined;} const initialState: UiState = {  projectExpandedById: {},  projectOrder: [],  threadLastVisitedAtById: {},}; const persistedExpandedProjectCwds = new Set<string>();const persistedProjectOrderCwds: string[] = [];const currentProjectCwdById = new Map<string, string>();let legacyKeysCleanedUp = false; 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;        }        hydratePersistedProjectState(JSON.parse(legacyRaw) as PersistedUiState);        return initialState;      }      return initialState;    }    hydratePersistedProjectState(JSON.parse(raw) as PersistedUiState);    return initialState;  } catch {    return initialState;  }} function hydratePersistedProjectState(parsed: PersistedUiState): void {  persistedExpandedProjectCwds.clear();  persistedProjectOrderCwds.length = 0;  for (const cwd of parsed.expandedProjectCwds ?? []) {    if (typeof cwd === "string" && cwd.length > 0) {      persistedExpandedProjectCwds.add(cwd);    }  }  for (const cwd of parsed.projectOrderCwds ?? []) {    if (typeof cwd === "string" && cwd.length > 0 && !persistedProjectOrderCwds.includes(cwd)) {      persistedProjectOrderCwds.push(cwd);    }  }} function persistState(state: UiState): void {  if (typeof window === "undefined") {    return;  }  try {    const expandedProjectCwds = Object.entries(state.projectExpandedById)      .filter(([, expanded]) => expanded)      .flatMap(([projectId]) => {        const cwd = currentProjectCwdById.get(projectId);        return cwd ? [cwd] : [];      });    const projectOrderCwds = state.projectOrder.flatMap((projectId) => {      const cwd = currentProjectCwdById.get(projectId);      return cwd ? [cwd] : [];    });    window.localStorage.setItem(      PERSISTED_STATE_KEY,      JSON.stringify({        expandedProjectCwds,        projectOrderCwds,      } 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 }); function recordsEqual<T>(left: Record<string, T>, right: Record<string, T>): boolean {  const leftEntries = Object.entries(left);  const rightEntries = Object.entries(right);  if (leftEntries.length !== rightEntries.length) {    return false;  }  for (const [key, value] of leftEntries) {    if (right[key] !== value) {      return false;    }  }  return true;} function projectOrdersEqual(left: readonly string[], right: readonly string[]): boolean {  return (    left.length === right.length && left.every((projectId, index) => projectId === right[index])  );} export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState {  const previousProjectCwdById = new Map(currentProjectCwdById);  const previousProjectIdByCwd = new Map(    [...previousProjectCwdById.entries()].map(([projectId, cwd]) => [cwd, projectId] as const),  );  currentProjectCwdById.clear();  for (const project of projects) {    currentProjectCwdById.set(project.key, project.cwd);  }  const cwdMappingChanged =    previousProjectCwdById.size !== currentProjectCwdById.size ||    projects.some((project) => previousProjectCwdById.get(project.key) !== project.cwd);   const nextExpandedById: Record<string, boolean> = {};  const previousExpandedById = state.projectExpandedById;  const persistedOrderByCwd = new Map(    persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const),  );  const mappedProjects = projects.map((project, index) => {    const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd);    const expanded =      previousExpandedById[project.key] ??      (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ??      (persistedExpandedProjectCwds.size > 0        ? persistedExpandedProjectCwds.has(project.cwd)        : true);    nextExpandedById[project.key] = expanded;    return {      id: project.key,      cwd: project.cwd,      incomingIndex: index,    };  });   const nextProjectOrder =    state.projectOrder.length > 0      ? (() => {          const nextProjectIdByCwd = new Map(            mappedProjects.map((project) => [project.cwd, project.id] as const),          );          const usedProjectIds = new Set<string>();          const orderedProjectIds: string[] = [];           for (const projectId of state.projectOrder) {            const matchedProjectId =              (projectId in nextExpandedById ? projectId : undefined) ??              (() => {                const previousCwd = previousProjectCwdById.get(projectId);                return previousCwd ? nextProjectIdByCwd.get(previousCwd) : undefined;              })();            if (!matchedProjectId || usedProjectIds.has(matchedProjectId)) {              continue;            }            usedProjectIds.add(matchedProjectId);            orderedProjectIds.push(matchedProjectId);          }           for (const project of mappedProjects) {            if (usedProjectIds.has(project.id)) {              continue;            }            orderedProjectIds.push(project.id);          }           return orderedProjectIds;        })()      : mappedProjects          .map((project) => ({            id: project.id,            incomingIndex: project.incomingIndex,            orderIndex:              persistedOrderByCwd.get(project.cwd) ??              persistedProjectOrderCwds.length + project.incomingIndex,          }))          .toSorted((left, right) => {            const byOrder = left.orderIndex - right.orderIndex;            if (byOrder !== 0) {              return byOrder;            }            return left.incomingIndex - right.incomingIndex;          })          .map((project) => project.id);   if (    recordsEqual(state.projectExpandedById, nextExpandedById) &&    projectOrdersEqual(state.projectOrder, nextProjectOrder) &&    !cwdMappingChanged  ) {    return state;  }   return {    ...state,    projectExpandedById: nextExpandedById,    projectOrder: nextProjectOrder,  };} export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]): UiState {  const retainedThreadIds = new Set(threads.map((thread) => thread.key));  const nextThreadLastVisitedAtById = Object.fromEntries(    Object.entries(state.threadLastVisitedAtById).filter(([threadId]) =>      retainedThreadIds.has(threadId),    ),  );  for (const thread of threads) {    if (      nextThreadLastVisitedAtById[thread.key] === undefined &&      thread.seedVisitedAt !== undefined &&      thread.seedVisitedAt.length > 0    ) {      nextThreadLastVisitedAtById[thread.key] = thread.seedVisitedAt;    }  }  if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) {    return state;  }  return {    ...state,    threadLastVisitedAtById: nextThreadLastVisitedAtById,  };} export function markThreadVisited(state: UiState, threadId: string, visitedAt?: string): UiState {  const at = visitedAt ?? new Date().toISOString();  const visitedAtMs = Date.parse(at);  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]: at,    },  };} 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 clearThreadUi(state: UiState, threadId: string): UiState {  if (!(threadId in state.threadLastVisitedAtById)) {    return state;  }  const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById };  delete nextThreadLastVisitedAtById[threadId];  return {    ...state,    threadLastVisitedAtById: nextThreadLastVisitedAtById,  };} export function toggleProject(state: UiState, projectId: string): UiState {  const expanded = state.projectExpandedById[projectId] ?? true;  return {    ...state,    projectExpandedById: {      ...state.projectExpandedById,      [projectId]: !expanded,    },  };} export function setProjectExpanded(state: UiState, projectId: string, expanded: boolean): UiState {  if ((state.projectExpandedById[projectId] ?? true) === expanded) {    return state;  }  return {    ...state,    projectExpandedById: {      ...state.projectExpandedById,      [projectId]: expanded,    },  };} export function reorderProjects(  state: UiState,  draggedProjectId: string,  targetProjectId: string,): UiState {  if (draggedProjectId === targetProjectId) {    return state;  }  const draggedIndex = state.projectOrder.findIndex((projectId) => projectId === draggedProjectId);  const targetIndex = state.projectOrder.findIndex((projectId) => projectId === targetProjectId);  if (draggedIndex < 0 || targetIndex < 0) {    return state;  }  const projectOrder = [...state.projectOrder];  const [draggedProject] = projectOrder.splice(draggedIndex, 1);  if (!draggedProject) {    return state;  }  projectOrder.splice(targetIndex, 0, draggedProject);  return {    ...state,    projectOrder,  };} interface UiStateStore extends UiState {  syncProjects: (projects: readonly SyncProjectInput[]) => void;  syncThreads: (threads: readonly SyncThreadInput[]) => void;  markThreadVisited: (threadId: string, visitedAt?: string) => void;  markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;  clearThreadUi: (threadId: string) => void;  toggleProject: (projectId: string) => void;  setProjectExpanded: (projectId: string, expanded: boolean) => void;  reorderProjects: (draggedProjectId: string, targetProjectId: string) => void;} export const useUiStateStore = create<UiStateStore>((set) => ({  ...readPersistedState(),  syncProjects: (projects) => set((state) => syncProjects(state, projects)),  syncThreads: (threads) => set((state) => syncThreads(state, threads)),  markThreadVisited: (threadId, visitedAt) =>    set((state) => markThreadVisited(state, threadId, visitedAt)),  markThreadUnread: (threadId, latestTurnCompletedAt) =>    set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)),  clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)),  toggleProject: (projectId) => set((state) => toggleProject(state, projectId)),  setProjectExpanded: (projectId, expanded) =>    set((state) => setProjectExpanded(state, projectId, expanded)),  reorderProjects: (draggedProjectId, targetProjectId) =>    set((state) => reorderProjects(state, draggedProjectId, targetProjectId)),})); useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); if (typeof window !== "undefined" && typeof window.addEventListener === "function") {  window.addEventListener("beforeunload", () => {    debouncedPersistState.flush();  });}