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