560 lines · 22.6 KB · TypeScript
RawDownload
/** * Thread-scoped right-panel surface state. * * This is intentionally a shallow workspace model: it owns an ordered set of * surface descriptors and the active surface, while each feature continues to * own its durable resource state. Browser surfaces point at preview tab ids, * terminal surfaces point at terminal session ids, file surfaces point at * workspace paths, and diff/plan/files remain singleton surfaces. */import { scopedThreadKey } from "@t3tools/client-runtime/environment";import type { ScopedThreadRef } from "@t3tools/contracts";import { create } from "zustand";import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; export const RIGHT_PANEL_KINDS = ["plan", "diff", "files", "file", "preview", "terminal"] as const;export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; export type RightPanelSurface =  | { id: `browser:${string}`; kind: "preview"; resourceId: string }  | { id: "browser:new"; kind: "preview"; resourceId: null }  | {      id: `terminal:${string}`;      kind: "terminal";      resourceId: string;      terminalIds: string[];      activeTerminalId: string;      splitDirection?: "horizontal" | "vertical";    }  | { id: "diff"; kind: "diff" }  | { id: "files"; kind: "files" }  | {      id: `file:${string}`;      kind: "file";      relativePath: string;      revealLine: number | null;      revealRequestId: number;    }  | { id: "plan"; kind: "plan" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";const RIGHT_PANEL_STORAGE_VERSION = 7; export interface ThreadRightPanelState {  isOpen: boolean;  activeSurfaceId: string | null;  surfaces: RightPanelSurface[];} interface RightPanelStoreState {  byThreadKey: Record<string, ThreadRightPanelState>;  open: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "file" | "terminal">) => void;  openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;  openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void;  openTerminal: (ref: ScopedThreadRef, terminalId: string) => void;  splitTerminal: (    ref: ScopedThreadRef,    surfaceId: string,    terminalId: string,    direction?: "horizontal" | "vertical",  ) => void;  activateTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void;  closeTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void;  activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void;  closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void;  closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void;  closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void;  closeAllSurfaces: (ref: ScopedThreadRef) => void;  reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void;  reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void;  show: (ref: ScopedThreadRef) => void;  close: (ref: ScopedThreadRef) => void;  toggleVisibility: (ref: ScopedThreadRef) => void;  toggle: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "file" | "terminal">) => void;  removeThread: (ref: ScopedThreadRef) => void;} const EMPTY_THREAD_STATE: ThreadRightPanelState = {  isOpen: false,  activeSurfaceId: null,  surfaces: [],}; const singletonSurface = (  kind: Exclude<RightPanelKind, "file" | "preview" | "terminal">,): RightPanelSurface => {  switch (kind) {    case "diff":      return { id: "diff", kind };    case "files":      return { id: "files", kind };    case "plan":      return { id: "plan", kind };  }}; const browserSurface = (tabId: string | null): RightPanelSurface =>  tabId    ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId }    : { id: "browser:new", kind: "preview", resourceId: null }; const fileSurface = (  relativePath: string,  revealLine: number | null,  revealRequestId: number,): RightPanelSurface => ({  id: `file:${relativePath}`,  kind: "file",  relativePath,  revealLine,  revealRequestId,}); const terminalSurface = (terminalId: string): RightPanelSurface => ({  id: `terminal:${terminalId}`,  kind: "terminal",  resourceId: terminalId,  terminalIds: [terminalId],  activeTerminalId: terminalId,}); const upsertSurface = (  current: ThreadRightPanelState,  surface: RightPanelSurface,  activate = true,): ThreadRightPanelState => ({  isOpen: true,  surfaces: current.surfaces.some((entry) => entry.id === surface.id)    ? current.surfaces    : [...current.surfaces, surface],  activeSurfaceId: activate ? surface.id : current.activeSurfaceId,}); const updateThread = (  byThreadKey: Record<string, ThreadRightPanelState>,  threadKey: string,  updater: (current: ThreadRightPanelState) => ThreadRightPanelState,): Record<string, ThreadRightPanelState> => {  const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;  const next = updater(current);  if (!next.isOpen && next.activeSurfaceId === null && next.surfaces.length === 0) {    if (!(threadKey in byThreadKey)) return byThreadKey;    const { [threadKey]: _removed, ...rest } = byThreadKey;    return rest;  }  if (next === current) return byThreadKey;  return { ...byThreadKey, [threadKey]: next };}; function normalizeRevealLine(line: number | undefined): number | null {  if (line === undefined || !Number.isFinite(line)) return null;  return Math.max(1, Math.trunc(line));} export function migratePersistedRightPanelState(persistedState: unknown): {  byThreadKey: Record<string, ThreadRightPanelState>;} {  if (!persistedState || typeof persistedState !== "object") {    return { byThreadKey: {} };  }  const byThreadKey =    "byThreadKey" in persistedState &&    persistedState.byThreadKey &&    typeof persistedState.byThreadKey === "object"      ? Object.fromEntries(          Object.entries(persistedState.byThreadKey as Record<string, ThreadRightPanelState>).map(            ([threadKey, threadState]) => {              const validThreadState =                threadState && typeof threadState === "object" ? threadState : null;              const surfaces = Array.isArray(validThreadState?.surfaces)                ? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => {                    if (surface.kind === "file") {                      const revealLine =                        typeof surface.revealLine === "number" &&                        Number.isFinite(surface.revealLine)                          ? Math.max(1, Math.trunc(surface.revealLine))                          : null;                      const revealRequestId =                        typeof surface.revealRequestId === "number" &&                        Number.isSafeInteger(surface.revealRequestId) &&                        surface.revealRequestId >= 0                          ? surface.revealRequestId                          : 0;                      return [{ ...surface, revealLine, revealRequestId }];                    }                    if (surface.kind !== "terminal") return [surface];                    if (                      !("resourceId" in surface) ||                      typeof surface.resourceId !== "string" ||                      surface.id !== `terminal:${surface.resourceId}`                    ) {                      return [];                    }                    const terminalIds =                      "terminalIds" in surface && Array.isArray(surface.terminalIds)                        ? [                            ...new Set(                              surface.terminalIds.filter(                                (terminalId): terminalId is string =>                                  typeof terminalId === "string",                              ),                            ),                          ]                        : [surface.resourceId];                    const activeTerminalId =                      "activeTerminalId" in surface &&                      typeof surface.activeTerminalId === "string" &&                      terminalIds.includes(surface.activeTerminalId)                        ? surface.activeTerminalId                        : (terminalIds[0] ?? surface.resourceId);                    return [                      {                        ...surface,                        terminalIds: terminalIds.length > 0 ? terminalIds : [surface.resourceId],                        activeTerminalId,                      },                    ];                  })                : [];              const activeSurfaceId = surfaces.some(                (surface) => surface.id === validThreadState?.activeSurfaceId,              )                ? (validThreadState?.activeSurfaceId ?? null)                : null;              const isOpen =                typeof validThreadState?.isOpen === "boolean"                  ? validThreadState.isOpen                  : activeSurfaceId !== null;              return [threadKey, { isOpen, surfaces, activeSurfaceId }];            },          ),        )      : {};  return { byThreadKey };} export const useRightPanelStore = create<RightPanelStoreState>()(  persist(    (set) => ({      byThreadKey: {},      open: (ref, kind) =>        set((state) => ({          byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {            if (kind === "preview") {              const existing = current.surfaces.find((surface) => surface.kind === "preview");              return upsertSurface(current, existing ?? browserSurface(null));            }            return upsertSurface(current, singletonSurface(kind));          }),        })),      openBrowser: (ref, tabId) =>        set((state) => ({          byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {            const surface = browserSurface(tabId);            const withoutPlaceholder = tabId              ? current.surfaces.filter((entry) => entry.id !== "browser:new")              : current.surfaces;            return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface);          }),        })),      openFile: (ref, relativePath, line) =>        set((state) => ({          byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {            const withoutStandaloneExplorer = current.surfaces.filter(              (surface) => surface.kind !== "files",            );            const surfaceId = `file:${relativePath}` as const;            const existing = withoutStandaloneExplorer.find(              (surface): surface is Extract<RightPanelSurface, { kind: "file" }> =>                surface.id === surfaceId && surface.kind === "file",            );            const surface = fileSurface(              relativePath,              normalizeRevealLine(line),              (existing?.revealRequestId ?? 0) + 1,            );            return {              isOpen: true,              activeSurfaceId: surface.id,              surfaces: existing                ? withoutStandaloneExplorer.map((entry) =>                    entry.id === surface.id ? surface : entry,                  )                : [...withoutStandaloneExplorer, surface],            };          }),        })),      openTerminal: (ref, terminalId) =>        set((state) => ({          byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>            upsertSurface(current, terminalSurface(terminalId)),          ),        })),      splitTerminal: (ref, surfaceId, terminalId, direction = "horizontal") =>        set((state) => ({          byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({            ...current,            isOpen: true,            activeSurfaceId: surfaceId,            surfaces: current.surfaces.map((surface) => {              if (surface.id !== surfaceId || surface.kind !== "terminal") return surface;              const { splitDirection: _splitDirection, ...baseSurface } = surface;              return {                ...baseSurface,                terminalIds: surface.terminalIds.includes(terminalId)                  ? surface.terminalIds                  : [...surface.terminalIds, terminalId],                activeTerminalId: terminalId,                ...(direction === "vertical" ? { splitDirection: "vertical" as const } : {}),              };            }),          })),        })),      activateTerminal: (ref, surfaceId, terminalId) =>        set((state) => ({          byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({            ...current,            activeSurfaceId: surfaceId,            surfaces: current.surfaces.map((surface) =>              surface.id === surfaceId &&              surface.kind === "terminal" &&              surface.terminalIds.includes(terminalId)                ? { ...surface, activeTerminalId: terminalId }                : surface,            ),          })),        })),      closeTerminal: (ref, surfaceId, terminalId) =>        set((state) => ({          byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {            const surface = current.surfaces.find(              (entry) => entry.id === surfaceId && entry.kind === "terminal",            );            if (!surface || surface.kind !== "terminal") return current;            const terminalIds = surface.terminalIds.filter((id) => id !== terminalId);            if (terminalIds.length === 0) {              const index = current.surfaces.findIndex((entry) => entry.id === surfaceId);              const surfaces = current.surfaces.filter((entry) => entry.id !== surfaceId);              const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null;              return {                ...current,                isOpen: surfaces.length > 0 && current.isOpen,                surfaces,                activeSurfaceId:                  current.activeSurfaceId === surfaceId                    ? (fallback?.id ?? null)                    : current.activeSurfaceId,              };            }            return {              ...current,              surfaces: current.surfaces.map((entry) =>                entry.id === surfaceId && entry.kind === "terminal"                  ? {                      ...entry,                      terminalIds,                      activeTerminalId:                        entry.activeTerminalId === terminalId                          ? (terminalIds.at(-1) ?? terminalIds[0]!)                          : entry.activeTerminalId,                    }                  : entry,              ),            };          }),        })),      activateSurface: (ref, surfaceId) =>        set((state) => ({          byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>            current.surfaces.some((surface) => surface.id === surfaceId)              ? { ...current, isOpen: true, activeSurfaceId: surfaceId }              : current,          ),        })),      closeSurface: (ref, surfaceId) =>        set((state) => ({          byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {            const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);            if (index < 0) return current;            const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId);            if (current.activeSurfaceId !== surfaceId) {              return { ...current, isOpen: surfaces.length > 0 && current.isOpen, surfaces };            }            const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null;            return {              ...current,              isOpen: surfaces.length > 0 && current.isOpen,              surfaces,              activeSurfaceId: fallback?.id ?? null,            };          }),        })),      closeOtherSurfaces: (ref, surfaceId) =>        set((state) => ({          byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {            const surface = current.surfaces.find((entry) => entry.id === surfaceId);            if (!surface || current.surfaces.length === 1) return current;            return {              ...current,              isOpen: true,              surfaces: [surface],              activeSurfaceId: surface.id,            };          }),        })),      closeSurfacesToRight: (ref, surfaceId) =>        set((state) => ({          byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {            const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);            if (index < 0 || index === current.surfaces.length - 1) return current;            const surfaces = current.surfaces.slice(0, index + 1);            const activeStillExists = surfaces.some(              (surface) => surface.id === current.activeSurfaceId,            );            return {              ...current,              surfaces,              activeSurfaceId: activeStillExists ? current.activeSurfaceId : surfaceId,            };          }),        })),      closeAllSurfaces: (ref) =>        set((state) => ({          byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>            current.surfaces.length === 0              ? current              : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null },          ),        })),      reconcileBrowserSurfaces: (ref, tabIds) =>        set((state) => ({          byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {            const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`));            const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview");            const existingBrowser = current.surfaces.filter(              (surface): surface is Extract<RightPanelSurface, { kind: "preview" }> =>                surface.kind === "preview" &&                surface.id !== "browser:new" &&                validIds.has(surface.id),            );            const knownIds = new Set(existingBrowser.map((surface) => surface.id));            const added = tabIds              .filter((tabId) => !knownIds.has(`browser:${tabId}`))              .map((tabId) => browserSurface(tabId));            const surfaces = [...nonBrowser, ...existingBrowser, ...added];            const activeStillExists = surfaces.some(              (surface) => surface.id === current.activeSurfaceId,            );            const fallbackBrowser = surfaces.find((surface) => surface.kind === "preview");            return {              ...current,              surfaces,              activeSurfaceId: activeStillExists                ? current.activeSurfaceId                : (fallbackBrowser?.id ?? surfaces[0]?.id ?? null),            };          }),        })),      reconcileFileSurfaces: (ref, workspaceAvailable) =>        set((state) => ({          byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {            if (workspaceAvailable) return current;            const surfaces = current.surfaces.filter(              (surface) => surface.kind !== "files" && surface.kind !== "file",            );            if (surfaces.length === current.surfaces.length) return current;            const activeStillExists = surfaces.some(              (surface) => surface.id === current.activeSurfaceId,            );            return {              ...current,              isOpen: surfaces.length > 0 ? current.isOpen : false,              surfaces,              activeSurfaceId: activeStillExists                ? current.activeSurfaceId                : (surfaces.at(-1)?.id ?? null),            };          }),        })),      show: (ref) =>        set((state) => ({          byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>            current.isOpen ? current : { ...current, isOpen: true },          ),        })),      close: (ref) =>        set((state) => ({          byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>            current.isOpen ? { ...current, isOpen: false } : current,          ),        })),      toggleVisibility: (ref) =>        set((state) => ({          byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({            ...current,            isOpen: !current.isOpen,          })),        })),      toggle: (ref, kind) =>

Lines 1–500 of 560.