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