1package entity
2
3import "time"
4
5const LegacySessionStateVersion = 1
6
7// SessionStateVersion is the current schema version for session state.
8// Increment when making breaking changes to the serialization format.
9// Version 2: added window-scoped sessions (Windows, ActiveWindowIndex).
10const SessionStateVersion = 2
11
12// SessionState represents a complete snapshot of a browser session.
13// This is serialized to JSON and stored in the database.
14type SessionState struct {
15 Version int `json:"version"`
16 SessionID SessionID `json:"session_id"`
17 Tabs []TabSnapshot `json:"tabs,omitempty"`
18 ActiveTabIndex int `json:"active_tab_index,omitempty"`
19 // v2: window-scoped sessions
20 Windows []WindowSnapshot `json:"windows,omitempty"`
21 ActiveWindowIndex int `json:"active_window_index,omitempty"`
22 SavedAt time.Time `json:"saved_at"`
23}
24
25// TabSnapshot captures the state of a single tab.
26type TabSnapshot struct {
27 ID TabID `json:"id"`
28 Name string `json:"name"`
29 Position int `json:"position"`
30 IsPinned bool `json:"is_pinned"`
31 Workspace WorkspaceSnapshot `json:"workspace"`
32}
33
34// WorkspaceSnapshot captures the pane tree layout.
35type WorkspaceSnapshot struct {
36 ID WorkspaceID `json:"id"`
37 Root *PaneNodeSnapshot `json:"root"`
38 ActivePaneID PaneID `json:"active_pane_id"`
39}
40
41// PaneNodeSnapshot captures a node in the pane tree.
42type PaneNodeSnapshot struct {
43 ID string `json:"id"`
44 Pane *PaneSnapshot `json:"pane,omitempty"` // Non-nil for leaf nodes
45 Children []*PaneNodeSnapshot `json:"children,omitempty"` // Non-nil for containers
46 SplitDir SplitDirection `json:"split_dir"`
47 SplitRatio float64 `json:"split_ratio"`
48 IsStacked bool `json:"is_stacked"`
49 ActiveStackIndex int `json:"active_stack_index"`
50}
51
52// PaneSnapshot captures the essential state of a pane.
53type PaneSnapshot struct {
54 ID PaneID `json:"id"`
55 URI string `json:"uri"`
56 Title string `json:"title"`
57 ZoomFactor float64 `json:"zoom_factor"`
58}
59
60// SnapshotFromTabList creates a SessionState from a live TabList.
61func SnapshotFromTabList(sessionID SessionID, tabs *TabList) *SessionState {
62 if tabs == nil {
63 return &SessionState{
64 Version: LegacySessionStateVersion,
65 SessionID: sessionID,
66 Tabs: []TabSnapshot{},
67 SavedAt: time.Now(),
68 }
69 }
70
71 snapTabs := make([]TabSnapshot, 0, len(tabs.Tabs))
72 activeTabIndex := 0
73
74 for i, tab := range tabs.Tabs {
75 if tab.ID == tabs.ActiveTabID {
76 activeTabIndex = i
77 }
78 snapTabs = append(snapTabs, snapshotTab(tab))
79 }
80
81 return &SessionState{
82 Version: LegacySessionStateVersion,
83 SessionID: sessionID,
84 Tabs: snapTabs,
85 ActiveTabIndex: activeTabIndex,
86 SavedAt: time.Now(),
87 }
88}
89
90func snapshotTab(tab *Tab) TabSnapshot {
91 return TabSnapshot{
92 ID: tab.ID,
93 Name: tab.Name,
94 Position: tab.Position,
95 IsPinned: tab.IsPinned,
96 Workspace: snapshotWorkspace(tab.Workspace),
97 }
98}
99
100func snapshotWorkspace(ws *Workspace) WorkspaceSnapshot {
101 if ws == nil {
102 return WorkspaceSnapshot{}
103 }
104 return WorkspaceSnapshot{
105 ID: ws.ID,
106 Root: snapshotPaneNode(ws.Root),
107 ActivePaneID: ws.ActivePaneID,
108 }
109}
110
111func snapshotPaneNode(node *PaneNode) *PaneNodeSnapshot {
112 if node == nil {
113 return nil
114 }
115
116 snapshot := &PaneNodeSnapshot{
117 ID: node.ID,
118 SplitDir: node.SplitDir,
119 SplitRatio: node.SplitRatio,
120 IsStacked: node.IsStacked,
121 ActiveStackIndex: node.ActiveStackIndex,
122 }
123
124 if node.Pane != nil {
125 snapshot.Pane = &PaneSnapshot{
126 ID: node.Pane.ID,
127 URI: node.Pane.URI,
128 Title: node.Pane.Title,
129 ZoomFactor: node.Pane.ZoomFactor,
130 }
131 }
132
133 if len(node.Children) > 0 {
134 snapshot.Children = make([]*PaneNodeSnapshot, 0, len(node.Children))
135 for _, child := range node.Children {
136 snapshot.Children = append(snapshot.Children, snapshotPaneNode(child))
137 }
138 }
139
140 return snapshot
141}
142
143// SessionInfo provides summary information for the session manager UI.
144type SessionInfo struct {
145 Session *Session
146 State *SessionState
147 TabCount int
148 PaneCount int
149 IsActive bool // Has active lock file
150 IsCurrent bool // Is the current session
151 UpdatedAt time.Time // When the state was last saved
152}
153
154// CountPanes returns the total number of panes in the session state.
155func (s *SessionState) CountPanes() int {
156 // v2: count across windows
157 if s.Version >= SessionStateVersion {
158 count := 0
159 for _, w := range s.Windows {
160 for _, tab := range w.Tabs {
161 count += countPanesInNode(tab.Workspace.Root)
162 }
163 }
164 return count
165 }
166 // v1: legacy flat tabs
167 count := 0
168 for _, tab := range s.Tabs {
169 count += countPanesInNode(tab.Workspace.Root)
170 }
171 return count
172}
173
174func countPanesInNode(node *PaneNodeSnapshot) int {
175 if node == nil {
176 return 0
177 }
178 if node.Pane != nil {
179 return 1
180 }
181 count := 0
182 for _, child := range node.Children {
183 count += countPanesInNode(child)
184 }
185 return count
186}
187
188// IDGenerator is a function that generates unique IDs.
189type IDGenerator func() string
190
191// tabListFromSnapshots converts a slice of TabSnapshots to a live TabList.
192// activeIndex is the index in snapshots that should become the active tab.
193func tabListFromSnapshots(snaps []TabSnapshot, activeIndex int, idGen IDGenerator) *TabList {
194 tabs := NewTabList()
195
196 for i, tabSnap := range snaps {
197 tab := tabFromSnapshot(&tabSnap, idGen)
198 if tab == nil {
199 continue
200 }
201 tabs.Tabs = append(tabs.Tabs, tab)
202 tab.Position = len(tabs.Tabs) - 1
203
204 if i == activeIndex {
205 tabs.ActiveTabID = tab.ID
206 }
207 }
208
209 // Ensure we have an active tab
210 if tabs.ActiveTabID == "" && len(tabs.Tabs) > 0 {
211 tabs.ActiveTabID = tabs.Tabs[0].ID
212 }
213
214 return tabs
215}
216
217// TabListFromSnapshot reconstructs a TabList from a SessionState snapshot.
218// Generates new IDs for all entities using the provided generator.
219// This is the inverse of SnapshotFromTabList.
220func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList {
221 if state == nil {
222 return NewTabList()
223 }
224 if state.Version >= SessionStateVersion {
225 tabs, activeIndex := flattenWindowTabSnapshots(state.Windows, state.ActiveWindowIndex)
226 return tabListFromSnapshots(tabs, activeIndex, idGen)
227 }
228
229 return tabListFromSnapshots(state.Tabs, state.ActiveTabIndex, idGen)
230}
231
232func flattenWindowTabSnapshots(windows []WindowSnapshot, activeWindowIndex int) ([]TabSnapshot, int) {
233 if activeWindowIndex < 0 || activeWindowIndex >= len(windows) {
234 activeWindowIndex = 0
235 }
236 flattened := make([]TabSnapshot, 0)
237 activeTabIndex := 0
238 for i, win := range windows {
239 if i == activeWindowIndex {
240 if len(win.Tabs) == 0 {
241 // Active window has zero tabs; activeTabIndex stays at 0
242 // (first tab overall if any), not len(flattened).
243 } else {
244 winActiveIndex := win.ActiveTabIndex
245 if winActiveIndex < 0 || winActiveIndex >= len(win.Tabs) {
246 winActiveIndex = 0
247 }
248 activeTabIndex = len(flattened) + winActiveIndex
249 }
250 }
251 flattened = append(flattened, win.Tabs...)
252 }
253 return flattened, activeTabIndex
254}
255
256func tabFromSnapshot(snap *TabSnapshot, idGen IDGenerator) *Tab {
257 if snap == nil {
258 return nil
259 }
260
261 ws := workspaceFromSnapshot(&snap.Workspace, idGen)
262 if ws == nil {
263 return nil
264 }
265
266 return &Tab{
267 ID: TabID(idGen()),
268 Name: snap.Name,
269 Workspace: ws,
270 Position: snap.Position,
271 IsPinned: snap.IsPinned,
272 CreatedAt: time.Now(),
273 }
274}
275
276func workspaceFromSnapshot(snap *WorkspaceSnapshot, idGen IDGenerator) *Workspace {
277 if snap == nil {
278 return nil
279 }
280
281 root := paneNodeFromSnapshot(snap.Root, nil, idGen)
282 if root == nil {
283 return nil
284 }
285
286 // Find the active pane ID in the restored tree
287 // We need to map the snapshot's ActivePaneID to the new pane ID
288 var activePaneID PaneID
289 root.Walk(func(node *PaneNode) bool {
290 if node.Pane != nil {
291 // Use the first pane as active if we can't find the original
292 if activePaneID == "" {
293 activePaneID = node.Pane.ID
294 }
295 }
296 return true
297 })
298
299 return &Workspace{
300 ID: WorkspaceID(idGen()),
301 Root: root,
302 ActivePaneID: activePaneID,
303 CreatedAt: time.Now(),
304 }
305}
306
307func paneNodeFromSnapshot(snap *PaneNodeSnapshot, parent *PaneNode, idGen IDGenerator) *PaneNode {
308 if snap == nil {
309 return nil
310 }
311
312 node := &PaneNode{
313 ID: idGen(),
314 Parent: parent,
315 SplitDir: snap.SplitDir,
316 SplitRatio: snap.SplitRatio,
317 IsStacked: snap.IsStacked,
318 ActiveStackIndex: snap.ActiveStackIndex,
319 }
320
321 // Restore pane if this is a leaf node
322 if snap.Pane != nil {
323 node.Pane = paneFromSnapshot(snap.Pane, idGen)
324 }
325
326 // Restore children recursively
327 if len(snap.Children) > 0 {
328 node.Children = make([]*PaneNode, 0, len(snap.Children))
329 for _, childSnap := range snap.Children {
330 child := paneNodeFromSnapshot(childSnap, node, idGen)
331 if child != nil {
332 node.Children = append(node.Children, child)
333 }
334 }
335 }
336
337 return node
338}
339
340func paneFromSnapshot(snap *PaneSnapshot, idGen IDGenerator) *Pane {
341 if snap == nil {
342 return nil
343 }
344
345 return &Pane{
346 ID: PaneID(idGen()),
347 URI: snap.URI,
348 Title: snap.Title,
349 ZoomFactor: snap.ZoomFactor,
350 WindowType: WindowMain,
351 CreatedAt: time.Now(),
352 }
353}