session_state.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close