forked from bots-garden/ori
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 1 | /** |
| 2 | * State of the workspace panel (file tree + Preview / Editor / Terminal | |
| 3 | * tabs), kept apart from the chat state: the two evolve independently. | |
| 4 | * | |
| 5 | * @example | |
| 6 | * openPreview("README.md"); // opens the panel on the preview tab | |
| 7 | * useWorkspace((w) => w.tab); // inside a component | |
| 8 | */ | |
| 9 | ||
| 10 | import { create } from "zustand"; | |
| 11 | ||
| 12 | export type WorkspaceTab = "preview" | "editor" | "terminal"; | |
| 13 | ||
| 14 | /** Default width of the file tree column, in pixels (14rem at 16px). */ | |
| 15 | export const DEFAULT_FILE_TREE_WIDTH = 224; | |
| 16 | /** Narrowest width the file tree column can be dragged to, in pixels. */ | |
| 17 | export const MIN_FILE_TREE_WIDTH = 120; | |
| 18 | /** Widest width the file tree column can be dragged to, in pixels. */ | |
| 19 | export const MAX_FILE_TREE_WIDTH = 800; | |
| 20 | /** localStorage key under which the chosen width is remembered. */ | |
| 21 | export const FILE_TREE_WIDTH_KEY = "ori.fileTreeWidth"; | |
| 22 | ||
| 23 | export interface WorkspaceState { | |
| 24 | /** Whether the right-hand panel is visible. */ | |
| 25 | open: boolean; | |
| 26 | /** The active tab. */ | |
| 27 | tab: WorkspaceTab; | |
| 28 | /** File shown in the preview tab, if any. */ | |
| 29 | previewPath: string | null; | |
| 30 | /** File opened in the editor tab, if any. */ | |
| 31 | editorPath: string | null; | |
| 32 | /** Width of the file tree column, in pixels, always within the bounds. */ | |
| 33 | fileTreeWidth: number; | |
| 34 | } | |
| 35 | ||
| 36 | const initialWorkspace: WorkspaceState = { | |
| 37 | open: false, | |
| 38 | tab: "preview", | |
| 39 | previewPath: null, | |
| 40 | editorPath: null, | |
| 41 | fileTreeWidth: loadFileTreeWidth(), | |
| 42 | }; | |
| 43 | ||
| 44 | /** React hook over the workspace store. */ | |
| 45 | export const useWorkspace = create<WorkspaceState>(() => initialWorkspace); | |
| 46 | ||
| 47 | /** toggleWorkspace shows or hides the panel. */ | |
| 48 | export function toggleWorkspace(): void { | |
| 49 | useWorkspace.setState((state) => ({ open: !state.open })); | |
| 50 | } | |
| 51 | ||
| 52 | /** showTab activates a tab (and opens the panel if needed). */ | |
| 53 | export function showTab(tab: WorkspaceTab): void { | |
| 54 | useWorkspace.setState({ open: true, tab }); | |
| 55 | } | |
| 56 | ||
| 57 | /** openPreview shows a file in the preview tab. */ | |
| 58 | export function openPreview(path: string): void { | |
| 59 | useWorkspace.setState({ open: true, tab: "preview", previewPath: path }); | |
| 60 | } | |
| 61 | ||
| 62 | /** openEditor opens a file in the editor tab. */ | |
| 63 | export function openEditor(path: string): void { | |
| 64 | useWorkspace.setState({ open: true, tab: "editor", editorPath: path }); | |
| 65 | } | |
| 66 | ||
| 67 | /** | |
| 68 | * clampFileTreeWidth brings a width back within the allowed bounds; a | |
| 69 | * non-finite value (NaN, Infinity) falls back to the default width. | |
| 70 | * | |
| 71 | * @example | |
| 72 | * clampFileTreeWidth(50); // 120 | |
| 73 | * clampFileTreeWidth(300); // 300 | |
| 74 | * clampFileTreeWidth(NaN); // 224 | |
| 75 | */ | |
| 76 | export function clampFileTreeWidth(width: number): number { | |
| 77 | if (!Number.isFinite(width)) { | |
| 78 | return DEFAULT_FILE_TREE_WIDTH; | |
| 79 | } | |
| 80 | return Math.min( | |
| 81 | MAX_FILE_TREE_WIDTH, | |
| 82 | Math.max(MIN_FILE_TREE_WIDTH, Math.round(width)), | |
| 83 | ); | |
| 84 | } | |
| 85 | ||
| 86 | /** | |
| 87 | * setFileTreeWidth resizes the file tree column and remembers the width in | |
| 88 | * localStorage so it survives a page reload. | |
| 89 | * | |
| 90 | * @example | |
| 91 | * setFileTreeWidth(320); | |
| 92 | * useWorkspace.getState().fileTreeWidth; // 320 | |
| 93 | */ | |
| 94 | export function setFileTreeWidth(width: number): void { | |
| 95 | const clamped = clampFileTreeWidth(width); | |
| 96 | useWorkspace.setState({ fileTreeWidth: clamped }); | |
| 97 | saveFileTreeWidth(clamped); | |
| 98 | } | |
| 99 | ||
| 100 | /** resetFileTreeWidth restores the default width (and forgets the stored one). */ | |
| 101 | export function resetFileTreeWidth(): void { | |
| 102 | setFileTreeWidth(DEFAULT_FILE_TREE_WIDTH); | |
| 103 | } | |
| 104 | ||
| 105 | /** | |
| 106 | * loadFileTreeWidth reads the remembered width. Storage can be missing or | |
| 107 | * throw (private browsing, blocked site data, non-browser test runs), so any | |
| 108 | * failure or unusable value yields the default. | |
| 109 | */ | |
| 110 | function loadFileTreeWidth(): number { | |
| 111 | try { | |
| 112 | const stored = globalThis.localStorage?.getItem(FILE_TREE_WIDTH_KEY); | |
| 113 | if (stored === null || stored === undefined) { | |
| 114 | return DEFAULT_FILE_TREE_WIDTH; | |
| 115 | } | |
| 116 | return clampFileTreeWidth(Number(stored)); | |
| 117 | } catch { | |
| 118 | return DEFAULT_FILE_TREE_WIDTH; | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | function saveFileTreeWidth(width: number): void { | |
| 123 | try { | |
| 124 | globalThis.localStorage?.setItem(FILE_TREE_WIDTH_KEY, String(width)); | |
| 125 | } catch { | |
| 126 | // Storage unavailable: the width still applies for this page. | |
| 127 | } | |
| 128 | } | |
| 129 | ||
| 130 | /** resetWorkspace restores the initial state; meant for tests. */ | |
| 131 | export function resetWorkspace(): void { | |
| 132 | useWorkspace.setState( | |
| 133 | { ...initialWorkspace, fileTreeWidth: DEFAULT_FILE_TREE_WIDTH }, | |
| 134 | true, | |
| 135 | ); | |
| 136 | } |