feat(session): implement auto-restore and update docs
- commit
- 4fc8d20ce4a212f1a1fc470b9c89f9ead975827b
- author
- bnema · · 2025-12-25 11:17 UTC
- parent
- e24e5cf
- signature
- GPG Unverified
2B82207D370127A0The key is unknown
7 files · +663 -29
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. | |
| @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. | |||||
| 4 | 4 | ||||
| 5 | ## [Unreleased] | 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 | ### Fixed | 16 | ### Fixed | ||
| 8 | - **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. | 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 | - **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. | 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) | |
| @@ -23,6 +23,7 @@ A dumb browser that works like your favorite terminal multiplexer. | |||||
| 23 | - **Customizable themes**: Light and dark palettes with semantic color tokens. | 23 | - **Customizable themes**: Light and dark palettes with semantic color tokens. | ||
| 24 | - **Persistent storage**: SQLite for history, zoom levels, and settings. | 24 | - **Persistent storage**: SQLite for history, zoom levels, and settings. | ||
| 25 | - **Live configuration**: Single config file with hot reload when possible. | 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 | ## Status | 28 | ## Status | ||
| 28 | Dumber recently completed a full rewrite in pure Go using the `puregotk` and `puregotk-webkit` libraries. | 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 | | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes | | 80 | | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes | | ||
| 80 | | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes | | 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 | ### Mouse Controls | 91 | ### Mouse Controls | ||
| 83 | | Action | Result | Notes | | 92 | | Action | Result | Notes | | ||
| 84 | |--------|---------|-------| | 93 | |--------|---------|-------| | ||
| @@ -231,6 +240,12 @@ sudo apt install va-driver-all | |||||
| 231 | - `dumber purge --force` # purge all data (no confirmation) | 240 | - `dumber purge --force` # purge all data (no confirmation) | ||
| 232 | - `dumber purge -d -H -c` # purge database and both caches | 241 | - `dumber purge -d -H -c` # purge database and both caches | ||
| 233 | - `dumber purge --browser-data` # purge WebKit data (cookies, etc.) | 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 | - Manage logs: | 249 | - Manage logs: | ||
| 235 | - `dumber logs list` # list available log files | 250 | - `dumber logs list` # list available log files | ||
| 236 | - `dumber logs tail` # tail current log file | 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 | 3. **Navigate**: Use `Alt+Arrow Keys` to move focus between split panes, or `Alt+Up/Down` to navigate within stacked panes | 291 | 3. **Navigate**: Use `Alt+Arrow Keys` to move focus between split panes, or `Alt+Up/Down` to navigate within stacked panes | ||
| 277 | 4. **Close**: Press `X` in pane mode to close the current pane | 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 | ## Configuration | 307 | ## Configuration | ||
| 280 | 308 | ||||
| 281 | Configuration file: `~/.config/dumber/config.toml` (supports TOML, JSON, YAML) | 309 | Configuration file: `~/.config/dumber/config.toml` (supports TOML, JSON, YAML) | ||
M cmd/dumber/main.go +50 -28
| @@ -107,16 +107,36 @@ func runGUI() int { | |||
| 107 | 107 | _ = browserSession.End(sessionCtx) | |
| 108 | 108 | }() | |
| 109 | 109 | ||
| 110 | - | logger := browserSession.Logger | |
| 111 | 110 | ctx = sessionCtx | |
| 111 | + | log := logging.FromContext(ctx) | |
| 112 | 112 | ||
| 113 | - | checkRuntimeRequirements(ctx, cfg, logger) | |
| 114 | - | checkMediaRequirements(ctx, cfg, logger) | |
| 113 | + | checkRuntimeRequirements(ctx, cfg, *log) | |
| 114 | + | checkMediaRequirements(ctx, cfg, *log) | |
| 115 | 115 | ||
| 116 | 116 | themeManager := theme.NewManager(ctx, cfg) | |
| 117 | - | stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger) | |
| 117 | + | stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, *log) | |
| 118 | 118 | repos := createRepositories(db) | |
| 119 | - | 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 | + | ||
| 120 | 140 | idleInhibitor := idle.NewPortalInhibitor(ctx) | |
| 121 | 141 | defer func() { | |
| 122 | 142 | if idleInhibitor != nil { | |
| @@ -127,7 +147,7 @@ func runGUI() int { | |||
| 127 | 147 | ||
| 128 | 148 | app, err := ui.New(uiDeps) | |
| 129 | 149 | if err != nil { | |
| 130 | - | logger.Error().Err(err).Msg("failed to create application") | |
| 150 | + | log.Error().Err(err).Msg("failed to create application") | |
| 131 | 151 | return 1 | |
| 132 | 152 | } | |
| 133 | 153 | ||
| @@ -139,7 +159,7 @@ func runGUI() int { | |||
| 139 | 159 | defer signal.Stop(sigCh) | |
| 140 | 160 | go func() { | |
| 141 | 161 | <-sigCh | |
| 142 | - | logger.Info().Msg("received interrupt, quitting") | |
| 162 | + | log.Info().Msg("received interrupt, quitting") | |
| 143 | 163 | app.Quit() | |
| 144 | 164 | }() | |
| 145 | 165 | ||
| @@ -254,19 +274,20 @@ func createRepositories(db *sql.DB) *repositories { | |||
| 254 | 274 | ||
| 255 | 275 | // useCases groups application layer use case implementations. | |
| 256 | 276 | type useCases struct { | |
| 257 | - | tabs *usecase.ManageTabsUseCase | |
| 258 | - | panes *usecase.ManagePanesUseCase | |
| 259 | - | history *usecase.SearchHistoryUseCase | |
| 260 | - | favorites *usecase.ManageFavoritesUseCase | |
| 261 | - | zoom *usecase.ManageZoomUseCase | |
| 262 | - | navigate *usecase.NavigateUseCase | |
| 263 | - | copyURL *usecase.CopyURLUseCase | |
| 264 | - | snapshot *usecase.SnapshotSessionUseCase | |
| 265 | - | clipboard port.Clipboard | |
| 266 | - | 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 | |
| 267 | 288 | } | |
| 268 | 289 | ||
| 269 | - | func createUseCases(repos *repositories, cfg *config.Config) *useCases { | |
| 290 | + | func createUseCases(repos *repositories, cfg *config.Config, stateDir string) *useCases { | |
| 270 | 291 | const idAlphabetSize = 26 | |
| 271 | 292 | idCounter := uint64(0) | |
| 272 | 293 | idGenerator := func() string { | |
| @@ -282,16 +303,17 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases { | |||
| 282 | 303 | zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize) | |
| 283 | 304 | ||
| 284 | 305 | return &useCases{ | |
| 285 | - | tabs: usecase.NewManageTabsUseCase(idGenerator), | |
| 286 | - | panes: usecase.NewManagePanesUseCase(idGenerator), | |
| 287 | - | history: usecase.NewSearchHistoryUseCase(repos.history), | |
| 288 | - | favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag), | |
| 289 | - | zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache), | |
| 290 | - | navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom), | |
| 291 | - | copyURL: usecase.NewCopyURLUseCase(clipboardAdapter), | |
| 292 | - | snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState), | |
| 293 | - | clipboard: clipboardAdapter, | |
| 294 | - | 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), | |
| 295 | 317 | } | |
| 296 | 318 | } | |
| 297 | 319 | ||
| @@ -107,16 +107,36 @@ func runGUI() int { | |||||
| 107 | _ = browserSession.End(sessionCtx) | 107 | _ = browserSession.End(sessionCtx) | ||
| 108 | }() | 108 | }() | ||
| 109 | 109 | ||||
| 110 | - | logger := browserSession.Logger | |||
| 111 | ctx = sessionCtx | 110 | ctx = sessionCtx | ||
| 111 | + | log := logging.FromContext(ctx) | |||
| 112 | 112 | ||||
| 113 | - | checkRuntimeRequirements(ctx, cfg, logger) | 113 | + | checkRuntimeRequirements(ctx, cfg, *log) |
| 114 | - | checkMediaRequirements(ctx, cfg, logger) | 114 | + | checkMediaRequirements(ctx, cfg, *log) |
| 115 | 115 | ||||
| 116 | themeManager := theme.NewManager(ctx, cfg) | 116 | themeManager := theme.NewManager(ctx, cfg) | ||
| 117 | - | stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger) | 117 | + | stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, *log) |
| 118 | repos := createRepositories(db) | 118 | repos := createRepositories(db) | ||
| 119 | - | 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 | + | ||||
| 120 | idleInhibitor := idle.NewPortalInhibitor(ctx) | 140 | idleInhibitor := idle.NewPortalInhibitor(ctx) | ||
| 121 | defer func() { | 141 | defer func() { | ||
| 122 | if idleInhibitor != nil { | 142 | if idleInhibitor != nil { | ||
| @@ -127,7 +147,7 @@ func runGUI() int { | |||||
| 127 | 147 | ||||
| 128 | app, err := ui.New(uiDeps) | 148 | app, err := ui.New(uiDeps) | ||
| 129 | if err != nil { | 149 | if err != nil { | ||
| 130 | - | logger.Error().Err(err).Msg("failed to create application") | 150 | + | log.Error().Err(err).Msg("failed to create application") |
| 131 | return 1 | 151 | return 1 | ||
| 132 | } | 152 | } | ||
| 133 | 153 | ||||
| @@ -139,7 +159,7 @@ func runGUI() int { | |||||
| 139 | defer signal.Stop(sigCh) | 159 | defer signal.Stop(sigCh) | ||
| 140 | go func() { | 160 | go func() { | ||
| 141 | <-sigCh | 161 | <-sigCh | ||
| 142 | - | logger.Info().Msg("received interrupt, quitting") | 162 | + | log.Info().Msg("received interrupt, quitting") |
| 143 | app.Quit() | 163 | app.Quit() | ||
| 144 | }() | 164 | }() | ||
| 145 | 165 | ||||
| @@ -254,19 +274,20 @@ func createRepositories(db *sql.DB) *repositories { | |||||
| 254 | 274 | ||||
| 255 | // useCases groups application layer use case implementations. | 275 | // useCases groups application layer use case implementations. | ||
| 256 | type useCases struct { | 276 | type useCases struct { | ||
| 257 | - | tabs *usecase.ManageTabsUseCase | 277 | + | tabs *usecase.ManageTabsUseCase |
| 258 | - | panes *usecase.ManagePanesUseCase | 278 | + | panes *usecase.ManagePanesUseCase |
| 259 | - | history *usecase.SearchHistoryUseCase | 279 | + | history *usecase.SearchHistoryUseCase |
| 260 | - | favorites *usecase.ManageFavoritesUseCase | 280 | + | favorites *usecase.ManageFavoritesUseCase |
| 261 | - | zoom *usecase.ManageZoomUseCase | 281 | + | zoom *usecase.ManageZoomUseCase |
| 262 | - | navigate *usecase.NavigateUseCase | 282 | + | navigate *usecase.NavigateUseCase |
| 263 | - | copyURL *usecase.CopyURLUseCase | 283 | + | copyURL *usecase.CopyURLUseCase |
| 264 | - | snapshot *usecase.SnapshotSessionUseCase | 284 | + | snapshot *usecase.SnapshotSessionUseCase |
| 265 | - | clipboard port.Clipboard | 285 | + | lastRestorable *usecase.GetLastRestorableSessionUseCase |
| 266 | - | favicon *favicon.Service | 286 | + | clipboard port.Clipboard |
| 287 | + | favicon *favicon.Service | |||
| 267 | } | 288 | } | ||
| 268 | 289 | ||||
| 269 | - | func createUseCases(repos *repositories, cfg *config.Config) *useCases { | 290 | + | func createUseCases(repos *repositories, cfg *config.Config, stateDir string) *useCases { |
| 270 | const idAlphabetSize = 26 | 291 | const idAlphabetSize = 26 | ||
| 271 | idCounter := uint64(0) | 292 | idCounter := uint64(0) | ||
| 272 | idGenerator := func() string { | 293 | idGenerator := func() string { | ||
| @@ -282,16 +303,17 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases { | |||||
| 282 | zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize) | 303 | zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize) | ||
| 283 | 304 | ||||
| 284 | return &useCases{ | 305 | return &useCases{ | ||
| 285 | - | tabs: usecase.NewManageTabsUseCase(idGenerator), | 306 | + | tabs: usecase.NewManageTabsUseCase(idGenerator), |
| 286 | - | panes: usecase.NewManagePanesUseCase(idGenerator), | 307 | + | panes: usecase.NewManagePanesUseCase(idGenerator), |
| 287 | - | history: usecase.NewSearchHistoryUseCase(repos.history), | 308 | + | history: usecase.NewSearchHistoryUseCase(repos.history), |
| 288 | - | favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag), | 309 | + | favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag), |
| 289 | - | zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache), | 310 | + | zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache), |
| 290 | - | navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom), | 311 | + | navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom), |
| 291 | - | copyURL: usecase.NewCopyURLUseCase(clipboardAdapter), | 312 | + | copyURL: usecase.NewCopyURLUseCase(clipboardAdapter), |
| 292 | - | snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState), | 313 | + | snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState), |
| 293 | - | clipboard: clipboardAdapter, | 314 | + | lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir), |
| 294 | - | favicon: favicon.NewService(faviconCacheDir), | 315 | + | clipboard: clipboardAdapter, |
| 316 | + | favicon: favicon.NewService(faviconCacheDir), | |||
| 295 | } | 317 | } | ||
| 296 | } | 318 | } | ||
| 297 | 319 | ||||
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 | | |
| @@ -275,8 +275,44 @@ cancel = ["escape"] | |||||
| 275 | | `workspace.styling.pane_mode_border_color` | string | `"#4A90E2"` | Pane mode border color (blue) | | 275 | | `workspace.styling.pane_mode_border_color` | string | `"#4A90E2"` | Pane mode border color (blue) | | ||
| 276 | | `workspace.styling.tab_mode_border_width` | int | `4` | Tab mode border width (px) - Ctrl+P T overlay | | 276 | | `workspace.styling.tab_mode_border_width` | int | `4` | Tab mode border width (px) - Ctrl+P T overlay | | ||
| 277 | | `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) | | 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 | | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) | | 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 | ## Media | 316 | ## Media | ||
| 281 | 317 | ||||
| 282 | | Key | Type | Default | Valid Values | Description | | 318 | | Key | Type | Default | Valid Values | Description | | ||
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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |||
M internal/ui/app.go +8 -1
| @@ -502,9 +502,16 @@ func (a *App) createInitialTab(ctx context.Context) { | |||
| 502 | 502 | if a.deps != nil && a.deps.RestoreSessionID != "" { | |
| 503 | 503 | if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil { | |
| 504 | 504 | log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab") | |
| 505 | - | // Fall through to create default tab | |
| 505 | + | // Show error toast and fall through to create default tab | |
| 506 | + | if a.appToaster != nil { | |
| 507 | + | a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning) | |
| 508 | + | } | |
| 506 | 509 | } else { | |
| 507 | 510 | log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored") | |
| 511 | + | // Show success toast for session restoration | |
| 512 | + | if a.appToaster != nil { | |
| 513 | + | a.appToaster.Show(ctx, "Session restored", component.ToastSuccess) | |
| 514 | + | } | |
| 508 | 515 | return | |
| 509 | 516 | } | |
| 510 | 517 | } | |
| @@ -502,9 +502,16 @@ func (a *App) createInitialTab(ctx context.Context) { | |||||
| 502 | if a.deps != nil && a.deps.RestoreSessionID != "" { | 502 | if a.deps != nil && a.deps.RestoreSessionID != "" { | ||
| 503 | if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil { | 503 | if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil { | ||
| 504 | log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab") | 504 | log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab") | ||
| 505 | - | // Fall through to create default tab | 505 | + | // Show error toast and fall through to create default tab |
| 506 | + | if a.appToaster != nil { | |||
| 507 | + | a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning) | |||
| 508 | + | } | |||
| 506 | } else { | 509 | } else { | ||
| 507 | log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored") | 510 | log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored") | ||
| 511 | + | // Show success toast for session restoration | |||
| 512 | + | if a.appToaster != nil { | |||
| 513 | + | a.appToaster.Show(ctx, "Session restored", component.ToastSuccess) | |||
| 514 | + | } | |||
| 508 | return | 515 | return | ||
| 509 | } | 516 | } | ||
| 510 | } | 517 | } | ||