Merge pull request #22 from bnema/feat/session-resurrection
feat: add session management and resurrection system
63 files · +7755 -283
M .mockery.yaml +3 -0
| @@ -9,6 +9,8 @@ packages: | |||
| 9 | 9 | pkgname: mocks | |
| 10 | 10 | interfaces: | |
| 11 | 11 | SessionLogger: {} | |
| 12 | + | SessionSpawner: {} | |
| 13 | + | TabListProvider: {} | |
| 12 | 14 | WebView: {} | |
| 13 | 15 | WebViewFactory: {} | |
| 14 | 16 | WebViewPool: {} | |
| @@ -23,6 +25,7 @@ packages: | |||
| 23 | 25 | FolderRepository: {} | |
| 24 | 26 | HistoryRepository: {} | |
| 25 | 27 | SessionRepository: {} | |
| 28 | + | SessionStateRepository: {} | |
| 26 | 29 | TagRepository: {} | |
| 27 | 30 | ZoomRepository: {} | |
| 28 | 31 | github.com/bnema/dumber/internal/infrastructure/filtering: | |
M CHANGELOG.md +9 -0
| @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. | |||
| 4 | 4 | ||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | + | ### Added | |
| 8 | + | - **Session management & resurrection**: Zellij-inspired session management system allowing users to save, list, and restore browser sessions. | |
| 9 | + | - **Automatic session snapshots**: Debounced state saving (configurable interval, default 5s) captures tabs, panes, splits, stacks, URLs, titles, and zoom levels. | |
| 10 | + | - **Session Manager modal**: Access via `Ctrl+O → s/w` or `Ctrl+Shift+S` to browse and restore sessions with inline preview of tabs and panes. | |
| 11 | + | - **Session mode**: New modal mode (`Ctrl+O`) with purple border indicator, similar to pane/tab modes. | |
| 12 | + | - **CLI session commands**: `dumber sessions` opens interactive TUI browser; `dumber sessions list` shows saved sessions; `dumber sessions restore <id>` resurrects a session in a new window; `dumber sessions delete <id>` removes a session. | |
| 13 | + | - **Session purging**: Integrated into `dumber purge` command for cleaning up old session data. | |
| 14 | + | - **Configuration options**: `session.auto_restore`, `session.snapshot_interval_ms`, `session.max_exited_sessions`, `session.max_listed_sessions`, plus session mode shortcuts and styling. | |
| 15 | + | ||
| 7 | 16 | ### Fixed | |
| 8 | 17 | - **Idle inhibition leak on pane close**: Fixed D-Bus idle inhibit request not being released when closing a pane/tab while in fullscreen or playing audio. The inhibition would remain active until the app exited. Now properly tracks fullscreen and audio state and releases inhibition before destroying WebView. | |
| 9 | 18 | - **Audio-based idle inhibition**: Extended idle inhibition to also activate when a page is playing audio (e.g., video/music playback), not just in fullscreen mode. Uses WebKit's `notify::is-playing-audio` signal. The inhibitor uses refcounting, so both fullscreen and audio can be active simultaneously. | |
M README.md +28 -0
| @@ -23,6 +23,7 @@ A dumb browser that works like your favorite terminal multiplexer. | |||
| 23 | 23 | - **Customizable themes**: Light and dark palettes with semantic color tokens. | |
| 24 | 24 | - **Persistent storage**: SQLite for history, zoom levels, and settings. | |
| 25 | 25 | - **Live configuration**: Single config file with hot reload when possible. | |
| 26 | + | - **Session management**: Zellij-style session save/restore with automatic snapshots and resurrection. | |
| 26 | 27 | ||
| 27 | 28 | ## Status | |
| 28 | 29 | Dumber recently completed a full rewrite in pure Go using the `puregotk` and `puregotk-webkit` libraries. | |
| @@ -79,6 +80,14 @@ Early development with regular releases. Core features work well for daily use b | |||
| 79 | 80 | | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes | | |
| 80 | 81 | | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes | | |
| 81 | 82 | ||
| 83 | + | #### Session Management | |
| 84 | + | | Shortcut | Action | Notes | | |
| 85 | + | |----------|--------|-------| | |
| 86 | + | | **Ctrl+O** | Enter Session Mode | Modal mode for session operations | | |
| 87 | + | | **Ctrl+Shift+S** | Open Session Manager | Direct access to session browser | | |
| 88 | + | | **S** / **W** (in session mode) | Open Session Manager | Browse and restore sessions | | |
| 89 | + | | **Escape** (in session mode) | Exit Session Mode | Return to normal navigation | | |
| 90 | + | ||
| 82 | 91 | ### Mouse Controls | |
| 83 | 92 | | Action | Result | Notes | | |
| 84 | 93 | |--------|---------|-------| | |
| @@ -231,6 +240,12 @@ sudo apt install va-driver-all | |||
| 231 | 240 | - `dumber purge --force` # purge all data (no confirmation) | |
| 232 | 241 | - `dumber purge -d -H -c` # purge database and both caches | |
| 233 | 242 | - `dumber purge --browser-data` # purge WebKit data (cookies, etc.) | |
| 243 | + | - Manage sessions: | |
| 244 | + | - `dumber sessions` # interactive session browser (TUI) | |
| 245 | + | - `dumber sessions list` # list saved sessions | |
| 246 | + | - `dumber sessions list --json` # output sessions as JSON | |
| 247 | + | - `dumber sessions restore <id>` # restore a session in new window | |
| 248 | + | - `dumber sessions delete <id>` # delete a saved session | |
| 234 | 249 | - Manage logs: | |
| 235 | 250 | - `dumber logs list` # list available log files | |
| 236 | 251 | - `dumber logs tail` # tail current log file | |
| @@ -276,6 +291,19 @@ Split your browser window into multiple panes, each running independent web sess | |||
| 276 | 291 | 3. **Navigate**: Use `Alt+Arrow Keys` to move focus between split panes, or `Alt+Up/Down` to navigate within stacked panes | |
| 277 | 292 | 4. **Close**: Press `X` in pane mode to close the current pane | |
| 278 | 293 | ||
| 294 | + | ## Session Management | |
| 295 | + | ||
| 296 | + | Dumber automatically saves your browser session (tabs, panes, splits, URLs) and allows you to restore previous sessions. | |
| 297 | + | ||
| 298 | + | ### How it Works | |
| 299 | + | 1. **Automatic Snapshots**: Session state is saved automatically (debounced, every 5s by default) and on graceful shutdown | |
| 300 | + | 2. **Session Manager**: Press `Ctrl+O → s` or `Ctrl+Shift+S` to open the session manager modal | |
| 301 | + | 3. **Browse Sessions**: View active and exited sessions with tab/pane previews | |
| 302 | + | 4. **Restore**: Select an exited session to restore it in a new browser window | |
| 303 | + | 5. **CLI Access**: Use `dumber sessions` for an interactive TUI, or `dumber sessions list/restore/delete` for scripting | |
| 304 | + | ||
| 305 | + | Session mode provides visual feedback with a purple border indicator. | |
| 306 | + | ||
| 279 | 307 | ## Configuration | |
| 280 | 308 | ||
| 281 | 309 | Configuration file: `~/.config/dumber/config.toml` (supports TOML, JSON, YAML) | |
M cmd/dumber/main.go +99 -60
| @@ -41,6 +41,9 @@ var ( | |||
| 41 | 41 | // initialURL holds the URL to open on startup (from browse command). | |
| 42 | 42 | var initialURL string | |
| 43 | 43 | ||
| 44 | + | // restoreSessionID holds the session ID to restore on startup. | |
| 45 | + | var restoreSessionID string | |
| 46 | + | ||
| 44 | 47 | func main() { | |
| 45 | 48 | // Run GUI mode for browse command | |
| 46 | 49 | if len(os.Args) > 1 && os.Args[1] == "browse" { | |
| @@ -48,6 +51,8 @@ func main() { | |||
| 48 | 51 | if len(os.Args) > 2 { | |
| 49 | 52 | initialURL = os.Args[2] | |
| 50 | 53 | } | |
| 54 | + | // Check for session restoration via environment variable | |
| 55 | + | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | |
| 51 | 56 | // Strip "browse" and URL from args so GTK doesn't see them | |
| 52 | 57 | os.Args = os.Args[:1] | |
| 53 | 58 | os.Exit(runGUI()) | |
| @@ -102,27 +107,47 @@ func runGUI() int { | |||
| 102 | 107 | _ = browserSession.End(sessionCtx) | |
| 103 | 108 | }() | |
| 104 | 109 | ||
| 105 | - | logger := browserSession.Logger | |
| 106 | 110 | ctx = sessionCtx | |
| 111 | + | log := logging.FromContext(ctx) | |
| 107 | 112 | ||
| 108 | - | checkRuntimeRequirements(ctx, cfg, logger) | |
| 109 | - | checkMediaRequirements(ctx, cfg, logger) | |
| 113 | + | checkRuntimeRequirements(ctx, cfg, *log) | |
| 114 | + | checkMediaRequirements(ctx, cfg, *log) | |
| 110 | 115 | ||
| 111 | 116 | themeManager := theme.NewManager(ctx, cfg) | |
| 112 | - | stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger) | |
| 117 | + | stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, *log) | |
| 113 | 118 | repos := createRepositories(db) | |
| 114 | - | useCases := createUseCases(repos, cfg) | |
| 119 | + | ||
| 120 | + | stateDir, _ := config.GetStateDir() | |
| 121 | + | useCases := createUseCases(repos, cfg, stateDir) | |
| 122 | + | ||
| 123 | + | // Auto-restore: find last restorable session if enabled and no explicit restore requested | |
| 124 | + | if cfg.Session.AutoRestore && restoreSessionID == "" { | |
| 125 | + | var out *usecase.GetLastRestorableSessionOutput | |
| 126 | + | out, err = useCases.lastRestorable.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 127 | + | ExcludeSessionID: browserSession.Session.ID, | |
| 128 | + | }) | |
| 129 | + | if err != nil { | |
| 130 | + | log.Warn().Err(err).Msg("auto-restore: failed to find restorable session") | |
| 131 | + | } else if out.SessionID != "" { | |
| 132 | + | restoreSessionID = string(out.SessionID) | |
| 133 | + | log.Info(). | |
| 134 | + | Str("session_id", restoreSessionID). | |
| 135 | + | Int("tabs", len(out.State.Tabs)). | |
| 136 | + | Msg("auto-restore: found last session") | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 115 | 140 | idleInhibitor := idle.NewPortalInhibitor(ctx) | |
| 116 | 141 | defer func() { | |
| 117 | 142 | if idleInhibitor != nil { | |
| 118 | 143 | _ = idleInhibitor.Close() | |
| 119 | 144 | } | |
| 120 | 145 | }() | |
| 121 | - | uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor) | |
| 146 | + | uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID) | |
| 122 | 147 | ||
| 123 | 148 | app, err := ui.New(uiDeps) | |
| 124 | 149 | if err != nil { | |
| 125 | - | logger.Error().Err(err).Msg("failed to create application") | |
| 150 | + | log.Error().Err(err).Msg("failed to create application") | |
| 126 | 151 | return 1 | |
| 127 | 152 | } | |
| 128 | 153 | ||
| @@ -134,7 +159,7 @@ func runGUI() int { | |||
| 134 | 159 | defer signal.Stop(sigCh) | |
| 135 | 160 | go func() { | |
| 136 | 161 | <-sigCh | |
| 137 | - | logger.Info().Msg("received interrupt, quitting") | |
| 162 | + | log.Info().Msg("received interrupt, quitting") | |
| 138 | 163 | app.Quit() | |
| 139 | 164 | }() | |
| 140 | 165 | ||
| @@ -226,37 +251,43 @@ func openDatabase(ctx context.Context, logger zerolog.Logger, dataDir string) (* | |||
| 226 | 251 | ||
| 227 | 252 | // repositories groups infrastructure layer repository implementations. | |
| 228 | 253 | type repositories struct { | |
| 229 | - | history repository.HistoryRepository | |
| 230 | - | favorite repository.FavoriteRepository | |
| 231 | - | folder repository.FolderRepository | |
| 232 | - | tag repository.TagRepository | |
| 233 | - | zoom repository.ZoomRepository | |
| 254 | + | history repository.HistoryRepository | |
| 255 | + | favorite repository.FavoriteRepository | |
| 256 | + | folder repository.FolderRepository | |
| 257 | + | tag repository.TagRepository | |
| 258 | + | zoom repository.ZoomRepository | |
| 259 | + | session repository.SessionRepository | |
| 260 | + | sessionState repository.SessionStateRepository | |
| 234 | 261 | } | |
| 235 | 262 | ||
| 236 | 263 | func createRepositories(db *sql.DB) *repositories { | |
| 237 | 264 | return &repositories{ | |
| 238 | - | history: sqlite.NewHistoryRepository(db), | |
| 239 | - | favorite: sqlite.NewFavoriteRepository(db), | |
| 240 | - | folder: sqlite.NewFolderRepository(db), | |
| 241 | - | tag: sqlite.NewTagRepository(db), | |
| 242 | - | zoom: sqlite.NewZoomRepository(db), | |
| 265 | + | history: sqlite.NewHistoryRepository(db), | |
| 266 | + | favorite: sqlite.NewFavoriteRepository(db), | |
| 267 | + | folder: sqlite.NewFolderRepository(db), | |
| 268 | + | tag: sqlite.NewTagRepository(db), | |
| 269 | + | zoom: sqlite.NewZoomRepository(db), | |
| 270 | + | session: sqlite.NewSessionRepository(db), | |
| 271 | + | sessionState: sqlite.NewSessionStateRepository(db), | |
| 243 | 272 | } | |
| 244 | 273 | } | |
| 245 | 274 | ||
| 246 | 275 | // useCases groups application layer use case implementations. | |
| 247 | 276 | type useCases struct { | |
| 248 | - | tabs *usecase.ManageTabsUseCase | |
| 249 | - | panes *usecase.ManagePanesUseCase | |
| 250 | - | history *usecase.SearchHistoryUseCase | |
| 251 | - | favorites *usecase.ManageFavoritesUseCase | |
| 252 | - | zoom *usecase.ManageZoomUseCase | |
| 253 | - | navigate *usecase.NavigateUseCase | |
| 254 | - | copyURL *usecase.CopyURLUseCase | |
| 255 | - | clipboard port.Clipboard | |
| 256 | - | favicon *favicon.Service | |
| 277 | + | tabs *usecase.ManageTabsUseCase | |
| 278 | + | panes *usecase.ManagePanesUseCase | |
| 279 | + | history *usecase.SearchHistoryUseCase | |
| 280 | + | favorites *usecase.ManageFavoritesUseCase | |
| 281 | + | zoom *usecase.ManageZoomUseCase | |
| 282 | + | navigate *usecase.NavigateUseCase | |
| 283 | + | copyURL *usecase.CopyURLUseCase | |
| 284 | + | snapshot *usecase.SnapshotSessionUseCase | |
| 285 | + | lastRestorable *usecase.GetLastRestorableSessionUseCase | |
| 286 | + | clipboard port.Clipboard | |
| 287 | + | favicon *favicon.Service | |
| 257 | 288 | } | |
| 258 | 289 | ||
| 259 | - | func createUseCases(repos *repositories, cfg *config.Config) *useCases { | |
| 290 | + | func createUseCases(repos *repositories, cfg *config.Config, stateDir string) *useCases { | |
| 260 | 291 | const idAlphabetSize = 26 | |
| 261 | 292 | idCounter := uint64(0) | |
| 262 | 293 | idGenerator := func() string { | |
| @@ -272,15 +303,17 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases { | |||
| 272 | 303 | zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize) | |
| 273 | 304 | ||
| 274 | 305 | return &useCases{ | |
| 275 | - | tabs: usecase.NewManageTabsUseCase(idGenerator), | |
| 276 | - | panes: usecase.NewManagePanesUseCase(idGenerator), | |
| 277 | - | history: usecase.NewSearchHistoryUseCase(repos.history), | |
| 278 | - | favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag), | |
| 279 | - | zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache), | |
| 280 | - | navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom), | |
| 281 | - | copyURL: usecase.NewCopyURLUseCase(clipboardAdapter), | |
| 282 | - | clipboard: clipboardAdapter, | |
| 283 | - | favicon: favicon.NewService(faviconCacheDir), | |
| 306 | + | tabs: usecase.NewManageTabsUseCase(idGenerator), | |
| 307 | + | panes: usecase.NewManagePanesUseCase(idGenerator), | |
| 308 | + | history: usecase.NewSearchHistoryUseCase(repos.history), | |
| 309 | + | favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag), | |
| 310 | + | zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache), | |
| 311 | + | navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom), | |
| 312 | + | copyURL: usecase.NewCopyURLUseCase(clipboardAdapter), | |
| 313 | + | snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState), | |
| 314 | + | lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir), | |
| 315 | + | clipboard: clipboardAdapter, | |
| 316 | + | favicon: favicon.NewService(faviconCacheDir), | |
| 284 | 317 | } | |
| 285 | 318 | } | |
| 286 | 319 | ||
| @@ -292,30 +325,36 @@ func buildUIDependencies( | |||
| 292 | 325 | repos *repositories, | |
| 293 | 326 | uc *useCases, | |
| 294 | 327 | idleInhibitor port.IdleInhibitor, | |
| 328 | + | currentSessionID entity.SessionID, | |
| 295 | 329 | ) *ui.Dependencies { | |
| 296 | 330 | return &ui.Dependencies{ | |
| 297 | - | Ctx: ctx, | |
| 298 | - | Config: cfg, | |
| 299 | - | InitialURL: initialURL, | |
| 300 | - | Theme: themeManager, | |
| 301 | - | WebContext: stack.Context, | |
| 302 | - | Pool: stack.Pool, | |
| 303 | - | Settings: stack.Settings, | |
| 304 | - | Injector: stack.Injector, | |
| 305 | - | MessageRouter: stack.MessageRouter, | |
| 306 | - | FilterManager: stack.FilterManager, | |
| 307 | - | HistoryRepo: repos.history, | |
| 308 | - | FavoriteRepo: repos.favorite, | |
| 309 | - | ZoomRepo: repos.zoom, | |
| 310 | - | TabsUC: uc.tabs, | |
| 311 | - | PanesUC: uc.panes, | |
| 312 | - | HistoryUC: uc.history, | |
| 313 | - | FavoritesUC: uc.favorites, | |
| 314 | - | ZoomUC: uc.zoom, | |
| 315 | - | NavigateUC: uc.navigate, | |
| 316 | - | CopyURLUC: uc.copyURL, | |
| 317 | - | Clipboard: uc.clipboard, | |
| 318 | - | FaviconService: uc.favicon, | |
| 319 | - | IdleInhibitor: idleInhibitor, | |
| 331 | + | Ctx: ctx, | |
| 332 | + | Config: cfg, | |
| 333 | + | InitialURL: initialURL, | |
| 334 | + | RestoreSessionID: restoreSessionID, | |
| 335 | + | Theme: themeManager, | |
| 336 | + | WebContext: stack.Context, | |
| 337 | + | Pool: stack.Pool, | |
| 338 | + | Settings: stack.Settings, | |
| 339 | + | Injector: stack.Injector, | |
| 340 | + | MessageRouter: stack.MessageRouter, | |
| 341 | + | FilterManager: stack.FilterManager, | |
| 342 | + | HistoryRepo: repos.history, | |
| 343 | + | FavoriteRepo: repos.favorite, | |
| 344 | + | ZoomRepo: repos.zoom, | |
| 345 | + | TabsUC: uc.tabs, | |
| 346 | + | PanesUC: uc.panes, | |
| 347 | + | HistoryUC: uc.history, | |
| 348 | + | FavoritesUC: uc.favorites, | |
| 349 | + | ZoomUC: uc.zoom, | |
| 350 | + | NavigateUC: uc.navigate, | |
| 351 | + | CopyURLUC: uc.copyURL, | |
| 352 | + | Clipboard: uc.clipboard, | |
| 353 | + | FaviconService: uc.favicon, | |
| 354 | + | IdleInhibitor: idleInhibitor, | |
| 355 | + | SessionRepo: repos.session, | |
| 356 | + | SessionStateRepo: repos.sessionState, | |
| 357 | + | CurrentSessionID: currentSessionID, | |
| 358 | + | SnapshotUC: uc.snapshot, | |
| 320 | 359 | } | |
| 321 | 360 | } | |
M docs/CONFIG.md +36 -0
| @@ -275,8 +275,44 @@ cancel = ["escape"] | |||
| 275 | 275 | | `workspace.styling.pane_mode_border_color` | string | `"#4A90E2"` | Pane mode border color (blue) | | |
| 276 | 276 | | `workspace.styling.tab_mode_border_width` | int | `4` | Tab mode border width (px) - Ctrl+P T overlay | | |
| 277 | 277 | | `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) | | |
| 278 | + | | `workspace.styling.session_mode_border_width` | int | `4` | Session mode border width (px) - Ctrl+O overlay | | |
| 279 | + | | `workspace.styling.session_mode_border_color` | string | `"#9B59B6"` | Session mode border color (purple) | | |
| 278 | 280 | | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) | | |
| 279 | 281 | ||
| 282 | + | ## Session | |
| 283 | + | ||
| 284 | + | | Key | Type | Default | Description | | |
| 285 | + | |-----|------|---------|-------------| | |
| 286 | + | | `session.auto_restore` | bool | `false` | Automatically restore the last session on startup | | |
| 287 | + | | `session.snapshot_interval_ms` | int | `5000` | Minimum interval between snapshots in milliseconds | | |
| 288 | + | | `session.max_exited_sessions` | int | `10` | Maximum number of exited sessions to keep | | |
| 289 | + | | `session.max_listed_sessions` | int | `50` | Maximum number of sessions to list in CLI/TUI | | |
| 290 | + | ||
| 291 | + | ### Session Mode | |
| 292 | + | ||
| 293 | + | | Key | Type | Default | Description | | |
| 294 | + | |-----|------|---------|-------------| | |
| 295 | + | | `session.session_mode.activation_shortcut` | string | `"ctrl+o"` | Session mode activation key | | |
| 296 | + | | `session.session_mode.timeout_ms` | int | `3000` | Session mode timeout (ms) | | |
| 297 | + | | `session.session_mode.actions` | map | See below | Action to keys mappings | | |
| 298 | + | ||
| 299 | + | **Default session mode actions:** | |
| 300 | + | ```toml | |
| 301 | + | [session.session_mode.actions] | |
| 302 | + | session-manager = ["s", "w"] | |
| 303 | + | confirm = ["enter"] | |
| 304 | + | cancel = ["escape"] | |
| 305 | + | ``` | |
| 306 | + | ||
| 307 | + | **Example:** | |
| 308 | + | ```toml | |
| 309 | + | [session] | |
| 310 | + | auto_restore = false # Don't auto-restore on startup | |
| 311 | + | snapshot_interval_ms = 5000 # Save state every 5 seconds (debounced) | |
| 312 | + | max_exited_sessions = 10 # Keep last 10 exited sessions | |
| 313 | + | max_listed_sessions = 50 # Show up to 50 sessions in CLI/TUI | |
| 314 | + | ``` | |
| 315 | + | ||
| 280 | 316 | ## Media | |
| 281 | 317 | ||
| 282 | 318 | | Key | Type | Default | Valid Values | Description | | |
M internal/application/port/mocks/mocks.go +195 -0
| @@ -120,6 +120,201 @@ func (_c *MockSessionLogger_CreateLogger_Call) RunAndReturn(run func(ctx context | |||
| 120 | 120 | return _c | |
| 121 | 121 | } | |
| 122 | 122 | ||
| 123 | + | // NewMockTabListProvider creates a new instance of MockTabListProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 124 | + | // The first argument is typically a *testing.T value. | |
| 125 | + | func NewMockTabListProvider(t interface { | |
| 126 | + | mock.TestingT | |
| 127 | + | Cleanup(func()) | |
| 128 | + | }) *MockTabListProvider { | |
| 129 | + | mock := &MockTabListProvider{} | |
| 130 | + | mock.Mock.Test(t) | |
| 131 | + | ||
| 132 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 133 | + | ||
| 134 | + | return mock | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // MockTabListProvider is an autogenerated mock type for the TabListProvider type | |
| 138 | + | type MockTabListProvider struct { | |
| 139 | + | mock.Mock | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | type MockTabListProvider_Expecter struct { | |
| 143 | + | mock *mock.Mock | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func (_m *MockTabListProvider) EXPECT() *MockTabListProvider_Expecter { | |
| 147 | + | return &MockTabListProvider_Expecter{mock: &_m.Mock} | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // GetSessionID provides a mock function for the type MockTabListProvider | |
| 151 | + | func (_mock *MockTabListProvider) GetSessionID() entity.SessionID { | |
| 152 | + | ret := _mock.Called() | |
| 153 | + | ||
| 154 | + | if len(ret) == 0 { | |
| 155 | + | panic("no return value specified for GetSessionID") | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | var r0 entity.SessionID | |
| 159 | + | if returnFunc, ok := ret.Get(0).(func() entity.SessionID); ok { | |
| 160 | + | r0 = returnFunc() | |
| 161 | + | } else { | |
| 162 | + | r0 = ret.Get(0).(entity.SessionID) | |
| 163 | + | } | |
| 164 | + | return r0 | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // MockTabListProvider_GetSessionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSessionID' | |
| 168 | + | type MockTabListProvider_GetSessionID_Call struct { | |
| 169 | + | *mock.Call | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // GetSessionID is a helper method to define mock.On call | |
| 173 | + | func (_e *MockTabListProvider_Expecter) GetSessionID() *MockTabListProvider_GetSessionID_Call { | |
| 174 | + | return &MockTabListProvider_GetSessionID_Call{Call: _e.mock.On("GetSessionID")} | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func (_c *MockTabListProvider_GetSessionID_Call) Run(run func()) *MockTabListProvider_GetSessionID_Call { | |
| 178 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 179 | + | run() | |
| 180 | + | }) | |
| 181 | + | return _c | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func (_c *MockTabListProvider_GetSessionID_Call) Return(sessionID entity.SessionID) *MockTabListProvider_GetSessionID_Call { | |
| 185 | + | _c.Call.Return(sessionID) | |
| 186 | + | return _c | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func (_c *MockTabListProvider_GetSessionID_Call) RunAndReturn(run func() entity.SessionID) *MockTabListProvider_GetSessionID_Call { | |
| 190 | + | _c.Call.Return(run) | |
| 191 | + | return _c | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // GetTabList provides a mock function for the type MockTabListProvider | |
| 195 | + | func (_mock *MockTabListProvider) GetTabList() *entity.TabList { | |
| 196 | + | ret := _mock.Called() | |
| 197 | + | ||
| 198 | + | if len(ret) == 0 { | |
| 199 | + | panic("no return value specified for GetTabList") | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | var r0 *entity.TabList | |
| 203 | + | if returnFunc, ok := ret.Get(0).(func() *entity.TabList); ok { | |
| 204 | + | r0 = returnFunc() | |
| 205 | + | } else { | |
| 206 | + | if ret.Get(0) != nil { | |
| 207 | + | r0 = ret.Get(0).(*entity.TabList) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | return r0 | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // MockTabListProvider_GetTabList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTabList' | |
| 214 | + | type MockTabListProvider_GetTabList_Call struct { | |
| 215 | + | *mock.Call | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // GetTabList is a helper method to define mock.On call | |
| 219 | + | func (_e *MockTabListProvider_Expecter) GetTabList() *MockTabListProvider_GetTabList_Call { | |
| 220 | + | return &MockTabListProvider_GetTabList_Call{Call: _e.mock.On("GetTabList")} | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func (_c *MockTabListProvider_GetTabList_Call) Run(run func()) *MockTabListProvider_GetTabList_Call { | |
| 224 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 225 | + | run() | |
| 226 | + | }) | |
| 227 | + | return _c | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func (_c *MockTabListProvider_GetTabList_Call) Return(tabList *entity.TabList) *MockTabListProvider_GetTabList_Call { | |
| 231 | + | _c.Call.Return(tabList) | |
| 232 | + | return _c | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func (_c *MockTabListProvider_GetTabList_Call) RunAndReturn(run func() *entity.TabList) *MockTabListProvider_GetTabList_Call { | |
| 236 | + | _c.Call.Return(run) | |
| 237 | + | return _c | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // NewMockSessionSpawner creates a new instance of MockSessionSpawner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 241 | + | // The first argument is typically a *testing.T value. | |
| 242 | + | func NewMockSessionSpawner(t interface { | |
| 243 | + | mock.TestingT | |
| 244 | + | Cleanup(func()) | |
| 245 | + | }) *MockSessionSpawner { | |
| 246 | + | mock := &MockSessionSpawner{} | |
| 247 | + | mock.Mock.Test(t) | |
| 248 | + | ||
| 249 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 250 | + | ||
| 251 | + | return mock | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // MockSessionSpawner is an autogenerated mock type for the SessionSpawner type | |
| 255 | + | type MockSessionSpawner struct { | |
| 256 | + | mock.Mock | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | type MockSessionSpawner_Expecter struct { | |
| 260 | + | mock *mock.Mock | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func (_m *MockSessionSpawner) EXPECT() *MockSessionSpawner_Expecter { | |
| 264 | + | return &MockSessionSpawner_Expecter{mock: &_m.Mock} | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // SpawnWithSession provides a mock function for the type MockSessionSpawner | |
| 268 | + | func (_mock *MockSessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | |
| 269 | + | ret := _mock.Called(sessionID) | |
| 270 | + | ||
| 271 | + | if len(ret) == 0 { | |
| 272 | + | panic("no return value specified for SpawnWithSession") | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | var r0 error | |
| 276 | + | if returnFunc, ok := ret.Get(0).(func(entity.SessionID) error); ok { | |
| 277 | + | r0 = returnFunc(sessionID) | |
| 278 | + | } else { | |
| 279 | + | r0 = ret.Error(0) | |
| 280 | + | } | |
| 281 | + | return r0 | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // MockSessionSpawner_SpawnWithSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SpawnWithSession' | |
| 285 | + | type MockSessionSpawner_SpawnWithSession_Call struct { | |
| 286 | + | *mock.Call | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // SpawnWithSession is a helper method to define mock.On call | |
| 290 | + | // - sessionID entity.SessionID | |
| 291 | + | func (_e *MockSessionSpawner_Expecter) SpawnWithSession(sessionID interface{}) *MockSessionSpawner_SpawnWithSession_Call { | |
| 292 | + | return &MockSessionSpawner_SpawnWithSession_Call{Call: _e.mock.On("SpawnWithSession", sessionID)} | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func (_c *MockSessionSpawner_SpawnWithSession_Call) Run(run func(sessionID entity.SessionID)) *MockSessionSpawner_SpawnWithSession_Call { | |
| 296 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 297 | + | var arg0 entity.SessionID | |
| 298 | + | if args[0] != nil { | |
| 299 | + | arg0 = args[0].(entity.SessionID) | |
| 300 | + | } | |
| 301 | + | run( | |
| 302 | + | arg0, | |
| 303 | + | ) | |
| 304 | + | }) | |
| 305 | + | return _c | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | func (_c *MockSessionSpawner_SpawnWithSession_Call) Return(err error) *MockSessionSpawner_SpawnWithSession_Call { | |
| 309 | + | _c.Call.Return(err) | |
| 310 | + | return _c | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func (_c *MockSessionSpawner_SpawnWithSession_Call) RunAndReturn(run func(sessionID entity.SessionID) error) *MockSessionSpawner_SpawnWithSession_Call { | |
| 314 | + | _c.Call.Return(run) | |
| 315 | + | return _c | |
| 316 | + | } | |
| 317 | + | ||
| 123 | 318 | // NewMockWebView creates a new instance of MockWebView. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 124 | 319 | // The first argument is typically a *testing.T value. | |
| 125 | 320 | func NewMockWebView(t interface { | |
A internal/application/port/session_state.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/dumber/internal/domain/entity" | |
| 4 | + | ||
| 5 | + | // TabListProvider provides access to the current tab list state. | |
| 6 | + | // Implemented by the UI layer to allow the snapshot service to read state. | |
| 7 | + | type TabListProvider interface { | |
| 8 | + | // GetTabList returns the current tab list state. | |
| 9 | + | GetTabList() *entity.TabList | |
| 10 | + | // GetSessionID returns the current session ID. | |
| 11 | + | GetSessionID() entity.SessionID | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // SessionSpawner spawns a new dumber instance for resurrection. | |
| 15 | + | type SessionSpawner interface { | |
| 16 | + | // SpawnWithSession starts a new dumber instance to restore a session. | |
| 17 | + | SpawnWithSession(sessionID entity.SessionID) error | |
| 18 | + | } | |
A internal/application/usecase/delete_session.go +79 -0
| @@ -0,0 +1,79 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // DeleteSessionUseCase errors. | |
| 13 | + | var ( | |
| 14 | + | ErrCannotDeleteCurrentSession = errors.New("cannot delete current session") | |
| 15 | + | ErrCannotDeleteActiveSession = errors.New("cannot delete active session") | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // DeleteSessionUseCase handles session deletion with proper validation. | |
| 19 | + | type DeleteSessionUseCase struct { | |
| 20 | + | stateRepo repository.SessionStateRepository | |
| 21 | + | sessionRepo repository.SessionRepository | |
| 22 | + | lockDir string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // NewDeleteSessionUseCase creates a new DeleteSessionUseCase. | |
| 26 | + | func NewDeleteSessionUseCase( | |
| 27 | + | stateRepo repository.SessionStateRepository, | |
| 28 | + | sessionRepo repository.SessionRepository, | |
| 29 | + | lockDir string, | |
| 30 | + | ) *DeleteSessionUseCase { | |
| 31 | + | return &DeleteSessionUseCase{ | |
| 32 | + | stateRepo: stateRepo, | |
| 33 | + | sessionRepo: sessionRepo, | |
| 34 | + | lockDir: lockDir, | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // DeleteSessionInput contains the parameters for session deletion. | |
| 39 | + | type DeleteSessionInput struct { | |
| 40 | + | SessionID entity.SessionID | |
| 41 | + | CurrentSessionID entity.SessionID | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Execute deletes a session and its state snapshot. | |
| 45 | + | // It validates that the session is not current or active before deletion. | |
| 46 | + | func (uc *DeleteSessionUseCase) Execute(ctx context.Context, input DeleteSessionInput) error { | |
| 47 | + | log := logging.FromContext(ctx) | |
| 48 | + | ||
| 49 | + | // Validate not deleting current session | |
| 50 | + | if input.SessionID == input.CurrentSessionID && input.CurrentSessionID != "" { | |
| 51 | + | return ErrCannotDeleteCurrentSession | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Check session exists | |
| 55 | + | session, err := uc.sessionRepo.FindByID(ctx, input.SessionID) | |
| 56 | + | if err != nil { | |
| 57 | + | return err | |
| 58 | + | } | |
| 59 | + | if session == nil { | |
| 60 | + | return ErrSessionNotFound | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Check not active (EndedAt is nil OR has lock file) | |
| 64 | + | if session.IsActive() || isSessionLocked(uc.lockDir, input.SessionID) { | |
| 65 | + | return ErrCannotDeleteActiveSession | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | log.Info().Str("session_id", string(input.SessionID)).Msg("deleting session") | |
| 69 | + | ||
| 70 | + | // Delete snapshot (ignore "not found" - session may not have a snapshot) | |
| 71 | + | if uc.stateRepo != nil { | |
| 72 | + | if err := uc.stateRepo.DeleteSnapshot(ctx, input.SessionID); err != nil { | |
| 73 | + | log.Debug().Err(err).Msg("failed to delete session snapshot (may not exist)") | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // Delete session record | |
| 78 | + | return uc.sessionRepo.Delete(ctx, input.SessionID) | |
| 79 | + | } | |
A internal/application/usecase/delete_session_test.go +255 -0
| @@ -0,0 +1,255 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestDeleteSessionUseCase_Execute_Success(t *testing.T) { | |
| 18 | + | ctx := testContext() | |
| 19 | + | ||
| 20 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 21 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 22 | + | ||
| 23 | + | sessionID := entity.SessionID("20251224_120000_delete") | |
| 24 | + | ||
| 25 | + | endedAt := time.Now().Add(-time.Hour) | |
| 26 | + | session := &entity.Session{ | |
| 27 | + | ID: sessionID, | |
| 28 | + | Type: entity.SessionTypeBrowser, | |
| 29 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 30 | + | EndedAt: &endedAt, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil) | |
| 34 | + | stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil) | |
| 35 | + | sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil) | |
| 36 | + | ||
| 37 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "") | |
| 38 | + | ||
| 39 | + | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 40 | + | SessionID: sessionID, | |
| 41 | + | CurrentSessionID: "other-session", | |
| 42 | + | }) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestDeleteSessionUseCase_Execute_CannotDeleteCurrentSession(t *testing.T) { | |
| 47 | + | ctx := testContext() | |
| 48 | + | ||
| 49 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 50 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 51 | + | ||
| 52 | + | sessionID := entity.SessionID("20251224_120000_current") | |
| 53 | + | ||
| 54 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "") | |
| 55 | + | ||
| 56 | + | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 57 | + | SessionID: sessionID, | |
| 58 | + | CurrentSessionID: sessionID, // Same as current | |
| 59 | + | }) | |
| 60 | + | require.Error(t, err) | |
| 61 | + | assert.ErrorIs(t, err, usecase.ErrCannotDeleteCurrentSession) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestDeleteSessionUseCase_Execute_SessionNotFound(t *testing.T) { | |
| 65 | + | ctx := testContext() | |
| 66 | + | ||
| 67 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 68 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 69 | + | ||
| 70 | + | sessionID := entity.SessionID("20251224_120000_notfound") | |
| 71 | + | ||
| 72 | + | sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, nil) | |
| 73 | + | ||
| 74 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "") | |
| 75 | + | ||
| 76 | + | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 77 | + | SessionID: sessionID, | |
| 78 | + | CurrentSessionID: "other-session", | |
| 79 | + | }) | |
| 80 | + | require.Error(t, err) | |
| 81 | + | assert.ErrorIs(t, err, usecase.ErrSessionNotFound) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestDeleteSessionUseCase_Execute_CannotDeleteActiveSession(t *testing.T) { | |
| 85 | + | ctx := testContext() | |
| 86 | + | ||
| 87 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 88 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 89 | + | ||
| 90 | + | sessionID := entity.SessionID("20251224_120000_active") | |
| 91 | + | ||
| 92 | + | // Session with nil EndedAt (still active) | |
| 93 | + | session := &entity.Session{ | |
| 94 | + | ID: sessionID, | |
| 95 | + | Type: entity.SessionTypeBrowser, | |
| 96 | + | StartedAt: time.Now().Add(-time.Hour), | |
| 97 | + | EndedAt: nil, // Active | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil) | |
| 101 | + | ||
| 102 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "") | |
| 103 | + | ||
| 104 | + | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 105 | + | SessionID: sessionID, | |
| 106 | + | CurrentSessionID: "other-session", | |
| 107 | + | }) | |
| 108 | + | require.Error(t, err) | |
| 109 | + | assert.ErrorIs(t, err, usecase.ErrCannotDeleteActiveSession) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestDeleteSessionUseCase_Execute_CannotDeleteLockedSession(t *testing.T) { | |
| 113 | + | ctx := testContext() | |
| 114 | + | ||
| 115 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 116 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 117 | + | ||
| 118 | + | // Create a temporary lock directory and lock file | |
| 119 | + | lockDir := t.TempDir() | |
| 120 | + | sessionID := entity.SessionID("20251224_120000_locked") | |
| 121 | + | lockPath := filepath.Join(lockDir, "session_"+string(sessionID)+".lock") | |
| 122 | + | f, err := os.Create(lockPath) | |
| 123 | + | require.NoError(t, err) | |
| 124 | + | f.Close() | |
| 125 | + | ||
| 126 | + | endedAt := time.Now().Add(-time.Hour) | |
| 127 | + | session := &entity.Session{ | |
| 128 | + | ID: sessionID, | |
| 129 | + | Type: entity.SessionTypeBrowser, | |
| 130 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 131 | + | EndedAt: &endedAt, // Ended, but has lock file | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil) | |
| 135 | + | ||
| 136 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, lockDir) | |
| 137 | + | ||
| 138 | + | err = uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 139 | + | SessionID: sessionID, | |
| 140 | + | CurrentSessionID: "other-session", | |
| 141 | + | }) | |
| 142 | + | require.Error(t, err) | |
| 143 | + | assert.ErrorIs(t, err, usecase.ErrCannotDeleteActiveSession) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func TestDeleteSessionUseCase_Execute_FindByIDError(t *testing.T) { | |
| 147 | + | ctx := testContext() | |
| 148 | + | ||
| 149 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 150 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 151 | + | ||
| 152 | + | sessionID := entity.SessionID("20251224_120000_dberror") | |
| 153 | + | ||
| 154 | + | sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, errors.New("db error")) | |
| 155 | + | ||
| 156 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "") | |
| 157 | + | ||
| 158 | + | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 159 | + | SessionID: sessionID, | |
| 160 | + | CurrentSessionID: "other-session", | |
| 161 | + | }) | |
| 162 | + | require.Error(t, err) | |
| 163 | + | assert.Contains(t, err.Error(), "db error") | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestDeleteSessionUseCase_Execute_DeleteSnapshotErrorIgnored(t *testing.T) { | |
| 167 | + | ctx := testContext() | |
| 168 | + | ||
| 169 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 170 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 171 | + | ||
| 172 | + | sessionID := entity.SessionID("20251224_120000_nosnapshot") | |
| 173 | + | ||
| 174 | + | endedAt := time.Now().Add(-time.Hour) | |
| 175 | + | session := &entity.Session{ | |
| 176 | + | ID: sessionID, | |
| 177 | + | Type: entity.SessionTypeBrowser, | |
| 178 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 179 | + | EndedAt: &endedAt, | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil) | |
| 183 | + | // Snapshot deletion fails (e.g., no snapshot exists) - should be ignored | |
| 184 | + | stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(errors.New("no rows")) | |
| 185 | + | sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil) | |
| 186 | + | ||
| 187 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "") | |
| 188 | + | ||
| 189 | + | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 190 | + | SessionID: sessionID, | |
| 191 | + | CurrentSessionID: "other-session", | |
| 192 | + | }) | |
| 193 | + | // Should succeed even though DeleteSnapshot failed | |
| 194 | + | require.NoError(t, err) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestDeleteSessionUseCase_Execute_DeleteSessionError(t *testing.T) { | |
| 198 | + | ctx := testContext() | |
| 199 | + | ||
| 200 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 201 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 202 | + | ||
| 203 | + | sessionID := entity.SessionID("20251224_120000_deleteerror") | |
| 204 | + | ||
| 205 | + | endedAt := time.Now().Add(-time.Hour) | |
| 206 | + | session := &entity.Session{ | |
| 207 | + | ID: sessionID, | |
| 208 | + | Type: entity.SessionTypeBrowser, | |
| 209 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 210 | + | EndedAt: &endedAt, | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil) | |
| 214 | + | stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil) | |
| 215 | + | sessionRepo.EXPECT().Delete(ctx, sessionID).Return(errors.New("delete failed")) | |
| 216 | + | ||
| 217 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "") | |
| 218 | + | ||
| 219 | + | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 220 | + | SessionID: sessionID, | |
| 221 | + | CurrentSessionID: "other-session", | |
| 222 | + | }) | |
| 223 | + | require.Error(t, err) | |
| 224 | + | assert.Contains(t, err.Error(), "delete failed") | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func TestDeleteSessionUseCase_Execute_EmptyCurrentSessionAllowed(t *testing.T) { | |
| 228 | + | ctx := testContext() | |
| 229 | + | ||
| 230 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 231 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 232 | + | ||
| 233 | + | sessionID := entity.SessionID("20251224_120000_nocurrent") | |
| 234 | + | ||
| 235 | + | endedAt := time.Now().Add(-time.Hour) | |
| 236 | + | session := &entity.Session{ | |
| 237 | + | ID: sessionID, | |
| 238 | + | Type: entity.SessionTypeBrowser, | |
| 239 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 240 | + | EndedAt: &endedAt, | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil) | |
| 244 | + | stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil) | |
| 245 | + | sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil) | |
| 246 | + | ||
| 247 | + | uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "") | |
| 248 | + | ||
| 249 | + | // CurrentSessionID is empty (e.g., CLI context) | |
| 250 | + | err := uc.Execute(ctx, usecase.DeleteSessionInput{ | |
| 251 | + | SessionID: sessionID, | |
| 252 | + | CurrentSessionID: "", | |
| 253 | + | }) | |
| 254 | + | require.NoError(t, err) | |
| 255 | + | } | |
A internal/application/usecase/get_last_restorable_session.go +124 -0
| @@ -0,0 +1,124 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 8 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | const ( | |
| 12 | + | // maxSessionsToCheck is the number of recent sessions to check for restoration. | |
| 13 | + | maxSessionsToCheck = 10 | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // GetLastRestorableSessionUseCase finds the most recent session that can be auto-restored. | |
| 17 | + | // A session is restorable if: | |
| 18 | + | // - It is not the current session | |
| 19 | + | // - It is not actively running (has ended or crashed without lock file) | |
| 20 | + | // - It has a valid snapshot with at least one tab | |
| 21 | + | type GetLastRestorableSessionUseCase struct { | |
| 22 | + | sessionRepo repository.SessionRepository | |
| 23 | + | stateRepo repository.SessionStateRepository | |
| 24 | + | lockDir string | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // NewGetLastRestorableSessionUseCase creates a new GetLastRestorableSessionUseCase. | |
| 28 | + | func NewGetLastRestorableSessionUseCase( | |
| 29 | + | sessionRepo repository.SessionRepository, | |
| 30 | + | stateRepo repository.SessionStateRepository, | |
| 31 | + | lockDir string, | |
| 32 | + | ) *GetLastRestorableSessionUseCase { | |
| 33 | + | return &GetLastRestorableSessionUseCase{ | |
| 34 | + | sessionRepo: sessionRepo, | |
| 35 | + | stateRepo: stateRepo, | |
| 36 | + | lockDir: lockDir, | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // GetLastRestorableSessionInput contains the parameters for finding a restorable session. | |
| 41 | + | type GetLastRestorableSessionInput struct { | |
| 42 | + | // ExcludeSessionID is the current session ID to exclude from results. | |
| 43 | + | ExcludeSessionID entity.SessionID | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // GetLastRestorableSessionOutput contains the found restorable session, if any. | |
| 47 | + | type GetLastRestorableSessionOutput struct { | |
| 48 | + | // SessionID is the ID of the restorable session, or empty if none found. | |
| 49 | + | SessionID entity.SessionID | |
| 50 | + | // State is the session state to restore, or nil if none found. | |
| 51 | + | State *entity.SessionState | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Execute finds the most recent restorable session. | |
| 55 | + | // Returns an empty output (not an error) if no restorable session is found. | |
| 56 | + | func (uc *GetLastRestorableSessionUseCase) Execute( | |
| 57 | + | ctx context.Context, | |
| 58 | + | input GetLastRestorableSessionInput, | |
| 59 | + | ) (*GetLastRestorableSessionOutput, error) { | |
| 60 | + | log := logging.FromContext(ctx) | |
| 61 | + | ||
| 62 | + | sessions, err := uc.sessionRepo.GetRecent(ctx, maxSessionsToCheck) | |
| 63 | + | if err != nil { | |
| 64 | + | return nil, err | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | for _, session := range sessions { | |
| 68 | + | // Only consider browser sessions | |
| 69 | + | if session.Type != entity.SessionTypeBrowser { | |
| 70 | + | continue | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Skip current session | |
| 74 | + | if session.ID == input.ExcludeSessionID { | |
| 75 | + | continue | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // Check if session is actively running | |
| 79 | + | // A session is "active" if ended_at IS NULL AND it has a lock file | |
| 80 | + | if session.IsActive() && isSessionLocked(uc.lockDir, session.ID) { | |
| 81 | + | log.Debug(). | |
| 82 | + | Str("session_id", string(session.ID)). | |
| 83 | + | Msg("auto-restore: skipping active session") | |
| 84 | + | continue | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Try to get snapshot | |
| 88 | + | state, err := uc.stateRepo.GetSnapshot(ctx, session.ID) | |
| 89 | + | if err != nil { | |
| 90 | + | log.Debug(). | |
| 91 | + | Err(err). | |
| 92 | + | Str("session_id", string(session.ID)). | |
| 93 | + | Msg("auto-restore: failed to get snapshot") | |
| 94 | + | continue | |
| 95 | + | } | |
| 96 | + | if state == nil { | |
| 97 | + | continue | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Skip sessions with no tabs | |
| 101 | + | if len(state.Tabs) == 0 { | |
| 102 | + | log.Debug(). | |
| 103 | + | Str("session_id", string(session.ID)). | |
| 104 | + | Msg("auto-restore: skipping session with no tabs") | |
| 105 | + | continue | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // Found a valid restorable session | |
| 109 | + | log.Info(). | |
| 110 | + | Str("session_id", string(session.ID)). | |
| 111 | + | Int("tabs", len(state.Tabs)). | |
| 112 | + | Int("panes", state.CountPanes()). | |
| 113 | + | Msg("auto-restore: found restorable session") | |
| 114 | + | ||
| 115 | + | return &GetLastRestorableSessionOutput{ | |
| 116 | + | SessionID: session.ID, | |
| 117 | + | State: state, | |
| 118 | + | }, nil | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // No restorable session found | |
| 122 | + | log.Debug().Msg("auto-restore: no restorable session found") | |
| 123 | + | return &GetLastRestorableSessionOutput{}, nil | |
| 124 | + | } | |
A internal/application/usecase/get_last_restorable_session_test.go +408 -0
| @@ -0,0 +1,408 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestGetLastRestorableSessionUseCase_NoSessions(t *testing.T) { | |
| 17 | + | ctx := testContext() | |
| 18 | + | ||
| 19 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 20 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 21 | + | ||
| 22 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return([]*entity.Session{}, nil) | |
| 23 | + | ||
| 24 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "") | |
| 25 | + | ||
| 26 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 27 | + | ExcludeSessionID: "current", | |
| 28 | + | }) | |
| 29 | + | ||
| 30 | + | require.NoError(t, err) | |
| 31 | + | require.NotNil(t, output) | |
| 32 | + | assert.Empty(t, output.SessionID) | |
| 33 | + | assert.Nil(t, output.State) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestGetLastRestorableSessionUseCase_OnlyCurrentSession(t *testing.T) { | |
| 37 | + | ctx := testContext() | |
| 38 | + | ||
| 39 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 40 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 41 | + | ||
| 42 | + | currentID := entity.SessionID("20251225_120000_curr") | |
| 43 | + | endedAt := time.Now() | |
| 44 | + | ||
| 45 | + | sessions := []*entity.Session{ | |
| 46 | + | { | |
| 47 | + | ID: currentID, | |
| 48 | + | Type: entity.SessionTypeBrowser, | |
| 49 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 50 | + | EndedAt: &endedAt, | |
| 51 | + | }, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 55 | + | ||
| 56 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "") | |
| 57 | + | ||
| 58 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 59 | + | ExcludeSessionID: currentID, | |
| 60 | + | }) | |
| 61 | + | ||
| 62 | + | require.NoError(t, err) | |
| 63 | + | require.NotNil(t, output) | |
| 64 | + | assert.Empty(t, output.SessionID) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestGetLastRestorableSessionUseCase_SkipsCLISessions(t *testing.T) { | |
| 68 | + | ctx := testContext() | |
| 69 | + | ||
| 70 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 71 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 72 | + | ||
| 73 | + | endedAt := time.Now() | |
| 74 | + | sessions := []*entity.Session{ | |
| 75 | + | { | |
| 76 | + | ID: "20251225_120000_cli1", | |
| 77 | + | Type: entity.SessionTypeCLI, // Not a browser session | |
| 78 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 79 | + | EndedAt: &endedAt, | |
| 80 | + | }, | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 84 | + | ||
| 85 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "") | |
| 86 | + | ||
| 87 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 88 | + | ExcludeSessionID: "current", | |
| 89 | + | }) | |
| 90 | + | ||
| 91 | + | require.NoError(t, err) | |
| 92 | + | assert.Empty(t, output.SessionID) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestGetLastRestorableSessionUseCase_SkipsActiveWithLock(t *testing.T) { | |
| 96 | + | ctx := testContext() | |
| 97 | + | ||
| 98 | + | // Create temp dir for lock files | |
| 99 | + | lockDir := t.TempDir() | |
| 100 | + | ||
| 101 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 102 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 103 | + | ||
| 104 | + | activeID := entity.SessionID("20251225_120000_actv") | |
| 105 | + | ||
| 106 | + | // Create lock file to simulate running session | |
| 107 | + | lockPath := filepath.Join(lockDir, "session_"+string(activeID)+".lock") | |
| 108 | + | f, err := os.Create(lockPath) | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | f.Close() | |
| 111 | + | ||
| 112 | + | sessions := []*entity.Session{ | |
| 113 | + | { | |
| 114 | + | ID: activeID, | |
| 115 | + | Type: entity.SessionTypeBrowser, | |
| 116 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 117 | + | EndedAt: nil, // Active (no ended_at) | |
| 118 | + | }, | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 122 | + | ||
| 123 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, lockDir) | |
| 124 | + | ||
| 125 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 126 | + | ExcludeSessionID: "current", | |
| 127 | + | }) | |
| 128 | + | ||
| 129 | + | require.NoError(t, err) | |
| 130 | + | assert.Empty(t, output.SessionID) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestGetLastRestorableSessionUseCase_SkipsNoSnapshot(t *testing.T) { | |
| 134 | + | ctx := testContext() | |
| 135 | + | ||
| 136 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 137 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 138 | + | ||
| 139 | + | endedAt := time.Now() | |
| 140 | + | sessionID := entity.SessionID("20251225_120000_nosn") | |
| 141 | + | ||
| 142 | + | sessions := []*entity.Session{ | |
| 143 | + | { | |
| 144 | + | ID: sessionID, | |
| 145 | + | Type: entity.SessionTypeBrowser, | |
| 146 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 147 | + | EndedAt: &endedAt, | |
| 148 | + | }, | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 152 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, nil) // No snapshot | |
| 153 | + | ||
| 154 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "") | |
| 155 | + | ||
| 156 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 157 | + | ExcludeSessionID: "current", | |
| 158 | + | }) | |
| 159 | + | ||
| 160 | + | require.NoError(t, err) | |
| 161 | + | assert.Empty(t, output.SessionID) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func TestGetLastRestorableSessionUseCase_SkipsEmptyTabs(t *testing.T) { | |
| 165 | + | ctx := testContext() | |
| 166 | + | ||
| 167 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 168 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 169 | + | ||
| 170 | + | endedAt := time.Now() | |
| 171 | + | sessionID := entity.SessionID("20251225_120000_emty") | |
| 172 | + | ||
| 173 | + | sessions := []*entity.Session{ | |
| 174 | + | { | |
| 175 | + | ID: sessionID, | |
| 176 | + | Type: entity.SessionTypeBrowser, | |
| 177 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 178 | + | EndedAt: &endedAt, | |
| 179 | + | }, | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | emptyState := &entity.SessionState{ | |
| 183 | + | Version: entity.SessionStateVersion, | |
| 184 | + | SessionID: sessionID, | |
| 185 | + | Tabs: []entity.TabSnapshot{}, // No tabs | |
| 186 | + | SavedAt: time.Now(), | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 190 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(emptyState, nil) | |
| 191 | + | ||
| 192 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "") | |
| 193 | + | ||
| 194 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 195 | + | ExcludeSessionID: "current", | |
| 196 | + | }) | |
| 197 | + | ||
| 198 | + | require.NoError(t, err) | |
| 199 | + | assert.Empty(t, output.SessionID) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestGetLastRestorableSessionUseCase_ReturnsEndedSession(t *testing.T) { | |
| 203 | + | ctx := testContext() | |
| 204 | + | ||
| 205 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 206 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 207 | + | ||
| 208 | + | endedAt := time.Now() | |
| 209 | + | sessionID := entity.SessionID("20251225_120000_good") | |
| 210 | + | ||
| 211 | + | sessions := []*entity.Session{ | |
| 212 | + | { | |
| 213 | + | ID: sessionID, | |
| 214 | + | Type: entity.SessionTypeBrowser, | |
| 215 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 216 | + | EndedAt: &endedAt, // Gracefully ended | |
| 217 | + | }, | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | validState := &entity.SessionState{ | |
| 221 | + | Version: entity.SessionStateVersion, | |
| 222 | + | SessionID: sessionID, | |
| 223 | + | Tabs: []entity.TabSnapshot{ | |
| 224 | + | { | |
| 225 | + | ID: "tab-1", | |
| 226 | + | Name: "Test Tab", | |
| 227 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 228 | + | Root: &entity.PaneNodeSnapshot{ | |
| 229 | + | Pane: &entity.PaneSnapshot{ | |
| 230 | + | ID: "pane-1", | |
| 231 | + | URI: "https://example.com", | |
| 232 | + | Title: "Example", | |
| 233 | + | }, | |
| 234 | + | }, | |
| 235 | + | }, | |
| 236 | + | }, | |
| 237 | + | }, | |
| 238 | + | SavedAt: time.Now(), | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 242 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil) | |
| 243 | + | ||
| 244 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "") | |
| 245 | + | ||
| 246 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 247 | + | ExcludeSessionID: "current", | |
| 248 | + | }) | |
| 249 | + | ||
| 250 | + | require.NoError(t, err) | |
| 251 | + | require.NotNil(t, output) | |
| 252 | + | assert.Equal(t, sessionID, output.SessionID) | |
| 253 | + | assert.Equal(t, validState, output.State) | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func TestGetLastRestorableSessionUseCase_ReturnsCrashedSession(t *testing.T) { | |
| 257 | + | ctx := testContext() | |
| 258 | + | ||
| 259 | + | // Empty lock dir (no lock files) | |
| 260 | + | lockDir := t.TempDir() | |
| 261 | + | ||
| 262 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 263 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 264 | + | ||
| 265 | + | sessionID := entity.SessionID("20251225_120000_crsh") | |
| 266 | + | ||
| 267 | + | // Session with no ended_at (crashed) but no lock file | |
| 268 | + | sessions := []*entity.Session{ | |
| 269 | + | { | |
| 270 | + | ID: sessionID, | |
| 271 | + | Type: entity.SessionTypeBrowser, | |
| 272 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 273 | + | EndedAt: nil, // Not ended - simulates crash | |
| 274 | + | }, | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | validState := &entity.SessionState{ | |
| 278 | + | Version: entity.SessionStateVersion, | |
| 279 | + | SessionID: sessionID, | |
| 280 | + | Tabs: []entity.TabSnapshot{ | |
| 281 | + | { | |
| 282 | + | ID: "tab-1", | |
| 283 | + | Name: "Crashed Tab", | |
| 284 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 285 | + | Root: &entity.PaneNodeSnapshot{ | |
| 286 | + | Pane: &entity.PaneSnapshot{ | |
| 287 | + | ID: "pane-1", | |
| 288 | + | URI: "https://example.com", | |
| 289 | + | Title: "Example", | |
| 290 | + | }, | |
| 291 | + | }, | |
| 292 | + | }, | |
| 293 | + | }, | |
| 294 | + | }, | |
| 295 | + | SavedAt: time.Now(), | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 299 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil) | |
| 300 | + | ||
| 301 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, lockDir) | |
| 302 | + | ||
| 303 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 304 | + | ExcludeSessionID: "current", | |
| 305 | + | }) | |
| 306 | + | ||
| 307 | + | require.NoError(t, err) | |
| 308 | + | require.NotNil(t, output) | |
| 309 | + | assert.Equal(t, sessionID, output.SessionID) | |
| 310 | + | assert.Equal(t, validState, output.State) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func TestGetLastRestorableSessionUseCase_ReturnsMostRecent(t *testing.T) { | |
| 314 | + | ctx := testContext() | |
| 315 | + | ||
| 316 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 317 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 318 | + | ||
| 319 | + | endedAt := time.Now() | |
| 320 | + | recentID := entity.SessionID("20251225_140000_new1") | |
| 321 | + | olderID := entity.SessionID("20251225_120000_old1") | |
| 322 | + | ||
| 323 | + | // Sessions ordered by started_at DESC (most recent first) | |
| 324 | + | sessions := []*entity.Session{ | |
| 325 | + | { | |
| 326 | + | ID: recentID, | |
| 327 | + | Type: entity.SessionTypeBrowser, | |
| 328 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 329 | + | EndedAt: &endedAt, | |
| 330 | + | }, | |
| 331 | + | { | |
| 332 | + | ID: olderID, | |
| 333 | + | Type: entity.SessionTypeBrowser, | |
| 334 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 335 | + | EndedAt: &endedAt, | |
| 336 | + | }, | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | recentState := &entity.SessionState{ | |
| 340 | + | Version: entity.SessionStateVersion, | |
| 341 | + | SessionID: recentID, | |
| 342 | + | Tabs: []entity.TabSnapshot{ | |
| 343 | + | {ID: "tab-1", Name: "Recent Tab"}, | |
| 344 | + | }, | |
| 345 | + | SavedAt: time.Now(), | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 349 | + | stateRepo.EXPECT().GetSnapshot(ctx, recentID).Return(recentState, nil) | |
| 350 | + | // Note: GetSnapshot for olderID should NOT be called since we return first match | |
| 351 | + | ||
| 352 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "") | |
| 353 | + | ||
| 354 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 355 | + | ExcludeSessionID: "current", | |
| 356 | + | }) | |
| 357 | + | ||
| 358 | + | require.NoError(t, err) | |
| 359 | + | assert.Equal(t, recentID, output.SessionID) | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | func TestGetLastRestorableSessionUseCase_SkipsFirstIfNoSnapshot(t *testing.T) { | |
| 363 | + | ctx := testContext() | |
| 364 | + | ||
| 365 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 366 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 367 | + | ||
| 368 | + | endedAt := time.Now() | |
| 369 | + | noSnapID := entity.SessionID("20251225_140000_nosn") | |
| 370 | + | goodID := entity.SessionID("20251225_120000_good") | |
| 371 | + | ||
| 372 | + | sessions := []*entity.Session{ | |
| 373 | + | { | |
| 374 | + | ID: noSnapID, | |
| 375 | + | Type: entity.SessionTypeBrowser, | |
| 376 | + | StartedAt: time.Now().Add(-1 * time.Hour), | |
| 377 | + | EndedAt: &endedAt, | |
| 378 | + | }, | |
| 379 | + | { | |
| 380 | + | ID: goodID, | |
| 381 | + | Type: entity.SessionTypeBrowser, | |
| 382 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 383 | + | EndedAt: &endedAt, | |
| 384 | + | }, | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | goodState := &entity.SessionState{ | |
| 388 | + | Version: entity.SessionStateVersion, | |
| 389 | + | SessionID: goodID, | |
| 390 | + | Tabs: []entity.TabSnapshot{ | |
| 391 | + | {ID: "tab-1", Name: "Good Tab"}, | |
| 392 | + | }, | |
| 393 | + | SavedAt: time.Now(), | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil) | |
| 397 | + | stateRepo.EXPECT().GetSnapshot(ctx, noSnapID).Return(nil, nil) // No snapshot | |
| 398 | + | stateRepo.EXPECT().GetSnapshot(ctx, goodID).Return(goodState, nil) | |
| 399 | + | ||
| 400 | + | uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "") | |
| 401 | + | ||
| 402 | + | output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{ | |
| 403 | + | ExcludeSessionID: "current", | |
| 404 | + | }) | |
| 405 | + | ||
| 406 | + | require.NoError(t, err) | |
| 407 | + | assert.Equal(t, goodID, output.SessionID) | |
| 408 | + | } | |
A internal/application/usecase/list_sessions.go +256 -0
| @@ -0,0 +1,256 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // ListSessionsUseCase handles listing sessions with their state information. | |
| 13 | + | type ListSessionsUseCase struct { | |
| 14 | + | sessionRepo repository.SessionRepository | |
| 15 | + | stateRepo repository.SessionStateRepository | |
| 16 | + | lockDir string // Directory where lock files are stored | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // NewListSessionsUseCase creates a new ListSessionsUseCase. | |
| 20 | + | func NewListSessionsUseCase( | |
| 21 | + | sessionRepo repository.SessionRepository, | |
| 22 | + | stateRepo repository.SessionStateRepository, | |
| 23 | + | lockDir string, | |
| 24 | + | ) *ListSessionsUseCase { | |
| 25 | + | return &ListSessionsUseCase{ | |
| 26 | + | sessionRepo: sessionRepo, | |
| 27 | + | stateRepo: stateRepo, | |
| 28 | + | lockDir: lockDir, | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // ListSessionsOutput contains the list of sessions with their info. | |
| 33 | + | type ListSessionsOutput struct { | |
| 34 | + | Sessions []entity.SessionInfo | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Execute returns a list of sessions with their state information. | |
| 38 | + | // Sessions are sorted with active first, then by date (most recent first). | |
| 39 | + | func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID entity.SessionID, limit int) (*ListSessionsOutput, error) { | |
| 40 | + | log := logging.FromContext(ctx) | |
| 41 | + | ||
| 42 | + | if limit <= 0 { | |
| 43 | + | limit = 50 | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | sessions, err := uc.sessionRepo.GetRecent(ctx, limit) | |
| 47 | + | if err != nil { | |
| 48 | + | return nil, err | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Get all snapshots | |
| 52 | + | snapshots, err := uc.stateRepo.GetAllSnapshots(ctx) | |
| 53 | + | if err != nil { | |
| 54 | + | log.Warn().Err(err).Msg("failed to get session snapshots") | |
| 55 | + | // Continue without snapshots | |
| 56 | + | snapshots = nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // Build snapshot lookup map | |
| 60 | + | snapshotMap := make(map[entity.SessionID]*entity.SessionState) | |
| 61 | + | for _, s := range snapshots { | |
| 62 | + | snapshotMap[s.SessionID] = s | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // Build session info list | |
| 66 | + | result := make([]entity.SessionInfo, 0, len(sessions)) | |
| 67 | + | for _, session := range sessions { | |
| 68 | + | // Only include browser sessions | |
| 69 | + | if session.Type != entity.SessionTypeBrowser { | |
| 70 | + | continue | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | info := entity.SessionInfo{ | |
| 74 | + | Session: session, | |
| 75 | + | IsCurrent: session.ID == currentSessionID, | |
| 76 | + | IsActive: session.IsActive() || uc.hasActiveLock(session.ID), | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | if state, ok := snapshotMap[session.ID]; ok { | |
| 80 | + | info.State = state | |
| 81 | + | info.TabCount = len(state.Tabs) | |
| 82 | + | info.PaneCount = state.CountPanes() | |
| 83 | + | info.UpdatedAt = state.SavedAt | |
| 84 | + | } else { | |
| 85 | + | info.UpdatedAt = session.StartedAt | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | result = append(result, info) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // Sort: current first, then active, then by date | |
| 92 | + | sortSessionInfos(result, currentSessionID) | |
| 93 | + | ||
| 94 | + | return &ListSessionsOutput{Sessions: result}, nil | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // hasActiveLock checks if a session has an active lock file. | |
| 98 | + | func (uc *ListSessionsUseCase) hasActiveLock(sessionID entity.SessionID) bool { | |
| 99 | + | return isSessionLocked(uc.lockDir, sessionID) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending. | |
| 103 | + | func sortSessionInfos(infos []entity.SessionInfo, currentID entity.SessionID) { | |
| 104 | + | // Simple bubble sort for small lists | |
| 105 | + | n := len(infos) | |
| 106 | + | for i := 0; i < n-1; i++ { | |
| 107 | + | for j := 0; j < n-i-1; j++ { | |
| 108 | + | if shouldSwap(infos[j], infos[j+1], currentID) { | |
| 109 | + | infos[j], infos[j+1] = infos[j+1], infos[j] | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func shouldSwap(a, b entity.SessionInfo, currentID entity.SessionID) bool { | |
| 116 | + | // Current session always first | |
| 117 | + | if a.Session.ID == currentID { | |
| 118 | + | return false | |
| 119 | + | } | |
| 120 | + | if b.Session.ID == currentID { | |
| 121 | + | return true | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Active sessions before inactive | |
| 125 | + | if a.IsActive && !b.IsActive { | |
| 126 | + | return false | |
| 127 | + | } | |
| 128 | + | if !a.IsActive && b.IsActive { | |
| 129 | + | return true | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // Most recent first | |
| 133 | + | return a.UpdatedAt.Before(b.UpdatedAt) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // GetSessionInfo returns info for a specific session. | |
| 137 | + | func (uc *ListSessionsUseCase) GetSessionInfo( | |
| 138 | + | ctx context.Context, | |
| 139 | + | sessionID, currentSessionID entity.SessionID, | |
| 140 | + | ) (*entity.SessionInfo, error) { | |
| 141 | + | session, err := uc.sessionRepo.FindByID(ctx, sessionID) | |
| 142 | + | if err != nil { | |
| 143 | + | return nil, err | |
| 144 | + | } | |
| 145 | + | if session == nil { | |
| 146 | + | return nil, nil | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | info := &entity.SessionInfo{ | |
| 150 | + | Session: session, | |
| 151 | + | IsCurrent: session.ID == currentSessionID, | |
| 152 | + | IsActive: session.IsActive() || uc.hasActiveLock(session.ID), | |
| 153 | + | UpdatedAt: session.StartedAt, | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | state, err := uc.stateRepo.GetSnapshot(ctx, sessionID) | |
| 157 | + | if err != nil { | |
| 158 | + | logging.FromContext(ctx).Warn().Err(err).Str("session_id", string(sessionID)).Msg("failed to get session state") | |
| 159 | + | } | |
| 160 | + | if state != nil { | |
| 161 | + | info.State = state | |
| 162 | + | info.TabCount = len(state.Tabs) | |
| 163 | + | info.PaneCount = state.CountPanes() | |
| 164 | + | info.UpdatedAt = state.SavedAt | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | return info, nil | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | const ( | |
| 171 | + | hoursPerDay = 24 | |
| 172 | + | daysPerWeek = 7 | |
| 173 | + | maxIDDisplay = 20 | |
| 174 | + | ) | |
| 175 | + | ||
| 176 | + | // GetRelativeTime returns a human-readable relative time string. | |
| 177 | + | func GetRelativeTime(t time.Time) string { | |
| 178 | + | now := time.Now() | |
| 179 | + | diff := now.Sub(t) | |
| 180 | + | ||
| 181 | + | switch { | |
| 182 | + | case diff < time.Minute: | |
| 183 | + | return "just now" | |
| 184 | + | case diff < time.Hour: | |
| 185 | + | mins := int(diff.Minutes()) | |
| 186 | + | if mins == 1 { | |
| 187 | + | return "1m ago" | |
| 188 | + | } | |
| 189 | + | return formatDuration(mins, "m") | |
| 190 | + | case diff < hoursPerDay*time.Hour: | |
| 191 | + | hours := int(diff.Hours()) | |
| 192 | + | if hours == 1 { | |
| 193 | + | return "1h ago" | |
| 194 | + | } | |
| 195 | + | return formatDuration(hours, "h") | |
| 196 | + | case diff < daysPerWeek*hoursPerDay*time.Hour: | |
| 197 | + | days := int(diff.Hours() / hoursPerDay) | |
| 198 | + | if days == 1 { | |
| 199 | + | return "1d ago" | |
| 200 | + | } | |
| 201 | + | return formatDuration(days, "d") | |
| 202 | + | default: | |
| 203 | + | weeks := int(diff.Hours() / hoursPerDay / daysPerWeek) | |
| 204 | + | if weeks == 1 { | |
| 205 | + | return "1w ago" | |
| 206 | + | } | |
| 207 | + | return formatDuration(weeks, "w") | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func formatDuration(n int, unit string) string { | |
| 212 | + | return string(rune('0'+n/10)) + string(rune('0'+n%10)) + unit + " ago" | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // PurgeableSessionsOutput contains inactive sessions that can be purged. | |
| 216 | + | type PurgeableSessionsOutput struct { | |
| 217 | + | Sessions []entity.SessionPurgeItem | |
| 218 | + | TotalSize int64 | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // GetPurgeableSessions returns all inactive sessions that can be safely deleted. | |
| 222 | + | // These are sessions that have ended and have no active lock file. | |
| 223 | + | func (uc *ListSessionsUseCase) GetPurgeableSessions(ctx context.Context) (*PurgeableSessionsOutput, error) { | |
| 224 | + | log := logging.FromContext(ctx) | |
| 225 | + | ||
| 226 | + | // Get all sessions (no current session in CLI context) | |
| 227 | + | output, err := uc.Execute(ctx, "", maxPurgeableSessions) | |
| 228 | + | if err != nil { | |
| 229 | + | return nil, err | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // Filter to only inactive sessions | |
| 233 | + | var purgeable []entity.SessionPurgeItem | |
| 234 | + | for _, info := range output.Sessions { | |
| 235 | + | if !info.IsActive { | |
| 236 | + | purgeable = append(purgeable, entity.SessionPurgeItem{ | |
| 237 | + | Info: info, | |
| 238 | + | Selected: true, // Default to selected | |
| 239 | + | }) | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // Get total size of all snapshots | |
| 244 | + | totalSize, err := uc.stateRepo.GetTotalSnapshotsSize(ctx) | |
| 245 | + | if err != nil { | |
| 246 | + | log.Warn().Err(err).Msg("failed to get total snapshots size") | |
| 247 | + | totalSize = 0 | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | return &PurgeableSessionsOutput{ | |
| 251 | + | Sessions: purgeable, | |
| 252 | + | TotalSize: totalSize, | |
| 253 | + | }, nil | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | const maxPurgeableSessions = 1000 | |
A internal/application/usecase/list_sessions_test.go +178 -0
| @@ -0,0 +1,178 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestListSessionsUseCase_Execute_ReturnsSessionsWithState(t *testing.T) { | |
| 15 | + | ctx := testContext() | |
| 16 | + | ||
| 17 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 18 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 19 | + | ||
| 20 | + | endedAt := time.Now().Add(-time.Hour) | |
| 21 | + | sessions := []*entity.Session{ | |
| 22 | + | { | |
| 23 | + | ID: "20251224_120000_abc1", | |
| 24 | + | Type: entity.SessionTypeBrowser, | |
| 25 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 26 | + | EndedAt: &endedAt, | |
| 27 | + | }, | |
| 28 | + | { | |
| 29 | + | ID: "20251224_130000_def2", | |
| 30 | + | Type: entity.SessionTypeBrowser, | |
| 31 | + | StartedAt: time.Now().Add(-time.Hour), | |
| 32 | + | EndedAt: nil, // Active | |
| 33 | + | }, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | states := []*entity.SessionState{ | |
| 37 | + | { | |
| 38 | + | SessionID: "20251224_120000_abc1", | |
| 39 | + | SavedAt: time.Now().Add(-time.Hour), | |
| 40 | + | Tabs: []entity.TabSnapshot{ | |
| 41 | + | {ID: "tab1"}, | |
| 42 | + | {ID: "tab2"}, | |
| 43 | + | }, | |
| 44 | + | }, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | sessionRepo.EXPECT().GetRecent(ctx, 50).Return(sessions, nil) | |
| 48 | + | stateRepo.EXPECT().GetAllSnapshots(ctx).Return(states, nil) | |
| 49 | + | ||
| 50 | + | uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "") | |
| 51 | + | ||
| 52 | + | output, err := uc.Execute(ctx, "", 50) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | require.NotNil(t, output) | |
| 55 | + | require.Len(t, output.Sessions, 2) | |
| 56 | + | ||
| 57 | + | // Active session should be first (or current, but we have no current) | |
| 58 | + | // The sorting puts active sessions before inactive | |
| 59 | + | activeSession := output.Sessions[0] | |
| 60 | + | assert.True(t, activeSession.IsActive) | |
| 61 | + | ||
| 62 | + | // Check session with state has tab count | |
| 63 | + | var sessionWithState *entity.SessionInfo | |
| 64 | + | for i := range output.Sessions { | |
| 65 | + | if output.Sessions[i].Session.ID == "20251224_120000_abc1" { | |
| 66 | + | sessionWithState = &output.Sessions[i] | |
| 67 | + | break | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | require.NotNil(t, sessionWithState) | |
| 71 | + | assert.Equal(t, 2, sessionWithState.TabCount) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestListSessionsUseCase_GetPurgeableSessions_ReturnsOnlyInactive(t *testing.T) { | |
| 75 | + | ctx := testContext() | |
| 76 | + | ||
| 77 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 78 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 79 | + | ||
| 80 | + | endedAt := time.Now().Add(-time.Hour) | |
| 81 | + | sessions := []*entity.Session{ | |
| 82 | + | { | |
| 83 | + | ID: "20251224_120000_inactive1", | |
| 84 | + | Type: entity.SessionTypeBrowser, | |
| 85 | + | StartedAt: time.Now().Add(-3 * time.Hour), | |
| 86 | + | EndedAt: &endedAt, // Ended - purgeable | |
| 87 | + | }, | |
| 88 | + | { | |
| 89 | + | ID: "20251224_130000_active1", | |
| 90 | + | Type: entity.SessionTypeBrowser, | |
| 91 | + | StartedAt: time.Now().Add(-time.Hour), | |
| 92 | + | EndedAt: nil, // Active - NOT purgeable | |
| 93 | + | }, | |
| 94 | + | { | |
| 95 | + | ID: "20251224_140000_inactive2", | |
| 96 | + | Type: entity.SessionTypeBrowser, | |
| 97 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 98 | + | EndedAt: &endedAt, // Ended - purgeable | |
| 99 | + | }, | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil) | |
| 103 | + | stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil) | |
| 104 | + | stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(12345), nil) | |
| 105 | + | ||
| 106 | + | uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "") | |
| 107 | + | ||
| 108 | + | output, err := uc.GetPurgeableSessions(ctx) | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | require.NotNil(t, output) | |
| 111 | + | ||
| 112 | + | // Should only have 2 inactive sessions, not the active one | |
| 113 | + | require.Len(t, output.Sessions, 2) | |
| 114 | + | assert.Equal(t, int64(12345), output.TotalSize) | |
| 115 | + | ||
| 116 | + | // Verify all returned sessions are inactive | |
| 117 | + | for _, s := range output.Sessions { | |
| 118 | + | assert.False(t, s.Info.IsActive, "session %s should not be active", s.Info.Session.ID) | |
| 119 | + | assert.True(t, s.Selected, "sessions should be selected by default") | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestListSessionsUseCase_GetPurgeableSessions_EmptyWhenAllActive(t *testing.T) { | |
| 124 | + | ctx := testContext() | |
| 125 | + | ||
| 126 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 127 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 128 | + | ||
| 129 | + | sessions := []*entity.Session{ | |
| 130 | + | { | |
| 131 | + | ID: "20251224_130000_active1", | |
| 132 | + | Type: entity.SessionTypeBrowser, | |
| 133 | + | StartedAt: time.Now().Add(-time.Hour), | |
| 134 | + | EndedAt: nil, // Active | |
| 135 | + | }, | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil) | |
| 139 | + | stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil) | |
| 140 | + | stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), nil) | |
| 141 | + | ||
| 142 | + | uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "") | |
| 143 | + | ||
| 144 | + | output, err := uc.GetPurgeableSessions(ctx) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | require.NotNil(t, output) | |
| 147 | + | require.Empty(t, output.Sessions) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestListSessionsUseCase_GetPurgeableSessions_HandlesGetSizeError(t *testing.T) { | |
| 151 | + | ctx := testContext() | |
| 152 | + | ||
| 153 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 154 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 155 | + | ||
| 156 | + | endedAt := time.Now().Add(-time.Hour) | |
| 157 | + | sessions := []*entity.Session{ | |
| 158 | + | { | |
| 159 | + | ID: "20251224_120000_inactive1", | |
| 160 | + | Type: entity.SessionTypeBrowser, | |
| 161 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 162 | + | EndedAt: &endedAt, | |
| 163 | + | }, | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil) | |
| 167 | + | stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil) | |
| 168 | + | stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), assert.AnError) | |
| 169 | + | ||
| 170 | + | uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "") | |
| 171 | + | ||
| 172 | + | output, err := uc.GetPurgeableSessions(ctx) | |
| 173 | + | // Should succeed even if size query fails | |
| 174 | + | require.NoError(t, err) | |
| 175 | + | require.NotNil(t, output) | |
| 176 | + | require.Len(t, output.Sessions, 1) | |
| 177 | + | assert.Equal(t, int64(0), output.TotalSize) // Size defaults to 0 on error | |
| 178 | + | } | |
A internal/application/usecase/restore_session.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // ErrSessionNotFound is returned when a session state cannot be found. | |
| 14 | + | var ErrSessionNotFound = errors.New("session not found") | |
| 15 | + | ||
| 16 | + | // ErrVersionMismatch is returned when the session state version is incompatible. | |
| 17 | + | var ErrVersionMismatch = errors.New("session state version mismatch") | |
| 18 | + | ||
| 19 | + | // RestoreSessionUseCase handles restoring session state from a snapshot. | |
| 20 | + | type RestoreSessionUseCase struct { | |
| 21 | + | stateRepo repository.SessionStateRepository | |
| 22 | + | sessionRepo repository.SessionRepository | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // NewRestoreSessionUseCase creates a new RestoreSessionUseCase. | |
| 26 | + | func NewRestoreSessionUseCase( | |
| 27 | + | stateRepo repository.SessionStateRepository, | |
| 28 | + | sessionRepo repository.SessionRepository, | |
| 29 | + | ) *RestoreSessionUseCase { | |
| 30 | + | return &RestoreSessionUseCase{ | |
| 31 | + | stateRepo: stateRepo, | |
| 32 | + | sessionRepo: sessionRepo, | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // RestoreInput contains the parameters for restoring a session. | |
| 37 | + | type RestoreInput struct { | |
| 38 | + | SessionID entity.SessionID | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // RestoreOutput contains the restored session state. | |
| 42 | + | type RestoreOutput struct { | |
| 43 | + | State *entity.SessionState | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Execute loads and validates a session state for restoration. | |
| 47 | + | func (uc *RestoreSessionUseCase) Execute(ctx context.Context, input RestoreInput) (*RestoreOutput, error) { | |
| 48 | + | log := logging.FromContext(ctx) | |
| 49 | + | ||
| 50 | + | if input.SessionID == "" { | |
| 51 | + | return nil, fmt.Errorf("session id required") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | log.Info(). | |
| 55 | + | Str("session_id", string(input.SessionID)). | |
| 56 | + | Msg("restoring session state") | |
| 57 | + | ||
| 58 | + | state, err := uc.stateRepo.GetSnapshot(ctx, input.SessionID) | |
| 59 | + | if err != nil { | |
| 60 | + | return nil, fmt.Errorf("get session snapshot: %w", err) | |
| 61 | + | } | |
| 62 | + | if state == nil { | |
| 63 | + | return nil, ErrSessionNotFound | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Validate version compatibility | |
| 67 | + | if state.Version > entity.SessionStateVersion { | |
| 68 | + | log.Warn(). | |
| 69 | + | Int("state_version", state.Version). | |
| 70 | + | Int("current_version", entity.SessionStateVersion). | |
| 71 | + | Msg("session state version is newer than current version") | |
| 72 | + | return nil, ErrVersionMismatch | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | log.Info(). | |
| 76 | + | Str("session_id", string(input.SessionID)). | |
| 77 | + | Int("tab_count", len(state.Tabs)). | |
| 78 | + | Int("pane_count", state.CountPanes()). | |
| 79 | + | Msg("session state loaded for restoration") | |
| 80 | + | ||
| 81 | + | return &RestoreOutput{State: state}, nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // DeleteSnapshot removes a session's snapshot (for cleanup after failed restore or user deletion). | |
| 85 | + | func (uc *RestoreSessionUseCase) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error { | |
| 86 | + | return uc.stateRepo.DeleteSnapshot(ctx, sessionID) | |
| 87 | + | } | |
A internal/application/usecase/restore_session_test.go +140 -0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestRestoreSessionUseCase_Execute_ReturnsState(t *testing.T) { | |
| 16 | + | ctx := testContext() | |
| 17 | + | ||
| 18 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 19 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 20 | + | ||
| 21 | + | sessionID := entity.SessionID("20251224_120000_restore") | |
| 22 | + | ||
| 23 | + | expectedState := &entity.SessionState{ | |
| 24 | + | Version: entity.SessionStateVersion, | |
| 25 | + | SessionID: sessionID, | |
| 26 | + | Tabs: []entity.TabSnapshot{ | |
| 27 | + | { | |
| 28 | + | ID: "tab-1", | |
| 29 | + | Name: "Test Tab", | |
| 30 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 31 | + | Root: &entity.PaneNodeSnapshot{ | |
| 32 | + | Pane: &entity.PaneSnapshot{ | |
| 33 | + | ID: "pane-1", | |
| 34 | + | URI: "https://example.com", | |
| 35 | + | Title: "Example", | |
| 36 | + | }, | |
| 37 | + | }, | |
| 38 | + | }, | |
| 39 | + | }, | |
| 40 | + | }, | |
| 41 | + | SavedAt: time.Now(), | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(expectedState, nil) | |
| 45 | + | ||
| 46 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 47 | + | ||
| 48 | + | output, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID}) | |
| 49 | + | require.NoError(t, err) | |
| 50 | + | require.NotNil(t, output) | |
| 51 | + | assert.Equal(t, expectedState, output.State) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestRestoreSessionUseCase_Execute_RequiresSessionID(t *testing.T) { | |
| 55 | + | ctx := testContext() | |
| 56 | + | ||
| 57 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 58 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 59 | + | ||
| 60 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 61 | + | ||
| 62 | + | _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: ""}) | |
| 63 | + | require.Error(t, err) | |
| 64 | + | assert.Contains(t, err.Error(), "session id required") | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestRestoreSessionUseCase_Execute_SessionNotFound(t *testing.T) { | |
| 68 | + | ctx := testContext() | |
| 69 | + | ||
| 70 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 71 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 72 | + | ||
| 73 | + | sessionID := entity.SessionID("20251224_120000_notfound") | |
| 74 | + | ||
| 75 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, nil) | |
| 76 | + | ||
| 77 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 78 | + | ||
| 79 | + | _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID}) | |
| 80 | + | require.Error(t, err) | |
| 81 | + | assert.ErrorIs(t, err, usecase.ErrSessionNotFound) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestRestoreSessionUseCase_Execute_VersionMismatch(t *testing.T) { | |
| 85 | + | ctx := testContext() | |
| 86 | + | ||
| 87 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 88 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 89 | + | ||
| 90 | + | sessionID := entity.SessionID("20251224_120000_version") | |
| 91 | + | ||
| 92 | + | // Return a state with a future version | |
| 93 | + | futureState := &entity.SessionState{ | |
| 94 | + | Version: entity.SessionStateVersion + 1, | |
| 95 | + | SessionID: sessionID, | |
| 96 | + | Tabs: []entity.TabSnapshot{}, | |
| 97 | + | SavedAt: time.Now(), | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(futureState, nil) | |
| 101 | + | ||
| 102 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 103 | + | ||
| 104 | + | _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID}) | |
| 105 | + | require.Error(t, err) | |
| 106 | + | assert.ErrorIs(t, err, usecase.ErrVersionMismatch) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestRestoreSessionUseCase_Execute_GetSnapshotError(t *testing.T) { | |
| 110 | + | ctx := testContext() | |
| 111 | + | ||
| 112 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 113 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 114 | + | ||
| 115 | + | sessionID := entity.SessionID("20251224_120000_error") | |
| 116 | + | ||
| 117 | + | stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, errors.New("db error")) | |
| 118 | + | ||
| 119 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 120 | + | ||
| 121 | + | _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID}) | |
| 122 | + | require.Error(t, err) | |
| 123 | + | assert.Contains(t, err.Error(), "get session snapshot") | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func TestRestoreSessionUseCase_DeleteSnapshot(t *testing.T) { | |
| 127 | + | ctx := testContext() | |
| 128 | + | ||
| 129 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 130 | + | sessionRepo := repomocks.NewMockSessionRepository(t) | |
| 131 | + | ||
| 132 | + | sessionID := entity.SessionID("20251224_120000_delete") | |
| 133 | + | ||
| 134 | + | stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil) | |
| 135 | + | ||
| 136 | + | uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo) | |
| 137 | + | ||
| 138 | + | err := uc.DeleteSnapshot(ctx, sessionID) | |
| 139 | + | require.NoError(t, err) | |
| 140 | + | } | |
A internal/application/usecase/session_lock.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // isSessionLocked checks if a session has an active lock file. | |
| 12 | + | // Lock files are created in lockDir with format: session_<sessionID>.lock | |
| 13 | + | // This matches the format used by bootstrap.sessionLockPath(). | |
| 14 | + | func isSessionLocked(lockDir string, sessionID entity.SessionID) bool { | |
| 15 | + | if lockDir == "" { | |
| 16 | + | return false | |
| 17 | + | } | |
| 18 | + | lockPath := filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID)) | |
| 19 | + | _, err := os.Stat(lockPath) | |
| 20 | + | return err == nil | |
| 21 | + | } | |
A internal/application/usecase/snapshot_session.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // SnapshotSessionUseCase handles saving session state snapshots. | |
| 13 | + | type SnapshotSessionUseCase struct { | |
| 14 | + | stateRepo repository.SessionStateRepository | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // NewSnapshotSessionUseCase creates a new SnapshotSessionUseCase. | |
| 18 | + | func NewSnapshotSessionUseCase(stateRepo repository.SessionStateRepository) *SnapshotSessionUseCase { | |
| 19 | + | return &SnapshotSessionUseCase{stateRepo: stateRepo} | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // SnapshotInput contains the parameters for creating a session snapshot. | |
| 23 | + | type SnapshotInput struct { | |
| 24 | + | SessionID entity.SessionID | |
| 25 | + | TabList *entity.TabList | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Execute creates a snapshot of the current session state and saves it. | |
| 29 | + | func (uc *SnapshotSessionUseCase) Execute(ctx context.Context, input SnapshotInput) error { | |
| 30 | + | log := logging.FromContext(ctx) | |
| 31 | + | ||
| 32 | + | if input.SessionID == "" { | |
| 33 | + | return fmt.Errorf("session id required") | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | state := entity.SnapshotFromTabList(input.SessionID, input.TabList) | |
| 37 | + | ||
| 38 | + | log.Debug(). | |
| 39 | + | Str("session_id", string(input.SessionID)). | |
| 40 | + | Int("tab_count", len(state.Tabs)). | |
| 41 | + | Int("pane_count", state.CountPanes()). | |
| 42 | + | Msg("creating session snapshot") | |
| 43 | + | ||
| 44 | + | if err := uc.stateRepo.SaveSnapshot(ctx, state); err != nil { | |
| 45 | + | return fmt.Errorf("save session snapshot: %w", err) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | return nil | |
| 49 | + | } | |
A internal/application/usecase/snapshot_session_test.go +173 -0
| @@ -0,0 +1,173 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestSnapshotSessionUseCase_Execute_SavesSnapshot(t *testing.T) { | |
| 17 | + | ctx := testContext() | |
| 18 | + | ||
| 19 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 20 | + | ||
| 21 | + | // Create a tab list with some tabs | |
| 22 | + | tabList := entity.NewTabList() | |
| 23 | + | pane := entity.NewPane("pane-1") | |
| 24 | + | pane.URI = "https://example.com" | |
| 25 | + | pane.Title = "Example" | |
| 26 | + | tab := entity.NewTab("tab-1", "ws-1", pane) | |
| 27 | + | tab.Name = "Test Tab" | |
| 28 | + | tabList.Add(tab) | |
| 29 | + | ||
| 30 | + | sessionID := entity.SessionID("20251224_120000_test") | |
| 31 | + | ||
| 32 | + | stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |
| 33 | + | Run(func(_ context.Context, state *entity.SessionState) { | |
| 34 | + | require.Equal(t, sessionID, state.SessionID) | |
| 35 | + | require.Len(t, state.Tabs, 1) | |
| 36 | + | require.Equal(t, "Test Tab", state.Tabs[0].Name) | |
| 37 | + | require.Equal(t, entity.SessionStateVersion, state.Version) | |
| 38 | + | }). | |
| 39 | + | Return(nil) | |
| 40 | + | ||
| 41 | + | uc := usecase.NewSnapshotSessionUseCase(stateRepo) | |
| 42 | + | ||
| 43 | + | err := uc.Execute(ctx, usecase.SnapshotInput{ | |
| 44 | + | SessionID: sessionID, | |
| 45 | + | TabList: tabList, | |
| 46 | + | }) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestSnapshotSessionUseCase_Execute_EmptyTabList(t *testing.T) { | |
| 51 | + | ctx := testContext() | |
| 52 | + | ||
| 53 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 54 | + | ||
| 55 | + | sessionID := entity.SessionID("20251224_120000_empty") | |
| 56 | + | ||
| 57 | + | stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")). | |
| 58 | + | Run(func(_ context.Context, state *entity.SessionState) { | |
| 59 | + | require.Equal(t, sessionID, state.SessionID) | |
| 60 | + | require.Empty(t, state.Tabs) | |
| 61 | + | }). | |
| 62 | + | Return(nil) | |
| 63 | + | ||
| 64 | + | uc := usecase.NewSnapshotSessionUseCase(stateRepo) | |
| 65 | + | ||
| 66 | + | err := uc.Execute(ctx, usecase.SnapshotInput{ | |
| 67 | + | SessionID: sessionID, | |
| 68 | + | TabList: nil, | |
| 69 | + | }) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestSnapshotSessionUseCase_Execute_RequiresSessionID(t *testing.T) { | |
| 74 | + | ctx := testContext() | |
| 75 | + | ||
| 76 | + | stateRepo := repomocks.NewMockSessionStateRepository(t) | |
| 77 | + | ||
| 78 | + | uc := usecase.NewSnapshotSessionUseCase(stateRepo) | |
| 79 | + | ||
| 80 | + | err := uc.Execute(ctx, usecase.SnapshotInput{ | |
| 81 | + | SessionID: "", | |
| 82 | + | TabList: entity.NewTabList(), | |
| 83 | + | }) | |
| 84 | + | require.Error(t, err) | |
| 85 | + | assert.Contains(t, err.Error(), "session id required") | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) { | |
| 89 | + | sessionID := entity.SessionID("20251224_120000_snap") | |
| 90 | + | ||
| 91 | + | // Create a workspace with multiple panes | |
| 92 | + | pane1 := entity.NewPane("pane-1") | |
| 93 | + | pane1.URI = "https://github.com" | |
| 94 | + | pane1.Title = "GitHub" | |
| 95 | + | pane1.ZoomFactor = 1.2 | |
| 96 | + | ||
| 97 | + | pane2 := entity.NewPane("pane-2") | |
| 98 | + | pane2.URI = "https://google.com" | |
| 99 | + | pane2.Title = "Google" | |
| 100 | + | ||
| 101 | + | tab := entity.NewTab("tab-1", "ws-1", pane1) | |
| 102 | + | tab.Name = "Dev Tab" | |
| 103 | + | tab.IsPinned = true | |
| 104 | + | ||
| 105 | + | tabList := entity.NewTabList() | |
| 106 | + | tabList.Add(tab) | |
| 107 | + | ||
| 108 | + | state := entity.SnapshotFromTabList(sessionID, tabList) | |
| 109 | + | ||
| 110 | + | require.NotNil(t, state) | |
| 111 | + | assert.Equal(t, entity.SessionStateVersion, state.Version) | |
| 112 | + | assert.Equal(t, sessionID, state.SessionID) | |
| 113 | + | assert.Len(t, state.Tabs, 1) | |
| 114 | + | assert.Equal(t, "Dev Tab", state.Tabs[0].Name) | |
| 115 | + | assert.True(t, state.Tabs[0].IsPinned) | |
| 116 | + | assert.NotNil(t, state.Tabs[0].Workspace.Root) | |
| 117 | + | assert.Equal(t, "https://github.com", state.Tabs[0].Workspace.Root.Pane.URI) | |
| 118 | + | assert.InDelta(t, 1.2, state.Tabs[0].Workspace.Root.Pane.ZoomFactor, 0.001) | |
| 119 | + | assert.False(t, state.SavedAt.IsZero()) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestSessionState_CountPanes(t *testing.T) { | |
| 123 | + | state := &entity.SessionState{ | |
| 124 | + | Tabs: []entity.TabSnapshot{ | |
| 125 | + | { | |
| 126 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 127 | + | Root: &entity.PaneNodeSnapshot{ | |
| 128 | + | Pane: &entity.PaneSnapshot{ID: "pane-1"}, | |
| 129 | + | }, | |
| 130 | + | }, | |
| 131 | + | }, | |
| 132 | + | { | |
| 133 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 134 | + | Root: &entity.PaneNodeSnapshot{ | |
| 135 | + | Children: []*entity.PaneNodeSnapshot{ | |
| 136 | + | {Pane: &entity.PaneSnapshot{ID: "pane-2"}}, | |
| 137 | + | {Pane: &entity.PaneSnapshot{ID: "pane-3"}}, | |
| 138 | + | }, | |
| 139 | + | }, | |
| 140 | + | }, | |
| 141 | + | }, | |
| 142 | + | }, | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | assert.Equal(t, 3, state.CountPanes()) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestGetRelativeTime(t *testing.T) { | |
| 149 | + | now := time.Now() | |
| 150 | + | ||
| 151 | + | tests := []struct { | |
| 152 | + | name string | |
| 153 | + | t time.Time | |
| 154 | + | expected string | |
| 155 | + | }{ | |
| 156 | + | {"just now", now.Add(-30 * time.Second), "just now"}, | |
| 157 | + | {"1 minute", now.Add(-1 * time.Minute), "1m ago"}, | |
| 158 | + | {"5 minutes", now.Add(-5 * time.Minute), "05m ago"}, | |
| 159 | + | {"1 hour", now.Add(-1 * time.Hour), "1h ago"}, | |
| 160 | + | {"3 hours", now.Add(-3 * time.Hour), "03h ago"}, | |
| 161 | + | {"1 day", now.Add(-24 * time.Hour), "1d ago"}, | |
| 162 | + | {"2 days", now.Add(-48 * time.Hour), "02d ago"}, | |
| 163 | + | {"1 week", now.Add(-7 * 24 * time.Hour), "1w ago"}, | |
| 164 | + | {"2 weeks", now.Add(-14 * 24 * time.Hour), "02w ago"}, | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | for _, tt := range tests { | |
| 168 | + | t.Run(tt.name, func(t *testing.T) { | |
| 169 | + | result := usecase.GetRelativeTime(tt.t) | |
| 170 | + | assert.Equal(t, tt.expected, result) | |
| 171 | + | }) | |
| 172 | + | } | |
| 173 | + | } | |
M internal/cli/app.go +10 -0
| @@ -31,6 +31,9 @@ type App struct { | |||
| 31 | 31 | // Use cases | |
| 32 | 32 | SearchHistoryUC *usecase.SearchHistoryUseCase | |
| 33 | 33 | SessionUC *usecase.ManageSessionUseCase | |
| 34 | + | ListSessionsUC *usecase.ListSessionsUseCase | |
| 35 | + | RestoreUC *usecase.RestoreSessionUseCase | |
| 36 | + | DeleteSessionUC *usecase.DeleteSessionUseCase | |
| 34 | 37 | ||
| 35 | 38 | // Services | |
| 36 | 39 | FaviconService *favicon.Service | |
| @@ -105,9 +108,13 @@ func NewApp() (*App, error) { | |||
| 105 | 108 | ||
| 106 | 109 | // Create repositories | |
| 107 | 110 | historyRepo := sqlite.NewHistoryRepository(db) | |
| 111 | + | sessionStateRepo := sqlite.NewSessionStateRepository(db) | |
| 108 | 112 | ||
| 109 | 113 | // Create use cases | |
| 110 | 114 | searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 115 | + | listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo, cfg.Logging.LogDir) | |
| 116 | + | restoreUC := usecase.NewRestoreSessionUseCase(sessionStateRepo, sessionRepo) | |
| 117 | + | deleteSessionUC := usecase.NewDeleteSessionUseCase(sessionStateRepo, sessionRepo, cfg.Logging.LogDir) | |
| 111 | 118 | ||
| 112 | 119 | // Create favicon service for CLI (path resolution for dmenu/fuzzel) | |
| 113 | 120 | faviconCacheDir, _ := config.GetFaviconCacheDir() | |
| @@ -120,6 +127,9 @@ func NewApp() (*App, error) { | |||
| 120 | 127 | History: historyRepo, | |
| 121 | 128 | SearchHistoryUC: searchHistoryUC, | |
| 122 | 129 | SessionUC: sessionUC, | |
| 130 | + | ListSessionsUC: listSessionsUC, | |
| 131 | + | RestoreUC: restoreUC, | |
| 132 | + | DeleteSessionUC: deleteSessionUC, | |
| 123 | 133 | FaviconService: faviconService, | |
| 124 | 134 | ctx: ctx, | |
| 125 | 135 | logCleanup: logCleanup, | |
M internal/cli/cmd/purge.go +34 -5
| @@ -24,11 +24,12 @@ var purgeCmd = &cobra.Command{ | |||
| 24 | 24 | ||
| 25 | 25 | This can remove: | |
| 26 | 26 | - Config directory | |
| 27 | - | - Data directory | |
| 27 | + | - Data directory (includes database) | |
| 28 | 28 | - State directory | |
| 29 | 29 | - Cache directory | |
| 30 | 30 | - Content filter caches | |
| 31 | 31 | - Desktop integration files | |
| 32 | + | - Inactive browser sessions | |
| 32 | 33 | ||
| 33 | 34 | Use --force to remove everything without prompting.`, | |
| 34 | 35 | RunE: runPurge, | |
| @@ -54,20 +55,48 @@ func runPurge(_ *cobra.Command, _ []string) error { | |||
| 54 | 55 | return runPurgeForce(app, purgeUC) | |
| 55 | 56 | } | |
| 56 | 57 | ||
| 57 | - | m := model.NewPurgeModel(app.Theme, purgeUC) | |
| 58 | + | // Build session config from app dependencies | |
| 59 | + | cfg := model.PurgeModelConfig{ | |
| 60 | + | ListSessionsUC: app.ListSessionsUC, | |
| 61 | + | DeleteSessionUC: app.DeleteSessionUC, | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | m := model.NewPurgeModel(app.Ctx(), app.Theme, purgeUC, cfg) | |
| 58 | 65 | p := tea.NewProgram(m, tea.WithAltScreen()) | |
| 59 | 66 | _, err := p.Run() | |
| 60 | 67 | return err | |
| 61 | 68 | } | |
| 62 | 69 | ||
| 63 | 70 | func runPurgeForce(app *cli.App, purgeUC *usecase.PurgeDataUseCase) error { | |
| 64 | - | out, err := purgeUC.PurgeAll(app.Ctx()) | |
| 71 | + | ctx := app.Ctx() | |
| 72 | + | theme := app.Theme | |
| 73 | + | ||
| 74 | + | // First, delete all inactive sessions (before we delete the database) | |
| 75 | + | if app.ListSessionsUC != nil && app.DeleteSessionUC != nil { | |
| 76 | + | output, err := app.ListSessionsUC.GetPurgeableSessions(ctx) | |
| 77 | + | if err == nil && output != nil { | |
| 78 | + | for _, s := range output.Sessions { | |
| 79 | + | sessionID := s.Info.Session.ID | |
| 80 | + | if delErr := app.DeleteSessionUC.Execute(ctx, usecase.DeleteSessionInput{ | |
| 81 | + | SessionID: sessionID, | |
| 82 | + | CurrentSessionID: "", // CLI has no current session | |
| 83 | + | }); delErr != nil { | |
| 84 | + | fmt.Printf("%s Session %s: %v\n", theme.ErrorStyle.Render(styles.IconX), sessionID, delErr) | |
| 85 | + | } else { | |
| 86 | + | fmt.Printf("%s Session %s\n", theme.SuccessStyle.Render(styles.IconCheck), sessionID) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Then purge all directories | |
| 93 | + | out, err := purgeUC.PurgeAll(ctx) | |
| 65 | 94 | if out != nil { | |
| 66 | 95 | for _, r := range out.Results { | |
| 67 | 96 | if r.Success { | |
| 68 | - | fmt.Printf("%s %s\n", app.Theme.SuccessStyle.Render(styles.IconCheck), r.Target.Path) | |
| 97 | + | fmt.Printf("%s %s\n", theme.SuccessStyle.Render(styles.IconCheck), r.Target.Path) | |
| 69 | 98 | } else { | |
| 70 | - | fmt.Printf("%s %s: %v\n", app.Theme.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error) | |
| 99 | + | fmt.Printf("%s %s: %v\n", theme.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error) | |
| 71 | 100 | } | |
| 72 | 101 | } | |
| 73 | 102 | } | |
A internal/cli/cmd/sessions.go +308 -0
| @@ -0,0 +1,308 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "strings" | |
| 8 | + | "text/tabwriter" | |
| 9 | + | ||
| 10 | + | tea "github.com/charmbracelet/bubbletea" | |
| 11 | + | "github.com/spf13/cobra" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 14 | + | "github.com/bnema/dumber/internal/cli/model" | |
| 15 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | const defaultSessionsLimit = 20 | |
| 20 | + | ||
| 21 | + | var ( | |
| 22 | + | sessionsJSON bool | |
| 23 | + | sessionsLimit int | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | var sessionsCmd = &cobra.Command{ | |
| 27 | + | Use: "sessions", | |
| 28 | + | Short: "Manage browser sessions", | |
| 29 | + | Long: `View, restore, and manage browser sessions. | |
| 30 | + | ||
| 31 | + | Sessions are automatically saved when the browser runs. You can list | |
| 32 | + | past sessions and restore them to continue where you left off. | |
| 33 | + | ||
| 34 | + | Run without arguments to open the interactive session browser.`, | |
| 35 | + | RunE: runSessions, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func init() { | |
| 39 | + | rootCmd.AddCommand(sessionsCmd) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func runSessions(_ *cobra.Command, _ []string) error { | |
| 43 | + | app := GetApp() | |
| 44 | + | if app == nil { | |
| 45 | + | return fmt.Errorf("app not initialized") | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | if app.ListSessionsUC == nil { | |
| 49 | + | return fmt.Errorf("session management not available") | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // Get current session ID (empty if not running as browser) | |
| 53 | + | var currentSessionID entity.SessionID | |
| 54 | + | if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil { | |
| 55 | + | currentSessionID = active.ID | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Run interactive TUI | |
| 59 | + | m := model.NewSessionsModel(app.Ctx(), app.Theme, model.SessionsModelConfig{ | |
| 60 | + | ListSessionsUC: app.ListSessionsUC, | |
| 61 | + | RestoreUC: app.RestoreUC, | |
| 62 | + | DeleteSessionUC: app.DeleteSessionUC, | |
| 63 | + | CurrentSession: currentSessionID, | |
| 64 | + | MaxListedSessions: app.Config.Session.MaxListedSessions, | |
| 65 | + | }) | |
| 66 | + | ||
| 67 | + | p := tea.NewProgram(m, tea.WithAltScreen()) | |
| 68 | + | _, err := p.Run() | |
| 69 | + | return err | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // sessions list | |
| 73 | + | var sessionsListCmd = &cobra.Command{ | |
| 74 | + | Use: "list", | |
| 75 | + | Short: "List saved sessions", | |
| 76 | + | Long: `List all saved browser sessions with their tab/pane counts. | |
| 77 | + | ||
| 78 | + | Sessions are marked as: | |
| 79 | + | ● current - the currently running session | |
| 80 | + | ○ active - another running dumber instance | |
| 81 | + | (blank) - exited session available for restoration`, | |
| 82 | + | RunE: runSessionsList, | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func init() { | |
| 86 | + | sessionsCmd.AddCommand(sessionsListCmd) | |
| 87 | + | sessionsListCmd.Flags().BoolVar(&sessionsJSON, "json", false, "output as JSON") | |
| 88 | + | sessionsListCmd.Flags().IntVar(&sessionsLimit, "limit", defaultSessionsLimit, "maximum sessions to show") | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func runSessionsList(_ *cobra.Command, _ []string) error { | |
| 92 | + | app := GetApp() | |
| 93 | + | if app == nil { | |
| 94 | + | return fmt.Errorf("app not initialized") | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | if app.ListSessionsUC == nil { | |
| 98 | + | return fmt.Errorf("session management not available") | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // Get current session ID (empty if not running as browser) | |
| 102 | + | var currentSessionID entity.SessionID | |
| 103 | + | if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil { | |
| 104 | + | currentSessionID = active.ID | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, sessionsLimit) | |
| 108 | + | if err != nil { | |
| 109 | + | return fmt.Errorf("list sessions: %w", err) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | if sessionsJSON { | |
| 113 | + | return outputSessionsJSON(output.Sessions) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | return outputSessionsTable(output.Sessions) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func outputSessionsJSON(sessions []entity.SessionInfo) error { | |
| 120 | + | enc := json.NewEncoder(os.Stdout) | |
| 121 | + | enc.SetIndent("", " ") | |
| 122 | + | return enc.Encode(sessions) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func outputSessionsTable(sessions []entity.SessionInfo) error { | |
| 126 | + | if len(sessions) == 0 { | |
| 127 | + | fmt.Println("No saved sessions found.") | |
| 128 | + | return nil | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 132 | + | _, _ = fmt.Fprintln(w, "STATUS\tSESSION ID\tTABS\tPANES\tLAST UPDATED") | |
| 133 | + | ||
| 134 | + | for _, info := range sessions { | |
| 135 | + | status := " " | |
| 136 | + | switch { | |
| 137 | + | case info.IsCurrent: | |
| 138 | + | status = "●" | |
| 139 | + | case info.IsActive: | |
| 140 | + | status = "○" | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | relTime := usecase.GetRelativeTime(info.UpdatedAt) | |
| 144 | + | _, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n", | |
| 145 | + | status, | |
| 146 | + | info.Session.ID, | |
| 147 | + | info.TabCount, | |
| 148 | + | info.PaneCount, | |
| 149 | + | relTime, | |
| 150 | + | ) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | return w.Flush() | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // sessions restore <id> | |
| 157 | + | var sessionsRestoreCmd = &cobra.Command{ | |
| 158 | + | Use: "restore <session-id>", | |
| 159 | + | Short: "Restore a saved session", | |
| 160 | + | Long: `Restore a previously saved browser session. | |
| 161 | + | ||
| 162 | + | This launches a new browser window with all tabs and panes from the | |
| 163 | + | saved session. The session ID can be found using 'dumber sessions list'. | |
| 164 | + | ||
| 165 | + | You can use a short suffix of the session ID as long as it's unique. | |
| 166 | + | ||
| 167 | + | Example: | |
| 168 | + | dumber sessions restore 20251224_143022_abc1 | |
| 169 | + | dumber sessions restore abc1`, | |
| 170 | + | Args: cobra.ExactArgs(1), | |
| 171 | + | RunE: runSessionsRestore, | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func init() { | |
| 175 | + | sessionsCmd.AddCommand(sessionsRestoreCmd) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func runSessionsRestore(_ *cobra.Command, args []string) error { | |
| 179 | + | app := GetApp() | |
| 180 | + | if app == nil { | |
| 181 | + | return fmt.Errorf("app not initialized") | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | if app.RestoreUC == nil || app.ListSessionsUC == nil { | |
| 185 | + | return fmt.Errorf("session restoration not available") | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // Find session by ID or suffix | |
| 189 | + | sessionInfo, err := findSessionByIDOrSuffix(args[0]) | |
| 190 | + | if err != nil { | |
| 191 | + | return err | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | sessionID := sessionInfo.Session.ID | |
| 195 | + | ||
| 196 | + | // Validate the session has restorable state | |
| 197 | + | _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID}) | |
| 198 | + | if err != nil { | |
| 199 | + | return fmt.Errorf("restore session: %w", err) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // Spawn a new dumber instance with the session | |
| 203 | + | spawner := desktop.NewSessionSpawner(app.Ctx()) | |
| 204 | + | if err := spawner.SpawnWithSession(sessionID); err != nil { | |
| 205 | + | return fmt.Errorf("spawn browser: %w", err) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | fmt.Printf("Restoring session %s...\n", sessionID) | |
| 209 | + | return nil | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // sessions delete <id> | |
| 213 | + | var sessionsDeleteCmd = &cobra.Command{ | |
| 214 | + | Use: "delete <session-id>", | |
| 215 | + | Short: "Delete a saved session", | |
| 216 | + | Long: `Delete a saved browser session and its state. | |
| 217 | + | ||
| 218 | + | This permanently removes the session snapshot. Active sessions cannot | |
| 219 | + | be deleted - close the browser first. | |
| 220 | + | ||
| 221 | + | You can use a short suffix of the session ID as long as it's unique. | |
| 222 | + | ||
| 223 | + | Example: | |
| 224 | + | dumber sessions delete 20251224_143022_abc1 | |
| 225 | + | dumber sessions delete abc1`, | |
| 226 | + | Args: cobra.ExactArgs(1), | |
| 227 | + | RunE: runSessionsDelete, | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func init() { | |
| 231 | + | sessionsCmd.AddCommand(sessionsDeleteCmd) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func runSessionsDelete(_ *cobra.Command, args []string) error { | |
| 235 | + | app := GetApp() | |
| 236 | + | if app == nil { | |
| 237 | + | return fmt.Errorf("app not initialized") | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | if app.DeleteSessionUC == nil || app.ListSessionsUC == nil { | |
| 241 | + | return fmt.Errorf("session management not available") | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // Find session by ID or suffix | |
| 245 | + | sessionInfo, err := findSessionByIDOrSuffix(args[0]) | |
| 246 | + | if err != nil { | |
| 247 | + | return err | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // Get current session ID | |
| 251 | + | var currentSessionID entity.SessionID | |
| 252 | + | if active, activeErr := app.SessionUC.GetActiveSession(app.Ctx()); activeErr == nil && active != nil { | |
| 253 | + | currentSessionID = active.ID | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // Delete using use case (handles validation internally) | |
| 257 | + | if err := app.DeleteSessionUC.Execute(app.Ctx(), usecase.DeleteSessionInput{ | |
| 258 | + | SessionID: sessionInfo.Session.ID, | |
| 259 | + | CurrentSessionID: currentSessionID, | |
| 260 | + | }); err != nil { | |
| 261 | + | return fmt.Errorf("delete session: %w", err) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | fmt.Printf("Session %s deleted.\n", sessionInfo.Session.ID) | |
| 265 | + | return nil | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // findSessionByIDOrSuffix finds a session by exact ID or unique suffix. | |
| 269 | + | // Users typically identify sessions by the last few characters (e.g., "dee5"). | |
| 270 | + | func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) { | |
| 271 | + | app := GetApp() | |
| 272 | + | if app == nil || app.ListSessionsUC == nil { | |
| 273 | + | return nil, fmt.Errorf("app not initialized") | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // Get current session ID (empty if not running as browser) | |
| 277 | + | var currentSessionID entity.SessionID | |
| 278 | + | if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil { | |
| 279 | + | currentSessionID = active.ID | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | // List all sessions | |
| 283 | + | output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, defaultSessionsLimit*5) | |
| 284 | + | if err != nil { | |
| 285 | + | return nil, fmt.Errorf("list sessions: %w", err) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // Find matching sessions | |
| 289 | + | var matches []entity.SessionInfo | |
| 290 | + | for _, info := range output.Sessions { | |
| 291 | + | if string(info.Session.ID) == idOrSuffix { | |
| 292 | + | // Exact match - return immediately | |
| 293 | + | return &info, nil | |
| 294 | + | } | |
| 295 | + | if strings.HasSuffix(string(info.Session.ID), idOrSuffix) { | |
| 296 | + | matches = append(matches, info) | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | switch len(matches) { | |
| 301 | + | case 0: | |
| 302 | + | return nil, fmt.Errorf("no session found matching '%s'", idOrSuffix) | |
| 303 | + | case 1: | |
| 304 | + | return &matches[0], nil | |
| 305 | + | default: | |
| 306 | + | return nil, fmt.Errorf("ambiguous session ID '%s' matches %d sessions - be more specific", idOrSuffix, len(matches)) | |
| 307 | + | } | |
| 308 | + | } | |
M internal/cli/model/purge.go +242 -69
| @@ -14,23 +14,57 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | // PurgeModel wraps styles.PurgeModel for standalone CLI use. | |
| 16 | 16 | type PurgeModel struct { | |
| 17 | - | selector styles.PurgeModel | |
| 18 | - | purgeUC *usecase.PurgeDataUseCase | |
| 17 | + | selector styles.PurgeModel | |
| 18 | + | purgeUC *usecase.PurgeDataUseCase | |
| 19 | + | listSessionsUC *usecase.ListSessionsUseCase | |
| 20 | + | deleteSessionUC *usecase.DeleteSessionUseCase | |
| 19 | 21 | ||
| 20 | - | loading bool | |
| 21 | - | purging bool | |
| 22 | - | done bool | |
| 22 | + | loading bool | |
| 23 | + | loadingSessions bool | |
| 24 | + | purging bool | |
| 25 | + | done bool | |
| 23 | 26 | ||
| 24 | - | results *usecase.PurgeOutput | |
| 25 | - | info string | |
| 26 | - | err error | |
| 27 | + | // Track loading state for parallel loading | |
| 28 | + | targetsLoaded bool | |
| 29 | + | sessionsLoaded bool | |
| 30 | + | targets []entity.PurgeTarget | |
| 31 | + | sessions []entity.SessionPurgeItem | |
| 32 | + | sessionsSize int64 | |
| 33 | + | ||
| 34 | + | results *usecase.PurgeOutput | |
| 35 | + | sessionResults []sessionPurgeResult | |
| 36 | + | info string | |
| 37 | + | err error | |
| 27 | 38 | ||
| 28 | 39 | theme *styles.Theme | |
| 40 | + | ctx context.Context | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // sessionPurgeResult tracks the result of deleting a session. | |
| 44 | + | type sessionPurgeResult struct { | |
| 45 | + | SessionID entity.SessionID | |
| 46 | + | Success bool | |
| 47 | + | Error error | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // PurgeModelConfig contains optional dependencies for session purging. | |
| 51 | + | type PurgeModelConfig struct { | |
| 52 | + | ListSessionsUC *usecase.ListSessionsUseCase | |
| 53 | + | DeleteSessionUC *usecase.DeleteSessionUseCase | |
| 29 | 54 | } | |
| 30 | 55 | ||
| 31 | 56 | // NewPurgeModel creates a new purge command model. | |
| 32 | - | func NewPurgeModel(theme *styles.Theme, purgeUC *usecase.PurgeDataUseCase) PurgeModel { | |
| 33 | - | return PurgeModel{theme: theme, purgeUC: purgeUC, loading: true} | |
| 57 | + | func NewPurgeModel(ctx context.Context, theme *styles.Theme, purgeUC *usecase.PurgeDataUseCase, cfg PurgeModelConfig) PurgeModel { | |
| 58 | + | m := PurgeModel{ | |
| 59 | + | theme: theme, | |
| 60 | + | purgeUC: purgeUC, | |
| 61 | + | listSessionsUC: cfg.ListSessionsUC, | |
| 62 | + | deleteSessionUC: cfg.DeleteSessionUC, | |
| 63 | + | loading: true, | |
| 64 | + | loadingSessions: cfg.ListSessionsUC != nil, | |
| 65 | + | ctx: ctx, | |
| 66 | + | } | |
| 67 | + | return m | |
| 34 | 68 | } | |
| 35 | 69 | ||
| 36 | 70 | type purgeTargetsLoadedMsg struct { | |
| @@ -38,14 +72,25 @@ type purgeTargetsLoadedMsg struct { | |||
| 38 | 72 | err error | |
| 39 | 73 | } | |
| 40 | 74 | ||
| 75 | + | type purgeSessionsLoadedMsg struct { | |
| 76 | + | sessions []entity.SessionPurgeItem | |
| 77 | + | size int64 | |
| 78 | + | err error | |
| 79 | + | } | |
| 80 | + | ||
| 41 | 81 | type purgeCompleteMsg struct { | |
| 42 | - | out *usecase.PurgeOutput | |
| 43 | - | err error | |
| 82 | + | targetResults []entity.PurgeResult | |
| 83 | + | sessionResults []sessionPurgeResult | |
| 84 | + | err error | |
| 44 | 85 | } | |
| 45 | 86 | ||
| 46 | 87 | // Init implements tea.Model. | |
| 47 | 88 | func (m PurgeModel) Init() tea.Cmd { | |
| 48 | - | return m.loadTargets() | |
| 89 | + | cmds := []tea.Cmd{m.loadTargets()} | |
| 90 | + | if m.listSessionsUC != nil { | |
| 91 | + | cmds = append(cmds, m.loadSessions()) | |
| 92 | + | } | |
| 93 | + | return tea.Batch(cmds...) | |
| 49 | 94 | } | |
| 50 | 95 | ||
| 51 | 96 | func (m PurgeModel) loadTargets() tea.Cmd { | |
| @@ -55,68 +100,172 @@ func (m PurgeModel) loadTargets() tea.Cmd { | |||
| 55 | 100 | } | |
| 56 | 101 | } | |
| 57 | 102 | ||
| 103 | + | func (m PurgeModel) loadSessions() tea.Cmd { | |
| 104 | + | return func() tea.Msg { | |
| 105 | + | output, err := m.listSessionsUC.GetPurgeableSessions(context.Background()) | |
| 106 | + | if err != nil { | |
| 107 | + | return purgeSessionsLoadedMsg{err: err} | |
| 108 | + | } | |
| 109 | + | return purgeSessionsLoadedMsg{ | |
| 110 | + | sessions: output.Sessions, | |
| 111 | + | size: output.TotalSize, | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 58 | 116 | // Update implements tea.Model. | |
| 59 | 117 | func (m PurgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 60 | 118 | switch msg := msg.(type) { | |
| 61 | 119 | case purgeTargetsLoadedMsg: | |
| 62 | - | m.loading = false | |
| 63 | - | if msg.err != nil { | |
| 64 | - | m.err = msg.err | |
| 65 | - | m.done = true | |
| 66 | - | return m, nil | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | m.selector = styles.NewPurge(m.theme, msg.targets) | |
| 70 | - | return m, nil | |
| 71 | - | ||
| 120 | + | return m.handleTargetsLoaded(msg), nil | |
| 121 | + | case purgeSessionsLoadedMsg: | |
| 122 | + | return m.handleSessionsLoaded(msg), nil | |
| 72 | 123 | case purgeCompleteMsg: | |
| 73 | - | m.purging = false | |
| 74 | - | m.done = true | |
| 75 | - | m.results = msg.out | |
| 76 | - | if msg.err != nil { | |
| 77 | - | m.err = msg.err | |
| 78 | - | } | |
| 79 | - | return m, nil | |
| 80 | - | ||
| 124 | + | return m.handlePurgeComplete(msg), nil | |
| 81 | 125 | case tea.KeyMsg: | |
| 82 | 126 | if m.done { | |
| 83 | 127 | return m, tea.Quit | |
| 84 | 128 | } | |
| 85 | - | if m.loading || m.purging { | |
| 129 | + | if m.isBlocked() { | |
| 86 | 130 | return m, nil | |
| 87 | 131 | } | |
| 132 | + | // Fall through to updateSelector | |
| 88 | 133 | } | |
| 89 | 134 | ||
| 90 | - | if m.loading || m.purging { | |
| 135 | + | if m.isBlocked() { | |
| 91 | 136 | return m, nil | |
| 92 | 137 | } | |
| 93 | 138 | ||
| 139 | + | return m.updateSelector(msg) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (m PurgeModel) handleTargetsLoaded(msg purgeTargetsLoadedMsg) PurgeModel { | |
| 143 | + | if msg.err != nil { | |
| 144 | + | m.err = msg.err | |
| 145 | + | m.done = true | |
| 146 | + | m.loading = false | |
| 147 | + | return m | |
| 148 | + | } | |
| 149 | + | m.targetsLoaded = true | |
| 150 | + | m.targets = msg.targets | |
| 151 | + | return m.checkLoadingComplete() | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func (m PurgeModel) handleSessionsLoaded(msg purgeSessionsLoadedMsg) PurgeModel { | |
| 155 | + | if msg.err != nil { | |
| 156 | + | // Session loading failure is non-fatal, continue without sessions | |
| 157 | + | m.sessionsLoaded = true | |
| 158 | + | m.loadingSessions = false | |
| 159 | + | return m.checkLoadingComplete() | |
| 160 | + | } | |
| 161 | + | m.sessionsLoaded = true | |
| 162 | + | m.sessions = msg.sessions | |
| 163 | + | m.sessionsSize = msg.size | |
| 164 | + | return m.checkLoadingComplete() | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func (m PurgeModel) handlePurgeComplete(msg purgeCompleteMsg) PurgeModel { | |
| 168 | + | m.purging = false | |
| 169 | + | m.done = true | |
| 170 | + | if msg.targetResults != nil { | |
| 171 | + | m.results = &usecase.PurgeOutput{Results: msg.targetResults} | |
| 172 | + | for _, r := range msg.targetResults { | |
| 173 | + | if r.Success { | |
| 174 | + | m.results.SuccessCount++ | |
| 175 | + | } else { | |
| 176 | + | m.results.FailureCount++ | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | m.sessionResults = msg.sessionResults | |
| 181 | + | if msg.err != nil { | |
| 182 | + | m.err = msg.err | |
| 183 | + | } | |
| 184 | + | return m | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func (m PurgeModel) isBlocked() bool { | |
| 188 | + | return m.loading || m.loadingSessions || m.purging | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func (m PurgeModel) updateSelector(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 94 | 192 | selector, cmd := m.selector.Update(msg) | |
| 95 | 193 | m.selector = selector | |
| 96 | 194 | ||
| 97 | - | if m.selector.Done() { | |
| 98 | - | if m.selector.Canceled { | |
| 99 | - | return m, tea.Quit | |
| 100 | - | } | |
| 101 | - | if m.selector.Confirmed { | |
| 102 | - | targetTypes := m.selector.SelectedTypes() | |
| 103 | - | if len(targetTypes) == 0 { | |
| 104 | - | m.done = true | |
| 105 | - | m.info = "Nothing selected" | |
| 106 | - | return m, nil | |
| 107 | - | } | |
| 108 | - | m.purging = true | |
| 109 | - | return m, m.performPurge(targetTypes) | |
| 195 | + | if !m.selector.Done() { | |
| 196 | + | return m, cmd | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | if m.selector.Canceled { | |
| 200 | + | return m, tea.Quit | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | if m.selector.Confirmed { | |
| 204 | + | targetTypes := m.selector.SelectedTypes() | |
| 205 | + | sessionIDs := m.selector.SelectedSessionIDs() | |
| 206 | + | if len(targetTypes) == 0 && len(sessionIDs) == 0 { | |
| 207 | + | m.done = true | |
| 208 | + | m.info = "Nothing selected" | |
| 209 | + | return m, nil | |
| 110 | 210 | } | |
| 211 | + | m.purging = true | |
| 212 | + | return m, m.performPurge(targetTypes, sessionIDs) | |
| 111 | 213 | } | |
| 112 | 214 | ||
| 113 | 215 | return m, cmd | |
| 114 | 216 | } | |
| 115 | 217 | ||
| 116 | - | func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType) tea.Cmd { | |
| 218 | + | func (m PurgeModel) checkLoadingComplete() PurgeModel { | |
| 219 | + | // If sessions aren't being loaded, only wait for targets | |
| 220 | + | needSessions := m.listSessionsUC != nil | |
| 221 | + | if m.targetsLoaded && (!needSessions || m.sessionsLoaded) { | |
| 222 | + | m.loading = false | |
| 223 | + | m.loadingSessions = false | |
| 224 | + | m.selector = styles.NewPurgeWithSessions(m.theme, m.targets, m.sessions, m.sessionsSize) | |
| 225 | + | } | |
| 226 | + | return m | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType, sessionIDs []entity.SessionID) tea.Cmd { | |
| 117 | 230 | return func() tea.Msg { | |
| 118 | - | out, err := m.purgeUC.Execute(context.Background(), usecase.PurgeInput{TargetTypes: targetTypes}) | |
| 119 | - | return purgeCompleteMsg{out: out, err: err} | |
| 231 | + | var targetResults []entity.PurgeResult | |
| 232 | + | var sessionResults []sessionPurgeResult | |
| 233 | + | var lastErr error | |
| 234 | + | ||
| 235 | + | // Delete sessions first (if any selected and Data not selected) | |
| 236 | + | if m.deleteSessionUC != nil { | |
| 237 | + | for _, sid := range sessionIDs { | |
| 238 | + | err := m.deleteSessionUC.Execute(m.ctx, usecase.DeleteSessionInput{ | |
| 239 | + | SessionID: sid, | |
| 240 | + | CurrentSessionID: "", // CLI has no current session | |
| 241 | + | }) | |
| 242 | + | sessionResults = append(sessionResults, sessionPurgeResult{ | |
| 243 | + | SessionID: sid, | |
| 244 | + | Success: err == nil, | |
| 245 | + | Error: err, | |
| 246 | + | }) | |
| 247 | + | if err != nil { | |
| 248 | + | lastErr = err | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // Then delete target directories | |
| 254 | + | if len(targetTypes) > 0 { | |
| 255 | + | out, err := m.purgeUC.Execute(m.ctx, usecase.PurgeInput{TargetTypes: targetTypes}) | |
| 256 | + | if out != nil { | |
| 257 | + | targetResults = out.Results | |
| 258 | + | } | |
| 259 | + | if err != nil { | |
| 260 | + | lastErr = err | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | return purgeCompleteMsg{ | |
| 265 | + | targetResults: targetResults, | |
| 266 | + | sessionResults: sessionResults, | |
| 267 | + | err: lastErr, | |
| 268 | + | } | |
| 120 | 269 | } | |
| 121 | 270 | } | |
| 122 | 271 | ||
| @@ -124,7 +273,7 @@ func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType) tea.Cmd { | |||
| 124 | 273 | func (m PurgeModel) View() string { | |
| 125 | 274 | t := m.theme | |
| 126 | 275 | ||
| 127 | - | if m.loading { | |
| 276 | + | if m.loading || m.loadingSessions { | |
| 128 | 277 | spinner := styles.NewLoading(t, "Scanning purge targets...") | |
| 129 | 278 | return t.Box.Render(spinner.View()) | |
| 130 | 279 | } | |
| @@ -145,7 +294,7 @@ func (m PurgeModel) View() string { | |||
| 145 | 294 | return t.Box.Render(content) | |
| 146 | 295 | } | |
| 147 | 296 | ||
| 148 | - | if m.err != nil && m.results == nil { | |
| 297 | + | if m.err != nil && m.results == nil && len(m.sessionResults) == 0 { | |
| 149 | 298 | content := lipgloss.JoinVertical( | |
| 150 | 299 | lipgloss.Left, | |
| 151 | 300 | t.ErrorStyle.Render("Error: "+m.err.Error()), | |
| @@ -155,28 +304,52 @@ func (m PurgeModel) View() string { | |||
| 155 | 304 | return t.Box.Render(content) | |
| 156 | 305 | } | |
| 157 | 306 | ||
| 158 | - | contentLines := []string{t.Title.Render("Purge complete")} | |
| 159 | - | if m.results != nil { | |
| 160 | - | for _, r := range m.results.Results { | |
| 161 | - | if r.Success { | |
| 162 | - | contentLines = append(contentLines, fmt.Sprintf("%s %s", t.SuccessStyle.Render(styles.IconCheck), r.Target.Path)) | |
| 163 | - | } else { | |
| 164 | - | contentLines = append(contentLines, fmt.Sprintf("%s %s: %v", t.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error)) | |
| 165 | - | } | |
| 307 | + | return m.renderResults() | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | return m.selector.View() | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func (m PurgeModel) renderResults() string { | |
| 314 | + | t := m.theme | |
| 315 | + | contentLines := []string{t.Title.Render("Purge complete")} | |
| 316 | + | ||
| 317 | + | successCount := 0 | |
| 318 | + | failureCount := 0 | |
| 319 | + | ||
| 320 | + | // Target results | |
| 321 | + | if m.results != nil { | |
| 322 | + | for _, r := range m.results.Results { | |
| 323 | + | if r.Success { | |
| 324 | + | contentLines = append(contentLines, fmt.Sprintf("%s %s", t.SuccessStyle.Render(styles.IconCheck), r.Target.Path)) | |
| 325 | + | successCount++ | |
| 326 | + | } else { | |
| 327 | + | contentLines = append(contentLines, fmt.Sprintf("%s %s: %v", t.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error)) | |
| 328 | + | failureCount++ | |
| 166 | 329 | } | |
| 167 | - | contentLines = append( | |
| 168 | - | contentLines, | |
| 169 | - | "", | |
| 170 | - | t.Subtle.Render( | |
| 171 | - | fmt.Sprintf("%d succeeded, %d failed", m.results.SuccessCount, m.results.FailureCount), | |
| 172 | - | ), | |
| 173 | - | ) | |
| 174 | 330 | } | |
| 175 | - | contentLines = append(contentLines, "", t.Subtle.Render("Press any key to exit")) | |
| 176 | - | return t.Box.Render(lipgloss.JoinVertical(lipgloss.Left, contentLines...)) | |
| 177 | 331 | } | |
| 178 | 332 | ||
| 179 | - | return m.selector.View() | |
| 333 | + | // Session results | |
| 334 | + | for _, r := range m.sessionResults { | |
| 335 | + | if r.Success { | |
| 336 | + | contentLines = append(contentLines, fmt.Sprintf("%s Session %s", t.SuccessStyle.Render(styles.IconCheck), r.SessionID)) | |
| 337 | + | successCount++ | |
| 338 | + | } else { | |
| 339 | + | contentLines = append(contentLines, fmt.Sprintf("%s Session %s: %v", t.ErrorStyle.Render(styles.IconX), r.SessionID, r.Error)) | |
| 340 | + | failureCount++ | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | contentLines = append( | |
| 345 | + | contentLines, | |
| 346 | + | "", | |
| 347 | + | t.Subtle.Render(fmt.Sprintf("%d succeeded, %d failed", successCount, failureCount)), | |
| 348 | + | "", | |
| 349 | + | t.Subtle.Render("Press any key to exit"), | |
| 350 | + | ) | |
| 351 | + | ||
| 352 | + | return t.Box.Render(lipgloss.JoinVertical(lipgloss.Left, contentLines...)) | |
| 180 | 353 | } | |
| 181 | 354 | ||
| 182 | 355 | var _ tea.Model = (*PurgeModel)(nil) | |
A internal/cli/model/sessions.go +642 -0
| @@ -0,0 +1,642 @@ | |||
| 1 | + | // Package model provides Bubble Tea models for CLI commands. | |
| 2 | + | package model | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/charmbracelet/bubbles/help" | |
| 10 | + | "github.com/charmbracelet/bubbles/key" | |
| 11 | + | tea "github.com/charmbracelet/bubbletea" | |
| 12 | + | "github.com/charmbracelet/lipgloss" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 15 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 16 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 17 | + | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 18 | + | "github.com/bnema/dumber/internal/logging" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // SessionsModel is the Bubble Tea model for interactive session browser. | |
| 22 | + | type SessionsModel struct { | |
| 23 | + | // UI components | |
| 24 | + | help help.Model | |
| 25 | + | keys sessionsKeyMap | |
| 26 | + | confirm *styles.ConfirmModel | |
| 27 | + | ||
| 28 | + | // State | |
| 29 | + | sessions []entity.SessionInfo | |
| 30 | + | selectedIdx int | |
| 31 | + | expandedIdx int // -1 means none expanded | |
| 32 | + | width int | |
| 33 | + | height int | |
| 34 | + | err error | |
| 35 | + | statusMessage string | |
| 36 | + | currentSession entity.SessionID | |
| 37 | + | ||
| 38 | + | // Config | |
| 39 | + | maxListedSessions int | |
| 40 | + | ||
| 41 | + | // Dependencies | |
| 42 | + | ctx context.Context | |
| 43 | + | listSessionsUC *usecase.ListSessionsUseCase | |
| 44 | + | restoreUC *usecase.RestoreSessionUseCase | |
| 45 | + | deleteSessionUC *usecase.DeleteSessionUseCase | |
| 46 | + | theme *styles.Theme | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // sessionsKeyMap defines keybindings for the sessions browser. | |
| 50 | + | type sessionsKeyMap struct { | |
| 51 | + | Up key.Binding | |
| 52 | + | Down key.Binding | |
| 53 | + | Expand key.Binding | |
| 54 | + | Restore key.Binding | |
| 55 | + | Delete key.Binding | |
| 56 | + | Refresh key.Binding | |
| 57 | + | Help key.Binding | |
| 58 | + | Quit key.Binding | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // ShortHelp returns keybindings for the short help view. | |
| 62 | + | func (k sessionsKeyMap) ShortHelp() []key.Binding { | |
| 63 | + | return []key.Binding{k.Up, k.Down, k.Expand, k.Restore, k.Delete, k.Quit} | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // FullHelp returns keybindings for the full help view. | |
| 67 | + | func (k sessionsKeyMap) FullHelp() [][]key.Binding { | |
| 68 | + | return [][]key.Binding{ | |
| 69 | + | {k.Up, k.Down, k.Expand}, | |
| 70 | + | {k.Restore, k.Delete, k.Refresh}, | |
| 71 | + | {k.Help, k.Quit}, | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func defaultSessionsKeyMap() sessionsKeyMap { | |
| 76 | + | return sessionsKeyMap{ | |
| 77 | + | Up: key.NewBinding( | |
| 78 | + | key.WithKeys("k", "up"), | |
| 79 | + | key.WithHelp("↑/k", "up"), | |
| 80 | + | ), | |
| 81 | + | Down: key.NewBinding( | |
| 82 | + | key.WithKeys("j", "down"), | |
| 83 | + | key.WithHelp("↓/j", "down"), | |
| 84 | + | ), | |
| 85 | + | Expand: key.NewBinding( | |
| 86 | + | key.WithKeys("enter", "tab"), | |
| 87 | + | key.WithHelp("enter", "expand/collapse"), | |
| 88 | + | ), | |
| 89 | + | Restore: key.NewBinding( | |
| 90 | + | key.WithKeys("r"), | |
| 91 | + | key.WithHelp("r", "restore"), | |
| 92 | + | ), | |
| 93 | + | Delete: key.NewBinding( | |
| 94 | + | key.WithKeys("x", "d"), | |
| 95 | + | key.WithHelp("x", "delete"), | |
| 96 | + | ), | |
| 97 | + | Refresh: key.NewBinding( | |
| 98 | + | key.WithKeys("R"), | |
| 99 | + | key.WithHelp("R", "refresh"), | |
| 100 | + | ), | |
| 101 | + | Help: key.NewBinding( | |
| 102 | + | key.WithKeys("?"), | |
| 103 | + | key.WithHelp("?", "help"), | |
| 104 | + | ), | |
| 105 | + | Quit: key.NewBinding( | |
| 106 | + | key.WithKeys("q", "esc", "ctrl+c"), | |
| 107 | + | key.WithHelp("q", "quit"), | |
| 108 | + | ), | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // SessionsModelConfig holds configuration for the sessions model. | |
| 113 | + | type SessionsModelConfig struct { | |
| 114 | + | ListSessionsUC *usecase.ListSessionsUseCase | |
| 115 | + | RestoreUC *usecase.RestoreSessionUseCase | |
| 116 | + | DeleteSessionUC *usecase.DeleteSessionUseCase | |
| 117 | + | CurrentSession entity.SessionID | |
| 118 | + | MaxListedSessions int | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // NewSessionsModel creates a new sessions browser model. | |
| 122 | + | func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsModelConfig) SessionsModel { | |
| 123 | + | maxListed := cfg.MaxListedSessions | |
| 124 | + | if maxListed <= 0 { | |
| 125 | + | maxListed = 50 // fallback default | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | return SessionsModel{ | |
| 129 | + | help: help.New(), | |
| 130 | + | keys: defaultSessionsKeyMap(), | |
| 131 | + | selectedIdx: 0, | |
| 132 | + | expandedIdx: -1, | |
| 133 | + | width: 80, | |
| 134 | + | height: 24, | |
| 135 | + | maxListedSessions: maxListed, | |
| 136 | + | ctx: ctx, | |
| 137 | + | listSessionsUC: cfg.ListSessionsUC, | |
| 138 | + | restoreUC: cfg.RestoreUC, | |
| 139 | + | deleteSessionUC: cfg.DeleteSessionUC, | |
| 140 | + | currentSession: cfg.CurrentSession, | |
| 141 | + | theme: theme, | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // Init implements tea.Model. | |
| 146 | + | func (m SessionsModel) Init() tea.Cmd { | |
| 147 | + | return m.loadSessions | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // sessionsLoadedMsg is sent when sessions are loaded. | |
| 151 | + | type sessionsLoadedMsg struct { | |
| 152 | + | sessions []entity.SessionInfo | |
| 153 | + | err error | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // sessionDeletedMsg is sent when a session is deleted. | |
| 157 | + | type sessionDeletedMsg struct { | |
| 158 | + | sessionID entity.SessionID | |
| 159 | + | err error | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // sessionRestoredMsg is sent when a session restoration is triggered. | |
| 163 | + | type sessionRestoredMsg struct { | |
| 164 | + | sessionID entity.SessionID | |
| 165 | + | err error | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func (m SessionsModel) loadSessions() tea.Msg { | |
| 169 | + | log := logging.FromContext(m.ctx) | |
| 170 | + | log.Debug().Msg("loading sessions") | |
| 171 | + | ||
| 172 | + | if m.listSessionsUC == nil { | |
| 173 | + | return sessionsLoadedMsg{err: fmt.Errorf("session management not available")} | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | output, err := m.listSessionsUC.Execute(m.ctx, m.currentSession, m.maxListedSessions) | |
| 177 | + | if err != nil { | |
| 178 | + | log.Error().Err(err).Msg("failed to load sessions") | |
| 179 | + | return sessionsLoadedMsg{err: err} | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | log.Debug().Int("count", len(output.Sessions)).Msg("loaded sessions") | |
| 183 | + | return sessionsLoadedMsg{sessions: output.Sessions} | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // Update implements tea.Model. | |
| 187 | + | func (m SessionsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 188 | + | // Handle confirm modal | |
| 189 | + | if m.confirm != nil { | |
| 190 | + | return m.handleConfirmModal(msg) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | switch msg := msg.(type) { | |
| 194 | + | case tea.WindowSizeMsg: | |
| 195 | + | m.width = msg.Width | |
| 196 | + | m.height = msg.Height | |
| 197 | + | m.help.Width = msg.Width | |
| 198 | + | return m, nil | |
| 199 | + | ||
| 200 | + | case tea.KeyMsg: | |
| 201 | + | return m.handleKeyMsg(msg) | |
| 202 | + | ||
| 203 | + | case sessionsLoadedMsg: | |
| 204 | + | if msg.err != nil { | |
| 205 | + | m.err = msg.err | |
| 206 | + | } else { | |
| 207 | + | m.sessions = msg.sessions | |
| 208 | + | m.err = nil | |
| 209 | + | } | |
| 210 | + | return m, nil | |
| 211 | + | ||
| 212 | + | case sessionDeletedMsg: | |
| 213 | + | if msg.err != nil { | |
| 214 | + | m.statusMessage = fmt.Sprintf("Error: %v", msg.err) | |
| 215 | + | } else { | |
| 216 | + | m.statusMessage = fmt.Sprintf("Session %s deleted", msg.sessionID) | |
| 217 | + | } | |
| 218 | + | return m, m.loadSessions | |
| 219 | + | ||
| 220 | + | case sessionRestoredMsg: | |
| 221 | + | if msg.err != nil { | |
| 222 | + | m.statusMessage = fmt.Sprintf("Error: %v", msg.err) | |
| 223 | + | } else { | |
| 224 | + | m.statusMessage = fmt.Sprintf("Restoring session %s...", msg.sessionID) | |
| 225 | + | return m, tea.Quit | |
| 226 | + | } | |
| 227 | + | return m, nil | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | return m, nil | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func (m SessionsModel) handleConfirmModal(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 234 | + | confirm, cmd := m.confirm.Update(msg) | |
| 235 | + | m.confirm = &confirm | |
| 236 | + | if m.confirm.Done() { | |
| 237 | + | if m.confirm.Result() { | |
| 238 | + | // User confirmed deletion | |
| 239 | + | if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) { | |
| 240 | + | session := m.sessions[m.selectedIdx] | |
| 241 | + | cmd = m.deleteSession(session.Session.ID) | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | m.confirm = nil | |
| 245 | + | return m, cmd | |
| 246 | + | } | |
| 247 | + | return m, cmd | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func (m SessionsModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | |
| 251 | + | switch { | |
| 252 | + | case key.Matches(msg, m.keys.Quit): | |
| 253 | + | return m, tea.Quit | |
| 254 | + | ||
| 255 | + | case key.Matches(msg, m.keys.Up): | |
| 256 | + | if m.selectedIdx > 0 { | |
| 257 | + | m.selectedIdx-- | |
| 258 | + | } | |
| 259 | + | return m, nil | |
| 260 | + | ||
| 261 | + | case key.Matches(msg, m.keys.Down): | |
| 262 | + | if m.selectedIdx < len(m.sessions)-1 { | |
| 263 | + | m.selectedIdx++ | |
| 264 | + | } | |
| 265 | + | return m, nil | |
| 266 | + | ||
| 267 | + | case key.Matches(msg, m.keys.Expand): | |
| 268 | + | if m.expandedIdx == m.selectedIdx { | |
| 269 | + | m.expandedIdx = -1 // collapse | |
| 270 | + | } else { | |
| 271 | + | m.expandedIdx = m.selectedIdx // expand | |
| 272 | + | } | |
| 273 | + | return m, nil | |
| 274 | + | ||
| 275 | + | case key.Matches(msg, m.keys.Restore): | |
| 276 | + | if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) { | |
| 277 | + | session := m.sessions[m.selectedIdx] | |
| 278 | + | if session.IsCurrent { | |
| 279 | + | m.statusMessage = "Cannot restore current session" | |
| 280 | + | return m, nil | |
| 281 | + | } | |
| 282 | + | return m, m.restoreSession(session.Session.ID) | |
| 283 | + | } | |
| 284 | + | return m, nil | |
| 285 | + | ||
| 286 | + | case key.Matches(msg, m.keys.Delete): | |
| 287 | + | if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) { | |
| 288 | + | session := m.sessions[m.selectedIdx] | |
| 289 | + | if session.IsCurrent || session.IsActive { | |
| 290 | + | m.statusMessage = "Cannot delete active session" | |
| 291 | + | return m, nil | |
| 292 | + | } | |
| 293 | + | confirm := styles.NewConfirm(m.theme, fmt.Sprintf("Delete session %s?", session.Session.ID)) | |
| 294 | + | m.confirm = &confirm | |
| 295 | + | } | |
| 296 | + | return m, nil | |
| 297 | + | ||
| 298 | + | case key.Matches(msg, m.keys.Refresh): | |
| 299 | + | return m, m.loadSessions | |
| 300 | + | ||
| 301 | + | case key.Matches(msg, m.keys.Help): | |
| 302 | + | m.help.ShowAll = !m.help.ShowAll | |
| 303 | + | return m, nil | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | return m, nil | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func (m SessionsModel) deleteSession(sessionID entity.SessionID) tea.Cmd { | |
| 310 | + | return func() tea.Msg { | |
| 311 | + | if m.deleteSessionUC == nil { | |
| 312 | + | return sessionDeletedMsg{sessionID: sessionID, err: fmt.Errorf("delete session use case not available")} | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | err := m.deleteSessionUC.Execute(m.ctx, usecase.DeleteSessionInput{ | |
| 316 | + | SessionID: sessionID, | |
| 317 | + | CurrentSessionID: m.currentSession, | |
| 318 | + | }) | |
| 319 | + | return sessionDeletedMsg{sessionID: sessionID, err: err} | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | func (m SessionsModel) restoreSession(sessionID entity.SessionID) tea.Cmd { | |
| 324 | + | return func() tea.Msg { | |
| 325 | + | log := logging.FromContext(m.ctx) | |
| 326 | + | log.Info().Str("session_id", string(sessionID)).Msg("restoring session") | |
| 327 | + | ||
| 328 | + | if m.restoreUC == nil { | |
| 329 | + | return sessionRestoredMsg{sessionID: sessionID, err: fmt.Errorf("restore use case not available")} | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | // Validate the session has restorable state | |
| 333 | + | _, err := m.restoreUC.Execute(m.ctx, usecase.RestoreInput{SessionID: sessionID}) | |
| 334 | + | if err != nil { | |
| 335 | + | return sessionRestoredMsg{sessionID: sessionID, err: err} | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // Spawn a new dumber instance | |
| 339 | + | spawner := desktop.NewSessionSpawner(m.ctx) | |
| 340 | + | if err := spawner.SpawnWithSession(sessionID); err != nil { | |
| 341 | + | return sessionRestoredMsg{sessionID: sessionID, err: err} | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | return sessionRestoredMsg{sessionID: sessionID} | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // View implements tea.Model. | |
| 349 | + | func (m SessionsModel) View() string { | |
| 350 | + | // Handle confirm modal | |
| 351 | + | if m.confirm != nil { | |
| 352 | + | return m.confirm.View() | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | t := m.theme | |
| 356 | + | var b strings.Builder | |
| 357 | + | ||
| 358 | + | // Header | |
| 359 | + | header := m.renderHeader() | |
| 360 | + | b.WriteString(header) | |
| 361 | + | b.WriteString("\n\n") | |
| 362 | + | ||
| 363 | + | // Error display | |
| 364 | + | if m.err != nil { | |
| 365 | + | b.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err))) | |
| 366 | + | b.WriteString("\n\n") | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | // Status message | |
| 370 | + | if m.statusMessage != "" { | |
| 371 | + | b.WriteString(t.Subtle.Render(m.statusMessage)) | |
| 372 | + | b.WriteString("\n\n") | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | // Sessions list | |
| 376 | + | if len(m.sessions) == 0 { | |
| 377 | + | b.WriteString(t.Subtle.Render(" No saved sessions found.")) | |
| 378 | + | b.WriteString("\n") | |
| 379 | + | } else { | |
| 380 | + | b.WriteString(m.renderSessionsList()) | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | b.WriteString("\n") | |
| 384 | + | ||
| 385 | + | // Help | |
| 386 | + | helpView := m.help.View(m.keys) | |
| 387 | + | b.WriteString(helpView) | |
| 388 | + | ||
| 389 | + | return b.String() | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | func (m SessionsModel) renderHeader() string { | |
| 393 | + | t := m.theme | |
| 394 | + | ||
| 395 | + | iconStyle := lipgloss.NewStyle().Foreground(t.Accent) | |
| 396 | + | titleStyle := t.Title.MarginLeft(1) | |
| 397 | + | ||
| 398 | + | icon := iconStyle.Render(styles.IconSession) | |
| 399 | + | title := titleStyle.Render("Sessions") | |
| 400 | + | ||
| 401 | + | // Count stats | |
| 402 | + | var activeCount, exitedCount int | |
| 403 | + | for _, s := range m.sessions { | |
| 404 | + | if s.IsCurrent || s.IsActive { | |
| 405 | + | activeCount++ | |
| 406 | + | } else { | |
| 407 | + | exitedCount++ | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | stats := t.Subtle.Render(fmt.Sprintf(" %s %d active %s %d exited", | |
| 412 | + | styles.IconPlay, activeCount, | |
| 413 | + | styles.IconStop, exitedCount, | |
| 414 | + | )) | |
| 415 | + | ||
| 416 | + | return icon + title + stats | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | func (m SessionsModel) renderSessionsList() string { | |
| 420 | + | var b strings.Builder | |
| 421 | + | ||
| 422 | + | for i, info := range m.sessions { | |
| 423 | + | isSelected := i == m.selectedIdx | |
| 424 | + | isExpanded := i == m.expandedIdx | |
| 425 | + | ||
| 426 | + | b.WriteString(m.renderSessionRow(info, isSelected, isExpanded)) | |
| 427 | + | b.WriteString("\n") | |
| 428 | + | ||
| 429 | + | // Render expanded details | |
| 430 | + | if isExpanded && info.State != nil { | |
| 431 | + | b.WriteString(m.renderSessionDetails(info)) | |
| 432 | + | } | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | return b.String() | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string { | |
| 439 | + | t := m.theme | |
| 440 | + | ||
| 441 | + | // Cursor | |
| 442 | + | cursor := " " | |
| 443 | + | if isSelected { | |
| 444 | + | cursor = t.Highlight.Render(styles.IconCursor + " ") | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | // Status icon | |
| 448 | + | var statusIcon string | |
| 449 | + | var statusStyle lipgloss.Style | |
| 450 | + | switch { | |
| 451 | + | case info.IsCurrent: | |
| 452 | + | statusIcon = styles.IconPlay | |
| 453 | + | statusStyle = lipgloss.NewStyle().Foreground(t.Accent) | |
| 454 | + | case info.IsActive: | |
| 455 | + | statusIcon = styles.IconPlay | |
| 456 | + | statusStyle = lipgloss.NewStyle().Foreground(t.Warning) | |
| 457 | + | default: | |
| 458 | + | statusIcon = styles.IconStop | |
| 459 | + | statusStyle = t.Subtle | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | // Session ID | |
| 463 | + | idStyle := t.Normal | |
| 464 | + | if isSelected { | |
| 465 | + | idStyle = t.Highlight | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | // Label for current/active | |
| 469 | + | label := "" | |
| 470 | + | if info.IsCurrent { | |
| 471 | + | label = t.Badge.Render("current") | |
| 472 | + | } else if info.IsActive { | |
| 473 | + | label = t.BadgeMuted.Render("active") | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | // Expand indicator | |
| 477 | + | expandIcon := styles.IconExpand | |
| 478 | + | if isExpanded { | |
| 479 | + | expandIcon = styles.IconCollapse | |
| 480 | + | } | |
| 481 | + | expandStyle := t.Subtle | |
| 482 | + | ||
| 483 | + | // Tab/pane counts | |
| 484 | + | counts := t.Subtle.Render(fmt.Sprintf("%s %d %s %d", | |
| 485 | + | styles.IconTab, info.TabCount, | |
| 486 | + | styles.IconPane, info.PaneCount, | |
| 487 | + | )) | |
| 488 | + | ||
| 489 | + | // Time | |
| 490 | + | relTime := usecase.GetRelativeTime(info.UpdatedAt) | |
| 491 | + | timeStr := t.Subtle.Render(fmt.Sprintf("%s %s", styles.IconClock, relTime)) | |
| 492 | + | ||
| 493 | + | // Build row | |
| 494 | + | row := fmt.Sprintf("%s%s %s %s %s %s %s", | |
| 495 | + | cursor, | |
| 496 | + | statusStyle.Render(statusIcon), | |
| 497 | + | idStyle.Render(string(info.Session.ID)), | |
| 498 | + | label, | |
| 499 | + | expandStyle.Render(expandIcon), | |
| 500 | + | counts, | |
| 501 | + | timeStr, | |
| 502 | + | ) | |
| 503 | + | ||
| 504 | + | return row | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string { | |
| 508 | + | t := m.theme | |
| 509 | + | var b strings.Builder | |
| 510 | + | ||
| 511 | + | if info.State == nil { | |
| 512 | + | b.WriteString(t.Subtle.Render(" No state data available\n")) | |
| 513 | + | return b.String() | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | // Tree prefix styles | |
| 517 | + | treeStyle := lipgloss.NewStyle().Foreground(t.Border) | |
| 518 | + | leafStyle := lipgloss.NewStyle().Foreground(t.Muted) | |
| 519 | + | ||
| 520 | + | // Render tabs as tree | |
| 521 | + | for tabIdx, tab := range info.State.Tabs { | |
| 522 | + | isLastTab := tabIdx == len(info.State.Tabs)-1 | |
| 523 | + | ||
| 524 | + | // Tab branch | |
| 525 | + | branch := "├── " | |
| 526 | + | if isLastTab { | |
| 527 | + | branch = "└── " | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | tabIcon := styles.IconTab | |
| 531 | + | if tab.IsPinned { | |
| 532 | + | tabIcon = styles.IconCheckboxChecked | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | tabName := tab.Name | |
| 536 | + | if tabName == "" { | |
| 537 | + | tabName = "Tab" | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | b.WriteString(fmt.Sprintf(" %s%s %s\n", | |
| 541 | + | treeStyle.Render(branch), | |
| 542 | + | leafStyle.Render(tabIcon), | |
| 543 | + | t.Normal.Render(tabName), | |
| 544 | + | )) | |
| 545 | + | ||
| 546 | + | // Render panes under this tab | |
| 547 | + | m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle) | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | b.WriteString("\n") | |
| 551 | + | return b.String() | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | func (m SessionsModel) renderPaneTree( | |
| 555 | + | b *strings.Builder, | |
| 556 | + | ws *entity.WorkspaceSnapshot, | |
| 557 | + | isLastTab bool, | |
| 558 | + | t *styles.Theme, | |
| 559 | + | treeStyle, leafStyle lipgloss.Style, | |
| 560 | + | ) { | |
| 561 | + | if ws == nil || ws.Root == nil { | |
| 562 | + | return | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | // Determine prefix for children | |
| 566 | + | childPrefix := " │ " | |
| 567 | + | if isLastTab { | |
| 568 | + | childPrefix = " " | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | m.renderPaneNode(b, ws.Root, childPrefix, true, t, treeStyle, leafStyle) | |
| 572 | + | } | |
| 573 | + | ||
| 574 | + | func (m SessionsModel) renderPaneNode( | |
| 575 | + | b *strings.Builder, | |
| 576 | + | node *entity.PaneNodeSnapshot, | |
| 577 | + | prefix string, | |
| 578 | + | isLast bool, | |
| 579 | + | t *styles.Theme, | |
| 580 | + | treeStyle, leafStyle lipgloss.Style, | |
| 581 | + | ) { | |
| 582 | + | if node == nil { | |
| 583 | + | return | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | branch := "├── " | |
| 587 | + | if isLast { | |
| 588 | + | branch = "└── " | |
| 589 | + | } | |
| 590 | + | ||
| 591 | + | if node.Pane != nil { | |
| 592 | + | // Leaf node (actual pane) | |
| 593 | + | pane := node.Pane | |
| 594 | + | title := pane.Title | |
| 595 | + | if title == "" { | |
| 596 | + | title = pane.URI | |
| 597 | + | } | |
| 598 | + | // Truncate long titles | |
| 599 | + | const maxTitleLen = 50 | |
| 600 | + | if len(title) > maxTitleLen { | |
| 601 | + | title = title[:maxTitleLen-3] + "..." | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | fmt.Fprintf(b, "%s%s%s %s\n", | |
| 605 | + | prefix, | |
| 606 | + | treeStyle.Render(branch), | |
| 607 | + | leafStyle.Render(styles.IconPane), | |
| 608 | + | t.Subtle.Render(title), | |
| 609 | + | ) | |
| 610 | + | } else if len(node.Children) > 0 { | |
| 611 | + | // Container node (split or stacked) | |
| 612 | + | splitIcon := styles.IconSessionStack | |
| 613 | + | splitType := "split" | |
| 614 | + | if node.IsStacked { | |
| 615 | + | splitType = "stacked" | |
| 616 | + | } else if node.SplitRatio > 0 { | |
| 617 | + | splitType = fmt.Sprintf("split %.0f%%", node.SplitRatio*100) | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | fmt.Fprintf(b, "%s%s%s %s\n", | |
| 621 | + | prefix, | |
| 622 | + | treeStyle.Render(branch), | |
| 623 | + | leafStyle.Render(splitIcon), | |
| 624 | + | t.Subtle.Render(splitType), | |
| 625 | + | ) | |
| 626 | + | ||
| 627 | + | // Child prefix | |
| 628 | + | childPrefix := prefix + "│ " | |
| 629 | + | if isLast { | |
| 630 | + | childPrefix = prefix + " " | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | // Render all children | |
| 634 | + | for i, child := range node.Children { | |
| 635 | + | isLastChild := i == len(node.Children)-1 | |
| 636 | + | m.renderPaneNode(b, child, childPrefix, isLastChild, t, treeStyle, leafStyle) | |
| 637 | + | } | |
| 638 | + | } | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | // Ensure interface compliance at compile time. | |
| 642 | + | var _ tea.Model = (*SessionsModel)(nil) | |
M internal/cli/styles/icons.go +13 -0
| @@ -40,4 +40,17 @@ const ( | |||
| 40 | 40 | ||
| 41 | 41 | // UI | |
| 42 | 42 | IconCursor = "\uf054" // chevron-right | |
| 43 | + | ||
| 44 | + | // Sessions / browser | |
| 45 | + | IconSession = "\uf2d2" // window | |
| 46 | + | IconSessionStack = "\uf24d" // clone/stack | |
| 47 | + | IconTab = "\uf0ce" // table | |
| 48 | + | IconPane = "\uf0db" // columns | |
| 49 | + | IconTree = "\uf1bb" // tree | |
| 50 | + | IconClock = "\uf017" // clock | |
| 51 | + | IconPlay = "\uf04b" // play (running) | |
| 52 | + | IconStop = "\uf04d" // stop (exited) | |
| 53 | + | IconRestore = "\uf0e2" // rotate-left (restore) | |
| 54 | + | IconExpand = "\uf065" // expand | |
| 55 | + | IconCollapse = "\uf066" // compress | |
| 43 | 56 | ) | |
M internal/cli/styles/purge.go +327 -41
| @@ -3,6 +3,7 @@ package styles | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "strings" | |
| 6 | + | "time" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/charmbracelet/bubbles/key" | |
| 8 | 9 | tea "github.com/charmbracelet/bubbletea" | |
| @@ -17,34 +18,41 @@ type PurgeItem struct { | |||
| 17 | 18 | Selected bool | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 20 | - | // PurgeModel is the multi-select purge modal. | |
| 21 | + | // PurgeModel is the multi-select purge modal with sessions support. | |
| 21 | 22 | type PurgeModel struct { | |
| 22 | - | Items []PurgeItem | |
| 23 | - | Cursor int | |
| 24 | - | Confirmed bool | |
| 25 | - | Canceled bool | |
| 26 | - | theme *Theme | |
| 23 | + | Items []PurgeItem | |
| 24 | + | Sessions []entity.SessionPurgeItem | |
| 25 | + | SessionsSize int64 // Total size of all session snapshots | |
| 26 | + | Cursor int | |
| 27 | + | Confirmed bool | |
| 28 | + | Canceled bool | |
| 29 | + | theme *Theme | |
| 30 | + | ||
| 31 | + | // Track cursor position: false = items section, true = sessions section | |
| 32 | + | inSessionsSection bool | |
| 27 | 33 | } | |
| 28 | 34 | ||
| 29 | 35 | // PurgeKeyMap defines keybindings for purge modal. | |
| 30 | 36 | type PurgeKeyMap struct { | |
| 31 | - | Up key.Binding | |
| 32 | - | Down key.Binding | |
| 33 | - | Toggle key.Binding | |
| 34 | - | ToggleAll key.Binding | |
| 35 | - | Confirm key.Binding | |
| 36 | - | Cancel key.Binding | |
| 37 | + | Up key.Binding | |
| 38 | + | Down key.Binding | |
| 39 | + | Toggle key.Binding | |
| 40 | + | ToggleAll key.Binding | |
| 41 | + | ToggleSessions key.Binding | |
| 42 | + | Confirm key.Binding | |
| 43 | + | Cancel key.Binding | |
| 37 | 44 | } | |
| 38 | 45 | ||
| 39 | 46 | // DefaultPurgeKeyMap returns default keybindings. | |
| 40 | 47 | func DefaultPurgeKeyMap() PurgeKeyMap { | |
| 41 | 48 | return PurgeKeyMap{ | |
| 42 | - | Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), | |
| 43 | - | Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), | |
| 44 | - | Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")), | |
| 45 | - | ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")), | |
| 46 | - | Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), | |
| 47 | - | Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")), | |
| 49 | + | Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), | |
| 50 | + | Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), | |
| 51 | + | Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")), | |
| 52 | + | ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")), | |
| 53 | + | ToggleSessions: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "toggle sessions")), | |
| 54 | + | Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), | |
| 55 | + | Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")), | |
| 48 | 56 | } | |
| 49 | 57 | } | |
| 50 | 58 | ||
| @@ -60,6 +68,14 @@ func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel { | |||
| 60 | 68 | return m | |
| 61 | 69 | } | |
| 62 | 70 | ||
| 71 | + | // NewPurgeWithSessions creates a new purge modal with targets and sessions. | |
| 72 | + | func NewPurgeWithSessions(theme *Theme, targets []entity.PurgeTarget, sessions []entity.SessionPurgeItem, sessionsSize int64) PurgeModel { | |
| 73 | + | m := NewPurge(theme, targets) | |
| 74 | + | m.Sessions = sessions | |
| 75 | + | m.SessionsSize = sessionsSize | |
| 76 | + | return m | |
| 77 | + | } | |
| 78 | + | ||
| 63 | 79 | // Init implements tea.Model. | |
| 64 | 80 | func (m PurgeModel) Init() tea.Cmd { | |
| 65 | 81 | _ = m | |
| @@ -81,6 +97,8 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) { | |||
| 81 | 97 | m.toggleCurrent() | |
| 82 | 98 | case key.Matches(msg, keys.ToggleAll): | |
| 83 | 99 | m.toggleAll() | |
| 100 | + | case key.Matches(msg, keys.ToggleSessions): | |
| 101 | + | m.ToggleAllSessions() | |
| 84 | 102 | case key.Matches(msg, keys.Confirm): | |
| 85 | 103 | m.Confirmed = true | |
| 86 | 104 | case key.Matches(msg, keys.Cancel): | |
| @@ -91,19 +109,78 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) { | |||
| 91 | 109 | return m, nil | |
| 92 | 110 | } | |
| 93 | 111 | ||
| 112 | + | // isDataSelected returns true if the Data target is selected. | |
| 113 | + | func (m PurgeModel) isDataSelected() bool { | |
| 114 | + | for _, it := range m.Items { | |
| 115 | + | if it.Type == entity.PurgeTargetData && it.Selected { | |
| 116 | + | return true | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | return false | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // SessionsEnabled returns true if sessions can be individually selected. | |
| 123 | + | // Sessions are disabled when Data is selected (database will be removed). | |
| 124 | + | func (m PurgeModel) SessionsEnabled() bool { | |
| 125 | + | return !m.isDataSelected() | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // totalSelectableCount returns the total number of selectable items across both sections. | |
| 129 | + | func (m PurgeModel) totalSelectableCount() int { | |
| 130 | + | count := 0 | |
| 131 | + | for _, it := range m.Items { | |
| 132 | + | if it.Exists { | |
| 133 | + | count++ | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | if m.SessionsEnabled() { | |
| 137 | + | count += len(m.Sessions) | |
| 138 | + | } | |
| 139 | + | return count | |
| 140 | + | } | |
| 141 | + | ||
| 94 | 142 | func (m *PurgeModel) moveCursor(delta int) { | |
| 95 | - | if len(m.Items) == 0 { | |
| 143 | + | total := m.totalSelectableCount() | |
| 144 | + | if total == 0 { | |
| 96 | 145 | return | |
| 97 | 146 | } | |
| 98 | 147 | ||
| 99 | - | idx := m.Cursor | |
| 100 | - | for i := 0; i < len(m.Items); i++ { | |
| 101 | - | idx = (idx + delta + len(m.Items)) % len(m.Items) | |
| 102 | - | if m.Items[idx].Exists { | |
| 103 | - | m.Cursor = idx | |
| 104 | - | return | |
| 148 | + | // Build a flat list of selectable indices | |
| 149 | + | // Items first, then sessions (if enabled) | |
| 150 | + | type position struct { | |
| 151 | + | inSessions bool | |
| 152 | + | index int | |
| 153 | + | } | |
| 154 | + | var positions []position | |
| 155 | + | ||
| 156 | + | for i, it := range m.Items { | |
| 157 | + | if it.Exists { | |
| 158 | + | positions = append(positions, position{inSessions: false, index: i}) | |
| 105 | 159 | } | |
| 106 | 160 | } | |
| 161 | + | if m.SessionsEnabled() { | |
| 162 | + | for i := range m.Sessions { | |
| 163 | + | positions = append(positions, position{inSessions: true, index: i}) | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | if len(positions) == 0 { | |
| 168 | + | return | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // Find current position in the flat list | |
| 172 | + | currentFlat := 0 | |
| 173 | + | for i, p := range positions { | |
| 174 | + | if p.inSessions == m.inSessionsSection && p.index == m.Cursor { | |
| 175 | + | currentFlat = i | |
| 176 | + | break | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // Move in the flat list | |
| 181 | + | newFlat := (currentFlat + delta + len(positions)) % len(positions) | |
| 182 | + | m.inSessionsSection = positions[newFlat].inSessions | |
| 183 | + | m.Cursor = positions[newFlat].index | |
| 107 | 184 | } | |
| 108 | 185 | ||
| 109 | 186 | func (m PurgeModel) firstSelectableIndex() int { | |
| @@ -116,6 +193,16 @@ func (m PurgeModel) firstSelectableIndex() int { | |||
| 116 | 193 | } | |
| 117 | 194 | ||
| 118 | 195 | func (m *PurgeModel) toggleCurrent() { | |
| 196 | + | if m.inSessionsSection { | |
| 197 | + | if !m.SessionsEnabled() { | |
| 198 | + | return | |
| 199 | + | } | |
| 200 | + | if m.Cursor >= 0 && m.Cursor < len(m.Sessions) { | |
| 201 | + | m.Sessions[m.Cursor].Selected = !m.Sessions[m.Cursor].Selected | |
| 202 | + | } | |
| 203 | + | return | |
| 204 | + | } | |
| 205 | + | ||
| 119 | 206 | if m.Cursor < 0 || m.Cursor >= len(m.Items) { | |
| 120 | 207 | return | |
| 121 | 208 | } | |
| @@ -126,6 +213,7 @@ func (m *PurgeModel) toggleCurrent() { | |||
| 126 | 213 | } | |
| 127 | 214 | ||
| 128 | 215 | func (m *PurgeModel) toggleAll() { | |
| 216 | + | // Toggle all items | |
| 129 | 217 | anyUnselected := false | |
| 130 | 218 | for _, it := range m.Items { | |
| 131 | 219 | if it.Exists && !it.Selected { | |
| @@ -133,6 +221,15 @@ func (m *PurgeModel) toggleAll() { | |||
| 133 | 221 | break | |
| 134 | 222 | } | |
| 135 | 223 | } | |
| 224 | + | // Also check sessions if enabled | |
| 225 | + | if m.SessionsEnabled() { | |
| 226 | + | for _, s := range m.Sessions { | |
| 227 | + | if !s.Selected { | |
| 228 | + | anyUnselected = true | |
| 229 | + | break | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | } | |
| 136 | 233 | ||
| 137 | 234 | for i := range m.Items { | |
| 138 | 235 | if !m.Items[i].Exists { | |
| @@ -140,6 +237,30 @@ func (m *PurgeModel) toggleAll() { | |||
| 140 | 237 | } | |
| 141 | 238 | m.Items[i].Selected = anyUnselected | |
| 142 | 239 | } | |
| 240 | + | if m.SessionsEnabled() { | |
| 241 | + | for i := range m.Sessions { | |
| 242 | + | m.Sessions[i].Selected = anyUnselected | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // ToggleAllSessions toggles the selection state of all sessions. | |
| 248 | + | func (m *PurgeModel) ToggleAllSessions() { | |
| 249 | + | if !m.SessionsEnabled() || len(m.Sessions) == 0 { | |
| 250 | + | return | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | anyUnselected := false | |
| 254 | + | for _, s := range m.Sessions { | |
| 255 | + | if !s.Selected { | |
| 256 | + | anyUnselected = true | |
| 257 | + | break | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | for i := range m.Sessions { | |
| 262 | + | m.Sessions[i].Selected = anyUnselected | |
| 263 | + | } | |
| 143 | 264 | } | |
| 144 | 265 | ||
| 145 | 266 | // View implements tea.Model. | |
| @@ -149,12 +270,33 @@ func (m PurgeModel) View() string { | |||
| 149 | 270 | header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash)) | |
| 150 | 271 | subtitle := t.Subtle.Render("Select items to remove") | |
| 151 | 272 | ||
| 152 | - | var rows []string | |
| 273 | + | // Application Data section | |
| 274 | + | dataSectionHeader := t.Subtle.Render("── Application Data ──") | |
| 275 | + | var itemRows []string | |
| 153 | 276 | for i, it := range m.Items { | |
| 154 | - | rows = append(rows, m.renderRow(i, it)) | |
| 277 | + | itemRows = append(itemRows, m.renderItemRow(i, it)) | |
| 155 | 278 | } | |
| 279 | + | itemsList := lipgloss.JoinVertical(lipgloss.Left, itemRows...) | |
| 280 | + | ||
| 281 | + | // Sessions section | |
| 282 | + | var sessionsList string | |
| 283 | + | if len(m.Sessions) > 0 { | |
| 284 | + | sessionsEnabled := m.SessionsEnabled() | |
| 285 | + | var sessionHeader string | |
| 286 | + | if sessionsEnabled { | |
| 287 | + | sessionHeader = t.Subtle.Render(fmt.Sprintf("── Inactive Sessions (%d total, %s) ──", | |
| 288 | + | len(m.Sessions), formatSize(m.SessionsSize))) | |
| 289 | + | } else { | |
| 290 | + | sessionHeader = t.Subtle.Render("── Inactive Sessions (database will be removed) ──") | |
| 291 | + | } | |
| 156 | 292 | ||
| 157 | - | list := lipgloss.JoinVertical(lipgloss.Left, rows...) | |
| 293 | + | var sessionRows []string | |
| 294 | + | sessionRows = append(sessionRows, sessionHeader) | |
| 295 | + | for i, s := range m.Sessions { | |
| 296 | + | sessionRows = append(sessionRows, m.renderSessionRow(i, s, sessionsEnabled)) | |
| 297 | + | } | |
| 298 | + | sessionsList = lipgloss.JoinVertical(lipgloss.Left, sessionRows...) | |
| 299 | + | } | |
| 158 | 300 | ||
| 159 | 301 | selectedCount := m.SelectedCount() | |
| 160 | 302 | size := m.SelectedSize() | |
| @@ -172,28 +314,35 @@ func (m PurgeModel) View() string { | |||
| 172 | 314 | summary = t.Subtle.Render("0 selected") | |
| 173 | 315 | } | |
| 174 | 316 | ||
| 175 | - | help := t.Subtle.Render("↑/↓ or j/k to move • space to toggle • a toggle all • enter confirm • esc cancel") | |
| 317 | + | helpText := "↑/↓ j/k move • space toggle • a all" | |
| 318 | + | if len(m.Sessions) > 0 && m.SessionsEnabled() { | |
| 319 | + | helpText += " • s sessions" | |
| 320 | + | } | |
| 321 | + | helpText += " • enter • esc" | |
| 322 | + | help := t.Subtle.Render(helpText) | |
| 176 | 323 | ||
| 177 | - | content := lipgloss.JoinVertical( | |
| 178 | - | lipgloss.Left, | |
| 324 | + | contentParts := []string{ | |
| 179 | 325 | header, | |
| 180 | 326 | subtitle, | |
| 181 | 327 | "", | |
| 182 | - | list, | |
| 183 | - | "", | |
| 184 | - | summary, | |
| 185 | - | "", | |
| 186 | - | help, | |
| 187 | - | ) | |
| 328 | + | dataSectionHeader, | |
| 329 | + | itemsList, | |
| 330 | + | } | |
| 331 | + | if sessionsList != "" { | |
| 332 | + | contentParts = append(contentParts, "", sessionsList) | |
| 333 | + | } | |
| 334 | + | contentParts = append(contentParts, "", summary, "", help) | |
| 335 | + | ||
| 336 | + | content := lipgloss.JoinVertical(lipgloss.Left, contentParts...) | |
| 188 | 337 | ||
| 189 | 338 | return t.Box.Render(content) | |
| 190 | 339 | } | |
| 191 | 340 | ||
| 192 | - | func (m PurgeModel) renderRow(i int, it PurgeItem) string { | |
| 341 | + | func (m PurgeModel) renderItemRow(i int, it PurgeItem) string { | |
| 193 | 342 | t := m.theme | |
| 194 | 343 | ||
| 195 | 344 | cursor := " " | |
| 196 | - | if i == m.Cursor { | |
| 345 | + | if !m.inSessionsSection && i == m.Cursor { | |
| 197 | 346 | cursor = IconCursor + " " | |
| 198 | 347 | } | |
| 199 | 348 | ||
| @@ -242,6 +391,62 @@ func (m PurgeModel) renderRow(i int, it PurgeItem) string { | |||
| 242 | 391 | return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing) | |
| 243 | 392 | } | |
| 244 | 393 | ||
| 394 | + | func (m PurgeModel) renderSessionRow(i int, s entity.SessionPurgeItem, enabled bool) string { | |
| 395 | + | t := m.theme | |
| 396 | + | ||
| 397 | + | cursor := " " | |
| 398 | + | if m.inSessionsSection && i == m.Cursor && enabled { | |
| 399 | + | cursor = IconCursor + " " | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | checkbox := IconCheckboxEmpty | |
| 403 | + | checkboxStyle := t.Subtle | |
| 404 | + | cursorStyle := t.Subtle | |
| 405 | + | labelStyle := t.Subtle | |
| 406 | + | detailStyle := t.Subtle | |
| 407 | + | ||
| 408 | + | if enabled { | |
| 409 | + | if s.Selected { | |
| 410 | + | checkbox = IconCheckboxChecked | |
| 411 | + | } | |
| 412 | + | checkboxStyle = lipgloss.NewStyle().Foreground(t.Accent) | |
| 413 | + | cursorStyle = lipgloss.NewStyle().Foreground(t.Accent) | |
| 414 | + | labelStyle = t.Normal | |
| 415 | + | detailStyle = t.Subtle | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | // Format: ...abc1 2 tabs 3 panes 2d ago | |
| 419 | + | shortID := s.Info.Session.ShortID() | |
| 420 | + | tabLabel := "tab" | |
| 421 | + | if s.Info.TabCount != 1 { | |
| 422 | + | tabLabel = "tabs" | |
| 423 | + | } | |
| 424 | + | paneLabel := "pane" | |
| 425 | + | if s.Info.PaneCount != 1 { | |
| 426 | + | paneLabel = "panes" | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | const idPadWidth = 8 | |
| 430 | + | const tabPadWidth = 8 | |
| 431 | + | const panePadWidth = 9 | |
| 432 | + | ||
| 433 | + | idPart := labelStyle.Render(padRight("..."+shortID, idPadWidth)) | |
| 434 | + | tabPart := detailStyle.Render(padRight(fmt.Sprintf("%d %s", s.Info.TabCount, tabLabel), tabPadWidth)) | |
| 435 | + | panePart := detailStyle.Render(padRight(fmt.Sprintf("%d %s", s.Info.PaneCount, paneLabel), panePadWidth)) | |
| 436 | + | timePart := detailStyle.Render(getRelativeTime(s.Info.UpdatedAt)) | |
| 437 | + | ||
| 438 | + | return lipgloss.JoinHorizontal( | |
| 439 | + | lipgloss.Left, | |
| 440 | + | cursorStyle.Render(cursor), | |
| 441 | + | checkboxStyle.Render(checkbox), | |
| 442 | + | " ", | |
| 443 | + | idPart, | |
| 444 | + | tabPart, | |
| 445 | + | panePart, | |
| 446 | + | timePart, | |
| 447 | + | ) | |
| 448 | + | } | |
| 449 | + | ||
| 245 | 450 | func purgeLabel(t entity.PurgeTargetType) string { | |
| 246 | 451 | switch t { | |
| 247 | 452 | case entity.PurgeTargetConfig: | |
| @@ -288,6 +493,45 @@ func formatSize(bytes int64) string { | |||
| 288 | 493 | return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) | |
| 289 | 494 | } | |
| 290 | 495 | ||
| 496 | + | func getRelativeTime(t time.Time) string { | |
| 497 | + | now := time.Now() | |
| 498 | + | diff := now.Sub(t) | |
| 499 | + | ||
| 500 | + | const ( | |
| 501 | + | hoursPerDay = 24 | |
| 502 | + | daysPerWeek = 7 | |
| 503 | + | ) | |
| 504 | + | ||
| 505 | + | switch { | |
| 506 | + | case diff < time.Minute: | |
| 507 | + | return "just now" | |
| 508 | + | case diff < time.Hour: | |
| 509 | + | mins := int(diff.Minutes()) | |
| 510 | + | if mins == 1 { | |
| 511 | + | return "1m ago" | |
| 512 | + | } | |
| 513 | + | return fmt.Sprintf("%dm ago", mins) | |
| 514 | + | case diff < hoursPerDay*time.Hour: | |
| 515 | + | hours := int(diff.Hours()) | |
| 516 | + | if hours == 1 { | |
| 517 | + | return "1h ago" | |
| 518 | + | } | |
| 519 | + | return fmt.Sprintf("%dh ago", hours) | |
| 520 | + | case diff < daysPerWeek*hoursPerDay*time.Hour: | |
| 521 | + | days := int(diff.Hours() / hoursPerDay) | |
| 522 | + | if days == 1 { | |
| 523 | + | return "1d ago" | |
| 524 | + | } | |
| 525 | + | return fmt.Sprintf("%dd ago", days) | |
| 526 | + | default: | |
| 527 | + | weeks := int(diff.Hours() / hoursPerDay / daysPerWeek) | |
| 528 | + | if weeks == 1 { | |
| 529 | + | return "1w ago" | |
| 530 | + | } | |
| 531 | + | return fmt.Sprintf("%dw ago", weeks) | |
| 532 | + | } | |
| 533 | + | } | |
| 534 | + | ||
| 291 | 535 | // Done returns true if the modal is complete. | |
| 292 | 536 | func (m PurgeModel) Done() bool { | |
| 293 | 537 | return m.Confirmed || m.Canceled | |
| @@ -309,7 +553,7 @@ func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType { | |||
| 309 | 553 | return out | |
| 310 | 554 | } | |
| 311 | 555 | ||
| 312 | - | // SelectedCount returns the number of selected, existing items. | |
| 556 | + | // SelectedCount returns the number of selected items (targets + sessions). | |
| 313 | 557 | func (m PurgeModel) SelectedCount() int { | |
| 314 | 558 | count := 0 | |
| 315 | 559 | for _, it := range m.Items { | |
| @@ -317,10 +561,19 @@ func (m PurgeModel) SelectedCount() int { | |||
| 317 | 561 | count++ | |
| 318 | 562 | } | |
| 319 | 563 | } | |
| 564 | + | // Only count sessions if they're enabled (Data not selected) | |
| 565 | + | if m.SessionsEnabled() { | |
| 566 | + | for _, s := range m.Sessions { | |
| 567 | + | if s.Selected { | |
| 568 | + | count++ | |
| 569 | + | } | |
| 570 | + | } | |
| 571 | + | } | |
| 320 | 572 | return count | |
| 321 | 573 | } | |
| 322 | 574 | ||
| 323 | - | // SelectedSize returns the total size of selected, existing items. | |
| 575 | + | // SelectedSize returns the total size of selected items. | |
| 576 | + | // When Data is selected, includes SessionsSize since DB will be removed. | |
| 324 | 577 | func (m PurgeModel) SelectedSize() int64 { | |
| 325 | 578 | var total int64 | |
| 326 | 579 | for _, it := range m.Items { | |
| @@ -328,5 +581,38 @@ func (m PurgeModel) SelectedSize() int64 { | |||
| 328 | 581 | total += it.Size | |
| 329 | 582 | } | |
| 330 | 583 | } | |
| 584 | + | // If Data is selected, sessions are implicitly included | |
| 585 | + | if m.isDataSelected() { | |
| 586 | + | total += m.SessionsSize | |
| 587 | + | } else { | |
| 588 | + | // Otherwise, count only selected sessions | |
| 589 | + | // Note: individual session sizes aren't tracked, only total | |
| 590 | + | // When sessions are selected, we include the total size proportionally | |
| 591 | + | selectedCount := 0 | |
| 592 | + | for _, s := range m.Sessions { | |
| 593 | + | if s.Selected { | |
| 594 | + | selectedCount++ | |
| 595 | + | } | |
| 596 | + | } | |
| 597 | + | if len(m.Sessions) > 0 && selectedCount > 0 { | |
| 598 | + | total += m.SessionsSize * int64(selectedCount) / int64(len(m.Sessions)) | |
| 599 | + | } | |
| 600 | + | } | |
| 331 | 601 | return total | |
| 332 | 602 | } | |
| 603 | + | ||
| 604 | + | // SelectedSessionIDs returns the IDs of selected sessions. | |
| 605 | + | // Returns nil if Data is selected (sessions are deleted with the database). | |
| 606 | + | func (m PurgeModel) SelectedSessionIDs() []entity.SessionID { | |
| 607 | + | if m.isDataSelected() { | |
| 608 | + | return nil | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | var ids []entity.SessionID | |
| 612 | + | for _, s := range m.Sessions { | |
| 613 | + | if s.Selected { | |
| 614 | + | ids = append(ids, s.Info.Session.ID) | |
| 615 | + | } | |
| 616 | + | } | |
| 617 | + | return ids | |
| 618 | + | } | |
A internal/cli/styles/purge_test.go +240 -0
| @@ -0,0 +1,240 @@ | |||
| 1 | + | package styles_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func testTheme() *styles.Theme { | |
| 15 | + | return styles.NewTheme(config.DefaultConfig()) | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestPurgeModel_SessionsDisabledWhenDataSelected(t *testing.T) { | |
| 19 | + | theme := testTheme() | |
| 20 | + | ||
| 21 | + | targets := []entity.PurgeTarget{ | |
| 22 | + | {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100}, | |
| 23 | + | {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 1000}, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | endedAt := time.Now().Add(-time.Hour) | |
| 27 | + | sessions := []entity.SessionPurgeItem{ | |
| 28 | + | { | |
| 29 | + | Info: entity.SessionInfo{ | |
| 30 | + | Session: &entity.Session{ | |
| 31 | + | ID: "session1", | |
| 32 | + | Type: entity.SessionTypeBrowser, | |
| 33 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 34 | + | EndedAt: &endedAt, | |
| 35 | + | }, | |
| 36 | + | TabCount: 2, | |
| 37 | + | PaneCount: 3, | |
| 38 | + | }, | |
| 39 | + | Selected: true, | |
| 40 | + | }, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | m := styles.NewPurgeWithSessions(theme, targets, sessions, 500) | |
| 44 | + | ||
| 45 | + | // Initially Data is selected (default for existing items) | |
| 46 | + | assert.False(t, m.SessionsEnabled(), "sessions should be disabled when Data is selected") | |
| 47 | + | ||
| 48 | + | // Deselect Data | |
| 49 | + | m.Items[1].Selected = false | |
| 50 | + | assert.True(t, m.SessionsEnabled(), "sessions should be enabled when Data is not selected") | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestPurgeModel_SelectedSessionIDs_ReturnsNilWhenDataSelected(t *testing.T) { | |
| 54 | + | theme := testTheme() | |
| 55 | + | ||
| 56 | + | targets := []entity.PurgeTarget{ | |
| 57 | + | {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 1000}, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | endedAt := time.Now().Add(-time.Hour) | |
| 61 | + | sessions := []entity.SessionPurgeItem{ | |
| 62 | + | { | |
| 63 | + | Info: entity.SessionInfo{ | |
| 64 | + | Session: &entity.Session{ | |
| 65 | + | ID: "session1", | |
| 66 | + | Type: entity.SessionTypeBrowser, | |
| 67 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 68 | + | EndedAt: &endedAt, | |
| 69 | + | }, | |
| 70 | + | }, | |
| 71 | + | Selected: true, | |
| 72 | + | }, | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | m := styles.NewPurgeWithSessions(theme, targets, sessions, 500) | |
| 76 | + | ||
| 77 | + | // Data is selected, so sessions should return nil | |
| 78 | + | ids := m.SelectedSessionIDs() | |
| 79 | + | assert.Nil(t, ids, "should return nil when Data is selected") | |
| 80 | + | ||
| 81 | + | // Deselect Data | |
| 82 | + | m.Items[0].Selected = false | |
| 83 | + | ids = m.SelectedSessionIDs() | |
| 84 | + | require.Len(t, ids, 1) | |
| 85 | + | assert.Equal(t, entity.SessionID("session1"), ids[0]) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestPurgeModel_SelectedCount_IncludesSessions(t *testing.T) { | |
| 89 | + | theme := testTheme() | |
| 90 | + | ||
| 91 | + | targets := []entity.PurgeTarget{ | |
| 92 | + | {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100}, | |
| 93 | + | {Type: entity.PurgeTargetCache, Path: "/cache", Exists: true, Size: 200}, | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | endedAt := time.Now().Add(-time.Hour) | |
| 97 | + | sessions := []entity.SessionPurgeItem{ | |
| 98 | + | { | |
| 99 | + | Info: entity.SessionInfo{ | |
| 100 | + | Session: &entity.Session{ | |
| 101 | + | ID: "session1", | |
| 102 | + | Type: entity.SessionTypeBrowser, | |
| 103 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 104 | + | EndedAt: &endedAt, | |
| 105 | + | }, | |
| 106 | + | }, | |
| 107 | + | Selected: true, | |
| 108 | + | }, | |
| 109 | + | { | |
| 110 | + | Info: entity.SessionInfo{ | |
| 111 | + | Session: &entity.Session{ | |
| 112 | + | ID: "session2", | |
| 113 | + | Type: entity.SessionTypeBrowser, | |
| 114 | + | StartedAt: time.Now().Add(-3 * time.Hour), | |
| 115 | + | EndedAt: &endedAt, | |
| 116 | + | }, | |
| 117 | + | }, | |
| 118 | + | Selected: false, // Not selected | |
| 119 | + | }, | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | m := styles.NewPurgeWithSessions(theme, targets, sessions, 500) | |
| 123 | + | ||
| 124 | + | // 2 targets + 1 selected session = 3 | |
| 125 | + | assert.Equal(t, 3, m.SelectedCount()) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestPurgeModel_ToggleAllSessions(t *testing.T) { | |
| 129 | + | theme := testTheme() | |
| 130 | + | ||
| 131 | + | targets := []entity.PurgeTarget{ | |
| 132 | + | {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100}, | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | endedAt := time.Now().Add(-time.Hour) | |
| 136 | + | sessions := []entity.SessionPurgeItem{ | |
| 137 | + | { | |
| 138 | + | Info: entity.SessionInfo{ | |
| 139 | + | Session: &entity.Session{ | |
| 140 | + | ID: "session1", | |
| 141 | + | Type: entity.SessionTypeBrowser, | |
| 142 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 143 | + | EndedAt: &endedAt, | |
| 144 | + | }, | |
| 145 | + | }, | |
| 146 | + | Selected: true, | |
| 147 | + | }, | |
| 148 | + | { | |
| 149 | + | Info: entity.SessionInfo{ | |
| 150 | + | Session: &entity.Session{ | |
| 151 | + | ID: "session2", | |
| 152 | + | Type: entity.SessionTypeBrowser, | |
| 153 | + | StartedAt: time.Now().Add(-3 * time.Hour), | |
| 154 | + | EndedAt: &endedAt, | |
| 155 | + | }, | |
| 156 | + | }, | |
| 157 | + | Selected: true, | |
| 158 | + | }, | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | m := styles.NewPurgeWithSessions(theme, targets, sessions, 500) | |
| 162 | + | ||
| 163 | + | // All sessions are selected, toggle should deselect all | |
| 164 | + | m.ToggleAllSessions() | |
| 165 | + | assert.False(t, m.Sessions[0].Selected) | |
| 166 | + | assert.False(t, m.Sessions[1].Selected) | |
| 167 | + | ||
| 168 | + | // Now toggle again to select all | |
| 169 | + | m.ToggleAllSessions() | |
| 170 | + | assert.True(t, m.Sessions[0].Selected) | |
| 171 | + | assert.True(t, m.Sessions[1].Selected) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) { | |
| 175 | + | theme := testTheme() | |
| 176 | + | ||
| 177 | + | targets := []entity.PurgeTarget{ | |
| 178 | + | {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100}, | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | endedAt := time.Now().Add(-time.Hour) | |
| 182 | + | sessions := []entity.SessionPurgeItem{ | |
| 183 | + | { | |
| 184 | + | Info: entity.SessionInfo{ | |
| 185 | + | Session: &entity.Session{ | |
| 186 | + | ID: "session1", | |
| 187 | + | Type: entity.SessionTypeBrowser, | |
| 188 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 189 | + | EndedAt: &endedAt, | |
| 190 | + | }, | |
| 191 | + | }, | |
| 192 | + | Selected: true, | |
| 193 | + | }, | |
| 194 | + | { | |
| 195 | + | Info: entity.SessionInfo{ | |
| 196 | + | Session: &entity.Session{ | |
| 197 | + | ID: "session2", | |
| 198 | + | Type: entity.SessionTypeBrowser, | |
| 199 | + | StartedAt: time.Now().Add(-3 * time.Hour), | |
| 200 | + | EndedAt: &endedAt, | |
| 201 | + | }, | |
| 202 | + | }, | |
| 203 | + | Selected: false, // Not selected | |
| 204 | + | }, | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // Total sessions size is 1000, 1 of 2 sessions selected = 500 | |
| 208 | + | m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000) | |
| 209 | + | ||
| 210 | + | // 100 (config) + 500 (1/2 of sessions) = 600 | |
| 211 | + | assert.Equal(t, int64(600), m.SelectedSize()) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *testing.T) { | |
| 215 | + | theme := testTheme() | |
| 216 | + | ||
| 217 | + | targets := []entity.PurgeTarget{ | |
| 218 | + | {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 100}, | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | endedAt := time.Now().Add(-time.Hour) | |
| 222 | + | sessions := []entity.SessionPurgeItem{ | |
| 223 | + | { | |
| 224 | + | Info: entity.SessionInfo{ | |
| 225 | + | Session: &entity.Session{ | |
| 226 | + | ID: "session1", | |
| 227 | + | Type: entity.SessionTypeBrowser, | |
| 228 | + | StartedAt: time.Now().Add(-2 * time.Hour), | |
| 229 | + | EndedAt: &endedAt, | |
| 230 | + | }, | |
| 231 | + | }, | |
| 232 | + | Selected: false, // Not individually selected, but Data is selected | |
| 233 | + | }, | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000) | |
| 237 | + | ||
| 238 | + | // 100 (data) + 1000 (all sessions because DB is being deleted) = 1100 | |
| 239 | + | assert.Equal(t, int64(1100), m.SelectedSize()) | |
| 240 | + | } | |
M internal/domain/entity/purge.go +6 -0
| @@ -30,3 +30,9 @@ type PurgeResult struct { | |||
| 30 | 30 | Success bool | |
| 31 | 31 | Error error | |
| 32 | 32 | } | |
| 33 | + | ||
| 34 | + | // SessionPurgeItem represents an inactive session in the purge selection UI. | |
| 35 | + | type SessionPurgeItem struct { | |
| 36 | + | Info SessionInfo | |
| 37 | + | Selected bool | |
| 38 | + | } | |
A internal/domain/entity/session_state.go +302 -0
| @@ -0,0 +1,302 @@ | |||
| 1 | + | package entity | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // SessionStateVersion is the current schema version for session state. | |
| 6 | + | // Increment when making breaking changes to the serialization format. | |
| 7 | + | const SessionStateVersion = 1 | |
| 8 | + | ||
| 9 | + | // SessionState represents a complete snapshot of a browser session. | |
| 10 | + | // This is serialized to JSON and stored in the database. | |
| 11 | + | type SessionState struct { | |
| 12 | + | Version int `json:"version"` | |
| 13 | + | SessionID SessionID `json:"session_id"` | |
| 14 | + | Tabs []TabSnapshot `json:"tabs"` | |
| 15 | + | ActiveTabIndex int `json:"active_tab_index"` | |
| 16 | + | SavedAt time.Time `json:"saved_at"` | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // TabSnapshot captures the state of a single tab. | |
| 20 | + | type TabSnapshot struct { | |
| 21 | + | ID TabID `json:"id"` | |
| 22 | + | Name string `json:"name"` | |
| 23 | + | Position int `json:"position"` | |
| 24 | + | IsPinned bool `json:"is_pinned"` | |
| 25 | + | Workspace WorkspaceSnapshot `json:"workspace"` | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // WorkspaceSnapshot captures the pane tree layout. | |
| 29 | + | type WorkspaceSnapshot struct { | |
| 30 | + | ID WorkspaceID `json:"id"` | |
| 31 | + | Root *PaneNodeSnapshot `json:"root"` | |
| 32 | + | ActivePaneID PaneID `json:"active_pane_id"` | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // PaneNodeSnapshot captures a node in the pane tree. | |
| 36 | + | type PaneNodeSnapshot struct { | |
| 37 | + | ID string `json:"id"` | |
| 38 | + | Pane *PaneSnapshot `json:"pane,omitempty"` // Non-nil for leaf nodes | |
| 39 | + | Children []*PaneNodeSnapshot `json:"children,omitempty"` // Non-nil for containers | |
| 40 | + | SplitDir SplitDirection `json:"split_dir"` | |
| 41 | + | SplitRatio float64 `json:"split_ratio"` | |
| 42 | + | IsStacked bool `json:"is_stacked"` | |
| 43 | + | ActiveStackIndex int `json:"active_stack_index"` | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // PaneSnapshot captures the essential state of a pane. | |
| 47 | + | type PaneSnapshot struct { | |
| 48 | + | ID PaneID `json:"id"` | |
| 49 | + | URI string `json:"uri"` | |
| 50 | + | Title string `json:"title"` | |
| 51 | + | ZoomFactor float64 `json:"zoom_factor"` | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // SnapshotFromTabList creates a SessionState from a live TabList. | |
| 55 | + | func SnapshotFromTabList(sessionID SessionID, tabs *TabList) *SessionState { | |
| 56 | + | if tabs == nil { | |
| 57 | + | return &SessionState{ | |
| 58 | + | Version: SessionStateVersion, | |
| 59 | + | SessionID: sessionID, | |
| 60 | + | Tabs: []TabSnapshot{}, | |
| 61 | + | SavedAt: time.Now(), | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | snapTabs := make([]TabSnapshot, 0, len(tabs.Tabs)) | |
| 66 | + | activeTabIndex := 0 | |
| 67 | + | ||
| 68 | + | for i, tab := range tabs.Tabs { | |
| 69 | + | if tab.ID == tabs.ActiveTabID { | |
| 70 | + | activeTabIndex = i | |
| 71 | + | } | |
| 72 | + | snapTabs = append(snapTabs, snapshotTab(tab)) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | return &SessionState{ | |
| 76 | + | Version: SessionStateVersion, | |
| 77 | + | SessionID: sessionID, | |
| 78 | + | Tabs: snapTabs, | |
| 79 | + | ActiveTabIndex: activeTabIndex, | |
| 80 | + | SavedAt: time.Now(), | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func snapshotTab(tab *Tab) TabSnapshot { | |
| 85 | + | return TabSnapshot{ | |
| 86 | + | ID: tab.ID, | |
| 87 | + | Name: tab.Name, | |
| 88 | + | Position: tab.Position, | |
| 89 | + | IsPinned: tab.IsPinned, | |
| 90 | + | Workspace: snapshotWorkspace(tab.Workspace), | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func snapshotWorkspace(ws *Workspace) WorkspaceSnapshot { | |
| 95 | + | if ws == nil { | |
| 96 | + | return WorkspaceSnapshot{} | |
| 97 | + | } | |
| 98 | + | return WorkspaceSnapshot{ | |
| 99 | + | ID: ws.ID, | |
| 100 | + | Root: snapshotPaneNode(ws.Root), | |
| 101 | + | ActivePaneID: ws.ActivePaneID, | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func snapshotPaneNode(node *PaneNode) *PaneNodeSnapshot { | |
| 106 | + | if node == nil { | |
| 107 | + | return nil | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | snapshot := &PaneNodeSnapshot{ | |
| 111 | + | ID: node.ID, | |
| 112 | + | SplitDir: node.SplitDir, | |
| 113 | + | SplitRatio: node.SplitRatio, | |
| 114 | + | IsStacked: node.IsStacked, | |
| 115 | + | ActiveStackIndex: node.ActiveStackIndex, | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | if node.Pane != nil { | |
| 119 | + | snapshot.Pane = &PaneSnapshot{ | |
| 120 | + | ID: node.Pane.ID, | |
| 121 | + | URI: node.Pane.URI, | |
| 122 | + | Title: node.Pane.Title, | |
| 123 | + | ZoomFactor: node.Pane.ZoomFactor, | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | if len(node.Children) > 0 { | |
| 128 | + | snapshot.Children = make([]*PaneNodeSnapshot, 0, len(node.Children)) | |
| 129 | + | for _, child := range node.Children { | |
| 130 | + | snapshot.Children = append(snapshot.Children, snapshotPaneNode(child)) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | return snapshot | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // SessionInfo provides summary information for the session manager UI. | |
| 138 | + | type SessionInfo struct { | |
| 139 | + | Session *Session | |
| 140 | + | State *SessionState | |
| 141 | + | TabCount int | |
| 142 | + | PaneCount int | |
| 143 | + | IsActive bool // Has active lock file | |
| 144 | + | IsCurrent bool // Is the current session | |
| 145 | + | UpdatedAt time.Time // When the state was last saved | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // CountPanes returns the total number of panes in the session state. | |
| 149 | + | func (s *SessionState) CountPanes() int { | |
| 150 | + | count := 0 | |
| 151 | + | for _, tab := range s.Tabs { | |
| 152 | + | count += countPanesInNode(tab.Workspace.Root) | |
| 153 | + | } | |
| 154 | + | return count | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func countPanesInNode(node *PaneNodeSnapshot) int { | |
| 158 | + | if node == nil { | |
| 159 | + | return 0 | |
| 160 | + | } | |
| 161 | + | if node.Pane != nil { | |
| 162 | + | return 1 | |
| 163 | + | } | |
| 164 | + | count := 0 | |
| 165 | + | for _, child := range node.Children { | |
| 166 | + | count += countPanesInNode(child) | |
| 167 | + | } | |
| 168 | + | return count | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // IDGenerator is a function that generates unique IDs. | |
| 172 | + | type IDGenerator func() string | |
| 173 | + | ||
| 174 | + | // TabListFromSnapshot reconstructs a TabList from a SessionState snapshot. | |
| 175 | + | // Generates new IDs for all entities using the provided generator. | |
| 176 | + | // This is the inverse of SnapshotFromTabList. | |
| 177 | + | func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList { | |
| 178 | + | if state == nil { | |
| 179 | + | return NewTabList() | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | tabs := NewTabList() | |
| 183 | + | ||
| 184 | + | for i, tabSnap := range state.Tabs { | |
| 185 | + | tab := tabFromSnapshot(&tabSnap, idGen) | |
| 186 | + | if tab == nil { | |
| 187 | + | continue | |
| 188 | + | } | |
| 189 | + | tabs.Tabs = append(tabs.Tabs, tab) | |
| 190 | + | tab.Position = len(tabs.Tabs) - 1 | |
| 191 | + | ||
| 192 | + | if i == state.ActiveTabIndex { | |
| 193 | + | tabs.ActiveTabID = tab.ID | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // Ensure we have an active tab | |
| 198 | + | if tabs.ActiveTabID == "" && len(tabs.Tabs) > 0 { | |
| 199 | + | tabs.ActiveTabID = tabs.Tabs[0].ID | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | return tabs | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func tabFromSnapshot(snap *TabSnapshot, idGen IDGenerator) *Tab { | |
| 206 | + | if snap == nil { | |
| 207 | + | return nil | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | ws := workspaceFromSnapshot(&snap.Workspace, idGen) | |
| 211 | + | if ws == nil { | |
| 212 | + | return nil | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | return &Tab{ | |
| 216 | + | ID: TabID(idGen()), | |
| 217 | + | Name: snap.Name, | |
| 218 | + | Workspace: ws, | |
| 219 | + | Position: snap.Position, | |
| 220 | + | IsPinned: snap.IsPinned, | |
| 221 | + | CreatedAt: time.Now(), | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func workspaceFromSnapshot(snap *WorkspaceSnapshot, idGen IDGenerator) *Workspace { | |
| 226 | + | if snap == nil { | |
| 227 | + | return nil | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | root := paneNodeFromSnapshot(snap.Root, nil, idGen) | |
| 231 | + | if root == nil { | |
| 232 | + | return nil | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // Find the active pane ID in the restored tree | |
| 236 | + | // We need to map the snapshot's ActivePaneID to the new pane ID | |
| 237 | + | var activePaneID PaneID | |
| 238 | + | root.Walk(func(node *PaneNode) bool { | |
| 239 | + | if node.Pane != nil { | |
| 240 | + | // Use the first pane as active if we can't find the original | |
| 241 | + | if activePaneID == "" { | |
| 242 | + | activePaneID = node.Pane.ID | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | return true | |
| 246 | + | }) | |
| 247 | + | ||
| 248 | + | return &Workspace{ | |
| 249 | + | ID: WorkspaceID(idGen()), | |
| 250 | + | Root: root, | |
| 251 | + | ActivePaneID: activePaneID, | |
| 252 | + | CreatedAt: time.Now(), | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func paneNodeFromSnapshot(snap *PaneNodeSnapshot, parent *PaneNode, idGen IDGenerator) *PaneNode { | |
| 257 | + | if snap == nil { | |
| 258 | + | return nil | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | node := &PaneNode{ | |
| 262 | + | ID: idGen(), | |
| 263 | + | Parent: parent, | |
| 264 | + | SplitDir: snap.SplitDir, | |
| 265 | + | SplitRatio: snap.SplitRatio, | |
| 266 | + | IsStacked: snap.IsStacked, | |
| 267 | + | ActiveStackIndex: snap.ActiveStackIndex, | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | // Restore pane if this is a leaf node | |
| 271 | + | if snap.Pane != nil { | |
| 272 | + | node.Pane = paneFromSnapshot(snap.Pane, idGen) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // Restore children recursively | |
| 276 | + | if len(snap.Children) > 0 { | |
| 277 | + | node.Children = make([]*PaneNode, 0, len(snap.Children)) | |
| 278 | + | for _, childSnap := range snap.Children { | |
| 279 | + | child := paneNodeFromSnapshot(childSnap, node, idGen) | |
| 280 | + | if child != nil { | |
| 281 | + | node.Children = append(node.Children, child) | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | return node | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func paneFromSnapshot(snap *PaneSnapshot, idGen IDGenerator) *Pane { | |
| 290 | + | if snap == nil { | |
| 291 | + | return nil | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | return &Pane{ | |
| 295 | + | ID: PaneID(idGen()), | |
| 296 | + | URI: snap.URI, | |
| 297 | + | Title: snap.Title, | |
| 298 | + | ZoomFactor: snap.ZoomFactor, | |
| 299 | + | WindowType: WindowMain, | |
| 300 | + | CreatedAt: time.Now(), | |
| 301 | + | } | |
| 302 | + | } | |
A internal/domain/entity/session_state_test.go +430 -0
| @@ -0,0 +1,430 @@ | |||
| 1 | + | package entity_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync/atomic" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // mockIDGenerator creates a simple ID generator for testing. | |
| 14 | + | func mockIDGenerator() entity.IDGenerator { | |
| 15 | + | var counter uint64 | |
| 16 | + | return func() string { | |
| 17 | + | id := atomic.AddUint64(&counter, 1) | |
| 18 | + | return string(rune('a'-1+id)) + "_new" | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestTabListFromSnapshot_EmptyState(t *testing.T) { | |
| 23 | + | idGen := mockIDGenerator() | |
| 24 | + | ||
| 25 | + | // Nil state | |
| 26 | + | tabs := entity.TabListFromSnapshot(nil, idGen) | |
| 27 | + | require.NotNil(t, tabs) | |
| 28 | + | assert.Empty(t, tabs.Tabs) | |
| 29 | + | ||
| 30 | + | // Empty state | |
| 31 | + | state := &entity.SessionState{ | |
| 32 | + | Version: 1, | |
| 33 | + | Tabs: []entity.TabSnapshot{}, | |
| 34 | + | } | |
| 35 | + | tabs = entity.TabListFromSnapshot(state, idGen) | |
| 36 | + | require.NotNil(t, tabs) | |
| 37 | + | assert.Empty(t, tabs.Tabs) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestTabListFromSnapshot_SingleTab(t *testing.T) { | |
| 41 | + | idGen := mockIDGenerator() | |
| 42 | + | ||
| 43 | + | state := &entity.SessionState{ | |
| 44 | + | Version: 1, | |
| 45 | + | SessionID: "test_session", | |
| 46 | + | ActiveTabIndex: 0, | |
| 47 | + | Tabs: []entity.TabSnapshot{ | |
| 48 | + | { | |
| 49 | + | ID: "old_tab_1", | |
| 50 | + | Name: "Test Tab", | |
| 51 | + | Position: 0, | |
| 52 | + | IsPinned: true, | |
| 53 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 54 | + | ID: "old_ws_1", | |
| 55 | + | ActivePaneID: "old_pane_1", | |
| 56 | + | Root: &entity.PaneNodeSnapshot{ | |
| 57 | + | ID: "old_node_1", | |
| 58 | + | Pane: &entity.PaneSnapshot{ | |
| 59 | + | ID: "old_pane_1", | |
| 60 | + | URI: "https://example.com", | |
| 61 | + | Title: "Example", | |
| 62 | + | ZoomFactor: 1.25, | |
| 63 | + | }, | |
| 64 | + | }, | |
| 65 | + | }, | |
| 66 | + | }, | |
| 67 | + | }, | |
| 68 | + | SavedAt: time.Now(), | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | tabs := entity.TabListFromSnapshot(state, idGen) | |
| 72 | + | ||
| 73 | + | require.NotNil(t, tabs) | |
| 74 | + | require.Len(t, tabs.Tabs, 1) | |
| 75 | + | ||
| 76 | + | tab := tabs.Tabs[0] | |
| 77 | + | assert.NotEqual(t, entity.TabID("old_tab_1"), tab.ID, "should generate new ID") | |
| 78 | + | assert.Equal(t, "Test Tab", tab.Name) | |
| 79 | + | assert.True(t, tab.IsPinned) | |
| 80 | + | ||
| 81 | + | require.NotNil(t, tab.Workspace) | |
| 82 | + | require.NotNil(t, tab.Workspace.Root) | |
| 83 | + | require.NotNil(t, tab.Workspace.Root.Pane) | |
| 84 | + | ||
| 85 | + | pane := tab.Workspace.Root.Pane | |
| 86 | + | assert.NotEqual(t, entity.PaneID("old_pane_1"), pane.ID, "should generate new pane ID") | |
| 87 | + | assert.Equal(t, "https://example.com", pane.URI) | |
| 88 | + | assert.Equal(t, "Example", pane.Title) | |
| 89 | + | assert.InDelta(t, 1.25, pane.ZoomFactor, 0.001) | |
| 90 | + | ||
| 91 | + | // Active tab should be set | |
| 92 | + | assert.Equal(t, tab.ID, tabs.ActiveTabID) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestTabListFromSnapshot_MultipleTabs(t *testing.T) { | |
| 96 | + | idGen := mockIDGenerator() | |
| 97 | + | ||
| 98 | + | state := &entity.SessionState{ | |
| 99 | + | Version: 1, | |
| 100 | + | ActiveTabIndex: 1, // Second tab is active | |
| 101 | + | Tabs: []entity.TabSnapshot{ | |
| 102 | + | { | |
| 103 | + | ID: "tab_1", | |
| 104 | + | Name: "First", | |
| 105 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 106 | + | Root: &entity.PaneNodeSnapshot{ | |
| 107 | + | Pane: &entity.PaneSnapshot{ | |
| 108 | + | ID: "pane_1", | |
| 109 | + | URI: "https://first.com", | |
| 110 | + | }, | |
| 111 | + | }, | |
| 112 | + | }, | |
| 113 | + | }, | |
| 114 | + | { | |
| 115 | + | ID: "tab_2", | |
| 116 | + | Name: "Second", | |
| 117 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 118 | + | Root: &entity.PaneNodeSnapshot{ | |
| 119 | + | Pane: &entity.PaneSnapshot{ | |
| 120 | + | ID: "pane_2", | |
| 121 | + | URI: "https://second.com", | |
| 122 | + | }, | |
| 123 | + | }, | |
| 124 | + | }, | |
| 125 | + | }, | |
| 126 | + | }, | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | tabs := entity.TabListFromSnapshot(state, idGen) | |
| 130 | + | ||
| 131 | + | require.Len(t, tabs.Tabs, 2) | |
| 132 | + | assert.Equal(t, "First", tabs.Tabs[0].Name) | |
| 133 | + | assert.Equal(t, "Second", tabs.Tabs[1].Name) | |
| 134 | + | ||
| 135 | + | // Active tab should be the second one | |
| 136 | + | assert.Equal(t, tabs.Tabs[1].ID, tabs.ActiveTabID) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestTabListFromSnapshot_SplitPanes(t *testing.T) { | |
| 140 | + | idGen := mockIDGenerator() | |
| 141 | + | ||
| 142 | + | // Horizontal split with two panes | |
| 143 | + | state := &entity.SessionState{ | |
| 144 | + | Version: 1, | |
| 145 | + | Tabs: []entity.TabSnapshot{ | |
| 146 | + | { | |
| 147 | + | ID: "tab_1", | |
| 148 | + | Name: "Split Tab", | |
| 149 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 150 | + | Root: &entity.PaneNodeSnapshot{ | |
| 151 | + | ID: "container", | |
| 152 | + | SplitDir: entity.SplitHorizontal, | |
| 153 | + | SplitRatio: 0.5, | |
| 154 | + | Children: []*entity.PaneNodeSnapshot{ | |
| 155 | + | { | |
| 156 | + | ID: "left_node", | |
| 157 | + | Pane: &entity.PaneSnapshot{ | |
| 158 | + | ID: "left_pane", | |
| 159 | + | URI: "https://left.com", | |
| 160 | + | }, | |
| 161 | + | }, | |
| 162 | + | { | |
| 163 | + | ID: "right_node", | |
| 164 | + | Pane: &entity.PaneSnapshot{ | |
| 165 | + | ID: "right_pane", | |
| 166 | + | URI: "https://right.com", | |
| 167 | + | }, | |
| 168 | + | }, | |
| 169 | + | }, | |
| 170 | + | }, | |
| 171 | + | }, | |
| 172 | + | }, | |
| 173 | + | }, | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | tabs := entity.TabListFromSnapshot(state, idGen) | |
| 177 | + | ||
| 178 | + | require.Len(t, tabs.Tabs, 1) | |
| 179 | + | ws := tabs.Tabs[0].Workspace | |
| 180 | + | require.NotNil(t, ws.Root) | |
| 181 | + | ||
| 182 | + | // Root should be a container with children | |
| 183 | + | assert.Nil(t, ws.Root.Pane) | |
| 184 | + | assert.Equal(t, entity.SplitHorizontal, ws.Root.SplitDir) | |
| 185 | + | assert.InDelta(t, 0.5, ws.Root.SplitRatio, 0.001) | |
| 186 | + | require.Len(t, ws.Root.Children, 2) | |
| 187 | + | ||
| 188 | + | // Check children | |
| 189 | + | leftPane := ws.Root.Children[0].Pane | |
| 190 | + | rightPane := ws.Root.Children[1].Pane | |
| 191 | + | require.NotNil(t, leftPane) | |
| 192 | + | require.NotNil(t, rightPane) | |
| 193 | + | assert.Equal(t, "https://left.com", leftPane.URI) | |
| 194 | + | assert.Equal(t, "https://right.com", rightPane.URI) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestTabListFromSnapshot_StackedPanes(t *testing.T) { | |
| 198 | + | idGen := mockIDGenerator() | |
| 199 | + | ||
| 200 | + | state := &entity.SessionState{ | |
| 201 | + | Version: 1, | |
| 202 | + | Tabs: []entity.TabSnapshot{ | |
| 203 | + | { | |
| 204 | + | ID: "tab_1", | |
| 205 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 206 | + | Root: &entity.PaneNodeSnapshot{ | |
| 207 | + | ID: "stack", | |
| 208 | + | IsStacked: true, | |
| 209 | + | ActiveStackIndex: 1, | |
| 210 | + | Children: []*entity.PaneNodeSnapshot{ | |
| 211 | + | {Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://a.com"}}, | |
| 212 | + | {Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://b.com"}}, | |
| 213 | + | {Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://c.com"}}, | |
| 214 | + | }, | |
| 215 | + | }, | |
| 216 | + | }, | |
| 217 | + | }, | |
| 218 | + | }, | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | tabs := entity.TabListFromSnapshot(state, idGen) | |
| 222 | + | ||
| 223 | + | ws := tabs.Tabs[0].Workspace | |
| 224 | + | require.NotNil(t, ws.Root) | |
| 225 | + | assert.True(t, ws.Root.IsStacked) | |
| 226 | + | assert.Equal(t, 1, ws.Root.ActiveStackIndex) | |
| 227 | + | require.Len(t, ws.Root.Children, 3) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func TestTabList_ReplaceFrom(t *testing.T) { | |
| 231 | + | // Create original list | |
| 232 | + | original := entity.NewTabList() | |
| 233 | + | pane1 := entity.NewPane("pane1") | |
| 234 | + | pane1.URI = "https://old.com" | |
| 235 | + | tab1 := entity.NewTab("tab1", "ws1", pane1) | |
| 236 | + | original.Add(tab1) | |
| 237 | + | original.SetActive("tab1") | |
| 238 | + | ||
| 239 | + | // Create new list | |
| 240 | + | newList := entity.NewTabList() | |
| 241 | + | pane2 := entity.NewPane("pane2") | |
| 242 | + | pane2.URI = "https://new1.com" | |
| 243 | + | pane3 := entity.NewPane("pane3") | |
| 244 | + | pane3.URI = "https://new2.com" | |
| 245 | + | tab2 := entity.NewTab("tab2", "ws2", pane2) | |
| 246 | + | tab3 := entity.NewTab("tab3", "ws3", pane3) | |
| 247 | + | newList.Add(tab2) | |
| 248 | + | newList.Add(tab3) | |
| 249 | + | newList.SetActive("tab3") | |
| 250 | + | ||
| 251 | + | // Keep reference to original | |
| 252 | + | ref := original | |
| 253 | + | ||
| 254 | + | // Replace contents | |
| 255 | + | original.ReplaceFrom(newList) | |
| 256 | + | ||
| 257 | + | // Verify the reference still points to the modified list | |
| 258 | + | assert.Same(t, original, ref) | |
| 259 | + | assert.Len(t, ref.Tabs, 2) | |
| 260 | + | assert.Equal(t, entity.TabID("tab3"), ref.ActiveTabID) | |
| 261 | + | assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestTabList_ReplaceFrom_Nil(t *testing.T) { | |
| 265 | + | original := entity.NewTabList() | |
| 266 | + | pane := entity.NewPane("pane1") | |
| 267 | + | tab := entity.NewTab("tab1", "ws1", pane) | |
| 268 | + | original.Add(tab) | |
| 269 | + | ||
| 270 | + | original.ReplaceFrom(nil) | |
| 271 | + | ||
| 272 | + | assert.Empty(t, original.Tabs) | |
| 273 | + | assert.Empty(t, original.ActiveTabID) | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func TestSnapshotRoundTrip(t *testing.T) { | |
| 277 | + | // Create a complex tab structure | |
| 278 | + | idGen := mockIDGenerator() | |
| 279 | + | ||
| 280 | + | pane1 := entity.NewPane("p1") | |
| 281 | + | pane1.URI = "https://google.com" | |
| 282 | + | pane1.Title = "Google" | |
| 283 | + | pane1.ZoomFactor = 1.5 | |
| 284 | + | ||
| 285 | + | pane2 := entity.NewPane("p2") | |
| 286 | + | pane2.URI = "https://github.com" | |
| 287 | + | pane2.Title = "GitHub" | |
| 288 | + | ||
| 289 | + | tab1 := entity.NewTab("t1", "ws1", pane1) | |
| 290 | + | tab1.Name = "Search" | |
| 291 | + | tab1.IsPinned = true | |
| 292 | + | ||
| 293 | + | tab2 := entity.NewTab("t2", "ws2", pane2) | |
| 294 | + | tab2.Name = "Code" | |
| 295 | + | ||
| 296 | + | original := entity.NewTabList() | |
| 297 | + | original.Add(tab1) | |
| 298 | + | original.Add(tab2) | |
| 299 | + | original.SetActive("t2") | |
| 300 | + | ||
| 301 | + | // Snapshot | |
| 302 | + | sessionID := entity.SessionID("test_session") | |
| 303 | + | state := entity.SnapshotFromTabList(sessionID, original) | |
| 304 | + | ||
| 305 | + | require.NotNil(t, state) | |
| 306 | + | assert.Equal(t, sessionID, state.SessionID) | |
| 307 | + | assert.Len(t, state.Tabs, 2) | |
| 308 | + | assert.Equal(t, 1, state.ActiveTabIndex) // Second tab active | |
| 309 | + | ||
| 310 | + | // Restore | |
| 311 | + | restored := entity.TabListFromSnapshot(state, idGen) | |
| 312 | + | ||
| 313 | + | require.NotNil(t, restored) | |
| 314 | + | require.Len(t, restored.Tabs, 2) | |
| 315 | + | ||
| 316 | + | // Check first tab | |
| 317 | + | restoredTab1 := restored.Tabs[0] | |
| 318 | + | assert.Equal(t, "Search", restoredTab1.Name) | |
| 319 | + | assert.True(t, restoredTab1.IsPinned) | |
| 320 | + | require.NotNil(t, restoredTab1.Workspace.Root.Pane) | |
| 321 | + | assert.Equal(t, "https://google.com", restoredTab1.Workspace.Root.Pane.URI) | |
| 322 | + | assert.Equal(t, "Google", restoredTab1.Workspace.Root.Pane.Title) | |
| 323 | + | assert.InDelta(t, 1.5, restoredTab1.Workspace.Root.Pane.ZoomFactor, 0.001) | |
| 324 | + | ||
| 325 | + | // Check second tab | |
| 326 | + | restoredTab2 := restored.Tabs[1] | |
| 327 | + | assert.Equal(t, "Code", restoredTab2.Name) | |
| 328 | + | assert.False(t, restoredTab2.IsPinned) | |
| 329 | + | assert.Equal(t, "https://github.com", restoredTab2.Workspace.Root.Pane.URI) | |
| 330 | + | ||
| 331 | + | // Active tab should be second | |
| 332 | + | assert.Equal(t, restoredTab2.ID, restored.ActiveTabID) | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | func TestFindPaneAcrossTabs(t *testing.T) { | |
| 336 | + | // Create tabs with panes | |
| 337 | + | pane1 := entity.NewPane("pane_in_tab1") | |
| 338 | + | pane1.URI = "https://tab1.com" | |
| 339 | + | tab1 := entity.NewTab("t1", "ws1", pane1) | |
| 340 | + | ||
| 341 | + | pane2 := entity.NewPane("pane_in_tab2") | |
| 342 | + | pane2.URI = "https://tab2.com" | |
| 343 | + | tab2 := entity.NewTab("t2", "ws2", pane2) | |
| 344 | + | ||
| 345 | + | tabs := entity.NewTabList() | |
| 346 | + | tabs.Add(tab1) | |
| 347 | + | tabs.Add(tab2) | |
| 348 | + | ||
| 349 | + | // Helper function to find pane across all tabs (mirrors App.updatePaneURIInAllTabs logic) | |
| 350 | + | findPaneInTabs := func(tabs *entity.TabList, paneID entity.PaneID) *entity.Pane { | |
| 351 | + | for _, tab := range tabs.Tabs { | |
| 352 | + | if tab.Workspace == nil { | |
| 353 | + | continue | |
| 354 | + | } | |
| 355 | + | paneNode := tab.Workspace.FindPane(paneID) | |
| 356 | + | if paneNode != nil && paneNode.Pane != nil { | |
| 357 | + | return paneNode.Pane | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | return nil | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // Find pane in first tab | |
| 364 | + | found := findPaneInTabs(tabs, "pane_in_tab1") | |
| 365 | + | require.NotNil(t, found) | |
| 366 | + | assert.Equal(t, "https://tab1.com", found.URI) | |
| 367 | + | ||
| 368 | + | // Find pane in second tab | |
| 369 | + | found = findPaneInTabs(tabs, "pane_in_tab2") | |
| 370 | + | require.NotNil(t, found) | |
| 371 | + | assert.Equal(t, "https://tab2.com", found.URI) | |
| 372 | + | ||
| 373 | + | // Update URI and verify | |
| 374 | + | found.URI = "https://updated.com" | |
| 375 | + | foundAgain := findPaneInTabs(tabs, "pane_in_tab2") | |
| 376 | + | assert.Equal(t, "https://updated.com", foundAgain.URI) | |
| 377 | + | ||
| 378 | + | // Non-existent pane | |
| 379 | + | notFound := findPaneInTabs(tabs, "non_existent") | |
| 380 | + | assert.Nil(t, notFound) | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func TestFindPaneInNestedStructure(t *testing.T) { | |
| 384 | + | // Create a tab with split panes | |
| 385 | + | pane1 := entity.NewPane("left_pane") | |
| 386 | + | pane1.URI = "https://left.com" | |
| 387 | + | ||
| 388 | + | pane2 := entity.NewPane("right_pane") | |
| 389 | + | pane2.URI = "https://right.com" | |
| 390 | + | ||
| 391 | + | // Build a split structure manually | |
| 392 | + | tab := entity.NewTab("t1", "ws1", pane1) | |
| 393 | + | ||
| 394 | + | // Add second pane as sibling (simulating a horizontal split) | |
| 395 | + | rightNode := &entity.PaneNode{ | |
| 396 | + | ID: "right_node", | |
| 397 | + | Pane: pane2, | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // Create container node | |
| 401 | + | containerNode := &entity.PaneNode{ | |
| 402 | + | ID: "container", | |
| 403 | + | SplitDir: entity.SplitHorizontal, | |
| 404 | + | Children: []*entity.PaneNode{ | |
| 405 | + | tab.Workspace.Root, // left pane | |
| 406 | + | rightNode, // right pane | |
| 407 | + | }, | |
| 408 | + | } | |
| 409 | + | tab.Workspace.Root = containerNode | |
| 410 | + | ||
| 411 | + | tabs := entity.NewTabList() | |
| 412 | + | tabs.Add(tab) | |
| 413 | + | ||
| 414 | + | // Find left pane | |
| 415 | + | leftNode := tab.Workspace.FindPane("left_pane") | |
| 416 | + | require.NotNil(t, leftNode) | |
| 417 | + | assert.Equal(t, "https://left.com", leftNode.Pane.URI) | |
| 418 | + | ||
| 419 | + | // Find right pane | |
| 420 | + | rightNodeFound := tab.Workspace.FindPane("right_pane") | |
| 421 | + | require.NotNil(t, rightNodeFound) | |
| 422 | + | assert.Equal(t, "https://right.com", rightNodeFound.Pane.URI) | |
| 423 | + | ||
| 424 | + | // Update URI on nested pane | |
| 425 | + | rightNodeFound.Pane.URI = "https://right-updated.com" | |
| 426 | + | ||
| 427 | + | // Verify update persisted | |
| 428 | + | rightNodeAgain := tab.Workspace.FindPane("right_pane") | |
| 429 | + | assert.Equal(t, "https://right-updated.com", rightNodeAgain.Pane.URI) | |
| 430 | + | } | |
M internal/domain/entity/tab.go +14 -0
| @@ -155,3 +155,17 @@ func (tl *TabList) Move(id TabID, newPos int) bool { | |||
| 155 | 155 | } | |
| 156 | 156 | return true | |
| 157 | 157 | } | |
| 158 | + | ||
| 159 | + | // ReplaceFrom replaces this TabList's contents with those from another TabList. | |
| 160 | + | // This modifies in-place so existing references to this TabList remain valid. | |
| 161 | + | func (tl *TabList) ReplaceFrom(other *TabList) { | |
| 162 | + | if other == nil { | |
| 163 | + | tl.Tabs = make([]*Tab, 0) | |
| 164 | + | tl.ActiveTabID = "" | |
| 165 | + | tl.PreviousActiveTabID = "" | |
| 166 | + | return | |
| 167 | + | } | |
| 168 | + | tl.Tabs = other.Tabs | |
| 169 | + | tl.ActiveTabID = other.ActiveTabID | |
| 170 | + | tl.PreviousActiveTabID = other.PreviousActiveTabID | |
| 171 | + | } | |
M internal/domain/repository/mocks/mocks.go +388 -0
| @@ -2746,6 +2746,63 @@ func (_m *MockSessionRepository) EXPECT() *MockSessionRepository_Expecter { | |||
| 2746 | 2746 | return &MockSessionRepository_Expecter{mock: &_m.Mock} | |
| 2747 | 2747 | } | |
| 2748 | 2748 | ||
| 2749 | + | // Delete provides a mock function for the type MockSessionRepository | |
| 2750 | + | func (_mock *MockSessionRepository) Delete(ctx context.Context, id entity.SessionID) error { | |
| 2751 | + | ret := _mock.Called(ctx, id) | |
| 2752 | + | ||
| 2753 | + | if len(ret) == 0 { | |
| 2754 | + | panic("no return value specified for Delete") | |
| 2755 | + | } | |
| 2756 | + | ||
| 2757 | + | var r0 error | |
| 2758 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) error); ok { | |
| 2759 | + | r0 = returnFunc(ctx, id) | |
| 2760 | + | } else { | |
| 2761 | + | r0 = ret.Error(0) | |
| 2762 | + | } | |
| 2763 | + | return r0 | |
| 2764 | + | } | |
| 2765 | + | ||
| 2766 | + | // MockSessionRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |
| 2767 | + | type MockSessionRepository_Delete_Call struct { | |
| 2768 | + | *mock.Call | |
| 2769 | + | } | |
| 2770 | + | ||
| 2771 | + | // Delete is a helper method to define mock.On call | |
| 2772 | + | // - ctx context.Context | |
| 2773 | + | // - id entity.SessionID | |
| 2774 | + | func (_e *MockSessionRepository_Expecter) Delete(ctx interface{}, id interface{}) *MockSessionRepository_Delete_Call { | |
| 2775 | + | return &MockSessionRepository_Delete_Call{Call: _e.mock.On("Delete", ctx, id)} | |
| 2776 | + | } | |
| 2777 | + | ||
| 2778 | + | func (_c *MockSessionRepository_Delete_Call) Run(run func(ctx context.Context, id entity.SessionID)) *MockSessionRepository_Delete_Call { | |
| 2779 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2780 | + | var arg0 context.Context | |
| 2781 | + | if args[0] != nil { | |
| 2782 | + | arg0 = args[0].(context.Context) | |
| 2783 | + | } | |
| 2784 | + | var arg1 entity.SessionID | |
| 2785 | + | if args[1] != nil { | |
| 2786 | + | arg1 = args[1].(entity.SessionID) | |
| 2787 | + | } | |
| 2788 | + | run( | |
| 2789 | + | arg0, | |
| 2790 | + | arg1, | |
| 2791 | + | ) | |
| 2792 | + | }) | |
| 2793 | + | return _c | |
| 2794 | + | } | |
| 2795 | + | ||
| 2796 | + | func (_c *MockSessionRepository_Delete_Call) Return(err error) *MockSessionRepository_Delete_Call { | |
| 2797 | + | _c.Call.Return(err) | |
| 2798 | + | return _c | |
| 2799 | + | } | |
| 2800 | + | ||
| 2801 | + | func (_c *MockSessionRepository_Delete_Call) RunAndReturn(run func(ctx context.Context, id entity.SessionID) error) *MockSessionRepository_Delete_Call { | |
| 2802 | + | _c.Call.Return(run) | |
| 2803 | + | return _c | |
| 2804 | + | } | |
| 2805 | + | ||
| 2749 | 2806 | // FindByID provides a mock function for the type MockSessionRepository | |
| 2750 | 2807 | func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) { | |
| 2751 | 2808 | ret := _mock.Called(ctx, id) | |
| @@ -3064,6 +3121,337 @@ func (_c *MockSessionRepository_Save_Call) RunAndReturn(run func(ctx context.Con | |||
| 3064 | 3121 | return _c | |
| 3065 | 3122 | } | |
| 3066 | 3123 | ||
| 3124 | + | // NewMockSessionStateRepository creates a new instance of MockSessionStateRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 3125 | + | // The first argument is typically a *testing.T value. | |
| 3126 | + | func NewMockSessionStateRepository(t interface { | |
| 3127 | + | mock.TestingT | |
| 3128 | + | Cleanup(func()) | |
| 3129 | + | }) *MockSessionStateRepository { | |
| 3130 | + | mock := &MockSessionStateRepository{} | |
| 3131 | + | mock.Mock.Test(t) | |
| 3132 | + | ||
| 3133 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 3134 | + | ||
| 3135 | + | return mock | |
| 3136 | + | } | |
| 3137 | + | ||
| 3138 | + | // MockSessionStateRepository is an autogenerated mock type for the SessionStateRepository type | |
| 3139 | + | type MockSessionStateRepository struct { | |
| 3140 | + | mock.Mock | |
| 3141 | + | } | |
| 3142 | + | ||
| 3143 | + | type MockSessionStateRepository_Expecter struct { | |
| 3144 | + | mock *mock.Mock | |
| 3145 | + | } | |
| 3146 | + | ||
| 3147 | + | func (_m *MockSessionStateRepository) EXPECT() *MockSessionStateRepository_Expecter { | |
| 3148 | + | return &MockSessionStateRepository_Expecter{mock: &_m.Mock} | |
| 3149 | + | } | |
| 3150 | + | ||
| 3151 | + | // DeleteSnapshot provides a mock function for the type MockSessionStateRepository | |
| 3152 | + | func (_mock *MockSessionStateRepository) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error { | |
| 3153 | + | ret := _mock.Called(ctx, sessionID) | |
| 3154 | + | ||
| 3155 | + | if len(ret) == 0 { | |
| 3156 | + | panic("no return value specified for DeleteSnapshot") | |
| 3157 | + | } | |
| 3158 | + | ||
| 3159 | + | var r0 error | |
| 3160 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) error); ok { | |
| 3161 | + | r0 = returnFunc(ctx, sessionID) | |
| 3162 | + | } else { | |
| 3163 | + | r0 = ret.Error(0) | |
| 3164 | + | } | |
| 3165 | + | return r0 | |
| 3166 | + | } | |
| 3167 | + | ||
| 3168 | + | // MockSessionStateRepository_DeleteSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSnapshot' | |
| 3169 | + | type MockSessionStateRepository_DeleteSnapshot_Call struct { | |
| 3170 | + | *mock.Call | |
| 3171 | + | } | |
| 3172 | + | ||
| 3173 | + | // DeleteSnapshot is a helper method to define mock.On call | |
| 3174 | + | // - ctx context.Context | |
| 3175 | + | // - sessionID entity.SessionID | |
| 3176 | + | func (_e *MockSessionStateRepository_Expecter) DeleteSnapshot(ctx interface{}, sessionID interface{}) *MockSessionStateRepository_DeleteSnapshot_Call { | |
| 3177 | + | return &MockSessionStateRepository_DeleteSnapshot_Call{Call: _e.mock.On("DeleteSnapshot", ctx, sessionID)} | |
| 3178 | + | } | |
| 3179 | + | ||
| 3180 | + | func (_c *MockSessionStateRepository_DeleteSnapshot_Call) Run(run func(ctx context.Context, sessionID entity.SessionID)) *MockSessionStateRepository_DeleteSnapshot_Call { | |
| 3181 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3182 | + | var arg0 context.Context | |
| 3183 | + | if args[0] != nil { | |
| 3184 | + | arg0 = args[0].(context.Context) | |
| 3185 | + | } | |
| 3186 | + | var arg1 entity.SessionID | |
| 3187 | + | if args[1] != nil { | |
| 3188 | + | arg1 = args[1].(entity.SessionID) | |
| 3189 | + | } | |
| 3190 | + | run( | |
| 3191 | + | arg0, | |
| 3192 | + | arg1, | |
| 3193 | + | ) | |
| 3194 | + | }) | |
| 3195 | + | return _c | |
| 3196 | + | } | |
| 3197 | + | ||
| 3198 | + | func (_c *MockSessionStateRepository_DeleteSnapshot_Call) Return(err error) *MockSessionStateRepository_DeleteSnapshot_Call { | |
| 3199 | + | _c.Call.Return(err) | |
| 3200 | + | return _c | |
| 3201 | + | } | |
| 3202 | + | ||
| 3203 | + | func (_c *MockSessionStateRepository_DeleteSnapshot_Call) RunAndReturn(run func(ctx context.Context, sessionID entity.SessionID) error) *MockSessionStateRepository_DeleteSnapshot_Call { | |
| 3204 | + | _c.Call.Return(run) | |
| 3205 | + | return _c | |
| 3206 | + | } | |
| 3207 | + | ||
| 3208 | + | // GetAllSnapshots provides a mock function for the type MockSessionStateRepository | |
| 3209 | + | func (_mock *MockSessionStateRepository) GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error) { | |
| 3210 | + | ret := _mock.Called(ctx) | |
| 3211 | + | ||
| 3212 | + | if len(ret) == 0 { | |
| 3213 | + | panic("no return value specified for GetAllSnapshots") | |
| 3214 | + | } | |
| 3215 | + | ||
| 3216 | + | var r0 []*entity.SessionState | |
| 3217 | + | var r1 error | |
| 3218 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.SessionState, error)); ok { | |
| 3219 | + | return returnFunc(ctx) | |
| 3220 | + | } | |
| 3221 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.SessionState); ok { | |
| 3222 | + | r0 = returnFunc(ctx) | |
| 3223 | + | } else { | |
| 3224 | + | if ret.Get(0) != nil { | |
| 3225 | + | r0 = ret.Get(0).([]*entity.SessionState) | |
| 3226 | + | } | |
| 3227 | + | } | |
| 3228 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 3229 | + | r1 = returnFunc(ctx) | |
| 3230 | + | } else { | |
| 3231 | + | r1 = ret.Error(1) | |
| 3232 | + | } | |
| 3233 | + | return r0, r1 | |
| 3234 | + | } | |
| 3235 | + | ||
| 3236 | + | // MockSessionStateRepository_GetAllSnapshots_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllSnapshots' | |
| 3237 | + | type MockSessionStateRepository_GetAllSnapshots_Call struct { | |
| 3238 | + | *mock.Call | |
| 3239 | + | } | |
| 3240 | + | ||
| 3241 | + | // GetAllSnapshots is a helper method to define mock.On call | |
| 3242 | + | // - ctx context.Context | |
| 3243 | + | func (_e *MockSessionStateRepository_Expecter) GetAllSnapshots(ctx interface{}) *MockSessionStateRepository_GetAllSnapshots_Call { | |
| 3244 | + | return &MockSessionStateRepository_GetAllSnapshots_Call{Call: _e.mock.On("GetAllSnapshots", ctx)} | |
| 3245 | + | } | |
| 3246 | + | ||
| 3247 | + | func (_c *MockSessionStateRepository_GetAllSnapshots_Call) Run(run func(ctx context.Context)) *MockSessionStateRepository_GetAllSnapshots_Call { | |
| 3248 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3249 | + | var arg0 context.Context | |
| 3250 | + | if args[0] != nil { | |
| 3251 | + | arg0 = args[0].(context.Context) | |
| 3252 | + | } | |
| 3253 | + | run( | |
| 3254 | + | arg0, | |
| 3255 | + | ) | |
| 3256 | + | }) | |
| 3257 | + | return _c | |
| 3258 | + | } | |
| 3259 | + | ||
| 3260 | + | func (_c *MockSessionStateRepository_GetAllSnapshots_Call) Return(sessionStates []*entity.SessionState, err error) *MockSessionStateRepository_GetAllSnapshots_Call { | |
| 3261 | + | _c.Call.Return(sessionStates, err) | |
| 3262 | + | return _c | |
| 3263 | + | } | |
| 3264 | + | ||
| 3265 | + | func (_c *MockSessionStateRepository_GetAllSnapshots_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.SessionState, error)) *MockSessionStateRepository_GetAllSnapshots_Call { | |
| 3266 | + | _c.Call.Return(run) | |
| 3267 | + | return _c | |
| 3268 | + | } | |
| 3269 | + | ||
| 3270 | + | // GetSnapshot provides a mock function for the type MockSessionStateRepository | |
| 3271 | + | func (_mock *MockSessionStateRepository) GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error) { | |
| 3272 | + | ret := _mock.Called(ctx, sessionID) | |
| 3273 | + | ||
| 3274 | + | if len(ret) == 0 { | |
| 3275 | + | panic("no return value specified for GetSnapshot") | |
| 3276 | + | } | |
| 3277 | + | ||
| 3278 | + | var r0 *entity.SessionState | |
| 3279 | + | var r1 error | |
| 3280 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) (*entity.SessionState, error)); ok { | |
| 3281 | + | return returnFunc(ctx, sessionID) | |
| 3282 | + | } | |
| 3283 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) *entity.SessionState); ok { | |
| 3284 | + | r0 = returnFunc(ctx, sessionID) | |
| 3285 | + | } else { | |
| 3286 | + | if ret.Get(0) != nil { | |
| 3287 | + | r0 = ret.Get(0).(*entity.SessionState) | |
| 3288 | + | } | |
| 3289 | + | } | |
| 3290 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, entity.SessionID) error); ok { | |
| 3291 | + | r1 = returnFunc(ctx, sessionID) | |
| 3292 | + | } else { | |
| 3293 | + | r1 = ret.Error(1) | |
| 3294 | + | } | |
| 3295 | + | return r0, r1 | |
| 3296 | + | } | |
| 3297 | + | ||
| 3298 | + | // MockSessionStateRepository_GetSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSnapshot' | |
| 3299 | + | type MockSessionStateRepository_GetSnapshot_Call struct { | |
| 3300 | + | *mock.Call | |
| 3301 | + | } | |
| 3302 | + | ||
| 3303 | + | // GetSnapshot is a helper method to define mock.On call | |
| 3304 | + | // - ctx context.Context | |
| 3305 | + | // - sessionID entity.SessionID | |
| 3306 | + | func (_e *MockSessionStateRepository_Expecter) GetSnapshot(ctx interface{}, sessionID interface{}) *MockSessionStateRepository_GetSnapshot_Call { | |
| 3307 | + | return &MockSessionStateRepository_GetSnapshot_Call{Call: _e.mock.On("GetSnapshot", ctx, sessionID)} | |
| 3308 | + | } | |
| 3309 | + | ||
| 3310 | + | func (_c *MockSessionStateRepository_GetSnapshot_Call) Run(run func(ctx context.Context, sessionID entity.SessionID)) *MockSessionStateRepository_GetSnapshot_Call { | |
| 3311 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3312 | + | var arg0 context.Context | |
| 3313 | + | if args[0] != nil { | |
| 3314 | + | arg0 = args[0].(context.Context) | |
| 3315 | + | } | |
| 3316 | + | var arg1 entity.SessionID | |
| 3317 | + | if args[1] != nil { | |
| 3318 | + | arg1 = args[1].(entity.SessionID) | |
| 3319 | + | } | |
| 3320 | + | run( | |
| 3321 | + | arg0, | |
| 3322 | + | arg1, | |
| 3323 | + | ) | |
| 3324 | + | }) | |
| 3325 | + | return _c | |
| 3326 | + | } | |
| 3327 | + | ||
| 3328 | + | func (_c *MockSessionStateRepository_GetSnapshot_Call) Return(sessionState *entity.SessionState, err error) *MockSessionStateRepository_GetSnapshot_Call { | |
| 3329 | + | _c.Call.Return(sessionState, err) | |
| 3330 | + | return _c | |
| 3331 | + | } | |
| 3332 | + | ||
| 3333 | + | func (_c *MockSessionStateRepository_GetSnapshot_Call) RunAndReturn(run func(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error)) *MockSessionStateRepository_GetSnapshot_Call { | |
| 3334 | + | _c.Call.Return(run) | |
| 3335 | + | return _c | |
| 3336 | + | } | |
| 3337 | + | ||
| 3338 | + | // GetTotalSnapshotsSize provides a mock function for the type MockSessionStateRepository | |
| 3339 | + | func (_mock *MockSessionStateRepository) GetTotalSnapshotsSize(ctx context.Context) (int64, error) { | |
| 3340 | + | ret := _mock.Called(ctx) | |
| 3341 | + | ||
| 3342 | + | if len(ret) == 0 { | |
| 3343 | + | panic("no return value specified for GetTotalSnapshotsSize") | |
| 3344 | + | } | |
| 3345 | + | ||
| 3346 | + | var r0 int64 | |
| 3347 | + | var r1 error | |
| 3348 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (int64, error)); ok { | |
| 3349 | + | return returnFunc(ctx) | |
| 3350 | + | } | |
| 3351 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) int64); ok { | |
| 3352 | + | r0 = returnFunc(ctx) | |
| 3353 | + | } else { | |
| 3354 | + | r0 = ret.Get(0).(int64) | |
| 3355 | + | } | |
| 3356 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 3357 | + | r1 = returnFunc(ctx) | |
| 3358 | + | } else { | |
| 3359 | + | r1 = ret.Error(1) | |
| 3360 | + | } | |
| 3361 | + | return r0, r1 | |
| 3362 | + | } | |
| 3363 | + | ||
| 3364 | + | // MockSessionStateRepository_GetTotalSnapshotsSize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTotalSnapshotsSize' | |
| 3365 | + | type MockSessionStateRepository_GetTotalSnapshotsSize_Call struct { | |
| 3366 | + | *mock.Call | |
| 3367 | + | } | |
| 3368 | + | ||
| 3369 | + | // GetTotalSnapshotsSize is a helper method to define mock.On call | |
| 3370 | + | // - ctx context.Context | |
| 3371 | + | func (_e *MockSessionStateRepository_Expecter) GetTotalSnapshotsSize(ctx interface{}) *MockSessionStateRepository_GetTotalSnapshotsSize_Call { | |
| 3372 | + | return &MockSessionStateRepository_GetTotalSnapshotsSize_Call{Call: _e.mock.On("GetTotalSnapshotsSize", ctx)} | |
| 3373 | + | } | |
| 3374 | + | ||
| 3375 | + | func (_c *MockSessionStateRepository_GetTotalSnapshotsSize_Call) Run(run func(ctx context.Context)) *MockSessionStateRepository_GetTotalSnapshotsSize_Call { | |
| 3376 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3377 | + | var arg0 context.Context | |
| 3378 | + | if args[0] != nil { | |
| 3379 | + | arg0 = args[0].(context.Context) | |
| 3380 | + | } | |
| 3381 | + | run( | |
| 3382 | + | arg0, | |
| 3383 | + | ) | |
| 3384 | + | }) | |
| 3385 | + | return _c | |
| 3386 | + | } | |
| 3387 | + | ||
| 3388 | + | func (_c *MockSessionStateRepository_GetTotalSnapshotsSize_Call) Return(n int64, err error) *MockSessionStateRepository_GetTotalSnapshotsSize_Call { | |
| 3389 | + | _c.Call.Return(n, err) | |
| 3390 | + | return _c | |
| 3391 | + | } | |
| 3392 | + | ||
| 3393 | + | func (_c *MockSessionStateRepository_GetTotalSnapshotsSize_Call) RunAndReturn(run func(ctx context.Context) (int64, error)) *MockSessionStateRepository_GetTotalSnapshotsSize_Call { | |
| 3394 | + | _c.Call.Return(run) | |
| 3395 | + | return _c | |
| 3396 | + | } | |
| 3397 | + | ||
| 3398 | + | // SaveSnapshot provides a mock function for the type MockSessionStateRepository | |
| 3399 | + | func (_mock *MockSessionStateRepository) SaveSnapshot(ctx context.Context, state *entity.SessionState) error { | |
| 3400 | + | ret := _mock.Called(ctx, state) | |
| 3401 | + | ||
| 3402 | + | if len(ret) == 0 { | |
| 3403 | + | panic("no return value specified for SaveSnapshot") | |
| 3404 | + | } | |
| 3405 | + | ||
| 3406 | + | var r0 error | |
| 3407 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.SessionState) error); ok { | |
| 3408 | + | r0 = returnFunc(ctx, state) | |
| 3409 | + | } else { | |
| 3410 | + | r0 = ret.Error(0) | |
| 3411 | + | } | |
| 3412 | + | return r0 | |
| 3413 | + | } | |
| 3414 | + | ||
| 3415 | + | // MockSessionStateRepository_SaveSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveSnapshot' | |
| 3416 | + | type MockSessionStateRepository_SaveSnapshot_Call struct { | |
| 3417 | + | *mock.Call | |
| 3418 | + | } | |
| 3419 | + | ||
| 3420 | + | // SaveSnapshot is a helper method to define mock.On call | |
| 3421 | + | // - ctx context.Context | |
| 3422 | + | // - state *entity.SessionState | |
| 3423 | + | func (_e *MockSessionStateRepository_Expecter) SaveSnapshot(ctx interface{}, state interface{}) *MockSessionStateRepository_SaveSnapshot_Call { | |
| 3424 | + | return &MockSessionStateRepository_SaveSnapshot_Call{Call: _e.mock.On("SaveSnapshot", ctx, state)} | |
| 3425 | + | } | |
| 3426 | + | ||
| 3427 | + | func (_c *MockSessionStateRepository_SaveSnapshot_Call) Run(run func(ctx context.Context, state *entity.SessionState)) *MockSessionStateRepository_SaveSnapshot_Call { | |
| 3428 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 3429 | + | var arg0 context.Context | |
| 3430 | + | if args[0] != nil { | |
| 3431 | + | arg0 = args[0].(context.Context) | |
| 3432 | + | } | |
| 3433 | + | var arg1 *entity.SessionState | |
| 3434 | + | if args[1] != nil { | |
| 3435 | + | arg1 = args[1].(*entity.SessionState) | |
| 3436 | + | } | |
| 3437 | + | run( | |
| 3438 | + | arg0, | |
| 3439 | + | arg1, | |
| 3440 | + | ) | |
| 3441 | + | }) | |
| 3442 | + | return _c | |
| 3443 | + | } | |
| 3444 | + | ||
| 3445 | + | func (_c *MockSessionStateRepository_SaveSnapshot_Call) Return(err error) *MockSessionStateRepository_SaveSnapshot_Call { | |
| 3446 | + | _c.Call.Return(err) | |
| 3447 | + | return _c | |
| 3448 | + | } | |
| 3449 | + | ||
| 3450 | + | func (_c *MockSessionStateRepository_SaveSnapshot_Call) RunAndReturn(run func(ctx context.Context, state *entity.SessionState) error) *MockSessionStateRepository_SaveSnapshot_Call { | |
| 3451 | + | _c.Call.Return(run) | |
| 3452 | + | return _c | |
| 3453 | + | } | |
| 3454 | + | ||
| 3067 | 3455 | // NewMockZoomRepository creates a new instance of MockZoomRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 3068 | 3456 | // The first argument is typically a *testing.T value. | |
| 3069 | 3457 | func NewMockZoomRepository(t interface { | |
M internal/domain/repository/session.go +3 -0
| @@ -16,4 +16,7 @@ type SessionRepository interface { | |||
| 16 | 16 | GetActive(ctx context.Context) (*entity.Session, error) | |
| 17 | 17 | GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) | |
| 18 | 18 | MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error | |
| 19 | + | ||
| 20 | + | // Delete removes a session record. | |
| 21 | + | Delete(ctx context.Context, id entity.SessionID) error | |
| 19 | 22 | } | |
A internal/domain/repository/session_state.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | package repository | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // SessionStateRepository persists session state snapshots. | |
| 10 | + | type SessionStateRepository interface { | |
| 11 | + | // SaveSnapshot saves or updates a session state snapshot. | |
| 12 | + | SaveSnapshot(ctx context.Context, state *entity.SessionState) error | |
| 13 | + | ||
| 14 | + | // GetSnapshot returns the latest snapshot for a session. | |
| 15 | + | GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error) | |
| 16 | + | ||
| 17 | + | // DeleteSnapshot removes a session's snapshot. | |
| 18 | + | DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error | |
| 19 | + | ||
| 20 | + | // GetAllSnapshots returns all snapshots with summary info. | |
| 21 | + | // Used by ListSessionsUseCase. | |
| 22 | + | GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error) | |
| 23 | + | ||
| 24 | + | // GetTotalSnapshotsSize returns the total size of all session snapshots in bytes. | |
| 25 | + | GetTotalSnapshotsSize(ctx context.Context) (int64, error) | |
| 26 | + | } | |
M internal/infrastructure/config/defaults.go +35 -7
| @@ -26,6 +26,13 @@ const ( | |||
| 26 | 26 | defaultTabBarPosition = "bottom" | |
| 27 | 27 | defaultPopupPlacement = "right" | |
| 28 | 28 | ||
| 29 | + | // Session defaults | |
| 30 | + | defaultSessionActivationShortcut = "ctrl+o" | |
| 31 | + | defaultSessionTimeoutMilliseconds = 3000 | |
| 32 | + | defaultSnapshotIntervalMs = 5000 | |
| 33 | + | defaultMaxExitedSessions = 10 | |
| 34 | + | defaultMaxListedSessions = 50 | |
| 35 | + | ||
| 29 | 36 | // Workspace styling defaults | |
| 30 | 37 | // Active pane border (overlay) | |
| 31 | 38 | defaultBorderWidth = 1 | |
| @@ -39,6 +46,10 @@ const ( | |||
| 39 | 46 | defaultTabModeBorderWidth = 4 | |
| 40 | 47 | defaultTabModeBorderColor = "#FFA500" // Orange for tab mode indicator | |
| 41 | 48 | ||
| 49 | + | // Session mode border (Ctrl+O - overlay) | |
| 50 | + | defaultSessionModeBorderWidth = 4 | |
| 51 | + | defaultSessionModeBorderColor = "#9B59B6" // Purple for session mode indicator | |
| 52 | + | ||
| 42 | 53 | // Other styling | |
| 43 | 54 | defaultTransitionDuration = 120 | |
| 44 | 55 | defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%) | |
| @@ -180,13 +191,15 @@ func DefaultConfig() *Config { | |||
| 180 | 191 | OAuthAutoClose: true, // Auto-close OAuth popups on success | |
| 181 | 192 | }, | |
| 182 | 193 | Styling: WorkspaceStylingConfig{ | |
| 183 | - | BorderWidth: defaultBorderWidth, | |
| 184 | - | BorderColor: defaultBorderColor, | |
| 185 | - | PaneModeBorderWidth: defaultPaneModeBorderWidth, | |
| 186 | - | PaneModeBorderColor: defaultPaneModeBorderColor, | |
| 187 | - | TabModeBorderWidth: defaultTabModeBorderWidth, | |
| 188 | - | TabModeBorderColor: defaultTabModeBorderColor, | |
| 189 | - | TransitionDuration: defaultTransitionDuration, | |
| 194 | + | BorderWidth: defaultBorderWidth, | |
| 195 | + | BorderColor: defaultBorderColor, | |
| 196 | + | PaneModeBorderWidth: defaultPaneModeBorderWidth, | |
| 197 | + | PaneModeBorderColor: defaultPaneModeBorderColor, | |
| 198 | + | TabModeBorderWidth: defaultTabModeBorderWidth, | |
| 199 | + | TabModeBorderColor: defaultTabModeBorderColor, | |
| 200 | + | SessionModeBorderWidth: defaultSessionModeBorderWidth, | |
| 201 | + | SessionModeBorderColor: defaultSessionModeBorderColor, | |
| 202 | + | TransitionDuration: defaultTransitionDuration, | |
| 190 | 203 | }, | |
| 191 | 204 | }, | |
| 192 | 205 | ContentFiltering: ContentFilteringConfig{ | |
| @@ -196,6 +209,21 @@ func DefaultConfig() *Config { | |||
| 196 | 209 | Omnibox: OmniboxConfig{ | |
| 197 | 210 | InitialBehavior: defaultOmniboxInitialBehavior, | |
| 198 | 211 | }, | |
| 212 | + | Session: SessionConfig{ | |
| 213 | + | AutoRestore: false, | |
| 214 | + | SnapshotIntervalMs: defaultSnapshotIntervalMs, | |
| 215 | + | MaxExitedSessions: defaultMaxExitedSessions, | |
| 216 | + | MaxListedSessions: defaultMaxListedSessions, | |
| 217 | + | SessionMode: SessionModeConfig{ | |
| 218 | + | ActivationShortcut: defaultSessionActivationShortcut, | |
| 219 | + | TimeoutMilliseconds: defaultSessionTimeoutMilliseconds, | |
| 220 | + | Actions: map[string][]string{ | |
| 221 | + | "session-manager": {"s", "w"}, | |
| 222 | + | "confirm": {"enter"}, | |
| 223 | + | "cancel": {"escape"}, | |
| 224 | + | }, | |
| 225 | + | }, | |
| 226 | + | }, | |
| 199 | 227 | Media: MediaConfig{ | |
| 200 | 228 | HardwareDecodingMode: HardwareDecodingAuto, // auto allows sw fallback | |
| 201 | 229 | PreferAV1: false, // Don't force codec preference, let site choose | |
M internal/infrastructure/config/loader.go +13 -0
| @@ -314,6 +314,7 @@ func (m *Manager) setDefaults() { | |||
| 314 | 314 | m.setOmniboxDefaults(defaults) | |
| 315 | 315 | m.setMediaDefaults(defaults) | |
| 316 | 316 | m.setRuntimeDefaults(defaults) | |
| 317 | + | m.setSessionDefaults(defaults) | |
| 317 | 318 | } | |
| 318 | 319 | ||
| 319 | 320 | func (m *Manager) setHistoryDefaults(defaults *Config) { | |
| @@ -402,6 +403,8 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |||
| 402 | 403 | m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor) | |
| 403 | 404 | m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth) | |
| 404 | 405 | m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor) | |
| 406 | + | m.viper.SetDefault("workspace.styling.session_mode_border_width", defaults.Workspace.Styling.SessionModeBorderWidth) | |
| 407 | + | m.viper.SetDefault("workspace.styling.session_mode_border_color", defaults.Workspace.Styling.SessionModeBorderColor) | |
| 405 | 408 | m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration) | |
| 406 | 409 | } | |
| 407 | 410 | ||
| @@ -429,6 +432,16 @@ func (m *Manager) setRuntimeDefaults(defaults *Config) { | |||
| 429 | 432 | m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix) | |
| 430 | 433 | } | |
| 431 | 434 | ||
| 435 | + | func (m *Manager) setSessionDefaults(defaults *Config) { | |
| 436 | + | m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore) | |
| 437 | + | m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs) | |
| 438 | + | m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions) | |
| 439 | + | m.viper.SetDefault("session.max_listed_sessions", defaults.Session.MaxListedSessions) | |
| 440 | + | m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut) | |
| 441 | + | m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds) | |
| 442 | + | m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions) | |
| 443 | + | } | |
| 444 | + | ||
| 432 | 445 | // New returns a new default configuration instance. | |
| 433 | 446 | // This is a convenience function for getting default config without the full manager. | |
| 434 | 447 | func New() *Config { | |
M internal/infrastructure/config/schema.go +49 -0
| @@ -17,6 +17,8 @@ type Config struct { | |||
| 17 | 17 | DefaultUIScale float64 `mapstructure:"default_ui_scale" yaml:"default_ui_scale" toml:"default_ui_scale"` | |
| 18 | 18 | // Workspace defines workspace, pane, and tab handling behavior. | |
| 19 | 19 | Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace" toml:"workspace"` | |
| 20 | + | // Session controls session persistence and restoration. | |
| 21 | + | Session SessionConfig `mapstructure:"session" yaml:"session" toml:"session"` | |
| 20 | 22 | // ContentFiltering controls ad blocking and content filtering | |
| 21 | 23 | ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"` | |
| 22 | 24 | // Omnibox controls the omnibox behavior (initial history display) | |
| @@ -401,6 +403,53 @@ type WorkspaceStylingConfig struct { | |||
| 401 | 403 | // Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor | |
| 402 | 404 | TabModeBorderColor string `mapstructure:"tab_mode_border_color" yaml:"tab_mode_border_color" toml:"tab_mode_border_color" json:"tab_mode_border_color"` //nolint:lll // struct tags must stay on one line | |
| 403 | 405 | ||
| 406 | + | // SessionModeBorderWidth in pixels for session mode indicator border (Ctrl+O overlay) | |
| 407 | + | SessionModeBorderWidth int `mapstructure:"session_mode_border_width" yaml:"session_mode_border_width" toml:"session_mode_border_width" json:"session_mode_border_width"` //nolint:lll // struct tags must stay on one line | |
| 408 | + | // SessionModeBorderColor for the session mode indicator border (CSS color value or theme variable) | |
| 409 | + | // Defaults to "#9B59B6" (purple) if not set - MUST be different from other mode colors | |
| 410 | + | SessionModeBorderColor string `mapstructure:"session_mode_border_color" yaml:"session_mode_border_color" toml:"session_mode_border_color" json:"session_mode_border_color"` //nolint:lll // struct tags must stay on one line | |
| 411 | + | ||
| 404 | 412 | // TransitionDuration in milliseconds for border animations | |
| 405 | 413 | TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line | |
| 406 | 414 | } | |
| 415 | + | ||
| 416 | + | // SessionConfig holds session persistence settings. | |
| 417 | + | type SessionConfig struct { | |
| 418 | + | // AutoRestore automatically restores the last session on startup. | |
| 419 | + | // Default: false | |
| 420 | + | AutoRestore bool `mapstructure:"auto_restore" yaml:"auto_restore" toml:"auto_restore" json:"auto_restore"` | |
| 421 | + | ||
| 422 | + | // SnapshotIntervalMs is the minimum interval between snapshots in milliseconds. | |
| 423 | + | // Default: 5000 | |
| 424 | + | SnapshotIntervalMs int `mapstructure:"snapshot_interval_ms" yaml:"snapshot_interval_ms" toml:"snapshot_interval_ms" json:"snapshot_interval_ms"` //nolint:lll // struct tags must stay on one line | |
| 425 | + | ||
| 426 | + | // MaxExitedSessions is the maximum number of exited sessions to keep. | |
| 427 | + | // Default: 10 | |
| 428 | + | MaxExitedSessions int `mapstructure:"max_exited_sessions" yaml:"max_exited_sessions" toml:"max_exited_sessions" json:"max_exited_sessions"` //nolint:lll // struct tags must stay on one line | |
| 429 | + | ||
| 430 | + | // MaxListedSessions is the maximum number of sessions to list in CLI/TUI. | |
| 431 | + | // Default: 50 | |
| 432 | + | MaxListedSessions int `mapstructure:"max_listed_sessions" yaml:"max_listed_sessions" toml:"max_listed_sessions" json:"max_listed_sessions"` //nolint:lll // struct tags must stay on one line | |
| 433 | + | ||
| 434 | + | // SessionMode defines modal behavior for session management (Ctrl+O). | |
| 435 | + | SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"` | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | // SessionModeConfig defines modal behavior for session management. | |
| 439 | + | type SessionModeConfig struct { | |
| 440 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | |
| 441 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | |
| 442 | + | Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | // GetKeyBindings returns an inverted map for O(1) key→action lookup. | |
| 446 | + | // This is built from the action→keys structure in the config. | |
| 447 | + | func (s *SessionModeConfig) GetKeyBindings() map[string]string { | |
| 448 | + | keyToAction := make(map[string]string) | |
| 449 | + | for action, keys := range s.Actions { | |
| 450 | + | for _, key := range keys { | |
| 451 | + | keyToAction[key] = action | |
| 452 | + | } | |
| 453 | + | } | |
| 454 | + | return keyToAction | |
| 455 | + | } | |
M internal/infrastructure/desktop/adapter.go +54 -0
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "strings" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| @@ -348,3 +349,56 @@ func (a *Adapter) UnsetAsDefaultBrowser(ctx context.Context) error { | |||
| 348 | 349 | log.Warn().Msg("dumber was default browser - user should set a new default") | |
| 349 | 350 | return nil | |
| 350 | 351 | } | |
| 352 | + | ||
| 353 | + | // SessionSpawner implements port.SessionSpawner by launching a new dumber process. | |
| 354 | + | type SessionSpawner struct { | |
| 355 | + | ctx context.Context | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | // NewSessionSpawner creates a new session spawner. | |
| 359 | + | func NewSessionSpawner(ctx context.Context) *SessionSpawner { | |
| 360 | + | return &SessionSpawner{ctx: ctx} | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration. | |
| 364 | + | const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION" | |
| 365 | + | ||
| 366 | + | // SpawnWithSession starts a new dumber instance to restore a session. | |
| 367 | + | func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | |
| 368 | + | log := logging.FromContext(s.ctx) | |
| 369 | + | ||
| 370 | + | execPath, err := getExecutablePath() | |
| 371 | + | if err != nil { | |
| 372 | + | return fmt.Errorf("get executable path: %w", err) | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | // Start dumber browse with session ID in environment | |
| 376 | + | cmd := exec.Command(execPath, "browse") | |
| 377 | + | ||
| 378 | + | // Inherit environment and add restore session ID | |
| 379 | + | cmd.Env = append(os.Environ(), RestoreSessionEnvVar+"="+string(sessionID)) | |
| 380 | + | ||
| 381 | + | // Detach from current process group so the new process survives | |
| 382 | + | cmd.Stdin = nil | |
| 383 | + | cmd.Stdout = nil | |
| 384 | + | cmd.Stderr = nil | |
| 385 | + | ||
| 386 | + | if err := cmd.Start(); err != nil { | |
| 387 | + | return fmt.Errorf("spawn session: %w", err) | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | // Capture PID before releasing | |
| 391 | + | pid := cmd.Process.Pid | |
| 392 | + | ||
| 393 | + | // Release the process so it continues running after we close | |
| 394 | + | if err := cmd.Process.Release(); err != nil { | |
| 395 | + | log.Warn().Err(err).Msg("failed to release spawned process (non-fatal)") | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | log.Info(). | |
| 399 | + | Str("session_id", string(sessionID)). | |
| 400 | + | Int("pid", pid). | |
| 401 | + | Msg("spawned dumber with session restoration") | |
| 402 | + | ||
| 403 | + | return nil | |
| 404 | + | } | |
A internal/infrastructure/persistence/sqlite/migrations/006_session_states.sql +14 -0
| @@ -0,0 +1,14 @@ | |||
| 1 | + | -- +goose Up | |
| 2 | + | CREATE TABLE session_states ( | |
| 3 | + | session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, | |
| 4 | + | state_json TEXT NOT NULL, | |
| 5 | + | version INTEGER NOT NULL DEFAULT 1, | |
| 6 | + | tab_count INTEGER NOT NULL DEFAULT 0, | |
| 7 | + | pane_count INTEGER NOT NULL DEFAULT 0, | |
| 8 | + | updated_at TIMESTAMP NOT NULL | |
| 9 | + | ); | |
| 10 | + | ||
| 11 | + | CREATE INDEX idx_session_states_updated_at ON session_states(updated_at DESC); | |
| 12 | + | ||
| 13 | + | -- +goose Down | |
| 14 | + | DROP TABLE session_states; | |
A internal/infrastructure/persistence/sqlite/queries/session_states.sql +36 -0
| @@ -0,0 +1,36 @@ | |||
| 1 | + | -- name: UpsertSessionState :exec | |
| 2 | + | INSERT INTO session_states (session_id, state_json, version, tab_count, pane_count, updated_at) | |
| 3 | + | VALUES (?, ?, ?, ?, ?, ?) | |
| 4 | + | ON CONFLICT(session_id) DO UPDATE SET | |
| 5 | + | state_json = excluded.state_json, | |
| 6 | + | version = excluded.version, | |
| 7 | + | tab_count = excluded.tab_count, | |
| 8 | + | pane_count = excluded.pane_count, | |
| 9 | + | updated_at = excluded.updated_at; | |
| 10 | + | ||
| 11 | + | -- name: GetSessionState :one | |
| 12 | + | SELECT session_id, state_json, version, tab_count, pane_count, updated_at | |
| 13 | + | FROM session_states | |
| 14 | + | WHERE session_id = ?; | |
| 15 | + | ||
| 16 | + | -- name: GetAllSessionStates :many | |
| 17 | + | SELECT session_id, state_json, version, tab_count, pane_count, updated_at | |
| 18 | + | FROM session_states | |
| 19 | + | ORDER BY updated_at DESC; | |
| 20 | + | ||
| 21 | + | -- name: DeleteSessionState :exec | |
| 22 | + | DELETE FROM session_states WHERE session_id = ?; | |
| 23 | + | ||
| 24 | + | -- name: GetSessionsWithState :many | |
| 25 | + | SELECT | |
| 26 | + | s.id, s.type, s.started_at, s.ended_at, | |
| 27 | + | ss.state_json, ss.tab_count, ss.pane_count, ss.updated_at as state_updated_at | |
| 28 | + | FROM sessions s | |
| 29 | + | LEFT JOIN session_states ss ON s.id = ss.session_id | |
| 30 | + | WHERE s.type = 'browser' | |
| 31 | + | ORDER BY s.started_at DESC | |
| 32 | + | LIMIT ?; | |
| 33 | + | ||
| 34 | + | -- name: GetTotalSessionStatesSize :one | |
| 35 | + | SELECT COALESCE(SUM(LENGTH(state_json)), 0) as total_size | |
| 36 | + | FROM session_states; | |
M internal/infrastructure/persistence/sqlite/queries/sessions.sql +3 -0
| @@ -20,3 +20,6 @@ LIMIT ?; | |||
| 20 | 20 | UPDATE sessions | |
| 21 | 21 | SET ended_at = ? | |
| 22 | 22 | WHERE id = ?; | |
| 23 | + | ||
| 24 | + | -- name: DeleteSession :exec | |
| 25 | + | DELETE FROM sessions WHERE id = ?; | |
M internal/infrastructure/persistence/sqlite/session_repo.go +6 -0
| @@ -87,6 +87,12 @@ func (r *sessionRepo) MarkEnded(ctx context.Context, id entity.SessionID, endedA | |||
| 87 | 87 | }) | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | + | func (r *sessionRepo) Delete(ctx context.Context, id entity.SessionID) error { | |
| 91 | + | log := logging.FromContext(ctx) | |
| 92 | + | log.Debug().Str("session_id", string(id)).Msg("deleting session") | |
| 93 | + | return r.queries.DeleteSession(ctx, string(id)) | |
| 94 | + | } | |
| 95 | + | ||
| 90 | 96 | func sessionFromRow(row sqlc.Session) *entity.Session { | |
| 91 | 97 | var endedAt *time.Time | |
| 92 | 98 | if row.EndedAt.Valid { | |
A internal/infrastructure/persistence/sqlite/session_state_repo.go +118 -0
| @@ -0,0 +1,118 @@ | |||
| 1 | + | package sqlite | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "database/sql" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc" | |
| 12 | + | "github.com/bnema/dumber/internal/logging" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type sessionStateRepo struct { | |
| 16 | + | queries *sqlc.Queries | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // NewSessionStateRepository creates a new session state repository. | |
| 20 | + | func NewSessionStateRepository(db *sql.DB) repository.SessionStateRepository { | |
| 21 | + | return &sessionStateRepo{queries: sqlc.New(db)} | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // SaveSnapshot saves or updates a session state snapshot. | |
| 25 | + | func (r *sessionStateRepo) SaveSnapshot(ctx context.Context, state *entity.SessionState) error { | |
| 26 | + | log := logging.FromContext(ctx) | |
| 27 | + | if state == nil { | |
| 28 | + | return errors.New("session state cannot be nil") | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | stateJSON, err := json.Marshal(state) | |
| 32 | + | if err != nil { | |
| 33 | + | log.Error().Err(err).Msg("failed to marshal session state") | |
| 34 | + | return err | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | log.Debug(). | |
| 38 | + | Str("session_id", string(state.SessionID)). | |
| 39 | + | Int("tab_count", len(state.Tabs)). | |
| 40 | + | Int("pane_count", state.CountPanes()). | |
| 41 | + | Msg("saving session state snapshot") | |
| 42 | + | ||
| 43 | + | return r.queries.UpsertSessionState(ctx, sqlc.UpsertSessionStateParams{ | |
| 44 | + | SessionID: string(state.SessionID), | |
| 45 | + | StateJson: string(stateJSON), | |
| 46 | + | Version: int64(state.Version), | |
| 47 | + | TabCount: int64(len(state.Tabs)), | |
| 48 | + | PaneCount: int64(state.CountPanes()), | |
| 49 | + | UpdatedAt: state.SavedAt, | |
| 50 | + | }) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // GetSnapshot returns the latest snapshot for a session. | |
| 54 | + | func (r *sessionStateRepo) GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error) { | |
| 55 | + | row, err := r.queries.GetSessionState(ctx, string(sessionID)) | |
| 56 | + | if err != nil { | |
| 57 | + | if errors.Is(err, sql.ErrNoRows) { | |
| 58 | + | return nil, nil | |
| 59 | + | } | |
| 60 | + | return nil, err | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | var state entity.SessionState | |
| 64 | + | if err := json.Unmarshal([]byte(row.StateJson), &state); err != nil { | |
| 65 | + | logging.FromContext(ctx).Error().Err(err). | |
| 66 | + | Str("session_id", string(sessionID)). | |
| 67 | + | Msg("failed to unmarshal session state") | |
| 68 | + | return nil, err | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | return &state, nil | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // DeleteSnapshot removes a session's snapshot. | |
| 75 | + | func (r *sessionStateRepo) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error { | |
| 76 | + | log := logging.FromContext(ctx) | |
| 77 | + | log.Debug().Str("session_id", string(sessionID)).Msg("deleting session state snapshot") | |
| 78 | + | return r.queries.DeleteSessionState(ctx, string(sessionID)) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // GetAllSnapshots returns all snapshots with summary info. | |
| 82 | + | func (r *sessionStateRepo) GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error) { | |
| 83 | + | rows, err := r.queries.GetAllSessionStates(ctx) | |
| 84 | + | if err != nil { | |
| 85 | + | return nil, err | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | states := make([]*entity.SessionState, 0, len(rows)) | |
| 89 | + | for _, row := range rows { | |
| 90 | + | var state entity.SessionState | |
| 91 | + | if err := json.Unmarshal([]byte(row.StateJson), &state); err != nil { | |
| 92 | + | logging.FromContext(ctx).Warn().Err(err). | |
| 93 | + | Str("session_id", row.SessionID). | |
| 94 | + | Msg("skipping corrupted session state") | |
| 95 | + | continue | |
| 96 | + | } | |
| 97 | + | states = append(states, &state) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | return states, nil | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // GetTotalSnapshotsSize returns the total size of all session snapshots in bytes. | |
| 104 | + | func (r *sessionStateRepo) GetTotalSnapshotsSize(ctx context.Context) (int64, error) { | |
| 105 | + | result, err := r.queries.GetTotalSessionStatesSize(ctx) | |
| 106 | + | if err != nil { | |
| 107 | + | return 0, err | |
| 108 | + | } | |
| 109 | + | // SQLite returns int64 for SUM, but COALESCE wraps it as interface{} | |
| 110 | + | switch v := result.(type) { | |
| 111 | + | case int64: | |
| 112 | + | return v, nil | |
| 113 | + | case float64: | |
| 114 | + | return int64(v), nil | |
| 115 | + | default: | |
| 116 | + | return 0, nil | |
| 117 | + | } | |
| 118 | + | } | |
M internal/infrastructure/persistence/sqlite/sqlc/models.go +9 -0
| @@ -70,6 +70,15 @@ type Session struct { | |||
| 70 | 70 | EndedAt sql.NullTime `json:"ended_at"` | |
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | + | type SessionState struct { | |
| 74 | + | SessionID string `json:"session_id"` | |
| 75 | + | StateJson string `json:"state_json"` | |
| 76 | + | Version int64 `json:"version"` | |
| 77 | + | TabCount int64 `json:"tab_count"` | |
| 78 | + | PaneCount int64 `json:"pane_count"` | |
| 79 | + | UpdatedAt time.Time `json:"updated_at"` | |
| 80 | + | } | |
| 81 | + | ||
| 73 | 82 | type ZoomLevel struct { | |
| 74 | 83 | Domain string `json:"domain"` | |
| 75 | 84 | ZoomFactor float64 `json:"zoom_factor"` | |
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +7 -0
| @@ -21,11 +21,14 @@ type Querier interface { | |||
| 21 | 21 | DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error | |
| 22 | 22 | DeleteHistoryByID(ctx context.Context, id int64) error | |
| 23 | 23 | DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error | |
| 24 | + | DeleteSession(ctx context.Context, id string) error | |
| 25 | + | DeleteSessionState(ctx context.Context, sessionID string) error | |
| 24 | 26 | DeleteTag(ctx context.Context, id int64) error | |
| 25 | 27 | DeleteZoomLevel(ctx context.Context, domain string) error | |
| 26 | 28 | GetActiveBrowserSession(ctx context.Context) (Session, error) | |
| 27 | 29 | GetAllFavorites(ctx context.Context) ([]Favorite, error) | |
| 28 | 30 | GetAllFolders(ctx context.Context) ([]FavoriteFolder, error) | |
| 31 | + | GetAllSessionStates(ctx context.Context) ([]SessionState, error) | |
| 29 | 32 | GetAllTags(ctx context.Context) ([]FavoriteTag, error) | |
| 30 | 33 | GetAllWhitelistedDomains(ctx context.Context) ([]string, error) | |
| 31 | 34 | GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error) | |
| @@ -44,9 +47,12 @@ type Querier interface { | |||
| 44 | 47 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | |
| 45 | 48 | GetRootFolders(ctx context.Context) ([]FavoriteFolder, error) | |
| 46 | 49 | GetSessionByID(ctx context.Context, id string) (Session, error) | |
| 50 | + | GetSessionState(ctx context.Context, sessionID string) (SessionState, error) | |
| 51 | + | GetSessionsWithState(ctx context.Context, limit int64) ([]GetSessionsWithStateRow, error) | |
| 47 | 52 | GetTagByID(ctx context.Context, id int64) (FavoriteTag, error) | |
| 48 | 53 | GetTagByName(ctx context.Context, name string) (FavoriteTag, error) | |
| 49 | 54 | GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error) | |
| 55 | + | GetTotalSessionStatesSize(ctx context.Context) (interface{}, error) | |
| 50 | 56 | GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error) | |
| 51 | 57 | IncrementVisitCount(ctx context.Context, url string) error | |
| 52 | 58 | InsertSession(ctx context.Context, arg InsertSessionParams) error | |
| @@ -66,6 +72,7 @@ type Querier interface { | |||
| 66 | 72 | UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error | |
| 67 | 73 | UpdateTag(ctx context.Context, arg UpdateTagParams) error | |
| 68 | 74 | UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error | |
| 75 | + | UpsertSessionState(ctx context.Context, arg UpsertSessionStateParams) error | |
| 69 | 76 | } | |
| 70 | 77 | ||
| 71 | 78 | var _ Querier = (*Queries)(nil) | |
A internal/infrastructure/persistence/sqlite/sqlc/session_states.sql.go +175 -0
| @@ -0,0 +1,175 @@ | |||
| 1 | + | // Code generated by sqlc. DO NOT EDIT. | |
| 2 | + | // versions: | |
| 3 | + | // sqlc v1.30.0 | |
| 4 | + | // source: session_states.sql | |
| 5 | + | ||
| 6 | + | package sqlc | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "context" | |
| 10 | + | "database/sql" | |
| 11 | + | "time" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const DeleteSessionState = `-- name: DeleteSessionState :exec | |
| 15 | + | DELETE FROM session_states WHERE session_id = ? | |
| 16 | + | ` | |
| 17 | + | ||
| 18 | + | func (q *Queries) DeleteSessionState(ctx context.Context, sessionID string) error { | |
| 19 | + | _, err := q.db.ExecContext(ctx, DeleteSessionState, sessionID) | |
| 20 | + | return err | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | const GetAllSessionStates = `-- name: GetAllSessionStates :many | |
| 24 | + | SELECT session_id, state_json, version, tab_count, pane_count, updated_at | |
| 25 | + | FROM session_states | |
| 26 | + | ORDER BY updated_at DESC | |
| 27 | + | ` | |
| 28 | + | ||
| 29 | + | func (q *Queries) GetAllSessionStates(ctx context.Context) ([]SessionState, error) { | |
| 30 | + | rows, err := q.db.QueryContext(ctx, GetAllSessionStates) | |
| 31 | + | if err != nil { | |
| 32 | + | return nil, err | |
| 33 | + | } | |
| 34 | + | defer rows.Close() | |
| 35 | + | items := []SessionState{} | |
| 36 | + | for rows.Next() { | |
| 37 | + | var i SessionState | |
| 38 | + | if err := rows.Scan( | |
| 39 | + | &i.SessionID, | |
| 40 | + | &i.StateJson, | |
| 41 | + | &i.Version, | |
| 42 | + | &i.TabCount, | |
| 43 | + | &i.PaneCount, | |
| 44 | + | &i.UpdatedAt, | |
| 45 | + | ); err != nil { | |
| 46 | + | return nil, err | |
| 47 | + | } | |
| 48 | + | items = append(items, i) | |
| 49 | + | } | |
| 50 | + | if err := rows.Close(); err != nil { | |
| 51 | + | return nil, err | |
| 52 | + | } | |
| 53 | + | if err := rows.Err(); err != nil { | |
| 54 | + | return nil, err | |
| 55 | + | } | |
| 56 | + | return items, nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | const GetSessionState = `-- name: GetSessionState :one | |
| 60 | + | SELECT session_id, state_json, version, tab_count, pane_count, updated_at | |
| 61 | + | FROM session_states | |
| 62 | + | WHERE session_id = ? | |
| 63 | + | ` | |
| 64 | + | ||
| 65 | + | func (q *Queries) GetSessionState(ctx context.Context, sessionID string) (SessionState, error) { | |
| 66 | + | row := q.db.QueryRowContext(ctx, GetSessionState, sessionID) | |
| 67 | + | var i SessionState | |
| 68 | + | err := row.Scan( | |
| 69 | + | &i.SessionID, | |
| 70 | + | &i.StateJson, | |
| 71 | + | &i.Version, | |
| 72 | + | &i.TabCount, | |
| 73 | + | &i.PaneCount, | |
| 74 | + | &i.UpdatedAt, | |
| 75 | + | ) | |
| 76 | + | return i, err | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | const GetSessionsWithState = `-- name: GetSessionsWithState :many | |
| 80 | + | SELECT | |
| 81 | + | s.id, s.type, s.started_at, s.ended_at, | |
| 82 | + | ss.state_json, ss.tab_count, ss.pane_count, ss.updated_at as state_updated_at | |
| 83 | + | FROM sessions s | |
| 84 | + | LEFT JOIN session_states ss ON s.id = ss.session_id | |
| 85 | + | WHERE s.type = 'browser' | |
| 86 | + | ORDER BY s.started_at DESC | |
| 87 | + | LIMIT ? | |
| 88 | + | ` | |
| 89 | + | ||
| 90 | + | type GetSessionsWithStateRow struct { | |
| 91 | + | ID string `json:"id"` | |
| 92 | + | Type string `json:"type"` | |
| 93 | + | StartedAt time.Time `json:"started_at"` | |
| 94 | + | EndedAt sql.NullTime `json:"ended_at"` | |
| 95 | + | StateJson sql.NullString `json:"state_json"` | |
| 96 | + | TabCount sql.NullInt64 `json:"tab_count"` | |
| 97 | + | PaneCount sql.NullInt64 `json:"pane_count"` | |
| 98 | + | StateUpdatedAt sql.NullTime `json:"state_updated_at"` | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (q *Queries) GetSessionsWithState(ctx context.Context, limit int64) ([]GetSessionsWithStateRow, error) { | |
| 102 | + | rows, err := q.db.QueryContext(ctx, GetSessionsWithState, limit) | |
| 103 | + | if err != nil { | |
| 104 | + | return nil, err | |
| 105 | + | } | |
| 106 | + | defer rows.Close() | |
| 107 | + | items := []GetSessionsWithStateRow{} | |
| 108 | + | for rows.Next() { | |
| 109 | + | var i GetSessionsWithStateRow | |
| 110 | + | if err := rows.Scan( | |
| 111 | + | &i.ID, | |
| 112 | + | &i.Type, | |
| 113 | + | &i.StartedAt, | |
| 114 | + | &i.EndedAt, | |
| 115 | + | &i.StateJson, | |
| 116 | + | &i.TabCount, | |
| 117 | + | &i.PaneCount, | |
| 118 | + | &i.StateUpdatedAt, | |
| 119 | + | ); err != nil { | |
| 120 | + | return nil, err | |
| 121 | + | } | |
| 122 | + | items = append(items, i) | |
| 123 | + | } | |
| 124 | + | if err := rows.Close(); err != nil { | |
| 125 | + | return nil, err | |
| 126 | + | } | |
| 127 | + | if err := rows.Err(); err != nil { | |
| 128 | + | return nil, err | |
| 129 | + | } | |
| 130 | + | return items, nil | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | const GetTotalSessionStatesSize = `-- name: GetTotalSessionStatesSize :one | |
| 134 | + | SELECT COALESCE(SUM(LENGTH(state_json)), 0) as total_size | |
| 135 | + | FROM session_states | |
| 136 | + | ` | |
| 137 | + | ||
| 138 | + | func (q *Queries) GetTotalSessionStatesSize(ctx context.Context) (interface{}, error) { | |
| 139 | + | row := q.db.QueryRowContext(ctx, GetTotalSessionStatesSize) | |
| 140 | + | var total_size interface{} | |
| 141 | + | err := row.Scan(&total_size) | |
| 142 | + | return total_size, err | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | const UpsertSessionState = `-- name: UpsertSessionState :exec | |
| 146 | + | INSERT INTO session_states (session_id, state_json, version, tab_count, pane_count, updated_at) | |
| 147 | + | VALUES (?, ?, ?, ?, ?, ?) | |
| 148 | + | ON CONFLICT(session_id) DO UPDATE SET | |
| 149 | + | state_json = excluded.state_json, | |
| 150 | + | version = excluded.version, | |
| 151 | + | tab_count = excluded.tab_count, | |
| 152 | + | pane_count = excluded.pane_count, | |
| 153 | + | updated_at = excluded.updated_at | |
| 154 | + | ` | |
| 155 | + | ||
| 156 | + | type UpsertSessionStateParams struct { | |
| 157 | + | SessionID string `json:"session_id"` | |
| 158 | + | StateJson string `json:"state_json"` | |
| 159 | + | Version int64 `json:"version"` | |
| 160 | + | TabCount int64 `json:"tab_count"` | |
| 161 | + | PaneCount int64 `json:"pane_count"` | |
| 162 | + | UpdatedAt time.Time `json:"updated_at"` | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (q *Queries) UpsertSessionState(ctx context.Context, arg UpsertSessionStateParams) error { | |
| 166 | + | _, err := q.db.ExecContext(ctx, UpsertSessionState, | |
| 167 | + | arg.SessionID, | |
| 168 | + | arg.StateJson, | |
| 169 | + | arg.Version, | |
| 170 | + | arg.TabCount, | |
| 171 | + | arg.PaneCount, | |
| 172 | + | arg.UpdatedAt, | |
| 173 | + | ) | |
| 174 | + | return err | |
| 175 | + | } | |
M internal/infrastructure/persistence/sqlite/sqlc/sessions.sql.go +9 -0
| @@ -11,6 +11,15 @@ import ( | |||
| 11 | 11 | "time" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | + | const DeleteSession = `-- name: DeleteSession :exec | |
| 15 | + | DELETE FROM sessions WHERE id = ? | |
| 16 | + | ` | |
| 17 | + | ||
| 18 | + | func (q *Queries) DeleteSession(ctx context.Context, id string) error { | |
| 19 | + | _, err := q.db.ExecContext(ctx, DeleteSession, id) | |
| 20 | + | return err | |
| 21 | + | } | |
| 22 | + | ||
| 14 | 23 | const GetActiveBrowserSession = `-- name: GetActiveBrowserSession :one | |
| 15 | 24 | SELECT id, type, started_at, ended_at FROM sessions | |
| 16 | 25 | WHERE type = 'browser' AND ended_at IS NULL | |
A internal/infrastructure/snapshot/service.go +128 -0
| @@ -0,0 +1,128 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // Service handles debounced session state snapshots. | |
| 14 | + | type Service struct { | |
| 15 | + | snapshotUC *usecase.SnapshotSessionUseCase | |
| 16 | + | provider port.TabListProvider | |
| 17 | + | interval time.Duration | |
| 18 | + | ||
| 19 | + | mu sync.Mutex | |
| 20 | + | timer *time.Timer | |
| 21 | + | dirty bool | |
| 22 | + | ctx context.Context | |
| 23 | + | cancel context.CancelFunc | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // NewService creates a new snapshot service. | |
| 27 | + | func NewService( | |
| 28 | + | snapshotUC *usecase.SnapshotSessionUseCase, | |
| 29 | + | provider port.TabListProvider, | |
| 30 | + | intervalMs int, | |
| 31 | + | ) *Service { | |
| 32 | + | if intervalMs <= 0 { | |
| 33 | + | intervalMs = 5000 // Default 5 seconds | |
| 34 | + | } | |
| 35 | + | return &Service{ | |
| 36 | + | snapshotUC: snapshotUC, | |
| 37 | + | provider: provider, | |
| 38 | + | interval: time.Duration(intervalMs) * time.Millisecond, | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // Start begins watching for dirty state. | |
| 43 | + | func (s *Service) Start(ctx context.Context) { | |
| 44 | + | s.mu.Lock() | |
| 45 | + | defer s.mu.Unlock() | |
| 46 | + | ||
| 47 | + | s.ctx, s.cancel = context.WithCancel(ctx) | |
| 48 | + | logging.FromContext(ctx).Debug().Dur("interval", s.interval).Msg("snapshot service started") | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Stop stops the service and saves final state. | |
| 52 | + | func (s *Service) Stop(ctx context.Context) error { | |
| 53 | + | s.mu.Lock() | |
| 54 | + | if s.cancel != nil { | |
| 55 | + | s.cancel() | |
| 56 | + | } | |
| 57 | + | if s.timer != nil { | |
| 58 | + | s.timer.Stop() | |
| 59 | + | s.timer = nil | |
| 60 | + | } | |
| 61 | + | s.mu.Unlock() | |
| 62 | + | ||
| 63 | + | // Final save on shutdown | |
| 64 | + | return s.SaveNow(ctx) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // MarkDirty signals that state has changed. | |
| 68 | + | // Debounces saves to avoid excessive DB writes. | |
| 69 | + | func (s *Service) MarkDirty() { | |
| 70 | + | s.mu.Lock() | |
| 71 | + | defer s.mu.Unlock() | |
| 72 | + | ||
| 73 | + | s.dirty = true | |
| 74 | + | ||
| 75 | + | // Reset or create timer | |
| 76 | + | if s.timer != nil { | |
| 77 | + | s.timer.Stop() | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | s.timer = time.AfterFunc(s.interval, func() { | |
| 81 | + | s.mu.Lock() | |
| 82 | + | ctx := s.ctx | |
| 83 | + | s.mu.Unlock() | |
| 84 | + | ||
| 85 | + | if ctx == nil { | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | if err := s.saveSnapshot(ctx); err != nil { | |
| 90 | + | logging.FromContext(ctx).Error().Err(err).Msg("failed to save session snapshot") | |
| 91 | + | } | |
| 92 | + | }) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // SaveNow forces immediate save (for shutdown). | |
| 96 | + | func (s *Service) SaveNow(ctx context.Context) error { | |
| 97 | + | s.mu.Lock() | |
| 98 | + | if s.timer != nil { | |
| 99 | + | s.timer.Stop() | |
| 100 | + | s.timer = nil | |
| 101 | + | } | |
| 102 | + | dirty := s.dirty | |
| 103 | + | s.mu.Unlock() | |
| 104 | + | ||
| 105 | + | if !dirty { | |
| 106 | + | return nil | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | return s.saveSnapshot(ctx) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (s *Service) saveSnapshot(ctx context.Context) error { | |
| 113 | + | s.mu.Lock() | |
| 114 | + | s.dirty = false | |
| 115 | + | s.mu.Unlock() | |
| 116 | + | ||
| 117 | + | tabList := s.provider.GetTabList() | |
| 118 | + | sessionID := s.provider.GetSessionID() | |
| 119 | + | ||
| 120 | + | if sessionID == "" { | |
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | return s.snapshotUC.Execute(ctx, usecase.SnapshotInput{ | |
| 125 | + | SessionID: sessionID, | |
| 126 | + | TabList: tabList, | |
| 127 | + | }) | |
| 128 | + | } | |
M internal/ui/app.go +254 -16
| @@ -11,7 +11,9 @@ import ( | |||
| 11 | 11 | "github.com/bnema/dumber/internal/application/usecase" | |
| 12 | 12 | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | 13 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 14 | + | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 14 | 15 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/snapshot" | |
| 15 | 17 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 16 | 18 | ||
| 17 | 19 | "github.com/bnema/dumber/internal/infrastructure/webkit/handlers" | |
| @@ -84,6 +86,10 @@ type App struct { | |||
| 84 | 86 | // App-level toaster for system notifications (filter status, etc.) | |
| 85 | 87 | appToaster *component.Toaster | |
| 86 | 88 | ||
| 89 | + | // Session management | |
| 90 | + | sessionManager *component.SessionManager | |
| 91 | + | snapshotService *snapshot.Service | |
| 92 | + | ||
| 87 | 93 | // ID generator for tabs/panes | |
| 88 | 94 | idCounter uint64 | |
| 89 | 95 | idMu sync.Mutex | |
| @@ -183,6 +189,8 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 183 | 189 | a.initKeyboardHandler(ctx) | |
| 184 | 190 | a.initOmniboxConfig(ctx) | |
| 185 | 191 | a.initFindBarConfig() | |
| 192 | + | a.initSessionManager(ctx) | |
| 193 | + | a.initSnapshotService(ctx) | |
| 186 | 194 | a.createInitialTab(ctx) | |
| 187 | 195 | a.finalizeActivation(ctx) | |
| 188 | 196 | } | |
| @@ -295,10 +303,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 295 | 303 | } | |
| 296 | 304 | ||
| 297 | 305 | // Create keyboard handler and wire to dispatcher. | |
| 298 | - | a.keyboardHandler = input.NewKeyboardHandler( | |
| 299 | - | ctx, | |
| 300 | - | &a.deps.Config.Workspace, | |
| 301 | - | ) | |
| 306 | + | a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config) | |
| 302 | 307 | a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error { | |
| 303 | 308 | return a.kbDispatcher.Dispatch(ctx, action) | |
| 304 | 309 | }) | |
| @@ -306,6 +311,10 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 306 | 311 | a.handleModeChange(ctx, from, to) | |
| 307 | 312 | }) | |
| 308 | 313 | a.keyboardHandler.SetShouldBypassInput(func() bool { | |
| 314 | + | // Bypass keyboard handler when session manager is visible | |
| 315 | + | if a.sessionManager != nil && a.sessionManager.IsVisible() { | |
| 316 | + | return true | |
| 317 | + | } | |
| 309 | 318 | wsView := a.activeWorkspaceView() | |
| 310 | 319 | if wsView == nil { | |
| 311 | 320 | return false | |
| @@ -369,9 +378,145 @@ func (a *App) initFindBarConfig() { | |||
| 369 | 378 | } | |
| 370 | 379 | } | |
| 371 | 380 | ||
| 381 | + | func (a *App) initSessionManager(ctx context.Context) { | |
| 382 | + | log := logging.FromContext(ctx) | |
| 383 | + | ||
| 384 | + | if a.deps == nil { | |
| 385 | + | log.Debug().Msg("deps not available, skipping session manager") | |
| 386 | + | return | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | // Session manager can work without a repo - it just shows an empty list | |
| 390 | + | var listSessionsUC *usecase.ListSessionsUseCase | |
| 391 | + | var deleteSessionUC *usecase.DeleteSessionUseCase | |
| 392 | + | if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil { | |
| 393 | + | listSessionsUC = usecase.NewListSessionsUseCase( | |
| 394 | + | a.deps.SessionRepo, | |
| 395 | + | a.deps.SessionStateRepo, | |
| 396 | + | a.deps.Config.Logging.LogDir, | |
| 397 | + | ) | |
| 398 | + | deleteSessionUC = usecase.NewDeleteSessionUseCase( | |
| 399 | + | a.deps.SessionStateRepo, | |
| 400 | + | a.deps.SessionRepo, | |
| 401 | + | a.deps.Config.Logging.LogDir, | |
| 402 | + | ) | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // Create session spawner for restoration | |
| 406 | + | spawner := desktop.NewSessionSpawner(ctx) | |
| 407 | + | ||
| 408 | + | // Create session manager component | |
| 409 | + | a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{ | |
| 410 | + | ListSessionsUC: listSessionsUC, | |
| 411 | + | DeleteSessionUC: deleteSessionUC, | |
| 412 | + | CurrentSession: a.deps.CurrentSessionID, | |
| 413 | + | UIScale: a.deps.Config.DefaultUIScale, | |
| 414 | + | OnClose: func() { | |
| 415 | + | log.Debug().Msg("session manager closed") | |
| 416 | + | }, | |
| 417 | + | OnOpen: func(sessionID entity.SessionID) { | |
| 418 | + | log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested") | |
| 419 | + | if err := spawner.SpawnWithSession(sessionID); err != nil { | |
| 420 | + | log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session") | |
| 421 | + | } | |
| 422 | + | }, | |
| 423 | + | OnToast: func(ctx context.Context, message string, level component.ToastLevel) { | |
| 424 | + | if a.appToaster != nil { | |
| 425 | + | a.appToaster.Show(ctx, message, level) | |
| 426 | + | } | |
| 427 | + | }, | |
| 428 | + | }) | |
| 429 | + | ||
| 430 | + | if a.sessionManager == nil { | |
| 431 | + | log.Warn().Msg("failed to create session manager") | |
| 432 | + | return | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | // Add session manager to main window overlay | |
| 436 | + | if a.mainWindow != nil { | |
| 437 | + | widget := a.sessionManager.Widget() | |
| 438 | + | if widget != nil { | |
| 439 | + | a.mainWindow.AddOverlay(widget) | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | // Wire session manager action to keyboard dispatcher | |
| 444 | + | a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context) error { | |
| 445 | + | a.ToggleSessionManager(ctx) | |
| 446 | + | return nil | |
| 447 | + | }) | |
| 448 | + | ||
| 449 | + | log.Debug().Msg("session manager initialized") | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | // ToggleSessionManager shows or hides the session manager. | |
| 453 | + | func (a *App) ToggleSessionManager(ctx context.Context) { | |
| 454 | + | if a.sessionManager == nil { | |
| 455 | + | return | |
| 456 | + | } | |
| 457 | + | a.sessionManager.Toggle(ctx) | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | func (a *App) initSnapshotService(ctx context.Context) { | |
| 461 | + | log := logging.FromContext(ctx) | |
| 462 | + | ||
| 463 | + | if a.deps == nil || a.deps.SnapshotUC == nil { | |
| 464 | + | log.Debug().Msg("snapshot use case not available, skipping snapshot service") | |
| 465 | + | return | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | intervalMs := 5000 // default | |
| 469 | + | if a.deps.Config != nil && a.deps.Config.Session.SnapshotIntervalMs > 0 { | |
| 470 | + | intervalMs = a.deps.Config.Session.SnapshotIntervalMs | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | a.snapshotService = snapshot.NewService(a.deps.SnapshotUC, a, intervalMs) | |
| 474 | + | a.snapshotService.Start(ctx) | |
| 475 | + | ||
| 476 | + | log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started") | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | // GetTabList implements port.TabListProvider. | |
| 480 | + | func (a *App) GetTabList() *entity.TabList { | |
| 481 | + | return a.tabs | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | // GetSessionID implements port.TabListProvider. | |
| 485 | + | func (a *App) GetSessionID() entity.SessionID { | |
| 486 | + | if a.deps == nil { | |
| 487 | + | return "" | |
| 488 | + | } | |
| 489 | + | return a.deps.CurrentSessionID | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | // MarkDirty signals that session state has changed and should be saved. | |
| 493 | + | func (a *App) MarkDirty() { | |
| 494 | + | if a.snapshotService != nil { | |
| 495 | + | a.snapshotService.MarkDirty() | |
| 496 | + | } | |
| 497 | + | } | |
| 498 | + | ||
| 372 | 499 | func (a *App) createInitialTab(ctx context.Context) { | |
| 373 | 500 | log := logging.FromContext(ctx) | |
| 374 | 501 | ||
| 502 | + | // Check if we should restore a session | |
| 503 | + | if a.deps != nil && a.deps.RestoreSessionID != "" { | |
| 504 | + | if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil { | |
| 505 | + | log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab") | |
| 506 | + | // Show error toast and fall through to create default tab | |
| 507 | + | if a.appToaster != nil { | |
| 508 | + | a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning) | |
| 509 | + | } | |
| 510 | + | } else { | |
| 511 | + | log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored") | |
| 512 | + | // Show success toast for session restoration | |
| 513 | + | if a.appToaster != nil { | |
| 514 | + | a.appToaster.Show(ctx, "Session restored", component.ToastSuccess) | |
| 515 | + | } | |
| 516 | + | return | |
| 517 | + | } | |
| 518 | + | } | |
| 519 | + | ||
| 375 | 520 | // Create an initial tab using coordinator. | |
| 376 | 521 | initialURL := "dumb://home" | |
| 377 | 522 | if a.deps != nil && a.deps.InitialURL != "" { | |
| @@ -382,6 +527,61 @@ func (a *App) createInitialTab(ctx context.Context) { | |||
| 382 | 527 | } | |
| 383 | 528 | } | |
| 384 | 529 | ||
| 530 | + | func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error { | |
| 531 | + | log := logging.FromContext(ctx) | |
| 532 | + | ||
| 533 | + | if a.deps == nil || a.deps.SessionStateRepo == nil { | |
| 534 | + | return fmt.Errorf("session state repo not available") | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | // Load session state | |
| 538 | + | state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID) | |
| 539 | + | if err != nil { | |
| 540 | + | return fmt.Errorf("load session state: %w", err) | |
| 541 | + | } | |
| 542 | + | if state == nil { | |
| 543 | + | return fmt.Errorf("session state not found") | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | log.Info().Int("tabs", len(state.Tabs)).Int("panes", state.CountPanes()).Msg("restoring session state") | |
| 547 | + | ||
| 548 | + | // Build complete TabList from snapshot (state-first approach) | |
| 549 | + | // This reconstructs the full pane tree with new IDs | |
| 550 | + | restoredTabs := entity.TabListFromSnapshot(state, a.generateID) | |
| 551 | + | if restoredTabs == nil || len(restoredTabs.Tabs) == 0 { | |
| 552 | + | return fmt.Errorf("failed to build tab list from snapshot") | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | // Replace tabs in-place to preserve references held by TabCoordinator | |
| 556 | + | a.tabs.ReplaceFrom(restoredTabs) | |
| 557 | + | ||
| 558 | + | // Create UI for each restored tab and add to tab bar | |
| 559 | + | // Don't attach to content area yet - only the active tab should be attached | |
| 560 | + | tabBar := a.mainWindow.TabBar() | |
| 561 | + | for _, tab := range a.tabs.Tabs { | |
| 562 | + | a.createWorkspaceViewWithoutAttach(ctx, tab) | |
| 563 | + | if tabBar != nil { | |
| 564 | + | tabBar.AddTab(tab) | |
| 565 | + | } | |
| 566 | + | log.Debug(). | |
| 567 | + | Str("tab_id", string(tab.ID)). | |
| 568 | + | Str("name", tab.Name). | |
| 569 | + | Int("panes", tab.Workspace.PaneCount()). | |
| 570 | + | Msg("restored tab with workspace") | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | // Switch to active tab - this attaches the active workspace view to content area | |
| 574 | + | activeTab := a.tabs.ActiveTab() | |
| 575 | + | if activeTab != nil { | |
| 576 | + | a.switchWorkspaceView(ctx, activeTab.ID) | |
| 577 | + | if tabBar != nil { | |
| 578 | + | tabBar.SetActive(activeTab.ID) | |
| 579 | + | } | |
| 580 | + | } | |
| 581 | + | ||
| 582 | + | return nil | |
| 583 | + | } | |
| 584 | + | ||
| 385 | 585 | func (a *App) finalizeActivation(ctx context.Context) { | |
| 386 | 586 | log := logging.FromContext(ctx) | |
| 387 | 587 | ||
| @@ -409,6 +609,13 @@ func (a *App) onShutdown(ctx context.Context) { | |||
| 409 | 609 | log := logging.FromContext(ctx) | |
| 410 | 610 | log.Debug().Msg("GTK application shutting down") | |
| 411 | 611 | ||
| 612 | + | // Save final session state before shutdown | |
| 613 | + | if a.snapshotService != nil { | |
| 614 | + | if err := a.snapshotService.Stop(ctx); err != nil { | |
| 615 | + | log.Warn().Err(err).Msg("failed to save final session state") | |
| 616 | + | } | |
| 617 | + | } | |
| 618 | + | ||
| 412 | 619 | // Cancel context to signal all goroutines | |
| 413 | 620 | a.cancel(errors.New("application shutdown")) | |
| 414 | 621 | ||
| @@ -475,6 +682,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 475 | 682 | a.switchWorkspaceView(ctx, tab.ID) | |
| 476 | 683 | }) | |
| 477 | 684 | a.tabCoord.SetOnQuit(a.Quit) | |
| 685 | + | a.tabCoord.SetOnStateChanged(a.MarkDirty) | |
| 478 | 686 | // Wire popup tab WebView attachment | |
| 479 | 687 | a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) { | |
| 480 | 688 | a.attachPopupToTab(ctx, tabID, pane, wv) | |
| @@ -502,6 +710,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 502 | 710 | a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error { | |
| 503 | 711 | return a.tabCoord.Close(ctx) | |
| 504 | 712 | }) | |
| 713 | + | a.wsCoord.SetOnStateChanged(a.MarkDirty) | |
| 505 | 714 | ||
| 506 | 715 | // Wire popup handling | |
| 507 | 716 | a.webViewFactory = webkit.NewWebViewFactory( | |
| @@ -555,6 +764,13 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 555 | 764 | a.navCoord.RecordHistory(ctx, paneID, url) | |
| 556 | 765 | }) | |
| 557 | 766 | ||
| 767 | + | // Wire pane URI updates for session snapshots (searches all tabs) | |
| 768 | + | a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) { | |
| 769 | + | a.updatePaneURIInAllTabs(paneID, url) | |
| 770 | + | // Mark dirty so snapshot captures the new URI | |
| 771 | + | a.MarkDirty() | |
| 772 | + | }) | |
| 773 | + | ||
| 558 | 774 | // 5. Keyboard Dispatcher | |
| 559 | 775 | a.kbDispatcher = dispatcher.NewKeyboardDispatcher( | |
| 560 | 776 | ctx, | |
| @@ -640,6 +856,24 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | |||
| 640 | 856 | ||
| 641 | 857 | // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area. | |
| 642 | 858 | func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) { | |
| 859 | + | a.createWorkspaceViewWithoutAttach(ctx, tab) | |
| 860 | + | ||
| 861 | + | // Attach to content area | |
| 862 | + | wsView := a.workspaceViews[tab.ID] | |
| 863 | + | if wsView != nil && a.mainWindow != nil { | |
| 864 | + | widget := wsView.Widget() | |
| 865 | + | if widget != nil { | |
| 866 | + | gtkWidget := widget.GtkWidget() | |
| 867 | + | if gtkWidget != nil { | |
| 868 | + | a.mainWindow.SetContent(gtkWidget) | |
| 869 | + | } | |
| 870 | + | } | |
| 871 | + | } | |
| 872 | + | } | |
| 873 | + | ||
| 874 | + | // createWorkspaceViewWithoutAttach creates a WorkspaceView for a tab without attaching to content area. | |
| 875 | + | // Used during session restoration where we create all views first, then attach only the active one. | |
| 876 | + | func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.Tab) { | |
| 643 | 877 | log := logging.FromContext(ctx) | |
| 644 | 878 | ||
| 645 | 879 | if a.widgetFactory == nil { | |
| @@ -676,18 +910,7 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) { | |||
| 676 | 910 | // Store in map | |
| 677 | 911 | a.workspaceViews[tab.ID] = wsView | |
| 678 | 912 | ||
| 679 | - | // Attach to content area | |
| 680 | - | if a.mainWindow != nil { | |
| 681 | - | widget := wsView.Widget() | |
| 682 | - | if widget != nil { | |
| 683 | - | gtkWidget := widget.GtkWidget() | |
| 684 | - | if gtkWidget != nil { | |
| 685 | - | a.mainWindow.SetContent(gtkWidget) | |
| 686 | - | } | |
| 687 | - | } | |
| 688 | - | } | |
| 689 | - | ||
| 690 | - | log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created and attached") | |
| 913 | + | log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created") | |
| 691 | 914 | } | |
| 692 | 915 | ||
| 693 | 916 | // activeWorkspace returns the workspace of the active tab. | |
| @@ -699,6 +922,21 @@ func (a *App) activeWorkspace() *entity.Workspace { | |||
| 699 | 922 | return activeTab.Workspace | |
| 700 | 923 | } | |
| 701 | 924 | ||
| 925 | + | // updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI. | |
| 926 | + | // This is necessary because panes in inactive tabs also need URI updates for session snapshots. | |
| 927 | + | func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) { | |
| 928 | + | for _, tab := range a.tabs.Tabs { | |
| 929 | + | if tab.Workspace == nil { | |
| 930 | + | continue | |
| 931 | + | } | |
| 932 | + | paneNode := tab.Workspace.FindPane(paneID) | |
| 933 | + | if paneNode != nil && paneNode.Pane != nil { | |
| 934 | + | paneNode.Pane.URI = url | |
| 935 | + | return // Pane IDs are unique, no need to continue | |
| 936 | + | } | |
| 937 | + | } | |
| 938 | + | } | |
| 939 | + | ||
| 702 | 940 | // activeWorkspaceView returns the workspace view for the active tab. | |
| 703 | 941 | func (a *App) activeWorkspaceView() *component.WorkspaceView { | |
| 704 | 942 | activeTab := a.tabs.ActiveTab() | |
A internal/ui/component/modal_sizing.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/dumber/internal/ui/layout" | |
| 5 | + | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // ModalSizeConfig holds configuration for modal sizing calculations. | |
| 9 | + | type ModalSizeConfig struct { | |
| 10 | + | WidthPct float64 // Percentage of parent width (e.g., 0.6) | |
| 11 | + | MaxWidth int // Maximum width in pixels | |
| 12 | + | TopMarginPct float64 // Top margin as percentage of parent height | |
| 13 | + | FallbackWidth int // Fallback when parent not allocated | |
| 14 | + | FallbackHeight int // Fallback height | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // RowHeightDefaults holds default (unscaled) row heights for list-based modals. | |
| 18 | + | // These are base pixel values that should be multiplied by UI scale. | |
| 19 | + | type RowHeightDefaults struct { | |
| 20 | + | Standard int // Standard list row (e.g., session row, history row) | |
| 21 | + | Compact int // Compact row (e.g., tree row, sub-item) | |
| 22 | + | Divider int // Divider/separator row | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // ListDisplayDefaults holds display limits for list-based modals. | |
| 26 | + | type ListDisplayDefaults struct { | |
| 27 | + | MaxVisibleRows int // Maximum rows visible before scrolling | |
| 28 | + | MaxResults int // Maximum results to fetch/display | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Package-level defaults for row heights. | |
| 32 | + | var DefaultRowHeights = RowHeightDefaults{ | |
| 33 | + | Standard: 50, | |
| 34 | + | Compact: 28, | |
| 35 | + | Divider: 30, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // OmniboxSizeDefaults provides default sizing for omnibox modal. | |
| 39 | + | var OmniboxSizeDefaults = ModalSizeConfig{ | |
| 40 | + | WidthPct: 0.8, | |
| 41 | + | MaxWidth: 800, | |
| 42 | + | TopMarginPct: 0.2, | |
| 43 | + | FallbackWidth: 800, | |
| 44 | + | FallbackHeight: 600, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // OmniboxListDefaults provides display limits for omnibox modal. | |
| 48 | + | var OmniboxListDefaults = ListDisplayDefaults{ | |
| 49 | + | MaxVisibleRows: 10, | |
| 50 | + | MaxResults: 10, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // SessionManagerSizeDefaults provides default sizing for session manager modal. | |
| 54 | + | var SessionManagerSizeDefaults = ModalSizeConfig{ | |
| 55 | + | WidthPct: 0.6, | |
| 56 | + | MaxWidth: 600, | |
| 57 | + | TopMarginPct: 0.15, | |
| 58 | + | FallbackWidth: 600, | |
| 59 | + | FallbackHeight: 600, | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // SessionManagerListDefaults provides display limits for session manager modal. | |
| 63 | + | var SessionManagerListDefaults = ListDisplayDefaults{ | |
| 64 | + | MaxVisibleRows: 6, | |
| 65 | + | MaxResults: 50, | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // CalculateModalDimensions computes width and top margin based on parent overlay. | |
| 69 | + | // Returns calculated width and top margin in pixels. | |
| 70 | + | func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) { | |
| 71 | + | var parentWidth, parentHeight int | |
| 72 | + | ||
| 73 | + | if parent != nil { | |
| 74 | + | parentWidth = parent.GetAllocatedWidth() | |
| 75 | + | parentHeight = parent.GetAllocatedHeight() | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | if parentWidth <= 0 { | |
| 79 | + | parentWidth = cfg.FallbackWidth | |
| 80 | + | } | |
| 81 | + | if parentHeight <= 0 { | |
| 82 | + | parentHeight = cfg.FallbackHeight | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | width = int(float64(parentWidth) * cfg.WidthPct) | |
| 86 | + | if width > cfg.MaxWidth { | |
| 87 | + | width = cfg.MaxWidth | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | marginTop = int(float64(parentHeight) * cfg.TopMarginPct) | |
| 91 | + | return width, marginTop | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // SetScrolledWindowHeight safely sets min/max content height on a ScrolledWindow. | |
| 95 | + | // Resets min to -1 first to avoid GTK assertion (min <= max) when shrinking. | |
| 96 | + | func SetScrolledWindowHeight(sw *gtk.ScrolledWindow, height int) { | |
| 97 | + | if sw == nil { | |
| 98 | + | return | |
| 99 | + | } | |
| 100 | + | sw.SetMinContentHeight(-1) | |
| 101 | + | sw.SetMaxContentHeight(height) | |
| 102 | + | sw.SetMinContentHeight(height) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // MeasureWidgetHeight returns the natural height of a widget for a given width. | |
| 106 | + | // Returns 0 if widget is nil or measurement fails. | |
| 107 | + | func MeasureWidgetHeight(widget *gtk.Widget, forWidth int) int { | |
| 108 | + | if widget == nil { | |
| 109 | + | return 0 | |
| 110 | + | } | |
| 111 | + | var minH, natH int | |
| 112 | + | widget.Measure(gtk.OrientationVerticalValue, forWidth, &minH, &natH, nil, nil) | |
| 113 | + | return natH | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // ScaleValue scales a base pixel value by UI scale factor. | |
| 117 | + | // Returns the base value if scale is <= 0. | |
| 118 | + | func ScaleValue(base int, uiScale float64) int { | |
| 119 | + | if uiScale <= 0 { | |
| 120 | + | uiScale = 1.0 | |
| 121 | + | } | |
| 122 | + | return int(float64(base) * uiScale) | |
| 123 | + | } | |
A internal/ui/component/modal_sizing_test.go +145 -0
| @@ -0,0 +1,145 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks" | |
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestCalculateModalDimensions_NilParent(t *testing.T) { | |
| 11 | + | cfg := ModalSizeConfig{ | |
| 12 | + | WidthPct: 0.6, | |
| 13 | + | MaxWidth: 600, | |
| 14 | + | TopMarginPct: 0.15, | |
| 15 | + | FallbackWidth: 800, | |
| 16 | + | FallbackHeight: 600, | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | width, marginTop := CalculateModalDimensions(nil, cfg) | |
| 20 | + | ||
| 21 | + | // Should use fallback values | |
| 22 | + | expectedWidth := int(float64(cfg.FallbackWidth) * cfg.WidthPct) | |
| 23 | + | expectedMargin := int(float64(cfg.FallbackHeight) * cfg.TopMarginPct) | |
| 24 | + | ||
| 25 | + | assert.Equal(t, expectedWidth, width) | |
| 26 | + | assert.Equal(t, expectedMargin, marginTop) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestCalculateModalDimensions_ZeroAllocatedSize(t *testing.T) { | |
| 30 | + | cfg := ModalSizeConfig{ | |
| 31 | + | WidthPct: 0.5, | |
| 32 | + | MaxWidth: 500, | |
| 33 | + | TopMarginPct: 0.2, | |
| 34 | + | FallbackWidth: 1000, | |
| 35 | + | FallbackHeight: 800, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | parent := layoutmocks.NewMockOverlayWidget(t) | |
| 39 | + | parent.EXPECT().GetAllocatedWidth().Return(0) | |
| 40 | + | parent.EXPECT().GetAllocatedHeight().Return(0) | |
| 41 | + | ||
| 42 | + | width, marginTop := CalculateModalDimensions(parent, cfg) | |
| 43 | + | ||
| 44 | + | // Should use fallback values when allocated size is 0 | |
| 45 | + | expectedWidth := int(float64(cfg.FallbackWidth) * cfg.WidthPct) | |
| 46 | + | expectedMargin := int(float64(cfg.FallbackHeight) * cfg.TopMarginPct) | |
| 47 | + | ||
| 48 | + | assert.Equal(t, expectedWidth, width) | |
| 49 | + | assert.Equal(t, expectedMargin, marginTop) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestCalculateModalDimensions_WidthCapped(t *testing.T) { | |
| 53 | + | cfg := ModalSizeConfig{ | |
| 54 | + | WidthPct: 0.8, | |
| 55 | + | MaxWidth: 600, | |
| 56 | + | TopMarginPct: 0.1, | |
| 57 | + | FallbackWidth: 800, | |
| 58 | + | FallbackHeight: 600, | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | parent := layoutmocks.NewMockOverlayWidget(t) | |
| 62 | + | parent.EXPECT().GetAllocatedWidth().Return(1920) // Large screen | |
| 63 | + | parent.EXPECT().GetAllocatedHeight().Return(1080) | |
| 64 | + | ||
| 65 | + | width, marginTop := CalculateModalDimensions(parent, cfg) | |
| 66 | + | ||
| 67 | + | // 0.8 * 1920 = 1536, but capped at MaxWidth 600 | |
| 68 | + | assert.Equal(t, cfg.MaxWidth, width) | |
| 69 | + | assert.Equal(t, int(float64(1080)*cfg.TopMarginPct), marginTop) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestCalculateModalDimensions_WidthPercentage(t *testing.T) { | |
| 73 | + | cfg := ModalSizeConfig{ | |
| 74 | + | WidthPct: 0.6, | |
| 75 | + | MaxWidth: 1000, // High cap, won't be hit | |
| 76 | + | TopMarginPct: 0.15, | |
| 77 | + | FallbackWidth: 800, | |
| 78 | + | FallbackHeight: 600, | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | parent := layoutmocks.NewMockOverlayWidget(t) | |
| 82 | + | parent.EXPECT().GetAllocatedWidth().Return(800) | |
| 83 | + | parent.EXPECT().GetAllocatedHeight().Return(600) | |
| 84 | + | ||
| 85 | + | width, marginTop := CalculateModalDimensions(parent, cfg) | |
| 86 | + | ||
| 87 | + | assert.Equal(t, 480, width) | |
| 88 | + | assert.Equal(t, 90, marginTop) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestScaleValue_DefaultScale(t *testing.T) { | |
| 92 | + | // When scale is 0 or negative, should default to 1.0 | |
| 93 | + | assert.Equal(t, 50, ScaleValue(50, 0)) | |
| 94 | + | assert.Equal(t, 50, ScaleValue(50, -1.0)) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestScaleValue_NormalScale(t *testing.T) { | |
| 98 | + | assert.Equal(t, 50, ScaleValue(50, 1.0)) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestScaleValue_Scaling(t *testing.T) { | |
| 102 | + | // 1.5x scale | |
| 103 | + | assert.Equal(t, 75, ScaleValue(50, 1.5)) | |
| 104 | + | // 2x scale | |
| 105 | + | assert.Equal(t, 100, ScaleValue(50, 2.0)) | |
| 106 | + | // 1.2x scale (common UI scale) | |
| 107 | + | assert.Equal(t, 60, ScaleValue(50, 1.2)) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestMeasureWidgetHeight_NilWidget(t *testing.T) { | |
| 111 | + | height := MeasureWidgetHeight(nil, 100) | |
| 112 | + | assert.Equal(t, 0, height) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestDefaultRowHeights_Values(t *testing.T) { | |
| 116 | + | // Ensure defaults are sensible | |
| 117 | + | assert.Positive(t, DefaultRowHeights.Standard) | |
| 118 | + | assert.Positive(t, DefaultRowHeights.Compact) | |
| 119 | + | assert.Positive(t, DefaultRowHeights.Divider) | |
| 120 | + | // Standard should be larger than compact | |
| 121 | + | assert.Greater(t, DefaultRowHeights.Standard, DefaultRowHeights.Compact) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestOmniboxSizeDefaults_Values(t *testing.T) { | |
| 125 | + | assert.Greater(t, OmniboxSizeDefaults.WidthPct, 0.0) | |
| 126 | + | assert.LessOrEqual(t, OmniboxSizeDefaults.WidthPct, 1.0) | |
| 127 | + | assert.Positive(t, OmniboxSizeDefaults.MaxWidth) | |
| 128 | + | assert.Greater(t, OmniboxSizeDefaults.TopMarginPct, 0.0) | |
| 129 | + | assert.LessOrEqual(t, OmniboxSizeDefaults.TopMarginPct, 1.0) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestSessionManagerSizeDefaults_Values(t *testing.T) { | |
| 133 | + | assert.Greater(t, SessionManagerSizeDefaults.WidthPct, 0.0) | |
| 134 | + | assert.LessOrEqual(t, SessionManagerSizeDefaults.WidthPct, 1.0) | |
| 135 | + | assert.Positive(t, SessionManagerSizeDefaults.MaxWidth) | |
| 136 | + | assert.Greater(t, SessionManagerSizeDefaults.TopMarginPct, 0.0) | |
| 137 | + | assert.LessOrEqual(t, SessionManagerSizeDefaults.TopMarginPct, 1.0) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestListDisplayDefaults_Values(t *testing.T) { | |
| 141 | + | assert.Positive(t, OmniboxListDefaults.MaxVisibleRows) | |
| 142 | + | assert.Positive(t, OmniboxListDefaults.MaxResults) | |
| 143 | + | assert.Positive(t, SessionManagerListDefaults.MaxVisibleRows) | |
| 144 | + | assert.Positive(t, SessionManagerListDefaults.MaxResults) | |
| 145 | + | } | |
M internal/ui/component/omnibox.go +15 -50
| @@ -20,13 +20,7 @@ import ( | |||
| 20 | 20 | ) | |
| 21 | 21 | ||
| 22 | 22 | const ( | |
| 23 | - | omniboxWidthPct = 0.8 // 80% of parent window width | |
| 24 | - | omniboxMaxWidth = 800 // Maximum width in pixels | |
| 25 | - | omniboxFallbackHeight = 600 // Fallback height when overlay not yet allocated | |
| 26 | - | omniboxHeightPct = 0.6 // 60% of parent window height | |
| 27 | - | omniboxMaxResults = 10 | |
| 28 | - | omniboxTopMarginPct = 0.2 // 20% from top | |
| 29 | - | debounceDelayMs = 150 | |
| 23 | + | debounceDelayMs = 150 | |
| 30 | 24 | ) | |
| 31 | 25 | ||
| 32 | 26 | // ViewMode distinguishes history search from favorites display. | |
| @@ -193,20 +187,12 @@ func (o *Omnibox) resizeAndCenter(rowCount int) { | |||
| 193 | 187 | return | |
| 194 | 188 | } | |
| 195 | 189 | ||
| 196 | - | // Use GetAllocatedWidth for actual rendered size (height not needed here) | |
| 197 | - | parentWidth := o.parentOverlay.GetAllocatedWidth() | |
| 198 | - | if parentWidth <= 0 { | |
| 199 | - | parentWidth = omniboxMaxWidth | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | width := int(float64(parentWidth) * omniboxWidthPct) | |
| 203 | - | if width > omniboxMaxWidth { | |
| 204 | - | width = omniboxMaxWidth | |
| 205 | - | } | |
| 190 | + | // Calculate width using shared helper | |
| 191 | + | width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults) | |
| 206 | 192 | ||
| 207 | 193 | // Cap at max results | |
| 208 | - | if rowCount > omniboxMaxResults { | |
| 209 | - | rowCount = omniboxMaxResults | |
| 194 | + | if rowCount > OmniboxListDefaults.MaxVisibleRows { | |
| 195 | + | rowCount = OmniboxListDefaults.MaxVisibleRows | |
| 210 | 196 | } | |
| 211 | 197 | ||
| 212 | 198 | // Schedule measurement after GTK has laid out widgets | |
| @@ -233,9 +219,8 @@ func (o *Omnibox) measureAndResize(width, rowCount int) { | |||
| 233 | 219 | // Use measured values from GTK4 Measure API | |
| 234 | 220 | rowHeight = o.measuredHeights.singleRow | |
| 235 | 221 | } else { | |
| 236 | - | // Fallback to hardcoded estimate (before first measurement) | |
| 237 | - | const fallbackRowHeight = 60 | |
| 238 | - | rowHeight = int(fallbackRowHeight * o.uiScale) | |
| 222 | + | // Fallback to scaled estimate (before first measurement) | |
| 223 | + | rowHeight = ScaleValue(DefaultRowHeights.Standard, o.uiScale) | |
| 239 | 224 | } | |
| 240 | 225 | ||
| 241 | 226 | // Calculate content height for the scrolled window | |
| @@ -246,19 +231,13 @@ func (o *Omnibox) measureAndResize(width, rowCount int) { | |||
| 246 | 231 | } | |
| 247 | 232 | ||
| 248 | 233 | // Cap at max results | |
| 249 | - | maxContentHeight := omniboxMaxResults * rowHeight | |
| 234 | + | maxContentHeight := OmniboxListDefaults.MaxVisibleRows * rowHeight | |
| 250 | 235 | if contentHeight > maxContentHeight { | |
| 251 | 236 | contentHeight = maxContentHeight | |
| 252 | 237 | } | |
| 253 | 238 | ||
| 254 | - | // Set size constraints on ScrolledWindow to control dynamic sizing | |
| 255 | - | if o.scrolledWin != nil { | |
| 256 | - | // Reset min first to avoid assertion failure when shrinking | |
| 257 | - | // GTK requires min <= max, so we reset min before setting new values | |
| 258 | - | o.scrolledWin.SetMinContentHeight(-1) | |
| 259 | - | o.scrolledWin.SetMaxContentHeight(contentHeight) | |
| 260 | - | o.scrolledWin.SetMinContentHeight(contentHeight) | |
| 261 | - | } | |
| 239 | + | // Set size constraints on ScrolledWindow using shared helper | |
| 240 | + | SetScrolledWindowHeight(o.scrolledWin, contentHeight) | |
| 262 | 241 | ||
| 263 | 242 | // Force layout recalculation | |
| 264 | 243 | o.outerBox.QueueResize() | |
| @@ -602,7 +581,7 @@ func (o *Omnibox) performSearch() { | |||
| 602 | 581 | ||
| 603 | 582 | input := usecase.SearchInput{ | |
| 604 | 583 | Query: query, | |
| 605 | - | Limit: omniboxMaxResults, | |
| 584 | + | Limit: OmniboxListDefaults.MaxResults, | |
| 606 | 585 | } | |
| 607 | 586 | output, err := o.historyUC.Search(ctx, input) | |
| 608 | 587 | if err != nil { | |
| @@ -641,7 +620,7 @@ func (o *Omnibox) loadInitialHistory() { | |||
| 641 | 620 | ||
| 642 | 621 | case "most_visited", "recent", "": | |
| 643 | 622 | // TODO: Implement GetMostVisited in use case if needed | |
| 644 | - | results, err := o.historyUC.GetRecent(ctx, omniboxMaxResults, 0) | |
| 623 | + | results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0) | |
| 645 | 624 | if err != nil { | |
| 646 | 625 | log.Error().Err(err).Msg("failed to load recent history") | |
| 647 | 626 | return | |
| @@ -778,7 +757,7 @@ func (o *Omnibox) rebuildList() { | |||
| 778 | 757 | if width <= 0 { | |
| 779 | 758 | return false // Overlay not allocated yet, skip | |
| 780 | 759 | } | |
| 781 | - | forWidth := int(float64(width) * omniboxWidthPct) | |
| 760 | + | forWidth := int(float64(width) * OmniboxSizeDefaults.WidthPct) | |
| 782 | 761 | if o.measureComponentHeights(forWidth) { | |
| 783 | 762 | // Re-trigger resize with accurate measurements | |
| 784 | 763 | o.mu.RLock() | |
| @@ -1234,22 +1213,8 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||
| 1234 | 1213 | o.scrolledWin.SetMaxContentHeight(0) | |
| 1235 | 1214 | } | |
| 1236 | 1215 | ||
| 1237 | - | // Get parent dimensions with fallback if not yet allocated | |
| 1238 | - | parentWidth := o.parentOverlay.GetAllocatedWidth() | |
| 1239 | - | parentHeight := o.parentOverlay.GetAllocatedHeight() | |
| 1240 | - | if parentWidth <= 0 { | |
| 1241 | - | parentWidth = omniboxMaxWidth | |
| 1242 | - | } | |
| 1243 | - | if parentHeight <= 0 { | |
| 1244 | - | parentHeight = omniboxFallbackHeight | |
| 1245 | - | } | |
| 1246 | - | ||
| 1247 | - | // Set width and vertical positioning (20% from top) | |
| 1248 | - | width := int(float64(parentWidth) * omniboxWidthPct) | |
| 1249 | - | if width > omniboxMaxWidth { | |
| 1250 | - | width = omniboxMaxWidth | |
| 1251 | - | } | |
| 1252 | - | marginTop := int(float64(parentHeight) * omniboxTopMarginPct) | |
| 1216 | + | // Calculate dimensions using shared helper | |
| 1217 | + | width, marginTop := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults) | |
| 1253 | 1218 | ||
| 1254 | 1219 | o.mainBox.SetSizeRequest(width, -1) | |
| 1255 | 1220 | o.outerBox.SetMarginTop(marginTop) | |
A internal/ui/component/session_manager.go +1028 -0
| @@ -0,0 +1,1028 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | "sync" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | "github.com/bnema/dumber/internal/logging" | |
| 13 | + | "github.com/bnema/dumber/internal/ui/layout" | |
| 14 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 15 | + | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 16 | + | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | const ( | |
| 20 | + | sessionIDMaxDisplay = 20 | |
| 21 | + | sessionRowBoxSpacing = 8 | |
| 22 | + | sessionMaxTitleLen = 45 // Max chars for pane title display | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // SessionManager is a modal overlay for managing sessions. | |
| 26 | + | type SessionManager struct { | |
| 27 | + | // GTK widgets | |
| 28 | + | outerBox *gtk.Box | |
| 29 | + | mainBox *gtk.Box | |
| 30 | + | headerBox *gtk.Box | |
| 31 | + | titleLabel *gtk.Label | |
| 32 | + | scrolledWindow *gtk.ScrolledWindow | |
| 33 | + | listBox *gtk.ListBox | |
| 34 | + | footerLabel *gtk.Label | |
| 35 | + | ||
| 36 | + | // Parent overlay reference for sizing | |
| 37 | + | parentOverlay layout.OverlayWidget | |
| 38 | + | ||
| 39 | + | // State | |
| 40 | + | mu sync.RWMutex | |
| 41 | + | visible bool | |
| 42 | + | sessions []entity.SessionInfo | |
| 43 | + | selectedIndex int // Index into sessions slice | |
| 44 | + | expandedIndex int // -1 means none expanded | |
| 45 | + | sessionToListRow []int // Maps session index to list row index | |
| 46 | + | listRowToSession []int // Maps list row index to session index (-1 for non-session rows) | |
| 47 | + | populatingList bool // True while rebuilding list, to ignore selection callbacks | |
| 48 | + | ||
| 49 | + | // Dependencies | |
| 50 | + | listSessionsUC *usecase.ListSessionsUseCase | |
| 51 | + | deleteSessionUC *usecase.DeleteSessionUseCase | |
| 52 | + | spawner port.SessionSpawner | |
| 53 | + | currentSession entity.SessionID | |
| 54 | + | ||
| 55 | + | // Callbacks | |
| 56 | + | onClose func() | |
| 57 | + | onOpen func(sessionID entity.SessionID) | |
| 58 | + | onToast func(ctx context.Context, message string, level ToastLevel) | |
| 59 | + | ||
| 60 | + | // GTK callback retention | |
| 61 | + | retainedCallbacks []interface{} | |
| 62 | + | ||
| 63 | + | // Scaling | |
| 64 | + | uiScale float64 | |
| 65 | + | ||
| 66 | + | // Cached measurements (populated after first layout) | |
| 67 | + | measuredHeights struct { | |
| 68 | + | sessionRow int // Session row natural height | |
| 69 | + | treeRow int // Tree row natural height | |
| 70 | + | dividerRow int // Divider row natural height | |
| 71 | + | valid bool // Whether cache is valid | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | ctx context.Context | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // SessionManagerConfig holds configuration for creating a SessionManager. | |
| 78 | + | type SessionManagerConfig struct { | |
| 79 | + | ListSessionsUC *usecase.ListSessionsUseCase | |
| 80 | + | DeleteSessionUC *usecase.DeleteSessionUseCase | |
| 81 | + | Spawner port.SessionSpawner | |
| 82 | + | CurrentSession entity.SessionID | |
| 83 | + | UIScale float64 | |
| 84 | + | OnClose func() | |
| 85 | + | OnOpen func(sessionID entity.SessionID) | |
| 86 | + | OnToast func(ctx context.Context, message string, level ToastLevel) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // NewSessionManager creates a new SessionManager component. | |
| 90 | + | func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionManager { | |
| 91 | + | log := logging.FromContext(ctx) | |
| 92 | + | ||
| 93 | + | uiScale := cfg.UIScale | |
| 94 | + | if uiScale <= 0 { | |
| 95 | + | uiScale = 1.0 | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | sm := &SessionManager{ | |
| 99 | + | ctx: ctx, | |
| 100 | + | listSessionsUC: cfg.ListSessionsUC, | |
| 101 | + | deleteSessionUC: cfg.DeleteSessionUC, | |
| 102 | + | spawner: cfg.Spawner, | |
| 103 | + | currentSession: cfg.CurrentSession, | |
| 104 | + | onClose: cfg.OnClose, | |
| 105 | + | onOpen: cfg.OnOpen, | |
| 106 | + | onToast: cfg.OnToast, | |
| 107 | + | selectedIndex: -1, | |
| 108 | + | expandedIndex: -1, | |
| 109 | + | uiScale: uiScale, | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | if err := sm.createWidgets(); err != nil { | |
| 113 | + | log.Error().Err(err).Msg("failed to create session manager widgets") | |
| 114 | + | return nil | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | sm.attachKeyController() | |
| 118 | + | ||
| 119 | + | log.Debug().Msg("session manager created") | |
| 120 | + | return sm | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // SetParentOverlay sets the overlay widget used for sizing calculations. | |
| 124 | + | // Must be called before Show(). | |
| 125 | + | func (sm *SessionManager) SetParentOverlay(overlay layout.OverlayWidget) { | |
| 126 | + | sm.mu.Lock() | |
| 127 | + | defer sm.mu.Unlock() | |
| 128 | + | sm.parentOverlay = overlay | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // WidgetAsLayout returns the session manager's outer widget as a layout.Widget. | |
| 132 | + | func (sm *SessionManager) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget { | |
| 133 | + | if sm.outerBox == nil { | |
| 134 | + | return nil | |
| 135 | + | } | |
| 136 | + | return factory.WrapWidget(&sm.outerBox.Widget) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // Widget returns the session manager widget for embedding in an overlay. | |
| 140 | + | func (sm *SessionManager) Widget() *gtk.Widget { | |
| 141 | + | if sm.outerBox == nil { | |
| 142 | + | return nil | |
| 143 | + | } | |
| 144 | + | return &sm.outerBox.Widget | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // Show displays the session manager and loads sessions. | |
| 148 | + | func (sm *SessionManager) Show(ctx context.Context) { | |
| 149 | + | log := logging.FromContext(ctx) | |
| 150 | + | log.Debug().Msg("showing session manager") | |
| 151 | + | ||
| 152 | + | sm.mu.Lock() | |
| 153 | + | if sm.visible { | |
| 154 | + | sm.mu.Unlock() | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | sm.visible = true | |
| 158 | + | sm.mu.Unlock() | |
| 159 | + | ||
| 160 | + | // Calculate size and position | |
| 161 | + | sm.resizeAndCenter() | |
| 162 | + | ||
| 163 | + | // Show the widget | |
| 164 | + | sm.outerBox.SetVisible(true) | |
| 165 | + | ||
| 166 | + | // Load sessions asynchronously | |
| 167 | + | go sm.loadSessions() | |
| 168 | + | ||
| 169 | + | // Focus the list | |
| 170 | + | if sm.listBox != nil { | |
| 171 | + | sm.listBox.GrabFocus() | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // Hide hides the session manager. | |
| 176 | + | func (sm *SessionManager) Hide(ctx context.Context) { | |
| 177 | + | log := logging.FromContext(ctx) | |
| 178 | + | log.Debug().Msg("hiding session manager") | |
| 179 | + | ||
| 180 | + | sm.mu.Lock() | |
| 181 | + | if !sm.visible { | |
| 182 | + | sm.mu.Unlock() | |
| 183 | + | return | |
| 184 | + | } | |
| 185 | + | sm.visible = false | |
| 186 | + | sm.mu.Unlock() | |
| 187 | + | ||
| 188 | + | sm.outerBox.SetVisible(false) | |
| 189 | + | ||
| 190 | + | // Clear state | |
| 191 | + | sm.listBox.RemoveAll() | |
| 192 | + | ||
| 193 | + | if sm.onClose != nil { | |
| 194 | + | sm.onClose() | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // IsVisible returns whether the session manager is visible. | |
| 199 | + | func (sm *SessionManager) IsVisible() bool { | |
| 200 | + | sm.mu.RLock() | |
| 201 | + | defer sm.mu.RUnlock() | |
| 202 | + | return sm.visible | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // Toggle shows if hidden, hides if visible. | |
| 206 | + | func (sm *SessionManager) Toggle(ctx context.Context) { | |
| 207 | + | sm.mu.RLock() | |
| 208 | + | visible := sm.visible | |
| 209 | + | sm.mu.RUnlock() | |
| 210 | + | ||
| 211 | + | if visible { | |
| 212 | + | sm.Hide(ctx) | |
| 213 | + | } else { | |
| 214 | + | sm.Show(ctx) | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func (sm *SessionManager) resizeAndCenter() { | |
| 219 | + | if sm.outerBox == nil || sm.mainBox == nil { | |
| 220 | + | return | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | width, marginTop := CalculateModalDimensions(sm.parentOverlay, SessionManagerSizeDefaults) | |
| 224 | + | ||
| 225 | + | sm.mainBox.SetSizeRequest(width, -1) | |
| 226 | + | sm.outerBox.SetMarginTop(marginTop) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func (sm *SessionManager) createWidgets() error { | |
| 230 | + | // Outer container for positioning | |
| 231 | + | sm.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 232 | + | if sm.outerBox == nil { | |
| 233 | + | return errNilWidget("sessionOuterBox") | |
| 234 | + | } | |
| 235 | + | sm.outerBox.AddCssClass("session-manager-outer") | |
| 236 | + | sm.outerBox.SetHalign(gtk.AlignCenterValue) | |
| 237 | + | sm.outerBox.SetValign(gtk.AlignStartValue) | |
| 238 | + | sm.outerBox.SetVisible(false) | |
| 239 | + | ||
| 240 | + | // Main container with styling | |
| 241 | + | sm.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 242 | + | if sm.mainBox == nil { | |
| 243 | + | return errNilWidget("sessionMainBox") | |
| 244 | + | } | |
| 245 | + | sm.mainBox.AddCssClass("session-manager-container") | |
| 246 | + | ||
| 247 | + | // Header | |
| 248 | + | if err := sm.initHeader(); err != nil { | |
| 249 | + | return err | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // Scrolled window for list | |
| 253 | + | if err := sm.initList(); err != nil { | |
| 254 | + | return err | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // Footer with shortcuts | |
| 258 | + | if err := sm.initFooter(); err != nil { | |
| 259 | + | return err | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | // Assemble | |
| 263 | + | sm.mainBox.Append(&sm.headerBox.Widget) | |
| 264 | + | sm.mainBox.Append(&sm.scrolledWindow.Widget) | |
| 265 | + | sm.mainBox.Append(&sm.footerLabel.Widget) | |
| 266 | + | sm.outerBox.Append(&sm.mainBox.Widget) | |
| 267 | + | ||
| 268 | + | return nil | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func (sm *SessionManager) initHeader() error { | |
| 272 | + | sm.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0) | |
| 273 | + | if sm.headerBox == nil { | |
| 274 | + | return errNilWidget("sessionHeaderBox") | |
| 275 | + | } | |
| 276 | + | sm.headerBox.AddCssClass("session-manager-header") | |
| 277 | + | ||
| 278 | + | title := "Sessions" | |
| 279 | + | sm.titleLabel = gtk.NewLabel(&title) | |
| 280 | + | if sm.titleLabel == nil { | |
| 281 | + | return errNilWidget("sessionTitleLabel") | |
| 282 | + | } | |
| 283 | + | sm.titleLabel.AddCssClass("session-manager-title") | |
| 284 | + | sm.titleLabel.SetHalign(gtk.AlignStartValue) | |
| 285 | + | sm.titleLabel.SetHexpand(true) | |
| 286 | + | sm.headerBox.Append(&sm.titleLabel.Widget) | |
| 287 | + | ||
| 288 | + | // Shortcut badge | |
| 289 | + | shortcutText := "Ctrl+O" | |
| 290 | + | shortcutLabel := gtk.NewLabel(&shortcutText) | |
| 291 | + | if shortcutLabel != nil { | |
| 292 | + | shortcutLabel.AddCssClass("omnibox-shortcut-badge") | |
| 293 | + | sm.headerBox.Append(&shortcutLabel.Widget) | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | return nil | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func (sm *SessionManager) initList() error { | |
| 300 | + | sm.scrolledWindow = gtk.NewScrolledWindow() | |
| 301 | + | if sm.scrolledWindow == nil { | |
| 302 | + | return errNilWidget("sessionScrolledWindow") | |
| 303 | + | } | |
| 304 | + | sm.scrolledWindow.AddCssClass("session-manager-scrolled") | |
| 305 | + | sm.scrolledWindow.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | |
| 306 | + | // Don't propagate natural height - we control it via resizeForContent | |
| 307 | + | sm.scrolledWindow.SetPropagateNaturalHeight(false) | |
| 308 | + | ||
| 309 | + | sm.listBox = gtk.NewListBox() | |
| 310 | + | if sm.listBox == nil { | |
| 311 | + | return errNilWidget("sessionListBox") | |
| 312 | + | } | |
| 313 | + | sm.listBox.AddCssClass("session-manager-list") | |
| 314 | + | sm.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |
| 315 | + | ||
| 316 | + | // Connect row selection - map list row index to session index | |
| 317 | + | rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) { | |
| 318 | + | if rowPtr == 0 { | |
| 319 | + | return | |
| 320 | + | } | |
| 321 | + | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |
| 322 | + | if row == nil { | |
| 323 | + | return | |
| 324 | + | } | |
| 325 | + | listRowIdx := row.GetIndex() | |
| 326 | + | sm.mu.Lock() | |
| 327 | + | // Ignore selection changes while rebuilding the list | |
| 328 | + | if sm.populatingList { | |
| 329 | + | sm.mu.Unlock() | |
| 330 | + | return | |
| 331 | + | } | |
| 332 | + | if listRowIdx >= 0 && listRowIdx < len(sm.listRowToSession) { | |
| 333 | + | sessionIdx := sm.listRowToSession[listRowIdx] | |
| 334 | + | if sessionIdx >= 0 { | |
| 335 | + | sm.selectedIndex = sessionIdx | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | sm.mu.Unlock() | |
| 339 | + | } | |
| 340 | + | sm.retainedCallbacks = append(sm.retainedCallbacks, rowSelectedCb) | |
| 341 | + | sm.listBox.ConnectRowSelected(&rowSelectedCb) | |
| 342 | + | ||
| 343 | + | sm.scrolledWindow.SetChild(&sm.listBox.Widget) | |
| 344 | + | return nil | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | func (sm *SessionManager) initFooter() error { | |
| 348 | + | footerText := "↑↓/jk navigate Enter expand r restore x delete Esc close" | |
| 349 | + | sm.footerLabel = gtk.NewLabel(&footerText) | |
| 350 | + | if sm.footerLabel == nil { | |
| 351 | + | return errNilWidget("sessionFooterLabel") | |
| 352 | + | } | |
| 353 | + | sm.footerLabel.AddCssClass("session-manager-footer") | |
| 354 | + | sm.footerLabel.SetHalign(gtk.AlignCenterValue) | |
| 355 | + | return nil | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | func (sm *SessionManager) attachKeyController() { | |
| 359 | + | controller := gtk.NewEventControllerKey() | |
| 360 | + | if controller == nil { | |
| 361 | + | return | |
| 362 | + | } | |
| 363 | + | controller.SetPropagationPhase(gtk.PhaseCaptureValue) | |
| 364 | + | ||
| 365 | + | keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool { | |
| 366 | + | return sm.handleKeyPress(keyval, state) | |
| 367 | + | } | |
| 368 | + | sm.retainedCallbacks = append(sm.retainedCallbacks, keyPressedCb) | |
| 369 | + | controller.ConnectKeyPressed(&keyPressedCb) | |
| 370 | + | sm.outerBox.AddController(&controller.EventController) | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | func (sm *SessionManager) handleKeyPress(keyval uint, _ gdk.ModifierType) bool { | |
| 374 | + | switch keyval { | |
| 375 | + | case uint(gdk.KEY_Escape): | |
| 376 | + | sm.Hide(sm.ctx) | |
| 377 | + | return true | |
| 378 | + | ||
| 379 | + | case uint(gdk.KEY_Up), uint(gdk.KEY_k): | |
| 380 | + | sm.selectPrevious() | |
| 381 | + | return true | |
| 382 | + | ||
| 383 | + | case uint(gdk.KEY_Down), uint(gdk.KEY_j): | |
| 384 | + | sm.selectNext() | |
| 385 | + | return true | |
| 386 | + | ||
| 387 | + | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter), uint(gdk.KEY_space): | |
| 388 | + | // Enter/Space expands/collapses to preview session contents | |
| 389 | + | sm.toggleExpanded() | |
| 390 | + | return true | |
| 391 | + | ||
| 392 | + | case uint(gdk.KEY_r), uint(gdk.KEY_o): | |
| 393 | + | // r/o restores the selected session | |
| 394 | + | sm.openSelected() | |
| 395 | + | return true | |
| 396 | + | ||
| 397 | + | case uint(gdk.KEY_x), uint(gdk.KEY_d), uint(gdk.KEY_Delete): | |
| 398 | + | sm.deleteSelected() | |
| 399 | + | return true | |
| 400 | + | } | |
| 401 | + | return false | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | func (sm *SessionManager) loadSessions() { | |
| 405 | + | log := logging.FromContext(sm.ctx) | |
| 406 | + | ||
| 407 | + | if sm.listSessionsUC == nil { | |
| 408 | + | log.Warn().Msg("listSessionsUC is nil") | |
| 409 | + | return | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | output, err := sm.listSessionsUC.Execute(sm.ctx, sm.currentSession, SessionManagerListDefaults.MaxResults) | |
| 413 | + | if err != nil { | |
| 414 | + | log.Error().Err(err).Msg("failed to list sessions") | |
| 415 | + | return | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | sm.mu.Lock() | |
| 419 | + | sm.sessions = output.Sessions | |
| 420 | + | sm.selectedIndex = 0 | |
| 421 | + | sm.mu.Unlock() | |
| 422 | + | ||
| 423 | + | // Update UI on main thread | |
| 424 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 425 | + | sm.populateList() | |
| 426 | + | return false | |
| 427 | + | }) | |
| 428 | + | glib.IdleAdd(&cb, 0) | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | // populateListState holds state during list population. | |
| 432 | + | type populateListState struct { | |
| 433 | + | listRowToSession []int | |
| 434 | + | listRowCount int | |
| 435 | + | sessionRowCount int | |
| 436 | + | treeRowCount int | |
| 437 | + | dividerCount int | |
| 438 | + | expandedIdx int | |
| 439 | + | firstExitedIdx int | |
| 440 | + | hasActive bool | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | func (sm *SessionManager) populateList() { | |
| 444 | + | sm.mu.Lock() | |
| 445 | + | sessions := sm.sessions | |
| 446 | + | selectedIdx := sm.selectedIndex | |
| 447 | + | sm.populatingList = true | |
| 448 | + | sm.sessionToListRow = make([]int, len(sessions)) | |
| 449 | + | sm.listRowToSession = nil | |
| 450 | + | expandedIdx := sm.expandedIndex | |
| 451 | + | sm.mu.Unlock() | |
| 452 | + | ||
| 453 | + | if sm.listBox == nil { | |
| 454 | + | sm.mu.Lock() | |
| 455 | + | sm.populatingList = false | |
| 456 | + | sm.mu.Unlock() | |
| 457 | + | return | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | sm.listBox.RemoveAll() | |
| 461 | + | ||
| 462 | + | state := &populateListState{expandedIdx: expandedIdx, firstExitedIdx: -1} | |
| 463 | + | for i, info := range sessions { | |
| 464 | + | if info.IsCurrent || info.IsActive { | |
| 465 | + | state.hasActive = true | |
| 466 | + | } else if state.firstExitedIdx == -1 { | |
| 467 | + | state.firstExitedIdx = i | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | for i, info := range sessions { | |
| 472 | + | sm.addSessionToList(i, info, state) | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | sm.mu.Lock() | |
| 476 | + | sm.listRowToSession = state.listRowToSession | |
| 477 | + | sm.populatingList = false | |
| 478 | + | sm.mu.Unlock() | |
| 479 | + | ||
| 480 | + | sm.selectBySessionIndex(selectedIdx) | |
| 481 | + | ||
| 482 | + | // Attempt to measure actual widget heights for accurate sizing | |
| 483 | + | width, _ := CalculateModalDimensions(sm.parentOverlay, SessionManagerSizeDefaults) | |
| 484 | + | sm.measureWidgetHeights(width) | |
| 485 | + | ||
| 486 | + | sm.resizeForContent(state.sessionRowCount, state.dividerCount, state.treeRowCount) | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | func (sm *SessionManager) addSessionToList(i int, info entity.SessionInfo, state *populateListState) { | |
| 490 | + | // Add divider before first exited session | |
| 491 | + | if i == state.firstExitedIdx && state.hasActive { | |
| 492 | + | if divider := sm.createDividerRow("EXITED"); divider != nil { | |
| 493 | + | sm.listBox.Append(÷r.Widget) | |
| 494 | + | state.listRowToSession = append(state.listRowToSession, -1) | |
| 495 | + | state.listRowCount++ | |
| 496 | + | state.dividerCount++ | |
| 497 | + | } | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | sm.mu.Lock() | |
| 501 | + | sm.sessionToListRow[i] = state.listRowCount | |
| 502 | + | sm.mu.Unlock() | |
| 503 | + | state.listRowToSession = append(state.listRowToSession, i) | |
| 504 | + | ||
| 505 | + | if row := sm.createSessionRow(info); row != nil { | |
| 506 | + | sm.listBox.Append(&row.Widget) | |
| 507 | + | state.listRowCount++ | |
| 508 | + | state.sessionRowCount++ | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | if i == state.expandedIdx && info.State != nil { | |
| 512 | + | for _, treeRow := range sm.createTreeRows(info.State) { | |
| 513 | + | sm.listBox.Append(&treeRow.Widget) | |
| 514 | + | state.listRowToSession = append(state.listRowToSession, -1) | |
| 515 | + | state.listRowCount++ | |
| 516 | + | state.treeRowCount++ | |
| 517 | + | } | |
| 518 | + | } | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | func (sm *SessionManager) selectBySessionIndex(sessionIdx int) { | |
| 522 | + | sm.mu.RLock() | |
| 523 | + | if sessionIdx < 0 || sessionIdx >= len(sm.sessionToListRow) { | |
| 524 | + | sm.mu.RUnlock() | |
| 525 | + | return | |
| 526 | + | } | |
| 527 | + | listRow := sm.sessionToListRow[sessionIdx] | |
| 528 | + | sm.mu.RUnlock() | |
| 529 | + | ||
| 530 | + | row := sm.listBox.GetRowAtIndex(listRow) | |
| 531 | + | if row == nil { | |
| 532 | + | return | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | sm.listBox.SelectRow(row) | |
| 536 | + | ||
| 537 | + | // Scroll the row into view using GrabFocus which triggers scroll | |
| 538 | + | row.GrabFocus() | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | func (sm *SessionManager) resizeForContent(sessionRowCount, dividerCount, treeRowCount int) { | |
| 542 | + | if sm.scrolledWindow == nil { | |
| 543 | + | return | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | // Get row heights - use measured values if available, otherwise scale defaults | |
| 547 | + | var sessionH, treeH, dividerH int | |
| 548 | + | if sm.measuredHeights.valid { | |
| 549 | + | sessionH = sm.measuredHeights.sessionRow | |
| 550 | + | treeH = sm.measuredHeights.treeRow | |
| 551 | + | dividerH = sm.measuredHeights.dividerRow | |
| 552 | + | } else { | |
| 553 | + | sessionH = ScaleValue(DefaultRowHeights.Standard, sm.uiScale) | |
| 554 | + | treeH = ScaleValue(DefaultRowHeights.Compact, sm.uiScale) | |
| 555 | + | dividerH = ScaleValue(DefaultRowHeights.Divider, sm.uiScale) | |
| 556 | + | } | |
| 557 | + | ||
| 558 | + | // Calculate actual content height | |
| 559 | + | contentHeight := sessionRowCount*sessionH + dividerCount*dividerH + treeRowCount*treeH | |
| 560 | + | ||
| 561 | + | // Calculate max visible height - limit to prevent oversized modal | |
| 562 | + | maxVisibleHeight := SessionManagerListDefaults.MaxVisibleRows*sessionH + dividerH | |
| 563 | + | ||
| 564 | + | // Cap at max visible height - rest will be scrollable | |
| 565 | + | if contentHeight > maxVisibleHeight { | |
| 566 | + | contentHeight = maxVisibleHeight | |
| 567 | + | } | |
| 568 | + | ||
| 569 | + | // Ensure minimum height for at least one row | |
| 570 | + | if contentHeight < sessionH { | |
| 571 | + | contentHeight = sessionH | |
| 572 | + | } | |
| 573 | + | ||
| 574 | + | // Schedule resize on main thread using shared helper | |
| 575 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 576 | + | SetScrolledWindowHeight(sm.scrolledWindow, contentHeight) | |
| 577 | + | sm.outerBox.QueueResize() | |
| 578 | + | return false | |
| 579 | + | }) | |
| 580 | + | glib.IdleAdd(&cb, 0) | |
| 581 | + | } | |
| 582 | + | ||
| 583 | + | // measureWidgetHeights attempts to measure actual row heights from the list. | |
| 584 | + | // Must be called after list is populated. Updates measuredHeights cache if successful. | |
| 585 | + | func (sm *SessionManager) measureWidgetHeights(forWidth int) { | |
| 586 | + | if sm.listBox == nil || sm.measuredHeights.valid { | |
| 587 | + | return | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | // We need to find different row types and measure them. | |
| 591 | + | // Session rows have "session-row" class, tree rows have "session-tree-row" class, | |
| 592 | + | // divider rows have "session-divider" class. | |
| 593 | + | ||
| 594 | + | // For now, measure using first row and estimate others based on ratio. | |
| 595 | + | // This is simpler and works well enough since we have fallback scaling. | |
| 596 | + | row := sm.listBox.GetRowAtIndex(0) | |
| 597 | + | if row == nil { | |
| 598 | + | return | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | sessionH := MeasureWidgetHeight(&row.Widget, forWidth) | |
| 602 | + | if sessionH <= 0 { | |
| 603 | + | return | |
| 604 | + | } | |
| 605 | + | ||
| 606 | + | // Estimate tree and divider heights based on ratio to standard row | |
| 607 | + | // These ratios match the original constants: tree=28/50, divider=30/50 | |
| 608 | + | treeH := sessionH * DefaultRowHeights.Compact / DefaultRowHeights.Standard | |
| 609 | + | dividerH := sessionH * DefaultRowHeights.Divider / DefaultRowHeights.Standard | |
| 610 | + | ||
| 611 | + | sm.measuredHeights.sessionRow = sessionH | |
| 612 | + | sm.measuredHeights.treeRow = treeH | |
| 613 | + | sm.measuredHeights.dividerRow = dividerH | |
| 614 | + | sm.measuredHeights.valid = true | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | // createTreeRows creates tree rows showing the tab/pane structure of a session. | |
| 618 | + | func (sm *SessionManager) createTreeRows(state *entity.SessionState) []*gtk.ListBoxRow { | |
| 619 | + | if state == nil { | |
| 620 | + | return nil | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | var rows []*gtk.ListBoxRow | |
| 624 | + | ||
| 625 | + | for tabIdx, tab := range state.Tabs { | |
| 626 | + | isLastTab := tabIdx == len(state.Tabs)-1 | |
| 627 | + | ||
| 628 | + | // Tab row | |
| 629 | + | tabRow := sm.createTreeRow(tab.Name, "tab", !isLastTab, 0) | |
| 630 | + | if tabRow != nil { | |
| 631 | + | rows = append(rows, tabRow) | |
| 632 | + | } | |
| 633 | + | ||
| 634 | + | // Pane rows under this tab | |
| 635 | + | paneRows := sm.createPaneTreeRows(&tab.Workspace) | |
| 636 | + | rows = append(rows, paneRows...) | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | return rows | |
| 640 | + | } | |
| 641 | + | ||
| 642 | + | // createPaneTreeRows creates rows for the pane tree under a tab. | |
| 643 | + | func (sm *SessionManager) createPaneTreeRows(ws *entity.WorkspaceSnapshot) []*gtk.ListBoxRow { | |
| 644 | + | if ws == nil || ws.Root == nil { | |
| 645 | + | return nil | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | var rows []*gtk.ListBoxRow | |
| 649 | + | sm.collectPaneRows(&rows, ws.Root, 1, true) | |
| 650 | + | return rows | |
| 651 | + | } | |
| 652 | + | ||
| 653 | + | // collectPaneRows recursively collects pane tree rows. | |
| 654 | + | func (sm *SessionManager) collectPaneRows( | |
| 655 | + | rows *[]*gtk.ListBoxRow, | |
| 656 | + | node *entity.PaneNodeSnapshot, | |
| 657 | + | depth int, | |
| 658 | + | isLast bool, | |
| 659 | + | ) { | |
| 660 | + | if node == nil { | |
| 661 | + | return | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | if node.Pane != nil { | |
| 665 | + | // Leaf node (actual pane) | |
| 666 | + | title := node.Pane.Title | |
| 667 | + | if title == "" { | |
| 668 | + | title = node.Pane.URI | |
| 669 | + | } | |
| 670 | + | if len(title) > sessionMaxTitleLen { | |
| 671 | + | title = title[:sessionMaxTitleLen-3] + "..." | |
| 672 | + | } | |
| 673 | + | ||
| 674 | + | row := sm.createTreeRow(title, "pane", !isLast, depth) | |
| 675 | + | if row != nil { | |
| 676 | + | *rows = append(*rows, row) | |
| 677 | + | } | |
| 678 | + | } else if len(node.Children) > 0 { | |
| 679 | + | // Container node (split) | |
| 680 | + | splitLabel := "split" | |
| 681 | + | if node.IsStacked { | |
| 682 | + | splitLabel = "stacked" | |
| 683 | + | } else if node.SplitRatio > 0 { | |
| 684 | + | splitLabel = fmt.Sprintf("split %.0f%%", node.SplitRatio*100) | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | row := sm.createTreeRow(splitLabel, "split", !isLast, depth) | |
| 688 | + | if row != nil { | |
| 689 | + | *rows = append(*rows, row) | |
| 690 | + | } | |
| 691 | + | ||
| 692 | + | // Recurse into children | |
| 693 | + | for i, child := range node.Children { | |
| 694 | + | isLastChild := i == len(node.Children)-1 | |
| 695 | + | sm.collectPaneRows(rows, child, depth+1, isLastChild) | |
| 696 | + | } | |
| 697 | + | } | |
| 698 | + | } | |
| 699 | + | ||
| 700 | + | // createTreeRow creates a single tree row with proper indentation and branch characters. | |
| 701 | + | func (sm *SessionManager) createTreeRow(text, nodeType string, hasSibling bool, depth int) *gtk.ListBoxRow { | |
| 702 | + | row := gtk.NewListBoxRow() | |
| 703 | + | if row == nil { | |
| 704 | + | return nil | |
| 705 | + | } | |
| 706 | + | row.SetSelectable(false) | |
| 707 | + | row.SetActivatable(false) | |
| 708 | + | row.AddCssClass("session-tree-row") | |
| 709 | + | ||
| 710 | + | hbox := gtk.NewBox(gtk.OrientationHorizontalValue, 4) | |
| 711 | + | if hbox == nil { | |
| 712 | + | return nil | |
| 713 | + | } | |
| 714 | + | hbox.AddCssClass("session-tree-content") | |
| 715 | + | ||
| 716 | + | // Build indent and branch prefix | |
| 717 | + | var prefix string | |
| 718 | + | indent := " " // Base indent (minimal, tree branches provide visual alignment) | |
| 719 | + | for i := 0; i < depth; i++ { | |
| 720 | + | indent += " " // 3 spaces per depth level | |
| 721 | + | } | |
| 722 | + | ||
| 723 | + | // Branch character | |
| 724 | + | if hasSibling { | |
| 725 | + | prefix = "├── " | |
| 726 | + | } else { | |
| 727 | + | prefix = "└── " | |
| 728 | + | } | |
| 729 | + | ||
| 730 | + | // Icon based on node type | |
| 731 | + | var icon string | |
| 732 | + | switch nodeType { | |
| 733 | + | case "tab": | |
| 734 | + | icon = " " // Tab icon (nerd font) | |
| 735 | + | case "pane": | |
| 736 | + | icon = " " // Pane icon | |
| 737 | + | case "split": | |
| 738 | + | icon = " " // Split icon | |
| 739 | + | default: | |
| 740 | + | icon = " " | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | // Create the label with all parts | |
| 744 | + | labelText := indent + prefix + icon + text | |
| 745 | + | label := gtk.NewLabel(&labelText) | |
| 746 | + | if label == nil { | |
| 747 | + | return nil | |
| 748 | + | } | |
| 749 | + | label.AddCssClass("session-tree-label") | |
| 750 | + | label.SetHalign(gtk.AlignStartValue) | |
| 751 | + | label.SetEllipsize(2) // PANGO_ELLIPSIZE_END | |
| 752 | + | ||
| 753 | + | hbox.Append(&label.Widget) | |
| 754 | + | row.SetChild(&hbox.Widget) | |
| 755 | + | ||
| 756 | + | return row | |
| 757 | + | } | |
| 758 | + | ||
| 759 | + | func (sm *SessionManager) createDividerRow(text string) *gtk.ListBoxRow { | |
| 760 | + | row := gtk.NewListBoxRow() | |
| 761 | + | if row == nil { | |
| 762 | + | return nil | |
| 763 | + | } | |
| 764 | + | row.SetSelectable(false) | |
| 765 | + | row.SetActivatable(false) | |
| 766 | + | ||
| 767 | + | label := gtk.NewLabel(&text) | |
| 768 | + | if label == nil { | |
| 769 | + | return nil | |
| 770 | + | } | |
| 771 | + | label.AddCssClass("session-divider") | |
| 772 | + | label.SetHalign(gtk.AlignStartValue) | |
| 773 | + | row.SetChild(&label.Widget) | |
| 774 | + | ||
| 775 | + | return row | |
| 776 | + | } | |
| 777 | + | ||
| 778 | + | func (sm *SessionManager) createSessionRow(info entity.SessionInfo) *gtk.ListBoxRow { | |
| 779 | + | row := gtk.NewListBoxRow() | |
| 780 | + | if row == nil { | |
| 781 | + | return nil | |
| 782 | + | } | |
| 783 | + | row.AddCssClass("session-manager-row-wrapper") | |
| 784 | + | ||
| 785 | + | hbox := gtk.NewBox(gtk.OrientationHorizontalValue, sessionRowBoxSpacing) | |
| 786 | + | if hbox == nil { | |
| 787 | + | return nil | |
| 788 | + | } | |
| 789 | + | hbox.AddCssClass("session-manager-row") | |
| 790 | + | ||
| 791 | + | sm.addStatusIndicator(hbox, info) | |
| 792 | + | sm.addSessionInfo(hbox, info) | |
| 793 | + | sm.addRelativeTime(hbox, info) | |
| 794 | + | ||
| 795 | + | row.SetChild(&hbox.Widget) | |
| 796 | + | return row | |
| 797 | + | } | |
| 798 | + | ||
| 799 | + | func (sm *SessionManager) addStatusIndicator(hbox *gtk.Box, info entity.SessionInfo) { | |
| 800 | + | var statusText string | |
| 801 | + | switch { | |
| 802 | + | case info.IsCurrent: | |
| 803 | + | statusText = "●" // Filled dot for current | |
| 804 | + | hbox.AddCssClass("session-current") | |
| 805 | + | case info.IsActive: | |
| 806 | + | statusText = "○" // Empty dot for other active instance | |
| 807 | + | hbox.AddCssClass("session-active") | |
| 808 | + | default: | |
| 809 | + | statusText = "◼" // Small square for exited (like stop icon) | |
| 810 | + | hbox.AddCssClass("session-exited") | |
| 811 | + | } | |
| 812 | + | statusLabel := gtk.NewLabel(&statusText) | |
| 813 | + | if statusLabel != nil { | |
| 814 | + | statusLabel.AddCssClass("session-status") | |
| 815 | + | hbox.Append(&statusLabel.Widget) | |
| 816 | + | } | |
| 817 | + | } | |
| 818 | + | ||
| 819 | + | func (sm *SessionManager) addSessionInfo(hbox *gtk.Box, info entity.SessionInfo) { | |
| 820 | + | const infoBoxSpacing = 2 | |
| 821 | + | infoBox := gtk.NewBox(gtk.OrientationVerticalValue, infoBoxSpacing) | |
| 822 | + | if infoBox == nil { | |
| 823 | + | return | |
| 824 | + | } | |
| 825 | + | infoBox.SetHexpand(true) | |
| 826 | + | ||
| 827 | + | // Session ID row: shortID (accent) + rest of ID (dimmed) + optional (current) badge | |
| 828 | + | idRowBox := gtk.NewBox(gtk.OrientationHorizontalValue, 0) | |
| 829 | + | if idRowBox != nil { | |
| 830 | + | idRowBox.SetHalign(gtk.AlignStartValue) | |
| 831 | + | ||
| 832 | + | fullID := string(info.Session.ID) | |
| 833 | + | shortID := info.Session.ShortID() // Last 4 chars | |
| 834 | + | ||
| 835 | + | // Short ID with accent color (displayed first) | |
| 836 | + | shortLabel := gtk.NewLabel(&shortID) | |
| 837 | + | if shortLabel != nil { | |
| 838 | + | shortLabel.AddCssClass("session-id-short") | |
| 839 | + | idRowBox.Append(&shortLabel.Widget) | |
| 840 | + | } | |
| 841 | + | ||
| 842 | + | // Rest of the ID (prefix before the short suffix), dimmed | |
| 843 | + | // Format: "20251225_083026_7566" -> shortID="7566", prefix="20251225_083026" | |
| 844 | + | // Remove trailing underscore for cleaner display | |
| 845 | + | if len(fullID) > 4 { | |
| 846 | + | prefix := fullID[:len(fullID)-4] | |
| 847 | + | // Trim trailing underscore if present | |
| 848 | + | prefix = strings.TrimSuffix(prefix, "_") | |
| 849 | + | prefix = " " + prefix | |
| 850 | + | prefixLabel := gtk.NewLabel(&prefix) | |
| 851 | + | if prefixLabel != nil { | |
| 852 | + | prefixLabel.AddCssClass("session-id-prefix") | |
| 853 | + | idRowBox.Append(&prefixLabel.Widget) | |
| 854 | + | } | |
| 855 | + | } | |
| 856 | + | ||
| 857 | + | // Current badge | |
| 858 | + | if info.IsCurrent { | |
| 859 | + | badge := " (current)" | |
| 860 | + | badgeLabel := gtk.NewLabel(&badge) | |
| 861 | + | if badgeLabel != nil { | |
| 862 | + | badgeLabel.AddCssClass("session-id-badge") | |
| 863 | + | idRowBox.Append(&badgeLabel.Widget) | |
| 864 | + | } | |
| 865 | + | } | |
| 866 | + | ||
| 867 | + | infoBox.Append(&idRowBox.Widget) | |
| 868 | + | } | |
| 869 | + | ||
| 870 | + | // Tab/pane count | |
| 871 | + | countText := fmt.Sprintf("%d tabs, %d panes", info.TabCount, info.PaneCount) | |
| 872 | + | countLabel := gtk.NewLabel(&countText) | |
| 873 | + | if countLabel != nil { | |
| 874 | + | countLabel.AddCssClass("session-count") | |
| 875 | + | countLabel.SetHalign(gtk.AlignStartValue) | |
| 876 | + | infoBox.Append(&countLabel.Widget) | |
| 877 | + | } | |
| 878 | + | ||
| 879 | + | hbox.Append(&infoBox.Widget) | |
| 880 | + | } | |
| 881 | + | ||
| 882 | + | func (sm *SessionManager) addRelativeTime(hbox *gtk.Box, info entity.SessionInfo) { | |
| 883 | + | relTime := usecase.GetRelativeTime(info.UpdatedAt) | |
| 884 | + | timeLabel := gtk.NewLabel(&relTime) | |
| 885 | + | if timeLabel != nil { | |
| 886 | + | timeLabel.AddCssClass("session-time") | |
| 887 | + | timeLabel.SetValign(gtk.AlignCenterValue) | |
| 888 | + | hbox.Append(&timeLabel.Widget) | |
| 889 | + | } | |
| 890 | + | } | |
| 891 | + | ||
| 892 | + | func (sm *SessionManager) selectPrevious() { | |
| 893 | + | sm.mu.Lock() | |
| 894 | + | maxIndex := len(sm.sessions) - 1 | |
| 895 | + | if maxIndex < 0 { | |
| 896 | + | sm.mu.Unlock() | |
| 897 | + | return | |
| 898 | + | } | |
| 899 | + | sm.selectedIndex-- | |
| 900 | + | if sm.selectedIndex < 0 { | |
| 901 | + | sm.selectedIndex = maxIndex // Wrap to bottom | |
| 902 | + | } | |
| 903 | + | idx := sm.selectedIndex | |
| 904 | + | sm.mu.Unlock() | |
| 905 | + | ||
| 906 | + | sm.selectBySessionIndex(idx) | |
| 907 | + | } | |
| 908 | + | ||
| 909 | + | func (sm *SessionManager) selectNext() { | |
| 910 | + | sm.mu.Lock() | |
| 911 | + | maxIndex := len(sm.sessions) - 1 | |
| 912 | + | if maxIndex < 0 { | |
| 913 | + | sm.mu.Unlock() | |
| 914 | + | return | |
| 915 | + | } | |
| 916 | + | sm.selectedIndex++ | |
| 917 | + | if sm.selectedIndex > maxIndex { | |
| 918 | + | sm.selectedIndex = 0 // Wrap to top | |
| 919 | + | } | |
| 920 | + | idx := sm.selectedIndex | |
| 921 | + | sm.mu.Unlock() | |
| 922 | + | ||
| 923 | + | sm.selectBySessionIndex(idx) | |
| 924 | + | } | |
| 925 | + | ||
| 926 | + | func (sm *SessionManager) toggleExpanded() { | |
| 927 | + | sm.mu.Lock() | |
| 928 | + | if sm.expandedIndex == sm.selectedIndex { | |
| 929 | + | sm.expandedIndex = -1 // collapse | |
| 930 | + | } else { | |
| 931 | + | sm.expandedIndex = sm.selectedIndex // expand | |
| 932 | + | } | |
| 933 | + | sm.mu.Unlock() | |
| 934 | + | ||
| 935 | + | // Rebuild the list to show/hide tree | |
| 936 | + | sm.populateList() | |
| 937 | + | } | |
| 938 | + | ||
| 939 | + | func (sm *SessionManager) openSelected() { | |
| 940 | + | sm.mu.RLock() | |
| 941 | + | if sm.selectedIndex < 0 || sm.selectedIndex >= len(sm.sessions) { | |
| 942 | + | sm.mu.RUnlock() | |
| 943 | + | return | |
| 944 | + | } | |
| 945 | + | info := sm.sessions[sm.selectedIndex] | |
| 946 | + | sm.mu.RUnlock() | |
| 947 | + | ||
| 948 | + | log := logging.FromContext(sm.ctx) | |
| 949 | + | ||
| 950 | + | // Don't open current session | |
| 951 | + | if info.IsCurrent { | |
| 952 | + | log.Debug().Msg("cannot open current session") | |
| 953 | + | return | |
| 954 | + | } | |
| 955 | + | ||
| 956 | + | log.Info().Str("session_id", string(info.Session.ID)).Msg("opening session") | |
| 957 | + | ||
| 958 | + | sm.Hide(sm.ctx) | |
| 959 | + | ||
| 960 | + | if sm.onOpen != nil { | |
| 961 | + | sm.onOpen(info.Session.ID) | |
| 962 | + | } | |
| 963 | + | } | |
| 964 | + | ||
| 965 | + | func (sm *SessionManager) deleteSelected() { | |
| 966 | + | sm.mu.RLock() | |
| 967 | + | if sm.selectedIndex < 0 || sm.selectedIndex >= len(sm.sessions) { | |
| 968 | + | sm.mu.RUnlock() | |
| 969 | + | return | |
| 970 | + | } | |
| 971 | + | info := sm.sessions[sm.selectedIndex] | |
| 972 | + | sm.mu.RUnlock() | |
| 973 | + | ||
| 974 | + | log := logging.FromContext(sm.ctx) | |
| 975 | + | ||
| 976 | + | // Don't delete current or active sessions (early check before async) | |
| 977 | + | if info.IsCurrent || info.IsActive { | |
| 978 | + | log.Debug().Msg("cannot delete current or active session") | |
| 979 | + | if sm.onToast != nil { | |
| 980 | + | sm.onToast(sm.ctx, "Cannot delete active session", ToastWarning) | |
| 981 | + | } | |
| 982 | + | return | |
| 983 | + | } | |
| 984 | + | ||
| 985 | + | if sm.deleteSessionUC == nil { | |
| 986 | + | log.Error().Msg("delete session use case not available") | |
| 987 | + | return | |
| 988 | + | } | |
| 989 | + | ||
| 990 | + | sessionID := info.Session.ID | |
| 991 | + | log.Info().Str("session_id", string(sessionID)).Msg("deleting session") | |
| 992 | + | ||
| 993 | + | go func() { | |
| 994 | + | err := sm.deleteSessionUC.Execute(sm.ctx, usecase.DeleteSessionInput{ | |
| 995 | + | SessionID: sessionID, | |
| 996 | + | CurrentSessionID: sm.currentSession, | |
| 997 | + | }) | |
| 998 | + | ||
| 999 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 1000 | + | if err != nil { | |
| 1001 | + | log.Error().Err(err).Msg("failed to delete session") | |
| 1002 | + | if sm.onToast != nil { | |
| 1003 | + | sm.onToast(sm.ctx, "Failed to delete session", ToastError) | |
| 1004 | + | } | |
| 1005 | + | } else if sm.onToast != nil { | |
| 1006 | + | sm.onToast(sm.ctx, "Session deleted", ToastSuccess) | |
| 1007 | + | } | |
| 1008 | + | sm.loadSessions() | |
| 1009 | + | return false | |
| 1010 | + | }) | |
| 1011 | + | glib.IdleAdd(&cb, 0) | |
| 1012 | + | }() | |
| 1013 | + | } | |
| 1014 | + | ||
| 1015 | + | // Destroy cleans up session manager resources. | |
| 1016 | + | func (sm *SessionManager) Destroy() { | |
| 1017 | + | sm.mu.Lock() | |
| 1018 | + | sm.visible = false | |
| 1019 | + | sm.mu.Unlock() | |
| 1020 | + | ||
| 1021 | + | sm.parentOverlay = nil | |
| 1022 | + | sm.retainedCallbacks = nil | |
| 1023 | + | ||
| 1024 | + | if sm.outerBox != nil { | |
| 1025 | + | sm.outerBox.Unparent() | |
| 1026 | + | sm.outerBox = nil | |
| 1027 | + | } | |
| 1028 | + | } | |
M internal/ui/coordinator/content.go +22 -0
| @@ -46,6 +46,9 @@ type ContentCoordinator struct { | |||
| 46 | 46 | // Callback when page is committed (for history recording) | |
| 47 | 47 | onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string) | |
| 48 | 48 | ||
| 49 | + | // Callback when pane URI changes (for session snapshots) | |
| 50 | + | onPaneURIUpdated func(paneID entity.PaneID, url string) | |
| 51 | + | ||
| 49 | 52 | // Gesture action handler for mouse button navigation | |
| 50 | 53 | gestureActionHandler input.ActionHandler | |
| 51 | 54 | ||
| @@ -103,6 +106,11 @@ func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, pan | |||
| 103 | 106 | c.onHistoryRecord = fn | |
| 104 | 107 | } | |
| 105 | 108 | ||
| 109 | + | // SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots). | |
| 110 | + | func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) { | |
| 111 | + | c.onPaneURIUpdated = fn | |
| 112 | + | } | |
| 113 | + | ||
| 106 | 114 | // SetGestureActionHandler sets the callback for mouse button navigation gestures. | |
| 107 | 115 | func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) { | |
| 108 | 116 | c.gestureActionHandler = handler | |
| @@ -655,6 +663,9 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity. | |||
| 655 | 663 | return | |
| 656 | 664 | } | |
| 657 | 665 | ||
| 666 | + | // Update domain model with current URI for session snapshots | |
| 667 | + | c.updatePaneURI(paneID, url) | |
| 668 | + | ||
| 658 | 669 | // Record history - URI is guaranteed to be correct at LoadCommitted | |
| 659 | 670 | if c.onHistoryRecord != nil { | |
| 660 | 671 | c.onHistoryRecord(ctx, paneID, url) | |
| @@ -679,12 +690,23 @@ func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity. | |||
| 679 | 690 | log := logging.FromContext(ctx) | |
| 680 | 691 | log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected") | |
| 681 | 692 | ||
| 693 | + | // Update domain model with current URI for session snapshots | |
| 694 | + | c.updatePaneURI(paneID, url) | |
| 695 | + | ||
| 682 | 696 | // Record history for SPA navigation | |
| 683 | 697 | if c.onHistoryRecord != nil { | |
| 684 | 698 | c.onHistoryRecord(ctx, paneID, url) | |
| 685 | 699 | } | |
| 686 | 700 | } | |
| 687 | 701 | ||
| 702 | + | // updatePaneURI updates the pane's URI in the domain model. | |
| 703 | + | // This is called on navigation so that session snapshots capture the current URL. | |
| 704 | + | func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, url string) { | |
| 705 | + | if c.onPaneURIUpdated != nil { | |
| 706 | + | c.onPaneURIUpdated(paneID, url) | |
| 707 | + | } | |
| 708 | + | } | |
| 709 | + | ||
| 688 | 710 | // onLoadStarted shows the progress bar when page loading begins. | |
| 689 | 711 | func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) { | |
| 690 | 712 | _, wsView := c.getActiveWS() | |
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 | } | |
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)). | |
M internal/ui/deps.go +11 -3
| @@ -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" | |
| @@ -18,9 +19,10 @@ import ( | |||
| 18 | 19 | // This struct is created once at startup and passed to UI components. | |
| 19 | 20 | type Dependencies struct { | |
| 20 | 21 | // Core context and configuration | |
| 21 | - | Ctx context.Context | |
| 22 | - | Config *config.Config | |
| 23 | - | InitialURL string // URL to open on startup (optional) | |
| 22 | + | Ctx context.Context | |
| 23 | + | Config *config.Config | |
| 24 | + | InitialURL string // URL to open on startup (optional) | |
| 25 | + | RestoreSessionID string // Session ID to restore on startup (optional) | |
| 24 | 26 | ||
| 25 | 27 | // Theme management | |
| 26 | 28 | Theme *theme.Manager | |
| @@ -53,6 +55,12 @@ type Dependencies struct { | |||
| 53 | 55 | FaviconService *favicon.Service | |
| 54 | 56 | FilterManager *filtering.Manager | |
| 55 | 57 | IdleInhibitor port.IdleInhibitor | |
| 58 | + | ||
| 59 | + | // Session management | |
| 60 | + | SessionStateRepo repository.SessionStateRepository | |
| 61 | + | SessionRepo repository.SessionRepository | |
| 62 | + | CurrentSessionID entity.SessionID | |
| 63 | + | SnapshotUC *usecase.SnapshotSessionUseCase | |
| 56 | 64 | } | |
| 57 | 65 | ||
| 58 | 66 | // 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") | |
M internal/ui/focus/borders.go +8 -2
| @@ -10,8 +10,9 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | // CSS classes for mode borders. | |
| 12 | 12 | const ( | |
| 13 | - | paneModeClass = "pane-mode-active" | |
| 14 | - | tabModeClass = "tab-mode-active" | |
| 13 | + | paneModeClass = "pane-mode-active" | |
| 14 | + | tabModeClass = "tab-mode-active" | |
| 15 | + | sessionModeClass = "session-mode-active" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 18 | // BorderManager manages mode indicator borders on the workspace container. | |
| @@ -58,6 +59,8 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode) | |||
| 58 | 59 | bm.borderOverlay.RemoveCssClass(paneModeClass) | |
| 59 | 60 | case input.ModeTab: | |
| 60 | 61 | bm.borderOverlay.RemoveCssClass(tabModeClass) | |
| 62 | + | case input.ModeSession: | |
| 63 | + | bm.borderOverlay.RemoveCssClass(sessionModeClass) | |
| 61 | 64 | } | |
| 62 | 65 | ||
| 63 | 66 | // Add new mode class and set visibility | |
| @@ -68,6 +71,9 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode) | |||
| 68 | 71 | case input.ModeTab: | |
| 69 | 72 | bm.borderOverlay.AddCssClass(tabModeClass) | |
| 70 | 73 | bm.borderOverlay.SetVisible(true) | |
| 74 | + | case input.ModeSession: | |
| 75 | + | bm.borderOverlay.AddCssClass(sessionModeClass) | |
| 76 | + | bm.borderOverlay.SetVisible(true) | |
| 71 | 77 | case input.ModeNormal: | |
| 72 | 78 | bm.borderOverlay.SetVisible(false) | |
| 73 | 79 | } | |
M internal/ui/input/global_shortcuts.go +7 -0
| @@ -84,6 +84,13 @@ func NewGlobalShortcutHandler( | |||
| 84 | 84 | Str("action", string(ActionSwitchLastTab)). | |
| 85 | 85 | Msg("registered global shortcut") | |
| 86 | 86 | ||
| 87 | + | // Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus) | |
| 88 | + | h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager) | |
| 89 | + | log.Trace(). | |
| 90 | + | Uint("keyval", uint(gdk.KEY_s)). | |
| 91 | + | Str("action", string(ActionOpenSessionManager)). | |
| 92 | + | Msg("registered global shortcut") | |
| 93 | + | ||
| 87 | 94 | // Attach to window | |
| 88 | 95 | window.AddController(&h.controller.EventController) | |
| 89 | 96 | ||
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() { | |
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) | |
M internal/ui/input/shortcuts.go +58 -21
| @@ -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 | ||
| @@ -278,6 +306,8 @@ func (s *ShortcutSet) registerStandardShortcuts() { | |||
| 278 | 306 | s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit | |
| 279 | 307 | s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen | |
| 280 | 308 | s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL | |
| 309 | + | // Session management - direct shortcut to open session manager | |
| 310 | + | s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager | |
| 281 | 311 | } | |
| 282 | 312 | ||
| 283 | 313 | func (s *ShortcutSet) registerPaneNavigationShortcuts() { | |
| @@ -379,6 +409,10 @@ func mapConfigAction(configAction string) Action { | |||
| 379 | 409 | case "stack-nav-down", "stack-down": | |
| 380 | 410 | return ActionStackNavDown | |
| 381 | 411 | ||
| 412 | + | // Session actions | |
| 413 | + | case "session-manager": | |
| 414 | + | return ActionOpenSessionManager | |
| 415 | + | ||
| 382 | 416 | default: | |
| 383 | 417 | return "" | |
| 384 | 418 | } | |
| @@ -454,6 +488,8 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) { | |||
| 454 | 488 | modeTable = s.TabMode | |
| 455 | 489 | case ModePane: | |
| 456 | 490 | modeTable = s.PaneMode | |
| 491 | + | case ModeSession: | |
| 492 | + | modeTable = s.SessionMode | |
| 457 | 493 | } | |
| 458 | 494 | ||
| 459 | 495 | if modeTable != nil { | |
| @@ -475,7 +511,8 @@ func ShouldAutoExitMode(action Action) bool { | |||
| 475 | 511 | switch action { | |
| 476 | 512 | case ActionNewTab, ActionCloseTab, ActionRenameTab, | |
| 477 | 513 | ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown, | |
| 478 | - | ActionClosePane, ActionStackPane: | |
| 514 | + | ActionClosePane, ActionStackPane, | |
| 515 | + | ActionOpenSessionManager: | |
| 479 | 516 | return true | |
| 480 | 517 | default: | |
| 481 | 518 | return false | |
M internal/ui/theme/css.go +166 -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,168 @@ 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 - dimmed appearance with subtle stop icon */ | |
| 647 | + | .session-exited { | |
| 648 | + | opacity: 0.75; | |
| 649 | + | } | |
| 650 | + | ||
| 651 | + | .session-exited .session-status { | |
| 652 | + | color: var(--error); | |
| 653 | + | opacity: 0.5; | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | /* Session ID labels */ | |
| 657 | + | .session-id-short { | |
| 658 | + | font-size: 0.875em; | |
| 659 | + | font-weight: 600; | |
| 660 | + | color: var(--accent); | |
| 661 | + | font-family: var(--font-mono); | |
| 662 | + | } | |
| 663 | + | ||
| 664 | + | .session-id-prefix { | |
| 665 | + | font-size: 0.875em; | |
| 666 | + | font-weight: 400; | |
| 667 | + | color: var(--muted); | |
| 668 | + | font-family: var(--font-mono); | |
| 669 | + | } | |
| 670 | + | ||
| 671 | + | .session-id-badge { | |
| 672 | + | font-size: 0.75em; | |
| 673 | + | font-weight: 500; | |
| 674 | + | color: var(--accent); | |
| 675 | + | margin-left: 0.5em; | |
| 676 | + | } | |
| 677 | + | ||
| 678 | + | /* Tab/pane count label */ | |
| 679 | + | .session-count { | |
| 680 | + | font-size: 0.75em; | |
| 681 | + | color: var(--muted); | |
| 682 | + | } | |
| 683 | + | ||
| 684 | + | /* Relative time label */ | |
| 685 | + | .session-time { | |
| 686 | + | font-size: 0.75em; | |
| 687 | + | color: var(--muted); | |
| 688 | + | margin-left: auto; | |
| 689 | + | } | |
| 690 | + | ||
| 691 | + | /* Section divider for EXITED sessions */ | |
| 692 | + | .session-divider { | |
| 693 | + | font-size: 0.6875em; | |
| 694 | + | font-weight: 600; | |
| 695 | + | color: var(--muted); | |
| 696 | + | text-transform: uppercase; | |
| 697 | + | letter-spacing: 0.05em; | |
| 698 | + | padding: 0.5em 0.75em 0.25em 0.75em; | |
| 699 | + | background-color: alpha(var(--border), 0.3); | |
| 700 | + | border-bottom: 0.0625em solid var(--border); | |
| 701 | + | } | |
| 702 | + | ||
| 703 | + | /* Footer with keyboard shortcuts */ | |
| 704 | + | .session-manager-footer { | |
| 705 | + | background-color: shade(var(--surface-variant), 0.9); | |
| 706 | + | border-top: 0.0625em solid var(--border); | |
| 707 | + | padding: 0.375em 0.75em; | |
| 708 | + | font-size: 0.6875em; | |
| 709 | + | color: var(--muted); | |
| 710 | + | font-family: var(--font-mono); | |
| 711 | + | } | |
| 712 | + | ||
| 713 | + | /* Session mode border (purple) */ | |
| 714 | + | .session-mode-active { | |
| 715 | + | background-color: transparent; | |
| 716 | + | box-shadow: inset 0 0 0 0.25em #9B59B6; | |
| 717 | + | border-radius: 0; | |
| 718 | + | } | |
| 719 | + | ` | |
| 720 | + | } | |
| 721 | + | ||
| 556 | 722 | // generateToasterCSS creates toast notification styles. | |
| 557 | 723 | // Uses em units for scalable UI. | |
| 558 | 724 | func generateToasterCSS(p Palette) string { | |