session_manager.go RawHistoryFolder

1package component
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"sync"
8
9	"github.com/bnema/dumber/internal/application/port"
10	"github.com/bnema/dumber/internal/application/usecase"
11	"github.com/bnema/dumber/internal/domain/entity"
12	"github.com/bnema/dumber/internal/logging"
13	"github.com/bnema/dumber/internal/ui/layout"
14	"github.com/bnema/puregotk/v4/gdk"
15	"github.com/bnema/puregotk/v4/glib"
16	"github.com/bnema/puregotk/v4/gtk"
17)
18
19const (
20	sessionIDMaxDisplay  = 20
21	sessionRowBoxSpacing = 8
22	sessionMaxTitleLen   = 45 // Max chars for pane title display
23)
24
25// SessionManager is a modal overlay for managing sessions.
26type SessionManager struct {
27	// GTK widgets
28	outerBox       *gtk.Box
29	mainBox        *gtk.Box
30	headerBox      *gtk.Box
31	titleLabel     *gtk.Label
32	scrolledWindow *gtk.ScrolledWindow
33	listBox        *gtk.ListBox
34	footerLabel    *gtk.Label
35
36	// Parent overlay reference for sizing
37	parentOverlay layout.OverlayWidget
38
39	// State
40	mu               sync.RWMutex
41	visible          bool
42	sessions         []entity.SessionInfo
43	selectedIndex    int   // Index into sessions slice
44	expandedIndex    int   // -1 means none expanded
45	sessionToListRow []int // Maps session index to list row index
46	listRowToSession []int // Maps list row index to session index (-1 for non-session rows)
47	populatingList   bool  // True while rebuilding list, to ignore selection callbacks
48
49	// Dependencies
50	listSessionsUC  *usecase.ListSessionsUseCase
51	deleteSessionUC *usecase.DeleteSessionUseCase
52	spawner         port.SessionSpawner
53	currentSession  entity.SessionID
54
55	// Callbacks
56	onClose func()
57	onOpen  func(sessionID entity.SessionID)
58	onToast func(ctx context.Context, message string, level ToastLevel)
59
60	// GTK callback retention
61	retainedCallbacks []any
62
63	// Scaling
64	uiScale float64
65
66	// Cached measurements (populated after first layout)
67	measuredHeights struct {
68		sessionRow int  // Session row natural height
69		treeRow    int  // Tree row natural height
70		dividerRow int  // Divider row natural height
71		valid      bool // Whether cache is valid
72	}
73
74	ctx context.Context
75}
76
77// SessionManagerConfig holds configuration for creating a SessionManager.
78type SessionManagerConfig struct {
79	ListSessionsUC  *usecase.ListSessionsUseCase
80	DeleteSessionUC *usecase.DeleteSessionUseCase
81	Spawner         port.SessionSpawner
82	CurrentSession  entity.SessionID
83	UIScale         float64
84	OnClose         func()
85	OnOpen          func(sessionID entity.SessionID)
86	OnToast         func(ctx context.Context, message string, level ToastLevel)
87}
88
89// NewSessionManager creates a new SessionManager component.
90func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionManager {
91	log := logging.FromContext(ctx)
92
93	uiScale := normalizeUIScale(cfg.UIScale)
94
95	sm := &SessionManager{
96		ctx:             ctx,
97		listSessionsUC:  cfg.ListSessionsUC,
98		deleteSessionUC: cfg.DeleteSessionUC,
99		spawner:         cfg.Spawner,
100		currentSession:  cfg.CurrentSession,
101		onClose:         cfg.OnClose,
102		onOpen:          cfg.OnOpen,
103		onToast:         cfg.OnToast,
104		selectedIndex:   -1,
105		expandedIndex:   -1,
106		uiScale:         uiScale,
107	}
108
109	if err := sm.createWidgets(); err != nil {
110		log.Error().Err(err).Msg("failed to create session manager widgets")
111		return nil
112	}
113
114	sm.attachKeyController()
115
116	log.Debug().Msg("session manager created")
117	return sm
118}
119
120// SetParentOverlay sets the overlay widget used for sizing calculations.
121// Must be called before Show().
122func (sm *SessionManager) SetParentOverlay(overlay layout.OverlayWidget) {
123	sm.mu.Lock()
124	defer sm.mu.Unlock()
125	sm.parentOverlay = overlay
126}
127
128// WidgetAsLayout returns the session manager's outer widget as a layout.Widget.
129func (sm *SessionManager) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
130	if sm.outerBox == nil {
131		return nil
132	}
133	return factory.WrapWidget(&sm.outerBox.Widget)
134}
135
136// Widget returns the session manager widget for embedding in an overlay.
137func (sm *SessionManager) Widget() *gtk.Widget {
138	if sm.outerBox == nil {
139		return nil
140	}
141	return &sm.outerBox.Widget
142}
143
144// Show displays the session manager and loads sessions.
145func (sm *SessionManager) Show(ctx context.Context) {
146	log := logging.FromContext(ctx)
147	log.Debug().Msg("showing session manager")
148
149	sm.mu.Lock()
150	if sm.visible {
151		sm.mu.Unlock()
152		return
153	}
154	sm.visible = true
155	sm.mu.Unlock()
156
157	// Calculate size and position
158	sm.resizeAndCenter()
159
160	// Show the widget
161	sm.outerBox.SetVisible(true)
162
163	// Load sessions asynchronously
164	go sm.loadSessions()
165
166	// Focus the list
167	if sm.listBox != nil {
168		sm.listBox.GrabFocus()
169	}
170}
171
172// Hide hides the session manager.
173func (sm *SessionManager) Hide(ctx context.Context) {
174	log := logging.FromContext(ctx)
175	log.Debug().Msg("hiding session manager")
176
177	sm.mu.Lock()
178	if !sm.visible {
179		sm.mu.Unlock()
180		return
181	}
182	sm.visible = false
183	sm.mu.Unlock()
184
185	sm.outerBox.SetVisible(false)
186
187	// Clear state
188	sm.listBox.RemoveAll()
189
190	if sm.onClose != nil {
191		sm.onClose()
192	}
193}
194
195// IsVisible returns whether the session manager is visible.
196func (sm *SessionManager) IsVisible() bool {
197	sm.mu.RLock()
198	defer sm.mu.RUnlock()
199	return sm.visible
200}
201
202// Toggle shows if hidden, hides if visible.
203func (sm *SessionManager) Toggle(ctx context.Context) {
204	sm.mu.RLock()
205	visible := sm.visible
206	sm.mu.RUnlock()
207
208	if visible {
209		sm.Hide(ctx)
210	} else {
211		sm.Show(ctx)
212	}
213}
214
215// SetUIScale updates modal geometry on the GTK main thread.
216func (sm *SessionManager) SetUIScale(scale float64) {
217	scale = normalizeUIScale(scale)
218	if sm.uiScale == scale {
219		return
220	}
221	sm.uiScale = scale
222	sm.measuredHeights.valid = false
223	sm.resizeAndCenter()
224	if sm.IsVisible() {
225		sm.populateList()
226	}
227}
228
229// requestedDimensions returns the scale-aware modal width and top margin for
230// the current parent overlay, session size defaults, and UI scale.
231func (sm *SessionManager) requestedDimensions() (width, marginTop int) {
232	return CalculateModalDimensionsWithScale(sm.parentOverlay, SessionManagerSizeDefaults, sm.uiScale)
233}
234
235func (sm *SessionManager) resizeAndCenter() {
236	if sm.outerBox == nil || sm.mainBox == nil {
237		return
238	}
239
240	width, marginTop := sm.requestedDimensions()
241
242	sm.mainBox.SetSizeRequest(width, -1)
243	sm.outerBox.SetMarginTop(marginTop)
244}
245
246func (sm *SessionManager) createWidgets() error {
247	// Outer container for positioning
248	sm.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
249	if sm.outerBox == nil {
250		return errNilWidget("sessionOuterBox")
251	}
252	sm.outerBox.AddCssClass("session-manager-outer")
253	sm.outerBox.SetHalign(gtk.AlignCenterValue)
254	sm.outerBox.SetValign(gtk.AlignStartValue)
255	sm.outerBox.SetVisible(false)
256
257	// Main container with styling
258	sm.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
259	if sm.mainBox == nil {
260		return errNilWidget("sessionMainBox")
261	}
262	sm.mainBox.AddCssClass("session-manager-container")
263
264	// Header
265	if err := sm.initHeader(); err != nil {
266		return err
267	}
268
269	// Scrolled window for list
270	if err := sm.initList(); err != nil {
271		return err
272	}
273
274	// Footer with shortcuts
275	if err := sm.initFooter(); err != nil {
276		return err
277	}
278
279	// Assemble
280	sm.mainBox.Append(&sm.headerBox.Widget)
281	sm.mainBox.Append(&sm.scrolledWindow.Widget)
282	sm.mainBox.Append(&sm.footerLabel.Widget)
283	sm.outerBox.Append(&sm.mainBox.Widget)
284
285	return nil
286}
287
288func (sm *SessionManager) initHeader() error {
289	sm.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
290	if sm.headerBox == nil {
291		return errNilWidget("sessionHeaderBox")
292	}
293	sm.headerBox.AddCssClass("session-manager-header")
294
295	title := "Sessions"
296	sm.titleLabel = gtk.NewLabel(&title)
297	if sm.titleLabel == nil {
298		return errNilWidget("sessionTitleLabel")
299	}
300	sm.titleLabel.AddCssClass("session-manager-title")
301	sm.titleLabel.SetHalign(gtk.AlignStartValue)
302	sm.titleLabel.SetHexpand(true)
303	sm.headerBox.Append(&sm.titleLabel.Widget)
304
305	// Shortcut badge
306	shortcutText := "Ctrl+O"
307	shortcutLabel := gtk.NewLabel(&shortcutText)
308	if shortcutLabel != nil {
309		shortcutLabel.AddCssClass("omnibox-shortcut-badge")
310		sm.headerBox.Append(&shortcutLabel.Widget)
311	}
312
313	return nil
314}
315
316func (sm *SessionManager) initList() error {
317	sm.scrolledWindow = gtk.NewScrolledWindow()
318	if sm.scrolledWindow == nil {
319		return errNilWidget("sessionScrolledWindow")
320	}
321	sm.scrolledWindow.AddCssClass("session-manager-scrolled")
322	sm.scrolledWindow.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
323	// Don't propagate natural height - we control it via resizeForContent
324	sm.scrolledWindow.SetPropagateNaturalHeight(false)
325
326	sm.listBox = gtk.NewListBox()
327	if sm.listBox == nil {
328		return errNilWidget("sessionListBox")
329	}
330	sm.listBox.AddCssClass("session-manager-list")
331	sm.listBox.SetSelectionMode(gtk.SelectionSingleValue)
332
333	// Connect row selection - map list row index to session index
334	rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
335		row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
336		if row == nil {
337			return
338		}
339		listRowIdx := row.GetIndex()
340		sm.mu.Lock()
341		// Ignore selection changes while rebuilding the list
342		if sm.populatingList {
343			sm.mu.Unlock()
344			return
345		}
346		if listRowIdx >= 0 && listRowIdx < len(sm.listRowToSession) {
347			sessionIdx := sm.listRowToSession[listRowIdx]
348			if sessionIdx >= 0 {
349				sm.selectedIndex = sessionIdx
350			}
351		}
352		sm.mu.Unlock()
353	}
354	sm.retainedCallbacks = append(sm.retainedCallbacks, rowSelectedCb)
355	sm.listBox.ConnectRowSelected(&rowSelectedCb)
356
357	sm.scrolledWindow.SetChild(&sm.listBox.Widget)
358	return nil
359}
360
361func (sm *SessionManager) initFooter() error {
362	footerText := "↑↓/jk navigate  Enter expand  r restore  x delete  Esc close"
363	sm.footerLabel = gtk.NewLabel(&footerText)
364	if sm.footerLabel == nil {
365		return errNilWidget("sessionFooterLabel")
366	}
367	sm.footerLabel.AddCssClass("session-manager-footer")
368	sm.footerLabel.SetHalign(gtk.AlignCenterValue)
369	return nil
370}
371
372func (sm *SessionManager) attachKeyController() {
373	controller := gtk.NewEventControllerKey()
374	if controller == nil {
375		return
376	}
377	controller.SetPropagationPhase(gtk.PhaseCaptureValue)
378
379	keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
380		return sm.handleKeyPress(keyval, state)
381	}
382	sm.retainedCallbacks = append(sm.retainedCallbacks, keyPressedCb)
383	controller.ConnectKeyPressed(&keyPressedCb)
384	sm.outerBox.AddController(&controller.EventController)
385}
386
387func (sm *SessionManager) handleKeyPress(keyval uint, _ gdk.ModifierType) bool {
388	switch keyval {
389	case uint(gdk.KEY_Escape):
390		sm.Hide(sm.ctx)
391		return true
392
393	case uint(gdk.KEY_Up), uint(gdk.KEY_k):
394		sm.selectPrevious()
395		return true
396
397	case uint(gdk.KEY_Down), uint(gdk.KEY_j):
398		sm.selectNext()
399		return true
400
401	case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter), uint(gdk.KEY_space):
402		// Enter/Space expands/collapses to preview session contents
403		sm.toggleExpanded()
404		return true
405
406	case uint(gdk.KEY_r), uint(gdk.KEY_o):
407		// r/o restores the selected session
408		sm.openSelected()
409		return true
410
411	case uint(gdk.KEY_x), uint(gdk.KEY_d), uint(gdk.KEY_Delete):
412		sm.deleteSelected()
413		return true
414	}
415	return false
416}
417
418func (sm *SessionManager) loadSessions() {
419	log := logging.FromContext(sm.ctx)
420
421	if sm.listSessionsUC == nil {
422		log.Warn().Msg("listSessionsUC is nil")
423		return
424	}
425
426	output, err := sm.listSessionsUC.Execute(sm.ctx, sm.currentSession, SessionManagerListDefaults.MaxResults)
427	if err != nil {
428		log.Error().Err(err).Msg("failed to list sessions")
429		return
430	}
431
432	sm.mu.Lock()
433	sm.sessions = output.Sessions
434	sm.selectedIndex = 0
435	sm.mu.Unlock()
436
437	// Update UI on main thread
438	cb := glib.SourceFunc(func(_ uintptr) bool {
439		sm.populateList()
440		return false
441	})
442	glib.IdleAdd(&cb, 0)
443}
444
445// populateListState holds state during list population.
446type populateListState struct {
447	listRowToSession []int
448	listRowCount     int
449	sessionRowCount  int
450	treeRowCount     int
451	dividerCount     int
452	expandedIdx      int
453	firstExitedIdx   int
454	hasActive        bool
455}
456
457func (sm *SessionManager) populateList() {
458	sm.mu.Lock()
459	sessions := sm.sessions
460	selectedIdx := sm.selectedIndex
461	sm.populatingList = true
462	sm.sessionToListRow = make([]int, len(sessions))
463	sm.listRowToSession = nil
464	expandedIdx := sm.expandedIndex
465	sm.mu.Unlock()
466
467	if sm.listBox == nil {
468		sm.mu.Lock()
469		sm.populatingList = false
470		sm.mu.Unlock()
471		return
472	}
473
474	sm.listBox.RemoveAll()
475
476	state := &populateListState{expandedIdx: expandedIdx, firstExitedIdx: -1}
477	for i, info := range sessions {
478		if info.IsCurrent || info.IsActive {
479			state.hasActive = true
480		} else if state.firstExitedIdx == -1 {
481			state.firstExitedIdx = i
482		}
483	}
484
485	for i, info := range sessions {
486		sm.addSessionToList(i, info, state)
487	}
488
489	sm.mu.Lock()
490	sm.listRowToSession = state.listRowToSession
491	sm.populatingList = false
492	sm.mu.Unlock()
493
494	sm.selectBySessionIndex(selectedIdx)
495
496	// Attempt to measure actual widget heights for accurate sizing
497	width, _ := sm.requestedDimensions()
498	sm.measureWidgetHeights(width)
499
500	sm.resizeForContent(state.sessionRowCount, state.dividerCount, state.treeRowCount)
501}
502
503func (sm *SessionManager) addSessionToList(i int, info entity.SessionInfo, state *populateListState) {
504	// Add divider before first exited session
505	if i == state.firstExitedIdx && state.hasActive {
506		if divider := sm.createDividerRow("EXITED"); divider != nil {
507			sm.listBox.Append(&divider.Widget)
508			state.listRowToSession = append(state.listRowToSession, -1)
509			state.listRowCount++
510			state.dividerCount++
511		}
512	}
513
514	sm.mu.Lock()
515	sm.sessionToListRow[i] = state.listRowCount
516	sm.mu.Unlock()
517	state.listRowToSession = append(state.listRowToSession, i)
518
519	if row := sm.createSessionRow(info); row != nil {
520		sm.listBox.Append(&row.Widget)
521		state.listRowCount++
522		state.sessionRowCount++
523	}
524
525	if i == state.expandedIdx && info.State != nil {
526		for _, treeRow := range sm.createTreeRows(info.State) {
527			sm.listBox.Append(&treeRow.Widget)
528			state.listRowToSession = append(state.listRowToSession, -1)
529			state.listRowCount++
530			state.treeRowCount++
531		}
532	}
533}
534
535func (sm *SessionManager) selectBySessionIndex(sessionIdx int) {
536	sm.mu.RLock()
537	if sessionIdx < 0 || sessionIdx >= len(sm.sessionToListRow) {
538		sm.mu.RUnlock()
539		return
540	}
541	listRow := sm.sessionToListRow[sessionIdx]
542	sm.mu.RUnlock()
543
544	row := sm.listBox.GetRowAtIndex(listRow)
545	if row == nil {
546		return
547	}
548
549	sm.listBox.SelectRow(row)
550
551	// Scroll the row into view using GrabFocus which triggers scroll
552	row.GrabFocus()
553}
554
555func (sm *SessionManager) resizeForContent(sessionRowCount, dividerCount, treeRowCount int) {
556	if sm.scrolledWindow == nil {
557		return
558	}
559
560	// Get row heights - use measured values if available, otherwise scale defaults
561	var sessionH, treeH, dividerH int
562	if sm.measuredHeights.valid {
563		sessionH = sm.measuredHeights.sessionRow
564		treeH = sm.measuredHeights.treeRow
565		dividerH = sm.measuredHeights.dividerRow
566	} else {
567		sessionH = ScaleValue(DefaultRowHeights.Standard, sm.uiScale)
568		treeH = ScaleValue(DefaultRowHeights.Compact, sm.uiScale)
569		dividerH = ScaleValue(DefaultRowHeights.Divider, sm.uiScale)
570	}
571
572	// Calculate actual content height
573	contentHeight := sessionRowCount*sessionH + dividerCount*dividerH + treeRowCount*treeH
574
575	// Calculate max visible height - limit to prevent oversized modal
576	maxVisibleHeight := SessionManagerListDefaults.MaxVisibleRows*sessionH + dividerH
577
578	// Cap at max visible height - rest will be scrollable
579	if contentHeight > maxVisibleHeight {
580		contentHeight = maxVisibleHeight
581	}
582
583	// Ensure minimum height for at least one row
584	if contentHeight < sessionH {
585		contentHeight = sessionH
586	}
587
588	// Schedule resize on main thread using shared helper
589	cb := glib.SourceFunc(func(_ uintptr) bool {
590		SetScrolledWindowHeight(sm.scrolledWindow, contentHeight)
591		sm.outerBox.QueueResize()
592		return false
593	})
594	glib.IdleAdd(&cb, 0)
595}
596
597// measureWidgetHeights attempts to measure actual row heights from the list.
598// Must be called after list is populated. Updates measuredHeights cache if successful.
599func (sm *SessionManager) measureWidgetHeights(forWidth int) {
600	if sm.listBox == nil || sm.measuredHeights.valid {
601		return
602	}
603
604	// We need to find different row types and measure them.
605	// Session rows have "session-row" class, tree rows have "session-tree-row" class,
606	// divider rows have "session-divider" class.
607
608	// For now, measure using first row and estimate others based on ratio.
609	// This is simpler and works well enough since we have fallback scaling.
610	row := sm.listBox.GetRowAtIndex(0)
611	if row == nil {
612		return
613	}
614
615	sessionH := MeasureWidgetHeight(&row.Widget, forWidth)
616	if sessionH <= 0 {
617		return
618	}
619
620	// Estimate tree and divider heights based on ratio to standard row
621	// These ratios match the original constants: tree=28/50, divider=30/50
622	treeH := sessionH * DefaultRowHeights.Compact / DefaultRowHeights.Standard
623	dividerH := sessionH * DefaultRowHeights.Divider / DefaultRowHeights.Standard
624
625	sm.measuredHeights.sessionRow = sessionH
626	sm.measuredHeights.treeRow = treeH
627	sm.measuredHeights.dividerRow = dividerH
628	sm.measuredHeights.valid = true
629}
630
631// createTreeRows creates tree rows showing the tab/pane structure of a session.
632func (sm *SessionManager) createTreeRows(state *entity.SessionState) []*gtk.ListBoxRow {
633	if state == nil {
634		return nil
635	}
636
637	var rows []*gtk.ListBoxRow
638
639	for tabIdx, tab := range state.Tabs {
640		isLastTab := tabIdx == len(state.Tabs)-1
641
642		// Tab row
643		tabRow := sm.createTreeRow(tab.Name, "tab", !isLastTab, 0)
644		if tabRow != nil {
645			rows = append(rows, tabRow)
646		}
647
648		// Pane rows under this tab
649		paneRows := sm.createPaneTreeRows(&tab.Workspace)
650		rows = append(rows, paneRows...)
651	}
652
653	return rows
654}
655
656// createPaneTreeRows creates rows for the pane tree under a tab.
657func (sm *SessionManager) createPaneTreeRows(ws *entity.WorkspaceSnapshot) []*gtk.ListBoxRow {
658	if ws == nil || ws.Root == nil {
659		return nil
660	}
661
662	var rows []*gtk.ListBoxRow
663	sm.collectPaneRows(&rows, ws.Root, 1, true)
664	return rows
665}
666
667// collectPaneRows recursively collects pane tree rows.
668func (sm *SessionManager) collectPaneRows(
669	rows *[]*gtk.ListBoxRow,
670	node *entity.PaneNodeSnapshot,
671	depth int,
672	isLast bool,
673) {
674	if node == nil {
675		return
676	}
677
678	if node.Pane != nil {
679		// Leaf node (actual pane)
680		title := node.Pane.Title
681		if title == "" {
682			title = node.Pane.URI
683		}
684		if len(title) > sessionMaxTitleLen {
685			title = title[:sessionMaxTitleLen-3] + "..."
686		}
687
688		row := sm.createTreeRow(title, "pane", !isLast, depth)
689		if row != nil {
690			*rows = append(*rows, row)
691		}
692	} else if len(node.Children) > 0 {
693		// Container node (split)
694		splitLabel := "split"
695		if node.IsStacked {
696			splitLabel = "stacked"
697		} else if node.SplitRatio > 0 {
698			splitLabel = fmt.Sprintf("split %.0f%%", node.SplitRatio*100)
699		}
700
701		row := sm.createTreeRow(splitLabel, "split", !isLast, depth)
702		if row != nil {
703			*rows = append(*rows, row)
704		}
705
706		// Recurse into children
707		for i, child := range node.Children {
708			isLastChild := i == len(node.Children)-1
709			sm.collectPaneRows(rows, child, depth+1, isLastChild)
710		}
711	}
712}
713
714// createTreeRow creates a single tree row with proper indentation and branch characters.
715func (sm *SessionManager) createTreeRow(text, nodeType string, hasSibling bool, depth int) *gtk.ListBoxRow {
716	row := gtk.NewListBoxRow()
717	if row == nil {
718		return nil
719	}
720	row.SetSelectable(false)
721	row.SetActivatable(false)
722	row.AddCssClass("session-tree-row")
723
724	hbox := gtk.NewBox(gtk.OrientationHorizontalValue, 4)
725	if hbox == nil {
726		return nil
727	}
728	hbox.AddCssClass("session-tree-content")
729
730	// Build indent and branch prefix
731	var prefix string
732	var indent strings.Builder
733	indent.WriteString("  ") // Base indent (minimal, tree branches provide visual alignment)
734	for range depth {
735		indent.WriteString("   ") // 3 spaces per depth level
736	}
737
738	// Branch character
739	if hasSibling {
740		prefix = "├── "
741	} else {
742		prefix = "└── "
743	}
744
745	// Icon based on node type
746	var icon string
747	switch nodeType {
748	case "tab":
749		icon = "󰓩 " // Tab icon (nerd font)
750	case "pane":
751		icon = "󰕮 " // Pane icon
752	case "split":
753		icon = "󰯍 " // Split icon
754	default:
755		icon = "  "
756	}
757
758	// Create the label with all parts
759	labelText := indent.String() + prefix + icon + text
760	label := gtk.NewLabel(&labelText)
761	if label == nil {
762		return nil
763	}
764	label.AddCssClass("session-tree-label")
765	label.SetHalign(gtk.AlignStartValue)
766	label.SetEllipsize(2) // PANGO_ELLIPSIZE_END
767
768	hbox.Append(&label.Widget)
769	row.SetChild(&hbox.Widget)
770
771	return row
772}
773
774func (sm *SessionManager) createDividerRow(text string) *gtk.ListBoxRow {
775	row := gtk.NewListBoxRow()
776	if row == nil {
777		return nil
778	}
779	row.SetSelectable(false)
780	row.SetActivatable(false)
781
782	label := gtk.NewLabel(&text)
783	if label == nil {
784		return nil
785	}
786	label.AddCssClass("session-divider")
787	label.SetHalign(gtk.AlignStartValue)
788	row.SetChild(&label.Widget)
789
790	return row
791}
792
793func (sm *SessionManager) createSessionRow(info entity.SessionInfo) *gtk.ListBoxRow {
794	row := gtk.NewListBoxRow()
795	if row == nil {
796		return nil
797	}
798	row.AddCssClass("session-manager-row-wrapper")
799
800	hbox := gtk.NewBox(gtk.OrientationHorizontalValue, sessionRowBoxSpacing)
801	if hbox == nil {
802		return nil
803	}
804	hbox.AddCssClass("session-manager-row")
805
806	sm.addStatusIndicator(hbox, info)
807	sm.addSessionInfo(hbox, info)
808	sm.addRelativeTime(hbox, info)
809
810	row.SetChild(&hbox.Widget)
811	return row
812}
813
814func (sm *SessionManager) addStatusIndicator(hbox *gtk.Box, info entity.SessionInfo) {
815	var statusText string
816	switch {
817	case info.IsCurrent:
818		statusText = "●" // Filled dot for current
819		hbox.AddCssClass("session-current")
820	case info.IsActive:
821		statusText = "○" // Empty dot for other active instance
822		hbox.AddCssClass("session-active")
823	default:
824		statusText = "◼" // Small square for exited (like stop icon)
825		hbox.AddCssClass("session-exited")
826	}
827	statusLabel := gtk.NewLabel(&statusText)
828	if statusLabel != nil {
829		statusLabel.AddCssClass("session-status")
830		hbox.Append(&statusLabel.Widget)
831	}
832}
833
834func (sm *SessionManager) addSessionInfo(hbox *gtk.Box, info entity.SessionInfo) {
835	const infoBoxSpacing = 2
836	infoBox := gtk.NewBox(gtk.OrientationVerticalValue, infoBoxSpacing)
837	if infoBox == nil {
838		return
839	}
840	infoBox.SetHexpand(true)
841
842	// Session ID row: shortID (accent) + rest of ID (dimmed) + optional (current) badge
843	idRowBox := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
844	if idRowBox != nil {
845		idRowBox.SetHalign(gtk.AlignStartValue)
846
847		fullID := string(info.Session.ID)
848		shortID := info.Session.ShortID() // Last 4 chars
849
850		// Short ID with accent color (displayed first)
851		shortLabel := gtk.NewLabel(&shortID)
852		if shortLabel != nil {
853			shortLabel.AddCssClass("session-id-short")
854			idRowBox.Append(&shortLabel.Widget)
855		}
856
857		// Rest of the ID (prefix before the short suffix), dimmed
858		// Format: "20251225_083026_7566" -> shortID="7566", prefix="20251225_083026"
859		// Remove trailing underscore for cleaner display
860		if len(fullID) > 4 {
861			prefix := fullID[:len(fullID)-4]
862			// Trim trailing underscore if present
863			prefix = strings.TrimSuffix(prefix, "_")
864			prefix = " " + prefix
865			prefixLabel := gtk.NewLabel(&prefix)
866			if prefixLabel != nil {
867				prefixLabel.AddCssClass("session-id-prefix")
868				idRowBox.Append(&prefixLabel.Widget)
869			}
870		}
871
872		// Current badge
873		if info.IsCurrent {
874			badge := " (current)"
875			badgeLabel := gtk.NewLabel(&badge)
876			if badgeLabel != nil {
877				badgeLabel.AddCssClass("session-id-badge")
878				idRowBox.Append(&badgeLabel.Widget)
879			}
880		}
881
882		infoBox.Append(&idRowBox.Widget)
883	}
884
885	// Tab/pane count
886	countText := fmt.Sprintf("%d tabs, %d panes", info.TabCount, info.PaneCount)
887	countLabel := gtk.NewLabel(&countText)
888	if countLabel != nil {
889		countLabel.AddCssClass("session-count")
890		countLabel.SetHalign(gtk.AlignStartValue)
891		infoBox.Append(&countLabel.Widget)
892	}
893
894	hbox.Append(&infoBox.Widget)
895}
896
897func (sm *SessionManager) addRelativeTime(hbox *gtk.Box, info entity.SessionInfo) {
898	relTime := usecase.GetRelativeTime(info.UpdatedAt)
899	timeLabel := gtk.NewLabel(&relTime)
900	if timeLabel != nil {
901		timeLabel.AddCssClass("session-time")
902		timeLabel.SetValign(gtk.AlignCenterValue)
903		hbox.Append(&timeLabel.Widget)
904	}
905}
906
907func (sm *SessionManager) selectPrevious() {
908	sm.mu.Lock()
909	maxIndex := len(sm.sessions) - 1
910	if maxIndex < 0 {
911		sm.mu.Unlock()
912		return
913	}
914	sm.selectedIndex--
915	if sm.selectedIndex < 0 {
916		sm.selectedIndex = maxIndex // Wrap to bottom
917	}
918	idx := sm.selectedIndex
919	sm.mu.Unlock()
920
921	sm.selectBySessionIndex(idx)
922}
923
924func (sm *SessionManager) selectNext() {
925	sm.mu.Lock()
926	maxIndex := len(sm.sessions) - 1
927	if maxIndex < 0 {
928		sm.mu.Unlock()
929		return
930	}
931	sm.selectedIndex++
932	if sm.selectedIndex > maxIndex {
933		sm.selectedIndex = 0 // Wrap to top
934	}
935	idx := sm.selectedIndex
936	sm.mu.Unlock()
937
938	sm.selectBySessionIndex(idx)
939}
940
941func (sm *SessionManager) toggleExpanded() {
942	sm.mu.Lock()
943	if sm.expandedIndex == sm.selectedIndex {
944		sm.expandedIndex = -1 // collapse
945	} else {
946		sm.expandedIndex = sm.selectedIndex // expand
947	}
948	sm.mu.Unlock()
949
950	// Rebuild the list to show/hide tree
951	sm.populateList()
952}
953
954func (sm *SessionManager) openSelected() {
955	sm.mu.RLock()
956	if sm.selectedIndex < 0 || sm.selectedIndex >= len(sm.sessions) {
957		sm.mu.RUnlock()
958		return
959	}
960	info := sm.sessions[sm.selectedIndex]
961	sm.mu.RUnlock()
962
963	log := logging.FromContext(sm.ctx)
964
965	// Don't open current session
966	if info.IsCurrent {
967		log.Debug().Msg("cannot open current session")
968		return
969	}
970
971	log.Info().Str("session_id", string(info.Session.ID)).Msg("opening session")
972
973	sm.Hide(sm.ctx)
974
975	if sm.onOpen != nil {
976		sm.onOpen(info.Session.ID)
977	}
978}
979
980func (sm *SessionManager) deleteSelected() {
981	sm.mu.RLock()
982	if sm.selectedIndex < 0 || sm.selectedIndex >= len(sm.sessions) {
983		sm.mu.RUnlock()
984		return
985	}
986	info := sm.sessions[sm.selectedIndex]
987	sm.mu.RUnlock()
988
989	log := logging.FromContext(sm.ctx)
990
991	// Don't delete current or active sessions (early check before async)
992	if info.IsCurrent || info.IsActive {
993		log.Debug().Msg("cannot delete current or active session")
994		if sm.onToast != nil {
995			sm.onToast(sm.ctx, "Cannot delete active session", ToastWarning)
996		}
997		return
998	}
999
1000	if sm.deleteSessionUC == nil {
1001		log.Error().Msg("delete session use case not available")
1002		return
1003	}
1004
1005	sessionID := info.Session.ID
1006	log.Info().Str("session_id", string(sessionID)).Msg("deleting session")
1007
1008	go func() {
1009		err := sm.deleteSessionUC.Execute(sm.ctx, usecase.DeleteSessionInput{
1010			SessionID:        sessionID,
1011			CurrentSessionID: sm.currentSession,
1012		})
1013
1014		cb := glib.SourceFunc(func(_ uintptr) bool {
1015			if err != nil {
1016				log.Error().Err(err).Msg("failed to delete session")
1017				if sm.onToast != nil {
1018					sm.onToast(sm.ctx, "Failed to delete session", ToastError)
1019				}
1020			} else if sm.onToast != nil {
1021				sm.onToast(sm.ctx, "Session deleted", ToastSuccess)
1022			}
1023			sm.loadSessions()
1024			return false
1025		})
1026		glib.IdleAdd(&cb, 0)
1027	}()
1028}
1029
1030// Destroy cleans up session manager resources.
1031func (sm *SessionManager) Destroy() {
1032	sm.mu.Lock()
1033	sm.visible = false
1034	sm.mu.Unlock()
1035
1036	sm.parentOverlay = nil
1037	sm.retainedCallbacks = nil
1038
1039	if sm.outerBox != nil {
1040		sm.outerBox.Unparent()
1041		sm.outerBox = nil
1042	}
1043}

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