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 ( | |||
| 41 | 41 | // initialURL holds the URL to open on startup (from browse command). | |
| 42 | 42 | var initialURL string | |
| 43 | 43 | ||
| 44 | + | // restoreSessionID holds the session ID to restore on startup. | |
| 45 | + | var restoreSessionID string | |
| 46 | + | ||
| 44 | 47 | func main() { | |
| 45 | 48 | // Run GUI mode for browse command | |
| 46 | 49 | if len(os.Args) > 1 && os.Args[1] == "browse" { | |
| @@ -48,6 +51,8 @@ func main() { | |||
| 48 | 51 | if len(os.Args) > 2 { | |
| 49 | 52 | initialURL = os.Args[2] | |
| 50 | 53 | } | |
| 54 | + | // Check for session restoration via environment variable | |
| 55 | + | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | |
| 51 | 56 | // Strip "browse" and URL from args so GTK doesn't see them | |
| 52 | 57 | os.Args = os.Args[:1] | |
| 53 | 58 | os.Exit(runGUI()) | |
| @@ -304,6 +309,7 @@ func buildUIDependencies( | |||
| 304 | 309 | Ctx: ctx, | |
| 305 | 310 | Config: cfg, | |
| 306 | 311 | InitialURL: initialURL, | |
| 312 | + | RestoreSessionID: restoreSessionID, | |
| 307 | 313 | Theme: themeManager, | |
| 308 | 314 | WebContext: stack.Context, | |
| 309 | 315 | 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 string | 42 | 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 command | 48 | // 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 them | 56 | // 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 { | |||
| 167 | 167 | } | |
| 168 | 168 | 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 | + | } | |
| @@ -167,3 +167,136 @@ func countPanesInNode(node *PaneNodeSnapshot) int { | |||||
| 167 | } | 167 | } | ||
| 168 | return count | 168 | 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 { | |||
| 218 | 218 | ActivationShortcut: defaultSessionActivationShortcut, | |
| 219 | 219 | TimeoutMilliseconds: defaultSessionTimeoutMilliseconds, | |
| 220 | 220 | Actions: map[string][]string{ | |
| 221 | - | "session-manager": {"w"}, | |
| 221 | + | "session-manager": {"s", "w"}, | |
| 222 | 222 | "confirm": {"enter"}, | |
| 223 | 223 | "cancel": {"escape"}, | |
| 224 | 224 | }, | |
| @@ -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() { | |||
| 314 | 314 | m.setOmniboxDefaults(defaults) | |
| 315 | 315 | m.setMediaDefaults(defaults) | |
| 316 | 316 | m.setRuntimeDefaults(defaults) | |
| 317 | + | m.setSessionDefaults(defaults) | |
| 317 | 318 | } | |
| 318 | 319 | ||
| 319 | 320 | func (m *Manager) setHistoryDefaults(defaults *Config) { | |
| @@ -402,6 +403,8 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||
| 402 | 403 | m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor) | |
| 403 | 404 | m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth) | |
| 404 | 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 | 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 | 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 | 445 | // New returns a new default configuration instance. | |
| 433 | 446 | // This is a convenience function for getting default config without the full manager. | |
| 434 | 447 | 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 { | |||
| 360 | 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 | 366 | // SpawnWithSession starts a new dumber instance to restore a session. | |
| 364 | 367 | func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | |
| 365 | 368 | log := logging.FromContext(s.ctx) | |
| @@ -369,8 +372,11 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | |||
| 369 | 372 | return fmt.Errorf("get executable path: %w", err) | |
| 370 | 373 | } | |
| 371 | 374 | ||
| 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)) | |
| 374 | 380 | ||
| 375 | 381 | // Detach from current process group so the new process survives | |
| 376 | 382 | cmd.Stdin = nil | |
| @@ -381,6 +387,9 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | |||
| 381 | 387 | return fmt.Errorf("spawn session: %w", err) | |
| 382 | 388 | } | |
| 383 | 389 | ||
| 390 | + | // Capture PID before releasing | |
| 391 | + | pid := cmd.Process.Pid | |
| 392 | + | ||
| 384 | 393 | // Release the process so it continues running after we close | |
| 385 | 394 | if err := cmd.Process.Release(); err != nil { | |
| 386 | 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 | 398 | log.Info(). | |
| 390 | 399 | Str("session_id", string(sessionID)). | |
| 391 | - | Int("pid", cmd.Process.Pid). | |
| 400 | + | Int("pid", pid). | |
| 392 | 401 | Msg("spawned dumber with session restoration") | |
| 393 | 402 | ||
| 394 | 403 | 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 flag | 375 | + | // 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 survives | 381 | // Detach from current process group so the new process survives | ||
| 376 | cmd.Stdin = nil | 382 | 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 close | 393 | // 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 nil | 403 | return nil | ||
M internal/ui/app.go +76 -1
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "github.com/bnema/dumber/internal/application/usecase" | |
| 12 | 12 | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | 13 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 14 | + | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 14 | 15 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| 15 | 16 | "github.com/bnema/dumber/internal/infrastructure/snapshot" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| @@ -310,6 +311,10 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 310 | 311 | a.handleModeChange(ctx, from, to) | |
| 311 | 312 | }) | |
| 312 | 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 | 318 | wsView := a.activeWorkspaceView() | |
| 314 | 319 | if wsView == nil { | |
| 315 | 320 | 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 | 402 | // Create session manager component | |
| 395 | 403 | a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{ | |
| 396 | 404 | ListSessionsUC: listSessionsUC, | |
| @@ -400,7 +408,9 @@ func (a *App) initSessionManager(ctx context.Context) { | |||
| 400 | 408 | }, | |
| 401 | 409 | OnOpen: func(sessionID entity.SessionID) { | |
| 402 | 410 | 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 | + | } | |
| 404 | 414 | }, | |
| 405 | 415 | }) | |
| 406 | 416 | ||
| @@ -476,6 +486,17 @@ func (a *App) MarkDirty() { | |||
| 476 | 486 | func (a *App) createInitialTab(ctx context.Context) { | |
| 477 | 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 | 500 | // Create an initial tab using coordinator. | |
| 480 | 501 | initialURL := "dumb://home" | |
| 481 | 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 | 564 | func (a *App) finalizeActivation(ctx context.Context) { | |
| 490 | 565 | log := logging.FromContext(ctx) | |
| 491 | 566 | ||
| @@ -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 false | 320 | 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 component | 402 | // 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/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 | + | } | |||
| 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 ( | |||
| 19 | 19 | sessionManagerWidthPct = 0.6 // 60% of parent window width | |
| 20 | 20 | sessionManagerMaxWidth = 600 // Maximum width in pixels | |
| 21 | 21 | sessionManagerTopMargin = 0.15 | |
| 22 | - | sessionListMaxHeight = 400 | |
| 22 | + | sessionMaxVisibleRows = 6 // Show 5 + current at max, rest is scrollable | |
| 23 | 23 | sessionDefaultListLimit = 50 | |
| 24 | 24 | sessionIDMaxDisplay = 20 | |
| 25 | 25 | 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 | 32 | // SessionManager is a modal overlay for managing sessions. | |
| @@ -40,10 +44,14 @@ type SessionManager struct { | |||
| 40 | 44 | parentOverlay layout.OverlayWidget | |
| 41 | 45 | ||
| 42 | 46 | // 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 | |
| 47 | 55 | ||
| 48 | 56 | // Dependencies | |
| 49 | 57 | listSessionsUC *usecase.ListSessionsUseCase | |
| @@ -81,6 +89,7 @@ func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionMa | |||
| 81 | 89 | onClose: cfg.OnClose, | |
| 82 | 90 | onOpen: cfg.OnOpen, | |
| 83 | 91 | selectedIndex: -1, | |
| 92 | + | expandedIndex: -1, | |
| 84 | 93 | } | |
| 85 | 94 | ||
| 86 | 95 | if err := sm.createWidgets(); err != nil { | |
| @@ -296,9 +305,9 @@ func (sm *SessionManager) initList() error { | |||
| 296 | 305 | return errNilWidget("sessionScrolledWindow") | |
| 297 | 306 | } | |
| 298 | 307 | sm.scrolledWindow.AddCssClass("session-manager-scrolled") | |
| 299 | - | sm.scrolledWindow.SetMaxContentHeight(sessionListMaxHeight) | |
| 300 | 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 | 312 | sm.listBox = gtk.NewListBox() | |
| 304 | 313 | if sm.listBox == nil { | |
| @@ -307,20 +316,29 @@ func (sm *SessionManager) initList() error { | |||
| 307 | 316 | sm.listBox.AddCssClass("session-manager-list") | |
| 308 | 317 | sm.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |
| 309 | 318 | ||
| 310 | - | // Connect row selection | |
| 319 | + | // Connect row selection - map list row index to session index | |
| 311 | 320 | rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) { | |
| 312 | 321 | if rowPtr == 0 { | |
| 313 | - | sm.mu.Lock() | |
| 314 | - | sm.selectedIndex = -1 | |
| 315 | - | sm.mu.Unlock() | |
| 316 | 322 | return | |
| 317 | 323 | } | |
| 318 | 324 | 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 { | |
| 322 | 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 | 343 | sm.retainedCallbacks = append(sm.retainedCallbacks, rowSelectedCb) | |
| 326 | 344 | sm.listBox.ConnectRowSelected(&rowSelectedCb) | |
| @@ -330,7 +348,7 @@ func (sm *SessionManager) initList() error { | |||
| 330 | 348 | } | |
| 331 | 349 | ||
| 332 | 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 | 352 | sm.footerLabel = gtk.NewLabel(&footerText) | |
| 335 | 353 | if sm.footerLabel == nil { | |
| 336 | 354 | return errNilWidget("sessionFooterLabel") | |
| @@ -369,6 +387,10 @@ func (sm *SessionManager) handleKeyPress(keyval uint, _ gdk.ModifierType) bool { | |||
| 369 | 387 | sm.selectNext() | |
| 370 | 388 | return true | |
| 371 | 389 | ||
| 390 | + | case uint(gdk.KEY_Tab), uint(gdk.KEY_space): | |
| 391 | + | sm.toggleExpanded() | |
| 392 | + | return true | |
| 393 | + | ||
| 372 | 394 | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |
| 373 | 395 | sm.openSelected() | |
| 374 | 396 | return true | |
| @@ -407,91 +429,286 @@ func (sm *SessionManager) loadSessions() { | |||
| 407 | 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 | 444 | func (sm *SessionManager) populateList() { | |
| 411 | - | sm.mu.RLock() | |
| 445 | + | sm.mu.Lock() | |
| 412 | 446 | 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 | 454 | if sm.listBox == nil { | |
| 455 | + | sm.mu.Lock() | |
| 456 | + | sm.populatingList = false | |
| 457 | + | sm.mu.Unlock() | |
| 416 | 458 | return | |
| 417 | 459 | } | |
| 418 | 460 | ||
| 419 | - | // Clear existing rows | |
| 420 | 461 | sm.listBox.RemoveAll() | |
| 421 | 462 | ||
| 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 { | |
| 425 | 465 | 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 | |
| 429 | 469 | } | |
| 430 | 470 | } | |
| 431 | 471 | ||
| 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) | |
| 438 | 474 | } | |
| 439 | 475 | ||
| 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 { | |
| 444 | 489 | sm.listBox.Append(÷r.Widget) | |
| 490 | + | state.listRowToSession = append(state.listRowToSession, -1) | |
| 491 | + | state.listRowCount++ | |
| 492 | + | state.dividerCount++ | |
| 445 | 493 | } | |
| 446 | 494 | } | |
| 447 | 495 | ||
| 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++ | |
| 454 | 505 | } | |
| 455 | 506 | ||
| 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++ | |
| 461 | 513 | } | |
| 462 | 514 | } | |
| 515 | + | } | |
| 463 | 516 | ||
| 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 | |
| 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 | 538 | if sm.scrolledWindow == nil { | |
| 474 | 539 | return | |
| 475 | 540 | } | |
| 476 | 541 | ||
| 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 | |
| 479 | 544 | ||
| 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 | |
| 482 | 558 | } | |
| 483 | 559 | ||
| 484 | 560 | // Schedule resize | |
| 485 | 561 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 486 | - | sm.scrolledWindow.SetMinContentHeight(-1) | |
| 487 | - | sm.scrolledWindow.SetMaxContentHeight(contentHeight) | |
| 488 | 562 | sm.scrolledWindow.SetMinContentHeight(contentHeight) | |
| 563 | + | sm.scrolledWindow.SetMaxContentHeight(contentHeight) | |
| 489 | 564 | sm.outerBox.QueueResize() | |
| 490 | 565 | return false | |
| 491 | 566 | }) | |
| 492 | 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 | 712 | func (sm *SessionManager) createDividerRow(text string) *gtk.ListBoxRow { | |
| 496 | 713 | row := gtk.NewListBoxRow() | |
| 497 | 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 | 818 | func (sm *SessionManager) selectPrevious() { | |
| 609 | 819 | 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 | |
| 612 | 828 | } | |
| 613 | 829 | idx := sm.selectedIndex | |
| 614 | 830 | sm.mu.Unlock() | |
| 615 | 831 | ||
| 616 | - | sm.selectIndex(idx) | |
| 832 | + | sm.selectBySessionIndex(idx) | |
| 617 | 833 | } | |
| 618 | 834 | ||
| 619 | 835 | func (sm *SessionManager) selectNext() { | |
| 620 | 836 | sm.mu.Lock() | |
| 621 | 837 | 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 | |
| 625 | 845 | } | |
| 626 | 846 | idx := sm.selectedIndex | |
| 627 | 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 | 865 | func (sm *SessionManager) openSelected() { | |
| @@ -19,10 +19,14 @@ const ( | |||||
| 19 | sessionManagerWidthPct = 0.6 // 60% of parent window width | 19 | sessionManagerWidthPct = 0.6 // 60% of parent window width | ||
| 20 | sessionManagerMaxWidth = 600 // Maximum width in pixels | 20 | sessionManagerMaxWidth = 600 // Maximum width in pixels | ||
| 21 | sessionManagerTopMargin = 0.15 | 21 | sessionManagerTopMargin = 0.15 | ||
| 22 | - | sessionListMaxHeight = 400 | 22 | + | sessionMaxVisibleRows = 6 // Show 5 + current at max, rest is scrollable |
| 23 | sessionDefaultListLimit = 50 | 23 | sessionDefaultListLimit = 50 | ||
| 24 | sessionIDMaxDisplay = 20 | 24 | sessionIDMaxDisplay = 20 | ||
| 25 | sessionRowBoxSpacing = 8 | 25 | 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.OverlayWidget | 44 | parentOverlay layout.OverlayWidget | ||
| 41 | 45 | ||||
| 42 | // State | 46 | // State | ||
| 43 | - | mu sync.RWMutex | 47 | + | mu sync.RWMutex |
| 44 | - | visible bool | 48 | + | visible bool |
| 45 | - | sessions []entity.SessionInfo | 49 | + | sessions []entity.SessionInfo |
| 46 | - | selectedIndex int | 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 | |||
| 47 | 55 | ||||
| 48 | // Dependencies | 56 | // Dependencies | ||
| 49 | listSessionsUC *usecase.ListSessionsUseCase | 57 | 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 selection | 319 | + | // 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 | return | 322 | 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 true | 388 | 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 true | 396 | 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.sessions | 446 | 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 | return | 458 | return | ||
| 417 | } | 459 | } | ||
| 418 | 460 | ||||
| 419 | - | // Clear existing rows | |||
| 420 | sm.listBox.RemoveAll() | 461 | sm.listBox.RemoveAll() | ||
| 421 | 462 | ||||
| 422 | - | // Separate active and exited sessions | 463 | + | state := &populateListState{expandedIdx: expandedIdx, firstExitedIdx: -1} |
| 423 | - | var activeSessions, exitedSessions []entity.SessionInfo | 464 | + | 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 rows | 472 | + | 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 sessions | 476 | + | 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(÷r.Widget) | 489 | sm.listBox.Append(÷r.Widget) | ||
| 490 | + | state.listRowToSession = append(state.listRowToSession, -1) | |||
| 491 | + | state.listRowCount++ | |||
| 492 | + | state.dividerCount++ | |||
| 445 | } | 493 | } | ||
| 446 | } | 494 | } | ||
| 447 | 495 | ||||
| 448 | - | // Add exited session rows | 496 | + | 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 row | 507 | + | 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 content | 517 | + | 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 row | 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 | |||
| 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 | return | 539 | return | ||
| 475 | } | 540 | } | ||
| 476 | 541 | ||||
| 477 | - | const rowHeight = 50 | 542 | + | // Calculate max visible height (6 session rows max visible, accounting for divider) |
| 478 | - | contentHeight := rowCount * rowHeight | 543 | + | maxVisibleHeight := sessionMaxVisibleRows*sessionRowHeight + sessionDividerRowHeight |
| 479 | 544 | ||||
| 480 | - | if contentHeight > sessionListMaxHeight { | 545 | + | // Calculate actual content height |
| 481 | - | contentHeight = sessionListMaxHeight | 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 | |||
| 482 | } | 558 | } | ||
| 483 | 559 | ||||
| 484 | // Schedule resize | 560 | // 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 false | 565 | 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.selectedIndex | 829 | 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) - 1 | 837 | maxIndex := len(sm.sessions) - 1 | ||
| 622 | - | // Account for divider row | 838 | + | 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.selectedIndex | 846 | 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 ( | |||
| 19 | 19 | // This struct is created once at startup and passed to UI components. | |
| 20 | 20 | type Dependencies struct { | |
| 21 | 21 | // 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) | |
| 25 | 26 | ||
| 26 | 27 | // Theme management | |
| 27 | 28 | 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 configuration | 21 | // Core context and configuration | ||
| 22 | - | Ctx context.Context | 22 | + | Ctx context.Context |
| 23 | - | Config *config.Config | 23 | + | 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 management | 27 | // Theme management | ||
| 27 | Theme *theme.Manager | 28 | Theme *theme.Manager | ||
M internal/ui/focus/borders.go +8 -2
| @@ -10,8 +10,9 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | // CSS classes for mode borders. | |
| 12 | 12 | 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" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 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 | 59 | bm.borderOverlay.RemoveCssClass(paneModeClass) | |
| 59 | 60 | case input.ModeTab: | |
| 60 | 61 | bm.borderOverlay.RemoveCssClass(tabModeClass) | |
| 62 | + | case input.ModeSession: | |
| 63 | + | bm.borderOverlay.RemoveCssClass(sessionModeClass) | |
| 61 | 64 | } | |
| 62 | 65 | ||
| 63 | 66 | // Add new mode class and set visibility | |
| @@ -68,6 +71,9 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode) | |||
| 68 | 71 | case input.ModeTab: | |
| 69 | 72 | bm.borderOverlay.AddCssClass(tabModeClass) | |
| 70 | 73 | bm.borderOverlay.SetVisible(true) | |
| 74 | + | case input.ModeSession: | |
| 75 | + | bm.borderOverlay.AddCssClass(sessionModeClass) | |
| 76 | + | bm.borderOverlay.SetVisible(true) | |
| 71 | 77 | case input.ModeNormal: | |
| 72 | 78 | bm.borderOverlay.SetVisible(false) | |
| 73 | 79 | } | |
| @@ -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 visibility | 66 | // 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() { | |||
| 306 | 306 | s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit | |
| 307 | 307 | s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen | |
| 308 | 308 | 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 | 313 | func (s *ShortcutSet) registerPaneNavigationShortcuts() { | |
| @@ -509,7 +511,8 @@ func ShouldAutoExitMode(action Action) bool { | |||
| 509 | 511 | switch action { | |
| 510 | 512 | case ActionNewTab, ActionCloseTab, ActionRenameTab, | |
| 511 | 513 | ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown, | |
| 512 | - | ActionClosePane, ActionStackPane: | |
| 514 | + | ActionClosePane, ActionStackPane, | |
| 515 | + | ActionOpenSessionManager: | |
| 513 | 516 | return true | |
| 514 | 517 | default: | |
| 515 | 518 | return false | |
| @@ -306,6 +306,8 @@ func (s *ShortcutSet) registerStandardShortcuts() { | |||||
| 306 | s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit | 306 | s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit | ||
| 307 | s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen | 307 | s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen | ||
| 308 | s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL | 308 | 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 true | 516 | return true | ||
| 514 | default: | 517 | default: | ||
| 515 | return false | 518 | return false | ||