847 lines · 30.6 KB · TypeScript
RawDownload
/** * Single Zustand store for terminal UI state keyed by scoped thread identity. * * Terminal transition helpers are intentionally private to keep the public * API constrained to store actions/selectors. */ import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime";import { type ScopedThreadRef, type TerminalEvent } from "@t3tools/contracts";import { create } from "zustand";import { createJSONStorage, persist } from "zustand/middleware";import { resolveStorage } from "./lib/storage";import { terminalRunningSubprocessFromEvent } from "./terminalActivity";import {  DEFAULT_THREAD_TERMINAL_HEIGHT,  DEFAULT_THREAD_TERMINAL_ID,  MAX_TERMINALS_PER_GROUP,  type ThreadTerminalGroup,} from "./types"; interface ThreadTerminalState {  terminalOpen: boolean;  terminalHeight: number;  terminalIds: string[];  runningTerminalIds: string[];  activeTerminalId: string;  terminalGroups: ThreadTerminalGroup[];  activeTerminalGroupId: string;} export interface ThreadTerminalLaunchContext {  cwd: string;  worktreePath: string | null;} export interface TerminalEventEntry {  id: number;  event: TerminalEvent;} const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";const EMPTY_TERMINAL_EVENT_ENTRIES: ReadonlyArray<TerminalEventEntry> = [];const MAX_TERMINAL_EVENT_BUFFER = 200; interface PersistedTerminalStateStoreState {  terminalStateByThreadKey?: Record<string, ThreadTerminalState>;} export function migratePersistedTerminalStateStoreState(  persistedState: unknown,  version: number,): PersistedTerminalStateStoreState {  if (version === 1 && persistedState && typeof persistedState === "object") {    const candidate = persistedState as PersistedTerminalStateStoreState;    const nextTerminalStateByThreadKey = Object.fromEntries(      Object.entries(candidate.terminalStateByThreadKey ?? {}).filter(([threadKey]) =>        parseScopedThreadKey(threadKey),      ),    );    return { terminalStateByThreadKey: nextTerminalStateByThreadKey };  }  return { terminalStateByThreadKey: {} };} function createTerminalStateStorage() {  return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined);} function normalizeTerminalIds(terminalIds: string[]): string[] {  const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];  return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];} function normalizeRunningTerminalIds(  runningTerminalIds: string[],  terminalIds: string[],): string[] {  if (runningTerminalIds.length === 0) return [];  const validTerminalIdSet = new Set(terminalIds);  return [...new Set(runningTerminalIds)]    .map((id) => id.trim())    .filter((id) => id.length > 0 && validTerminalIdSet.has(id));} function fallbackGroupId(terminalId: string): string {  return `group-${terminalId}`;} function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string {  let candidate = baseId;  let index = 2;  while (usedGroupIds.has(candidate)) {    candidate = `${baseId}-${index}`;    index += 1;  }  usedGroupIds.add(candidate);  return candidate;} function findGroupIndexByTerminalId(  terminalGroups: ThreadTerminalGroup[],  terminalId: string,): number {  return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));} function normalizeTerminalGroupIds(terminalIds: string[]): string[] {  return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];} function normalizeTerminalGroups(  terminalGroups: ThreadTerminalGroup[],  terminalIds: string[],): ThreadTerminalGroup[] {  const validTerminalIdSet = new Set(terminalIds);  const assignedTerminalIds = new Set<string>();  const nextGroups: ThreadTerminalGroup[] = [];  const usedGroupIds = new Set<string>();   for (const group of terminalGroups) {    const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => {      if (!validTerminalIdSet.has(terminalId)) return false;      if (assignedTerminalIds.has(terminalId)) return false;      return true;    });    if (groupTerminalIds.length === 0) continue;    for (const terminalId of groupTerminalIds) {      assignedTerminalIds.add(terminalId);    }    const baseGroupId =      group.id.trim().length > 0        ? group.id.trim()        : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);    nextGroups.push({      id: assignUniqueGroupId(baseGroupId, usedGroupIds),      terminalIds: groupTerminalIds,    });  }   for (const terminalId of terminalIds) {    if (assignedTerminalIds.has(terminalId)) continue;    nextGroups.push({      id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds),      terminalIds: [terminalId],    });  }   if (nextGroups.length === 0) {    return [      {        id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),        terminalIds: [DEFAULT_THREAD_TERMINAL_ID],      },    ];  }   return nextGroups;} function arraysEqual(a: string[], b: string[]): boolean {  if (a.length !== b.length) return false;  for (let index = 0; index < a.length; index += 1) {    if (a[index] !== b[index]) return false;  }  return true;} function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalGroup[]): boolean {  if (left.length !== right.length) return false;  for (let index = 0; index < left.length; index += 1) {    const leftGroup = left[index];    const rightGroup = right[index];    if (!leftGroup || !rightGroup) return false;    if (leftGroup.id !== rightGroup.id) return false;    if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false;  }  return true;} function threadTerminalStateEqual(left: ThreadTerminalState, right: ThreadTerminalState): boolean {  return (    left.terminalOpen === right.terminalOpen &&    left.terminalHeight === right.terminalHeight &&    left.activeTerminalId === right.activeTerminalId &&    left.activeTerminalGroupId === right.activeTerminalGroupId &&    arraysEqual(left.terminalIds, right.terminalIds) &&    arraysEqual(left.runningTerminalIds, right.runningTerminalIds) &&    terminalGroupsEqual(left.terminalGroups, right.terminalGroups)  );} const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({  terminalOpen: false,  terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,  terminalIds: [DEFAULT_THREAD_TERMINAL_ID],  runningTerminalIds: [],  activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,  terminalGroups: [    {      id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),      terminalIds: [DEFAULT_THREAD_TERMINAL_ID],    },  ],  activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),}); function createDefaultThreadTerminalState(): ThreadTerminalState {  return {    ...DEFAULT_THREAD_TERMINAL_STATE,    terminalIds: [...DEFAULT_THREAD_TERMINAL_STATE.terminalIds],    runningTerminalIds: [...DEFAULT_THREAD_TERMINAL_STATE.runningTerminalIds],    terminalGroups: copyTerminalGroups(DEFAULT_THREAD_TERMINAL_STATE.terminalGroups),  };} function getDefaultThreadTerminalState(): ThreadTerminalState {  return DEFAULT_THREAD_TERMINAL_STATE;} function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTerminalState {  const terminalIds = normalizeTerminalIds(state.terminalIds);  const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID];  const runningTerminalIds = normalizeRunningTerminalIds(state.runningTerminalIds, nextTerminalIds);  const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId)    ? state.activeTerminalId    : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);  const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds);  const activeGroupIdFromState = terminalGroups.some(    (group) => group.id === state.activeTerminalGroupId,  )    ? state.activeTerminalGroupId    : null;  const activeGroupIdFromTerminal =    terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null;   const normalized: ThreadTerminalState = {    terminalOpen: state.terminalOpen,    terminalHeight:      Number.isFinite(state.terminalHeight) && state.terminalHeight > 0        ? state.terminalHeight        : DEFAULT_THREAD_TERMINAL_HEIGHT,    terminalIds: nextTerminalIds,    runningTerminalIds,    activeTerminalId,    terminalGroups,    activeTerminalGroupId:      activeGroupIdFromState ??      activeGroupIdFromTerminal ??      terminalGroups[0]?.id ??      fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),  };  return threadTerminalStateEqual(state, normalized) ? state : normalized;} function isDefaultThreadTerminalState(state: ThreadTerminalState): boolean {  const normalized = normalizeThreadTerminalState(state);  return threadTerminalStateEqual(normalized, DEFAULT_THREAD_TERMINAL_STATE);} function isValidTerminalId(terminalId: string): boolean {  return terminalId.trim().length > 0;} function terminalThreadKey(threadRef: ScopedThreadRef): string {  return scopedThreadKey(threadRef);} function terminalEventBufferKey(threadRef: ScopedThreadRef, terminalId: string): string {  return `${terminalThreadKey(threadRef)}\u0000${terminalId}`;} function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {  return groups.map((group) => ({    id: group.id,    terminalIds: [...group.terminalIds],  }));} function appendTerminalEventEntry(  terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>,  nextTerminalEventId: number,  threadRef: ScopedThreadRef,  event: TerminalEvent,) {  const key = terminalEventBufferKey(threadRef, event.terminalId);  const currentEntries = terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES;  const nextEntry: TerminalEventEntry = {    id: nextTerminalEventId,    event,  };  const nextEntries =    currentEntries.length >= MAX_TERMINAL_EVENT_BUFFER      ? [...currentEntries.slice(1), nextEntry]      : [...currentEntries, nextEntry];   return {    terminalEventEntriesByKey: {      ...terminalEventEntriesByKey,      [key]: nextEntries,    },    nextTerminalEventId: nextTerminalEventId + 1,  };} function launchContextFromStartEvent(  event: Extract<TerminalEvent, { type: "started" | "restarted" }>,): ThreadTerminalLaunchContext {  return {    cwd: event.snapshot.cwd,    worktreePath: event.snapshot.worktreePath,  };} function upsertTerminalIntoGroups(  state: ThreadTerminalState,  terminalId: string,  mode: "split" | "new",): ThreadTerminalState {  const normalized = normalizeThreadTerminalState(state);  if (!isValidTerminalId(terminalId)) {    return normalized;  }   const isNewTerminal = !normalized.terminalIds.includes(terminalId);  const terminalIds = isNewTerminal    ? [...normalized.terminalIds, terminalId]    : normalized.terminalIds;  const terminalGroups = copyTerminalGroups(normalized.terminalGroups);   const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId);  if (existingGroupIndex >= 0) {    terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[      existingGroupIndex    ]!.terminalIds.filter((id) => id !== terminalId);    if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {      terminalGroups.splice(existingGroupIndex, 1);    }  }   if (mode === "new") {    const usedGroupIds = new Set(terminalGroups.map((group) => group.id));    const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds);    terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] });    return normalizeThreadTerminalState({      ...normalized,      terminalOpen: true,      terminalIds,      activeTerminalId: terminalId,      terminalGroups,      activeTerminalGroupId: nextGroupId,    });  }   let activeGroupIndex = terminalGroups.findIndex(    (group) => group.id === normalized.activeTerminalGroupId,  );  if (activeGroupIndex < 0) {    activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId);  }  if (activeGroupIndex < 0) {    const usedGroupIds = new Set(terminalGroups.map((group) => group.id));    const nextGroupId = assignUniqueGroupId(      fallbackGroupId(normalized.activeTerminalId),      usedGroupIds,    );    terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] });    activeGroupIndex = terminalGroups.length - 1;  }   const destinationGroup = terminalGroups[activeGroupIndex];  if (!destinationGroup) {    return normalized;  }   if (    isNewTerminal &&    !destinationGroup.terminalIds.includes(terminalId) &&    destinationGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP  ) {    return normalized;  }   if (!destinationGroup.terminalIds.includes(terminalId)) {    const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId);    if (anchorIndex >= 0) {      destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId);    } else {      destinationGroup.terminalIds.push(terminalId);    }  }   return normalizeThreadTerminalState({    ...normalized,    terminalOpen: true,    terminalIds,    activeTerminalId: terminalId,    terminalGroups,    activeTerminalGroupId: destinationGroup.id,  });} function setThreadTerminalOpen(state: ThreadTerminalState, open: boolean): ThreadTerminalState {  const normalized = normalizeThreadTerminalState(state);  if (normalized.terminalOpen === open) return normalized;  return { ...normalized, terminalOpen: open };} function setThreadTerminalHeight(state: ThreadTerminalState, height: number): ThreadTerminalState {  const normalized = normalizeThreadTerminalState(state);  if (!Number.isFinite(height) || height <= 0 || normalized.terminalHeight === height) {    return normalized;  }  return { ...normalized, terminalHeight: height };} function splitThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState {  return upsertTerminalIntoGroups(state, terminalId, "split");} function newThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState {  return upsertTerminalIntoGroups(state, terminalId, "new");} function setThreadActiveTerminal(  state: ThreadTerminalState,  terminalId: string,): ThreadTerminalState {  const normalized = normalizeThreadTerminalState(state);  if (!normalized.terminalIds.includes(terminalId)) {    return normalized;  }  const activeTerminalGroupId =    normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId))?.id ??    normalized.activeTerminalGroupId;  if (    normalized.activeTerminalId === terminalId &&    normalized.activeTerminalGroupId === activeTerminalGroupId  ) {    return normalized;  }  return {    ...normalized,    activeTerminalId: terminalId,    activeTerminalGroupId,  };} function closeThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState {  const normalized = normalizeThreadTerminalState(state);  if (!normalized.terminalIds.includes(terminalId)) {    return normalized;  }   const remainingTerminalIds = normalized.terminalIds.filter((id) => id !== terminalId);  if (remainingTerminalIds.length === 0) {    return createDefaultThreadTerminalState();  }   const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId);  const nextActiveTerminalId =    normalized.activeTerminalId === terminalId      ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ??        remainingTerminalIds[0] ??        DEFAULT_THREAD_TERMINAL_ID)      : normalized.activeTerminalId;   const terminalGroups = normalized.terminalGroups    .map((group) => ({      ...group,      terminalIds: group.terminalIds.filter((id) => id !== terminalId),    }))    .filter((group) => group.terminalIds.length > 0);   const nextActiveTerminalGroupId =    terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ??    terminalGroups[0]?.id ??    fallbackGroupId(nextActiveTerminalId);   return normalizeThreadTerminalState({    terminalOpen: normalized.terminalOpen,    terminalHeight: normalized.terminalHeight,    terminalIds: remainingTerminalIds,    runningTerminalIds: normalized.runningTerminalIds.filter((id) => id !== terminalId),    activeTerminalId: nextActiveTerminalId,    terminalGroups,    activeTerminalGroupId: nextActiveTerminalGroupId,  });} function setThreadTerminalActivity(  state: ThreadTerminalState,  terminalId: string,  hasRunningSubprocess: boolean,): ThreadTerminalState {  const normalized = normalizeThreadTerminalState(state);  if (!normalized.terminalIds.includes(terminalId)) {    return normalized;  }  const alreadyRunning = normalized.runningTerminalIds.includes(terminalId);  if (hasRunningSubprocess === alreadyRunning) {

Lines 1–500 of 847.