Repository Public
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