/** * State of the workspace panel (file tree + Preview / Editor / Terminal * tabs), kept apart from the chat state: the two evolve independently. * * @example * openPreview("README.md"); // opens the panel on the preview tab * useWorkspace((w) => w.tab); // inside a component */ import { create } from "zustand"; export type WorkspaceTab = "preview" | "editor" | "terminal"; /** Default width of the file tree column, in pixels (14rem at 16px). */ export const DEFAULT_FILE_TREE_WIDTH = 224; /** Narrowest width the file tree column can be dragged to, in pixels. */ export const MIN_FILE_TREE_WIDTH = 120; /** Widest width the file tree column can be dragged to, in pixels. */ export const MAX_FILE_TREE_WIDTH = 800; /** localStorage key under which the chosen width is remembered. */ export const FILE_TREE_WIDTH_KEY = "ori.fileTreeWidth"; export interface WorkspaceState { /** Whether the right-hand panel is visible. */ open: boolean; /** The active tab. */ tab: WorkspaceTab; /** File shown in the preview tab, if any. */ previewPath: string | null; /** File opened in the editor tab, if any. */ editorPath: string | null; /** Width of the file tree column, in pixels, always within the bounds. */ fileTreeWidth: number; } const initialWorkspace: WorkspaceState = { open: false, tab: "preview", previewPath: null, editorPath: null, fileTreeWidth: loadFileTreeWidth(), }; /** React hook over the workspace store. */ export const useWorkspace = create(() => initialWorkspace); /** toggleWorkspace shows or hides the panel. */ export function toggleWorkspace(): void { useWorkspace.setState((state) => ({ open: !state.open })); } /** showTab activates a tab (and opens the panel if needed). */ export function showTab(tab: WorkspaceTab): void { useWorkspace.setState({ open: true, tab }); } /** openPreview shows a file in the preview tab. */ export function openPreview(path: string): void { useWorkspace.setState({ open: true, tab: "preview", previewPath: path }); } /** openEditor opens a file in the editor tab. */ export function openEditor(path: string): void { useWorkspace.setState({ open: true, tab: "editor", editorPath: path }); } /** * clampFileTreeWidth brings a width back within the allowed bounds; a * non-finite value (NaN, Infinity) falls back to the default width. * * @example * clampFileTreeWidth(50); // 120 * clampFileTreeWidth(300); // 300 * clampFileTreeWidth(NaN); // 224 */ export function clampFileTreeWidth(width: number): number { if (!Number.isFinite(width)) { return DEFAULT_FILE_TREE_WIDTH; } return Math.min( MAX_FILE_TREE_WIDTH, Math.max(MIN_FILE_TREE_WIDTH, Math.round(width)), ); } /** * setFileTreeWidth resizes the file tree column and remembers the width in * localStorage so it survives a page reload. * * @example * setFileTreeWidth(320); * useWorkspace.getState().fileTreeWidth; // 320 */ export function setFileTreeWidth(width: number): void { const clamped = clampFileTreeWidth(width); useWorkspace.setState({ fileTreeWidth: clamped }); saveFileTreeWidth(clamped); } /** resetFileTreeWidth restores the default width (and forgets the stored one). */ export function resetFileTreeWidth(): void { setFileTreeWidth(DEFAULT_FILE_TREE_WIDTH); } /** * loadFileTreeWidth reads the remembered width. Storage can be missing or * throw (private browsing, blocked site data, non-browser test runs), so any * failure or unusable value yields the default. */ function loadFileTreeWidth(): number { try { const stored = globalThis.localStorage?.getItem(FILE_TREE_WIDTH_KEY); if (stored === null || stored === undefined) { return DEFAULT_FILE_TREE_WIDTH; } return clampFileTreeWidth(Number(stored)); } catch { return DEFAULT_FILE_TREE_WIDTH; } } function saveFileTreeWidth(width: number): void { try { globalThis.localStorage?.setItem(FILE_TREE_WIDTH_KEY, String(width)); } catch { // Storage unavailable: the width still applies for this page. } } /** resetWorkspace restores the initial state; meant for tests. */ export function resetWorkspace(): void { useWorkspace.setState( { ...initialWorkspace, fileTreeWidth: DEFAULT_FILE_TREE_WIDTH }, true, ); }