detached 4fc8d20

feat(session): implement auto-restore and update docs

commit
4fc8d20ce4a212f1a1fc470b9c89f9ead975827b
author
bnema · · 2025-12-25 11:17 UTC
parent
e24e5cf
signature
GPG Unverified2B82207D370127A0 The 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.
44
55 ## [Unreleased]
66
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+
716 ### Fixed
817 - **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.
918 - **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 ### Fixed16 ### 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.
2323 - **Customizable themes**: Light and dark palettes with semantic color tokens.
2424 - **Persistent storage**: SQLite for history, zoom levels, and settings.
2525 - **Live configuration**: Single config file with hot reload when possible.
26+- **Session management**: Zellij-style session save/restore with automatic snapshots and resurrection.
2627
2728 ## Status
2829 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
7980 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |
8081 | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes |
8182
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+
8291 ### Mouse Controls
8392 | Action | Result | Notes |
8493 |--------|---------|-------|
@@ -231,6 +240,12 @@ sudo apt install va-driver-all
231240 - `dumber purge --force` # purge all data (no confirmation)
232241 - `dumber purge -d -H -c` # purge database and both caches
233242 - `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
234249 - Manage logs:
235250 - `dumber logs list` # list available log files
236251 - `dumber logs tail` # tail current log file
@@ -276,6 +291,19 @@ Split your browser window into multiple panes, each running independent web sess
276291 3. **Navigate**: Use `Alt+Arrow Keys` to move focus between split panes, or `Alt+Up/Down` to navigate within stacked panes
277292 4. **Close**: Press `X` in pane mode to close the current pane
278293
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+
279307 ## Configuration
280308
281309 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 ## Status28 ## 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 Controls91 ### 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 caches241 - `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 files250 - `dumber logs list` # list available log files
236 - `dumber logs tail` # tail current log file251 - `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 panes291 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 pane292 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 ## Configuration307 ## 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 {
107107 _ = browserSession.End(sessionCtx)
108108 }()
109109
110- logger := browserSession.Logger
111110 ctx = sessionCtx
111+ log := logging.FromContext(ctx)
112112
113- checkRuntimeRequirements(ctx, cfg, logger)
114- checkMediaRequirements(ctx, cfg, logger)
113+ checkRuntimeRequirements(ctx, cfg, *log)
114+ checkMediaRequirements(ctx, cfg, *log)
115115
116116 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)
118118 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+
120140 idleInhibitor := idle.NewPortalInhibitor(ctx)
121141 defer func() {
122142 if idleInhibitor != nil {
@@ -127,7 +147,7 @@ func runGUI() int {
127147
128148 app, err := ui.New(uiDeps)
129149 if err != nil {
130- logger.Error().Err(err).Msg("failed to create application")
150+ log.Error().Err(err).Msg("failed to create application")
131151 return 1
132152 }
133153
@@ -139,7 +159,7 @@ func runGUI() int {
139159 defer signal.Stop(sigCh)
140160 go func() {
141161 <-sigCh
142- logger.Info().Msg("received interrupt, quitting")
162+ log.Info().Msg("received interrupt, quitting")
143163 app.Quit()
144164 }()
145165
@@ -254,19 +274,20 @@ func createRepositories(db *sql.DB) *repositories {
254274
255275 // useCases groups application layer use case implementations.
256276 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
267288 }
268289
269-func createUseCases(repos *repositories, cfg *config.Config) *useCases {
290+func createUseCases(repos *repositories, cfg *config.Config, stateDir string) *useCases {
270291 const idAlphabetSize = 26
271292 idCounter := uint64(0)
272293 idGenerator := func() string {
@@ -282,16 +303,17 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
282303 zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)
283304
284305 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),
295317 }
296318 }
297319
@@ -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 = sessionCtx110 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 1151 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 <-sigCh161 <-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.ManageTabsUseCase277+ tabs *usecase.ManageTabsUseCase
258- panes *usecase.ManagePanesUseCase278+ panes *usecase.ManagePanesUseCase
259- history *usecase.SearchHistoryUseCase279+ history *usecase.SearchHistoryUseCase
260- favorites *usecase.ManageFavoritesUseCase280+ favorites *usecase.ManageFavoritesUseCase
261- zoom *usecase.ManageZoomUseCase281+ zoom *usecase.ManageZoomUseCase
262- navigate *usecase.NavigateUseCase282+ navigate *usecase.NavigateUseCase
263- copyURL *usecase.CopyURLUseCase283+ copyURL *usecase.CopyURLUseCase
264- snapshot *usecase.SnapshotSessionUseCase284+ snapshot *usecase.SnapshotSessionUseCase
265- clipboard port.Clipboard285+ lastRestorable *usecase.GetLastRestorableSessionUseCase
266- favicon *favicon.Service286+ 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 = 26291 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"]
275275 | `workspace.styling.pane_mode_border_color` | string | `"#4A90E2"` | Pane mode border color (blue) |
276276 | `workspace.styling.tab_mode_border_width` | int | `4` | Tab mode border width (px) - Ctrl+P T overlay |
277277 | `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) |
278280 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |
279281
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+
280316 ## Media
281317
282318 | 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 ## Media316 ## 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) {
502502 if a.deps != nil && a.deps.RestoreSessionID != "" {
503503 if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil {
504504 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+ }
506509 } else {
507510 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+ }
508515 return
509516 }
510517 }
@@ -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 tab505+ // 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 return515 return
509 }516 }
510 }517 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close