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(÷r.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}