544 lines · 17.0 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[];  threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;} export interface UiProjectState {  projectExpandedById: Record<string, boolean>;  projectOrder: string[];} export interface UiThreadState {  threadLastVisitedAtById: Record<string, string>;  threadChangedFilesExpandedById: Record<string, Record<string, boolean>>;} 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: {},  threadChangedFilesExpandedById: {},}; 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;    }    const parsed = JSON.parse(raw) as PersistedUiState;    hydratePersistedProjectState(parsed);    return {      ...initialState,      threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded(        parsed.threadChangedFilesExpandedById,      ),    };  } 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" && expanded === false) {        nextTurns[turnId] = false;      }    }     if (Object.keys(nextTurns).length > 0) {      nextState[threadId] = nextTurns;    }  }   return nextState;} 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] : [];    });    const threadChangedFilesExpandedById = Object.fromEntries(      Object.entries(state.threadChangedFilesExpandedById).flatMap(([threadId, turns]) => {        const nextTurns = Object.fromEntries(          Object.entries(turns).filter(([, expanded]) => expanded === false),        );        return Object.keys(nextTurns).length > 0 ? [[threadId, nextTurns]] : [];      }),    );    window.localStorage.setItem(      PERSISTED_STATE_KEY,      JSON.stringify({        expandedProjectCwds,        projectOrderCwds,        threadChangedFilesExpandedById,      } 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])  );} function nestedBooleanRecordsEqual(  left: Record<string, Record<string, boolean>>,  right: Record<string, Record<string, boolean>>,): 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 (!(key in right) || !recordsEqual(value, right[key]!)) {      return false;    }  }  return true;} 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;    }  }  const nextThreadChangedFilesExpandedById = Object.fromEntries(    Object.entries(state.threadChangedFilesExpandedById).filter(([threadId]) =>      retainedThreadIds.has(threadId),    ),  );  if (    recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById) &&    nestedBooleanRecordsEqual(      state.threadChangedFilesExpandedById,      nextThreadChangedFilesExpandedById,    )  ) {    return state;  }  return {    ...state,    threadLastVisitedAtById: nextThreadLastVisitedAtById,    threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById,  };} 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 {  const hasVisitedState = threadId in state.threadLastVisitedAtById;  const hasChangedFilesState = threadId in state.threadChangedFilesExpandedById;  if (!hasVisitedState && !hasChangedFilesState) {    return state;  }  const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById };  const nextThreadChangedFilesExpandedById = { ...state.threadChangedFilesExpandedById };  delete nextThreadLastVisitedAtById[threadId];  delete nextThreadChangedFilesExpandedById[threadId];  return {    ...state,    threadLastVisitedAtById: nextThreadLastVisitedAtById,    threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById,  };} export function setThreadChangedFilesExpanded(  state: UiState,  threadId: string,  turnId: string,  expanded: boolean,): UiState {  const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {};  const currentExpanded = currentThreadState[turnId] ?? true;  if (currentExpanded === expanded) {    return state;  }   if (expanded) {    if (!(turnId in currentThreadState)) {      return state;    }     const nextThreadState = { ...currentThreadState };    delete nextThreadState[turnId];    if (Object.keys(nextThreadState).length === 0) {      const nextState = { ...state.threadChangedFilesExpandedById };      delete nextState[threadId];      return {        ...state,        threadChangedFilesExpandedById: nextState,      };    }     return {      ...state,      threadChangedFilesExpandedById: {        ...state.threadChangedFilesExpandedById,        [threadId]: nextThreadState,      },    };  }   return {    ...state,    threadChangedFilesExpandedById: {      ...state.threadChangedFilesExpandedById,      [threadId]: {        ...currentThreadState,        [turnId]: false,      },    },  };} 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;  }

Lines 1–500 of 544.