Skip to content
File

Blob: src/client/workspace/store.ts

typescript220 lines
1import { create } from "zustand";
2import { persist } from "zustand/middleware";
3import type { TabRecord } from "./types";
4import type { SocketState } from "../terminal/socket";
5 
6// ---------------------------------------------------------------------------
7// ID generators
8// ---------------------------------------------------------------------------
9 
10export function makeTabId(): string {
11 return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
12}
13 
14export function makeSessionId(): string {
15 return `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
16}
17 
18type PersistedWorkspaceState = Pick<WorkspaceState, "tabs" | "activeTabId" | "nextTabNumber">;
19 
20// ---------------------------------------------------------------------------
21// Store shape
22// ---------------------------------------------------------------------------
23 
24export interface WorkspaceState {
25 // ---- Persisted (sessionStorage via zustand persist) ----
26 tabs: TabRecord[];
27 activeTabId: string;
28 nextTabNumber: number;
29 
30 // ---- Ephemeral (NOT persisted) ----
31 reconnectKeys: Record<string, number>;
32 connectionStates: Record<string, SocketState>;
33 isSharedSession: boolean;
34 
35 // ---- Actions ----
36 newTab: () => void;
37 closeTab: (tabId: string) => void;
38 switchTab: (tabId: string) => void;
39 reconnect: () => void;
40 newSession: () => void;
41 resetSession: () => Promise<void>;
42 setConnectionState: (tabId: string, state: SocketState) => void;
43 setSharedSession: (shared: boolean) => void;
44}
45 
46// ---------------------------------------------------------------------------
47// Default tab factory
48// ---------------------------------------------------------------------------
49 
50function createTab(number: number): TabRecord {
51 return {
52 id: makeTabId(),
53 sessionId: makeSessionId(),
54 title: `Terminal ${number}`,
55 createdAt: new Date().toISOString(),
56 };
57}
58 
59// ---------------------------------------------------------------------------
60// Store
61// ---------------------------------------------------------------------------
62 
63export const useWorkspaceStore = create<WorkspaceState>()(
64 persist<WorkspaceState, [], [], PersistedWorkspaceState>(
65 (set, get) => {
66 const defaultTab = createTab(1);
67 
68 return {
69 // ---- persisted ----
70 tabs: [defaultTab],
71 activeTabId: defaultTab.id,
72 nextTabNumber: 2,
73 
74 // ---- ephemeral ----
75 reconnectKeys: {},
76 connectionStates: {},
77 isSharedSession: false,
78 
79 // ---- actions ----
80 
81 newTab() {
82 const { nextTabNumber } = get();
83 const tab = createTab(nextTabNumber);
84 set((s) => ({
85 tabs: [...s.tabs, tab],
86 activeTabId: tab.id,
87 nextTabNumber: s.nextTabNumber + 1,
88 }));
89 },
90 
91 closeTab(tabId: string) {
92 const { tabs, activeTabId } = get();
93 
94 // If this is the last tab, replace it with a fresh default
95 if (tabs.length === 1) {
96 const { nextTabNumber } = get();
97 const tab = createTab(nextTabNumber);
98 set((s) => ({
99 tabs: [tab],
100 activeTabId: tab.id,
101 nextTabNumber: s.nextTabNumber + 1,
102 reconnectKeys: {},
103 connectionStates: {},
104 }));
105 return;
106 }
107 
108 // Determine new active tab if we're closing the active one
109 let newActiveId = activeTabId;
110 if (tabId === activeTabId) {
111 const idx = tabs.findIndex((t) => t.id === tabId);
112 // Prefer the tab to the left, fall back to the right
113 const neighbor = tabs[idx - 1] ?? tabs[idx + 1];
114 newActiveId = neighbor.id;
115 }
116 
117 set((s) => {
118 const { [tabId]: _rk, ...reconnectKeys } = s.reconnectKeys;
119 const { [tabId]: _cs, ...connectionStates } = s.connectionStates;
120 return {
121 tabs: s.tabs.filter((t) => t.id !== tabId),
122 activeTabId: newActiveId,
123 reconnectKeys,
124 connectionStates,
125 };
126 });
127 },
128 
129 switchTab(tabId: string) {
130 set({ activeTabId: tabId });
131 },
132 
133 reconnect() {
134 const { activeTabId } = get();
135 set((s) => ({
136 reconnectKeys: {
137 ...s.reconnectKeys,
138 [activeTabId]: (s.reconnectKeys[activeTabId] ?? 0) + 1,
139 },
140 }));
141 },
142 
143 newSession() {
144 const { activeTabId } = get();
145 const newSessionId = makeSessionId();
146 set((s) => ({
147 tabs: s.tabs.map((t) => (t.id === activeTabId ? { ...t, sessionId: newSessionId } : t)),
148 reconnectKeys: {
149 ...s.reconnectKeys,
150 [activeTabId]: (s.reconnectKeys[activeTabId] ?? 0) + 1,
151 },
152 }));
153 },
154 
155 async resetSession() {
156 const { activeTabId, tabs } = get();
157 const activeTab = tabs.find((t) => t.id === activeTabId);
158 if (!activeTab) return;
159 
160 // Delete the backend session
161 try {
162 const params = new URLSearchParams({
163 workspace: "default",
164 session: activeTab.sessionId,
165 });
166 const res = await fetch(`/api/sessions?${params}`, {
167 method: "DELETE",
168 });
169 if (!res.ok && res.status !== 404) {
170 throw new Error(`Failed to reset session (${res.status})`);
171 }
172 } catch (error) {
173 console.error("[ccccocc] reset session:", error);
174 }
175 
176 // Replace with a new session
177 const newSessionId = makeSessionId();
178 set((s) => ({
179 tabs: s.tabs.map((t) => (t.id === activeTabId ? { ...t, sessionId: newSessionId } : t)),
180 reconnectKeys: {
181 ...s.reconnectKeys,
182 [activeTabId]: (s.reconnectKeys[activeTabId] ?? 0) + 1,
183 },
184 }));
185 },
186 
187 setConnectionState(tabId: string, state: SocketState) {
188 set((s) => ({
189 connectionStates: { ...s.connectionStates, [tabId]: state },
190 }));
191 },
192 
193 setSharedSession(shared: boolean) {
194 set({ isSharedSession: shared });
195 },
196 };
197 },
198 {
199 name: "ccccocc:workspace",
200 storage: {
201 getItem: (name) => {
202 const raw = sessionStorage.getItem(name);
203 return raw ? JSON.parse(raw) : null;
204 },
205 setItem: (name, value) => {
206 sessionStorage.setItem(name, JSON.stringify(value));
207 },
208 removeItem: (name) => {
209 sessionStorage.removeItem(name);
210 },
211 },
212 partialize: (state) => ({
213 tabs: state.tabs,
214 activeTabId: state.activeTabId,
215 nextTabNumber: state.nextTabNumber,
216 }),
217 },
218 ),
219);