SPB Git

Repository Public

ui.ts 4.6 KB 98 lines typescript

1import { create } from 'zustand';2import type { UserSettings } from '@cr/shared';34export type Preset = UserSettings['defaultLayout'];5export type RightTab = 'files' | 'git' | 'activity' | 'metrics' | 'task';6export const PRESETS: { id: Preset; label: string; hint: string }[] = [7  { id: 'terminal', label: 'Terminal only', hint: 'Full-width terminal' },8  { id: 'terminal+files', label: 'Terminal + Files', hint: 'File tree on the right' },9  { id: 'terminal+editor', label: 'Terminal + Editor', hint: 'Editor beside the terminal' },10  { id: 'terminal+monitoring', label: 'Terminal + Monitoring', hint: 'Host metrics on the right' },11  { id: 'research', label: 'Research', hint: 'Terminal, editor and files; sidebar collapsed' },12];1314/** The persisted part (PUT /settings `layout`, localStorage fallback). Never holds secrets. */15export interface LayoutState {16  preset: Preset;17  sidebarCollapsed: boolean;18  rightOpen: boolean;19  rightTab: RightTab;20  editorOpen: boolean;21  sizes: Record<string, Record<string, number>>;22  /** True once the user picked a theme in this UI (older stored settings always said 'dark'). */23  themeSet: boolean;24}25interface UiState extends LayoutState {26  hydrated: boolean;27  paletteOpen: boolean;28  switcherOpen: boolean;29  sessionsSheetOpen: boolean;30  notificationsOpen: boolean;31  newSession: { open: boolean; projectId: string | null };32  set: (p: Partial<UiState>) => void;33  applyPreset: (p: Preset) => void;34  setSizes: (group: string, layout: Record<string, number>) => void;35  hydrate: (l: Partial<LayoutState>) => void;36  openNewSession: (projectId?: string | null) => void;37}3839export function presetLayout(p: Preset): Pick<LayoutState, 'preset' | 'sidebarCollapsed' | 'rightOpen' | 'rightTab' | 'editorOpen'> {40  switch (p) {41    case 'terminal':42      return { preset: p, sidebarCollapsed: false, rightOpen: false, rightTab: 'files', editorOpen: false };43    case 'terminal+files':44      return { preset: p, sidebarCollapsed: false, rightOpen: true, rightTab: 'files', editorOpen: false };45    case 'terminal+editor':46      return { preset: p, sidebarCollapsed: false, rightOpen: true, rightTab: 'files', editorOpen: true };47    case 'terminal+monitoring':48      return { preset: p, sidebarCollapsed: false, rightOpen: true, rightTab: 'metrics', editorOpen: false };49    case 'research':50      return { preset: p, sidebarCollapsed: true, rightOpen: true, rightTab: 'files', editorOpen: true };51  }52}5354export const LAYOUT_KEY = 'cr.layout';55export function pickLayout(s: LayoutState): LayoutState {56  return { preset: s.preset, sidebarCollapsed: s.sidebarCollapsed, rightOpen: s.rightOpen, rightTab: s.rightTab, editorOpen: s.editorOpen, sizes: s.sizes, themeSet: s.themeSet };57}58const TABS: readonly RightTab[] = ['files', 'git', 'activity', 'metrics', 'task'];59/** Validate an untrusted persisted layout (server settings or localStorage). */60export function parseLayout(raw: unknown): Partial<LayoutState> {61  if (!raw || typeof raw !== 'object') return {};62  const r = raw as Record<string, unknown>;63  const out: Partial<LayoutState> = {};64  if (typeof r.preset === 'string' && PRESETS.some((p) => p.id === r.preset)) out.preset = r.preset as Preset;65  if (typeof r.sidebarCollapsed === 'boolean') out.sidebarCollapsed = r.sidebarCollapsed;66  if (typeof r.rightOpen === 'boolean') out.rightOpen = r.rightOpen;67  if (typeof r.editorOpen === 'boolean') out.editorOpen = r.editorOpen;68  if (typeof r.themeSet === 'boolean') out.themeSet = r.themeSet;69  if (typeof r.rightTab === 'string' && TABS.includes(r.rightTab as RightTab)) out.rightTab = r.rightTab as RightTab;70  if (r.sizes && typeof r.sizes === 'object') {71    const sizes: LayoutState['sizes'] = {};72    for (const [g, v] of Object.entries(r.sizes as Record<string, unknown>)) {73      if (!v || typeof v !== 'object') continue;74      const entries = Object.entries(v as Record<string, unknown>).filter((e): e is [string, number] => typeof e[1] === 'number' && Number.isFinite(e[1]));75      if (entries.length) sizes[g] = Object.fromEntries(entries);76    }77    out.sizes = sizes;78  }79  return out;80}8182export const useUi = create<UiState>((set) => ({83  ...presetLayout('terminal+files'),84  sizes: {},85  themeSet: false,86  hydrated: false,87  paletteOpen: false,88  switcherOpen: false,89  sessionsSheetOpen: false,90  notificationsOpen: false,91  newSession: { open: false, projectId: null },92  set: (p) => set(p),93  applyPreset: (p) => set(presetLayout(p)),94  setSizes: (group, layout) => set((s) => ({ sizes: { ...s.sizes, [group]: layout } })),95  hydrate: (l) => set({ ...l, hydrated: true }),96  openNewSession: (projectId = null) => set({ newSession: { open: true, projectId } }),97}));98
Files main
Menu

Theme