v0.23.2 bfd5eb4

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

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