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