detached fe2d897

feat(ui): implement full session restoration with state-first approach

commit
fe2d897153fcf682811420470ea564dd873a7ca1
author
brice · · 2025-12-25 06:44 UTC
parent
924f06c
signature
unsigned
10 files · +561 -82
M cmd/dumber/main.go +6 -0
@@ -41,6 +41,9 @@ var (
4141 // initialURL holds the URL to open on startup (from browse command).
4242 var initialURL string
4343
44+// restoreSessionID holds the session ID to restore on startup.
45+var restoreSessionID string
46+
4447 func main() {
4548 // Run GUI mode for browse command
4649 if len(os.Args) > 1 && os.Args[1] == "browse" {
@@ -48,6 +51,8 @@ func main() {
4851 if len(os.Args) > 2 {
4952 initialURL = os.Args[2]
5053 }
54+ // Check for session restoration via environment variable
55+ restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
5156 // Strip "browse" and URL from args so GTK doesn't see them
5257 os.Args = os.Args[:1]
5358 os.Exit(runGUI())
@@ -304,6 +309,7 @@ func buildUIDependencies(
304309 Ctx: ctx,
305310 Config: cfg,
306311 InitialURL: initialURL,
312+ RestoreSessionID: restoreSessionID,
307313 Theme: themeManager,
308314 WebContext: stack.Context,
309315 Pool: stack.Pool,
@@ -41,6 +41,9 @@ var (
41 // initialURL holds the URL to open on startup (from browse command).41 // initialURL holds the URL to open on startup (from browse command).
42 var initialURL string42 var initialURL string
43 43
44+// restoreSessionID holds the session ID to restore on startup.
45+var restoreSessionID string
46+
44 func main() {47 func main() {
45 // Run GUI mode for browse command48 // Run GUI mode for browse command
46 if len(os.Args) > 1 && os.Args[1] == "browse" {49 if len(os.Args) > 1 && os.Args[1] == "browse" {
@@ -48,6 +51,8 @@ func main() {
48 if len(os.Args) > 2 {51 if len(os.Args) > 2 {
49 initialURL = os.Args[2]52 initialURL = os.Args[2]
50 }53 }
54+ // Check for session restoration via environment variable
55+ restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
51 // Strip "browse" and URL from args so GTK doesn't see them56 // Strip "browse" and URL from args so GTK doesn't see them
52 os.Args = os.Args[:1]57 os.Args = os.Args[:1]
53 os.Exit(runGUI())58 os.Exit(runGUI())
@@ -304,6 +309,7 @@ func buildUIDependencies(
304 Ctx: ctx,309 Ctx: ctx,
305 Config: cfg,310 Config: cfg,
306 InitialURL: initialURL,311 InitialURL: initialURL,
312+ RestoreSessionID: restoreSessionID,
307 Theme: themeManager,313 Theme: themeManager,
308 WebContext: stack.Context,314 WebContext: stack.Context,
309 Pool: stack.Pool,315 Pool: stack.Pool,
M internal/domain/entity/session_state.go +133 -0
@@ -167,3 +167,136 @@ func countPanesInNode(node *PaneNodeSnapshot) int {
167167 }
168168 return count
169169 }
170+
171+// IDGenerator is a function that generates unique IDs.
172+type IDGenerator func() string
173+
174+// TabListFromSnapshot reconstructs a TabList from a SessionState snapshot.
175+// Generates new IDs for all entities using the provided generator.
176+// This is the inverse of SnapshotFromTabList.
177+func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList {
178+ if state == nil {
179+ return NewTabList()
180+ }
181+
182+ tabs := NewTabList()
183+
184+ for i, tabSnap := range state.Tabs {
185+ tab := tabFromSnapshot(&tabSnap, idGen)
186+ if tab == nil {
187+ continue
188+ }
189+ tabs.Tabs = append(tabs.Tabs, tab)
190+ tab.Position = len(tabs.Tabs) - 1
191+
192+ if i == state.ActiveTabIndex {
193+ tabs.ActiveTabID = tab.ID
194+ }
195+ }
196+
197+ // Ensure we have an active tab
198+ if tabs.ActiveTabID == "" && len(tabs.Tabs) > 0 {
199+ tabs.ActiveTabID = tabs.Tabs[0].ID
200+ }
201+
202+ return tabs
203+}
204+
205+func tabFromSnapshot(snap *TabSnapshot, idGen IDGenerator) *Tab {
206+ if snap == nil {
207+ return nil
208+ }
209+
210+ ws := workspaceFromSnapshot(&snap.Workspace, idGen)
211+ if ws == nil {
212+ return nil
213+ }
214+
215+ return &Tab{
216+ ID: TabID(idGen()),
217+ Name: snap.Name,
218+ Workspace: ws,
219+ Position: snap.Position,
220+ IsPinned: snap.IsPinned,
221+ CreatedAt: time.Now(),
222+ }
223+}
224+
225+func workspaceFromSnapshot(snap *WorkspaceSnapshot, idGen IDGenerator) *Workspace {
226+ if snap == nil {
227+ return nil
228+ }
229+
230+ root := paneNodeFromSnapshot(snap.Root, nil, idGen)
231+ if root == nil {
232+ return nil
233+ }
234+
235+ // Find the active pane ID in the restored tree
236+ // We need to map the snapshot's ActivePaneID to the new pane ID
237+ var activePaneID PaneID
238+ root.Walk(func(node *PaneNode) bool {
239+ if node.Pane != nil {
240+ // Use the first pane as active if we can't find the original
241+ if activePaneID == "" {
242+ activePaneID = node.Pane.ID
243+ }
244+ }
245+ return true
246+ })
247+
248+ return &Workspace{
249+ ID: WorkspaceID(idGen()),
250+ Root: root,
251+ ActivePaneID: activePaneID,
252+ CreatedAt: time.Now(),
253+ }
254+}
255+
256+func paneNodeFromSnapshot(snap *PaneNodeSnapshot, parent *PaneNode, idGen IDGenerator) *PaneNode {
257+ if snap == nil {
258+ return nil
259+ }
260+
261+ node := &PaneNode{
262+ ID: idGen(),
263+ Parent: parent,
264+ SplitDir: snap.SplitDir,
265+ SplitRatio: snap.SplitRatio,
266+ IsStacked: snap.IsStacked,
267+ ActiveStackIndex: snap.ActiveStackIndex,
268+ }
269+
270+ // Restore pane if this is a leaf node
271+ if snap.Pane != nil {
272+ node.Pane = paneFromSnapshot(snap.Pane, idGen)
273+ }
274+
275+ // Restore children recursively
276+ if len(snap.Children) > 0 {
277+ node.Children = make([]*PaneNode, 0, len(snap.Children))
278+ for _, childSnap := range snap.Children {
279+ child := paneNodeFromSnapshot(childSnap, node, idGen)
280+ if child != nil {
281+ node.Children = append(node.Children, child)
282+ }
283+ }
284+ }
285+
286+ return node
287+}
288+
289+func paneFromSnapshot(snap *PaneSnapshot, idGen IDGenerator) *Pane {
290+ if snap == nil {
291+ return nil
292+ }
293+
294+ return &Pane{
295+ ID: PaneID(idGen()),
296+ URI: snap.URI,
297+ Title: snap.Title,
298+ ZoomFactor: snap.ZoomFactor,
299+ WindowType: WindowMain,
300+ CreatedAt: time.Now(),
301+ }
302+}
@@ -167,3 +167,136 @@ func countPanesInNode(node *PaneNodeSnapshot) int {
167 }167 }
168 return count168 return count
169 }169 }
170+
171+// IDGenerator is a function that generates unique IDs.
172+type IDGenerator func() string
173+
174+// TabListFromSnapshot reconstructs a TabList from a SessionState snapshot.
175+// Generates new IDs for all entities using the provided generator.
176+// This is the inverse of SnapshotFromTabList.
177+func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList {
178+ if state == nil {
179+ return NewTabList()
180+ }
181+
182+ tabs := NewTabList()
183+
184+ for i, tabSnap := range state.Tabs {
185+ tab := tabFromSnapshot(&tabSnap, idGen)
186+ if tab == nil {
187+ continue
188+ }
189+ tabs.Tabs = append(tabs.Tabs, tab)
190+ tab.Position = len(tabs.Tabs) - 1
191+
192+ if i == state.ActiveTabIndex {
193+ tabs.ActiveTabID = tab.ID
194+ }
195+ }
196+
197+ // Ensure we have an active tab
198+ if tabs.ActiveTabID == "" && len(tabs.Tabs) > 0 {
199+ tabs.ActiveTabID = tabs.Tabs[0].ID
200+ }
201+
202+ return tabs
203+}
204+
205+func tabFromSnapshot(snap *TabSnapshot, idGen IDGenerator) *Tab {
206+ if snap == nil {
207+ return nil
208+ }
209+
210+ ws := workspaceFromSnapshot(&snap.Workspace, idGen)
211+ if ws == nil {
212+ return nil
213+ }
214+
215+ return &Tab{
216+ ID: TabID(idGen()),
217+ Name: snap.Name,
218+ Workspace: ws,
219+ Position: snap.Position,
220+ IsPinned: snap.IsPinned,
221+ CreatedAt: time.Now(),
222+ }
223+}
224+
225+func workspaceFromSnapshot(snap *WorkspaceSnapshot, idGen IDGenerator) *Workspace {
226+ if snap == nil {
227+ return nil
228+ }
229+
230+ root := paneNodeFromSnapshot(snap.Root, nil, idGen)
231+ if root == nil {
232+ return nil
233+ }
234+
235+ // Find the active pane ID in the restored tree
236+ // We need to map the snapshot's ActivePaneID to the new pane ID
237+ var activePaneID PaneID
238+ root.Walk(func(node *PaneNode) bool {
239+ if node.Pane != nil {
240+ // Use the first pane as active if we can't find the original
241+ if activePaneID == "" {
242+ activePaneID = node.Pane.ID
243+ }
244+ }
245+ return true
246+ })
247+
248+ return &Workspace{
249+ ID: WorkspaceID(idGen()),
250+ Root: root,
251+ ActivePaneID: activePaneID,
252+ CreatedAt: time.Now(),
253+ }
254+}
255+
256+func paneNodeFromSnapshot(snap *PaneNodeSnapshot, parent *PaneNode, idGen IDGenerator) *PaneNode {
257+ if snap == nil {
258+ return nil
259+ }
260+
261+ node := &PaneNode{
262+ ID: idGen(),
263+ Parent: parent,
264+ SplitDir: snap.SplitDir,
265+ SplitRatio: snap.SplitRatio,
266+ IsStacked: snap.IsStacked,
267+ ActiveStackIndex: snap.ActiveStackIndex,
268+ }
269+
270+ // Restore pane if this is a leaf node
271+ if snap.Pane != nil {
272+ node.Pane = paneFromSnapshot(snap.Pane, idGen)
273+ }
274+
275+ // Restore children recursively
276+ if len(snap.Children) > 0 {
277+ node.Children = make([]*PaneNode, 0, len(snap.Children))
278+ for _, childSnap := range snap.Children {
279+ child := paneNodeFromSnapshot(childSnap, node, idGen)
280+ if child != nil {
281+ node.Children = append(node.Children, child)
282+ }
283+ }
284+ }
285+
286+ return node
287+}
288+
289+func paneFromSnapshot(snap *PaneSnapshot, idGen IDGenerator) *Pane {
290+ if snap == nil {
291+ return nil
292+ }
293+
294+ return &Pane{
295+ ID: PaneID(idGen()),
296+ URI: snap.URI,
297+ Title: snap.Title,
298+ ZoomFactor: snap.ZoomFactor,
299+ WindowType: WindowMain,
300+ CreatedAt: time.Now(),
301+ }
302+}
M internal/infrastructure/config/defaults.go +1 -1
@@ -218,7 +218,7 @@ func DefaultConfig() *Config {
218218 ActivationShortcut: defaultSessionActivationShortcut,
219219 TimeoutMilliseconds: defaultSessionTimeoutMilliseconds,
220220 Actions: map[string][]string{
221- "session-manager": {"w"},
221+ "session-manager": {"s", "w"},
222222 "confirm": {"enter"},
223223 "cancel": {"escape"},
224224 },
@@ -218,7 +218,7 @@ func DefaultConfig() *Config {
218 ActivationShortcut: defaultSessionActivationShortcut,218 ActivationShortcut: defaultSessionActivationShortcut,
219 TimeoutMilliseconds: defaultSessionTimeoutMilliseconds,219 TimeoutMilliseconds: defaultSessionTimeoutMilliseconds,
220 Actions: map[string][]string{220 Actions: map[string][]string{
221- "session-manager": {"w"},221+ "session-manager": {"s", "w"},
222 "confirm": {"enter"},222 "confirm": {"enter"},
223 "cancel": {"escape"},223 "cancel": {"escape"},
224 },224 },
M internal/infrastructure/config/loader.go +13 -0
@@ -314,6 +314,7 @@ func (m *Manager) setDefaults() {
314314 m.setOmniboxDefaults(defaults)
315315 m.setMediaDefaults(defaults)
316316 m.setRuntimeDefaults(defaults)
317+ m.setSessionDefaults(defaults)
317318 }
318319
319320 func (m *Manager) setHistoryDefaults(defaults *Config) {
@@ -402,6 +403,8 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
402403 m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)
403404 m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth)
404405 m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)
406+ m.viper.SetDefault("workspace.styling.session_mode_border_width", defaults.Workspace.Styling.SessionModeBorderWidth)
407+ m.viper.SetDefault("workspace.styling.session_mode_border_color", defaults.Workspace.Styling.SessionModeBorderColor)
405408 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
406409 }
407410
@@ -429,6 +432,16 @@ func (m *Manager) setRuntimeDefaults(defaults *Config) {
429432 m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix)
430433 }
431434
435+func (m *Manager) setSessionDefaults(defaults *Config) {
436+ m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore)
437+ m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs)
438+ m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions)
439+ m.viper.SetDefault("session.max_listed_sessions", defaults.Session.MaxListedSessions)
440+ m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut)
441+ m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds)
442+ m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions)
443+}
444+
432445 // New returns a new default configuration instance.
433446 // This is a convenience function for getting default config without the full manager.
434447 func New() *Config {
@@ -314,6 +314,7 @@ func (m *Manager) setDefaults() {
314 m.setOmniboxDefaults(defaults)314 m.setOmniboxDefaults(defaults)
315 m.setMediaDefaults(defaults)315 m.setMediaDefaults(defaults)
316 m.setRuntimeDefaults(defaults)316 m.setRuntimeDefaults(defaults)
317+ m.setSessionDefaults(defaults)
317 }318 }
318 319
319 func (m *Manager) setHistoryDefaults(defaults *Config) {320 func (m *Manager) setHistoryDefaults(defaults *Config) {
@@ -402,6 +403,8 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
402 m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)403 m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)
403 m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth)404 m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth)
404 m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)405 m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)
406+ m.viper.SetDefault("workspace.styling.session_mode_border_width", defaults.Workspace.Styling.SessionModeBorderWidth)
407+ m.viper.SetDefault("workspace.styling.session_mode_border_color", defaults.Workspace.Styling.SessionModeBorderColor)
405 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)408 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
406 }409 }
407 410
@@ -429,6 +432,16 @@ func (m *Manager) setRuntimeDefaults(defaults *Config) {
429 m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix)432 m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix)
430 }433 }
431 434
435+func (m *Manager) setSessionDefaults(defaults *Config) {
436+ m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore)
437+ m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs)
438+ m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions)
439+ m.viper.SetDefault("session.max_listed_sessions", defaults.Session.MaxListedSessions)
440+ m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut)
441+ m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds)
442+ m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions)
443+}
444+
432 // New returns a new default configuration instance.445 // New returns a new default configuration instance.
433 // This is a convenience function for getting default config without the full manager.446 // This is a convenience function for getting default config without the full manager.
434 func New() *Config {447 func New() *Config {
M internal/infrastructure/desktop/adapter.go +12 -3
@@ -360,6 +360,9 @@ func NewSessionSpawner(ctx context.Context) *SessionSpawner {
360360 return &SessionSpawner{ctx: ctx}
361361 }
362362
363+// RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.
364+const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"
365+
363366 // SpawnWithSession starts a new dumber instance to restore a session.
364367 func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
365368 log := logging.FromContext(s.ctx)
@@ -369,8 +372,11 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
369372 return fmt.Errorf("get executable path: %w", err)
370373 }
371374
372- // Start a detached process with the --restore-session flag
373- cmd := exec.Command(execPath, "browse", "--restore-session", string(sessionID))
375+ // Start dumber browse with session ID in environment
376+ cmd := exec.Command(execPath, "browse")
377+
378+ // Inherit environment and add restore session ID
379+ cmd.Env = append(os.Environ(), RestoreSessionEnvVar+"="+string(sessionID))
374380
375381 // Detach from current process group so the new process survives
376382 cmd.Stdin = nil
@@ -381,6 +387,9 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
381387 return fmt.Errorf("spawn session: %w", err)
382388 }
383389
390+ // Capture PID before releasing
391+ pid := cmd.Process.Pid
392+
384393 // Release the process so it continues running after we close
385394 if err := cmd.Process.Release(); err != nil {
386395 log.Warn().Err(err).Msg("failed to release spawned process (non-fatal)")
@@ -388,7 +397,7 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
388397
389398 log.Info().
390399 Str("session_id", string(sessionID)).
391- Int("pid", cmd.Process.Pid).
400+ Int("pid", pid).
392401 Msg("spawned dumber with session restoration")
393402
394403 return nil
@@ -360,6 +360,9 @@ func NewSessionSpawner(ctx context.Context) *SessionSpawner {
360 return &SessionSpawner{ctx: ctx}360 return &SessionSpawner{ctx: ctx}
361 }361 }
362 362
363+// RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.
364+const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"
365+
363 // SpawnWithSession starts a new dumber instance to restore a session.366 // SpawnWithSession starts a new dumber instance to restore a session.
364 func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {367 func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
365 log := logging.FromContext(s.ctx)368 log := logging.FromContext(s.ctx)
@@ -369,8 +372,11 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
369 return fmt.Errorf("get executable path: %w", err)372 return fmt.Errorf("get executable path: %w", err)
370 }373 }
371 374
372- // Start a detached process with the --restore-session flag375+ // Start dumber browse with session ID in environment
373- cmd := exec.Command(execPath, "browse", "--restore-session", string(sessionID))376+ cmd := exec.Command(execPath, "browse")
377+
378+ // Inherit environment and add restore session ID
379+ cmd.Env = append(os.Environ(), RestoreSessionEnvVar+"="+string(sessionID))
374 380
375 // Detach from current process group so the new process survives381 // Detach from current process group so the new process survives
376 cmd.Stdin = nil382 cmd.Stdin = nil
@@ -381,6 +387,9 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
381 return fmt.Errorf("spawn session: %w", err)387 return fmt.Errorf("spawn session: %w", err)
382 }388 }
383 389
390+ // Capture PID before releasing
391+ pid := cmd.Process.Pid
392+
384 // Release the process so it continues running after we close393 // Release the process so it continues running after we close
385 if err := cmd.Process.Release(); err != nil {394 if err := cmd.Process.Release(); err != nil {
386 log.Warn().Err(err).Msg("failed to release spawned process (non-fatal)")395 log.Warn().Err(err).Msg("failed to release spawned process (non-fatal)")
@@ -388,7 +397,7 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
388 397
389 log.Info().398 log.Info().
390 Str("session_id", string(sessionID)).399 Str("session_id", string(sessionID)).
391- Int("pid", cmd.Process.Pid).400+ Int("pid", pid).
392 Msg("spawned dumber with session restoration")401 Msg("spawned dumber with session restoration")
393 402
394 return nil403 return nil
M internal/ui/app.go +76 -1
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/dumber/internal/application/usecase"
1212 "github.com/bnema/dumber/internal/domain/entity"
1313 "github.com/bnema/dumber/internal/infrastructure/config"
14+ "github.com/bnema/dumber/internal/infrastructure/desktop"
1415 "github.com/bnema/dumber/internal/infrastructure/filtering"
1516 "github.com/bnema/dumber/internal/infrastructure/snapshot"
1617 "github.com/bnema/dumber/internal/infrastructure/webkit"
@@ -310,6 +311,10 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
310311 a.handleModeChange(ctx, from, to)
311312 })
312313 a.keyboardHandler.SetShouldBypassInput(func() bool {
314+ // Bypass keyboard handler when session manager is visible
315+ if a.sessionManager != nil && a.sessionManager.IsVisible() {
316+ return true
317+ }
313318 wsView := a.activeWorkspaceView()
314319 if wsView == nil {
315320 return false
@@ -391,6 +396,9 @@ func (a *App) initSessionManager(ctx context.Context) {
391396 )
392397 }
393398
399+ // Create session spawner for restoration
400+ spawner := desktop.NewSessionSpawner(ctx)
401+
394402 // Create session manager component
395403 a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
396404 ListSessionsUC: listSessionsUC,
@@ -400,7 +408,9 @@ func (a *App) initSessionManager(ctx context.Context) {
400408 },
401409 OnOpen: func(sessionID entity.SessionID) {
402410 log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
403- // TODO: Implement session spawning/resurrection
411+ if err := spawner.SpawnWithSession(sessionID); err != nil {
412+ log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
413+ }
404414 },
405415 })
406416
@@ -476,6 +486,17 @@ func (a *App) MarkDirty() {
476486 func (a *App) createInitialTab(ctx context.Context) {
477487 log := logging.FromContext(ctx)
478488
489+ // Check if we should restore a session
490+ if a.deps != nil && a.deps.RestoreSessionID != "" {
491+ if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil {
492+ log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab")
493+ // Fall through to create default tab
494+ } else {
495+ log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored")
496+ return
497+ }
498+ }
499+
479500 // Create an initial tab using coordinator.
480501 initialURL := "dumb://home"
481502 if a.deps != nil && a.deps.InitialURL != "" {
@@ -486,6 +507,60 @@ func (a *App) createInitialTab(ctx context.Context) {
486507 }
487508 }
488509
510+func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {
511+ log := logging.FromContext(ctx)
512+
513+ if a.deps == nil || a.deps.SessionStateRepo == nil {
514+ return fmt.Errorf("session state repo not available")
515+ }
516+
517+ // Load session state
518+ state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID)
519+ if err != nil {
520+ return fmt.Errorf("load session state: %w", err)
521+ }
522+ if state == nil {
523+ return fmt.Errorf("session state not found")
524+ }
525+
526+ log.Info().Int("tabs", len(state.Tabs)).Int("panes", state.CountPanes()).Msg("restoring session state")
527+
528+ // Build complete TabList from snapshot (state-first approach)
529+ // This reconstructs the full pane tree with new IDs
530+ restoredTabs := entity.TabListFromSnapshot(state, a.generateID)
531+ if restoredTabs == nil || len(restoredTabs.Tabs) == 0 {
532+ return fmt.Errorf("failed to build tab list from snapshot")
533+ }
534+
535+ // Replace empty tabs with restored ones
536+ a.tabs = restoredTabs
537+
538+ // Create UI for each restored tab and add to tab bar
539+ tabBar := a.mainWindow.TabBar()
540+ for _, tab := range a.tabs.Tabs {
541+ a.createWorkspaceView(ctx, tab)
542+ if tabBar != nil {
543+ tabBar.AddTab(tab)
544+ }
545+ log.Debug().
546+ Str("tab_id", string(tab.ID)).
547+ Str("name", tab.Name).
548+ Int("panes", tab.Workspace.PaneCount()).
549+ Msg("restored tab with workspace")
550+ }
551+
552+ // Switch to active tab
553+ activeTab := a.tabs.ActiveTab()
554+ if activeTab != nil {
555+ a.switchWorkspaceView(ctx, activeTab.ID)
556+ if tabBar != nil {
557+ tabBar.SetActive(activeTab.ID)
558+ }
559+ }
560+
561+ return nil
562+}
563+
489564 func (a *App) finalizeActivation(ctx context.Context) {
490565 log := logging.FromContext(ctx)
491566
@@ -11,6 +11,7 @@ import (
11 "github.com/bnema/dumber/internal/application/usecase"11 "github.com/bnema/dumber/internal/application/usecase"
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/desktop"
14 "github.com/bnema/dumber/internal/infrastructure/filtering"15 "github.com/bnema/dumber/internal/infrastructure/filtering"
15 "github.com/bnema/dumber/internal/infrastructure/snapshot"16 "github.com/bnema/dumber/internal/infrastructure/snapshot"
16 "github.com/bnema/dumber/internal/infrastructure/webkit"17 "github.com/bnema/dumber/internal/infrastructure/webkit"
@@ -310,6 +311,10 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
310 a.handleModeChange(ctx, from, to)311 a.handleModeChange(ctx, from, to)
311 })312 })
312 a.keyboardHandler.SetShouldBypassInput(func() bool {313 a.keyboardHandler.SetShouldBypassInput(func() bool {
314+ // Bypass keyboard handler when session manager is visible
315+ if a.sessionManager != nil && a.sessionManager.IsVisible() {
316+ return true
317+ }
313 wsView := a.activeWorkspaceView()318 wsView := a.activeWorkspaceView()
314 if wsView == nil {319 if wsView == nil {
315 return false320 return false
@@ -391,6 +396,9 @@ func (a *App) initSessionManager(ctx context.Context) {
391 )396 )
392 }397 }
393 398
399+ // Create session spawner for restoration
400+ spawner := desktop.NewSessionSpawner(ctx)
401+
394 // Create session manager component402 // Create session manager component
395 a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{403 a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
396 ListSessionsUC: listSessionsUC,404 ListSessionsUC: listSessionsUC,
@@ -400,7 +408,9 @@ func (a *App) initSessionManager(ctx context.Context) {
400 },408 },
401 OnOpen: func(sessionID entity.SessionID) {409 OnOpen: func(sessionID entity.SessionID) {
402 log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")410 log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
403- // TODO: Implement session spawning/resurrection411+ if err := spawner.SpawnWithSession(sessionID); err != nil {
412+ log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
413+ }
404 },414 },
405 })415 })
406 416
@@ -476,6 +486,17 @@ func (a *App) MarkDirty() {
476 func (a *App) createInitialTab(ctx context.Context) {486 func (a *App) createInitialTab(ctx context.Context) {
477 log := logging.FromContext(ctx)487 log := logging.FromContext(ctx)
478 488
489+ // Check if we should restore a session
490+ if a.deps != nil && a.deps.RestoreSessionID != "" {
491+ if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil {
492+ log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab")
493+ // Fall through to create default tab
494+ } else {
495+ log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored")
496+ return
497+ }
498+ }
499+
479 // Create an initial tab using coordinator.500 // Create an initial tab using coordinator.
480 initialURL := "dumb://home"501 initialURL := "dumb://home"
481 if a.deps != nil && a.deps.InitialURL != "" {502 if a.deps != nil && a.deps.InitialURL != "" {
@@ -486,6 +507,60 @@ func (a *App) createInitialTab(ctx context.Context) {
486 }507 }
487 }508 }
488 509
510+func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {
511+ log := logging.FromContext(ctx)
512+
513+ if a.deps == nil || a.deps.SessionStateRepo == nil {
514+ return fmt.Errorf("session state repo not available")
515+ }
516+
517+ // Load session state
518+ state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID)
519+ if err != nil {
520+ return fmt.Errorf("load session state: %w", err)
521+ }
522+ if state == nil {
523+ return fmt.Errorf("session state not found")
524+ }
525+
526+ log.Info().Int("tabs", len(state.Tabs)).Int("panes", state.CountPanes()).Msg("restoring session state")
527+
528+ // Build complete TabList from snapshot (state-first approach)
529+ // This reconstructs the full pane tree with new IDs
530+ restoredTabs := entity.TabListFromSnapshot(state, a.generateID)
531+ if restoredTabs == nil || len(restoredTabs.Tabs) == 0 {
532+ return fmt.Errorf("failed to build tab list from snapshot")
533+ }
534+
535+ // Replace empty tabs with restored ones
536+ a.tabs = restoredTabs
537+
538+ // Create UI for each restored tab and add to tab bar
539+ tabBar := a.mainWindow.TabBar()
540+ for _, tab := range a.tabs.Tabs {
541+ a.createWorkspaceView(ctx, tab)
542+ if tabBar != nil {
543+ tabBar.AddTab(tab)
544+ }
545+ log.Debug().
546+ Str("tab_id", string(tab.ID)).
547+ Str("name", tab.Name).
548+ Int("panes", tab.Workspace.PaneCount()).
549+ Msg("restored tab with workspace")
550+ }
551+
552+ // Switch to active tab
553+ activeTab := a.tabs.ActiveTab()
554+ if activeTab != nil {
555+ a.switchWorkspaceView(ctx, activeTab.ID)
556+ if tabBar != nil {
557+ tabBar.SetActive(activeTab.ID)
558+ }
559+ }
560+
561+ return nil
562+}
563+
489 func (a *App) finalizeActivation(ctx context.Context) {564 func (a *App) finalizeActivation(ctx context.Context) {
490 log := logging.FromContext(ctx)565 log := logging.FromContext(ctx)
491 566
M internal/ui/component/session_manager.go +304 -71
@@ -19,10 +19,14 @@ const (
1919 sessionManagerWidthPct = 0.6 // 60% of parent window width
2020 sessionManagerMaxWidth = 600 // Maximum width in pixels
2121 sessionManagerTopMargin = 0.15
22- sessionListMaxHeight = 400
22+ sessionMaxVisibleRows = 6 // Show 5 + current at max, rest is scrollable
2323 sessionDefaultListLimit = 50
2424 sessionIDMaxDisplay = 20
2525 sessionRowBoxSpacing = 8
26+ sessionRowHeight = 50 // Approximate height per session row
27+ sessionTreeRowHeight = 28 // Approximate height per tree row
28+ sessionDividerRowHeight = 30 // Height for divider rows
29+ sessionMaxTitleLen = 45 // Max chars for pane title display
2630 )
2731
2832 // SessionManager is a modal overlay for managing sessions.
@@ -40,10 +44,14 @@ type SessionManager struct {
4044 parentOverlay layout.OverlayWidget
4145
4246 // State
43- mu sync.RWMutex
44- visible bool
45- sessions []entity.SessionInfo
46- selectedIndex int
47+ mu sync.RWMutex
48+ visible bool
49+ sessions []entity.SessionInfo
50+ selectedIndex int // Index into sessions slice
51+ expandedIndex int // -1 means none expanded
52+ sessionToListRow []int // Maps session index to list row index
53+ listRowToSession []int // Maps list row index to session index (-1 for non-session rows)
54+ populatingList bool // True while rebuilding list, to ignore selection callbacks
4755
4856 // Dependencies
4957 listSessionsUC *usecase.ListSessionsUseCase
@@ -81,6 +89,7 @@ func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionMa
8189 onClose: cfg.OnClose,
8290 onOpen: cfg.OnOpen,
8391 selectedIndex: -1,
92+ expandedIndex: -1,
8493 }
8594
8695 if err := sm.createWidgets(); err != nil {
@@ -296,9 +305,9 @@ func (sm *SessionManager) initList() error {
296305 return errNilWidget("sessionScrolledWindow")
297306 }
298307 sm.scrolledWindow.AddCssClass("session-manager-scrolled")
299- sm.scrolledWindow.SetMaxContentHeight(sessionListMaxHeight)
300308 sm.scrolledWindow.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
301- sm.scrolledWindow.SetPropagateNaturalHeight(true)
309+ // Don't propagate natural height - we control it via resizeForContent
310+ sm.scrolledWindow.SetPropagateNaturalHeight(false)
302311
303312 sm.listBox = gtk.NewListBox()
304313 if sm.listBox == nil {
@@ -307,20 +316,29 @@ func (sm *SessionManager) initList() error {
307316 sm.listBox.AddCssClass("session-manager-list")
308317 sm.listBox.SetSelectionMode(gtk.SelectionSingleValue)
309318
310- // Connect row selection
319+ // Connect row selection - map list row index to session index
311320 rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
312321 if rowPtr == 0 {
313- sm.mu.Lock()
314- sm.selectedIndex = -1
315- sm.mu.Unlock()
316322 return
317323 }
318324 row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
319- if row != nil {
320- sm.mu.Lock()
321- sm.selectedIndex = row.GetIndex()
325+ if row == nil {
326+ return
327+ }
328+ listRowIdx := row.GetIndex()
329+ sm.mu.Lock()
330+ // Ignore selection changes while rebuilding the list
331+ if sm.populatingList {
322332 sm.mu.Unlock()
333+ return
334+ }
335+ if listRowIdx >= 0 && listRowIdx < len(sm.listRowToSession) {
336+ sessionIdx := sm.listRowToSession[listRowIdx]
337+ if sessionIdx >= 0 {
338+ sm.selectedIndex = sessionIdx
339+ }
323340 }
341+ sm.mu.Unlock()
324342 }
325343 sm.retainedCallbacks = append(sm.retainedCallbacks, rowSelectedCb)
326344 sm.listBox.ConnectRowSelected(&rowSelectedCb)
@@ -330,7 +348,7 @@ func (sm *SessionManager) initList() error {
330348 }
331349
332350 func (sm *SessionManager) initFooter() error {
333- footerText := "↑↓/jk navigate Enter open x delete Esc close"
351+ footerText := "↑↓/jk navigate Tab expand Enter open x delete Esc close"
334352 sm.footerLabel = gtk.NewLabel(&footerText)
335353 if sm.footerLabel == nil {
336354 return errNilWidget("sessionFooterLabel")
@@ -369,6 +387,10 @@ func (sm *SessionManager) handleKeyPress(keyval uint, _ gdk.ModifierType) bool {
369387 sm.selectNext()
370388 return true
371389
390+ case uint(gdk.KEY_Tab), uint(gdk.KEY_space):
391+ sm.toggleExpanded()
392+ return true
393+
372394 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
373395 sm.openSelected()
374396 return true
@@ -407,91 +429,286 @@ func (sm *SessionManager) loadSessions() {
407429 glib.IdleAdd(&cb, 0)
408430 }
409431
432+// populateListState holds state during list population.
433+type populateListState struct {
434+ listRowToSession []int
435+ listRowCount int
436+ sessionRowCount int
437+ treeRowCount int
438+ dividerCount int
439+ expandedIdx int
440+ firstExitedIdx int
441+ hasActive bool
442+}
443+
410444 func (sm *SessionManager) populateList() {
411- sm.mu.RLock()
445+ sm.mu.Lock()
412446 sessions := sm.sessions
413- sm.mu.RUnlock()
447+ selectedIdx := sm.selectedIndex
448+ sm.populatingList = true
449+ sm.sessionToListRow = make([]int, len(sessions))
450+ sm.listRowToSession = nil
451+ expandedIdx := sm.expandedIndex
452+ sm.mu.Unlock()
414453
415454 if sm.listBox == nil {
455+ sm.mu.Lock()
456+ sm.populatingList = false
457+ sm.mu.Unlock()
416458 return
417459 }
418460
419- // Clear existing rows
420461 sm.listBox.RemoveAll()
421462
422- // Separate active and exited sessions
423- var activeSessions, exitedSessions []entity.SessionInfo
424- for _, info := range sessions {
463+ state := &populateListState{expandedIdx: expandedIdx, firstExitedIdx: -1}
464+ for i, info := range sessions {
425465 if info.IsCurrent || info.IsActive {
426- activeSessions = append(activeSessions, info)
427- } else {
428- exitedSessions = append(exitedSessions, info)
466+ state.hasActive = true
467+ } else if state.firstExitedIdx == -1 {
468+ state.firstExitedIdx = i
429469 }
430470 }
431471
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- }
472+ for i, info := range sessions {
473+ sm.addSessionToList(i, info, state)
438474 }
439475
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 {
476+ sm.mu.Lock()
477+ sm.listRowToSession = state.listRowToSession
478+ sm.populatingList = false
479+ sm.mu.Unlock()
480+
481+ sm.selectBySessionIndex(selectedIdx)
482+ sm.resizeForContent(state.sessionRowCount, state.dividerCount, state.treeRowCount)
483+}
484+
485+func (sm *SessionManager) addSessionToList(i int, info entity.SessionInfo, state *populateListState) {
486+ // Add divider before first exited session
487+ if i == state.firstExitedIdx && state.hasActive {
488+ if divider := sm.createDividerRow("EXITED"); divider != nil {
444489 sm.listBox.Append(&divider.Widget)
490+ state.listRowToSession = append(state.listRowToSession, -1)
491+ state.listRowCount++
492+ state.dividerCount++
445493 }
446494 }
447495
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- }
496+ sm.mu.Lock()
497+ sm.sessionToListRow[i] = state.listRowCount
498+ sm.mu.Unlock()
499+ state.listRowToSession = append(state.listRowToSession, i)
500+
501+ if row := sm.createSessionRow(info); row != nil {
502+ sm.listBox.Append(&row.Widget)
503+ state.listRowCount++
504+ state.sessionRowCount++
454505 }
455506
456- // Select first row
457- if len(sessions) > 0 {
458- firstRow := sm.listBox.GetRowAtIndex(0)
459- if firstRow != nil {
460- sm.listBox.SelectRow(firstRow)
507+ if i == state.expandedIdx && info.State != nil {
508+ for _, treeRow := range sm.createTreeRows(info.State) {
509+ sm.listBox.Append(&treeRow.Widget)
510+ state.listRowToSession = append(state.listRowToSession, -1)
511+ state.listRowCount++
512+ state.treeRowCount++
461513 }
462514 }
515+}
463516
464- // Resize based on content
465- rowCount := len(activeSessions) + len(exitedSessions)
466- if len(exitedSessions) > 0 && len(activeSessions) > 0 {
467- rowCount++ // divider row
517+func (sm *SessionManager) selectBySessionIndex(sessionIdx int) {
518+ sm.mu.RLock()
519+ if sessionIdx < 0 || sessionIdx >= len(sm.sessionToListRow) {
520+ sm.mu.RUnlock()
521+ return
522+ }
523+ listRow := sm.sessionToListRow[sessionIdx]
524+ sm.mu.RUnlock()
525+
526+ row := sm.listBox.GetRowAtIndex(listRow)
527+ if row == nil {
528+ return
468529 }
469- sm.resizeForContent(rowCount)
530+
531+ sm.listBox.SelectRow(row)
532+
533+ // Scroll the row into view using GrabFocus which triggers scroll
534+ row.GrabFocus()
470535 }
471536
472-func (sm *SessionManager) resizeForContent(rowCount int) {
537+func (sm *SessionManager) resizeForContent(sessionRowCount, dividerCount, treeRowCount int) {
473538 if sm.scrolledWindow == nil {
474539 return
475540 }
476541
477- const rowHeight = 50
478- contentHeight := rowCount * rowHeight
542+ // Calculate max visible height (6 session rows max visible, accounting for divider)
543+ maxVisibleHeight := sessionMaxVisibleRows*sessionRowHeight + sessionDividerRowHeight
479544
480- if contentHeight > sessionListMaxHeight {
481- contentHeight = sessionListMaxHeight
545+ // Calculate actual content height
546+ contentHeight := sessionRowCount*sessionRowHeight +
547+ dividerCount*sessionDividerRowHeight +
548+ treeRowCount*sessionTreeRowHeight
549+
550+ // Cap at max visible height - rest will be scrollable
551+ if contentHeight > maxVisibleHeight {
552+ contentHeight = maxVisibleHeight
553+ }
554+
555+ // Ensure minimum height for at least one row
556+ if contentHeight < sessionRowHeight {
557+ contentHeight = sessionRowHeight
482558 }
483559
484560 // Schedule resize
485561 cb := glib.SourceFunc(func(_ uintptr) bool {
486- sm.scrolledWindow.SetMinContentHeight(-1)
487- sm.scrolledWindow.SetMaxContentHeight(contentHeight)
488562 sm.scrolledWindow.SetMinContentHeight(contentHeight)
563+ sm.scrolledWindow.SetMaxContentHeight(contentHeight)
489564 sm.outerBox.QueueResize()
490565 return false
491566 })
492567 glib.IdleAdd(&cb, 0)
493568 }
494569
570+// createTreeRows creates tree rows showing the tab/pane structure of a session.
571+func (sm *SessionManager) createTreeRows(state *entity.SessionState) []*gtk.ListBoxRow {
572+ if state == nil {
573+ return nil
574+ }
575+
576+ var rows []*gtk.ListBoxRow
577+
578+ for tabIdx, tab := range state.Tabs {
579+ isLastTab := tabIdx == len(state.Tabs)-1
580+
581+ // Tab row
582+ tabRow := sm.createTreeRow(tab.Name, "tab", !isLastTab, 0)
583+ if tabRow != nil {
584+ rows = append(rows, tabRow)
585+ }
586+
587+ // Pane rows under this tab
588+ paneRows := sm.createPaneTreeRows(&tab.Workspace)
589+ rows = append(rows, paneRows...)
590+ }
591+
592+ return rows
593+}
594+
595+// createPaneTreeRows creates rows for the pane tree under a tab.
596+func (sm *SessionManager) createPaneTreeRows(ws *entity.WorkspaceSnapshot) []*gtk.ListBoxRow {
597+ if ws == nil || ws.Root == nil {
598+ return nil
599+ }
600+
601+ var rows []*gtk.ListBoxRow
602+ sm.collectPaneRows(&rows, ws.Root, 1, true)
603+ return rows
604+}
605+
606+// collectPaneRows recursively collects pane tree rows.
607+func (sm *SessionManager) collectPaneRows(
608+ rows *[]*gtk.ListBoxRow,
609+ node *entity.PaneNodeSnapshot,
610+ depth int,
611+ isLast bool,
612+) {
613+ if node == nil {
614+ return
615+ }
616+
617+ if node.Pane != nil {
618+ // Leaf node (actual pane)
619+ title := node.Pane.Title
620+ if title == "" {
621+ title = node.Pane.URI
622+ }
623+ if len(title) > sessionMaxTitleLen {
624+ title = title[:sessionMaxTitleLen-3] + "..."
625+ }
626+
627+ row := sm.createTreeRow(title, "pane", !isLast, depth)
628+ if row != nil {
629+ *rows = append(*rows, row)
630+ }
631+ } else if len(node.Children) > 0 {
632+ // Container node (split)
633+ splitLabel := "split"
634+ if node.IsStacked {
635+ splitLabel = "stacked"
636+ } else if node.SplitRatio > 0 {
637+ splitLabel = fmt.Sprintf("split %.0f%%", node.SplitRatio*100)
638+ }
639+
640+ row := sm.createTreeRow(splitLabel, "split", !isLast, depth)
641+ if row != nil {
642+ *rows = append(*rows, row)
643+ }
644+
645+ // Recurse into children
646+ for i, child := range node.Children {
647+ isLastChild := i == len(node.Children)-1
648+ sm.collectPaneRows(rows, child, depth+1, isLastChild)
649+ }
650+ }
651+}
652+
653+// createTreeRow creates a single tree row with proper indentation and branch characters.
654+func (sm *SessionManager) createTreeRow(text, nodeType string, hasSibling bool, depth int) *gtk.ListBoxRow {
655+ row := gtk.NewListBoxRow()
656+ if row == nil {
657+ return nil
658+ }
659+ row.SetSelectable(false)
660+ row.SetActivatable(false)
661+ row.AddCssClass("session-tree-row")
662+
663+ hbox := gtk.NewBox(gtk.OrientationHorizontalValue, 4)
664+ if hbox == nil {
665+ return nil
666+ }
667+ hbox.AddCssClass("session-tree-content")
668+
669+ // Build indent and branch prefix
670+ var prefix string
671+ indent := " " // Base indent to align with session row content
672+ for i := 0; i < depth; i++ {
673+ indent += " " // 4 spaces per depth level
674+ }
675+
676+ // Branch character
677+ if hasSibling {
678+ prefix = "├── "
679+ } else {
680+ prefix = "└── "
681+ }
682+
683+ // Icon based on node type
684+ var icon string
685+ switch nodeType {
686+ case "tab":
687+ icon = "󰓩 " // Tab icon (nerd font)
688+ case "pane":
689+ icon = "󰕮 " // Pane icon
690+ case "split":
691+ icon = "󰯍 " // Split icon
692+ default:
693+ icon = " "
694+ }
695+
696+ // Create the label with all parts
697+ labelText := indent + prefix + icon + text
698+ label := gtk.NewLabel(&labelText)
699+ if label == nil {
700+ return nil
701+ }
702+ label.AddCssClass("session-tree-label")
703+ label.SetHalign(gtk.AlignStartValue)
704+ label.SetEllipsize(2) // PANGO_ELLIPSIZE_END
705+
706+ hbox.Append(&label.Widget)
707+ row.SetChild(&hbox.Widget)
708+
709+ return row
710+}
711+
495712 func (sm *SessionManager) createDividerRow(text string) *gtk.ListBoxRow {
496713 row := gtk.NewListBoxRow()
497714 if row == nil {
@@ -598,35 +815,51 @@ func (sm *SessionManager) addRelativeTime(hbox *gtk.Box, info entity.SessionInfo
598815 }
599816 }
600817
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-
608818 func (sm *SessionManager) selectPrevious() {
609819 sm.mu.Lock()
610- if sm.selectedIndex > 0 {
611- sm.selectedIndex--
820+ maxIndex := len(sm.sessions) - 1
821+ if maxIndex < 0 {
822+ sm.mu.Unlock()
823+ return
824+ }
825+ sm.selectedIndex--
826+ if sm.selectedIndex < 0 {
827+ sm.selectedIndex = maxIndex // Wrap to bottom
612828 }
613829 idx := sm.selectedIndex
614830 sm.mu.Unlock()
615831
616- sm.selectIndex(idx)
832+ sm.selectBySessionIndex(idx)
617833 }
618834
619835 func (sm *SessionManager) selectNext() {
620836 sm.mu.Lock()
621837 maxIndex := len(sm.sessions) - 1
622- // Account for divider row
623- if sm.selectedIndex < maxIndex {
624- sm.selectedIndex++
838+ if maxIndex < 0 {
839+ sm.mu.Unlock()
840+ return
841+ }
842+ sm.selectedIndex++
843+ if sm.selectedIndex > maxIndex {
844+ sm.selectedIndex = 0 // Wrap to top
625845 }
626846 idx := sm.selectedIndex
627847 sm.mu.Unlock()
628848
629- sm.selectIndex(idx)
849+ sm.selectBySessionIndex(idx)
850+}
851+
852+func (sm *SessionManager) toggleExpanded() {
853+ sm.mu.Lock()
854+ if sm.expandedIndex == sm.selectedIndex {
855+ sm.expandedIndex = -1 // collapse
856+ } else {
857+ sm.expandedIndex = sm.selectedIndex // expand
858+ }
859+ sm.mu.Unlock()
860+
861+ // Rebuild the list to show/hide tree
862+ sm.populateList()
630863 }
631864
632865 func (sm *SessionManager) openSelected() {
@@ -19,10 +19,14 @@ const (
19 sessionManagerWidthPct = 0.6 // 60% of parent window width19 sessionManagerWidthPct = 0.6 // 60% of parent window width
20 sessionManagerMaxWidth = 600 // Maximum width in pixels20 sessionManagerMaxWidth = 600 // Maximum width in pixels
21 sessionManagerTopMargin = 0.1521 sessionManagerTopMargin = 0.15
22- sessionListMaxHeight = 40022+ sessionMaxVisibleRows = 6 // Show 5 + current at max, rest is scrollable
23 sessionDefaultListLimit = 5023 sessionDefaultListLimit = 50
24 sessionIDMaxDisplay = 2024 sessionIDMaxDisplay = 20
25 sessionRowBoxSpacing = 825 sessionRowBoxSpacing = 8
26+ sessionRowHeight = 50 // Approximate height per session row
27+ sessionTreeRowHeight = 28 // Approximate height per tree row
28+ sessionDividerRowHeight = 30 // Height for divider rows
29+ sessionMaxTitleLen = 45 // Max chars for pane title display
26 )30 )
27 31
28 // SessionManager is a modal overlay for managing sessions.32 // SessionManager is a modal overlay for managing sessions.
@@ -40,10 +44,14 @@ type SessionManager struct {
40 parentOverlay layout.OverlayWidget44 parentOverlay layout.OverlayWidget
41 45
42 // State46 // State
43- mu sync.RWMutex47+ mu sync.RWMutex
44- visible bool48+ visible bool
45- sessions []entity.SessionInfo49+ sessions []entity.SessionInfo
46- selectedIndex int50+ selectedIndex int // Index into sessions slice
51+ expandedIndex int // -1 means none expanded
52+ sessionToListRow []int // Maps session index to list row index
53+ listRowToSession []int // Maps list row index to session index (-1 for non-session rows)
54+ populatingList bool // True while rebuilding list, to ignore selection callbacks
47 55
48 // Dependencies56 // Dependencies
49 listSessionsUC *usecase.ListSessionsUseCase57 listSessionsUC *usecase.ListSessionsUseCase
@@ -81,6 +89,7 @@ func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionMa
81 onClose: cfg.OnClose,89 onClose: cfg.OnClose,
82 onOpen: cfg.OnOpen,90 onOpen: cfg.OnOpen,
83 selectedIndex: -1,91 selectedIndex: -1,
92+ expandedIndex: -1,
84 }93 }
85 94
86 if err := sm.createWidgets(); err != nil {95 if err := sm.createWidgets(); err != nil {
@@ -296,9 +305,9 @@ func (sm *SessionManager) initList() error {
296 return errNilWidget("sessionScrolledWindow")305 return errNilWidget("sessionScrolledWindow")
297 }306 }
298 sm.scrolledWindow.AddCssClass("session-manager-scrolled")307 sm.scrolledWindow.AddCssClass("session-manager-scrolled")
299- sm.scrolledWindow.SetMaxContentHeight(sessionListMaxHeight)
300 sm.scrolledWindow.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)308 sm.scrolledWindow.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
301- sm.scrolledWindow.SetPropagateNaturalHeight(true)309+ // Don't propagate natural height - we control it via resizeForContent
310+ sm.scrolledWindow.SetPropagateNaturalHeight(false)
302 311
303 sm.listBox = gtk.NewListBox()312 sm.listBox = gtk.NewListBox()
304 if sm.listBox == nil {313 if sm.listBox == nil {
@@ -307,20 +316,29 @@ func (sm *SessionManager) initList() error {
307 sm.listBox.AddCssClass("session-manager-list")316 sm.listBox.AddCssClass("session-manager-list")
308 sm.listBox.SetSelectionMode(gtk.SelectionSingleValue)317 sm.listBox.SetSelectionMode(gtk.SelectionSingleValue)
309 318
310- // Connect row selection319+ // Connect row selection - map list row index to session index
311 rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {320 rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
312 if rowPtr == 0 {321 if rowPtr == 0 {
313- sm.mu.Lock()
314- sm.selectedIndex = -1
315- sm.mu.Unlock()
316 return322 return
317 }323 }
318 row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)324 row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
319- if row != nil {325+ if row == nil {
320- sm.mu.Lock()326+ return
321- sm.selectedIndex = row.GetIndex()327+ }
328+ listRowIdx := row.GetIndex()
329+ sm.mu.Lock()
330+ // Ignore selection changes while rebuilding the list
331+ if sm.populatingList {
322 sm.mu.Unlock()332 sm.mu.Unlock()
333+ return
334+ }
335+ if listRowIdx >= 0 && listRowIdx < len(sm.listRowToSession) {
336+ sessionIdx := sm.listRowToSession[listRowIdx]
337+ if sessionIdx >= 0 {
338+ sm.selectedIndex = sessionIdx
339+ }
323 }340 }
341+ sm.mu.Unlock()
324 }342 }
325 sm.retainedCallbacks = append(sm.retainedCallbacks, rowSelectedCb)343 sm.retainedCallbacks = append(sm.retainedCallbacks, rowSelectedCb)
326 sm.listBox.ConnectRowSelected(&rowSelectedCb)344 sm.listBox.ConnectRowSelected(&rowSelectedCb)
@@ -330,7 +348,7 @@ func (sm *SessionManager) initList() error {
330 }348 }
331 349
332 func (sm *SessionManager) initFooter() error {350 func (sm *SessionManager) initFooter() error {
333- footerText := "↑↓/jk navigate Enter open x delete Esc close"351+ footerText := "↑↓/jk navigate Tab expand Enter open x delete Esc close"
334 sm.footerLabel = gtk.NewLabel(&footerText)352 sm.footerLabel = gtk.NewLabel(&footerText)
335 if sm.footerLabel == nil {353 if sm.footerLabel == nil {
336 return errNilWidget("sessionFooterLabel")354 return errNilWidget("sessionFooterLabel")
@@ -369,6 +387,10 @@ func (sm *SessionManager) handleKeyPress(keyval uint, _ gdk.ModifierType) bool {
369 sm.selectNext()387 sm.selectNext()
370 return true388 return true
371 389
390+ case uint(gdk.KEY_Tab), uint(gdk.KEY_space):
391+ sm.toggleExpanded()
392+ return true
393+
372 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):394 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
373 sm.openSelected()395 sm.openSelected()
374 return true396 return true
@@ -407,91 +429,286 @@ func (sm *SessionManager) loadSessions() {
407 glib.IdleAdd(&cb, 0)429 glib.IdleAdd(&cb, 0)
408 }430 }
409 431
432+// populateListState holds state during list population.
433+type populateListState struct {
434+ listRowToSession []int
435+ listRowCount int
436+ sessionRowCount int
437+ treeRowCount int
438+ dividerCount int
439+ expandedIdx int
440+ firstExitedIdx int
441+ hasActive bool
442+}
443+
410 func (sm *SessionManager) populateList() {444 func (sm *SessionManager) populateList() {
411- sm.mu.RLock()445+ sm.mu.Lock()
412 sessions := sm.sessions446 sessions := sm.sessions
413- sm.mu.RUnlock()447+ selectedIdx := sm.selectedIndex
448+ sm.populatingList = true
449+ sm.sessionToListRow = make([]int, len(sessions))
450+ sm.listRowToSession = nil
451+ expandedIdx := sm.expandedIndex
452+ sm.mu.Unlock()
414 453
415 if sm.listBox == nil {454 if sm.listBox == nil {
455+ sm.mu.Lock()
456+ sm.populatingList = false
457+ sm.mu.Unlock()
416 return458 return
417 }459 }
418 460
419- // Clear existing rows
420 sm.listBox.RemoveAll()461 sm.listBox.RemoveAll()
421 462
422- // Separate active and exited sessions463+ state := &populateListState{expandedIdx: expandedIdx, firstExitedIdx: -1}
423- var activeSessions, exitedSessions []entity.SessionInfo464+ for i, info := range sessions {
424- for _, info := range sessions {
425 if info.IsCurrent || info.IsActive {465 if info.IsCurrent || info.IsActive {
426- activeSessions = append(activeSessions, info)466+ state.hasActive = true
427- } else {467+ } else if state.firstExitedIdx == -1 {
428- exitedSessions = append(exitedSessions, info)468+ state.firstExitedIdx = i
429 }469 }
430 }470 }
431 471
432- // Add active session rows472+ for i, info := range sessions {
433- for _, info := range activeSessions {473+ sm.addSessionToList(i, info, state)
434- row := sm.createSessionRow(info)
435- if row != nil {
436- sm.listBox.Append(&row.Widget)
437- }
438 }474 }
439 475
440- // Add divider if there are exited sessions476+ sm.mu.Lock()
441- if len(exitedSessions) > 0 && len(activeSessions) > 0 {477+ sm.listRowToSession = state.listRowToSession
442- divider := sm.createDividerRow("EXITED")478+ sm.populatingList = false
443- if divider != nil {479+ sm.mu.Unlock()
480+
481+ sm.selectBySessionIndex(selectedIdx)
482+ sm.resizeForContent(state.sessionRowCount, state.dividerCount, state.treeRowCount)
483+}
484+
485+func (sm *SessionManager) addSessionToList(i int, info entity.SessionInfo, state *populateListState) {
486+ // Add divider before first exited session
487+ if i == state.firstExitedIdx && state.hasActive {
488+ if divider := sm.createDividerRow("EXITED"); divider != nil {
444 sm.listBox.Append(&divider.Widget)489 sm.listBox.Append(&divider.Widget)
490+ state.listRowToSession = append(state.listRowToSession, -1)
491+ state.listRowCount++
492+ state.dividerCount++
445 }493 }
446 }494 }
447 495
448- // Add exited session rows496+ sm.mu.Lock()
449- for _, info := range exitedSessions {497+ sm.sessionToListRow[i] = state.listRowCount
450- row := sm.createSessionRow(info)498+ sm.mu.Unlock()
451- if row != nil {499+ state.listRowToSession = append(state.listRowToSession, i)
452- sm.listBox.Append(&row.Widget)500+
453- }501+ if row := sm.createSessionRow(info); row != nil {
502+ sm.listBox.Append(&row.Widget)
503+ state.listRowCount++
504+ state.sessionRowCount++
454 }505 }
455 506
456- // Select first row507+ if i == state.expandedIdx && info.State != nil {
457- if len(sessions) > 0 {508+ for _, treeRow := range sm.createTreeRows(info.State) {
458- firstRow := sm.listBox.GetRowAtIndex(0)509+ sm.listBox.Append(&treeRow.Widget)
459- if firstRow != nil {510+ state.listRowToSession = append(state.listRowToSession, -1)
460- sm.listBox.SelectRow(firstRow)511+ state.listRowCount++
512+ state.treeRowCount++
461 }513 }
462 }514 }
515+}
463 516
464- // Resize based on content517+func (sm *SessionManager) selectBySessionIndex(sessionIdx int) {
465- rowCount := len(activeSessions) + len(exitedSessions)518+ sm.mu.RLock()
466- if len(exitedSessions) > 0 && len(activeSessions) > 0 {519+ if sessionIdx < 0 || sessionIdx >= len(sm.sessionToListRow) {
467- rowCount++ // divider row520+ sm.mu.RUnlock()
521+ return
522+ }
523+ listRow := sm.sessionToListRow[sessionIdx]
524+ sm.mu.RUnlock()
525+
526+ row := sm.listBox.GetRowAtIndex(listRow)
527+ if row == nil {
528+ return
468 }529 }
469- sm.resizeForContent(rowCount)530+
531+ sm.listBox.SelectRow(row)
532+
533+ // Scroll the row into view using GrabFocus which triggers scroll
534+ row.GrabFocus()
470 }535 }
471 536
472-func (sm *SessionManager) resizeForContent(rowCount int) {537+func (sm *SessionManager) resizeForContent(sessionRowCount, dividerCount, treeRowCount int) {
473 if sm.scrolledWindow == nil {538 if sm.scrolledWindow == nil {
474 return539 return
475 }540 }
476 541
477- const rowHeight = 50542+ // Calculate max visible height (6 session rows max visible, accounting for divider)
478- contentHeight := rowCount * rowHeight543+ maxVisibleHeight := sessionMaxVisibleRows*sessionRowHeight + sessionDividerRowHeight
479 544
480- if contentHeight > sessionListMaxHeight {545+ // Calculate actual content height
481- contentHeight = sessionListMaxHeight546+ contentHeight := sessionRowCount*sessionRowHeight +
547+ dividerCount*sessionDividerRowHeight +
548+ treeRowCount*sessionTreeRowHeight
549+
550+ // Cap at max visible height - rest will be scrollable
551+ if contentHeight > maxVisibleHeight {
552+ contentHeight = maxVisibleHeight
553+ }
554+
555+ // Ensure minimum height for at least one row
556+ if contentHeight < sessionRowHeight {
557+ contentHeight = sessionRowHeight
482 }558 }
483 559
484 // Schedule resize560 // Schedule resize
485 cb := glib.SourceFunc(func(_ uintptr) bool {561 cb := glib.SourceFunc(func(_ uintptr) bool {
486- sm.scrolledWindow.SetMinContentHeight(-1)
487- sm.scrolledWindow.SetMaxContentHeight(contentHeight)
488 sm.scrolledWindow.SetMinContentHeight(contentHeight)562 sm.scrolledWindow.SetMinContentHeight(contentHeight)
563+ sm.scrolledWindow.SetMaxContentHeight(contentHeight)
489 sm.outerBox.QueueResize()564 sm.outerBox.QueueResize()
490 return false565 return false
491 })566 })
492 glib.IdleAdd(&cb, 0)567 glib.IdleAdd(&cb, 0)
493 }568 }
494 569
570+// createTreeRows creates tree rows showing the tab/pane structure of a session.
571+func (sm *SessionManager) createTreeRows(state *entity.SessionState) []*gtk.ListBoxRow {
572+ if state == nil {
573+ return nil
574+ }
575+
576+ var rows []*gtk.ListBoxRow
577+
578+ for tabIdx, tab := range state.Tabs {
579+ isLastTab := tabIdx == len(state.Tabs)-1
580+
581+ // Tab row
582+ tabRow := sm.createTreeRow(tab.Name, "tab", !isLastTab, 0)
583+ if tabRow != nil {
584+ rows = append(rows, tabRow)
585+ }
586+
587+ // Pane rows under this tab
588+ paneRows := sm.createPaneTreeRows(&tab.Workspace)
589+ rows = append(rows, paneRows...)
590+ }
591+
592+ return rows
593+}
594+
595+// createPaneTreeRows creates rows for the pane tree under a tab.
596+func (sm *SessionManager) createPaneTreeRows(ws *entity.WorkspaceSnapshot) []*gtk.ListBoxRow {
597+ if ws == nil || ws.Root == nil {
598+ return nil
599+ }
600+
601+ var rows []*gtk.ListBoxRow
602+ sm.collectPaneRows(&rows, ws.Root, 1, true)
603+ return rows
604+}
605+
606+// collectPaneRows recursively collects pane tree rows.
607+func (sm *SessionManager) collectPaneRows(
608+ rows *[]*gtk.ListBoxRow,
609+ node *entity.PaneNodeSnapshot,
610+ depth int,
611+ isLast bool,
612+) {
613+ if node == nil {
614+ return
615+ }
616+
617+ if node.Pane != nil {
618+ // Leaf node (actual pane)
619+ title := node.Pane.Title
620+ if title == "" {
621+ title = node.Pane.URI
622+ }
623+ if len(title) > sessionMaxTitleLen {
624+ title = title[:sessionMaxTitleLen-3] + "..."
625+ }
626+
627+ row := sm.createTreeRow(title, "pane", !isLast, depth)
628+ if row != nil {
629+ *rows = append(*rows, row)
630+ }
631+ } else if len(node.Children) > 0 {
632+ // Container node (split)
633+ splitLabel := "split"
634+ if node.IsStacked {
635+ splitLabel = "stacked"
636+ } else if node.SplitRatio > 0 {
637+ splitLabel = fmt.Sprintf("split %.0f%%", node.SplitRatio*100)
638+ }
639+
640+ row := sm.createTreeRow(splitLabel, "split", !isLast, depth)
641+ if row != nil {
642+ *rows = append(*rows, row)
643+ }
644+
645+ // Recurse into children
646+ for i, child := range node.Children {
647+ isLastChild := i == len(node.Children)-1
648+ sm.collectPaneRows(rows, child, depth+1, isLastChild)
649+ }
650+ }
651+}
652+
653+// createTreeRow creates a single tree row with proper indentation and branch characters.
654+func (sm *SessionManager) createTreeRow(text, nodeType string, hasSibling bool, depth int) *gtk.ListBoxRow {
655+ row := gtk.NewListBoxRow()
656+ if row == nil {
657+ return nil
658+ }
659+ row.SetSelectable(false)
660+ row.SetActivatable(false)
661+ row.AddCssClass("session-tree-row")
662+
663+ hbox := gtk.NewBox(gtk.OrientationHorizontalValue, 4)
664+ if hbox == nil {
665+ return nil
666+ }
667+ hbox.AddCssClass("session-tree-content")
668+
669+ // Build indent and branch prefix
670+ var prefix string
671+ indent := " " // Base indent to align with session row content
672+ for i := 0; i < depth; i++ {
673+ indent += " " // 4 spaces per depth level
674+ }
675+
676+ // Branch character
677+ if hasSibling {
678+ prefix = "├── "
679+ } else {
680+ prefix = "└── "
681+ }
682+
683+ // Icon based on node type
684+ var icon string
685+ switch nodeType {
686+ case "tab":
687+ icon = "󰓩 " // Tab icon (nerd font)
688+ case "pane":
689+ icon = "󰕮 " // Pane icon
690+ case "split":
691+ icon = "󰯍 " // Split icon
692+ default:
693+ icon = " "
694+ }
695+
696+ // Create the label with all parts
697+ labelText := indent + prefix + icon + text
698+ label := gtk.NewLabel(&labelText)
699+ if label == nil {
700+ return nil
701+ }
702+ label.AddCssClass("session-tree-label")
703+ label.SetHalign(gtk.AlignStartValue)
704+ label.SetEllipsize(2) // PANGO_ELLIPSIZE_END
705+
706+ hbox.Append(&label.Widget)
707+ row.SetChild(&hbox.Widget)
708+
709+ return row
710+}
711+
495 func (sm *SessionManager) createDividerRow(text string) *gtk.ListBoxRow {712 func (sm *SessionManager) createDividerRow(text string) *gtk.ListBoxRow {
496 row := gtk.NewListBoxRow()713 row := gtk.NewListBoxRow()
497 if row == nil {714 if row == nil {
@@ -598,35 +815,51 @@ func (sm *SessionManager) addRelativeTime(hbox *gtk.Box, info entity.SessionInfo
598 }815 }
599 }816 }
600 817
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() {818 func (sm *SessionManager) selectPrevious() {
609 sm.mu.Lock()819 sm.mu.Lock()
610- if sm.selectedIndex > 0 {820+ maxIndex := len(sm.sessions) - 1
611- sm.selectedIndex--821+ if maxIndex < 0 {
822+ sm.mu.Unlock()
823+ return
824+ }
825+ sm.selectedIndex--
826+ if sm.selectedIndex < 0 {
827+ sm.selectedIndex = maxIndex // Wrap to bottom
612 }828 }
613 idx := sm.selectedIndex829 idx := sm.selectedIndex
614 sm.mu.Unlock()830 sm.mu.Unlock()
615 831
616- sm.selectIndex(idx)832+ sm.selectBySessionIndex(idx)
617 }833 }
618 834
619 func (sm *SessionManager) selectNext() {835 func (sm *SessionManager) selectNext() {
620 sm.mu.Lock()836 sm.mu.Lock()
621 maxIndex := len(sm.sessions) - 1837 maxIndex := len(sm.sessions) - 1
622- // Account for divider row838+ if maxIndex < 0 {
623- if sm.selectedIndex < maxIndex {839+ sm.mu.Unlock()
624- sm.selectedIndex++840+ return
841+ }
842+ sm.selectedIndex++
843+ if sm.selectedIndex > maxIndex {
844+ sm.selectedIndex = 0 // Wrap to top
625 }845 }
626 idx := sm.selectedIndex846 idx := sm.selectedIndex
627 sm.mu.Unlock()847 sm.mu.Unlock()
628 848
629- sm.selectIndex(idx)849+ sm.selectBySessionIndex(idx)
850+}
851+
852+func (sm *SessionManager) toggleExpanded() {
853+ sm.mu.Lock()
854+ if sm.expandedIndex == sm.selectedIndex {
855+ sm.expandedIndex = -1 // collapse
856+ } else {
857+ sm.expandedIndex = sm.selectedIndex // expand
858+ }
859+ sm.mu.Unlock()
860+
861+ // Rebuild the list to show/hide tree
862+ sm.populateList()
630 }863 }
631 864
632 func (sm *SessionManager) openSelected() {865 func (sm *SessionManager) openSelected() {
M internal/ui/deps.go +4 -3
@@ -19,9 +19,10 @@ import (
1919 // This struct is created once at startup and passed to UI components.
2020 type Dependencies struct {
2121 // Core context and configuration
22- Ctx context.Context
23- Config *config.Config
24- InitialURL string // URL to open on startup (optional)
22+ Ctx context.Context
23+ Config *config.Config
24+ InitialURL string // URL to open on startup (optional)
25+ RestoreSessionID string // Session ID to restore on startup (optional)
2526
2627 // Theme management
2728 Theme *theme.Manager
@@ -19,9 +19,10 @@ import (
19 // This struct is created once at startup and passed to UI components.19 // This struct is created once at startup and passed to UI components.
20 type Dependencies struct {20 type Dependencies struct {
21 // Core context and configuration21 // Core context and configuration
22- Ctx context.Context22+ Ctx context.Context
23- Config *config.Config23+ Config *config.Config
24- InitialURL string // URL to open on startup (optional)24+ InitialURL string // URL to open on startup (optional)
25+ RestoreSessionID string // Session ID to restore on startup (optional)
25 26
26 // Theme management27 // Theme management
27 Theme *theme.Manager28 Theme *theme.Manager
M internal/ui/focus/borders.go +8 -2
@@ -10,8 +10,9 @@ import (
1010
1111 // CSS classes for mode borders.
1212 const (
13- paneModeClass = "pane-mode-active"
14- tabModeClass = "tab-mode-active"
13+ paneModeClass = "pane-mode-active"
14+ tabModeClass = "tab-mode-active"
15+ sessionModeClass = "session-mode-active"
1516 )
1617
1718 // BorderManager manages mode indicator borders on the workspace container.
@@ -58,6 +59,8 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode)
5859 bm.borderOverlay.RemoveCssClass(paneModeClass)
5960 case input.ModeTab:
6061 bm.borderOverlay.RemoveCssClass(tabModeClass)
62+ case input.ModeSession:
63+ bm.borderOverlay.RemoveCssClass(sessionModeClass)
6164 }
6265
6366 // Add new mode class and set visibility
@@ -68,6 +71,9 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode)
6871 case input.ModeTab:
6972 bm.borderOverlay.AddCssClass(tabModeClass)
7073 bm.borderOverlay.SetVisible(true)
74+ case input.ModeSession:
75+ bm.borderOverlay.AddCssClass(sessionModeClass)
76+ bm.borderOverlay.SetVisible(true)
7177 case input.ModeNormal:
7278 bm.borderOverlay.SetVisible(false)
7379 }
@@ -10,8 +10,9 @@ import (
10 10
11 // CSS classes for mode borders.11 // CSS classes for mode borders.
12 const (12 const (
13- paneModeClass = "pane-mode-active"13+ paneModeClass = "pane-mode-active"
14- tabModeClass = "tab-mode-active"14+ tabModeClass = "tab-mode-active"
15+ sessionModeClass = "session-mode-active"
15 )16 )
16 17
17 // BorderManager manages mode indicator borders on the workspace container.18 // BorderManager manages mode indicator borders on the workspace container.
@@ -58,6 +59,8 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode)
58 bm.borderOverlay.RemoveCssClass(paneModeClass)59 bm.borderOverlay.RemoveCssClass(paneModeClass)
59 case input.ModeTab:60 case input.ModeTab:
60 bm.borderOverlay.RemoveCssClass(tabModeClass)61 bm.borderOverlay.RemoveCssClass(tabModeClass)
62+ case input.ModeSession:
63+ bm.borderOverlay.RemoveCssClass(sessionModeClass)
61 }64 }
62 65
63 // Add new mode class and set visibility66 // Add new mode class and set visibility
@@ -68,6 +71,9 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode)
68 case input.ModeTab:71 case input.ModeTab:
69 bm.borderOverlay.AddCssClass(tabModeClass)72 bm.borderOverlay.AddCssClass(tabModeClass)
70 bm.borderOverlay.SetVisible(true)73 bm.borderOverlay.SetVisible(true)
74+ case input.ModeSession:
75+ bm.borderOverlay.AddCssClass(sessionModeClass)
76+ bm.borderOverlay.SetVisible(true)
71 case input.ModeNormal:77 case input.ModeNormal:
72 bm.borderOverlay.SetVisible(false)78 bm.borderOverlay.SetVisible(false)
73 }79 }
M internal/ui/input/shortcuts.go +4 -1
@@ -306,6 +306,8 @@ func (s *ShortcutSet) registerStandardShortcuts() {
306306 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
307307 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
308308 s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL
309+ // Session management - direct shortcut to open session manager
310+ s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager
309311 }
310312
311313 func (s *ShortcutSet) registerPaneNavigationShortcuts() {
@@ -509,7 +511,8 @@ func ShouldAutoExitMode(action Action) bool {
509511 switch action {
510512 case ActionNewTab, ActionCloseTab, ActionRenameTab,
511513 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
512- ActionClosePane, ActionStackPane:
514+ ActionClosePane, ActionStackPane,
515+ ActionOpenSessionManager:
513516 return true
514517 default:
515518 return false
@@ -306,6 +306,8 @@ func (s *ShortcutSet) registerStandardShortcuts() {
306 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit306 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
307 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen307 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
308 s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL308 s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL
309+ // Session management - direct shortcut to open session manager
310+ s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager
309 }311 }
310 312
311 func (s *ShortcutSet) registerPaneNavigationShortcuts() {313 func (s *ShortcutSet) registerPaneNavigationShortcuts() {
@@ -509,7 +511,8 @@ func ShouldAutoExitMode(action Action) bool {
509 switch action {511 switch action {
510 case ActionNewTab, ActionCloseTab, ActionRenameTab,512 case ActionNewTab, ActionCloseTab, ActionRenameTab,
511 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,513 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
512- ActionClosePane, ActionStackPane:514+ ActionClosePane, ActionStackPane,
515+ ActionOpenSessionManager:
513 return true516 return true
514 default:517 default:
515 return false518 return false

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