detached 76add37

feat(ui): add session manager modal and state change tracking

commit
76add378640ef031ff52da2b5fc94108928d9d0a
author
bnema · · 2025-12-24 21:27 UTC
committer
brice · · 2025-12-25 05:18 UTC
parent
2c68850
signature
unsigned
10 files · +1147 -33
M internal/ui/app.go +117 -4
@@ -12,6 +12,7 @@ import (
1212 "github.com/bnema/dumber/internal/domain/entity"
1313 "github.com/bnema/dumber/internal/infrastructure/config"
1414 "github.com/bnema/dumber/internal/infrastructure/filtering"
15+ "github.com/bnema/dumber/internal/infrastructure/snapshot"
1516 "github.com/bnema/dumber/internal/infrastructure/webkit"
1617
1718 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
@@ -84,6 +85,10 @@ type App struct {
8485 // App-level toaster for system notifications (filter status, etc.)
8586 appToaster *component.Toaster
8687
88+ // Session management
89+ sessionManager *component.SessionManager
90+ snapshotService *snapshot.Service
91+
8792 // ID generator for tabs/panes
8893 idCounter uint64
8994 idMu sync.Mutex
@@ -183,6 +188,8 @@ func (a *App) onActivate(ctx context.Context) {
183188 a.initKeyboardHandler(ctx)
184189 a.initOmniboxConfig(ctx)
185190 a.initFindBarConfig()
191+ a.initSessionManager(ctx)
192+ a.initSnapshotService(ctx)
186193 a.createInitialTab(ctx)
187194 a.finalizeActivation(ctx)
188195 }
@@ -295,10 +302,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
295302 }
296303
297304 // Create keyboard handler and wire to dispatcher.
298- a.keyboardHandler = input.NewKeyboardHandler(
299- ctx,
300- &a.deps.Config.Workspace,
301- )
305+ a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)
302306 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
303307 return a.kbDispatcher.Dispatch(ctx, action)
304308 })
@@ -369,6 +373,106 @@ func (a *App) initFindBarConfig() {
369373 }
370374 }
371375
376+func (a *App) initSessionManager(ctx context.Context) {
377+ log := logging.FromContext(ctx)
378+
379+ if a.deps == nil {
380+ log.Debug().Msg("deps not available, skipping session manager")
381+ return
382+ }
383+
384+ // Session manager can work without a repo - it just shows an empty list
385+ var listSessionsUC *usecase.ListSessionsUseCase
386+ if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
387+ listSessionsUC = usecase.NewListSessionsUseCase(
388+ a.deps.SessionRepo,
389+ a.deps.SessionStateRepo,
390+ a.deps.Config.Logging.LogDir,
391+ )
392+ }
393+
394+ // Create session manager component
395+ a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
396+ ListSessionsUC: listSessionsUC,
397+ CurrentSession: a.deps.CurrentSessionID,
398+ OnClose: func() {
399+ log.Debug().Msg("session manager closed")
400+ },
401+ OnOpen: func(sessionID entity.SessionID) {
402+ log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
403+ // TODO: Implement session spawning/resurrection
404+ },
405+ })
406+
407+ if a.sessionManager == nil {
408+ log.Warn().Msg("failed to create session manager")
409+ return
410+ }
411+
412+ // Add session manager to main window overlay
413+ if a.mainWindow != nil {
414+ widget := a.sessionManager.Widget()
415+ if widget != nil {
416+ a.mainWindow.AddOverlay(widget)
417+ }
418+ }
419+
420+ // Wire session manager action to keyboard dispatcher
421+ a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context) error {
422+ a.ToggleSessionManager(ctx)
423+ return nil
424+ })
425+
426+ log.Debug().Msg("session manager initialized")
427+}
428+
429+// ToggleSessionManager shows or hides the session manager.
430+func (a *App) ToggleSessionManager(ctx context.Context) {
431+ if a.sessionManager == nil {
432+ return
433+ }
434+ a.sessionManager.Toggle(ctx)
435+}
436+
437+func (a *App) initSnapshotService(ctx context.Context) {
438+ log := logging.FromContext(ctx)
439+
440+ if a.deps == nil || a.deps.SnapshotUC == nil {
441+ log.Debug().Msg("snapshot use case not available, skipping snapshot service")
442+ return
443+ }
444+
445+ intervalMs := 5000 // default
446+ if a.deps.Config != nil && a.deps.Config.Session.SnapshotIntervalMs > 0 {
447+ intervalMs = a.deps.Config.Session.SnapshotIntervalMs
448+ }
449+
450+ a.snapshotService = snapshot.NewService(a.deps.SnapshotUC, a, intervalMs)
451+ a.snapshotService.Start(ctx)
452+
453+ log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started")
454+}
455+
456+// GetTabList implements port.TabListProvider.
457+func (a *App) GetTabList() *entity.TabList {
458+ return a.tabs
459+}
460+
461+// GetSessionID implements port.TabListProvider.
462+func (a *App) GetSessionID() entity.SessionID {
463+ if a.deps == nil {
464+ return ""
465+ }
466+ return a.deps.CurrentSessionID
467+}
468+
469+// MarkDirty signals that session state has changed and should be saved.
470+func (a *App) MarkDirty() {
471+ if a.snapshotService != nil {
472+ a.snapshotService.MarkDirty()
473+ }
474+}
475+
372476 func (a *App) createInitialTab(ctx context.Context) {
373477 log := logging.FromContext(ctx)
374478
@@ -409,6 +513,13 @@ func (a *App) onShutdown(ctx context.Context) {
409513 log := logging.FromContext(ctx)
410514 log.Debug().Msg("GTK application shutting down")
411515
516+ // Save final session state before shutdown
517+ if a.snapshotService != nil {
518+ if err := a.snapshotService.Stop(ctx); err != nil {
519+ log.Warn().Err(err).Msg("failed to save final session state")
520+ }
521+ }
522+
412523 // Cancel context to signal all goroutines
413524 a.cancel(errors.New("application shutdown"))
414525
@@ -475,6 +586,7 @@ func (a *App) initCoordinators(ctx context.Context) {
475586 a.switchWorkspaceView(ctx, tab.ID)
476587 })
477588 a.tabCoord.SetOnQuit(a.Quit)
589+ a.tabCoord.SetOnStateChanged(a.MarkDirty)
478590 // Wire popup tab WebView attachment
479591 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
480592 a.attachPopupToTab(ctx, tabID, pane, wv)
@@ -502,6 +614,7 @@ func (a *App) initCoordinators(ctx context.Context) {
502614 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
503615 return a.tabCoord.Close(ctx)
504616 })
617+ a.wsCoord.SetOnStateChanged(a.MarkDirty)
505618
506619 // Wire popup handling
507620 a.webViewFactory = webkit.NewWebViewFactory(
@@ -12,6 +12,7 @@ import (
12 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/infrastructure/config"13 "github.com/bnema/dumber/internal/infrastructure/config"
14 "github.com/bnema/dumber/internal/infrastructure/filtering"14 "github.com/bnema/dumber/internal/infrastructure/filtering"
15+ "github.com/bnema/dumber/internal/infrastructure/snapshot"
15 "github.com/bnema/dumber/internal/infrastructure/webkit"16 "github.com/bnema/dumber/internal/infrastructure/webkit"
16 17
17 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"18 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
@@ -84,6 +85,10 @@ type App struct {
84 // App-level toaster for system notifications (filter status, etc.)85 // App-level toaster for system notifications (filter status, etc.)
85 appToaster *component.Toaster86 appToaster *component.Toaster
86 87
88+ // Session management
89+ sessionManager *component.SessionManager
90+ snapshotService *snapshot.Service
91+
87 // ID generator for tabs/panes92 // ID generator for tabs/panes
88 idCounter uint6493 idCounter uint64
89 idMu sync.Mutex94 idMu sync.Mutex
@@ -183,6 +188,8 @@ func (a *App) onActivate(ctx context.Context) {
183 a.initKeyboardHandler(ctx)188 a.initKeyboardHandler(ctx)
184 a.initOmniboxConfig(ctx)189 a.initOmniboxConfig(ctx)
185 a.initFindBarConfig()190 a.initFindBarConfig()
191+ a.initSessionManager(ctx)
192+ a.initSnapshotService(ctx)
186 a.createInitialTab(ctx)193 a.createInitialTab(ctx)
187 a.finalizeActivation(ctx)194 a.finalizeActivation(ctx)
188 }195 }
@@ -295,10 +302,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
295 }302 }
296 303
297 // Create keyboard handler and wire to dispatcher.304 // Create keyboard handler and wire to dispatcher.
298- a.keyboardHandler = input.NewKeyboardHandler(305+ a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)
299- ctx,
300- &a.deps.Config.Workspace,
301- )
302 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {306 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
303 return a.kbDispatcher.Dispatch(ctx, action)307 return a.kbDispatcher.Dispatch(ctx, action)
304 })308 })
@@ -369,6 +373,106 @@ func (a *App) initFindBarConfig() {
369 }373 }
370 }374 }
371 375
376+func (a *App) initSessionManager(ctx context.Context) {
377+ log := logging.FromContext(ctx)
378+
379+ if a.deps == nil {
380+ log.Debug().Msg("deps not available, skipping session manager")
381+ return
382+ }
383+
384+ // Session manager can work without a repo - it just shows an empty list
385+ var listSessionsUC *usecase.ListSessionsUseCase
386+ if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
387+ listSessionsUC = usecase.NewListSessionsUseCase(
388+ a.deps.SessionRepo,
389+ a.deps.SessionStateRepo,
390+ a.deps.Config.Logging.LogDir,
391+ )
392+ }
393+
394+ // Create session manager component
395+ a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
396+ ListSessionsUC: listSessionsUC,
397+ CurrentSession: a.deps.CurrentSessionID,
398+ OnClose: func() {
399+ log.Debug().Msg("session manager closed")
400+ },
401+ OnOpen: func(sessionID entity.SessionID) {
402+ log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
403+ // TODO: Implement session spawning/resurrection
404+ },
405+ })
406+
407+ if a.sessionManager == nil {
408+ log.Warn().Msg("failed to create session manager")
409+ return
410+ }
411+
412+ // Add session manager to main window overlay
413+ if a.mainWindow != nil {
414+ widget := a.sessionManager.Widget()
415+ if widget != nil {
416+ a.mainWindow.AddOverlay(widget)
417+ }
418+ }
419+
420+ // Wire session manager action to keyboard dispatcher
421+ a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context) error {
422+ a.ToggleSessionManager(ctx)
423+ return nil
424+ })
425+
426+ log.Debug().Msg("session manager initialized")
427+}
428+
429+// ToggleSessionManager shows or hides the session manager.
430+func (a *App) ToggleSessionManager(ctx context.Context) {
431+ if a.sessionManager == nil {
432+ return
433+ }
434+ a.sessionManager.Toggle(ctx)
435+}
436+
437+func (a *App) initSnapshotService(ctx context.Context) {
438+ log := logging.FromContext(ctx)
439+
440+ if a.deps == nil || a.deps.SnapshotUC == nil {
441+ log.Debug().Msg("snapshot use case not available, skipping snapshot service")
442+ return
443+ }
444+
445+ intervalMs := 5000 // default
446+ if a.deps.Config != nil && a.deps.Config.Session.SnapshotIntervalMs > 0 {
447+ intervalMs = a.deps.Config.Session.SnapshotIntervalMs
448+ }
449+
450+ a.snapshotService = snapshot.NewService(a.deps.SnapshotUC, a, intervalMs)
451+ a.snapshotService.Start(ctx)
452+
453+ log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started")
454+}
455+
456+// GetTabList implements port.TabListProvider.
457+func (a *App) GetTabList() *entity.TabList {
458+ return a.tabs
459+}
460+
461+// GetSessionID implements port.TabListProvider.
462+func (a *App) GetSessionID() entity.SessionID {
463+ if a.deps == nil {
464+ return ""
465+ }
466+ return a.deps.CurrentSessionID
467+}
468+
469+// MarkDirty signals that session state has changed and should be saved.
470+func (a *App) MarkDirty() {
471+ if a.snapshotService != nil {
472+ a.snapshotService.MarkDirty()
473+ }
474+}
475+
372 func (a *App) createInitialTab(ctx context.Context) {476 func (a *App) createInitialTab(ctx context.Context) {
373 log := logging.FromContext(ctx)477 log := logging.FromContext(ctx)
374 478
@@ -409,6 +513,13 @@ func (a *App) onShutdown(ctx context.Context) {
409 log := logging.FromContext(ctx)513 log := logging.FromContext(ctx)
410 log.Debug().Msg("GTK application shutting down")514 log.Debug().Msg("GTK application shutting down")
411 515
516+ // Save final session state before shutdown
517+ if a.snapshotService != nil {
518+ if err := a.snapshotService.Stop(ctx); err != nil {
519+ log.Warn().Err(err).Msg("failed to save final session state")
520+ }
521+ }
522+
412 // Cancel context to signal all goroutines523 // Cancel context to signal all goroutines
413 a.cancel(errors.New("application shutdown"))524 a.cancel(errors.New("application shutdown"))
414 525
@@ -475,6 +586,7 @@ func (a *App) initCoordinators(ctx context.Context) {
475 a.switchWorkspaceView(ctx, tab.ID)586 a.switchWorkspaceView(ctx, tab.ID)
476 })587 })
477 a.tabCoord.SetOnQuit(a.Quit)588 a.tabCoord.SetOnQuit(a.Quit)
589+ a.tabCoord.SetOnStateChanged(a.MarkDirty)
478 // Wire popup tab WebView attachment590 // Wire popup tab WebView attachment
479 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {591 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
480 a.attachPopupToTab(ctx, tabID, pane, wv)592 a.attachPopupToTab(ctx, tabID, pane, wv)
@@ -502,6 +614,7 @@ func (a *App) initCoordinators(ctx context.Context) {
502 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {614 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
503 return a.tabCoord.Close(ctx)615 return a.tabCoord.Close(ctx)
504 })616 })
617+ a.wsCoord.SetOnStateChanged(a.MarkDirty)
505 618
506 // Wire popup handling619 // Wire popup handling
507 a.webViewFactory = webkit.NewWebViewFactory(620 a.webViewFactory = webkit.NewWebViewFactory(
A internal/ui/component/session_manager.go +695 -0
@@ -0,0 +1,695 @@
1+package component
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/bnema/dumber/internal/ui/layout"
13+ "github.com/jwijenbergh/puregotk/v4/gdk"
14+ "github.com/jwijenbergh/puregotk/v4/glib"
15+ "github.com/jwijenbergh/puregotk/v4/gtk"
16+)
17+
18+const (
19+ sessionManagerWidthPct = 0.6 // 60% of parent window width
20+ sessionManagerMaxWidth = 600 // Maximum width in pixels
21+ sessionManagerTopMargin = 0.15
22+ sessionListMaxHeight = 400
23+ sessionDefaultListLimit = 50
24+ sessionIDMaxDisplay = 20
25+ sessionRowBoxSpacing = 8
26+)
27+
28+// SessionManager is a modal overlay for managing sessions.
29+type SessionManager struct {
30+ // GTK widgets
31+ outerBox *gtk.Box
32+ mainBox *gtk.Box
33+ headerBox *gtk.Box
34+ titleLabel *gtk.Label
35+ scrolledWindow *gtk.ScrolledWindow
36+ listBox *gtk.ListBox
37+ footerLabel *gtk.Label
38+
39+ // Parent overlay reference for sizing
40+ parentOverlay layout.OverlayWidget
41+
42+ // State
43+ mu sync.RWMutex
44+ visible bool
45+ sessions []entity.SessionInfo
46+ selectedIndex int
47+
48+ // Dependencies
49+ listSessionsUC *usecase.ListSessionsUseCase
50+ spawner port.SessionSpawner
51+ currentSession entity.SessionID
52+
53+ // Callbacks
54+ onClose func()
55+ onOpen func(sessionID entity.SessionID)
56+
57+ // GTK callback retention
58+ retainedCallbacks []interface{}
59+
60+ ctx context.Context
61+}
62+
63+// SessionManagerConfig holds configuration for creating a SessionManager.
64+type SessionManagerConfig struct {
65+ ListSessionsUC *usecase.ListSessionsUseCase
66+ Spawner port.SessionSpawner
67+ CurrentSession entity.SessionID
68+ OnClose func()
69+ OnOpen func(sessionID entity.SessionID)
70+}
71+
72+// NewSessionManager creates a new SessionManager component.
73+func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionManager {
74+ log := logging.FromContext(ctx)
75+
76+ sm := &SessionManager{
77+ ctx: ctx,
78+ listSessionsUC: cfg.ListSessionsUC,
79+ spawner: cfg.Spawner,
80+ currentSession: cfg.CurrentSession,
81+ onClose: cfg.OnClose,
82+ onOpen: cfg.OnOpen,
83+ selectedIndex: -1,
84+ }
85+
86+ if err := sm.createWidgets(); err != nil {
87+ log.Error().Err(err).Msg("failed to create session manager widgets")
88+ return nil
89+ }
90+
91+ sm.attachKeyController()
92+
93+ log.Debug().Msg("session manager created")
94+ return sm
95+}
96+
97+// SetParentOverlay sets the overlay widget used for sizing calculations.
98+// Must be called before Show().
99+func (sm *SessionManager) SetParentOverlay(overlay layout.OverlayWidget) {
100+ sm.mu.Lock()
101+ defer sm.mu.Unlock()
102+ sm.parentOverlay = overlay
103+}
104+
105+// WidgetAsLayout returns the session manager's outer widget as a layout.Widget.
106+func (sm *SessionManager) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
107+ if sm.outerBox == nil {
108+ return nil
109+ }
110+ return factory.WrapWidget(&sm.outerBox.Widget)
111+}
112+
113+// Widget returns the session manager widget for embedding in an overlay.
114+func (sm *SessionManager) Widget() *gtk.Widget {
115+ if sm.outerBox == nil {
116+ return nil
117+ }
118+ return &sm.outerBox.Widget
119+}
120+
121+// Show displays the session manager and loads sessions.
122+func (sm *SessionManager) Show(ctx context.Context) {
123+ log := logging.FromContext(ctx)
124+ log.Debug().Msg("showing session manager")
125+
126+ sm.mu.Lock()
127+ if sm.visible {
128+ sm.mu.Unlock()
129+ return
130+ }
131+ sm.visible = true
132+ sm.mu.Unlock()
133+
134+ // Calculate size and position
135+ sm.resizeAndCenter()
136+
137+ // Show the widget
138+ sm.outerBox.SetVisible(true)
139+
140+ // Load sessions asynchronously
141+ go sm.loadSessions()
142+
143+ // Focus the list
144+ if sm.listBox != nil {
145+ sm.listBox.GrabFocus()
146+ }
147+}
148+
149+// Hide hides the session manager.
150+func (sm *SessionManager) Hide(ctx context.Context) {
151+ log := logging.FromContext(ctx)
152+ log.Debug().Msg("hiding session manager")
153+
154+ sm.mu.Lock()
155+ if !sm.visible {
156+ sm.mu.Unlock()
157+ return
158+ }
159+ sm.visible = false
160+ sm.mu.Unlock()
161+
162+ sm.outerBox.SetVisible(false)
163+
164+ // Clear state
165+ sm.listBox.RemoveAll()
166+
167+ if sm.onClose != nil {
168+ sm.onClose()
169+ }
170+}
171+
172+// IsVisible returns whether the session manager is visible.
173+func (sm *SessionManager) IsVisible() bool {
174+ sm.mu.RLock()
175+ defer sm.mu.RUnlock()
176+ return sm.visible
177+}
178+
179+// Toggle shows if hidden, hides if visible.
180+func (sm *SessionManager) Toggle(ctx context.Context) {
181+ sm.mu.RLock()
182+ visible := sm.visible
183+ sm.mu.RUnlock()
184+
185+ if visible {
186+ sm.Hide(ctx)
187+ } else {
188+ sm.Show(ctx)
189+ }
190+}
191+
192+func (sm *SessionManager) resizeAndCenter() {
193+ if sm.outerBox == nil || sm.mainBox == nil {
194+ return
195+ }
196+
197+ var parentWidth, parentHeight int
198+
199+ if sm.parentOverlay != nil {
200+ parentWidth = sm.parentOverlay.GetAllocatedWidth()
201+ parentHeight = sm.parentOverlay.GetAllocatedHeight()
202+ }
203+
204+ // Use defaults if parent overlay not set or not yet allocated
205+ if parentWidth <= 0 {
206+ parentWidth = sessionManagerMaxWidth
207+ }
208+ if parentHeight <= 0 {
209+ parentHeight = 600
210+ }
211+
212+ width := int(float64(parentWidth) * sessionManagerWidthPct)
213+ if width > sessionManagerMaxWidth {
214+ width = sessionManagerMaxWidth
215+ }
216+
217+ marginTop := int(float64(parentHeight) * sessionManagerTopMargin)
218+
219+ sm.mainBox.SetSizeRequest(width, -1)
220+ sm.outerBox.SetMarginTop(marginTop)
221+}
222+
223+func (sm *SessionManager) createWidgets() error {
224+ // Outer container for positioning
225+ sm.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
226+ if sm.outerBox == nil {
227+ return errNilWidget("sessionOuterBox")
228+ }
229+ sm.outerBox.AddCssClass("session-manager-outer")
230+ sm.outerBox.SetHalign(gtk.AlignCenterValue)
231+ sm.outerBox.SetValign(gtk.AlignStartValue)
232+ sm.outerBox.SetVisible(false)
233+
234+ // Main container with styling
235+ sm.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
236+ if sm.mainBox == nil {
237+ return errNilWidget("sessionMainBox")
238+ }
239+ sm.mainBox.AddCssClass("session-manager-container")
240+
241+ // Header
242+ if err := sm.initHeader(); err != nil {
243+ return err
244+ }
245+
246+ // Scrolled window for list
247+ if err := sm.initList(); err != nil {
248+ return err
249+ }
250+
251+ // Footer with shortcuts
252+ if err := sm.initFooter(); err != nil {
253+ return err
254+ }
255+
256+ // Assemble
257+ sm.mainBox.Append(&sm.headerBox.Widget)
258+ sm.mainBox.Append(&sm.scrolledWindow.Widget)
259+ sm.mainBox.Append(&sm.footerLabel.Widget)
260+ sm.outerBox.Append(&sm.mainBox.Widget)
261+
262+ return nil
263+}
264+
265+func (sm *SessionManager) initHeader() error {
266+ sm.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
267+ if sm.headerBox == nil {
268+ return errNilWidget("sessionHeaderBox")
269+ }
270+ sm.headerBox.AddCssClass("session-manager-header")
271+
272+ title := "Sessions"
273+ sm.titleLabel = gtk.NewLabel(&title)
274+ if sm.titleLabel == nil {
275+ return errNilWidget("sessionTitleLabel")
276+ }
277+ sm.titleLabel.AddCssClass("session-manager-title")
278+ sm.titleLabel.SetHalign(gtk.AlignStartValue)
279+ sm.titleLabel.SetHexpand(true)
280+ sm.headerBox.Append(&sm.titleLabel.Widget)
281+
282+ // Shortcut badge
283+ shortcutText := "Ctrl+O"
284+ shortcutLabel := gtk.NewLabel(&shortcutText)
285+ if shortcutLabel != nil {
286+ shortcutLabel.AddCssClass("omnibox-shortcut-badge")
287+ sm.headerBox.Append(&shortcutLabel.Widget)
288+ }
289+
290+ return nil
291+}
292+
293+func (sm *SessionManager) initList() error {
294+ sm.scrolledWindow = gtk.NewScrolledWindow()
295+ if sm.scrolledWindow == nil {
296+ return errNilWidget("sessionScrolledWindow")
297+ }
298+ sm.scrolledWindow.AddCssClass("session-manager-scrolled")
299+ sm.scrolledWindow.SetMaxContentHeight(sessionListMaxHeight)
300+ sm.scrolledWindow.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
301+ sm.scrolledWindow.SetPropagateNaturalHeight(true)
302+
303+ sm.listBox = gtk.NewListBox()
304+ if sm.listBox == nil {
305+ return errNilWidget("sessionListBox")
306+ }
307+ sm.listBox.AddCssClass("session-manager-list")
308+ sm.listBox.SetSelectionMode(gtk.SelectionSingleValue)
309+
310+ // Connect row selection
311+ rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
312+ if rowPtr == 0 {
313+ sm.mu.Lock()
314+ sm.selectedIndex = -1
315+ sm.mu.Unlock()
316+ return
317+ }
318+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
319+ if row != nil {
320+ sm.mu.Lock()
321+ sm.selectedIndex = row.GetIndex()
322+ sm.mu.Unlock()
323+ }
324+ }
325+ sm.retainedCallbacks = append(sm.retainedCallbacks, rowSelectedCb)
326+ sm.listBox.ConnectRowSelected(&rowSelectedCb)
327+
328+ sm.scrolledWindow.SetChild(&sm.listBox.Widget)
329+ return nil
330+}
331+
332+func (sm *SessionManager) initFooter() error {
333+ footerText := "↑↓/jk navigate Enter open x delete Esc close"
334+ sm.footerLabel = gtk.NewLabel(&footerText)
335+ if sm.footerLabel == nil {
336+ return errNilWidget("sessionFooterLabel")
337+ }
338+ sm.footerLabel.AddCssClass("session-manager-footer")
339+ sm.footerLabel.SetHalign(gtk.AlignCenterValue)
340+ return nil
341+}
342+
343+func (sm *SessionManager) attachKeyController() {
344+ controller := gtk.NewEventControllerKey()
345+ if controller == nil {
346+ return
347+ }
348+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
349+
350+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
351+ return sm.handleKeyPress(keyval, state)
352+ }
353+ sm.retainedCallbacks = append(sm.retainedCallbacks, keyPressedCb)
354+ controller.ConnectKeyPressed(&keyPressedCb)
355+ sm.outerBox.AddController(&controller.EventController)
356+}
357+
358+func (sm *SessionManager) handleKeyPress(keyval uint, _ gdk.ModifierType) bool {
359+ switch keyval {
360+ case uint(gdk.KEY_Escape):
361+ sm.Hide(sm.ctx)
362+ return true
363+
364+ case uint(gdk.KEY_Up), uint(gdk.KEY_k):
365+ sm.selectPrevious()
366+ return true
367+
368+ case uint(gdk.KEY_Down), uint(gdk.KEY_j):
369+ sm.selectNext()
370+ return true
371+
372+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
373+ sm.openSelected()
374+ return true
375+
376+ case uint(gdk.KEY_x), uint(gdk.KEY_Delete):
377+ sm.deleteSelected()
378+ return true
379+ }
380+ return false
381+}
382+
383+func (sm *SessionManager) loadSessions() {
384+ log := logging.FromContext(sm.ctx)
385+
386+ if sm.listSessionsUC == nil {
387+ log.Warn().Msg("listSessionsUC is nil")
388+ return
389+ }
390+
391+ output, err := sm.listSessionsUC.Execute(sm.ctx, sm.currentSession, sessionDefaultListLimit)
392+ if err != nil {
393+ log.Error().Err(err).Msg("failed to list sessions")
394+ return
395+ }
396+
397+ sm.mu.Lock()
398+ sm.sessions = output.Sessions
399+ sm.selectedIndex = 0
400+ sm.mu.Unlock()
401+
402+ // Update UI on main thread
403+ cb := glib.SourceFunc(func(_ uintptr) bool {
404+ sm.populateList()
405+ return false
406+ })
407+ glib.IdleAdd(&cb, 0)
408+}
409+
410+func (sm *SessionManager) populateList() {
411+ sm.mu.RLock()
412+ sessions := sm.sessions
413+ sm.mu.RUnlock()
414+
415+ if sm.listBox == nil {
416+ return
417+ }
418+
419+ // Clear existing rows
420+ sm.listBox.RemoveAll()
421+
422+ // Separate active and exited sessions
423+ var activeSessions, exitedSessions []entity.SessionInfo
424+ for _, info := range sessions {
425+ if info.IsCurrent || info.IsActive {
426+ activeSessions = append(activeSessions, info)
427+ } else {
428+ exitedSessions = append(exitedSessions, info)
429+ }
430+ }
431+
432+ // Add active session rows
433+ for _, info := range activeSessions {
434+ row := sm.createSessionRow(info)
435+ if row != nil {
436+ sm.listBox.Append(&row.Widget)
437+ }
438+ }
439+
440+ // Add divider if there are exited sessions
441+ if len(exitedSessions) > 0 && len(activeSessions) > 0 {
442+ divider := sm.createDividerRow("EXITED")
443+ if divider != nil {
444+ sm.listBox.Append(&divider.Widget)
445+ }
446+ }
447+
448+ // Add exited session rows
449+ for _, info := range exitedSessions {
450+ row := sm.createSessionRow(info)
451+ if row != nil {
452+ sm.listBox.Append(&row.Widget)
453+ }
454+ }
455+
456+ // Select first row
457+ if len(sessions) > 0 {
458+ firstRow := sm.listBox.GetRowAtIndex(0)
459+ if firstRow != nil {
460+ sm.listBox.SelectRow(firstRow)
461+ }
462+ }
463+
464+ // Resize based on content
465+ rowCount := len(activeSessions) + len(exitedSessions)
466+ if len(exitedSessions) > 0 && len(activeSessions) > 0 {
467+ rowCount++ // divider row
468+ }
469+ sm.resizeForContent(rowCount)
470+}
471+
472+func (sm *SessionManager) resizeForContent(rowCount int) {
473+ if sm.scrolledWindow == nil {
474+ return
475+ }
476+
477+ const rowHeight = 50
478+ contentHeight := rowCount * rowHeight
479+
480+ if contentHeight > sessionListMaxHeight {
481+ contentHeight = sessionListMaxHeight
482+ }
483+
484+ // Schedule resize
485+ cb := glib.SourceFunc(func(_ uintptr) bool {
486+ sm.scrolledWindow.SetMinContentHeight(-1)
487+ sm.scrolledWindow.SetMaxContentHeight(contentHeight)
488+ sm.scrolledWindow.SetMinContentHeight(contentHeight)
489+ sm.outerBox.QueueResize()
490+ return false
491+ })
492+ glib.IdleAdd(&cb, 0)
493+}
494+
495+func (sm *SessionManager) createDividerRow(text string) *gtk.ListBoxRow {
496+ row := gtk.NewListBoxRow()
497+ if row == nil {
498+ return nil
499+ }
500+ row.SetSelectable(false)
501+ row.SetActivatable(false)
502+
503+ label := gtk.NewLabel(&text)
504+ if label == nil {
505+ return nil
506+ }
507+ label.AddCssClass("session-divider")
508+ label.SetHalign(gtk.AlignStartValue)
509+ row.SetChild(&label.Widget)
510+
511+ return row
512+}
513+
514+func (sm *SessionManager) createSessionRow(info entity.SessionInfo) *gtk.ListBoxRow {
515+ row := gtk.NewListBoxRow()
516+ if row == nil {
517+ return nil
518+ }
519+ row.AddCssClass("session-manager-row-wrapper")
520+
521+ hbox := gtk.NewBox(gtk.OrientationHorizontalValue, sessionRowBoxSpacing)
522+ if hbox == nil {
523+ return nil
524+ }
525+ hbox.AddCssClass("session-manager-row")
526+
527+ sm.addStatusIndicator(hbox, info)
528+ sm.addSessionInfo(hbox, info)
529+ sm.addRelativeTime(hbox, info)
530+
531+ row.SetChild(&hbox.Widget)
532+ return row
533+}
534+
535+func (sm *SessionManager) addStatusIndicator(hbox *gtk.Box, info entity.SessionInfo) {
536+ var statusText string
537+ switch {
538+ case info.IsCurrent:
539+ statusText = "●"
540+ hbox.AddCssClass("session-current")
541+ case info.IsActive:
542+ statusText = "○"
543+ hbox.AddCssClass("session-active")
544+ default:
545+ statusText = " "
546+ hbox.AddCssClass("session-exited")
547+ }
548+ statusLabel := gtk.NewLabel(&statusText)
549+ if statusLabel != nil {
550+ statusLabel.AddCssClass("session-status")
551+ hbox.Append(&statusLabel.Widget)
552+ }
553+}
554+
555+func (sm *SessionManager) addSessionInfo(hbox *gtk.Box, info entity.SessionInfo) {
556+ const infoBoxSpacing = 2
557+ infoBox := gtk.NewBox(gtk.OrientationVerticalValue, infoBoxSpacing)
558+ if infoBox == nil {
559+ return
560+ }
561+ infoBox.SetHexpand(true)
562+
563+ // Session ID (short)
564+ idText := string(info.Session.ID)
565+ if len(idText) > sessionIDMaxDisplay {
566+ idText = idText[:sessionIDMaxDisplay]
567+ }
568+ if info.IsCurrent {
569+ idText += " (current)"
570+ }
571+ idLabel := gtk.NewLabel(&idText)
572+ if idLabel != nil {
573+ idLabel.AddCssClass("session-id")
574+ idLabel.SetHalign(gtk.AlignStartValue)
575+ idLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
576+ infoBox.Append(&idLabel.Widget)
577+ }
578+
579+ // Tab/pane count
580+ countText := fmt.Sprintf("%d tabs, %d panes", info.TabCount, info.PaneCount)
581+ countLabel := gtk.NewLabel(&countText)
582+ if countLabel != nil {
583+ countLabel.AddCssClass("session-count")
584+ countLabel.SetHalign(gtk.AlignStartValue)
585+ infoBox.Append(&countLabel.Widget)
586+ }
587+
588+ hbox.Append(&infoBox.Widget)
589+}
590+
591+func (sm *SessionManager) addRelativeTime(hbox *gtk.Box, info entity.SessionInfo) {
592+ relTime := usecase.GetRelativeTime(info.UpdatedAt)
593+ timeLabel := gtk.NewLabel(&relTime)
594+ if timeLabel != nil {
595+ timeLabel.AddCssClass("session-time")
596+ timeLabel.SetValign(gtk.AlignCenterValue)
597+ hbox.Append(&timeLabel.Widget)
598+ }
599+}
600+
601+func (sm *SessionManager) selectIndex(index int) {
602+ row := sm.listBox.GetRowAtIndex(index)
603+ if row != nil {
604+ sm.listBox.SelectRow(row)
605+ }
606+}
607+
608+func (sm *SessionManager) selectPrevious() {
609+ sm.mu.Lock()
610+ if sm.selectedIndex > 0 {
611+ sm.selectedIndex--
612+ }
613+ idx := sm.selectedIndex
614+ sm.mu.Unlock()
615+
616+ sm.selectIndex(idx)
617+}
618+
619+func (sm *SessionManager) selectNext() {
620+ sm.mu.Lock()
621+ maxIndex := len(sm.sessions) - 1
622+ // Account for divider row
623+ if sm.selectedIndex < maxIndex {
624+ sm.selectedIndex++
625+ }
626+ idx := sm.selectedIndex
627+ sm.mu.Unlock()
628+
629+ sm.selectIndex(idx)
630+}
631+
632+func (sm *SessionManager) openSelected() {
633+ sm.mu.RLock()
634+ if sm.selectedIndex < 0 || sm.selectedIndex >= len(sm.sessions) {
635+ sm.mu.RUnlock()
636+ return
637+ }
638+ info := sm.sessions[sm.selectedIndex]
639+ sm.mu.RUnlock()
640+
641+ log := logging.FromContext(sm.ctx)
642+
643+ // Don't open current session
644+ if info.IsCurrent {
645+ log.Debug().Msg("cannot open current session")
646+ return
647+ }
648+
649+ log.Info().Str("session_id", string(info.Session.ID)).Msg("opening session")
650+
651+ sm.Hide(sm.ctx)
652+
653+ if sm.onOpen != nil {
654+ sm.onOpen(info.Session.ID)
655+ }
656+}
657+
658+func (sm *SessionManager) deleteSelected() {
659+ sm.mu.RLock()
660+ if sm.selectedIndex < 0 || sm.selectedIndex >= len(sm.sessions) {
661+ sm.mu.RUnlock()
662+ return
663+ }
664+ info := sm.sessions[sm.selectedIndex]
665+ sm.mu.RUnlock()
666+
667+ log := logging.FromContext(sm.ctx)
668+
669+ // Don't delete current or active sessions
670+ if info.IsCurrent || info.IsActive {
671+ log.Debug().Msg("cannot delete current or active session")
672+ return
673+ }
674+
675+ log.Info().Str("session_id", string(info.Session.ID)).Msg("deleting session")
676+
677+ // TODO: Implement session deletion via use case
678+ // For now, just reload the list
679+ go sm.loadSessions()
680+}
681+
682+// Destroy cleans up session manager resources.
683+func (sm *SessionManager) Destroy() {
684+ sm.mu.Lock()
685+ sm.visible = false
686+ sm.mu.Unlock()
687+
688+ sm.parentOverlay = nil
689+ sm.retainedCallbacks = nil
690+
691+ if sm.outerBox != nil {
692+ sm.outerBox.Unparent()
693+ sm.outerBox = nil
694+ }
695+}
@@ -0,0 +1,695 @@
1+package component
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/bnema/dumber/internal/ui/layout"
13+ "github.com/jwijenbergh/puregotk/v4/gdk"
14+ "github.com/jwijenbergh/puregotk/v4/glib"
15+ "github.com/jwijenbergh/puregotk/v4/gtk"
16+)
17+
18+const (
19+ sessionManagerWidthPct = 0.6 // 60% of parent window width
20+ sessionManagerMaxWidth = 600 // Maximum width in pixels
21+ sessionManagerTopMargin = 0.15
22+ sessionListMaxHeight = 400
23+ sessionDefaultListLimit = 50
24+ sessionIDMaxDisplay = 20
25+ sessionRowBoxSpacing = 8
26+)
27+
28+// SessionManager is a modal overlay for managing sessions.
29+type SessionManager struct {
30+ // GTK widgets
31+ outerBox *gtk.Box
32+ mainBox *gtk.Box
33+ headerBox *gtk.Box
34+ titleLabel *gtk.Label
35+ scrolledWindow *gtk.ScrolledWindow
36+ listBox *gtk.ListBox
37+ footerLabel *gtk.Label
38+
39+ // Parent overlay reference for sizing
40+ parentOverlay layout.OverlayWidget
41+
42+ // State
43+ mu sync.RWMutex
44+ visible bool
45+ sessions []entity.SessionInfo
46+ selectedIndex int
47+
48+ // Dependencies
49+ listSessionsUC *usecase.ListSessionsUseCase
50+ spawner port.SessionSpawner
51+ currentSession entity.SessionID
52+
53+ // Callbacks
54+ onClose func()
55+ onOpen func(sessionID entity.SessionID)
56+
57+ // GTK callback retention
58+ retainedCallbacks []interface{}
59+
60+ ctx context.Context
61+}
62+
63+// SessionManagerConfig holds configuration for creating a SessionManager.
64+type SessionManagerConfig struct {
65+ ListSessionsUC *usecase.ListSessionsUseCase
66+ Spawner port.SessionSpawner
67+ CurrentSession entity.SessionID
68+ OnClose func()
69+ OnOpen func(sessionID entity.SessionID)
70+}
71+
72+// NewSessionManager creates a new SessionManager component.
73+func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionManager {
74+ log := logging.FromContext(ctx)
75+
76+ sm := &SessionManager{
77+ ctx: ctx,
78+ listSessionsUC: cfg.ListSessionsUC,
79+ spawner: cfg.Spawner,
80+ currentSession: cfg.CurrentSession,
81+ onClose: cfg.OnClose,
82+ onOpen: cfg.OnOpen,
83+ selectedIndex: -1,
84+ }
85+
86+ if err := sm.createWidgets(); err != nil {
87+ log.Error().Err(err).Msg("failed to create session manager widgets")
88+ return nil
89+ }
90+
91+ sm.attachKeyController()
92+
93+ log.Debug().Msg("session manager created")
94+ return sm
95+}
96+
97+// SetParentOverlay sets the overlay widget used for sizing calculations.
98+// Must be called before Show().
99+func (sm *SessionManager) SetParentOverlay(overlay layout.OverlayWidget) {
100+ sm.mu.Lock()
101+ defer sm.mu.Unlock()
102+ sm.parentOverlay = overlay
103+}
104+
105+// WidgetAsLayout returns the session manager's outer widget as a layout.Widget.
106+func (sm *SessionManager) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
107+ if sm.outerBox == nil {
108+ return nil
109+ }
110+ return factory.WrapWidget(&sm.outerBox.Widget)
111+}
112+
113+// Widget returns the session manager widget for embedding in an overlay.
114+func (sm *SessionManager) Widget() *gtk.Widget {
115+ if sm.outerBox == nil {
116+ return nil
117+ }
118+ return &sm.outerBox.Widget
119+}
120+
121+// Show displays the session manager and loads sessions.
122+func (sm *SessionManager) Show(ctx context.Context) {
123+ log := logging.FromContext(ctx)
124+ log.Debug().Msg("showing session manager")
125+
126+ sm.mu.Lock()
127+ if sm.visible {
128+ sm.mu.Unlock()
129+ return
130+ }
131+ sm.visible = true
132+ sm.mu.Unlock()
133+
134+ // Calculate size and position
135+ sm.resizeAndCenter()
136+
137+ // Show the widget
138+ sm.outerBox.SetVisible(true)
139+
140+ // Load sessions asynchronously
141+ go sm.loadSessions()
142+
143+ // Focus the list
144+ if sm.listBox != nil {
145+ sm.listBox.GrabFocus()
146+ }
147+}
148+
149+// Hide hides the session manager.
150+func (sm *SessionManager) Hide(ctx context.Context) {
151+ log := logging.FromContext(ctx)
152+ log.Debug().Msg("hiding session manager")
153+
154+ sm.mu.Lock()
155+ if !sm.visible {
156+ sm.mu.Unlock()
157+ return
158+ }
159+ sm.visible = false
160+ sm.mu.Unlock()
161+
162+ sm.outerBox.SetVisible(false)
163+
164+ // Clear state
165+ sm.listBox.RemoveAll()
166+
167+ if sm.onClose != nil {
168+ sm.onClose()
169+ }
170+}
171+
172+// IsVisible returns whether the session manager is visible.
173+func (sm *SessionManager) IsVisible() bool {
174+ sm.mu.RLock()
175+ defer sm.mu.RUnlock()
176+ return sm.visible
177+}
178+
179+// Toggle shows if hidden, hides if visible.
180+func (sm *SessionManager) Toggle(ctx context.Context) {
181+ sm.mu.RLock()
182+ visible := sm.visible
183+ sm.mu.RUnlock()
184+
185+ if visible {
186+ sm.Hide(ctx)
187+ } else {
188+ sm.Show(ctx)
189+ }
190+}
191+
192+func (sm *SessionManager) resizeAndCenter() {
193+ if sm.outerBox == nil || sm.mainBox == nil {
194+ return
195+ }
196+
197+ var parentWidth, parentHeight int
198+
199+ if sm.parentOverlay != nil {
200+ parentWidth = sm.parentOverlay.GetAllocatedWidth()
201+ parentHeight = sm.parentOverlay.GetAllocatedHeight()
202+ }
203+
204+ // Use defaults if parent overlay not set or not yet allocated
205+ if parentWidth <= 0 {
206+ parentWidth = sessionManagerMaxWidth
207+ }
208+ if parentHeight <= 0 {
209+ parentHeight = 600
210+ }
211+
212+ width := int(float64(parentWidth) * sessionManagerWidthPct)
213+ if width > sessionManagerMaxWidth {
214+ width = sessionManagerMaxWidth
215+ }
216+
217+ marginTop := int(float64(parentHeight) * sessionManagerTopMargin)
218+
219+ sm.mainBox.SetSizeRequest(width, -1)
220+ sm.outerBox.SetMarginTop(marginTop)
221+}
222+
223+func (sm *SessionManager) createWidgets() error {
224+ // Outer container for positioning
225+ sm.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
226+ if sm.outerBox == nil {
227+ return errNilWidget("sessionOuterBox")
228+ }
229+ sm.outerBox.AddCssClass("session-manager-outer")
230+ sm.outerBox.SetHalign(gtk.AlignCenterValue)
231+ sm.outerBox.SetValign(gtk.AlignStartValue)
232+ sm.outerBox.SetVisible(false)
233+
234+ // Main container with styling
235+ sm.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
236+ if sm.mainBox == nil {
237+ return errNilWidget("sessionMainBox")
238+ }
239+ sm.mainBox.AddCssClass("session-manager-container")
240+
241+ // Header
242+ if err := sm.initHeader(); err != nil {
243+ return err
244+ }
245+
246+ // Scrolled window for list
247+ if err := sm.initList(); err != nil {
248+ return err
249+ }
250+
251+ // Footer with shortcuts
252+ if err := sm.initFooter(); err != nil {
253+ return err
254+ }
255+
256+ // Assemble
257+ sm.mainBox.Append(&sm.headerBox.Widget)
258+ sm.mainBox.Append(&sm.scrolledWindow.Widget)
259+ sm.mainBox.Append(&sm.footerLabel.Widget)
260+ sm.outerBox.Append(&sm.mainBox.Widget)
261+
262+ return nil
263+}
264+
265+func (sm *SessionManager) initHeader() error {
266+ sm.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
267+ if sm.headerBox == nil {
268+ return errNilWidget("sessionHeaderBox")
269+ }
270+ sm.headerBox.AddCssClass("session-manager-header")
271+
272+ title := "Sessions"
273+ sm.titleLabel = gtk.NewLabel(&title)
274+ if sm.titleLabel == nil {
275+ return errNilWidget("sessionTitleLabel")
276+ }
277+ sm.titleLabel.AddCssClass("session-manager-title")
278+ sm.titleLabel.SetHalign(gtk.AlignStartValue)
279+ sm.titleLabel.SetHexpand(true)
280+ sm.headerBox.Append(&sm.titleLabel.Widget)
281+
282+ // Shortcut badge
283+ shortcutText := "Ctrl+O"
284+ shortcutLabel := gtk.NewLabel(&shortcutText)
285+ if shortcutLabel != nil {
286+ shortcutLabel.AddCssClass("omnibox-shortcut-badge")
287+ sm.headerBox.Append(&shortcutLabel.Widget)
288+ }
289+
290+ return nil
291+}
292+
293+func (sm *SessionManager) initList() error {
294+ sm.scrolledWindow = gtk.NewScrolledWindow()
295+ if sm.scrolledWindow == nil {
296+ return errNilWidget("sessionScrolledWindow")
297+ }
298+ sm.scrolledWindow.AddCssClass("session-manager-scrolled")
299+ sm.scrolledWindow.SetMaxContentHeight(sessionListMaxHeight)
300+ sm.scrolledWindow.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
301+ sm.scrolledWindow.SetPropagateNaturalHeight(true)
302+
303+ sm.listBox = gtk.NewListBox()
304+ if sm.listBox == nil {
305+ return errNilWidget("sessionListBox")
306+ }
307+ sm.listBox.AddCssClass("session-manager-list")
308+ sm.listBox.SetSelectionMode(gtk.SelectionSingleValue)
309+
310+ // Connect row selection
311+ rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
312+ if rowPtr == 0 {
313+ sm.mu.Lock()
314+ sm.selectedIndex = -1
315+ sm.mu.Unlock()
316+ return
317+ }
318+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
319+ if row != nil {
320+ sm.mu.Lock()
321+ sm.selectedIndex = row.GetIndex()
322+ sm.mu.Unlock()
323+ }
324+ }
325+ sm.retainedCallbacks = append(sm.retainedCallbacks, rowSelectedCb)
326+ sm.listBox.ConnectRowSelected(&rowSelectedCb)
327+
328+ sm.scrolledWindow.SetChild(&sm.listBox.Widget)
329+ return nil
330+}
331+
332+func (sm *SessionManager) initFooter() error {
333+ footerText := "↑↓/jk navigate Enter open x delete Esc close"
334+ sm.footerLabel = gtk.NewLabel(&footerText)
335+ if sm.footerLabel == nil {
336+ return errNilWidget("sessionFooterLabel")
337+ }
338+ sm.footerLabel.AddCssClass("session-manager-footer")
339+ sm.footerLabel.SetHalign(gtk.AlignCenterValue)
340+ return nil
341+}
342+
343+func (sm *SessionManager) attachKeyController() {
344+ controller := gtk.NewEventControllerKey()
345+ if controller == nil {
346+ return
347+ }
348+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
349+
350+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
351+ return sm.handleKeyPress(keyval, state)
352+ }
353+ sm.retainedCallbacks = append(sm.retainedCallbacks, keyPressedCb)
354+ controller.ConnectKeyPressed(&keyPressedCb)
355+ sm.outerBox.AddController(&controller.EventController)
356+}
357+
358+func (sm *SessionManager) handleKeyPress(keyval uint, _ gdk.ModifierType) bool {
359+ switch keyval {
360+ case uint(gdk.KEY_Escape):
361+ sm.Hide(sm.ctx)
362+ return true
363+
364+ case uint(gdk.KEY_Up), uint(gdk.KEY_k):
365+ sm.selectPrevious()
366+ return true
367+
368+ case uint(gdk.KEY_Down), uint(gdk.KEY_j):
369+ sm.selectNext()
370+ return true
371+
372+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
373+ sm.openSelected()
374+ return true
375+
376+ case uint(gdk.KEY_x), uint(gdk.KEY_Delete):
377+ sm.deleteSelected()
378+ return true
379+ }
380+ return false
381+}
382+
383+func (sm *SessionManager) loadSessions() {
384+ log := logging.FromContext(sm.ctx)
385+
386+ if sm.listSessionsUC == nil {
387+ log.Warn().Msg("listSessionsUC is nil")
388+ return
389+ }
390+
391+ output, err := sm.listSessionsUC.Execute(sm.ctx, sm.currentSession, sessionDefaultListLimit)
392+ if err != nil {
393+ log.Error().Err(err).Msg("failed to list sessions")
394+ return
395+ }
396+
397+ sm.mu.Lock()
398+ sm.sessions = output.Sessions
399+ sm.selectedIndex = 0
400+ sm.mu.Unlock()
401+
402+ // Update UI on main thread
403+ cb := glib.SourceFunc(func(_ uintptr) bool {
404+ sm.populateList()
405+ return false
406+ })
407+ glib.IdleAdd(&cb, 0)
408+}
409+
410+func (sm *SessionManager) populateList() {
411+ sm.mu.RLock()
412+ sessions := sm.sessions
413+ sm.mu.RUnlock()
414+
415+ if sm.listBox == nil {
416+ return
417+ }
418+
419+ // Clear existing rows
420+ sm.listBox.RemoveAll()
421+
422+ // Separate active and exited sessions
423+ var activeSessions, exitedSessions []entity.SessionInfo
424+ for _, info := range sessions {
425+ if info.IsCurrent || info.IsActive {
426+ activeSessions = append(activeSessions, info)
427+ } else {
428+ exitedSessions = append(exitedSessions, info)
429+ }
430+ }
431+
432+ // Add active session rows
433+ for _, info := range activeSessions {
434+ row := sm.createSessionRow(info)
435+ if row != nil {
436+ sm.listBox.Append(&row.Widget)
437+ }
438+ }
439+
440+ // Add divider if there are exited sessions
441+ if len(exitedSessions) > 0 && len(activeSessions) > 0 {
442+ divider := sm.createDividerRow("EXITED")
443+ if divider != nil {
444+ sm.listBox.Append(&divider.Widget)
445+ }
446+ }
447+
448+ // Add exited session rows
449+ for _, info := range exitedSessions {
450+ row := sm.createSessionRow(info)
451+ if row != nil {
452+ sm.listBox.Append(&row.Widget)
453+ }
454+ }
455+
456+ // Select first row
457+ if len(sessions) > 0 {
458+ firstRow := sm.listBox.GetRowAtIndex(0)
459+ if firstRow != nil {
460+ sm.listBox.SelectRow(firstRow)
461+ }
462+ }
463+
464+ // Resize based on content
465+ rowCount := len(activeSessions) + len(exitedSessions)
466+ if len(exitedSessions) > 0 && len(activeSessions) > 0 {
467+ rowCount++ // divider row
468+ }
469+ sm.resizeForContent(rowCount)
470+}
471+
472+func (sm *SessionManager) resizeForContent(rowCount int) {
473+ if sm.scrolledWindow == nil {
474+ return
475+ }
476+
477+ const rowHeight = 50
478+ contentHeight := rowCount * rowHeight
479+
480+ if contentHeight > sessionListMaxHeight {
481+ contentHeight = sessionListMaxHeight
482+ }
483+
484+ // Schedule resize
485+ cb := glib.SourceFunc(func(_ uintptr) bool {
486+ sm.scrolledWindow.SetMinContentHeight(-1)
487+ sm.scrolledWindow.SetMaxContentHeight(contentHeight)
488+ sm.scrolledWindow.SetMinContentHeight(contentHeight)
489+ sm.outerBox.QueueResize()
490+ return false
491+ })
492+ glib.IdleAdd(&cb, 0)
493+}
494+
495+func (sm *SessionManager) createDividerRow(text string) *gtk.ListBoxRow {
496+ row := gtk.NewListBoxRow()
497+ if row == nil {
498+ return nil
499+ }
500+ row.SetSelectable(false)
501+ row.SetActivatable(false)
502+
503+ label := gtk.NewLabel(&text)
504+ if label == nil {
505+ return nil
506+ }
507+ label.AddCssClass("session-divider")
508+ label.SetHalign(gtk.AlignStartValue)
509+ row.SetChild(&label.Widget)
510+
511+ return row
512+}
513+
514+func (sm *SessionManager) createSessionRow(info entity.SessionInfo) *gtk.ListBoxRow {
515+ row := gtk.NewListBoxRow()
516+ if row == nil {
517+ return nil
518+ }
519+ row.AddCssClass("session-manager-row-wrapper")
520+
521+ hbox := gtk.NewBox(gtk.OrientationHorizontalValue, sessionRowBoxSpacing)
522+ if hbox == nil {
523+ return nil
524+ }
525+ hbox.AddCssClass("session-manager-row")
526+
527+ sm.addStatusIndicator(hbox, info)
528+ sm.addSessionInfo(hbox, info)
529+ sm.addRelativeTime(hbox, info)
530+
531+ row.SetChild(&hbox.Widget)
532+ return row
533+}
534+
535+func (sm *SessionManager) addStatusIndicator(hbox *gtk.Box, info entity.SessionInfo) {
536+ var statusText string
537+ switch {
538+ case info.IsCurrent:
539+ statusText = "●"
540+ hbox.AddCssClass("session-current")
541+ case info.IsActive:
542+ statusText = "○"
543+ hbox.AddCssClass("session-active")
544+ default:
545+ statusText = " "
546+ hbox.AddCssClass("session-exited")
547+ }
548+ statusLabel := gtk.NewLabel(&statusText)
549+ if statusLabel != nil {
550+ statusLabel.AddCssClass("session-status")
551+ hbox.Append(&statusLabel.Widget)
552+ }
553+}
554+
555+func (sm *SessionManager) addSessionInfo(hbox *gtk.Box, info entity.SessionInfo) {
556+ const infoBoxSpacing = 2
557+ infoBox := gtk.NewBox(gtk.OrientationVerticalValue, infoBoxSpacing)
558+ if infoBox == nil {
559+ return
560+ }
561+ infoBox.SetHexpand(true)
562+
563+ // Session ID (short)
564+ idText := string(info.Session.ID)
565+ if len(idText) > sessionIDMaxDisplay {
566+ idText = idText[:sessionIDMaxDisplay]
567+ }
568+ if info.IsCurrent {
569+ idText += " (current)"
570+ }
571+ idLabel := gtk.NewLabel(&idText)
572+ if idLabel != nil {
573+ idLabel.AddCssClass("session-id")
574+ idLabel.SetHalign(gtk.AlignStartValue)
575+ idLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
576+ infoBox.Append(&idLabel.Widget)
577+ }
578+
579+ // Tab/pane count
580+ countText := fmt.Sprintf("%d tabs, %d panes", info.TabCount, info.PaneCount)
581+ countLabel := gtk.NewLabel(&countText)
582+ if countLabel != nil {
583+ countLabel.AddCssClass("session-count")
584+ countLabel.SetHalign(gtk.AlignStartValue)
585+ infoBox.Append(&countLabel.Widget)
586+ }
587+
588+ hbox.Append(&infoBox.Widget)
589+}
590+
591+func (sm *SessionManager) addRelativeTime(hbox *gtk.Box, info entity.SessionInfo) {
592+ relTime := usecase.GetRelativeTime(info.UpdatedAt)
593+ timeLabel := gtk.NewLabel(&relTime)
594+ if timeLabel != nil {
595+ timeLabel.AddCssClass("session-time")
596+ timeLabel.SetValign(gtk.AlignCenterValue)
597+ hbox.Append(&timeLabel.Widget)
598+ }
599+}
600+
601+func (sm *SessionManager) selectIndex(index int) {
602+ row := sm.listBox.GetRowAtIndex(index)
603+ if row != nil {
604+ sm.listBox.SelectRow(row)
605+ }
606+}
607+
608+func (sm *SessionManager) selectPrevious() {
609+ sm.mu.Lock()
610+ if sm.selectedIndex > 0 {
611+ sm.selectedIndex--
612+ }
613+ idx := sm.selectedIndex
614+ sm.mu.Unlock()
615+
616+ sm.selectIndex(idx)
617+}
618+
619+func (sm *SessionManager) selectNext() {
620+ sm.mu.Lock()
621+ maxIndex := len(sm.sessions) - 1
622+ // Account for divider row
623+ if sm.selectedIndex < maxIndex {
624+ sm.selectedIndex++
625+ }
626+ idx := sm.selectedIndex
627+ sm.mu.Unlock()
628+
629+ sm.selectIndex(idx)
630+}
631+
632+func (sm *SessionManager) openSelected() {
633+ sm.mu.RLock()
634+ if sm.selectedIndex < 0 || sm.selectedIndex >= len(sm.sessions) {
635+ sm.mu.RUnlock()
636+ return
637+ }
638+ info := sm.sessions[sm.selectedIndex]
639+ sm.mu.RUnlock()
640+
641+ log := logging.FromContext(sm.ctx)
642+
643+ // Don't open current session
644+ if info.IsCurrent {
645+ log.Debug().Msg("cannot open current session")
646+ return
647+ }
648+
649+ log.Info().Str("session_id", string(info.Session.ID)).Msg("opening session")
650+
651+ sm.Hide(sm.ctx)
652+
653+ if sm.onOpen != nil {
654+ sm.onOpen(info.Session.ID)
655+ }
656+}
657+
658+func (sm *SessionManager) deleteSelected() {
659+ sm.mu.RLock()
660+ if sm.selectedIndex < 0 || sm.selectedIndex >= len(sm.sessions) {
661+ sm.mu.RUnlock()
662+ return
663+ }
664+ info := sm.sessions[sm.selectedIndex]
665+ sm.mu.RUnlock()
666+
667+ log := logging.FromContext(sm.ctx)
668+
669+ // Don't delete current or active sessions
670+ if info.IsCurrent || info.IsActive {
671+ log.Debug().Msg("cannot delete current or active session")
672+ return
673+ }
674+
675+ log.Info().Str("session_id", string(info.Session.ID)).Msg("deleting session")
676+
677+ // TODO: Implement session deletion via use case
678+ // For now, just reload the list
679+ go sm.loadSessions()
680+}
681+
682+// Destroy cleans up session manager resources.
683+func (sm *SessionManager) Destroy() {
684+ sm.mu.Lock()
685+ sm.visible = false
686+ sm.mu.Unlock()
687+
688+ sm.parentOverlay = nil
689+ sm.retainedCallbacks = nil
690+
691+ if sm.outerBox != nil {
692+ sm.outerBox.Unparent()
693+ sm.outerBox = nil
694+ }
695+}
M internal/ui/coordinator/tab.go +21 -0
@@ -24,6 +24,7 @@ type TabCoordinator struct {
2424 onTabSwitched func(ctx context.Context, tab *entity.Tab)
2525 onQuit func()
2626 onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) // For popup tabs
27+ onStateChanged func() // For session snapshots
2728 }
2829
2930 // TabCoordinatorConfig holds configuration for TabCoordinator.
@@ -63,6 +64,18 @@ func (c *TabCoordinator) SetOnQuit(fn func()) {
6364 c.onQuit = fn
6465 }
6566
67+// SetOnStateChanged sets the callback for when tab state changes (for session snapshots).
68+func (c *TabCoordinator) SetOnStateChanged(fn func()) {
69+ c.onStateChanged = fn
70+}
71+
72+// notifyStateChanged triggers the state changed callback if set.
73+func (c *TabCoordinator) notifyStateChanged() {
74+ if c.onStateChanged != nil {
75+ c.onStateChanged()
76+ }
77+}
78+
6679 // Create creates a new tab with the given initial URL.
6780 func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity.Tab, error) {
6881 log := logging.FromContext(ctx)
@@ -99,6 +112,9 @@ func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity
99112 c.onTabSwitched(ctx, output.Tab)
100113 }
101114
115+ // Notify state change for session snapshots
116+ c.notifyStateChanged()
117+
102118 log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("tab created")
103119 return output.Tab, nil
104120 }
@@ -143,6 +159,11 @@ func (c *TabCoordinator) Close(ctx context.Context) error {
143159 }
144160 }
145161
162+ // Notify state change for session snapshots (unless quitting)
163+ if !wasLast {
164+ c.notifyStateChanged()
165+ }
166+
146167 log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed")
147168 return nil
148169 }
@@ -24,6 +24,7 @@ type TabCoordinator struct {
24 onTabSwitched func(ctx context.Context, tab *entity.Tab)24 onTabSwitched func(ctx context.Context, tab *entity.Tab)
25 onQuit func()25 onQuit func()
26 onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) // For popup tabs26 onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) // For popup tabs
27+ onStateChanged func() // For session snapshots
27 }28 }
28 29
29 // TabCoordinatorConfig holds configuration for TabCoordinator.30 // TabCoordinatorConfig holds configuration for TabCoordinator.
@@ -63,6 +64,18 @@ func (c *TabCoordinator) SetOnQuit(fn func()) {
63 c.onQuit = fn64 c.onQuit = fn
64 }65 }
65 66
67+// SetOnStateChanged sets the callback for when tab state changes (for session snapshots).
68+func (c *TabCoordinator) SetOnStateChanged(fn func()) {
69+ c.onStateChanged = fn
70+}
71+
72+// notifyStateChanged triggers the state changed callback if set.
73+func (c *TabCoordinator) notifyStateChanged() {
74+ if c.onStateChanged != nil {
75+ c.onStateChanged()
76+ }
77+}
78+
66 // Create creates a new tab with the given initial URL.79 // Create creates a new tab with the given initial URL.
67 func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity.Tab, error) {80 func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity.Tab, error) {
68 log := logging.FromContext(ctx)81 log := logging.FromContext(ctx)
@@ -99,6 +112,9 @@ func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity
99 c.onTabSwitched(ctx, output.Tab)112 c.onTabSwitched(ctx, output.Tab)
100 }113 }
101 114
115+ // Notify state change for session snapshots
116+ c.notifyStateChanged()
117+
102 log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("tab created")118 log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("tab created")
103 return output.Tab, nil119 return output.Tab, nil
104 }120 }
@@ -143,6 +159,11 @@ func (c *TabCoordinator) Close(ctx context.Context) error {
143 }159 }
144 }160 }
145 161
162+ // Notify state change for session snapshots (unless quitting)
163+ if !wasLast {
164+ c.notifyStateChanged()
165+ }
166+
146 log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed")167 log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed")
147 return nil168 return nil
148 }169 }
M internal/ui/coordinator/workspace.go +25 -0
@@ -32,6 +32,7 @@ type WorkspaceCoordinator struct {
3232 generateID func() string
3333 onCloseLastPane func(ctx context.Context) error
3434 onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior
35+ onStateChanged func() // For session snapshots
3536 }
3637
3738 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
@@ -82,6 +83,18 @@ func (c *WorkspaceCoordinator) SetOnCloseLastPane(fn func(ctx context.Context) e
8283 c.onCloseLastPane = fn
8384 }
8485
86+// SetOnStateChanged sets the callback for when workspace state changes (for session snapshots).
87+func (c *WorkspaceCoordinator) SetOnStateChanged(fn func()) {
88+ c.onStateChanged = fn
89+}
90+
91+// notifyStateChanged triggers the state changed callback if set.
92+func (c *WorkspaceCoordinator) notifyStateChanged() {
93+ if c.onStateChanged != nil {
94+ c.onStateChanged()
95+ }
96+}
97+
8598 // setupPaneViewHover configures hover-to-focus behavior on a PaneView.
8699 func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *component.WorkspaceView) {
87100 pv.SetOnHover(func(paneID entity.PaneID) {
@@ -129,6 +142,9 @@ func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.Spli
129142 c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID)
130143 }
131144
145+ // Notify state change for session snapshots
146+ c.notifyStateChanged()
147+
132148 log.Info().Str("direction", string(direction)).Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).Msg("pane split completed")
133149
134150 return nil
@@ -740,6 +756,9 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
740756 parentIsStartInGrand,
741757 )
742758
759+ // Notify state change for session snapshots
760+ c.notifyStateChanged()
761+
743762 log.Info().Msg("pane closed")
744763 return nil
745764 }
@@ -827,6 +846,9 @@ func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity.
827846 parentIsStartInGrand,
828847 )
829848
849+ // Notify state change for session snapshots
850+ c.notifyStateChanged()
851+
830852 log.Info().Str("pane_id", string(paneID)).Msg("pane closed by ID")
831853 return nil
832854 }
@@ -1240,6 +1262,9 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
12401262 }
12411263 }
12421264
1265+ // Notify state change for session snapshots
1266+ c.notifyStateChanged()
1267+
12431268 log.Info().
12441269 Str("original_pane", string(stackCtx.activePaneID)).
12451270 Str("new_pane", string(newPaneID)).
@@ -32,6 +32,7 @@ type WorkspaceCoordinator struct {
32 generateID func() string32 generateID func() string
33 onCloseLastPane func(ctx context.Context) error33 onCloseLastPane func(ctx context.Context) error
34 onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior34 onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior
35+ onStateChanged func() // For session snapshots
35 }36 }
36 37
37 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.38 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
@@ -82,6 +83,18 @@ func (c *WorkspaceCoordinator) SetOnCloseLastPane(fn func(ctx context.Context) e
82 c.onCloseLastPane = fn83 c.onCloseLastPane = fn
83 }84 }
84 85
86+// SetOnStateChanged sets the callback for when workspace state changes (for session snapshots).
87+func (c *WorkspaceCoordinator) SetOnStateChanged(fn func()) {
88+ c.onStateChanged = fn
89+}
90+
91+// notifyStateChanged triggers the state changed callback if set.
92+func (c *WorkspaceCoordinator) notifyStateChanged() {
93+ if c.onStateChanged != nil {
94+ c.onStateChanged()
95+ }
96+}
97+
85 // setupPaneViewHover configures hover-to-focus behavior on a PaneView.98 // setupPaneViewHover configures hover-to-focus behavior on a PaneView.
86 func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *component.WorkspaceView) {99 func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *component.WorkspaceView) {
87 pv.SetOnHover(func(paneID entity.PaneID) {100 pv.SetOnHover(func(paneID entity.PaneID) {
@@ -129,6 +142,9 @@ func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.Spli
129 c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID)142 c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID)
130 }143 }
131 144
145+ // Notify state change for session snapshots
146+ c.notifyStateChanged()
147+
132 log.Info().Str("direction", string(direction)).Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).Msg("pane split completed")148 log.Info().Str("direction", string(direction)).Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).Msg("pane split completed")
133 149
134 return nil150 return nil
@@ -740,6 +756,9 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
740 parentIsStartInGrand,756 parentIsStartInGrand,
741 )757 )
742 758
759+ // Notify state change for session snapshots
760+ c.notifyStateChanged()
761+
743 log.Info().Msg("pane closed")762 log.Info().Msg("pane closed")
744 return nil763 return nil
745 }764 }
@@ -827,6 +846,9 @@ func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity.
827 parentIsStartInGrand,846 parentIsStartInGrand,
828 )847 )
829 848
849+ // Notify state change for session snapshots
850+ c.notifyStateChanged()
851+
830 log.Info().Str("pane_id", string(paneID)).Msg("pane closed by ID")852 log.Info().Str("pane_id", string(paneID)).Msg("pane closed by ID")
831 return nil853 return nil
832 }854 }
@@ -1240,6 +1262,9 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1240 }1262 }
1241 }1263 }
1242 1264
1265+ // Notify state change for session snapshots
1266+ c.notifyStateChanged()
1267+
1243 log.Info().1268 log.Info().
1244 Str("original_pane", string(stackCtx.activePaneID)).1269 Str("original_pane", string(stackCtx.activePaneID)).
1245 Str("new_pane", string(newPaneID)).1270 Str("new_pane", string(newPaneID)).
M internal/ui/deps.go +7 -0
@@ -6,6 +6,7 @@ import (
66
77 "github.com/bnema/dumber/internal/application/port"
88 "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
910 "github.com/bnema/dumber/internal/domain/repository"
1011 "github.com/bnema/dumber/internal/infrastructure/config"
1112 "github.com/bnema/dumber/internal/infrastructure/favicon"
@@ -53,6 +54,12 @@ type Dependencies struct {
5354 FaviconService *favicon.Service
5455 FilterManager *filtering.Manager
5556 IdleInhibitor port.IdleInhibitor
57+
58+ // Session management
59+ SessionStateRepo repository.SessionStateRepository
60+ SessionRepo repository.SessionRepository
61+ CurrentSessionID entity.SessionID
62+ SnapshotUC *usecase.SnapshotSessionUseCase
5663 }
5764
5865 // Validate checks that all required dependencies are set.
@@ -6,6 +6,7 @@ import (
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/domain/repository"10 "github.com/bnema/dumber/internal/domain/repository"
10 "github.com/bnema/dumber/internal/infrastructure/config"11 "github.com/bnema/dumber/internal/infrastructure/config"
11 "github.com/bnema/dumber/internal/infrastructure/favicon"12 "github.com/bnema/dumber/internal/infrastructure/favicon"
@@ -53,6 +54,12 @@ type Dependencies struct {
53 FaviconService *favicon.Service54 FaviconService *favicon.Service
54 FilterManager *filtering.Manager55 FilterManager *filtering.Manager
55 IdleInhibitor port.IdleInhibitor56 IdleInhibitor port.IdleInhibitor
57+
58+ // Session management
59+ SessionStateRepo repository.SessionStateRepository
60+ SessionRepo repository.SessionRepository
61+ CurrentSessionID entity.SessionID
62+ SnapshotUC *usecase.SnapshotSessionUseCase
56 }63 }
57 64
58 // Validate checks that all required dependencies are set.65 // Validate checks that all required dependencies are set.
M internal/ui/dispatcher/keyboard.go +16 -0
@@ -26,6 +26,7 @@ type KeyboardDispatcher struct {
2626 onFindNext func(ctx context.Context) error
2727 onFindPrev func(ctx context.Context) error
2828 onFindClose func(ctx context.Context) error
29+ onSessionOpen func(ctx context.Context) error
2930 }
3031
3132 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -76,6 +77,11 @@ func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error)
7677 d.onFindClose = fn
7778 }
7879
80+// SetOnSessionOpen sets the callback for opening the session manager.
81+func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error) {
82+ d.onSessionOpen = fn
83+}
84+
7985 func (d *KeyboardDispatcher) initActionHandlers() {
8086 const (
8187 firstTabIndex = 0
@@ -144,6 +150,8 @@ func (d *KeyboardDispatcher) initActionHandlers() {
144150 },
145151 // Clipboard
146152 input.ActionCopyURL: d.handleCopyURL,
153+ // Session management
154+ input.ActionOpenSessionManager: d.handleSessionOpen,
147155 // Application
148156 input.ActionQuit: d.handleQuit,
149157 }
@@ -194,6 +202,14 @@ func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error {
194202 return nil
195203 }
196204
205+func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
206+ if d.onSessionOpen != nil {
207+ return d.onSessionOpen(ctx)
208+ }
209+ logging.FromContext(ctx).Debug().Msg("session open action (no handler)")
210+ return nil
211+}
212+
197213 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
198214 if ctx == nil {
199215 return fmt.Errorf("missing context")
@@ -26,6 +26,7 @@ type KeyboardDispatcher struct {
26 onFindNext func(ctx context.Context) error26 onFindNext func(ctx context.Context) error
27 onFindPrev func(ctx context.Context) error27 onFindPrev func(ctx context.Context) error
28 onFindClose func(ctx context.Context) error28 onFindClose func(ctx context.Context) error
29+ onSessionOpen func(ctx context.Context) error
29 }30 }
30 31
31 // NewKeyboardDispatcher creates a new KeyboardDispatcher.32 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -76,6 +77,11 @@ func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error)
76 d.onFindClose = fn77 d.onFindClose = fn
77 }78 }
78 79
80+// SetOnSessionOpen sets the callback for opening the session manager.
81+func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error) {
82+ d.onSessionOpen = fn
83+}
84+
79 func (d *KeyboardDispatcher) initActionHandlers() {85 func (d *KeyboardDispatcher) initActionHandlers() {
80 const (86 const (
81 firstTabIndex = 087 firstTabIndex = 0
@@ -144,6 +150,8 @@ func (d *KeyboardDispatcher) initActionHandlers() {
144 },150 },
145 // Clipboard151 // Clipboard
146 input.ActionCopyURL: d.handleCopyURL,152 input.ActionCopyURL: d.handleCopyURL,
153+ // Session management
154+ input.ActionOpenSessionManager: d.handleSessionOpen,
147 // Application155 // Application
148 input.ActionQuit: d.handleQuit,156 input.ActionQuit: d.handleQuit,
149 }157 }
@@ -194,6 +202,14 @@ func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error {
194 return nil202 return nil
195 }203 }
196 204
205+func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
206+ if d.onSessionOpen != nil {
207+ return d.onSessionOpen(ctx)
208+ }
209+ logging.FromContext(ctx).Debug().Msg("session open action (no handler)")
210+ return nil
211+}
212+
197 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {213 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
198 if ctx == nil {214 if ctx == nil {
199 return fmt.Errorf("missing context")215 return fmt.Errorf("missing context")
M internal/ui/input/keyboard.go +18 -9
@@ -21,7 +21,7 @@ type ActionHandler func(ctx context.Context, action Action) error
2121 type KeyboardHandler struct {
2222 shortcuts *ShortcutSet
2323 modal *ModalState
24- cfg *config.WorkspaceConfig
24+ cfg *config.Config
2525
2626 // Action handler callback
2727 onAction ActionHandler
@@ -39,10 +39,7 @@ type KeyboardHandler struct {
3939 }
4040
4141 // NewKeyboardHandler creates a new keyboard handler.
42-func NewKeyboardHandler(
43- ctx context.Context,
44- cfg *config.WorkspaceConfig,
45-) *KeyboardHandler {
42+func NewKeyboardHandler(ctx context.Context, cfg *config.Config) *KeyboardHandler {
4643 log := logging.FromContext(ctx)
4744
4845 log.Debug().Msg("creating keyboard handler")
@@ -157,15 +154,20 @@ func (h *KeyboardHandler) handleKeyPress(keyval uint, state gdk.ModifierType) bo
157154 // Handle special mode actions first
158155 switch action {
159156 case ActionEnterTabMode:
160- timeout := time.Duration(h.cfg.TabMode.TimeoutMilliseconds) * time.Millisecond
157+ timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
161158 h.modal.EnterTabMode(h.ctx, timeout)
162159 return true
163160
164161 case ActionEnterPaneMode:
165- timeout := time.Duration(h.cfg.PaneMode.TimeoutMilliseconds) * time.Millisecond
162+ timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
166163 h.modal.EnterPaneMode(h.ctx, timeout)
167164 return true
168165
166+ case ActionEnterSessionMode:
167+ timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
168+ h.modal.EnterSessionMode(h.ctx, timeout)
169+ return true
170+
169171 case ActionExitMode:
170172 h.modal.ExitMode(h.ctx)
171173 return true
@@ -197,17 +199,24 @@ func (h *KeyboardHandler) handleKeyPress(keyval uint, state gdk.ModifierType) bo
197199 // EnterTabMode programmatically enters tab mode.
198200 // Useful for testing or programmatic mode changes.
199201 func (h *KeyboardHandler) EnterTabMode() {
200- timeout := time.Duration(h.cfg.TabMode.TimeoutMilliseconds) * time.Millisecond
202+ timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
201203 h.modal.EnterTabMode(h.ctx, timeout)
202204 }
203205
204206 // EnterPaneMode programmatically enters pane mode.
205207 // Useful for testing or programmatic mode changes.
206208 func (h *KeyboardHandler) EnterPaneMode() {
207- timeout := time.Duration(h.cfg.PaneMode.TimeoutMilliseconds) * time.Millisecond
209+ timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
208210 h.modal.EnterPaneMode(h.ctx, timeout)
209211 }
210212
213+// EnterSessionMode programmatically enters session mode.
214+// Useful for testing or programmatic mode changes.
215+func (h *KeyboardHandler) EnterSessionMode() {
216+ timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
217+ h.modal.EnterSessionMode(h.ctx, timeout)
218+}
219+
211220 // ExitMode programmatically exits modal mode.
212221 // Useful for testing or programmatic mode changes.
213222 func (h *KeyboardHandler) ExitMode() {
@@ -21,7 +21,7 @@ type ActionHandler func(ctx context.Context, action Action) error
21 type KeyboardHandler struct {21 type KeyboardHandler struct {
22 shortcuts *ShortcutSet22 shortcuts *ShortcutSet
23 modal *ModalState23 modal *ModalState
24- cfg *config.WorkspaceConfig24+ cfg *config.Config
25 25
26 // Action handler callback26 // Action handler callback
27 onAction ActionHandler27 onAction ActionHandler
@@ -39,10 +39,7 @@ type KeyboardHandler struct {
39 }39 }
40 40
41 // NewKeyboardHandler creates a new keyboard handler.41 // NewKeyboardHandler creates a new keyboard handler.
42-func NewKeyboardHandler(42+func NewKeyboardHandler(ctx context.Context, cfg *config.Config) *KeyboardHandler {
43- ctx context.Context,
44- cfg *config.WorkspaceConfig,
45-) *KeyboardHandler {
46 log := logging.FromContext(ctx)43 log := logging.FromContext(ctx)
47 44
48 log.Debug().Msg("creating keyboard handler")45 log.Debug().Msg("creating keyboard handler")
@@ -157,15 +154,20 @@ func (h *KeyboardHandler) handleKeyPress(keyval uint, state gdk.ModifierType) bo
157 // Handle special mode actions first154 // Handle special mode actions first
158 switch action {155 switch action {
159 case ActionEnterTabMode:156 case ActionEnterTabMode:
160- timeout := time.Duration(h.cfg.TabMode.TimeoutMilliseconds) * time.Millisecond157+ timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
161 h.modal.EnterTabMode(h.ctx, timeout)158 h.modal.EnterTabMode(h.ctx, timeout)
162 return true159 return true
163 160
164 case ActionEnterPaneMode:161 case ActionEnterPaneMode:
165- timeout := time.Duration(h.cfg.PaneMode.TimeoutMilliseconds) * time.Millisecond162+ timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
166 h.modal.EnterPaneMode(h.ctx, timeout)163 h.modal.EnterPaneMode(h.ctx, timeout)
167 return true164 return true
168 165
166+ case ActionEnterSessionMode:
167+ timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
168+ h.modal.EnterSessionMode(h.ctx, timeout)
169+ return true
170+
169 case ActionExitMode:171 case ActionExitMode:
170 h.modal.ExitMode(h.ctx)172 h.modal.ExitMode(h.ctx)
171 return true173 return true
@@ -197,17 +199,24 @@ func (h *KeyboardHandler) handleKeyPress(keyval uint, state gdk.ModifierType) bo
197 // EnterTabMode programmatically enters tab mode.199 // EnterTabMode programmatically enters tab mode.
198 // Useful for testing or programmatic mode changes.200 // Useful for testing or programmatic mode changes.
199 func (h *KeyboardHandler) EnterTabMode() {201 func (h *KeyboardHandler) EnterTabMode() {
200- timeout := time.Duration(h.cfg.TabMode.TimeoutMilliseconds) * time.Millisecond202+ timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
201 h.modal.EnterTabMode(h.ctx, timeout)203 h.modal.EnterTabMode(h.ctx, timeout)
202 }204 }
203 205
204 // EnterPaneMode programmatically enters pane mode.206 // EnterPaneMode programmatically enters pane mode.
205 // Useful for testing or programmatic mode changes.207 // Useful for testing or programmatic mode changes.
206 func (h *KeyboardHandler) EnterPaneMode() {208 func (h *KeyboardHandler) EnterPaneMode() {
207- timeout := time.Duration(h.cfg.PaneMode.TimeoutMilliseconds) * time.Millisecond209+ timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
208 h.modal.EnterPaneMode(h.ctx, timeout)210 h.modal.EnterPaneMode(h.ctx, timeout)
209 }211 }
210 212
213+// EnterSessionMode programmatically enters session mode.
214+// Useful for testing or programmatic mode changes.
215+func (h *KeyboardHandler) EnterSessionMode() {
216+ timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
217+ h.modal.EnterSessionMode(h.ctx, timeout)
218+}
219+
211 // ExitMode programmatically exits modal mode.220 // ExitMode programmatically exits modal mode.
212 // Useful for testing or programmatic mode changes.221 // Useful for testing or programmatic mode changes.
213 func (h *KeyboardHandler) ExitMode() {222 func (h *KeyboardHandler) ExitMode() {
M internal/ui/input/modal.go +39 -0
@@ -18,6 +18,8 @@ const (
1818 ModeTab
1919 // ModePane is the modal pane management mode.
2020 ModePane
21+ // ModeSession is the modal session management mode.
22+ ModeSession
2123 )
2224
2325 // String returns a human-readable mode name.
@@ -29,6 +31,8 @@ func (m Mode) String() string {
2931 return "tab"
3032 case ModePane:
3133 return "pane"
34+ case ModeSession:
35+ return "session"
3236 default:
3337 return "unknown"
3438 }
@@ -132,6 +136,41 @@ func (m *ModalState) EnterPaneMode(ctx context.Context, timeout time.Duration) {
132136 }
133137 }
134138
139+// EnterSessionMode switches to session mode with an optional timeout.
140+// If timeout is 0, the mode stays until explicitly exited.
141+func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration) {
142+ log := logging.FromContext(ctx)
143+ m.mu.Lock()
144+ defer m.mu.Unlock()
145+
146+ if m.mode == ModeSession {
147+ // Already in session mode, just reset timeout
148+ m.resetTimeoutLocked(ctx, timeout)
149+ return
150+ }
151+
152+ m.cancelTimerLocked()
153+ oldMode := m.mode
154+ m.mode = ModeSession
155+ m.timeout = timeout
156+
157+ if timeout > 0 {
158+ m.timer = time.AfterFunc(timeout, func() {
159+ m.ExitMode(ctx)
160+ })
161+ }
162+
163+ log.Debug().
164+ Str("from", oldMode.String()).
165+ Str("to", m.mode.String()).
166+ Dur("timeout", timeout).
167+ Msg("entered session mode")
168+
169+ if m.onModeChange != nil {
170+ m.onModeChange(oldMode, m.mode)
171+ }
172+}
173+
135174 // ExitMode returns to normal mode.
136175 func (m *ModalState) ExitMode(ctx context.Context) {
137176 log := logging.FromContext(ctx)
@@ -18,6 +18,8 @@ const (
18 ModeTab18 ModeTab
19 // ModePane is the modal pane management mode.19 // ModePane is the modal pane management mode.
20 ModePane20 ModePane
21+ // ModeSession is the modal session management mode.
22+ ModeSession
21 )23 )
22 24
23 // String returns a human-readable mode name.25 // String returns a human-readable mode name.
@@ -29,6 +31,8 @@ func (m Mode) String() string {
29 return "tab"31 return "tab"
30 case ModePane:32 case ModePane:
31 return "pane"33 return "pane"
34+ case ModeSession:
35+ return "session"
32 default:36 default:
33 return "unknown"37 return "unknown"
34 }38 }
@@ -132,6 +136,41 @@ func (m *ModalState) EnterPaneMode(ctx context.Context, timeout time.Duration) {
132 }136 }
133 }137 }
134 138
139+// EnterSessionMode switches to session mode with an optional timeout.
140+// If timeout is 0, the mode stays until explicitly exited.
141+func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration) {
142+ log := logging.FromContext(ctx)
143+ m.mu.Lock()
144+ defer m.mu.Unlock()
145+
146+ if m.mode == ModeSession {
147+ // Already in session mode, just reset timeout
148+ m.resetTimeoutLocked(ctx, timeout)
149+ return
150+ }
151+
152+ m.cancelTimerLocked()
153+ oldMode := m.mode
154+ m.mode = ModeSession
155+ m.timeout = timeout
156+
157+ if timeout > 0 {
158+ m.timer = time.AfterFunc(timeout, func() {
159+ m.ExitMode(ctx)
160+ })
161+ }
162+
163+ log.Debug().
164+ Str("from", oldMode.String()).
165+ Str("to", m.mode.String()).
166+ Dur("timeout", timeout).
167+ Msg("entered session mode")
168+
169+ if m.onModeChange != nil {
170+ m.onModeChange(oldMode, m.mode)
171+ }
172+}
173+
135 // ExitMode returns to normal mode.174 // ExitMode returns to normal mode.
136 func (m *ModalState) ExitMode(ctx context.Context) {175 func (m *ModalState) ExitMode(ctx context.Context) {
137 log := logging.FromContext(ctx)176 log := logging.FromContext(ctx)
M internal/ui/input/shortcuts.go +54 -20
@@ -93,9 +93,10 @@ type Action string
9393 // Predefined actions for the keyboard system.
9494 const (
9595 // Mode management
96- ActionEnterTabMode Action = "enter_tab_mode"
97- ActionEnterPaneMode Action = "enter_pane_mode"
98- ActionExitMode Action = "exit_mode"
96+ ActionEnterTabMode Action = "enter_tab_mode"
97+ ActionEnterPaneMode Action = "enter_pane_mode"
98+ ActionEnterSessionMode Action = "enter_session_mode"
99+ ActionExitMode Action = "exit_mode"
99100
100101 // Tab actions (global and modal)
101102 ActionNewTab Action = "new_tab"
@@ -157,6 +158,9 @@ const (
157158 // Clipboard
158159 ActionCopyURL Action = "copy_url"
159160
161+ // Session management
162+ ActionOpenSessionManager Action = "open_session_manager"
163+
160164 // Application
161165 ActionQuit Action = "quit"
162166 )
@@ -172,30 +176,39 @@ type ShortcutSet struct {
172176 TabMode ShortcutTable
173177 // PaneMode shortcuts are only active in pane mode.
174178 PaneMode ShortcutTable
179+ // SessionMode shortcuts are only active in session mode.
180+ SessionMode ShortcutTable
175181 }
176182
177-// NewShortcutSet creates a ShortcutSet from the workspace configuration.
178-func NewShortcutSet(ctx context.Context, cfg *config.WorkspaceConfig) *ShortcutSet {
183+// NewShortcutSet creates a ShortcutSet from the configuration.
184+func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet {
179185 log := logging.FromContext(ctx)
180186 set := &ShortcutSet{
181- Global: make(ShortcutTable),
182- TabMode: make(ShortcutTable),
183- PaneMode: make(ShortcutTable),
187+ Global: make(ShortcutTable),
188+ TabMode: make(ShortcutTable),
189+ PaneMode: make(ShortcutTable),
190+ SessionMode: make(ShortcutTable),
184191 }
185192
186193 set.buildGlobalShortcuts(ctx, cfg)
187- set.buildTabModeShortcuts(ctx, cfg)
188- set.buildPaneModeShortcuts(ctx, cfg)
194+ set.buildTabModeShortcuts(ctx, &cfg.Workspace)
195+ set.buildPaneModeShortcuts(ctx, &cfg.Workspace)
196+ set.buildSessionModeShortcuts(ctx, &cfg.Session)
189197
190- log.Debug().Int("global", len(set.Global)).Int("tab", len(set.TabMode)).Int("pane", len(set.PaneMode)).Msg("shortcuts registered")
198+ log.Debug().
199+ Int("global", len(set.Global)).
200+ Int("tab", len(set.TabMode)).
201+ Int("pane", len(set.PaneMode)).
202+ Int("session", len(set.SessionMode)).
203+ Msg("shortcuts registered")
191204
192205 return set
193206 }
194207
195208 // buildGlobalShortcuts populates global shortcuts from config.
196-func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
209+func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Config) {
197210 s.registerActivationShortcuts(ctx, cfg)
198- s.registerConfiguredShortcuts(cfg)
211+ s.registerConfiguredShortcuts(&cfg.Workspace)
199212 s.registerStandardShortcuts()
200213 s.registerPaneNavigationShortcuts()
201214 s.registerTabSwitchShortcuts()
@@ -211,27 +224,42 @@ func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *config.Wo
211224 s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane")
212225 }
213226
214-func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
227+// buildSessionModeShortcuts populates session mode shortcuts from config.
228+func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *config.SessionConfig) {
229+ s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session")
230+}
231+
232+func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.Config) {
215233 log := logging.FromContext(ctx)
216- if binding, ok := ParseKeyString(cfg.TabMode.ActivationShortcut); ok {
234+ if binding, ok := ParseKeyString(cfg.Workspace.TabMode.ActivationShortcut); ok {
217235 s.Global[binding] = ActionEnterTabMode
218236 log.Trace().
219- Str("shortcut", cfg.TabMode.ActivationShortcut).
237+ Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut).
220238 Uint("keyval", binding.Keyval).
221239 Uint("mod", uint(binding.Modifiers)).
222240 Msg("tab mode activation registered")
223241 } else {
224- log.Warn().Str("shortcut", cfg.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
242+ log.Warn().Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
225243 }
226- if binding, ok := ParseKeyString(cfg.PaneMode.ActivationShortcut); ok {
244+ if binding, ok := ParseKeyString(cfg.Workspace.PaneMode.ActivationShortcut); ok {
227245 s.Global[binding] = ActionEnterPaneMode
228246 log.Trace().
229- Str("shortcut", cfg.PaneMode.ActivationShortcut).
247+ Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut).
230248 Uint("keyval", binding.Keyval).
231249 Uint("mod", uint(binding.Modifiers)).
232250 Msg("pane mode activation registered")
233251 } else {
234- log.Warn().Str("shortcut", cfg.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
252+ log.Warn().Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
253+ }
254+ if binding, ok := ParseKeyString(cfg.Session.SessionMode.ActivationShortcut); ok {
255+ s.Global[binding] = ActionEnterSessionMode
256+ log.Trace().
257+ Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).
258+ Uint("keyval", binding.Keyval).
259+ Uint("mod", uint(binding.Modifiers)).
260+ Msg("session mode activation registered")
261+ } else {
262+ log.Warn().Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
235263 }
236264 }
237265
@@ -379,6 +407,10 @@ func mapConfigAction(configAction string) Action {
379407 case "stack-nav-down", "stack-down":
380408 return ActionStackNavDown
381409
410+ // Session actions
411+ case "session-manager":
412+ return ActionOpenSessionManager
413+
382414 default:
383415 return ""
384416 }
@@ -454,6 +486,8 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
454486 modeTable = s.TabMode
455487 case ModePane:
456488 modeTable = s.PaneMode
489+ case ModeSession:
490+ modeTable = s.SessionMode
457491 }
458492
459493 if modeTable != nil {
@@ -93,9 +93,10 @@ type Action string
93 // Predefined actions for the keyboard system.93 // Predefined actions for the keyboard system.
94 const (94 const (
95 // Mode management95 // Mode management
96- ActionEnterTabMode Action = "enter_tab_mode"96+ ActionEnterTabMode Action = "enter_tab_mode"
97- ActionEnterPaneMode Action = "enter_pane_mode"97+ ActionEnterPaneMode Action = "enter_pane_mode"
98- ActionExitMode Action = "exit_mode"98+ ActionEnterSessionMode Action = "enter_session_mode"
99+ ActionExitMode Action = "exit_mode"
99 100
100 // Tab actions (global and modal)101 // Tab actions (global and modal)
101 ActionNewTab Action = "new_tab"102 ActionNewTab Action = "new_tab"
@@ -157,6 +158,9 @@ const (
157 // Clipboard158 // Clipboard
158 ActionCopyURL Action = "copy_url"159 ActionCopyURL Action = "copy_url"
159 160
161+ // Session management
162+ ActionOpenSessionManager Action = "open_session_manager"
163+
160 // Application164 // Application
161 ActionQuit Action = "quit"165 ActionQuit Action = "quit"
162 )166 )
@@ -172,30 +176,39 @@ type ShortcutSet struct {
172 TabMode ShortcutTable176 TabMode ShortcutTable
173 // PaneMode shortcuts are only active in pane mode.177 // PaneMode shortcuts are only active in pane mode.
174 PaneMode ShortcutTable178 PaneMode ShortcutTable
179+ // SessionMode shortcuts are only active in session mode.
180+ SessionMode ShortcutTable
175 }181 }
176 182
177-// NewShortcutSet creates a ShortcutSet from the workspace configuration.183+// NewShortcutSet creates a ShortcutSet from the configuration.
178-func NewShortcutSet(ctx context.Context, cfg *config.WorkspaceConfig) *ShortcutSet {184+func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet {
179 log := logging.FromContext(ctx)185 log := logging.FromContext(ctx)
180 set := &ShortcutSet{186 set := &ShortcutSet{
181- Global: make(ShortcutTable),187+ Global: make(ShortcutTable),
182- TabMode: make(ShortcutTable),188+ TabMode: make(ShortcutTable),
183- PaneMode: make(ShortcutTable),189+ PaneMode: make(ShortcutTable),
190+ SessionMode: make(ShortcutTable),
184 }191 }
185 192
186 set.buildGlobalShortcuts(ctx, cfg)193 set.buildGlobalShortcuts(ctx, cfg)
187- set.buildTabModeShortcuts(ctx, cfg)194+ set.buildTabModeShortcuts(ctx, &cfg.Workspace)
188- set.buildPaneModeShortcuts(ctx, cfg)195+ set.buildPaneModeShortcuts(ctx, &cfg.Workspace)
196+ set.buildSessionModeShortcuts(ctx, &cfg.Session)
189 197
190- log.Debug().Int("global", len(set.Global)).Int("tab", len(set.TabMode)).Int("pane", len(set.PaneMode)).Msg("shortcuts registered")198+ log.Debug().
199+ Int("global", len(set.Global)).
200+ Int("tab", len(set.TabMode)).
201+ Int("pane", len(set.PaneMode)).
202+ Int("session", len(set.SessionMode)).
203+ Msg("shortcuts registered")
191 204
192 return set205 return set
193 }206 }
194 207
195 // buildGlobalShortcuts populates global shortcuts from config.208 // buildGlobalShortcuts populates global shortcuts from config.
196-func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {209+func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Config) {
197 s.registerActivationShortcuts(ctx, cfg)210 s.registerActivationShortcuts(ctx, cfg)
198- s.registerConfiguredShortcuts(cfg)211+ s.registerConfiguredShortcuts(&cfg.Workspace)
199 s.registerStandardShortcuts()212 s.registerStandardShortcuts()
200 s.registerPaneNavigationShortcuts()213 s.registerPaneNavigationShortcuts()
201 s.registerTabSwitchShortcuts()214 s.registerTabSwitchShortcuts()
@@ -211,27 +224,42 @@ func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *config.Wo
211 s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane")224 s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane")
212 }225 }
213 226
214-func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {227+// buildSessionModeShortcuts populates session mode shortcuts from config.
228+func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *config.SessionConfig) {
229+ s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session")
230+}
231+
232+func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.Config) {
215 log := logging.FromContext(ctx)233 log := logging.FromContext(ctx)
216- if binding, ok := ParseKeyString(cfg.TabMode.ActivationShortcut); ok {234+ if binding, ok := ParseKeyString(cfg.Workspace.TabMode.ActivationShortcut); ok {
217 s.Global[binding] = ActionEnterTabMode235 s.Global[binding] = ActionEnterTabMode
218 log.Trace().236 log.Trace().
219- Str("shortcut", cfg.TabMode.ActivationShortcut).237+ Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut).
220 Uint("keyval", binding.Keyval).238 Uint("keyval", binding.Keyval).
221 Uint("mod", uint(binding.Modifiers)).239 Uint("mod", uint(binding.Modifiers)).
222 Msg("tab mode activation registered")240 Msg("tab mode activation registered")
223 } else {241 } else {
224- log.Warn().Str("shortcut", cfg.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")242+ log.Warn().Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
225 }243 }
226- if binding, ok := ParseKeyString(cfg.PaneMode.ActivationShortcut); ok {244+ if binding, ok := ParseKeyString(cfg.Workspace.PaneMode.ActivationShortcut); ok {
227 s.Global[binding] = ActionEnterPaneMode245 s.Global[binding] = ActionEnterPaneMode
228 log.Trace().246 log.Trace().
229- Str("shortcut", cfg.PaneMode.ActivationShortcut).247+ Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut).
230 Uint("keyval", binding.Keyval).248 Uint("keyval", binding.Keyval).
231 Uint("mod", uint(binding.Modifiers)).249 Uint("mod", uint(binding.Modifiers)).
232 Msg("pane mode activation registered")250 Msg("pane mode activation registered")
233 } else {251 } else {
234- log.Warn().Str("shortcut", cfg.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")252+ log.Warn().Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
253+ }
254+ if binding, ok := ParseKeyString(cfg.Session.SessionMode.ActivationShortcut); ok {
255+ s.Global[binding] = ActionEnterSessionMode
256+ log.Trace().
257+ Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).
258+ Uint("keyval", binding.Keyval).
259+ Uint("mod", uint(binding.Modifiers)).
260+ Msg("session mode activation registered")
261+ } else {
262+ log.Warn().Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
235 }263 }
236 }264 }
237 265
@@ -379,6 +407,10 @@ func mapConfigAction(configAction string) Action {
379 case "stack-nav-down", "stack-down":407 case "stack-nav-down", "stack-down":
380 return ActionStackNavDown408 return ActionStackNavDown
381 409
410+ // Session actions
411+ case "session-manager":
412+ return ActionOpenSessionManager
413+
382 default:414 default:
383 return ""415 return ""
384 }416 }
@@ -454,6 +486,8 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
454 modeTable = s.TabMode486 modeTable = s.TabMode
455 case ModePane:487 case ModePane:
456 modeTable = s.PaneMode488 modeTable = s.PaneMode
489+ case ModeSession:
490+ modeTable = s.SessionMode
457 }491 }
458 492
459 if modeTable != nil {493 if modeTable != nil {
M internal/ui/theme/css.go +155 -0
@@ -106,6 +106,10 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
106106
107107 // Toaster styling
108108 sb.WriteString(generateToasterCSS(p))
109+ sb.WriteString("\n")
110+
111+ // Session manager styling
112+ sb.WriteString(generateSessionManagerCSS(p))
109113
110114 return sb.String()
111115 }
@@ -553,6 +557,157 @@ progressbar.osd progress {
553557 `
554558 }
555559
560+// generateSessionManagerCSS creates session manager modal styles.
561+// Uses em units for scalable UI, matches omnibox styling patterns.
562+func generateSessionManagerCSS(p Palette) string {
563+ return `/* ===== Session Manager Styling ===== */
564+
565+/* Session manager outer container - for positioning in overlay */
566+.session-manager-outer {
567+ /* Positioning is handled via SetHalign/SetValign in Go */
568+}
569+
570+/* Session manager main container - the visible popup */
571+.session-manager-container {
572+ background-color: var(--surface-variant);
573+ border: 0.0625em solid var(--border);
574+ border-radius: 0.1875em;
575+ padding: 0;
576+ min-width: 28em;
577+ max-width: 40em;
578+}
579+
580+/* Header with title */
581+.session-manager-header {
582+ background-color: shade(var(--surface-variant), 1.1);
583+ border-bottom: 0.0625em solid var(--border);
584+ padding: 0.5em 0.75em;
585+}
586+
587+.session-manager-title {
588+ font-size: 0.9375em;
589+ font-weight: 600;
590+ color: var(--text);
591+}
592+
593+/* Scrolled window for session list */
594+.session-manager-scrolled {
595+ background-color: shade(var(--surface-variant), 0.95);
596+}
597+
598+/* List box */
599+.session-manager-list {
600+ background-color: transparent;
601+}
602+
603+/* Session rows */
604+.session-manager-row {
605+ padding: 0.5em 0.75em;
606+ margin: 0;
607+ border-radius: 0;
608+ border-left: 0.1875em solid transparent;
609+ border-bottom: 0.0625em solid alpha(var(--border), 0.5);
610+ transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;
611+ min-height: 2.75em;
612+}
613+
614+.session-manager-row:last-child {
615+ border-bottom: none;
616+}
617+
618+.session-manager-row:hover {
619+ background-color: alpha(var(--accent), 0.12);
620+ border-left: 0.1875em solid var(--accent);
621+}
622+
623+.session-manager-row:selected,
624+row:selected .session-manager-row {
625+ background-color: alpha(var(--accent), 0.2);
626+ border-left: 0.1875em solid var(--accent);
627+}
628+
629+/* Status indicator dot */
630+.session-status {
631+ font-size: 0.875em;
632+ min-width: 1em;
633+ margin-right: 0.5em;
634+}
635+
636+/* Current session - accent color dot */
637+.session-current .session-status {
638+ color: var(--accent);
639+}
640+
641+/* Active session (other instance) - muted dot */
642+.session-active .session-status {
643+ color: var(--muted);
644+}
645+
646+/* Exited session - no dot, dimmed */
647+.session-exited {
648+ opacity: 0.7;
649+}
650+
651+.session-exited .session-status {
652+ color: transparent;
653+}
654+
655+/* Session ID label */
656+.session-id {
657+ font-size: 0.875em;
658+ font-weight: 500;
659+ color: var(--text);
660+ font-family: var(--font-mono);
661+}
662+
663+.session-current .session-id {
664+ color: var(--accent);
665+}
666+
667+/* Tab/pane count label */
668+.session-count {
669+ font-size: 0.75em;
670+ color: var(--muted);
671+}
672+
673+/* Relative time label */
674+.session-time {
675+ font-size: 0.75em;
676+ color: var(--muted);
677+ margin-left: auto;
678+}
679+
680+/* Section divider for EXITED sessions */
681+.session-divider {
682+ font-size: 0.6875em;
683+ font-weight: 600;
684+ color: var(--muted);
685+ text-transform: uppercase;
686+ letter-spacing: 0.05em;
687+ padding: 0.5em 0.75em 0.25em 0.75em;
688+ background-color: alpha(var(--border), 0.3);
689+ border-bottom: 0.0625em solid var(--border);
690+}
691+
692+/* Footer with keyboard shortcuts */
693+.session-manager-footer {
694+ background-color: shade(var(--surface-variant), 0.9);
695+ border-top: 0.0625em solid var(--border);
696+ padding: 0.375em 0.75em;
697+ font-size: 0.6875em;
698+ color: var(--muted);
699+ font-family: var(--font-mono);
700+}
701+
702+/* Session mode border (purple) */
703+.session-mode-active {
704+ background-color: transparent;
705+ box-shadow: inset 0 0 0 0.25em #9B59B6;
706+ border-radius: 0;
707+}
708+`
709+}
710+
556711 // generateToasterCSS creates toast notification styles.
557712 // Uses em units for scalable UI.
558713 func generateToasterCSS(p Palette) string {
@@ -106,6 +106,10 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
106 106
107 // Toaster styling107 // Toaster styling
108 sb.WriteString(generateToasterCSS(p))108 sb.WriteString(generateToasterCSS(p))
109+ sb.WriteString("\n")
110+
111+ // Session manager styling
112+ sb.WriteString(generateSessionManagerCSS(p))
109 113
110 return sb.String()114 return sb.String()
111 }115 }
@@ -553,6 +557,157 @@ progressbar.osd progress {
553 `557 `
554 }558 }
555 559
560+// generateSessionManagerCSS creates session manager modal styles.
561+// Uses em units for scalable UI, matches omnibox styling patterns.
562+func generateSessionManagerCSS(p Palette) string {
563+ return `/* ===== Session Manager Styling ===== */
564+
565+/* Session manager outer container - for positioning in overlay */
566+.session-manager-outer {
567+ /* Positioning is handled via SetHalign/SetValign in Go */
568+}
569+
570+/* Session manager main container - the visible popup */
571+.session-manager-container {
572+ background-color: var(--surface-variant);
573+ border: 0.0625em solid var(--border);
574+ border-radius: 0.1875em;
575+ padding: 0;
576+ min-width: 28em;
577+ max-width: 40em;
578+}
579+
580+/* Header with title */
581+.session-manager-header {
582+ background-color: shade(var(--surface-variant), 1.1);
583+ border-bottom: 0.0625em solid var(--border);
584+ padding: 0.5em 0.75em;
585+}
586+
587+.session-manager-title {
588+ font-size: 0.9375em;
589+ font-weight: 600;
590+ color: var(--text);
591+}
592+
593+/* Scrolled window for session list */
594+.session-manager-scrolled {
595+ background-color: shade(var(--surface-variant), 0.95);
596+}
597+
598+/* List box */
599+.session-manager-list {
600+ background-color: transparent;
601+}
602+
603+/* Session rows */
604+.session-manager-row {
605+ padding: 0.5em 0.75em;
606+ margin: 0;
607+ border-radius: 0;
608+ border-left: 0.1875em solid transparent;
609+ border-bottom: 0.0625em solid alpha(var(--border), 0.5);
610+ transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;
611+ min-height: 2.75em;
612+}
613+
614+.session-manager-row:last-child {
615+ border-bottom: none;
616+}
617+
618+.session-manager-row:hover {
619+ background-color: alpha(var(--accent), 0.12);
620+ border-left: 0.1875em solid var(--accent);
621+}
622+
623+.session-manager-row:selected,
624+row:selected .session-manager-row {
625+ background-color: alpha(var(--accent), 0.2);
626+ border-left: 0.1875em solid var(--accent);
627+}
628+
629+/* Status indicator dot */
630+.session-status {
631+ font-size: 0.875em;
632+ min-width: 1em;
633+ margin-right: 0.5em;
634+}
635+
636+/* Current session - accent color dot */
637+.session-current .session-status {
638+ color: var(--accent);
639+}
640+
641+/* Active session (other instance) - muted dot */
642+.session-active .session-status {
643+ color: var(--muted);
644+}
645+
646+/* Exited session - no dot, dimmed */
647+.session-exited {
648+ opacity: 0.7;
649+}
650+
651+.session-exited .session-status {
652+ color: transparent;
653+}
654+
655+/* Session ID label */
656+.session-id {
657+ font-size: 0.875em;
658+ font-weight: 500;
659+ color: var(--text);
660+ font-family: var(--font-mono);
661+}
662+
663+.session-current .session-id {
664+ color: var(--accent);
665+}
666+
667+/* Tab/pane count label */
668+.session-count {
669+ font-size: 0.75em;
670+ color: var(--muted);
671+}
672+
673+/* Relative time label */
674+.session-time {
675+ font-size: 0.75em;
676+ color: var(--muted);
677+ margin-left: auto;
678+}
679+
680+/* Section divider for EXITED sessions */
681+.session-divider {
682+ font-size: 0.6875em;
683+ font-weight: 600;
684+ color: var(--muted);
685+ text-transform: uppercase;
686+ letter-spacing: 0.05em;
687+ padding: 0.5em 0.75em 0.25em 0.75em;
688+ background-color: alpha(var(--border), 0.3);
689+ border-bottom: 0.0625em solid var(--border);
690+}
691+
692+/* Footer with keyboard shortcuts */
693+.session-manager-footer {
694+ background-color: shade(var(--surface-variant), 0.9);
695+ border-top: 0.0625em solid var(--border);
696+ padding: 0.375em 0.75em;
697+ font-size: 0.6875em;
698+ color: var(--muted);
699+ font-family: var(--font-mono);
700+}
701+
702+/* Session mode border (purple) */
703+.session-mode-active {
704+ background-color: transparent;
705+ box-shadow: inset 0 0 0 0.25em #9B59B6;
706+ border-radius: 0;
707+}
708+`
709+}
710+
556 // generateToasterCSS creates toast notification styles.711 // generateToasterCSS creates toast notification styles.
557 // Uses em units for scalable UI.712 // Uses em units for scalable UI.
558 func generateToasterCSS(p Palette) string {713 func generateToasterCSS(p Palette) string {

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