detached 935afff

Merge pull request #22 from bnema/feat/session-resurrection

feat: add session management and resurrection system
commit
935afffe4d831ae427799d8d000c0afb00ba8850
author
bnema · · 2025-12-25 11:47 UTC
committer
GitHub · · 2025-12-25 11:47 UTC
merge of
351fdaf + a753e76 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
63 files · +7755 -283
M .mockery.yaml +3 -0
@@ -9,6 +9,8 @@ packages:
9 pkgname: mocks9 pkgname: mocks
10 interfaces:10 interfaces:
11 SessionLogger: {}11 SessionLogger: {}
12+ SessionSpawner: {}
13+ TabListProvider: {}
12 WebView: {}14 WebView: {}
13 WebViewFactory: {}15 WebViewFactory: {}
14 WebViewPool: {}16 WebViewPool: {}
@@ -23,6 +25,7 @@ packages:
23 FolderRepository: {}25 FolderRepository: {}
24 HistoryRepository: {}26 HistoryRepository: {}
25 SessionRepository: {}27 SessionRepository: {}
28+ SessionStateRepository: {}
26 TagRepository: {}29 TagRepository: {}
27 ZoomRepository: {}30 ZoomRepository: {}
28 github.com/bnema/dumber/internal/infrastructure/filtering:31 github.com/bnema/dumber/internal/infrastructure/filtering:
M CHANGELOG.md +9 -0
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
4 4
5 ## [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.
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 +99 -60
@@ -41,6 +41,9 @@ var (
41 // initialURL holds the URL to open on startup (from browse command).41 // initialURL holds the URL to open on startup (from browse command).
42 var initialURL string42 var initialURL string
43 43
44+// restoreSessionID holds the session ID to restore on startup.
45+var restoreSessionID string
46+
44 func main() {47 func main() {
45 // Run GUI mode for browse command48 // Run GUI mode for browse command
46 if len(os.Args) > 1 && os.Args[1] == "browse" {49 if len(os.Args) > 1 && os.Args[1] == "browse" {
@@ -48,6 +51,8 @@ func main() {
48 if len(os.Args) > 2 {51 if len(os.Args) > 2 {
49 initialURL = os.Args[2]52 initialURL = os.Args[2]
50 }53 }
54+ // Check for session restoration via environment variable
55+ restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
51 // Strip "browse" and URL from args so GTK doesn't see them56 // Strip "browse" and URL from args so GTK doesn't see them
52 os.Args = os.Args[:1]57 os.Args = os.Args[:1]
53 os.Exit(runGUI())58 os.Exit(runGUI())
@@ -102,27 +107,47 @@ func runGUI() int {
102 _ = browserSession.End(sessionCtx)107 _ = browserSession.End(sessionCtx)
103 }()108 }()
104 109
105- logger := browserSession.Logger
106 ctx = sessionCtx110 ctx = sessionCtx
111+ log := logging.FromContext(ctx)
107 112
108- checkRuntimeRequirements(ctx, cfg, logger)113+ checkRuntimeRequirements(ctx, cfg, *log)
109- checkMediaRequirements(ctx, cfg, logger)114+ checkMediaRequirements(ctx, cfg, *log)
110 115
111 themeManager := theme.NewManager(ctx, cfg)116 themeManager := theme.NewManager(ctx, cfg)
112- stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, logger)117+ stack := bootstrap.BuildWebKitStack(ctx, cfg, dataDir, cacheDir, themeManager, *log)
113 repos := createRepositories(db)118 repos := createRepositories(db)
114- useCases := createUseCases(repos, cfg)119+
120+ stateDir, _ := config.GetStateDir()
121+ useCases := createUseCases(repos, cfg, stateDir)
122+
123+ // Auto-restore: find last restorable session if enabled and no explicit restore requested
124+ if cfg.Session.AutoRestore && restoreSessionID == "" {
125+ var out *usecase.GetLastRestorableSessionOutput
126+ out, err = useCases.lastRestorable.Execute(ctx, usecase.GetLastRestorableSessionInput{
127+ ExcludeSessionID: browserSession.Session.ID,
128+ })
129+ if err != nil {
130+ log.Warn().Err(err).Msg("auto-restore: failed to find restorable session")
131+ } else if out.SessionID != "" {
132+ restoreSessionID = string(out.SessionID)
133+ log.Info().
134+ Str("session_id", restoreSessionID).
135+ Int("tabs", len(out.State.Tabs)).
136+ Msg("auto-restore: found last session")
137+ }
138+ }
139+
115 idleInhibitor := idle.NewPortalInhibitor(ctx)140 idleInhibitor := idle.NewPortalInhibitor(ctx)
116 defer func() {141 defer func() {
117 if idleInhibitor != nil {142 if idleInhibitor != nil {
118 _ = idleInhibitor.Close()143 _ = idleInhibitor.Close()
119 }144 }
120 }()145 }()
121- uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor)146+ uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)
122 147
123 app, err := ui.New(uiDeps)148 app, err := ui.New(uiDeps)
124 if err != nil {149 if err != nil {
125- logger.Error().Err(err).Msg("failed to create application")150+ log.Error().Err(err).Msg("failed to create application")
126 return 1151 return 1
127 }152 }
128 153
@@ -134,7 +159,7 @@ func runGUI() int {
134 defer signal.Stop(sigCh)159 defer signal.Stop(sigCh)
135 go func() {160 go func() {
136 <-sigCh161 <-sigCh
137- logger.Info().Msg("received interrupt, quitting")162+ log.Info().Msg("received interrupt, quitting")
138 app.Quit()163 app.Quit()
139 }()164 }()
140 165
@@ -226,37 +251,43 @@ func openDatabase(ctx context.Context, logger zerolog.Logger, dataDir string) (*
226 251
227 // repositories groups infrastructure layer repository implementations.252 // repositories groups infrastructure layer repository implementations.
228 type repositories struct {253 type repositories struct {
229- history repository.HistoryRepository254+ history repository.HistoryRepository
230- favorite repository.FavoriteRepository255+ favorite repository.FavoriteRepository
231- folder repository.FolderRepository256+ folder repository.FolderRepository
232- tag repository.TagRepository257+ tag repository.TagRepository
233- zoom repository.ZoomRepository258+ zoom repository.ZoomRepository
259+ session repository.SessionRepository
260+ sessionState repository.SessionStateRepository
234 }261 }
235 262
236 func createRepositories(db *sql.DB) *repositories {263 func createRepositories(db *sql.DB) *repositories {
237 return &repositories{264 return &repositories{
238- history: sqlite.NewHistoryRepository(db),265+ history: sqlite.NewHistoryRepository(db),
239- favorite: sqlite.NewFavoriteRepository(db),266+ favorite: sqlite.NewFavoriteRepository(db),
240- folder: sqlite.NewFolderRepository(db),267+ folder: sqlite.NewFolderRepository(db),
241- tag: sqlite.NewTagRepository(db),268+ tag: sqlite.NewTagRepository(db),
242- zoom: sqlite.NewZoomRepository(db),269+ zoom: sqlite.NewZoomRepository(db),
270+ session: sqlite.NewSessionRepository(db),
271+ sessionState: sqlite.NewSessionStateRepository(db),
243 }272 }
244 }273 }
245 274
246 // useCases groups application layer use case implementations.275 // useCases groups application layer use case implementations.
247 type useCases struct {276 type useCases struct {
248- tabs *usecase.ManageTabsUseCase277+ tabs *usecase.ManageTabsUseCase
249- panes *usecase.ManagePanesUseCase278+ panes *usecase.ManagePanesUseCase
250- history *usecase.SearchHistoryUseCase279+ history *usecase.SearchHistoryUseCase
251- favorites *usecase.ManageFavoritesUseCase280+ favorites *usecase.ManageFavoritesUseCase
252- zoom *usecase.ManageZoomUseCase281+ zoom *usecase.ManageZoomUseCase
253- navigate *usecase.NavigateUseCase282+ navigate *usecase.NavigateUseCase
254- copyURL *usecase.CopyURLUseCase283+ copyURL *usecase.CopyURLUseCase
255- clipboard port.Clipboard284+ snapshot *usecase.SnapshotSessionUseCase
256- favicon *favicon.Service285+ lastRestorable *usecase.GetLastRestorableSessionUseCase
286+ clipboard port.Clipboard
287+ favicon *favicon.Service
257 }288 }
258 289
259-func createUseCases(repos *repositories, cfg *config.Config) *useCases {290+func createUseCases(repos *repositories, cfg *config.Config, stateDir string) *useCases {
260 const idAlphabetSize = 26291 const idAlphabetSize = 26
261 idCounter := uint64(0)292 idCounter := uint64(0)
262 idGenerator := func() string {293 idGenerator := func() string {
@@ -272,15 +303,17 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
272 zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)303 zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)
273 304
274 return &useCases{305 return &useCases{
275- tabs: usecase.NewManageTabsUseCase(idGenerator),306+ tabs: usecase.NewManageTabsUseCase(idGenerator),
276- panes: usecase.NewManagePanesUseCase(idGenerator),307+ panes: usecase.NewManagePanesUseCase(idGenerator),
277- history: usecase.NewSearchHistoryUseCase(repos.history),308+ history: usecase.NewSearchHistoryUseCase(repos.history),
278- favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),309+ favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
279- zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),310+ zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
280- navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),311+ navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),
281- copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),312+ copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),
282- clipboard: clipboardAdapter,313+ snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),
283- favicon: favicon.NewService(faviconCacheDir),314+ lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir),
315+ clipboard: clipboardAdapter,
316+ favicon: favicon.NewService(faviconCacheDir),
284 }317 }
285 }318 }
286 319
@@ -292,30 +325,36 @@ func buildUIDependencies(
292 repos *repositories,325 repos *repositories,
293 uc *useCases,326 uc *useCases,
294 idleInhibitor port.IdleInhibitor,327 idleInhibitor port.IdleInhibitor,
328+ currentSessionID entity.SessionID,
295 ) *ui.Dependencies {329 ) *ui.Dependencies {
296 return &ui.Dependencies{330 return &ui.Dependencies{
297- Ctx: ctx,331+ Ctx: ctx,
298- Config: cfg,332+ Config: cfg,
299- InitialURL: initialURL,333+ InitialURL: initialURL,
300- Theme: themeManager,334+ RestoreSessionID: restoreSessionID,
301- WebContext: stack.Context,335+ Theme: themeManager,
302- Pool: stack.Pool,336+ WebContext: stack.Context,
303- Settings: stack.Settings,337+ Pool: stack.Pool,
304- Injector: stack.Injector,338+ Settings: stack.Settings,
305- MessageRouter: stack.MessageRouter,339+ Injector: stack.Injector,
306- FilterManager: stack.FilterManager,340+ MessageRouter: stack.MessageRouter,
307- HistoryRepo: repos.history,341+ FilterManager: stack.FilterManager,
308- FavoriteRepo: repos.favorite,342+ HistoryRepo: repos.history,
309- ZoomRepo: repos.zoom,343+ FavoriteRepo: repos.favorite,
310- TabsUC: uc.tabs,344+ ZoomRepo: repos.zoom,
311- PanesUC: uc.panes,345+ TabsUC: uc.tabs,
312- HistoryUC: uc.history,346+ PanesUC: uc.panes,
313- FavoritesUC: uc.favorites,347+ HistoryUC: uc.history,
314- ZoomUC: uc.zoom,348+ FavoritesUC: uc.favorites,
315- NavigateUC: uc.navigate,349+ ZoomUC: uc.zoom,
316- CopyURLUC: uc.copyURL,350+ NavigateUC: uc.navigate,
317- Clipboard: uc.clipboard,351+ CopyURLUC: uc.copyURL,
318- FaviconService: uc.favicon,352+ Clipboard: uc.clipboard,
319- IdleInhibitor: idleInhibitor,353+ FaviconService: uc.favicon,
354+ IdleInhibitor: idleInhibitor,
355+ SessionRepo: repos.session,
356+ SessionStateRepo: repos.sessionState,
357+ CurrentSessionID: currentSessionID,
358+ SnapshotUC: uc.snapshot,
320 }359 }
321 }360 }
M docs/CONFIG.md +36 -0
@@ -275,8 +275,44 @@ cancel = ["escape"]
275 | `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 |
M internal/application/port/mocks/mocks.go +195 -0
@@ -120,6 +120,201 @@ func (_c *MockSessionLogger_CreateLogger_Call) RunAndReturn(run func(ctx context
120 return _c120 return _c
121 }121 }
122 122
123+// NewMockTabListProvider creates a new instance of MockTabListProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
124+// The first argument is typically a *testing.T value.
125+func NewMockTabListProvider(t interface {
126+ mock.TestingT
127+ Cleanup(func())
128+}) *MockTabListProvider {
129+ mock := &MockTabListProvider{}
130+ mock.Mock.Test(t)
131+
132+ t.Cleanup(func() { mock.AssertExpectations(t) })
133+
134+ return mock
135+}
136+
137+// MockTabListProvider is an autogenerated mock type for the TabListProvider type
138+type MockTabListProvider struct {
139+ mock.Mock
140+}
141+
142+type MockTabListProvider_Expecter struct {
143+ mock *mock.Mock
144+}
145+
146+func (_m *MockTabListProvider) EXPECT() *MockTabListProvider_Expecter {
147+ return &MockTabListProvider_Expecter{mock: &_m.Mock}
148+}
149+
150+// GetSessionID provides a mock function for the type MockTabListProvider
151+func (_mock *MockTabListProvider) GetSessionID() entity.SessionID {
152+ ret := _mock.Called()
153+
154+ if len(ret) == 0 {
155+ panic("no return value specified for GetSessionID")
156+ }
157+
158+ var r0 entity.SessionID
159+ if returnFunc, ok := ret.Get(0).(func() entity.SessionID); ok {
160+ r0 = returnFunc()
161+ } else {
162+ r0 = ret.Get(0).(entity.SessionID)
163+ }
164+ return r0
165+}
166+
167+// MockTabListProvider_GetSessionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSessionID'
168+type MockTabListProvider_GetSessionID_Call struct {
169+ *mock.Call
170+}
171+
172+// GetSessionID is a helper method to define mock.On call
173+func (_e *MockTabListProvider_Expecter) GetSessionID() *MockTabListProvider_GetSessionID_Call {
174+ return &MockTabListProvider_GetSessionID_Call{Call: _e.mock.On("GetSessionID")}
175+}
176+
177+func (_c *MockTabListProvider_GetSessionID_Call) Run(run func()) *MockTabListProvider_GetSessionID_Call {
178+ _c.Call.Run(func(args mock.Arguments) {
179+ run()
180+ })
181+ return _c
182+}
183+
184+func (_c *MockTabListProvider_GetSessionID_Call) Return(sessionID entity.SessionID) *MockTabListProvider_GetSessionID_Call {
185+ _c.Call.Return(sessionID)
186+ return _c
187+}
188+
189+func (_c *MockTabListProvider_GetSessionID_Call) RunAndReturn(run func() entity.SessionID) *MockTabListProvider_GetSessionID_Call {
190+ _c.Call.Return(run)
191+ return _c
192+}
193+
194+// GetTabList provides a mock function for the type MockTabListProvider
195+func (_mock *MockTabListProvider) GetTabList() *entity.TabList {
196+ ret := _mock.Called()
197+
198+ if len(ret) == 0 {
199+ panic("no return value specified for GetTabList")
200+ }
201+
202+ var r0 *entity.TabList
203+ if returnFunc, ok := ret.Get(0).(func() *entity.TabList); ok {
204+ r0 = returnFunc()
205+ } else {
206+ if ret.Get(0) != nil {
207+ r0 = ret.Get(0).(*entity.TabList)
208+ }
209+ }
210+ return r0
211+}
212+
213+// MockTabListProvider_GetTabList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTabList'
214+type MockTabListProvider_GetTabList_Call struct {
215+ *mock.Call
216+}
217+
218+// GetTabList is a helper method to define mock.On call
219+func (_e *MockTabListProvider_Expecter) GetTabList() *MockTabListProvider_GetTabList_Call {
220+ return &MockTabListProvider_GetTabList_Call{Call: _e.mock.On("GetTabList")}
221+}
222+
223+func (_c *MockTabListProvider_GetTabList_Call) Run(run func()) *MockTabListProvider_GetTabList_Call {
224+ _c.Call.Run(func(args mock.Arguments) {
225+ run()
226+ })
227+ return _c
228+}
229+
230+func (_c *MockTabListProvider_GetTabList_Call) Return(tabList *entity.TabList) *MockTabListProvider_GetTabList_Call {
231+ _c.Call.Return(tabList)
232+ return _c
233+}
234+
235+func (_c *MockTabListProvider_GetTabList_Call) RunAndReturn(run func() *entity.TabList) *MockTabListProvider_GetTabList_Call {
236+ _c.Call.Return(run)
237+ return _c
238+}
239+
240+// NewMockSessionSpawner creates a new instance of MockSessionSpawner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
241+// The first argument is typically a *testing.T value.
242+func NewMockSessionSpawner(t interface {
243+ mock.TestingT
244+ Cleanup(func())
245+}) *MockSessionSpawner {
246+ mock := &MockSessionSpawner{}
247+ mock.Mock.Test(t)
248+
249+ t.Cleanup(func() { mock.AssertExpectations(t) })
250+
251+ return mock
252+}
253+
254+// MockSessionSpawner is an autogenerated mock type for the SessionSpawner type
255+type MockSessionSpawner struct {
256+ mock.Mock
257+}
258+
259+type MockSessionSpawner_Expecter struct {
260+ mock *mock.Mock
261+}
262+
263+func (_m *MockSessionSpawner) EXPECT() *MockSessionSpawner_Expecter {
264+ return &MockSessionSpawner_Expecter{mock: &_m.Mock}
265+}
266+
267+// SpawnWithSession provides a mock function for the type MockSessionSpawner
268+func (_mock *MockSessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
269+ ret := _mock.Called(sessionID)
270+
271+ if len(ret) == 0 {
272+ panic("no return value specified for SpawnWithSession")
273+ }
274+
275+ var r0 error
276+ if returnFunc, ok := ret.Get(0).(func(entity.SessionID) error); ok {
277+ r0 = returnFunc(sessionID)
278+ } else {
279+ r0 = ret.Error(0)
280+ }
281+ return r0
282+}
283+
284+// MockSessionSpawner_SpawnWithSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SpawnWithSession'
285+type MockSessionSpawner_SpawnWithSession_Call struct {
286+ *mock.Call
287+}
288+
289+// SpawnWithSession is a helper method to define mock.On call
290+// - sessionID entity.SessionID
291+func (_e *MockSessionSpawner_Expecter) SpawnWithSession(sessionID interface{}) *MockSessionSpawner_SpawnWithSession_Call {
292+ return &MockSessionSpawner_SpawnWithSession_Call{Call: _e.mock.On("SpawnWithSession", sessionID)}
293+}
294+
295+func (_c *MockSessionSpawner_SpawnWithSession_Call) Run(run func(sessionID entity.SessionID)) *MockSessionSpawner_SpawnWithSession_Call {
296+ _c.Call.Run(func(args mock.Arguments) {
297+ var arg0 entity.SessionID
298+ if args[0] != nil {
299+ arg0 = args[0].(entity.SessionID)
300+ }
301+ run(
302+ arg0,
303+ )
304+ })
305+ return _c
306+}
307+
308+func (_c *MockSessionSpawner_SpawnWithSession_Call) Return(err error) *MockSessionSpawner_SpawnWithSession_Call {
309+ _c.Call.Return(err)
310+ return _c
311+}
312+
313+func (_c *MockSessionSpawner_SpawnWithSession_Call) RunAndReturn(run func(sessionID entity.SessionID) error) *MockSessionSpawner_SpawnWithSession_Call {
314+ _c.Call.Return(run)
315+ return _c
316+}
317+
123 // NewMockWebView creates a new instance of MockWebView. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.318 // NewMockWebView creates a new instance of MockWebView. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
124 // The first argument is typically a *testing.T value.319 // The first argument is typically a *testing.T value.
125 func NewMockWebView(t interface {320 func NewMockWebView(t interface {
A internal/application/port/session_state.go +18 -0
@@ -0,0 +1,18 @@
1+package port
2+
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
5+// TabListProvider provides access to the current tab list state.
6+// Implemented by the UI layer to allow the snapshot service to read state.
7+type TabListProvider interface {
8+ // GetTabList returns the current tab list state.
9+ GetTabList() *entity.TabList
10+ // GetSessionID returns the current session ID.
11+ GetSessionID() entity.SessionID
12+}
13+
14+// SessionSpawner spawns a new dumber instance for resurrection.
15+type SessionSpawner interface {
16+ // SpawnWithSession starts a new dumber instance to restore a session.
17+ SpawnWithSession(sessionID entity.SessionID) error
18+}
A internal/application/usecase/delete_session.go +79 -0
@@ -0,0 +1,79 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/repository"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// DeleteSessionUseCase errors.
13+var (
14+ ErrCannotDeleteCurrentSession = errors.New("cannot delete current session")
15+ ErrCannotDeleteActiveSession = errors.New("cannot delete active session")
16+)
17+
18+// DeleteSessionUseCase handles session deletion with proper validation.
19+type DeleteSessionUseCase struct {
20+ stateRepo repository.SessionStateRepository
21+ sessionRepo repository.SessionRepository
22+ lockDir string
23+}
24+
25+// NewDeleteSessionUseCase creates a new DeleteSessionUseCase.
26+func NewDeleteSessionUseCase(
27+ stateRepo repository.SessionStateRepository,
28+ sessionRepo repository.SessionRepository,
29+ lockDir string,
30+) *DeleteSessionUseCase {
31+ return &DeleteSessionUseCase{
32+ stateRepo: stateRepo,
33+ sessionRepo: sessionRepo,
34+ lockDir: lockDir,
35+ }
36+}
37+
38+// DeleteSessionInput contains the parameters for session deletion.
39+type DeleteSessionInput struct {
40+ SessionID entity.SessionID
41+ CurrentSessionID entity.SessionID
42+}
43+
44+// Execute deletes a session and its state snapshot.
45+// It validates that the session is not current or active before deletion.
46+func (uc *DeleteSessionUseCase) Execute(ctx context.Context, input DeleteSessionInput) error {
47+ log := logging.FromContext(ctx)
48+
49+ // Validate not deleting current session
50+ if input.SessionID == input.CurrentSessionID && input.CurrentSessionID != "" {
51+ return ErrCannotDeleteCurrentSession
52+ }
53+
54+ // Check session exists
55+ session, err := uc.sessionRepo.FindByID(ctx, input.SessionID)
56+ if err != nil {
57+ return err
58+ }
59+ if session == nil {
60+ return ErrSessionNotFound
61+ }
62+
63+ // Check not active (EndedAt is nil OR has lock file)
64+ if session.IsActive() || isSessionLocked(uc.lockDir, input.SessionID) {
65+ return ErrCannotDeleteActiveSession
66+ }
67+
68+ log.Info().Str("session_id", string(input.SessionID)).Msg("deleting session")
69+
70+ // Delete snapshot (ignore "not found" - session may not have a snapshot)
71+ if uc.stateRepo != nil {
72+ if err := uc.stateRepo.DeleteSnapshot(ctx, input.SessionID); err != nil {
73+ log.Debug().Err(err).Msg("failed to delete session snapshot (may not exist)")
74+ }
75+ }
76+
77+ // Delete session record
78+ return uc.sessionRepo.Delete(ctx, input.SessionID)
79+}
A internal/application/usecase/delete_session_test.go +255 -0
@@ -0,0 +1,255 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/usecase"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestDeleteSessionUseCase_Execute_Success(t *testing.T) {
18+ ctx := testContext()
19+
20+ stateRepo := repomocks.NewMockSessionStateRepository(t)
21+ sessionRepo := repomocks.NewMockSessionRepository(t)
22+
23+ sessionID := entity.SessionID("20251224_120000_delete")
24+
25+ endedAt := time.Now().Add(-time.Hour)
26+ session := &entity.Session{
27+ ID: sessionID,
28+ Type: entity.SessionTypeBrowser,
29+ StartedAt: time.Now().Add(-2 * time.Hour),
30+ EndedAt: &endedAt,
31+ }
32+
33+ sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
34+ stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
35+ sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)
36+
37+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
38+
39+ err := uc.Execute(ctx, usecase.DeleteSessionInput{
40+ SessionID: sessionID,
41+ CurrentSessionID: "other-session",
42+ })
43+ require.NoError(t, err)
44+}
45+
46+func TestDeleteSessionUseCase_Execute_CannotDeleteCurrentSession(t *testing.T) {
47+ ctx := testContext()
48+
49+ stateRepo := repomocks.NewMockSessionStateRepository(t)
50+ sessionRepo := repomocks.NewMockSessionRepository(t)
51+
52+ sessionID := entity.SessionID("20251224_120000_current")
53+
54+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
55+
56+ err := uc.Execute(ctx, usecase.DeleteSessionInput{
57+ SessionID: sessionID,
58+ CurrentSessionID: sessionID, // Same as current
59+ })
60+ require.Error(t, err)
61+ assert.ErrorIs(t, err, usecase.ErrCannotDeleteCurrentSession)
62+}
63+
64+func TestDeleteSessionUseCase_Execute_SessionNotFound(t *testing.T) {
65+ ctx := testContext()
66+
67+ stateRepo := repomocks.NewMockSessionStateRepository(t)
68+ sessionRepo := repomocks.NewMockSessionRepository(t)
69+
70+ sessionID := entity.SessionID("20251224_120000_notfound")
71+
72+ sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, nil)
73+
74+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
75+
76+ err := uc.Execute(ctx, usecase.DeleteSessionInput{
77+ SessionID: sessionID,
78+ CurrentSessionID: "other-session",
79+ })
80+ require.Error(t, err)
81+ assert.ErrorIs(t, err, usecase.ErrSessionNotFound)
82+}
83+
84+func TestDeleteSessionUseCase_Execute_CannotDeleteActiveSession(t *testing.T) {
85+ ctx := testContext()
86+
87+ stateRepo := repomocks.NewMockSessionStateRepository(t)
88+ sessionRepo := repomocks.NewMockSessionRepository(t)
89+
90+ sessionID := entity.SessionID("20251224_120000_active")
91+
92+ // Session with nil EndedAt (still active)
93+ session := &entity.Session{
94+ ID: sessionID,
95+ Type: entity.SessionTypeBrowser,
96+ StartedAt: time.Now().Add(-time.Hour),
97+ EndedAt: nil, // Active
98+ }
99+
100+ sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
101+
102+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
103+
104+ err := uc.Execute(ctx, usecase.DeleteSessionInput{
105+ SessionID: sessionID,
106+ CurrentSessionID: "other-session",
107+ })
108+ require.Error(t, err)
109+ assert.ErrorIs(t, err, usecase.ErrCannotDeleteActiveSession)
110+}
111+
112+func TestDeleteSessionUseCase_Execute_CannotDeleteLockedSession(t *testing.T) {
113+ ctx := testContext()
114+
115+ stateRepo := repomocks.NewMockSessionStateRepository(t)
116+ sessionRepo := repomocks.NewMockSessionRepository(t)
117+
118+ // Create a temporary lock directory and lock file
119+ lockDir := t.TempDir()
120+ sessionID := entity.SessionID("20251224_120000_locked")
121+ lockPath := filepath.Join(lockDir, "session_"+string(sessionID)+".lock")
122+ f, err := os.Create(lockPath)
123+ require.NoError(t, err)
124+ f.Close()
125+
126+ endedAt := time.Now().Add(-time.Hour)
127+ session := &entity.Session{
128+ ID: sessionID,
129+ Type: entity.SessionTypeBrowser,
130+ StartedAt: time.Now().Add(-2 * time.Hour),
131+ EndedAt: &endedAt, // Ended, but has lock file
132+ }
133+
134+ sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
135+
136+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, lockDir)
137+
138+ err = uc.Execute(ctx, usecase.DeleteSessionInput{
139+ SessionID: sessionID,
140+ CurrentSessionID: "other-session",
141+ })
142+ require.Error(t, err)
143+ assert.ErrorIs(t, err, usecase.ErrCannotDeleteActiveSession)
144+}
145+
146+func TestDeleteSessionUseCase_Execute_FindByIDError(t *testing.T) {
147+ ctx := testContext()
148+
149+ stateRepo := repomocks.NewMockSessionStateRepository(t)
150+ sessionRepo := repomocks.NewMockSessionRepository(t)
151+
152+ sessionID := entity.SessionID("20251224_120000_dberror")
153+
154+ sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(nil, errors.New("db error"))
155+
156+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
157+
158+ err := uc.Execute(ctx, usecase.DeleteSessionInput{
159+ SessionID: sessionID,
160+ CurrentSessionID: "other-session",
161+ })
162+ require.Error(t, err)
163+ assert.Contains(t, err.Error(), "db error")
164+}
165+
166+func TestDeleteSessionUseCase_Execute_DeleteSnapshotErrorIgnored(t *testing.T) {
167+ ctx := testContext()
168+
169+ stateRepo := repomocks.NewMockSessionStateRepository(t)
170+ sessionRepo := repomocks.NewMockSessionRepository(t)
171+
172+ sessionID := entity.SessionID("20251224_120000_nosnapshot")
173+
174+ endedAt := time.Now().Add(-time.Hour)
175+ session := &entity.Session{
176+ ID: sessionID,
177+ Type: entity.SessionTypeBrowser,
178+ StartedAt: time.Now().Add(-2 * time.Hour),
179+ EndedAt: &endedAt,
180+ }
181+
182+ sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
183+ // Snapshot deletion fails (e.g., no snapshot exists) - should be ignored
184+ stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(errors.New("no rows"))
185+ sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)
186+
187+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
188+
189+ err := uc.Execute(ctx, usecase.DeleteSessionInput{
190+ SessionID: sessionID,
191+ CurrentSessionID: "other-session",
192+ })
193+ // Should succeed even though DeleteSnapshot failed
194+ require.NoError(t, err)
195+}
196+
197+func TestDeleteSessionUseCase_Execute_DeleteSessionError(t *testing.T) {
198+ ctx := testContext()
199+
200+ stateRepo := repomocks.NewMockSessionStateRepository(t)
201+ sessionRepo := repomocks.NewMockSessionRepository(t)
202+
203+ sessionID := entity.SessionID("20251224_120000_deleteerror")
204+
205+ endedAt := time.Now().Add(-time.Hour)
206+ session := &entity.Session{
207+ ID: sessionID,
208+ Type: entity.SessionTypeBrowser,
209+ StartedAt: time.Now().Add(-2 * time.Hour),
210+ EndedAt: &endedAt,
211+ }
212+
213+ sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
214+ stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
215+ sessionRepo.EXPECT().Delete(ctx, sessionID).Return(errors.New("delete failed"))
216+
217+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
218+
219+ err := uc.Execute(ctx, usecase.DeleteSessionInput{
220+ SessionID: sessionID,
221+ CurrentSessionID: "other-session",
222+ })
223+ require.Error(t, err)
224+ assert.Contains(t, err.Error(), "delete failed")
225+}
226+
227+func TestDeleteSessionUseCase_Execute_EmptyCurrentSessionAllowed(t *testing.T) {
228+ ctx := testContext()
229+
230+ stateRepo := repomocks.NewMockSessionStateRepository(t)
231+ sessionRepo := repomocks.NewMockSessionRepository(t)
232+
233+ sessionID := entity.SessionID("20251224_120000_nocurrent")
234+
235+ endedAt := time.Now().Add(-time.Hour)
236+ session := &entity.Session{
237+ ID: sessionID,
238+ Type: entity.SessionTypeBrowser,
239+ StartedAt: time.Now().Add(-2 * time.Hour),
240+ EndedAt: &endedAt,
241+ }
242+
243+ sessionRepo.EXPECT().FindByID(ctx, sessionID).Return(session, nil)
244+ stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
245+ sessionRepo.EXPECT().Delete(ctx, sessionID).Return(nil)
246+
247+ uc := usecase.NewDeleteSessionUseCase(stateRepo, sessionRepo, "")
248+
249+ // CurrentSessionID is empty (e.g., CLI context)
250+ err := uc.Execute(ctx, usecase.DeleteSessionInput{
251+ SessionID: sessionID,
252+ CurrentSessionID: "",
253+ })
254+ require.NoError(t, err)
255+}
A internal/application/usecase/get_last_restorable_session.go +124 -0
@@ -0,0 +1,124 @@
1+package usecase
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/bnema/dumber/internal/domain/repository"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+const (
12+ // maxSessionsToCheck is the number of recent sessions to check for restoration.
13+ maxSessionsToCheck = 10
14+)
15+
16+// GetLastRestorableSessionUseCase finds the most recent session that can be auto-restored.
17+// A session is restorable if:
18+// - It is not the current session
19+// - It is not actively running (has ended or crashed without lock file)
20+// - It has a valid snapshot with at least one tab
21+type GetLastRestorableSessionUseCase struct {
22+ sessionRepo repository.SessionRepository
23+ stateRepo repository.SessionStateRepository
24+ lockDir string
25+}
26+
27+// NewGetLastRestorableSessionUseCase creates a new GetLastRestorableSessionUseCase.
28+func NewGetLastRestorableSessionUseCase(
29+ sessionRepo repository.SessionRepository,
30+ stateRepo repository.SessionStateRepository,
31+ lockDir string,
32+) *GetLastRestorableSessionUseCase {
33+ return &GetLastRestorableSessionUseCase{
34+ sessionRepo: sessionRepo,
35+ stateRepo: stateRepo,
36+ lockDir: lockDir,
37+ }
38+}
39+
40+// GetLastRestorableSessionInput contains the parameters for finding a restorable session.
41+type GetLastRestorableSessionInput struct {
42+ // ExcludeSessionID is the current session ID to exclude from results.
43+ ExcludeSessionID entity.SessionID
44+}
45+
46+// GetLastRestorableSessionOutput contains the found restorable session, if any.
47+type GetLastRestorableSessionOutput struct {
48+ // SessionID is the ID of the restorable session, or empty if none found.
49+ SessionID entity.SessionID
50+ // State is the session state to restore, or nil if none found.
51+ State *entity.SessionState
52+}
53+
54+// Execute finds the most recent restorable session.
55+// Returns an empty output (not an error) if no restorable session is found.
56+func (uc *GetLastRestorableSessionUseCase) Execute(
57+ ctx context.Context,
58+ input GetLastRestorableSessionInput,
59+) (*GetLastRestorableSessionOutput, error) {
60+ log := logging.FromContext(ctx)
61+
62+ sessions, err := uc.sessionRepo.GetRecent(ctx, maxSessionsToCheck)
63+ if err != nil {
64+ return nil, err
65+ }
66+
67+ for _, session := range sessions {
68+ // Only consider browser sessions
69+ if session.Type != entity.SessionTypeBrowser {
70+ continue
71+ }
72+
73+ // Skip current session
74+ if session.ID == input.ExcludeSessionID {
75+ continue
76+ }
77+
78+ // Check if session is actively running
79+ // A session is "active" if ended_at IS NULL AND it has a lock file
80+ if session.IsActive() && isSessionLocked(uc.lockDir, session.ID) {
81+ log.Debug().
82+ Str("session_id", string(session.ID)).
83+ Msg("auto-restore: skipping active session")
84+ continue
85+ }
86+
87+ // Try to get snapshot
88+ state, err := uc.stateRepo.GetSnapshot(ctx, session.ID)
89+ if err != nil {
90+ log.Debug().
91+ Err(err).
92+ Str("session_id", string(session.ID)).
93+ Msg("auto-restore: failed to get snapshot")
94+ continue
95+ }
96+ if state == nil {
97+ continue
98+ }
99+
100+ // Skip sessions with no tabs
101+ if len(state.Tabs) == 0 {
102+ log.Debug().
103+ Str("session_id", string(session.ID)).
104+ Msg("auto-restore: skipping session with no tabs")
105+ continue
106+ }
107+
108+ // Found a valid restorable session
109+ log.Info().
110+ Str("session_id", string(session.ID)).
111+ Int("tabs", len(state.Tabs)).
112+ Int("panes", state.CountPanes()).
113+ Msg("auto-restore: found restorable session")
114+
115+ return &GetLastRestorableSessionOutput{
116+ SessionID: session.ID,
117+ State: state,
118+ }, nil
119+ }
120+
121+ // No restorable session found
122+ log.Debug().Msg("auto-restore: no restorable session found")
123+ return &GetLastRestorableSessionOutput{}, nil
124+}
A internal/application/usecase/get_last_restorable_session_test.go +408 -0
@@ -0,0 +1,408 @@
1+package usecase_test
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestGetLastRestorableSessionUseCase_NoSessions(t *testing.T) {
17+ ctx := testContext()
18+
19+ sessionRepo := repomocks.NewMockSessionRepository(t)
20+ stateRepo := repomocks.NewMockSessionStateRepository(t)
21+
22+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return([]*entity.Session{}, nil)
23+
24+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
25+
26+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
27+ ExcludeSessionID: "current",
28+ })
29+
30+ require.NoError(t, err)
31+ require.NotNil(t, output)
32+ assert.Empty(t, output.SessionID)
33+ assert.Nil(t, output.State)
34+}
35+
36+func TestGetLastRestorableSessionUseCase_OnlyCurrentSession(t *testing.T) {
37+ ctx := testContext()
38+
39+ sessionRepo := repomocks.NewMockSessionRepository(t)
40+ stateRepo := repomocks.NewMockSessionStateRepository(t)
41+
42+ currentID := entity.SessionID("20251225_120000_curr")
43+ endedAt := time.Now()
44+
45+ sessions := []*entity.Session{
46+ {
47+ ID: currentID,
48+ Type: entity.SessionTypeBrowser,
49+ StartedAt: time.Now().Add(-1 * time.Hour),
50+ EndedAt: &endedAt,
51+ },
52+ }
53+
54+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
55+
56+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
57+
58+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
59+ ExcludeSessionID: currentID,
60+ })
61+
62+ require.NoError(t, err)
63+ require.NotNil(t, output)
64+ assert.Empty(t, output.SessionID)
65+}
66+
67+func TestGetLastRestorableSessionUseCase_SkipsCLISessions(t *testing.T) {
68+ ctx := testContext()
69+
70+ sessionRepo := repomocks.NewMockSessionRepository(t)
71+ stateRepo := repomocks.NewMockSessionStateRepository(t)
72+
73+ endedAt := time.Now()
74+ sessions := []*entity.Session{
75+ {
76+ ID: "20251225_120000_cli1",
77+ Type: entity.SessionTypeCLI, // Not a browser session
78+ StartedAt: time.Now().Add(-1 * time.Hour),
79+ EndedAt: &endedAt,
80+ },
81+ }
82+
83+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
84+
85+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
86+
87+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
88+ ExcludeSessionID: "current",
89+ })
90+
91+ require.NoError(t, err)
92+ assert.Empty(t, output.SessionID)
93+}
94+
95+func TestGetLastRestorableSessionUseCase_SkipsActiveWithLock(t *testing.T) {
96+ ctx := testContext()
97+
98+ // Create temp dir for lock files
99+ lockDir := t.TempDir()
100+
101+ sessionRepo := repomocks.NewMockSessionRepository(t)
102+ stateRepo := repomocks.NewMockSessionStateRepository(t)
103+
104+ activeID := entity.SessionID("20251225_120000_actv")
105+
106+ // Create lock file to simulate running session
107+ lockPath := filepath.Join(lockDir, "session_"+string(activeID)+".lock")
108+ f, err := os.Create(lockPath)
109+ require.NoError(t, err)
110+ f.Close()
111+
112+ sessions := []*entity.Session{
113+ {
114+ ID: activeID,
115+ Type: entity.SessionTypeBrowser,
116+ StartedAt: time.Now().Add(-1 * time.Hour),
117+ EndedAt: nil, // Active (no ended_at)
118+ },
119+ }
120+
121+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
122+
123+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, lockDir)
124+
125+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
126+ ExcludeSessionID: "current",
127+ })
128+
129+ require.NoError(t, err)
130+ assert.Empty(t, output.SessionID)
131+}
132+
133+func TestGetLastRestorableSessionUseCase_SkipsNoSnapshot(t *testing.T) {
134+ ctx := testContext()
135+
136+ sessionRepo := repomocks.NewMockSessionRepository(t)
137+ stateRepo := repomocks.NewMockSessionStateRepository(t)
138+
139+ endedAt := time.Now()
140+ sessionID := entity.SessionID("20251225_120000_nosn")
141+
142+ sessions := []*entity.Session{
143+ {
144+ ID: sessionID,
145+ Type: entity.SessionTypeBrowser,
146+ StartedAt: time.Now().Add(-1 * time.Hour),
147+ EndedAt: &endedAt,
148+ },
149+ }
150+
151+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
152+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, nil) // No snapshot
153+
154+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
155+
156+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
157+ ExcludeSessionID: "current",
158+ })
159+
160+ require.NoError(t, err)
161+ assert.Empty(t, output.SessionID)
162+}
163+
164+func TestGetLastRestorableSessionUseCase_SkipsEmptyTabs(t *testing.T) {
165+ ctx := testContext()
166+
167+ sessionRepo := repomocks.NewMockSessionRepository(t)
168+ stateRepo := repomocks.NewMockSessionStateRepository(t)
169+
170+ endedAt := time.Now()
171+ sessionID := entity.SessionID("20251225_120000_emty")
172+
173+ sessions := []*entity.Session{
174+ {
175+ ID: sessionID,
176+ Type: entity.SessionTypeBrowser,
177+ StartedAt: time.Now().Add(-1 * time.Hour),
178+ EndedAt: &endedAt,
179+ },
180+ }
181+
182+ emptyState := &entity.SessionState{
183+ Version: entity.SessionStateVersion,
184+ SessionID: sessionID,
185+ Tabs: []entity.TabSnapshot{}, // No tabs
186+ SavedAt: time.Now(),
187+ }
188+
189+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
190+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(emptyState, nil)
191+
192+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
193+
194+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
195+ ExcludeSessionID: "current",
196+ })
197+
198+ require.NoError(t, err)
199+ assert.Empty(t, output.SessionID)
200+}
201+
202+func TestGetLastRestorableSessionUseCase_ReturnsEndedSession(t *testing.T) {
203+ ctx := testContext()
204+
205+ sessionRepo := repomocks.NewMockSessionRepository(t)
206+ stateRepo := repomocks.NewMockSessionStateRepository(t)
207+
208+ endedAt := time.Now()
209+ sessionID := entity.SessionID("20251225_120000_good")
210+
211+ sessions := []*entity.Session{
212+ {
213+ ID: sessionID,
214+ Type: entity.SessionTypeBrowser,
215+ StartedAt: time.Now().Add(-1 * time.Hour),
216+ EndedAt: &endedAt, // Gracefully ended
217+ },
218+ }
219+
220+ validState := &entity.SessionState{
221+ Version: entity.SessionStateVersion,
222+ SessionID: sessionID,
223+ Tabs: []entity.TabSnapshot{
224+ {
225+ ID: "tab-1",
226+ Name: "Test Tab",
227+ Workspace: entity.WorkspaceSnapshot{
228+ Root: &entity.PaneNodeSnapshot{
229+ Pane: &entity.PaneSnapshot{
230+ ID: "pane-1",
231+ URI: "https://example.com",
232+ Title: "Example",
233+ },
234+ },
235+ },
236+ },
237+ },
238+ SavedAt: time.Now(),
239+ }
240+
241+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
242+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil)
243+
244+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
245+
246+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
247+ ExcludeSessionID: "current",
248+ })
249+
250+ require.NoError(t, err)
251+ require.NotNil(t, output)
252+ assert.Equal(t, sessionID, output.SessionID)
253+ assert.Equal(t, validState, output.State)
254+}
255+
256+func TestGetLastRestorableSessionUseCase_ReturnsCrashedSession(t *testing.T) {
257+ ctx := testContext()
258+
259+ // Empty lock dir (no lock files)
260+ lockDir := t.TempDir()
261+
262+ sessionRepo := repomocks.NewMockSessionRepository(t)
263+ stateRepo := repomocks.NewMockSessionStateRepository(t)
264+
265+ sessionID := entity.SessionID("20251225_120000_crsh")
266+
267+ // Session with no ended_at (crashed) but no lock file
268+ sessions := []*entity.Session{
269+ {
270+ ID: sessionID,
271+ Type: entity.SessionTypeBrowser,
272+ StartedAt: time.Now().Add(-1 * time.Hour),
273+ EndedAt: nil, // Not ended - simulates crash
274+ },
275+ }
276+
277+ validState := &entity.SessionState{
278+ Version: entity.SessionStateVersion,
279+ SessionID: sessionID,
280+ Tabs: []entity.TabSnapshot{
281+ {
282+ ID: "tab-1",
283+ Name: "Crashed Tab",
284+ Workspace: entity.WorkspaceSnapshot{
285+ Root: &entity.PaneNodeSnapshot{
286+ Pane: &entity.PaneSnapshot{
287+ ID: "pane-1",
288+ URI: "https://example.com",
289+ Title: "Example",
290+ },
291+ },
292+ },
293+ },
294+ },
295+ SavedAt: time.Now(),
296+ }
297+
298+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
299+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(validState, nil)
300+
301+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, lockDir)
302+
303+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
304+ ExcludeSessionID: "current",
305+ })
306+
307+ require.NoError(t, err)
308+ require.NotNil(t, output)
309+ assert.Equal(t, sessionID, output.SessionID)
310+ assert.Equal(t, validState, output.State)
311+}
312+
313+func TestGetLastRestorableSessionUseCase_ReturnsMostRecent(t *testing.T) {
314+ ctx := testContext()
315+
316+ sessionRepo := repomocks.NewMockSessionRepository(t)
317+ stateRepo := repomocks.NewMockSessionStateRepository(t)
318+
319+ endedAt := time.Now()
320+ recentID := entity.SessionID("20251225_140000_new1")
321+ olderID := entity.SessionID("20251225_120000_old1")
322+
323+ // Sessions ordered by started_at DESC (most recent first)
324+ sessions := []*entity.Session{
325+ {
326+ ID: recentID,
327+ Type: entity.SessionTypeBrowser,
328+ StartedAt: time.Now().Add(-1 * time.Hour),
329+ EndedAt: &endedAt,
330+ },
331+ {
332+ ID: olderID,
333+ Type: entity.SessionTypeBrowser,
334+ StartedAt: time.Now().Add(-2 * time.Hour),
335+ EndedAt: &endedAt,
336+ },
337+ }
338+
339+ recentState := &entity.SessionState{
340+ Version: entity.SessionStateVersion,
341+ SessionID: recentID,
342+ Tabs: []entity.TabSnapshot{
343+ {ID: "tab-1", Name: "Recent Tab"},
344+ },
345+ SavedAt: time.Now(),
346+ }
347+
348+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
349+ stateRepo.EXPECT().GetSnapshot(ctx, recentID).Return(recentState, nil)
350+ // Note: GetSnapshot for olderID should NOT be called since we return first match
351+
352+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
353+
354+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
355+ ExcludeSessionID: "current",
356+ })
357+
358+ require.NoError(t, err)
359+ assert.Equal(t, recentID, output.SessionID)
360+}
361+
362+func TestGetLastRestorableSessionUseCase_SkipsFirstIfNoSnapshot(t *testing.T) {
363+ ctx := testContext()
364+
365+ sessionRepo := repomocks.NewMockSessionRepository(t)
366+ stateRepo := repomocks.NewMockSessionStateRepository(t)
367+
368+ endedAt := time.Now()
369+ noSnapID := entity.SessionID("20251225_140000_nosn")
370+ goodID := entity.SessionID("20251225_120000_good")
371+
372+ sessions := []*entity.Session{
373+ {
374+ ID: noSnapID,
375+ Type: entity.SessionTypeBrowser,
376+ StartedAt: time.Now().Add(-1 * time.Hour),
377+ EndedAt: &endedAt,
378+ },
379+ {
380+ ID: goodID,
381+ Type: entity.SessionTypeBrowser,
382+ StartedAt: time.Now().Add(-2 * time.Hour),
383+ EndedAt: &endedAt,
384+ },
385+ }
386+
387+ goodState := &entity.SessionState{
388+ Version: entity.SessionStateVersion,
389+ SessionID: goodID,
390+ Tabs: []entity.TabSnapshot{
391+ {ID: "tab-1", Name: "Good Tab"},
392+ },
393+ SavedAt: time.Now(),
394+ }
395+
396+ sessionRepo.EXPECT().GetRecent(ctx, 10).Return(sessions, nil)
397+ stateRepo.EXPECT().GetSnapshot(ctx, noSnapID).Return(nil, nil) // No snapshot
398+ stateRepo.EXPECT().GetSnapshot(ctx, goodID).Return(goodState, nil)
399+
400+ uc := usecase.NewGetLastRestorableSessionUseCase(sessionRepo, stateRepo, "")
401+
402+ output, err := uc.Execute(ctx, usecase.GetLastRestorableSessionInput{
403+ ExcludeSessionID: "current",
404+ })
405+
406+ require.NoError(t, err)
407+ assert.Equal(t, goodID, output.SessionID)
408+}
A internal/application/usecase/list_sessions.go +256 -0
@@ -0,0 +1,256 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/repository"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// ListSessionsUseCase handles listing sessions with their state information.
13+type ListSessionsUseCase struct {
14+ sessionRepo repository.SessionRepository
15+ stateRepo repository.SessionStateRepository
16+ lockDir string // Directory where lock files are stored
17+}
18+
19+// NewListSessionsUseCase creates a new ListSessionsUseCase.
20+func NewListSessionsUseCase(
21+ sessionRepo repository.SessionRepository,
22+ stateRepo repository.SessionStateRepository,
23+ lockDir string,
24+) *ListSessionsUseCase {
25+ return &ListSessionsUseCase{
26+ sessionRepo: sessionRepo,
27+ stateRepo: stateRepo,
28+ lockDir: lockDir,
29+ }
30+}
31+
32+// ListSessionsOutput contains the list of sessions with their info.
33+type ListSessionsOutput struct {
34+ Sessions []entity.SessionInfo
35+}
36+
37+// Execute returns a list of sessions with their state information.
38+// Sessions are sorted with active first, then by date (most recent first).
39+func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID entity.SessionID, limit int) (*ListSessionsOutput, error) {
40+ log := logging.FromContext(ctx)
41+
42+ if limit <= 0 {
43+ limit = 50
44+ }
45+
46+ sessions, err := uc.sessionRepo.GetRecent(ctx, limit)
47+ if err != nil {
48+ return nil, err
49+ }
50+
51+ // Get all snapshots
52+ snapshots, err := uc.stateRepo.GetAllSnapshots(ctx)
53+ if err != nil {
54+ log.Warn().Err(err).Msg("failed to get session snapshots")
55+ // Continue without snapshots
56+ snapshots = nil
57+ }
58+
59+ // Build snapshot lookup map
60+ snapshotMap := make(map[entity.SessionID]*entity.SessionState)
61+ for _, s := range snapshots {
62+ snapshotMap[s.SessionID] = s
63+ }
64+
65+ // Build session info list
66+ result := make([]entity.SessionInfo, 0, len(sessions))
67+ for _, session := range sessions {
68+ // Only include browser sessions
69+ if session.Type != entity.SessionTypeBrowser {
70+ continue
71+ }
72+
73+ info := entity.SessionInfo{
74+ Session: session,
75+ IsCurrent: session.ID == currentSessionID,
76+ IsActive: session.IsActive() || uc.hasActiveLock(session.ID),
77+ }
78+
79+ if state, ok := snapshotMap[session.ID]; ok {
80+ info.State = state
81+ info.TabCount = len(state.Tabs)
82+ info.PaneCount = state.CountPanes()
83+ info.UpdatedAt = state.SavedAt
84+ } else {
85+ info.UpdatedAt = session.StartedAt
86+ }
87+
88+ result = append(result, info)
89+ }
90+
91+ // Sort: current first, then active, then by date
92+ sortSessionInfos(result, currentSessionID)
93+
94+ return &ListSessionsOutput{Sessions: result}, nil
95+}
96+
97+// hasActiveLock checks if a session has an active lock file.
98+func (uc *ListSessionsUseCase) hasActiveLock(sessionID entity.SessionID) bool {
99+ return isSessionLocked(uc.lockDir, sessionID)
100+}
101+
102+// sortSessionInfos sorts sessions by: current first, then active, then by UpdatedAt descending.
103+func sortSessionInfos(infos []entity.SessionInfo, currentID entity.SessionID) {
104+ // Simple bubble sort for small lists
105+ n := len(infos)
106+ for i := 0; i < n-1; i++ {
107+ for j := 0; j < n-i-1; j++ {
108+ if shouldSwap(infos[j], infos[j+1], currentID) {
109+ infos[j], infos[j+1] = infos[j+1], infos[j]
110+ }
111+ }
112+ }
113+}
114+
115+func shouldSwap(a, b entity.SessionInfo, currentID entity.SessionID) bool {
116+ // Current session always first
117+ if a.Session.ID == currentID {
118+ return false
119+ }
120+ if b.Session.ID == currentID {
121+ return true
122+ }
123+
124+ // Active sessions before inactive
125+ if a.IsActive && !b.IsActive {
126+ return false
127+ }
128+ if !a.IsActive && b.IsActive {
129+ return true
130+ }
131+
132+ // Most recent first
133+ return a.UpdatedAt.Before(b.UpdatedAt)
134+}
135+
136+// GetSessionInfo returns info for a specific session.
137+func (uc *ListSessionsUseCase) GetSessionInfo(
138+ ctx context.Context,
139+ sessionID, currentSessionID entity.SessionID,
140+) (*entity.SessionInfo, error) {
141+ session, err := uc.sessionRepo.FindByID(ctx, sessionID)
142+ if err != nil {
143+ return nil, err
144+ }
145+ if session == nil {
146+ return nil, nil
147+ }
148+
149+ info := &entity.SessionInfo{
150+ Session: session,
151+ IsCurrent: session.ID == currentSessionID,
152+ IsActive: session.IsActive() || uc.hasActiveLock(session.ID),
153+ UpdatedAt: session.StartedAt,
154+ }
155+
156+ state, err := uc.stateRepo.GetSnapshot(ctx, sessionID)
157+ if err != nil {
158+ logging.FromContext(ctx).Warn().Err(err).Str("session_id", string(sessionID)).Msg("failed to get session state")
159+ }
160+ if state != nil {
161+ info.State = state
162+ info.TabCount = len(state.Tabs)
163+ info.PaneCount = state.CountPanes()
164+ info.UpdatedAt = state.SavedAt
165+ }
166+
167+ return info, nil
168+}
169+
170+const (
171+ hoursPerDay = 24
172+ daysPerWeek = 7
173+ maxIDDisplay = 20
174+)
175+
176+// GetRelativeTime returns a human-readable relative time string.
177+func GetRelativeTime(t time.Time) string {
178+ now := time.Now()
179+ diff := now.Sub(t)
180+
181+ switch {
182+ case diff < time.Minute:
183+ return "just now"
184+ case diff < time.Hour:
185+ mins := int(diff.Minutes())
186+ if mins == 1 {
187+ return "1m ago"
188+ }
189+ return formatDuration(mins, "m")
190+ case diff < hoursPerDay*time.Hour:
191+ hours := int(diff.Hours())
192+ if hours == 1 {
193+ return "1h ago"
194+ }
195+ return formatDuration(hours, "h")
196+ case diff < daysPerWeek*hoursPerDay*time.Hour:
197+ days := int(diff.Hours() / hoursPerDay)
198+ if days == 1 {
199+ return "1d ago"
200+ }
201+ return formatDuration(days, "d")
202+ default:
203+ weeks := int(diff.Hours() / hoursPerDay / daysPerWeek)
204+ if weeks == 1 {
205+ return "1w ago"
206+ }
207+ return formatDuration(weeks, "w")
208+ }
209+}
210+
211+func formatDuration(n int, unit string) string {
212+ return string(rune('0'+n/10)) + string(rune('0'+n%10)) + unit + " ago"
213+}
214+
215+// PurgeableSessionsOutput contains inactive sessions that can be purged.
216+type PurgeableSessionsOutput struct {
217+ Sessions []entity.SessionPurgeItem
218+ TotalSize int64
219+}
220+
221+// GetPurgeableSessions returns all inactive sessions that can be safely deleted.
222+// These are sessions that have ended and have no active lock file.
223+func (uc *ListSessionsUseCase) GetPurgeableSessions(ctx context.Context) (*PurgeableSessionsOutput, error) {
224+ log := logging.FromContext(ctx)
225+
226+ // Get all sessions (no current session in CLI context)
227+ output, err := uc.Execute(ctx, "", maxPurgeableSessions)
228+ if err != nil {
229+ return nil, err
230+ }
231+
232+ // Filter to only inactive sessions
233+ var purgeable []entity.SessionPurgeItem
234+ for _, info := range output.Sessions {
235+ if !info.IsActive {
236+ purgeable = append(purgeable, entity.SessionPurgeItem{
237+ Info: info,
238+ Selected: true, // Default to selected
239+ })
240+ }
241+ }
242+
243+ // Get total size of all snapshots
244+ totalSize, err := uc.stateRepo.GetTotalSnapshotsSize(ctx)
245+ if err != nil {
246+ log.Warn().Err(err).Msg("failed to get total snapshots size")
247+ totalSize = 0
248+ }
249+
250+ return &PurgeableSessionsOutput{
251+ Sessions: purgeable,
252+ TotalSize: totalSize,
253+ }, nil
254+}
255+
256+const maxPurgeableSessions = 1000
A internal/application/usecase/list_sessions_test.go +178 -0
@@ -0,0 +1,178 @@
1+package usecase_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestListSessionsUseCase_Execute_ReturnsSessionsWithState(t *testing.T) {
15+ ctx := testContext()
16+
17+ sessionRepo := repomocks.NewMockSessionRepository(t)
18+ stateRepo := repomocks.NewMockSessionStateRepository(t)
19+
20+ endedAt := time.Now().Add(-time.Hour)
21+ sessions := []*entity.Session{
22+ {
23+ ID: "20251224_120000_abc1",
24+ Type: entity.SessionTypeBrowser,
25+ StartedAt: time.Now().Add(-2 * time.Hour),
26+ EndedAt: &endedAt,
27+ },
28+ {
29+ ID: "20251224_130000_def2",
30+ Type: entity.SessionTypeBrowser,
31+ StartedAt: time.Now().Add(-time.Hour),
32+ EndedAt: nil, // Active
33+ },
34+ }
35+
36+ states := []*entity.SessionState{
37+ {
38+ SessionID: "20251224_120000_abc1",
39+ SavedAt: time.Now().Add(-time.Hour),
40+ Tabs: []entity.TabSnapshot{
41+ {ID: "tab1"},
42+ {ID: "tab2"},
43+ },
44+ },
45+ }
46+
47+ sessionRepo.EXPECT().GetRecent(ctx, 50).Return(sessions, nil)
48+ stateRepo.EXPECT().GetAllSnapshots(ctx).Return(states, nil)
49+
50+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
51+
52+ output, err := uc.Execute(ctx, "", 50)
53+ require.NoError(t, err)
54+ require.NotNil(t, output)
55+ require.Len(t, output.Sessions, 2)
56+
57+ // Active session should be first (or current, but we have no current)
58+ // The sorting puts active sessions before inactive
59+ activeSession := output.Sessions[0]
60+ assert.True(t, activeSession.IsActive)
61+
62+ // Check session with state has tab count
63+ var sessionWithState *entity.SessionInfo
64+ for i := range output.Sessions {
65+ if output.Sessions[i].Session.ID == "20251224_120000_abc1" {
66+ sessionWithState = &output.Sessions[i]
67+ break
68+ }
69+ }
70+ require.NotNil(t, sessionWithState)
71+ assert.Equal(t, 2, sessionWithState.TabCount)
72+}
73+
74+func TestListSessionsUseCase_GetPurgeableSessions_ReturnsOnlyInactive(t *testing.T) {
75+ ctx := testContext()
76+
77+ sessionRepo := repomocks.NewMockSessionRepository(t)
78+ stateRepo := repomocks.NewMockSessionStateRepository(t)
79+
80+ endedAt := time.Now().Add(-time.Hour)
81+ sessions := []*entity.Session{
82+ {
83+ ID: "20251224_120000_inactive1",
84+ Type: entity.SessionTypeBrowser,
85+ StartedAt: time.Now().Add(-3 * time.Hour),
86+ EndedAt: &endedAt, // Ended - purgeable
87+ },
88+ {
89+ ID: "20251224_130000_active1",
90+ Type: entity.SessionTypeBrowser,
91+ StartedAt: time.Now().Add(-time.Hour),
92+ EndedAt: nil, // Active - NOT purgeable
93+ },
94+ {
95+ ID: "20251224_140000_inactive2",
96+ Type: entity.SessionTypeBrowser,
97+ StartedAt: time.Now().Add(-2 * time.Hour),
98+ EndedAt: &endedAt, // Ended - purgeable
99+ },
100+ }
101+
102+ sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil)
103+ stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
104+ stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(12345), nil)
105+
106+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
107+
108+ output, err := uc.GetPurgeableSessions(ctx)
109+ require.NoError(t, err)
110+ require.NotNil(t, output)
111+
112+ // Should only have 2 inactive sessions, not the active one
113+ require.Len(t, output.Sessions, 2)
114+ assert.Equal(t, int64(12345), output.TotalSize)
115+
116+ // Verify all returned sessions are inactive
117+ for _, s := range output.Sessions {
118+ assert.False(t, s.Info.IsActive, "session %s should not be active", s.Info.Session.ID)
119+ assert.True(t, s.Selected, "sessions should be selected by default")
120+ }
121+}
122+
123+func TestListSessionsUseCase_GetPurgeableSessions_EmptyWhenAllActive(t *testing.T) {
124+ ctx := testContext()
125+
126+ sessionRepo := repomocks.NewMockSessionRepository(t)
127+ stateRepo := repomocks.NewMockSessionStateRepository(t)
128+
129+ sessions := []*entity.Session{
130+ {
131+ ID: "20251224_130000_active1",
132+ Type: entity.SessionTypeBrowser,
133+ StartedAt: time.Now().Add(-time.Hour),
134+ EndedAt: nil, // Active
135+ },
136+ }
137+
138+ sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil)
139+ stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
140+ stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), nil)
141+
142+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
143+
144+ output, err := uc.GetPurgeableSessions(ctx)
145+ require.NoError(t, err)
146+ require.NotNil(t, output)
147+ require.Empty(t, output.Sessions)
148+}
149+
150+func TestListSessionsUseCase_GetPurgeableSessions_HandlesGetSizeError(t *testing.T) {
151+ ctx := testContext()
152+
153+ sessionRepo := repomocks.NewMockSessionRepository(t)
154+ stateRepo := repomocks.NewMockSessionStateRepository(t)
155+
156+ endedAt := time.Now().Add(-time.Hour)
157+ sessions := []*entity.Session{
158+ {
159+ ID: "20251224_120000_inactive1",
160+ Type: entity.SessionTypeBrowser,
161+ StartedAt: time.Now().Add(-2 * time.Hour),
162+ EndedAt: &endedAt,
163+ },
164+ }
165+
166+ sessionRepo.EXPECT().GetRecent(ctx, 1000).Return(sessions, nil)
167+ stateRepo.EXPECT().GetAllSnapshots(ctx).Return(nil, nil)
168+ stateRepo.EXPECT().GetTotalSnapshotsSize(ctx).Return(int64(0), assert.AnError)
169+
170+ uc := usecase.NewListSessionsUseCase(sessionRepo, stateRepo, "")
171+
172+ output, err := uc.GetPurgeableSessions(ctx)
173+ // Should succeed even if size query fails
174+ require.NoError(t, err)
175+ require.NotNil(t, output)
176+ require.Len(t, output.Sessions, 1)
177+ assert.Equal(t, int64(0), output.TotalSize) // Size defaults to 0 on error
178+}
A internal/application/usecase/restore_session.go +87 -0
@@ -0,0 +1,87 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/domain/repository"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// ErrSessionNotFound is returned when a session state cannot be found.
14+var ErrSessionNotFound = errors.New("session not found")
15+
16+// ErrVersionMismatch is returned when the session state version is incompatible.
17+var ErrVersionMismatch = errors.New("session state version mismatch")
18+
19+// RestoreSessionUseCase handles restoring session state from a snapshot.
20+type RestoreSessionUseCase struct {
21+ stateRepo repository.SessionStateRepository
22+ sessionRepo repository.SessionRepository
23+}
24+
25+// NewRestoreSessionUseCase creates a new RestoreSessionUseCase.
26+func NewRestoreSessionUseCase(
27+ stateRepo repository.SessionStateRepository,
28+ sessionRepo repository.SessionRepository,
29+) *RestoreSessionUseCase {
30+ return &RestoreSessionUseCase{
31+ stateRepo: stateRepo,
32+ sessionRepo: sessionRepo,
33+ }
34+}
35+
36+// RestoreInput contains the parameters for restoring a session.
37+type RestoreInput struct {
38+ SessionID entity.SessionID
39+}
40+
41+// RestoreOutput contains the restored session state.
42+type RestoreOutput struct {
43+ State *entity.SessionState
44+}
45+
46+// Execute loads and validates a session state for restoration.
47+func (uc *RestoreSessionUseCase) Execute(ctx context.Context, input RestoreInput) (*RestoreOutput, error) {
48+ log := logging.FromContext(ctx)
49+
50+ if input.SessionID == "" {
51+ return nil, fmt.Errorf("session id required")
52+ }
53+
54+ log.Info().
55+ Str("session_id", string(input.SessionID)).
56+ Msg("restoring session state")
57+
58+ state, err := uc.stateRepo.GetSnapshot(ctx, input.SessionID)
59+ if err != nil {
60+ return nil, fmt.Errorf("get session snapshot: %w", err)
61+ }
62+ if state == nil {
63+ return nil, ErrSessionNotFound
64+ }
65+
66+ // Validate version compatibility
67+ if state.Version > entity.SessionStateVersion {
68+ log.Warn().
69+ Int("state_version", state.Version).
70+ Int("current_version", entity.SessionStateVersion).
71+ Msg("session state version is newer than current version")
72+ return nil, ErrVersionMismatch
73+ }
74+
75+ log.Info().
76+ Str("session_id", string(input.SessionID)).
77+ Int("tab_count", len(state.Tabs)).
78+ Int("pane_count", state.CountPanes()).
79+ Msg("session state loaded for restoration")
80+
81+ return &RestoreOutput{State: state}, nil
82+}
83+
84+// DeleteSnapshot removes a session's snapshot (for cleanup after failed restore or user deletion).
85+func (uc *RestoreSessionUseCase) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error {
86+ return uc.stateRepo.DeleteSnapshot(ctx, sessionID)
87+}
A internal/application/usecase/restore_session_test.go +140 -0
@@ -0,0 +1,140 @@
1+package usecase_test
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestRestoreSessionUseCase_Execute_ReturnsState(t *testing.T) {
16+ ctx := testContext()
17+
18+ stateRepo := repomocks.NewMockSessionStateRepository(t)
19+ sessionRepo := repomocks.NewMockSessionRepository(t)
20+
21+ sessionID := entity.SessionID("20251224_120000_restore")
22+
23+ expectedState := &entity.SessionState{
24+ Version: entity.SessionStateVersion,
25+ SessionID: sessionID,
26+ Tabs: []entity.TabSnapshot{
27+ {
28+ ID: "tab-1",
29+ Name: "Test Tab",
30+ Workspace: entity.WorkspaceSnapshot{
31+ Root: &entity.PaneNodeSnapshot{
32+ Pane: &entity.PaneSnapshot{
33+ ID: "pane-1",
34+ URI: "https://example.com",
35+ Title: "Example",
36+ },
37+ },
38+ },
39+ },
40+ },
41+ SavedAt: time.Now(),
42+ }
43+
44+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(expectedState, nil)
45+
46+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
47+
48+ output, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID})
49+ require.NoError(t, err)
50+ require.NotNil(t, output)
51+ assert.Equal(t, expectedState, output.State)
52+}
53+
54+func TestRestoreSessionUseCase_Execute_RequiresSessionID(t *testing.T) {
55+ ctx := testContext()
56+
57+ stateRepo := repomocks.NewMockSessionStateRepository(t)
58+ sessionRepo := repomocks.NewMockSessionRepository(t)
59+
60+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
61+
62+ _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: ""})
63+ require.Error(t, err)
64+ assert.Contains(t, err.Error(), "session id required")
65+}
66+
67+func TestRestoreSessionUseCase_Execute_SessionNotFound(t *testing.T) {
68+ ctx := testContext()
69+
70+ stateRepo := repomocks.NewMockSessionStateRepository(t)
71+ sessionRepo := repomocks.NewMockSessionRepository(t)
72+
73+ sessionID := entity.SessionID("20251224_120000_notfound")
74+
75+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, nil)
76+
77+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
78+
79+ _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID})
80+ require.Error(t, err)
81+ assert.ErrorIs(t, err, usecase.ErrSessionNotFound)
82+}
83+
84+func TestRestoreSessionUseCase_Execute_VersionMismatch(t *testing.T) {
85+ ctx := testContext()
86+
87+ stateRepo := repomocks.NewMockSessionStateRepository(t)
88+ sessionRepo := repomocks.NewMockSessionRepository(t)
89+
90+ sessionID := entity.SessionID("20251224_120000_version")
91+
92+ // Return a state with a future version
93+ futureState := &entity.SessionState{
94+ Version: entity.SessionStateVersion + 1,
95+ SessionID: sessionID,
96+ Tabs: []entity.TabSnapshot{},
97+ SavedAt: time.Now(),
98+ }
99+
100+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(futureState, nil)
101+
102+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
103+
104+ _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID})
105+ require.Error(t, err)
106+ assert.ErrorIs(t, err, usecase.ErrVersionMismatch)
107+}
108+
109+func TestRestoreSessionUseCase_Execute_GetSnapshotError(t *testing.T) {
110+ ctx := testContext()
111+
112+ stateRepo := repomocks.NewMockSessionStateRepository(t)
113+ sessionRepo := repomocks.NewMockSessionRepository(t)
114+
115+ sessionID := entity.SessionID("20251224_120000_error")
116+
117+ stateRepo.EXPECT().GetSnapshot(ctx, sessionID).Return(nil, errors.New("db error"))
118+
119+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
120+
121+ _, err := uc.Execute(ctx, usecase.RestoreInput{SessionID: sessionID})
122+ require.Error(t, err)
123+ assert.Contains(t, err.Error(), "get session snapshot")
124+}
125+
126+func TestRestoreSessionUseCase_DeleteSnapshot(t *testing.T) {
127+ ctx := testContext()
128+
129+ stateRepo := repomocks.NewMockSessionStateRepository(t)
130+ sessionRepo := repomocks.NewMockSessionRepository(t)
131+
132+ sessionID := entity.SessionID("20251224_120000_delete")
133+
134+ stateRepo.EXPECT().DeleteSnapshot(ctx, sessionID).Return(nil)
135+
136+ uc := usecase.NewRestoreSessionUseCase(stateRepo, sessionRepo)
137+
138+ err := uc.DeleteSnapshot(ctx, sessionID)
139+ require.NoError(t, err)
140+}
A internal/application/usecase/session_lock.go +21 -0
@@ -0,0 +1,21 @@
1+package usecase
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+// isSessionLocked checks if a session has an active lock file.
12+// Lock files are created in lockDir with format: session_<sessionID>.lock
13+// This matches the format used by bootstrap.sessionLockPath().
14+func isSessionLocked(lockDir string, sessionID entity.SessionID) bool {
15+ if lockDir == "" {
16+ return false
17+ }
18+ lockPath := filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))
19+ _, err := os.Stat(lockPath)
20+ return err == nil
21+}
A internal/application/usecase/snapshot_session.go +49 -0
@@ -0,0 +1,49 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/repository"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// SnapshotSessionUseCase handles saving session state snapshots.
13+type SnapshotSessionUseCase struct {
14+ stateRepo repository.SessionStateRepository
15+}
16+
17+// NewSnapshotSessionUseCase creates a new SnapshotSessionUseCase.
18+func NewSnapshotSessionUseCase(stateRepo repository.SessionStateRepository) *SnapshotSessionUseCase {
19+ return &SnapshotSessionUseCase{stateRepo: stateRepo}
20+}
21+
22+// SnapshotInput contains the parameters for creating a session snapshot.
23+type SnapshotInput struct {
24+ SessionID entity.SessionID
25+ TabList *entity.TabList
26+}
27+
28+// Execute creates a snapshot of the current session state and saves it.
29+func (uc *SnapshotSessionUseCase) Execute(ctx context.Context, input SnapshotInput) error {
30+ log := logging.FromContext(ctx)
31+
32+ if input.SessionID == "" {
33+ return fmt.Errorf("session id required")
34+ }
35+
36+ state := entity.SnapshotFromTabList(input.SessionID, input.TabList)
37+
38+ log.Debug().
39+ Str("session_id", string(input.SessionID)).
40+ Int("tab_count", len(state.Tabs)).
41+ Int("pane_count", state.CountPanes()).
42+ Msg("creating session snapshot")
43+
44+ if err := uc.stateRepo.SaveSnapshot(ctx, state); err != nil {
45+ return fmt.Errorf("save session snapshot: %w", err)
46+ }
47+
48+ return nil
49+}
A internal/application/usecase/snapshot_session_test.go +173 -0
@@ -0,0 +1,173 @@
1+package usecase_test
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestSnapshotSessionUseCase_Execute_SavesSnapshot(t *testing.T) {
17+ ctx := testContext()
18+
19+ stateRepo := repomocks.NewMockSessionStateRepository(t)
20+
21+ // Create a tab list with some tabs
22+ tabList := entity.NewTabList()
23+ pane := entity.NewPane("pane-1")
24+ pane.URI = "https://example.com"
25+ pane.Title = "Example"
26+ tab := entity.NewTab("tab-1", "ws-1", pane)
27+ tab.Name = "Test Tab"
28+ tabList.Add(tab)
29+
30+ sessionID := entity.SessionID("20251224_120000_test")
31+
32+ stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
33+ Run(func(_ context.Context, state *entity.SessionState) {
34+ require.Equal(t, sessionID, state.SessionID)
35+ require.Len(t, state.Tabs, 1)
36+ require.Equal(t, "Test Tab", state.Tabs[0].Name)
37+ require.Equal(t, entity.SessionStateVersion, state.Version)
38+ }).
39+ Return(nil)
40+
41+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
42+
43+ err := uc.Execute(ctx, usecase.SnapshotInput{
44+ SessionID: sessionID,
45+ TabList: tabList,
46+ })
47+ require.NoError(t, err)
48+}
49+
50+func TestSnapshotSessionUseCase_Execute_EmptyTabList(t *testing.T) {
51+ ctx := testContext()
52+
53+ stateRepo := repomocks.NewMockSessionStateRepository(t)
54+
55+ sessionID := entity.SessionID("20251224_120000_empty")
56+
57+ stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
58+ Run(func(_ context.Context, state *entity.SessionState) {
59+ require.Equal(t, sessionID, state.SessionID)
60+ require.Empty(t, state.Tabs)
61+ }).
62+ Return(nil)
63+
64+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
65+
66+ err := uc.Execute(ctx, usecase.SnapshotInput{
67+ SessionID: sessionID,
68+ TabList: nil,
69+ })
70+ require.NoError(t, err)
71+}
72+
73+func TestSnapshotSessionUseCase_Execute_RequiresSessionID(t *testing.T) {
74+ ctx := testContext()
75+
76+ stateRepo := repomocks.NewMockSessionStateRepository(t)
77+
78+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
79+
80+ err := uc.Execute(ctx, usecase.SnapshotInput{
81+ SessionID: "",
82+ TabList: entity.NewTabList(),
83+ })
84+ require.Error(t, err)
85+ assert.Contains(t, err.Error(), "session id required")
86+}
87+
88+func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) {
89+ sessionID := entity.SessionID("20251224_120000_snap")
90+
91+ // Create a workspace with multiple panes
92+ pane1 := entity.NewPane("pane-1")
93+ pane1.URI = "https://github.com"
94+ pane1.Title = "GitHub"
95+ pane1.ZoomFactor = 1.2
96+
97+ pane2 := entity.NewPane("pane-2")
98+ pane2.URI = "https://google.com"
99+ pane2.Title = "Google"
100+
101+ tab := entity.NewTab("tab-1", "ws-1", pane1)
102+ tab.Name = "Dev Tab"
103+ tab.IsPinned = true
104+
105+ tabList := entity.NewTabList()
106+ tabList.Add(tab)
107+
108+ state := entity.SnapshotFromTabList(sessionID, tabList)
109+
110+ require.NotNil(t, state)
111+ assert.Equal(t, entity.SessionStateVersion, state.Version)
112+ assert.Equal(t, sessionID, state.SessionID)
113+ assert.Len(t, state.Tabs, 1)
114+ assert.Equal(t, "Dev Tab", state.Tabs[0].Name)
115+ assert.True(t, state.Tabs[0].IsPinned)
116+ assert.NotNil(t, state.Tabs[0].Workspace.Root)
117+ assert.Equal(t, "https://github.com", state.Tabs[0].Workspace.Root.Pane.URI)
118+ assert.InDelta(t, 1.2, state.Tabs[0].Workspace.Root.Pane.ZoomFactor, 0.001)
119+ assert.False(t, state.SavedAt.IsZero())
120+}
121+
122+func TestSessionState_CountPanes(t *testing.T) {
123+ state := &entity.SessionState{
124+ Tabs: []entity.TabSnapshot{
125+ {
126+ Workspace: entity.WorkspaceSnapshot{
127+ Root: &entity.PaneNodeSnapshot{
128+ Pane: &entity.PaneSnapshot{ID: "pane-1"},
129+ },
130+ },
131+ },
132+ {
133+ Workspace: entity.WorkspaceSnapshot{
134+ Root: &entity.PaneNodeSnapshot{
135+ Children: []*entity.PaneNodeSnapshot{
136+ {Pane: &entity.PaneSnapshot{ID: "pane-2"}},
137+ {Pane: &entity.PaneSnapshot{ID: "pane-3"}},
138+ },
139+ },
140+ },
141+ },
142+ },
143+ }
144+
145+ assert.Equal(t, 3, state.CountPanes())
146+}
147+
148+func TestGetRelativeTime(t *testing.T) {
149+ now := time.Now()
150+
151+ tests := []struct {
152+ name string
153+ t time.Time
154+ expected string
155+ }{
156+ {"just now", now.Add(-30 * time.Second), "just now"},
157+ {"1 minute", now.Add(-1 * time.Minute), "1m ago"},
158+ {"5 minutes", now.Add(-5 * time.Minute), "05m ago"},
159+ {"1 hour", now.Add(-1 * time.Hour), "1h ago"},
160+ {"3 hours", now.Add(-3 * time.Hour), "03h ago"},
161+ {"1 day", now.Add(-24 * time.Hour), "1d ago"},
162+ {"2 days", now.Add(-48 * time.Hour), "02d ago"},
163+ {"1 week", now.Add(-7 * 24 * time.Hour), "1w ago"},
164+ {"2 weeks", now.Add(-14 * 24 * time.Hour), "02w ago"},
165+ }
166+
167+ for _, tt := range tests {
168+ t.Run(tt.name, func(t *testing.T) {
169+ result := usecase.GetRelativeTime(tt.t)
170+ assert.Equal(t, tt.expected, result)
171+ })
172+ }
173+}
M internal/cli/app.go +10 -0
@@ -31,6 +31,9 @@ type App struct {
31 // Use cases31 // Use cases
32 SearchHistoryUC *usecase.SearchHistoryUseCase32 SearchHistoryUC *usecase.SearchHistoryUseCase
33 SessionUC *usecase.ManageSessionUseCase33 SessionUC *usecase.ManageSessionUseCase
34+ ListSessionsUC *usecase.ListSessionsUseCase
35+ RestoreUC *usecase.RestoreSessionUseCase
36+ DeleteSessionUC *usecase.DeleteSessionUseCase
34 37
35 // Services38 // Services
36 FaviconService *favicon.Service39 FaviconService *favicon.Service
@@ -105,9 +108,13 @@ func NewApp() (*App, error) {
105 108
106 // Create repositories109 // Create repositories
107 historyRepo := sqlite.NewHistoryRepository(db)110 historyRepo := sqlite.NewHistoryRepository(db)
111+ sessionStateRepo := sqlite.NewSessionStateRepository(db)
108 112
109 // Create use cases113 // Create use cases
110 searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)114 searchHistoryUC := usecase.NewSearchHistoryUseCase(historyRepo)
115+ listSessionsUC := usecase.NewListSessionsUseCase(sessionRepo, sessionStateRepo, cfg.Logging.LogDir)
116+ restoreUC := usecase.NewRestoreSessionUseCase(sessionStateRepo, sessionRepo)
117+ deleteSessionUC := usecase.NewDeleteSessionUseCase(sessionStateRepo, sessionRepo, cfg.Logging.LogDir)
111 118
112 // Create favicon service for CLI (path resolution for dmenu/fuzzel)119 // Create favicon service for CLI (path resolution for dmenu/fuzzel)
113 faviconCacheDir, _ := config.GetFaviconCacheDir()120 faviconCacheDir, _ := config.GetFaviconCacheDir()
@@ -120,6 +127,9 @@ func NewApp() (*App, error) {
120 History: historyRepo,127 History: historyRepo,
121 SearchHistoryUC: searchHistoryUC,128 SearchHistoryUC: searchHistoryUC,
122 SessionUC: sessionUC,129 SessionUC: sessionUC,
130+ ListSessionsUC: listSessionsUC,
131+ RestoreUC: restoreUC,
132+ DeleteSessionUC: deleteSessionUC,
123 FaviconService: faviconService,133 FaviconService: faviconService,
124 ctx: ctx,134 ctx: ctx,
125 logCleanup: logCleanup,135 logCleanup: logCleanup,
M internal/cli/cmd/purge.go +34 -5
@@ -24,11 +24,12 @@ var purgeCmd = &cobra.Command{
24 24
25 This can remove:25 This can remove:
26 - Config directory26 - Config directory
27- - Data directory27+ - Data directory (includes database)
28 - State directory28 - State directory
29 - Cache directory29 - Cache directory
30 - Content filter caches30 - Content filter caches
31 - Desktop integration files31 - Desktop integration files
32+ - Inactive browser sessions
32 33
33 Use --force to remove everything without prompting.`,34 Use --force to remove everything without prompting.`,
34 RunE: runPurge,35 RunE: runPurge,
@@ -54,20 +55,48 @@ func runPurge(_ *cobra.Command, _ []string) error {
54 return runPurgeForce(app, purgeUC)55 return runPurgeForce(app, purgeUC)
55 }56 }
56 57
57- m := model.NewPurgeModel(app.Theme, purgeUC)58+ // Build session config from app dependencies
59+ cfg := model.PurgeModelConfig{
60+ ListSessionsUC: app.ListSessionsUC,
61+ DeleteSessionUC: app.DeleteSessionUC,
62+ }
63+
64+ m := model.NewPurgeModel(app.Ctx(), app.Theme, purgeUC, cfg)
58 p := tea.NewProgram(m, tea.WithAltScreen())65 p := tea.NewProgram(m, tea.WithAltScreen())
59 _, err := p.Run()66 _, err := p.Run()
60 return err67 return err
61 }68 }
62 69
63 func runPurgeForce(app *cli.App, purgeUC *usecase.PurgeDataUseCase) error {70 func runPurgeForce(app *cli.App, purgeUC *usecase.PurgeDataUseCase) error {
64- out, err := purgeUC.PurgeAll(app.Ctx())71+ ctx := app.Ctx()
72+ theme := app.Theme
73+
74+ // First, delete all inactive sessions (before we delete the database)
75+ if app.ListSessionsUC != nil && app.DeleteSessionUC != nil {
76+ output, err := app.ListSessionsUC.GetPurgeableSessions(ctx)
77+ if err == nil && output != nil {
78+ for _, s := range output.Sessions {
79+ sessionID := s.Info.Session.ID
80+ if delErr := app.DeleteSessionUC.Execute(ctx, usecase.DeleteSessionInput{
81+ SessionID: sessionID,
82+ CurrentSessionID: "", // CLI has no current session
83+ }); delErr != nil {
84+ fmt.Printf("%s Session %s: %v\n", theme.ErrorStyle.Render(styles.IconX), sessionID, delErr)
85+ } else {
86+ fmt.Printf("%s Session %s\n", theme.SuccessStyle.Render(styles.IconCheck), sessionID)
87+ }
88+ }
89+ }
90+ }
91+
92+ // Then purge all directories
93+ out, err := purgeUC.PurgeAll(ctx)
65 if out != nil {94 if out != nil {
66 for _, r := range out.Results {95 for _, r := range out.Results {
67 if r.Success {96 if r.Success {
68- fmt.Printf("%s %s\n", app.Theme.SuccessStyle.Render(styles.IconCheck), r.Target.Path)97+ fmt.Printf("%s %s\n", theme.SuccessStyle.Render(styles.IconCheck), r.Target.Path)
69 } else {98 } else {
70- fmt.Printf("%s %s: %v\n", app.Theme.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error)99+ fmt.Printf("%s %s: %v\n", theme.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error)
71 }100 }
72 }101 }
73 }102 }
A internal/cli/cmd/sessions.go +308 -0
@@ -0,0 +1,308 @@
1+package cmd
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "os"
7+ "strings"
8+ "text/tabwriter"
9+
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/spf13/cobra"
12+
13+ "github.com/bnema/dumber/internal/application/usecase"
14+ "github.com/bnema/dumber/internal/cli/model"
15+ "github.com/bnema/dumber/internal/domain/entity"
16+ "github.com/bnema/dumber/internal/infrastructure/desktop"
17+)
18+
19+const defaultSessionsLimit = 20
20+
21+var (
22+ sessionsJSON bool
23+ sessionsLimit int
24+)
25+
26+var sessionsCmd = &cobra.Command{
27+ Use: "sessions",
28+ Short: "Manage browser sessions",
29+ Long: `View, restore, and manage browser sessions.
30+
31+Sessions are automatically saved when the browser runs. You can list
32+past sessions and restore them to continue where you left off.
33+
34+Run without arguments to open the interactive session browser.`,
35+ RunE: runSessions,
36+}
37+
38+func init() {
39+ rootCmd.AddCommand(sessionsCmd)
40+}
41+
42+func runSessions(_ *cobra.Command, _ []string) error {
43+ app := GetApp()
44+ if app == nil {
45+ return fmt.Errorf("app not initialized")
46+ }
47+
48+ if app.ListSessionsUC == nil {
49+ return fmt.Errorf("session management not available")
50+ }
51+
52+ // Get current session ID (empty if not running as browser)
53+ var currentSessionID entity.SessionID
54+ if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil {
55+ currentSessionID = active.ID
56+ }
57+
58+ // Run interactive TUI
59+ m := model.NewSessionsModel(app.Ctx(), app.Theme, model.SessionsModelConfig{
60+ ListSessionsUC: app.ListSessionsUC,
61+ RestoreUC: app.RestoreUC,
62+ DeleteSessionUC: app.DeleteSessionUC,
63+ CurrentSession: currentSessionID,
64+ MaxListedSessions: app.Config.Session.MaxListedSessions,
65+ })
66+
67+ p := tea.NewProgram(m, tea.WithAltScreen())
68+ _, err := p.Run()
69+ return err
70+}
71+
72+// sessions list
73+var sessionsListCmd = &cobra.Command{
74+ Use: "list",
75+ Short: "List saved sessions",
76+ Long: `List all saved browser sessions with their tab/pane counts.
77+
78+Sessions are marked as:
79+ ● current - the currently running session
80+ ○ active - another running dumber instance
81+ (blank) - exited session available for restoration`,
82+ RunE: runSessionsList,
83+}
84+
85+func init() {
86+ sessionsCmd.AddCommand(sessionsListCmd)
87+ sessionsListCmd.Flags().BoolVar(&sessionsJSON, "json", false, "output as JSON")
88+ sessionsListCmd.Flags().IntVar(&sessionsLimit, "limit", defaultSessionsLimit, "maximum sessions to show")
89+}
90+
91+func runSessionsList(_ *cobra.Command, _ []string) error {
92+ app := GetApp()
93+ if app == nil {
94+ return fmt.Errorf("app not initialized")
95+ }
96+
97+ if app.ListSessionsUC == nil {
98+ return fmt.Errorf("session management not available")
99+ }
100+
101+ // Get current session ID (empty if not running as browser)
102+ var currentSessionID entity.SessionID
103+ if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil {
104+ currentSessionID = active.ID
105+ }
106+
107+ output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, sessionsLimit)
108+ if err != nil {
109+ return fmt.Errorf("list sessions: %w", err)
110+ }
111+
112+ if sessionsJSON {
113+ return outputSessionsJSON(output.Sessions)
114+ }
115+
116+ return outputSessionsTable(output.Sessions)
117+}
118+
119+func outputSessionsJSON(sessions []entity.SessionInfo) error {
120+ enc := json.NewEncoder(os.Stdout)
121+ enc.SetIndent("", " ")
122+ return enc.Encode(sessions)
123+}
124+
125+func outputSessionsTable(sessions []entity.SessionInfo) error {
126+ if len(sessions) == 0 {
127+ fmt.Println("No saved sessions found.")
128+ return nil
129+ }
130+
131+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
132+ _, _ = fmt.Fprintln(w, "STATUS\tSESSION ID\tTABS\tPANES\tLAST UPDATED")
133+
134+ for _, info := range sessions {
135+ status := " "
136+ switch {
137+ case info.IsCurrent:
138+ status = "●"
139+ case info.IsActive:
140+ status = "○"
141+ }
142+
143+ relTime := usecase.GetRelativeTime(info.UpdatedAt)
144+ _, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n",
145+ status,
146+ info.Session.ID,
147+ info.TabCount,
148+ info.PaneCount,
149+ relTime,
150+ )
151+ }
152+
153+ return w.Flush()
154+}
155+
156+// sessions restore <id>
157+var sessionsRestoreCmd = &cobra.Command{
158+ Use: "restore <session-id>",
159+ Short: "Restore a saved session",
160+ Long: `Restore a previously saved browser session.
161+
162+This launches a new browser window with all tabs and panes from the
163+saved session. The session ID can be found using 'dumber sessions list'.
164+
165+You can use a short suffix of the session ID as long as it's unique.
166+
167+Example:
168+ dumber sessions restore 20251224_143022_abc1
169+ dumber sessions restore abc1`,
170+ Args: cobra.ExactArgs(1),
171+ RunE: runSessionsRestore,
172+}
173+
174+func init() {
175+ sessionsCmd.AddCommand(sessionsRestoreCmd)
176+}
177+
178+func runSessionsRestore(_ *cobra.Command, args []string) error {
179+ app := GetApp()
180+ if app == nil {
181+ return fmt.Errorf("app not initialized")
182+ }
183+
184+ if app.RestoreUC == nil || app.ListSessionsUC == nil {
185+ return fmt.Errorf("session restoration not available")
186+ }
187+
188+ // Find session by ID or suffix
189+ sessionInfo, err := findSessionByIDOrSuffix(args[0])
190+ if err != nil {
191+ return err
192+ }
193+
194+ sessionID := sessionInfo.Session.ID
195+
196+ // Validate the session has restorable state
197+ _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID})
198+ if err != nil {
199+ return fmt.Errorf("restore session: %w", err)
200+ }
201+
202+ // Spawn a new dumber instance with the session
203+ spawner := desktop.NewSessionSpawner(app.Ctx())
204+ if err := spawner.SpawnWithSession(sessionID); err != nil {
205+ return fmt.Errorf("spawn browser: %w", err)
206+ }
207+
208+ fmt.Printf("Restoring session %s...\n", sessionID)
209+ return nil
210+}
211+
212+// sessions delete <id>
213+var sessionsDeleteCmd = &cobra.Command{
214+ Use: "delete <session-id>",
215+ Short: "Delete a saved session",
216+ Long: `Delete a saved browser session and its state.
217+
218+This permanently removes the session snapshot. Active sessions cannot
219+be deleted - close the browser first.
220+
221+You can use a short suffix of the session ID as long as it's unique.
222+
223+Example:
224+ dumber sessions delete 20251224_143022_abc1
225+ dumber sessions delete abc1`,
226+ Args: cobra.ExactArgs(1),
227+ RunE: runSessionsDelete,
228+}
229+
230+func init() {
231+ sessionsCmd.AddCommand(sessionsDeleteCmd)
232+}
233+
234+func runSessionsDelete(_ *cobra.Command, args []string) error {
235+ app := GetApp()
236+ if app == nil {
237+ return fmt.Errorf("app not initialized")
238+ }
239+
240+ if app.DeleteSessionUC == nil || app.ListSessionsUC == nil {
241+ return fmt.Errorf("session management not available")
242+ }
243+
244+ // Find session by ID or suffix
245+ sessionInfo, err := findSessionByIDOrSuffix(args[0])
246+ if err != nil {
247+ return err
248+ }
249+
250+ // Get current session ID
251+ var currentSessionID entity.SessionID
252+ if active, activeErr := app.SessionUC.GetActiveSession(app.Ctx()); activeErr == nil && active != nil {
253+ currentSessionID = active.ID
254+ }
255+
256+ // Delete using use case (handles validation internally)
257+ if err := app.DeleteSessionUC.Execute(app.Ctx(), usecase.DeleteSessionInput{
258+ SessionID: sessionInfo.Session.ID,
259+ CurrentSessionID: currentSessionID,
260+ }); err != nil {
261+ return fmt.Errorf("delete session: %w", err)
262+ }
263+
264+ fmt.Printf("Session %s deleted.\n", sessionInfo.Session.ID)
265+ return nil
266+}
267+
268+// findSessionByIDOrSuffix finds a session by exact ID or unique suffix.
269+// Users typically identify sessions by the last few characters (e.g., "dee5").
270+func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) {
271+ app := GetApp()
272+ if app == nil || app.ListSessionsUC == nil {
273+ return nil, fmt.Errorf("app not initialized")
274+ }
275+
276+ // Get current session ID (empty if not running as browser)
277+ var currentSessionID entity.SessionID
278+ if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil {
279+ currentSessionID = active.ID
280+ }
281+
282+ // List all sessions
283+ output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, defaultSessionsLimit*5)
284+ if err != nil {
285+ return nil, fmt.Errorf("list sessions: %w", err)
286+ }
287+
288+ // Find matching sessions
289+ var matches []entity.SessionInfo
290+ for _, info := range output.Sessions {
291+ if string(info.Session.ID) == idOrSuffix {
292+ // Exact match - return immediately
293+ return &info, nil
294+ }
295+ if strings.HasSuffix(string(info.Session.ID), idOrSuffix) {
296+ matches = append(matches, info)
297+ }
298+ }
299+
300+ switch len(matches) {
301+ case 0:
302+ return nil, fmt.Errorf("no session found matching '%s'", idOrSuffix)
303+ case 1:
304+ return &matches[0], nil
305+ default:
306+ return nil, fmt.Errorf("ambiguous session ID '%s' matches %d sessions - be more specific", idOrSuffix, len(matches))
307+ }
308+}
M internal/cli/model/purge.go +242 -69
@@ -14,23 +14,57 @@ import (
14 14
15 // PurgeModel wraps styles.PurgeModel for standalone CLI use.15 // PurgeModel wraps styles.PurgeModel for standalone CLI use.
16 type PurgeModel struct {16 type PurgeModel struct {
17- selector styles.PurgeModel17+ selector styles.PurgeModel
18- purgeUC *usecase.PurgeDataUseCase18+ purgeUC *usecase.PurgeDataUseCase
19+ listSessionsUC *usecase.ListSessionsUseCase
20+ deleteSessionUC *usecase.DeleteSessionUseCase
19 21
20- loading bool22+ loading bool
21- purging bool23+ loadingSessions bool
22- done bool24+ purging bool
25+ done bool
23 26
24- results *usecase.PurgeOutput27+ // Track loading state for parallel loading
25- info string28+ targetsLoaded bool
26- err error29+ sessionsLoaded bool
30+ targets []entity.PurgeTarget
31+ sessions []entity.SessionPurgeItem
32+ sessionsSize int64
33+
34+ results *usecase.PurgeOutput
35+ sessionResults []sessionPurgeResult
36+ info string
37+ err error
27 38
28 theme *styles.Theme39 theme *styles.Theme
40+ ctx context.Context
41+}
42+
43+// sessionPurgeResult tracks the result of deleting a session.
44+type sessionPurgeResult struct {
45+ SessionID entity.SessionID
46+ Success bool
47+ Error error
48+}
49+
50+// PurgeModelConfig contains optional dependencies for session purging.
51+type PurgeModelConfig struct {
52+ ListSessionsUC *usecase.ListSessionsUseCase
53+ DeleteSessionUC *usecase.DeleteSessionUseCase
29 }54 }
30 55
31 // NewPurgeModel creates a new purge command model.56 // NewPurgeModel creates a new purge command model.
32-func NewPurgeModel(theme *styles.Theme, purgeUC *usecase.PurgeDataUseCase) PurgeModel {57+func NewPurgeModel(ctx context.Context, theme *styles.Theme, purgeUC *usecase.PurgeDataUseCase, cfg PurgeModelConfig) PurgeModel {
33- return PurgeModel{theme: theme, purgeUC: purgeUC, loading: true}58+ m := PurgeModel{
59+ theme: theme,
60+ purgeUC: purgeUC,
61+ listSessionsUC: cfg.ListSessionsUC,
62+ deleteSessionUC: cfg.DeleteSessionUC,
63+ loading: true,
64+ loadingSessions: cfg.ListSessionsUC != nil,
65+ ctx: ctx,
66+ }
67+ return m
34 }68 }
35 69
36 type purgeTargetsLoadedMsg struct {70 type purgeTargetsLoadedMsg struct {
@@ -38,14 +72,25 @@ type purgeTargetsLoadedMsg struct {
38 err error72 err error
39 }73 }
40 74
75+type purgeSessionsLoadedMsg struct {
76+ sessions []entity.SessionPurgeItem
77+ size int64
78+ err error
79+}
80+
41 type purgeCompleteMsg struct {81 type purgeCompleteMsg struct {
42- out *usecase.PurgeOutput82+ targetResults []entity.PurgeResult
43- err error83+ sessionResults []sessionPurgeResult
84+ err error
44 }85 }
45 86
46 // Init implements tea.Model.87 // Init implements tea.Model.
47 func (m PurgeModel) Init() tea.Cmd {88 func (m PurgeModel) Init() tea.Cmd {
48- return m.loadTargets()89+ cmds := []tea.Cmd{m.loadTargets()}
90+ if m.listSessionsUC != nil {
91+ cmds = append(cmds, m.loadSessions())
92+ }
93+ return tea.Batch(cmds...)
49 }94 }
50 95
51 func (m PurgeModel) loadTargets() tea.Cmd {96 func (m PurgeModel) loadTargets() tea.Cmd {
@@ -55,68 +100,172 @@ func (m PurgeModel) loadTargets() tea.Cmd {
55 }100 }
56 }101 }
57 102
103+func (m PurgeModel) loadSessions() tea.Cmd {
104+ return func() tea.Msg {
105+ output, err := m.listSessionsUC.GetPurgeableSessions(context.Background())
106+ if err != nil {
107+ return purgeSessionsLoadedMsg{err: err}
108+ }
109+ return purgeSessionsLoadedMsg{
110+ sessions: output.Sessions,
111+ size: output.TotalSize,
112+ }
113+ }
114+}
115+
58 // Update implements tea.Model.116 // Update implements tea.Model.
59 func (m PurgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {117 func (m PurgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
60 switch msg := msg.(type) {118 switch msg := msg.(type) {
61 case purgeTargetsLoadedMsg:119 case purgeTargetsLoadedMsg:
62- m.loading = false120+ return m.handleTargetsLoaded(msg), nil
63- if msg.err != nil {121+ case purgeSessionsLoadedMsg:
64- m.err = msg.err122+ return m.handleSessionsLoaded(msg), nil
65- m.done = true
66- return m, nil
67- }
68-
69- m.selector = styles.NewPurge(m.theme, msg.targets)
70- return m, nil
71-
72 case purgeCompleteMsg:123 case purgeCompleteMsg:
73- m.purging = false124+ return m.handlePurgeComplete(msg), nil
74- m.done = true
75- m.results = msg.out
76- if msg.err != nil {
77- m.err = msg.err
78- }
79- return m, nil
80-
81 case tea.KeyMsg:125 case tea.KeyMsg:
82 if m.done {126 if m.done {
83 return m, tea.Quit127 return m, tea.Quit
84 }128 }
85- if m.loading || m.purging {129+ if m.isBlocked() {
86 return m, nil130 return m, nil
87 }131 }
132+ // Fall through to updateSelector
88 }133 }
89 134
90- if m.loading || m.purging {135+ if m.isBlocked() {
91 return m, nil136 return m, nil
92 }137 }
93 138
139+ return m.updateSelector(msg)
140+}
141+
142+func (m PurgeModel) handleTargetsLoaded(msg purgeTargetsLoadedMsg) PurgeModel {
143+ if msg.err != nil {
144+ m.err = msg.err
145+ m.done = true
146+ m.loading = false
147+ return m
148+ }
149+ m.targetsLoaded = true
150+ m.targets = msg.targets
151+ return m.checkLoadingComplete()
152+}
153+
154+func (m PurgeModel) handleSessionsLoaded(msg purgeSessionsLoadedMsg) PurgeModel {
155+ if msg.err != nil {
156+ // Session loading failure is non-fatal, continue without sessions
157+ m.sessionsLoaded = true
158+ m.loadingSessions = false
159+ return m.checkLoadingComplete()
160+ }
161+ m.sessionsLoaded = true
162+ m.sessions = msg.sessions
163+ m.sessionsSize = msg.size
164+ return m.checkLoadingComplete()
165+}
166+
167+func (m PurgeModel) handlePurgeComplete(msg purgeCompleteMsg) PurgeModel {
168+ m.purging = false
169+ m.done = true
170+ if msg.targetResults != nil {
171+ m.results = &usecase.PurgeOutput{Results: msg.targetResults}
172+ for _, r := range msg.targetResults {
173+ if r.Success {
174+ m.results.SuccessCount++
175+ } else {
176+ m.results.FailureCount++
177+ }
178+ }
179+ }
180+ m.sessionResults = msg.sessionResults
181+ if msg.err != nil {
182+ m.err = msg.err
183+ }
184+ return m
185+}
186+
187+func (m PurgeModel) isBlocked() bool {
188+ return m.loading || m.loadingSessions || m.purging
189+}
190+
191+func (m PurgeModel) updateSelector(msg tea.Msg) (tea.Model, tea.Cmd) {
94 selector, cmd := m.selector.Update(msg)192 selector, cmd := m.selector.Update(msg)
95 m.selector = selector193 m.selector = selector
96 194
97- if m.selector.Done() {195+ if !m.selector.Done() {
98- if m.selector.Canceled {196+ return m, cmd
99- return m, tea.Quit197+ }
100- }198+
101- if m.selector.Confirmed {199+ if m.selector.Canceled {
102- targetTypes := m.selector.SelectedTypes()200+ return m, tea.Quit
103- if len(targetTypes) == 0 {201+ }
104- m.done = true202+
105- m.info = "Nothing selected"203+ if m.selector.Confirmed {
106- return m, nil204+ targetTypes := m.selector.SelectedTypes()
107- }205+ sessionIDs := m.selector.SelectedSessionIDs()
108- m.purging = true206+ if len(targetTypes) == 0 && len(sessionIDs) == 0 {
109- return m, m.performPurge(targetTypes)207+ m.done = true
208+ m.info = "Nothing selected"
209+ return m, nil
110 }210 }
211+ m.purging = true
212+ return m, m.performPurge(targetTypes, sessionIDs)
111 }213 }
112 214
113 return m, cmd215 return m, cmd
114 }216 }
115 217
116-func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType) tea.Cmd {218+func (m PurgeModel) checkLoadingComplete() PurgeModel {
219+ // If sessions aren't being loaded, only wait for targets
220+ needSessions := m.listSessionsUC != nil
221+ if m.targetsLoaded && (!needSessions || m.sessionsLoaded) {
222+ m.loading = false
223+ m.loadingSessions = false
224+ m.selector = styles.NewPurgeWithSessions(m.theme, m.targets, m.sessions, m.sessionsSize)
225+ }
226+ return m
227+}
228+
229+func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType, sessionIDs []entity.SessionID) tea.Cmd {
117 return func() tea.Msg {230 return func() tea.Msg {
118- out, err := m.purgeUC.Execute(context.Background(), usecase.PurgeInput{TargetTypes: targetTypes})231+ var targetResults []entity.PurgeResult
119- return purgeCompleteMsg{out: out, err: err}232+ var sessionResults []sessionPurgeResult
233+ var lastErr error
234+
235+ // Delete sessions first (if any selected and Data not selected)
236+ if m.deleteSessionUC != nil {
237+ for _, sid := range sessionIDs {
238+ err := m.deleteSessionUC.Execute(m.ctx, usecase.DeleteSessionInput{
239+ SessionID: sid,
240+ CurrentSessionID: "", // CLI has no current session
241+ })
242+ sessionResults = append(sessionResults, sessionPurgeResult{
243+ SessionID: sid,
244+ Success: err == nil,
245+ Error: err,
246+ })
247+ if err != nil {
248+ lastErr = err
249+ }
250+ }
251+ }
252+
253+ // Then delete target directories
254+ if len(targetTypes) > 0 {
255+ out, err := m.purgeUC.Execute(m.ctx, usecase.PurgeInput{TargetTypes: targetTypes})
256+ if out != nil {
257+ targetResults = out.Results
258+ }
259+ if err != nil {
260+ lastErr = err
261+ }
262+ }
263+
264+ return purgeCompleteMsg{
265+ targetResults: targetResults,
266+ sessionResults: sessionResults,
267+ err: lastErr,
268+ }
120 }269 }
121 }270 }
122 271
@@ -124,7 +273,7 @@ func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType) tea.Cmd {
124 func (m PurgeModel) View() string {273 func (m PurgeModel) View() string {
125 t := m.theme274 t := m.theme
126 275
127- if m.loading {276+ if m.loading || m.loadingSessions {
128 spinner := styles.NewLoading(t, "Scanning purge targets...")277 spinner := styles.NewLoading(t, "Scanning purge targets...")
129 return t.Box.Render(spinner.View())278 return t.Box.Render(spinner.View())
130 }279 }
@@ -145,7 +294,7 @@ func (m PurgeModel) View() string {
145 return t.Box.Render(content)294 return t.Box.Render(content)
146 }295 }
147 296
148- if m.err != nil && m.results == nil {297+ if m.err != nil && m.results == nil && len(m.sessionResults) == 0 {
149 content := lipgloss.JoinVertical(298 content := lipgloss.JoinVertical(
150 lipgloss.Left,299 lipgloss.Left,
151 t.ErrorStyle.Render("Error: "+m.err.Error()),300 t.ErrorStyle.Render("Error: "+m.err.Error()),
@@ -155,28 +304,52 @@ func (m PurgeModel) View() string {
155 return t.Box.Render(content)304 return t.Box.Render(content)
156 }305 }
157 306
158- contentLines := []string{t.Title.Render("Purge complete")}307+ return m.renderResults()
159- if m.results != nil {308+ }
160- for _, r := range m.results.Results {309+
161- if r.Success {310+ return m.selector.View()
162- contentLines = append(contentLines, fmt.Sprintf("%s %s", t.SuccessStyle.Render(styles.IconCheck), r.Target.Path))311+}
163- } else {312+
164- contentLines = append(contentLines, fmt.Sprintf("%s %s: %v", t.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error))313+func (m PurgeModel) renderResults() string {
165- }314+ t := m.theme
315+ contentLines := []string{t.Title.Render("Purge complete")}
316+
317+ successCount := 0
318+ failureCount := 0
319+
320+ // Target results
321+ if m.results != nil {
322+ for _, r := range m.results.Results {
323+ if r.Success {
324+ contentLines = append(contentLines, fmt.Sprintf("%s %s", t.SuccessStyle.Render(styles.IconCheck), r.Target.Path))
325+ successCount++
326+ } else {
327+ contentLines = append(contentLines, fmt.Sprintf("%s %s: %v", t.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error))
328+ failureCount++
166 }329 }
167- contentLines = append(
168- contentLines,
169- "",
170- t.Subtle.Render(
171- fmt.Sprintf("%d succeeded, %d failed", m.results.SuccessCount, m.results.FailureCount),
172- ),
173- )
174 }330 }
175- contentLines = append(contentLines, "", t.Subtle.Render("Press any key to exit"))
176- return t.Box.Render(lipgloss.JoinVertical(lipgloss.Left, contentLines...))
177 }331 }
178 332
179- return m.selector.View()333+ // Session results
334+ for _, r := range m.sessionResults {
335+ if r.Success {
336+ contentLines = append(contentLines, fmt.Sprintf("%s Session %s", t.SuccessStyle.Render(styles.IconCheck), r.SessionID))
337+ successCount++
338+ } else {
339+ contentLines = append(contentLines, fmt.Sprintf("%s Session %s: %v", t.ErrorStyle.Render(styles.IconX), r.SessionID, r.Error))
340+ failureCount++
341+ }
342+ }
343+
344+ contentLines = append(
345+ contentLines,
346+ "",
347+ t.Subtle.Render(fmt.Sprintf("%d succeeded, %d failed", successCount, failureCount)),
348+ "",
349+ t.Subtle.Render("Press any key to exit"),
350+ )
351+
352+ return t.Box.Render(lipgloss.JoinVertical(lipgloss.Left, contentLines...))
180 }353 }
181 354
182 var _ tea.Model = (*PurgeModel)(nil)355 var _ tea.Model = (*PurgeModel)(nil)
A internal/cli/model/sessions.go +642 -0
@@ -0,0 +1,642 @@
1+// Package model provides Bubble Tea models for CLI commands.
2+package model
3+
4+import (
5+ "context"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/charmbracelet/bubbles/help"
10+ "github.com/charmbracelet/bubbles/key"
11+ tea "github.com/charmbracelet/bubbletea"
12+ "github.com/charmbracelet/lipgloss"
13+
14+ "github.com/bnema/dumber/internal/application/usecase"
15+ "github.com/bnema/dumber/internal/cli/styles"
16+ "github.com/bnema/dumber/internal/domain/entity"
17+ "github.com/bnema/dumber/internal/infrastructure/desktop"
18+ "github.com/bnema/dumber/internal/logging"
19+)
20+
21+// SessionsModel is the Bubble Tea model for interactive session browser.
22+type SessionsModel struct {
23+ // UI components
24+ help help.Model
25+ keys sessionsKeyMap
26+ confirm *styles.ConfirmModel
27+
28+ // State
29+ sessions []entity.SessionInfo
30+ selectedIdx int
31+ expandedIdx int // -1 means none expanded
32+ width int
33+ height int
34+ err error
35+ statusMessage string
36+ currentSession entity.SessionID
37+
38+ // Config
39+ maxListedSessions int
40+
41+ // Dependencies
42+ ctx context.Context
43+ listSessionsUC *usecase.ListSessionsUseCase
44+ restoreUC *usecase.RestoreSessionUseCase
45+ deleteSessionUC *usecase.DeleteSessionUseCase
46+ theme *styles.Theme
47+}
48+
49+// sessionsKeyMap defines keybindings for the sessions browser.
50+type sessionsKeyMap struct {
51+ Up key.Binding
52+ Down key.Binding
53+ Expand key.Binding
54+ Restore key.Binding
55+ Delete key.Binding
56+ Refresh key.Binding
57+ Help key.Binding
58+ Quit key.Binding
59+}
60+
61+// ShortHelp returns keybindings for the short help view.
62+func (k sessionsKeyMap) ShortHelp() []key.Binding {
63+ return []key.Binding{k.Up, k.Down, k.Expand, k.Restore, k.Delete, k.Quit}
64+}
65+
66+// FullHelp returns keybindings for the full help view.
67+func (k sessionsKeyMap) FullHelp() [][]key.Binding {
68+ return [][]key.Binding{
69+ {k.Up, k.Down, k.Expand},
70+ {k.Restore, k.Delete, k.Refresh},
71+ {k.Help, k.Quit},
72+ }
73+}
74+
75+func defaultSessionsKeyMap() sessionsKeyMap {
76+ return sessionsKeyMap{
77+ Up: key.NewBinding(
78+ key.WithKeys("k", "up"),
79+ key.WithHelp("↑/k", "up"),
80+ ),
81+ Down: key.NewBinding(
82+ key.WithKeys("j", "down"),
83+ key.WithHelp("↓/j", "down"),
84+ ),
85+ Expand: key.NewBinding(
86+ key.WithKeys("enter", "tab"),
87+ key.WithHelp("enter", "expand/collapse"),
88+ ),
89+ Restore: key.NewBinding(
90+ key.WithKeys("r"),
91+ key.WithHelp("r", "restore"),
92+ ),
93+ Delete: key.NewBinding(
94+ key.WithKeys("x", "d"),
95+ key.WithHelp("x", "delete"),
96+ ),
97+ Refresh: key.NewBinding(
98+ key.WithKeys("R"),
99+ key.WithHelp("R", "refresh"),
100+ ),
101+ Help: key.NewBinding(
102+ key.WithKeys("?"),
103+ key.WithHelp("?", "help"),
104+ ),
105+ Quit: key.NewBinding(
106+ key.WithKeys("q", "esc", "ctrl+c"),
107+ key.WithHelp("q", "quit"),
108+ ),
109+ }
110+}
111+
112+// SessionsModelConfig holds configuration for the sessions model.
113+type SessionsModelConfig struct {
114+ ListSessionsUC *usecase.ListSessionsUseCase
115+ RestoreUC *usecase.RestoreSessionUseCase
116+ DeleteSessionUC *usecase.DeleteSessionUseCase
117+ CurrentSession entity.SessionID
118+ MaxListedSessions int
119+}
120+
121+// NewSessionsModel creates a new sessions browser model.
122+func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsModelConfig) SessionsModel {
123+ maxListed := cfg.MaxListedSessions
124+ if maxListed <= 0 {
125+ maxListed = 50 // fallback default
126+ }
127+
128+ return SessionsModel{
129+ help: help.New(),
130+ keys: defaultSessionsKeyMap(),
131+ selectedIdx: 0,
132+ expandedIdx: -1,
133+ width: 80,
134+ height: 24,
135+ maxListedSessions: maxListed,
136+ ctx: ctx,
137+ listSessionsUC: cfg.ListSessionsUC,
138+ restoreUC: cfg.RestoreUC,
139+ deleteSessionUC: cfg.DeleteSessionUC,
140+ currentSession: cfg.CurrentSession,
141+ theme: theme,
142+ }
143+}
144+
145+// Init implements tea.Model.
146+func (m SessionsModel) Init() tea.Cmd {
147+ return m.loadSessions
148+}
149+
150+// sessionsLoadedMsg is sent when sessions are loaded.
151+type sessionsLoadedMsg struct {
152+ sessions []entity.SessionInfo
153+ err error
154+}
155+
156+// sessionDeletedMsg is sent when a session is deleted.
157+type sessionDeletedMsg struct {
158+ sessionID entity.SessionID
159+ err error
160+}
161+
162+// sessionRestoredMsg is sent when a session restoration is triggered.
163+type sessionRestoredMsg struct {
164+ sessionID entity.SessionID
165+ err error
166+}
167+
168+func (m SessionsModel) loadSessions() tea.Msg {
169+ log := logging.FromContext(m.ctx)
170+ log.Debug().Msg("loading sessions")
171+
172+ if m.listSessionsUC == nil {
173+ return sessionsLoadedMsg{err: fmt.Errorf("session management not available")}
174+ }
175+
176+ output, err := m.listSessionsUC.Execute(m.ctx, m.currentSession, m.maxListedSessions)
177+ if err != nil {
178+ log.Error().Err(err).Msg("failed to load sessions")
179+ return sessionsLoadedMsg{err: err}
180+ }
181+
182+ log.Debug().Int("count", len(output.Sessions)).Msg("loaded sessions")
183+ return sessionsLoadedMsg{sessions: output.Sessions}
184+}
185+
186+// Update implements tea.Model.
187+func (m SessionsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
188+ // Handle confirm modal
189+ if m.confirm != nil {
190+ return m.handleConfirmModal(msg)
191+ }
192+
193+ switch msg := msg.(type) {
194+ case tea.WindowSizeMsg:
195+ m.width = msg.Width
196+ m.height = msg.Height
197+ m.help.Width = msg.Width
198+ return m, nil
199+
200+ case tea.KeyMsg:
201+ return m.handleKeyMsg(msg)
202+
203+ case sessionsLoadedMsg:
204+ if msg.err != nil {
205+ m.err = msg.err
206+ } else {
207+ m.sessions = msg.sessions
208+ m.err = nil
209+ }
210+ return m, nil
211+
212+ case sessionDeletedMsg:
213+ if msg.err != nil {
214+ m.statusMessage = fmt.Sprintf("Error: %v", msg.err)
215+ } else {
216+ m.statusMessage = fmt.Sprintf("Session %s deleted", msg.sessionID)
217+ }
218+ return m, m.loadSessions
219+
220+ case sessionRestoredMsg:
221+ if msg.err != nil {
222+ m.statusMessage = fmt.Sprintf("Error: %v", msg.err)
223+ } else {
224+ m.statusMessage = fmt.Sprintf("Restoring session %s...", msg.sessionID)
225+ return m, tea.Quit
226+ }
227+ return m, nil
228+ }
229+
230+ return m, nil
231+}
232+
233+func (m SessionsModel) handleConfirmModal(msg tea.Msg) (tea.Model, tea.Cmd) {
234+ confirm, cmd := m.confirm.Update(msg)
235+ m.confirm = &confirm
236+ if m.confirm.Done() {
237+ if m.confirm.Result() {
238+ // User confirmed deletion
239+ if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
240+ session := m.sessions[m.selectedIdx]
241+ cmd = m.deleteSession(session.Session.ID)
242+ }
243+ }
244+ m.confirm = nil
245+ return m, cmd
246+ }
247+ return m, cmd
248+}
249+
250+func (m SessionsModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
251+ switch {
252+ case key.Matches(msg, m.keys.Quit):
253+ return m, tea.Quit
254+
255+ case key.Matches(msg, m.keys.Up):
256+ if m.selectedIdx > 0 {
257+ m.selectedIdx--
258+ }
259+ return m, nil
260+
261+ case key.Matches(msg, m.keys.Down):
262+ if m.selectedIdx < len(m.sessions)-1 {
263+ m.selectedIdx++
264+ }
265+ return m, nil
266+
267+ case key.Matches(msg, m.keys.Expand):
268+ if m.expandedIdx == m.selectedIdx {
269+ m.expandedIdx = -1 // collapse
270+ } else {
271+ m.expandedIdx = m.selectedIdx // expand
272+ }
273+ return m, nil
274+
275+ case key.Matches(msg, m.keys.Restore):
276+ if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
277+ session := m.sessions[m.selectedIdx]
278+ if session.IsCurrent {
279+ m.statusMessage = "Cannot restore current session"
280+ return m, nil
281+ }
282+ return m, m.restoreSession(session.Session.ID)
283+ }
284+ return m, nil
285+
286+ case key.Matches(msg, m.keys.Delete):
287+ if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
288+ session := m.sessions[m.selectedIdx]
289+ if session.IsCurrent || session.IsActive {
290+ m.statusMessage = "Cannot delete active session"
291+ return m, nil
292+ }
293+ confirm := styles.NewConfirm(m.theme, fmt.Sprintf("Delete session %s?", session.Session.ID))
294+ m.confirm = &confirm
295+ }
296+ return m, nil
297+
298+ case key.Matches(msg, m.keys.Refresh):
299+ return m, m.loadSessions
300+
301+ case key.Matches(msg, m.keys.Help):
302+ m.help.ShowAll = !m.help.ShowAll
303+ return m, nil
304+ }
305+
306+ return m, nil
307+}
308+
309+func (m SessionsModel) deleteSession(sessionID entity.SessionID) tea.Cmd {
310+ return func() tea.Msg {
311+ if m.deleteSessionUC == nil {
312+ return sessionDeletedMsg{sessionID: sessionID, err: fmt.Errorf("delete session use case not available")}
313+ }
314+
315+ err := m.deleteSessionUC.Execute(m.ctx, usecase.DeleteSessionInput{
316+ SessionID: sessionID,
317+ CurrentSessionID: m.currentSession,
318+ })
319+ return sessionDeletedMsg{sessionID: sessionID, err: err}
320+ }
321+}
322+
323+func (m SessionsModel) restoreSession(sessionID entity.SessionID) tea.Cmd {
324+ return func() tea.Msg {
325+ log := logging.FromContext(m.ctx)
326+ log.Info().Str("session_id", string(sessionID)).Msg("restoring session")
327+
328+ if m.restoreUC == nil {
329+ return sessionRestoredMsg{sessionID: sessionID, err: fmt.Errorf("restore use case not available")}
330+ }
331+
332+ // Validate the session has restorable state
333+ _, err := m.restoreUC.Execute(m.ctx, usecase.RestoreInput{SessionID: sessionID})
334+ if err != nil {
335+ return sessionRestoredMsg{sessionID: sessionID, err: err}
336+ }
337+
338+ // Spawn a new dumber instance
339+ spawner := desktop.NewSessionSpawner(m.ctx)
340+ if err := spawner.SpawnWithSession(sessionID); err != nil {
341+ return sessionRestoredMsg{sessionID: sessionID, err: err}
342+ }
343+
344+ return sessionRestoredMsg{sessionID: sessionID}
345+ }
346+}
347+
348+// View implements tea.Model.
349+func (m SessionsModel) View() string {
350+ // Handle confirm modal
351+ if m.confirm != nil {
352+ return m.confirm.View()
353+ }
354+
355+ t := m.theme
356+ var b strings.Builder
357+
358+ // Header
359+ header := m.renderHeader()
360+ b.WriteString(header)
361+ b.WriteString("\n\n")
362+
363+ // Error display
364+ if m.err != nil {
365+ b.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err)))
366+ b.WriteString("\n\n")
367+ }
368+
369+ // Status message
370+ if m.statusMessage != "" {
371+ b.WriteString(t.Subtle.Render(m.statusMessage))
372+ b.WriteString("\n\n")
373+ }
374+
375+ // Sessions list
376+ if len(m.sessions) == 0 {
377+ b.WriteString(t.Subtle.Render(" No saved sessions found."))
378+ b.WriteString("\n")
379+ } else {
380+ b.WriteString(m.renderSessionsList())
381+ }
382+
383+ b.WriteString("\n")
384+
385+ // Help
386+ helpView := m.help.View(m.keys)
387+ b.WriteString(helpView)
388+
389+ return b.String()
390+}
391+
392+func (m SessionsModel) renderHeader() string {
393+ t := m.theme
394+
395+ iconStyle := lipgloss.NewStyle().Foreground(t.Accent)
396+ titleStyle := t.Title.MarginLeft(1)
397+
398+ icon := iconStyle.Render(styles.IconSession)
399+ title := titleStyle.Render("Sessions")
400+
401+ // Count stats
402+ var activeCount, exitedCount int
403+ for _, s := range m.sessions {
404+ if s.IsCurrent || s.IsActive {
405+ activeCount++
406+ } else {
407+ exitedCount++
408+ }
409+ }
410+
411+ stats := t.Subtle.Render(fmt.Sprintf(" %s %d active %s %d exited",
412+ styles.IconPlay, activeCount,
413+ styles.IconStop, exitedCount,
414+ ))
415+
416+ return icon + title + stats
417+}
418+
419+func (m SessionsModel) renderSessionsList() string {
420+ var b strings.Builder
421+
422+ for i, info := range m.sessions {
423+ isSelected := i == m.selectedIdx
424+ isExpanded := i == m.expandedIdx
425+
426+ b.WriteString(m.renderSessionRow(info, isSelected, isExpanded))
427+ b.WriteString("\n")
428+
429+ // Render expanded details
430+ if isExpanded && info.State != nil {
431+ b.WriteString(m.renderSessionDetails(info))
432+ }
433+ }
434+
435+ return b.String()
436+}
437+
438+func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string {
439+ t := m.theme
440+
441+ // Cursor
442+ cursor := " "
443+ if isSelected {
444+ cursor = t.Highlight.Render(styles.IconCursor + " ")
445+ }
446+
447+ // Status icon
448+ var statusIcon string
449+ var statusStyle lipgloss.Style
450+ switch {
451+ case info.IsCurrent:
452+ statusIcon = styles.IconPlay
453+ statusStyle = lipgloss.NewStyle().Foreground(t.Accent)
454+ case info.IsActive:
455+ statusIcon = styles.IconPlay
456+ statusStyle = lipgloss.NewStyle().Foreground(t.Warning)
457+ default:
458+ statusIcon = styles.IconStop
459+ statusStyle = t.Subtle
460+ }
461+
462+ // Session ID
463+ idStyle := t.Normal
464+ if isSelected {
465+ idStyle = t.Highlight
466+ }
467+
468+ // Label for current/active
469+ label := ""
470+ if info.IsCurrent {
471+ label = t.Badge.Render("current")
472+ } else if info.IsActive {
473+ label = t.BadgeMuted.Render("active")
474+ }
475+
476+ // Expand indicator
477+ expandIcon := styles.IconExpand
478+ if isExpanded {
479+ expandIcon = styles.IconCollapse
480+ }
481+ expandStyle := t.Subtle
482+
483+ // Tab/pane counts
484+ counts := t.Subtle.Render(fmt.Sprintf("%s %d %s %d",
485+ styles.IconTab, info.TabCount,
486+ styles.IconPane, info.PaneCount,
487+ ))
488+
489+ // Time
490+ relTime := usecase.GetRelativeTime(info.UpdatedAt)
491+ timeStr := t.Subtle.Render(fmt.Sprintf("%s %s", styles.IconClock, relTime))
492+
493+ // Build row
494+ row := fmt.Sprintf("%s%s %s %s %s %s %s",
495+ cursor,
496+ statusStyle.Render(statusIcon),
497+ idStyle.Render(string(info.Session.ID)),
498+ label,
499+ expandStyle.Render(expandIcon),
500+ counts,
501+ timeStr,
502+ )
503+
504+ return row
505+}
506+
507+func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string {
508+ t := m.theme
509+ var b strings.Builder
510+
511+ if info.State == nil {
512+ b.WriteString(t.Subtle.Render(" No state data available\n"))
513+ return b.String()
514+ }
515+
516+ // Tree prefix styles
517+ treeStyle := lipgloss.NewStyle().Foreground(t.Border)
518+ leafStyle := lipgloss.NewStyle().Foreground(t.Muted)
519+
520+ // Render tabs as tree
521+ for tabIdx, tab := range info.State.Tabs {
522+ isLastTab := tabIdx == len(info.State.Tabs)-1
523+
524+ // Tab branch
525+ branch := "├── "
526+ if isLastTab {
527+ branch = "└── "
528+ }
529+
530+ tabIcon := styles.IconTab
531+ if tab.IsPinned {
532+ tabIcon = styles.IconCheckboxChecked
533+ }
534+
535+ tabName := tab.Name
536+ if tabName == "" {
537+ tabName = "Tab"
538+ }
539+
540+ b.WriteString(fmt.Sprintf(" %s%s %s\n",
541+ treeStyle.Render(branch),
542+ leafStyle.Render(tabIcon),
543+ t.Normal.Render(tabName),
544+ ))
545+
546+ // Render panes under this tab
547+ m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle)
548+ }
549+
550+ b.WriteString("\n")
551+ return b.String()
552+}
553+
554+func (m SessionsModel) renderPaneTree(
555+ b *strings.Builder,
556+ ws *entity.WorkspaceSnapshot,
557+ isLastTab bool,
558+ t *styles.Theme,
559+ treeStyle, leafStyle lipgloss.Style,
560+) {
561+ if ws == nil || ws.Root == nil {
562+ return
563+ }
564+
565+ // Determine prefix for children
566+ childPrefix := " │ "
567+ if isLastTab {
568+ childPrefix = " "
569+ }
570+
571+ m.renderPaneNode(b, ws.Root, childPrefix, true, t, treeStyle, leafStyle)
572+}
573+
574+func (m SessionsModel) renderPaneNode(
575+ b *strings.Builder,
576+ node *entity.PaneNodeSnapshot,
577+ prefix string,
578+ isLast bool,
579+ t *styles.Theme,
580+ treeStyle, leafStyle lipgloss.Style,
581+) {
582+ if node == nil {
583+ return
584+ }
585+
586+ branch := "├── "
587+ if isLast {
588+ branch = "└── "
589+ }
590+
591+ if node.Pane != nil {
592+ // Leaf node (actual pane)
593+ pane := node.Pane
594+ title := pane.Title
595+ if title == "" {
596+ title = pane.URI
597+ }
598+ // Truncate long titles
599+ const maxTitleLen = 50
600+ if len(title) > maxTitleLen {
601+ title = title[:maxTitleLen-3] + "..."
602+ }
603+
604+ fmt.Fprintf(b, "%s%s%s %s\n",
605+ prefix,
606+ treeStyle.Render(branch),
607+ leafStyle.Render(styles.IconPane),
608+ t.Subtle.Render(title),
609+ )
610+ } else if len(node.Children) > 0 {
611+ // Container node (split or stacked)
612+ splitIcon := styles.IconSessionStack
613+ splitType := "split"
614+ if node.IsStacked {
615+ splitType = "stacked"
616+ } else if node.SplitRatio > 0 {
617+ splitType = fmt.Sprintf("split %.0f%%", node.SplitRatio*100)
618+ }
619+
620+ fmt.Fprintf(b, "%s%s%s %s\n",
621+ prefix,
622+ treeStyle.Render(branch),
623+ leafStyle.Render(splitIcon),
624+ t.Subtle.Render(splitType),
625+ )
626+
627+ // Child prefix
628+ childPrefix := prefix + "│ "
629+ if isLast {
630+ childPrefix = prefix + " "
631+ }
632+
633+ // Render all children
634+ for i, child := range node.Children {
635+ isLastChild := i == len(node.Children)-1
636+ m.renderPaneNode(b, child, childPrefix, isLastChild, t, treeStyle, leafStyle)
637+ }
638+ }
639+}
640+
641+// Ensure interface compliance at compile time.
642+var _ tea.Model = (*SessionsModel)(nil)
M internal/cli/styles/icons.go +13 -0
@@ -40,4 +40,17 @@ const (
40 40
41 // UI41 // UI
42 IconCursor = "\uf054" // chevron-right42 IconCursor = "\uf054" // chevron-right
43+
44+ // Sessions / browser
45+ IconSession = "\uf2d2" // window
46+ IconSessionStack = "\uf24d" // clone/stack
47+ IconTab = "\uf0ce" // table
48+ IconPane = "\uf0db" // columns
49+ IconTree = "\uf1bb" // tree
50+ IconClock = "\uf017" // clock
51+ IconPlay = "\uf04b" // play (running)
52+ IconStop = "\uf04d" // stop (exited)
53+ IconRestore = "\uf0e2" // rotate-left (restore)
54+ IconExpand = "\uf065" // expand
55+ IconCollapse = "\uf066" // compress
43 )56 )
M internal/cli/styles/purge.go +327 -41
@@ -3,6 +3,7 @@ package styles
3 import (3 import (
4 "fmt"4 "fmt"
5 "strings"5 "strings"
6+ "time"
6 7
7 "github.com/charmbracelet/bubbles/key"8 "github.com/charmbracelet/bubbles/key"
8 tea "github.com/charmbracelet/bubbletea"9 tea "github.com/charmbracelet/bubbletea"
@@ -17,34 +18,41 @@ type PurgeItem struct {
17 Selected bool18 Selected bool
18 }19 }
19 20
20-// PurgeModel is the multi-select purge modal.21+// PurgeModel is the multi-select purge modal with sessions support.
21 type PurgeModel struct {22 type PurgeModel struct {
22- Items []PurgeItem23+ Items []PurgeItem
23- Cursor int24+ Sessions []entity.SessionPurgeItem
24- Confirmed bool25+ SessionsSize int64 // Total size of all session snapshots
25- Canceled bool26+ Cursor int
26- theme *Theme27+ Confirmed bool
28+ Canceled bool
29+ theme *Theme
30+
31+ // Track cursor position: false = items section, true = sessions section
32+ inSessionsSection bool
27 }33 }
28 34
29 // PurgeKeyMap defines keybindings for purge modal.35 // PurgeKeyMap defines keybindings for purge modal.
30 type PurgeKeyMap struct {36 type PurgeKeyMap struct {
31- Up key.Binding37+ Up key.Binding
32- Down key.Binding38+ Down key.Binding
33- Toggle key.Binding39+ Toggle key.Binding
34- ToggleAll key.Binding40+ ToggleAll key.Binding
35- Confirm key.Binding41+ ToggleSessions key.Binding
36- Cancel key.Binding42+ Confirm key.Binding
43+ Cancel key.Binding
37 }44 }
38 45
39 // DefaultPurgeKeyMap returns default keybindings.46 // DefaultPurgeKeyMap returns default keybindings.
40 func DefaultPurgeKeyMap() PurgeKeyMap {47 func DefaultPurgeKeyMap() PurgeKeyMap {
41 return PurgeKeyMap{48 return PurgeKeyMap{
42- Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),49+ Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
43- Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),50+ Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
44- Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),51+ Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),
45- ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")),52+ ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")),
46- Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),53+ ToggleSessions: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "toggle sessions")),
47- Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")),54+ Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
55+ Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")),
48 }56 }
49 }57 }
50 58
@@ -60,6 +68,14 @@ func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel {
60 return m68 return m
61 }69 }
62 70
71+// NewPurgeWithSessions creates a new purge modal with targets and sessions.
72+func NewPurgeWithSessions(theme *Theme, targets []entity.PurgeTarget, sessions []entity.SessionPurgeItem, sessionsSize int64) PurgeModel {
73+ m := NewPurge(theme, targets)
74+ m.Sessions = sessions
75+ m.SessionsSize = sessionsSize
76+ return m
77+}
78+
63 // Init implements tea.Model.79 // Init implements tea.Model.
64 func (m PurgeModel) Init() tea.Cmd {80 func (m PurgeModel) Init() tea.Cmd {
65 _ = m81 _ = m
@@ -81,6 +97,8 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) {
81 m.toggleCurrent()97 m.toggleCurrent()
82 case key.Matches(msg, keys.ToggleAll):98 case key.Matches(msg, keys.ToggleAll):
83 m.toggleAll()99 m.toggleAll()
100+ case key.Matches(msg, keys.ToggleSessions):
101+ m.ToggleAllSessions()
84 case key.Matches(msg, keys.Confirm):102 case key.Matches(msg, keys.Confirm):
85 m.Confirmed = true103 m.Confirmed = true
86 case key.Matches(msg, keys.Cancel):104 case key.Matches(msg, keys.Cancel):
@@ -91,19 +109,78 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) {
91 return m, nil109 return m, nil
92 }110 }
93 111
112+// isDataSelected returns true if the Data target is selected.
113+func (m PurgeModel) isDataSelected() bool {
114+ for _, it := range m.Items {
115+ if it.Type == entity.PurgeTargetData && it.Selected {
116+ return true
117+ }
118+ }
119+ return false
120+}
121+
122+// SessionsEnabled returns true if sessions can be individually selected.
123+// Sessions are disabled when Data is selected (database will be removed).
124+func (m PurgeModel) SessionsEnabled() bool {
125+ return !m.isDataSelected()
126+}
127+
128+// totalSelectableCount returns the total number of selectable items across both sections.
129+func (m PurgeModel) totalSelectableCount() int {
130+ count := 0
131+ for _, it := range m.Items {
132+ if it.Exists {
133+ count++
134+ }
135+ }
136+ if m.SessionsEnabled() {
137+ count += len(m.Sessions)
138+ }
139+ return count
140+}
141+
94 func (m *PurgeModel) moveCursor(delta int) {142 func (m *PurgeModel) moveCursor(delta int) {
95- if len(m.Items) == 0 {143+ total := m.totalSelectableCount()
144+ if total == 0 {
96 return145 return
97 }146 }
98 147
99- idx := m.Cursor148+ // Build a flat list of selectable indices
100- for i := 0; i < len(m.Items); i++ {149+ // Items first, then sessions (if enabled)
101- idx = (idx + delta + len(m.Items)) % len(m.Items)150+ type position struct {
102- if m.Items[idx].Exists {151+ inSessions bool
103- m.Cursor = idx152+ index int
104- return153+ }
154+ var positions []position
155+
156+ for i, it := range m.Items {
157+ if it.Exists {
158+ positions = append(positions, position{inSessions: false, index: i})
105 }159 }
106 }160 }
161+ if m.SessionsEnabled() {
162+ for i := range m.Sessions {
163+ positions = append(positions, position{inSessions: true, index: i})
164+ }
165+ }
166+
167+ if len(positions) == 0 {
168+ return
169+ }
170+
171+ // Find current position in the flat list
172+ currentFlat := 0
173+ for i, p := range positions {
174+ if p.inSessions == m.inSessionsSection && p.index == m.Cursor {
175+ currentFlat = i
176+ break
177+ }
178+ }
179+
180+ // Move in the flat list
181+ newFlat := (currentFlat + delta + len(positions)) % len(positions)
182+ m.inSessionsSection = positions[newFlat].inSessions
183+ m.Cursor = positions[newFlat].index
107 }184 }
108 185
109 func (m PurgeModel) firstSelectableIndex() int {186 func (m PurgeModel) firstSelectableIndex() int {
@@ -116,6 +193,16 @@ func (m PurgeModel) firstSelectableIndex() int {
116 }193 }
117 194
118 func (m *PurgeModel) toggleCurrent() {195 func (m *PurgeModel) toggleCurrent() {
196+ if m.inSessionsSection {
197+ if !m.SessionsEnabled() {
198+ return
199+ }
200+ if m.Cursor >= 0 && m.Cursor < len(m.Sessions) {
201+ m.Sessions[m.Cursor].Selected = !m.Sessions[m.Cursor].Selected
202+ }
203+ return
204+ }
205+
119 if m.Cursor < 0 || m.Cursor >= len(m.Items) {206 if m.Cursor < 0 || m.Cursor >= len(m.Items) {
120 return207 return
121 }208 }
@@ -126,6 +213,7 @@ func (m *PurgeModel) toggleCurrent() {
126 }213 }
127 214
128 func (m *PurgeModel) toggleAll() {215 func (m *PurgeModel) toggleAll() {
216+ // Toggle all items
129 anyUnselected := false217 anyUnselected := false
130 for _, it := range m.Items {218 for _, it := range m.Items {
131 if it.Exists && !it.Selected {219 if it.Exists && !it.Selected {
@@ -133,6 +221,15 @@ func (m *PurgeModel) toggleAll() {
133 break221 break
134 }222 }
135 }223 }
224+ // Also check sessions if enabled
225+ if m.SessionsEnabled() {
226+ for _, s := range m.Sessions {
227+ if !s.Selected {
228+ anyUnselected = true
229+ break
230+ }
231+ }
232+ }
136 233
137 for i := range m.Items {234 for i := range m.Items {
138 if !m.Items[i].Exists {235 if !m.Items[i].Exists {
@@ -140,6 +237,30 @@ func (m *PurgeModel) toggleAll() {
140 }237 }
141 m.Items[i].Selected = anyUnselected238 m.Items[i].Selected = anyUnselected
142 }239 }
240+ if m.SessionsEnabled() {
241+ for i := range m.Sessions {
242+ m.Sessions[i].Selected = anyUnselected
243+ }
244+ }
245+}
246+
247+// ToggleAllSessions toggles the selection state of all sessions.
248+func (m *PurgeModel) ToggleAllSessions() {
249+ if !m.SessionsEnabled() || len(m.Sessions) == 0 {
250+ return
251+ }
252+
253+ anyUnselected := false
254+ for _, s := range m.Sessions {
255+ if !s.Selected {
256+ anyUnselected = true
257+ break
258+ }
259+ }
260+
261+ for i := range m.Sessions {
262+ m.Sessions[i].Selected = anyUnselected
263+ }
143 }264 }
144 265
145 // View implements tea.Model.266 // View implements tea.Model.
@@ -149,12 +270,33 @@ func (m PurgeModel) View() string {
149 header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash))270 header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash))
150 subtitle := t.Subtle.Render("Select items to remove")271 subtitle := t.Subtle.Render("Select items to remove")
151 272
152- var rows []string273+ // Application Data section
274+ dataSectionHeader := t.Subtle.Render("── Application Data ──")
275+ var itemRows []string
153 for i, it := range m.Items {276 for i, it := range m.Items {
154- rows = append(rows, m.renderRow(i, it))277+ itemRows = append(itemRows, m.renderItemRow(i, it))
155 }278 }
279+ itemsList := lipgloss.JoinVertical(lipgloss.Left, itemRows...)
280+
281+ // Sessions section
282+ var sessionsList string
283+ if len(m.Sessions) > 0 {
284+ sessionsEnabled := m.SessionsEnabled()
285+ var sessionHeader string
286+ if sessionsEnabled {
287+ sessionHeader = t.Subtle.Render(fmt.Sprintf("── Inactive Sessions (%d total, %s) ──",
288+ len(m.Sessions), formatSize(m.SessionsSize)))
289+ } else {
290+ sessionHeader = t.Subtle.Render("── Inactive Sessions (database will be removed) ──")
291+ }
156 292
157- list := lipgloss.JoinVertical(lipgloss.Left, rows...)293+ var sessionRows []string
294+ sessionRows = append(sessionRows, sessionHeader)
295+ for i, s := range m.Sessions {
296+ sessionRows = append(sessionRows, m.renderSessionRow(i, s, sessionsEnabled))
297+ }
298+ sessionsList = lipgloss.JoinVertical(lipgloss.Left, sessionRows...)
299+ }
158 300
159 selectedCount := m.SelectedCount()301 selectedCount := m.SelectedCount()
160 size := m.SelectedSize()302 size := m.SelectedSize()
@@ -172,28 +314,35 @@ func (m PurgeModel) View() string {
172 summary = t.Subtle.Render("0 selected")314 summary = t.Subtle.Render("0 selected")
173 }315 }
174 316
175- help := t.Subtle.Render("↑/↓ or j/k to move • space to toggle • a toggle all • enter confirm • esc cancel")317+ helpText := "↑/↓ j/k move • space toggle • a all"
318+ if len(m.Sessions) > 0 && m.SessionsEnabled() {
319+ helpText += " • s sessions"
320+ }
321+ helpText += " • enter • esc"
322+ help := t.Subtle.Render(helpText)
176 323
177- content := lipgloss.JoinVertical(324+ contentParts := []string{
178- lipgloss.Left,
179 header,325 header,
180 subtitle,326 subtitle,
181 "",327 "",
182- list,328+ dataSectionHeader,
183- "",329+ itemsList,
184- summary,330+ }
185- "",331+ if sessionsList != "" {
186- help,332+ contentParts = append(contentParts, "", sessionsList)
187- )333+ }
334+ contentParts = append(contentParts, "", summary, "", help)
335+
336+ content := lipgloss.JoinVertical(lipgloss.Left, contentParts...)
188 337
189 return t.Box.Render(content)338 return t.Box.Render(content)
190 }339 }
191 340
192-func (m PurgeModel) renderRow(i int, it PurgeItem) string {341+func (m PurgeModel) renderItemRow(i int, it PurgeItem) string {
193 t := m.theme342 t := m.theme
194 343
195 cursor := " "344 cursor := " "
196- if i == m.Cursor {345+ if !m.inSessionsSection && i == m.Cursor {
197 cursor = IconCursor + " "346 cursor = IconCursor + " "
198 }347 }
199 348
@@ -242,6 +391,62 @@ func (m PurgeModel) renderRow(i int, it PurgeItem) string {
242 return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing)391 return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing)
243 }392 }
244 393
394+func (m PurgeModel) renderSessionRow(i int, s entity.SessionPurgeItem, enabled bool) string {
395+ t := m.theme
396+
397+ cursor := " "
398+ if m.inSessionsSection && i == m.Cursor && enabled {
399+ cursor = IconCursor + " "
400+ }
401+
402+ checkbox := IconCheckboxEmpty
403+ checkboxStyle := t.Subtle
404+ cursorStyle := t.Subtle
405+ labelStyle := t.Subtle
406+ detailStyle := t.Subtle
407+
408+ if enabled {
409+ if s.Selected {
410+ checkbox = IconCheckboxChecked
411+ }
412+ checkboxStyle = lipgloss.NewStyle().Foreground(t.Accent)
413+ cursorStyle = lipgloss.NewStyle().Foreground(t.Accent)
414+ labelStyle = t.Normal
415+ detailStyle = t.Subtle
416+ }
417+
418+ // Format: ...abc1 2 tabs 3 panes 2d ago
419+ shortID := s.Info.Session.ShortID()
420+ tabLabel := "tab"
421+ if s.Info.TabCount != 1 {
422+ tabLabel = "tabs"
423+ }
424+ paneLabel := "pane"
425+ if s.Info.PaneCount != 1 {
426+ paneLabel = "panes"
427+ }
428+
429+ const idPadWidth = 8
430+ const tabPadWidth = 8
431+ const panePadWidth = 9
432+
433+ idPart := labelStyle.Render(padRight("..."+shortID, idPadWidth))
434+ tabPart := detailStyle.Render(padRight(fmt.Sprintf("%d %s", s.Info.TabCount, tabLabel), tabPadWidth))
435+ panePart := detailStyle.Render(padRight(fmt.Sprintf("%d %s", s.Info.PaneCount, paneLabel), panePadWidth))
436+ timePart := detailStyle.Render(getRelativeTime(s.Info.UpdatedAt))
437+
438+ return lipgloss.JoinHorizontal(
439+ lipgloss.Left,
440+ cursorStyle.Render(cursor),
441+ checkboxStyle.Render(checkbox),
442+ " ",
443+ idPart,
444+ tabPart,
445+ panePart,
446+ timePart,
447+ )
448+}
449+
245 func purgeLabel(t entity.PurgeTargetType) string {450 func purgeLabel(t entity.PurgeTargetType) string {
246 switch t {451 switch t {
247 case entity.PurgeTargetConfig:452 case entity.PurgeTargetConfig:
@@ -288,6 +493,45 @@ func formatSize(bytes int64) string {
288 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])493 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
289 }494 }
290 495
496+func getRelativeTime(t time.Time) string {
497+ now := time.Now()
498+ diff := now.Sub(t)
499+
500+ const (
501+ hoursPerDay = 24
502+ daysPerWeek = 7
503+ )
504+
505+ switch {
506+ case diff < time.Minute:
507+ return "just now"
508+ case diff < time.Hour:
509+ mins := int(diff.Minutes())
510+ if mins == 1 {
511+ return "1m ago"
512+ }
513+ return fmt.Sprintf("%dm ago", mins)
514+ case diff < hoursPerDay*time.Hour:
515+ hours := int(diff.Hours())
516+ if hours == 1 {
517+ return "1h ago"
518+ }
519+ return fmt.Sprintf("%dh ago", hours)
520+ case diff < daysPerWeek*hoursPerDay*time.Hour:
521+ days := int(diff.Hours() / hoursPerDay)
522+ if days == 1 {
523+ return "1d ago"
524+ }
525+ return fmt.Sprintf("%dd ago", days)
526+ default:
527+ weeks := int(diff.Hours() / hoursPerDay / daysPerWeek)
528+ if weeks == 1 {
529+ return "1w ago"
530+ }
531+ return fmt.Sprintf("%dw ago", weeks)
532+ }
533+}
534+
291 // Done returns true if the modal is complete.535 // Done returns true if the modal is complete.
292 func (m PurgeModel) Done() bool {536 func (m PurgeModel) Done() bool {
293 return m.Confirmed || m.Canceled537 return m.Confirmed || m.Canceled
@@ -309,7 +553,7 @@ func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType {
309 return out553 return out
310 }554 }
311 555
312-// SelectedCount returns the number of selected, existing items.556+// SelectedCount returns the number of selected items (targets + sessions).
313 func (m PurgeModel) SelectedCount() int {557 func (m PurgeModel) SelectedCount() int {
314 count := 0558 count := 0
315 for _, it := range m.Items {559 for _, it := range m.Items {
@@ -317,10 +561,19 @@ func (m PurgeModel) SelectedCount() int {
317 count++561 count++
318 }562 }
319 }563 }
564+ // Only count sessions if they're enabled (Data not selected)
565+ if m.SessionsEnabled() {
566+ for _, s := range m.Sessions {
567+ if s.Selected {
568+ count++
569+ }
570+ }
571+ }
320 return count572 return count
321 }573 }
322 574
323-// SelectedSize returns the total size of selected, existing items.575+// SelectedSize returns the total size of selected items.
576+// When Data is selected, includes SessionsSize since DB will be removed.
324 func (m PurgeModel) SelectedSize() int64 {577 func (m PurgeModel) SelectedSize() int64 {
325 var total int64578 var total int64
326 for _, it := range m.Items {579 for _, it := range m.Items {
@@ -328,5 +581,38 @@ func (m PurgeModel) SelectedSize() int64 {
328 total += it.Size581 total += it.Size
329 }582 }
330 }583 }
584+ // If Data is selected, sessions are implicitly included
585+ if m.isDataSelected() {
586+ total += m.SessionsSize
587+ } else {
588+ // Otherwise, count only selected sessions
589+ // Note: individual session sizes aren't tracked, only total
590+ // When sessions are selected, we include the total size proportionally
591+ selectedCount := 0
592+ for _, s := range m.Sessions {
593+ if s.Selected {
594+ selectedCount++
595+ }
596+ }
597+ if len(m.Sessions) > 0 && selectedCount > 0 {
598+ total += m.SessionsSize * int64(selectedCount) / int64(len(m.Sessions))
599+ }
600+ }
331 return total601 return total
332 }602 }
603+
604+// SelectedSessionIDs returns the IDs of selected sessions.
605+// Returns nil if Data is selected (sessions are deleted with the database).
606+func (m PurgeModel) SelectedSessionIDs() []entity.SessionID {
607+ if m.isDataSelected() {
608+ return nil
609+ }
610+
611+ var ids []entity.SessionID
612+ for _, s := range m.Sessions {
613+ if s.Selected {
614+ ids = append(ids, s.Info.Session.ID)
615+ }
616+ }
617+ return ids
618+}
A internal/cli/styles/purge_test.go +240 -0
@@ -0,0 +1,240 @@
1+package styles_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/cli/styles"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/infrastructure/config"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func testTheme() *styles.Theme {
15+ return styles.NewTheme(config.DefaultConfig())
16+}
17+
18+func TestPurgeModel_SessionsDisabledWhenDataSelected(t *testing.T) {
19+ theme := testTheme()
20+
21+ targets := []entity.PurgeTarget{
22+ {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100},
23+ {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 1000},
24+ }
25+
26+ endedAt := time.Now().Add(-time.Hour)
27+ sessions := []entity.SessionPurgeItem{
28+ {
29+ Info: entity.SessionInfo{
30+ Session: &entity.Session{
31+ ID: "session1",
32+ Type: entity.SessionTypeBrowser,
33+ StartedAt: time.Now().Add(-2 * time.Hour),
34+ EndedAt: &endedAt,
35+ },
36+ TabCount: 2,
37+ PaneCount: 3,
38+ },
39+ Selected: true,
40+ },
41+ }
42+
43+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
44+
45+ // Initially Data is selected (default for existing items)
46+ assert.False(t, m.SessionsEnabled(), "sessions should be disabled when Data is selected")
47+
48+ // Deselect Data
49+ m.Items[1].Selected = false
50+ assert.True(t, m.SessionsEnabled(), "sessions should be enabled when Data is not selected")
51+}
52+
53+func TestPurgeModel_SelectedSessionIDs_ReturnsNilWhenDataSelected(t *testing.T) {
54+ theme := testTheme()
55+
56+ targets := []entity.PurgeTarget{
57+ {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 1000},
58+ }
59+
60+ endedAt := time.Now().Add(-time.Hour)
61+ sessions := []entity.SessionPurgeItem{
62+ {
63+ Info: entity.SessionInfo{
64+ Session: &entity.Session{
65+ ID: "session1",
66+ Type: entity.SessionTypeBrowser,
67+ StartedAt: time.Now().Add(-2 * time.Hour),
68+ EndedAt: &endedAt,
69+ },
70+ },
71+ Selected: true,
72+ },
73+ }
74+
75+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
76+
77+ // Data is selected, so sessions should return nil
78+ ids := m.SelectedSessionIDs()
79+ assert.Nil(t, ids, "should return nil when Data is selected")
80+
81+ // Deselect Data
82+ m.Items[0].Selected = false
83+ ids = m.SelectedSessionIDs()
84+ require.Len(t, ids, 1)
85+ assert.Equal(t, entity.SessionID("session1"), ids[0])
86+}
87+
88+func TestPurgeModel_SelectedCount_IncludesSessions(t *testing.T) {
89+ theme := testTheme()
90+
91+ targets := []entity.PurgeTarget{
92+ {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100},
93+ {Type: entity.PurgeTargetCache, Path: "/cache", Exists: true, Size: 200},
94+ }
95+
96+ endedAt := time.Now().Add(-time.Hour)
97+ sessions := []entity.SessionPurgeItem{
98+ {
99+ Info: entity.SessionInfo{
100+ Session: &entity.Session{
101+ ID: "session1",
102+ Type: entity.SessionTypeBrowser,
103+ StartedAt: time.Now().Add(-2 * time.Hour),
104+ EndedAt: &endedAt,
105+ },
106+ },
107+ Selected: true,
108+ },
109+ {
110+ Info: entity.SessionInfo{
111+ Session: &entity.Session{
112+ ID: "session2",
113+ Type: entity.SessionTypeBrowser,
114+ StartedAt: time.Now().Add(-3 * time.Hour),
115+ EndedAt: &endedAt,
116+ },
117+ },
118+ Selected: false, // Not selected
119+ },
120+ }
121+
122+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
123+
124+ // 2 targets + 1 selected session = 3
125+ assert.Equal(t, 3, m.SelectedCount())
126+}
127+
128+func TestPurgeModel_ToggleAllSessions(t *testing.T) {
129+ theme := testTheme()
130+
131+ targets := []entity.PurgeTarget{
132+ {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100},
133+ }
134+
135+ endedAt := time.Now().Add(-time.Hour)
136+ sessions := []entity.SessionPurgeItem{
137+ {
138+ Info: entity.SessionInfo{
139+ Session: &entity.Session{
140+ ID: "session1",
141+ Type: entity.SessionTypeBrowser,
142+ StartedAt: time.Now().Add(-2 * time.Hour),
143+ EndedAt: &endedAt,
144+ },
145+ },
146+ Selected: true,
147+ },
148+ {
149+ Info: entity.SessionInfo{
150+ Session: &entity.Session{
151+ ID: "session2",
152+ Type: entity.SessionTypeBrowser,
153+ StartedAt: time.Now().Add(-3 * time.Hour),
154+ EndedAt: &endedAt,
155+ },
156+ },
157+ Selected: true,
158+ },
159+ }
160+
161+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 500)
162+
163+ // All sessions are selected, toggle should deselect all
164+ m.ToggleAllSessions()
165+ assert.False(t, m.Sessions[0].Selected)
166+ assert.False(t, m.Sessions[1].Selected)
167+
168+ // Now toggle again to select all
169+ m.ToggleAllSessions()
170+ assert.True(t, m.Sessions[0].Selected)
171+ assert.True(t, m.Sessions[1].Selected)
172+}
173+
174+func TestPurgeModel_SelectedSize_IncludesSessionsProportionally(t *testing.T) {
175+ theme := testTheme()
176+
177+ targets := []entity.PurgeTarget{
178+ {Type: entity.PurgeTargetConfig, Path: "/config", Exists: true, Size: 100},
179+ }
180+
181+ endedAt := time.Now().Add(-time.Hour)
182+ sessions := []entity.SessionPurgeItem{
183+ {
184+ Info: entity.SessionInfo{
185+ Session: &entity.Session{
186+ ID: "session1",
187+ Type: entity.SessionTypeBrowser,
188+ StartedAt: time.Now().Add(-2 * time.Hour),
189+ EndedAt: &endedAt,
190+ },
191+ },
192+ Selected: true,
193+ },
194+ {
195+ Info: entity.SessionInfo{
196+ Session: &entity.Session{
197+ ID: "session2",
198+ Type: entity.SessionTypeBrowser,
199+ StartedAt: time.Now().Add(-3 * time.Hour),
200+ EndedAt: &endedAt,
201+ },
202+ },
203+ Selected: false, // Not selected
204+ },
205+ }
206+
207+ // Total sessions size is 1000, 1 of 2 sessions selected = 500
208+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000)
209+
210+ // 100 (config) + 500 (1/2 of sessions) = 600
211+ assert.Equal(t, int64(600), m.SelectedSize())
212+}
213+
214+func TestPurgeModel_SelectedSize_IncludesFullSessionsSizeWhenDataSelected(t *testing.T) {
215+ theme := testTheme()
216+
217+ targets := []entity.PurgeTarget{
218+ {Type: entity.PurgeTargetData, Path: "/data", Exists: true, Size: 100},
219+ }
220+
221+ endedAt := time.Now().Add(-time.Hour)
222+ sessions := []entity.SessionPurgeItem{
223+ {
224+ Info: entity.SessionInfo{
225+ Session: &entity.Session{
226+ ID: "session1",
227+ Type: entity.SessionTypeBrowser,
228+ StartedAt: time.Now().Add(-2 * time.Hour),
229+ EndedAt: &endedAt,
230+ },
231+ },
232+ Selected: false, // Not individually selected, but Data is selected
233+ },
234+ }
235+
236+ m := styles.NewPurgeWithSessions(theme, targets, sessions, 1000)
237+
238+ // 100 (data) + 1000 (all sessions because DB is being deleted) = 1100
239+ assert.Equal(t, int64(1100), m.SelectedSize())
240+}
M internal/domain/entity/purge.go +6 -0
@@ -30,3 +30,9 @@ type PurgeResult struct {
30 Success bool30 Success bool
31 Error error31 Error error
32 }32 }
33+
34+// SessionPurgeItem represents an inactive session in the purge selection UI.
35+type SessionPurgeItem struct {
36+ Info SessionInfo
37+ Selected bool
38+}
A internal/domain/entity/session_state.go +302 -0
@@ -0,0 +1,302 @@
1+package entity
2+
3+import "time"
4+
5+// SessionStateVersion is the current schema version for session state.
6+// Increment when making breaking changes to the serialization format.
7+const SessionStateVersion = 1
8+
9+// SessionState represents a complete snapshot of a browser session.
10+// This is serialized to JSON and stored in the database.
11+type SessionState struct {
12+ Version int `json:"version"`
13+ SessionID SessionID `json:"session_id"`
14+ Tabs []TabSnapshot `json:"tabs"`
15+ ActiveTabIndex int `json:"active_tab_index"`
16+ SavedAt time.Time `json:"saved_at"`
17+}
18+
19+// TabSnapshot captures the state of a single tab.
20+type TabSnapshot struct {
21+ ID TabID `json:"id"`
22+ Name string `json:"name"`
23+ Position int `json:"position"`
24+ IsPinned bool `json:"is_pinned"`
25+ Workspace WorkspaceSnapshot `json:"workspace"`
26+}
27+
28+// WorkspaceSnapshot captures the pane tree layout.
29+type WorkspaceSnapshot struct {
30+ ID WorkspaceID `json:"id"`
31+ Root *PaneNodeSnapshot `json:"root"`
32+ ActivePaneID PaneID `json:"active_pane_id"`
33+}
34+
35+// PaneNodeSnapshot captures a node in the pane tree.
36+type PaneNodeSnapshot struct {
37+ ID string `json:"id"`
38+ Pane *PaneSnapshot `json:"pane,omitempty"` // Non-nil for leaf nodes
39+ Children []*PaneNodeSnapshot `json:"children,omitempty"` // Non-nil for containers
40+ SplitDir SplitDirection `json:"split_dir"`
41+ SplitRatio float64 `json:"split_ratio"`
42+ IsStacked bool `json:"is_stacked"`
43+ ActiveStackIndex int `json:"active_stack_index"`
44+}
45+
46+// PaneSnapshot captures the essential state of a pane.
47+type PaneSnapshot struct {
48+ ID PaneID `json:"id"`
49+ URI string `json:"uri"`
50+ Title string `json:"title"`
51+ ZoomFactor float64 `json:"zoom_factor"`
52+}
53+
54+// SnapshotFromTabList creates a SessionState from a live TabList.
55+func SnapshotFromTabList(sessionID SessionID, tabs *TabList) *SessionState {
56+ if tabs == nil {
57+ return &SessionState{
58+ Version: SessionStateVersion,
59+ SessionID: sessionID,
60+ Tabs: []TabSnapshot{},
61+ SavedAt: time.Now(),
62+ }
63+ }
64+
65+ snapTabs := make([]TabSnapshot, 0, len(tabs.Tabs))
66+ activeTabIndex := 0
67+
68+ for i, tab := range tabs.Tabs {
69+ if tab.ID == tabs.ActiveTabID {
70+ activeTabIndex = i
71+ }
72+ snapTabs = append(snapTabs, snapshotTab(tab))
73+ }
74+
75+ return &SessionState{
76+ Version: SessionStateVersion,
77+ SessionID: sessionID,
78+ Tabs: snapTabs,
79+ ActiveTabIndex: activeTabIndex,
80+ SavedAt: time.Now(),
81+ }
82+}
83+
84+func snapshotTab(tab *Tab) TabSnapshot {
85+ return TabSnapshot{
86+ ID: tab.ID,
87+ Name: tab.Name,
88+ Position: tab.Position,
89+ IsPinned: tab.IsPinned,
90+ Workspace: snapshotWorkspace(tab.Workspace),
91+ }
92+}
93+
94+func snapshotWorkspace(ws *Workspace) WorkspaceSnapshot {
95+ if ws == nil {
96+ return WorkspaceSnapshot{}
97+ }
98+ return WorkspaceSnapshot{
99+ ID: ws.ID,
100+ Root: snapshotPaneNode(ws.Root),
101+ ActivePaneID: ws.ActivePaneID,
102+ }
103+}
104+
105+func snapshotPaneNode(node *PaneNode) *PaneNodeSnapshot {
106+ if node == nil {
107+ return nil
108+ }
109+
110+ snapshot := &PaneNodeSnapshot{
111+ ID: node.ID,
112+ SplitDir: node.SplitDir,
113+ SplitRatio: node.SplitRatio,
114+ IsStacked: node.IsStacked,
115+ ActiveStackIndex: node.ActiveStackIndex,
116+ }
117+
118+ if node.Pane != nil {
119+ snapshot.Pane = &PaneSnapshot{
120+ ID: node.Pane.ID,
121+ URI: node.Pane.URI,
122+ Title: node.Pane.Title,
123+ ZoomFactor: node.Pane.ZoomFactor,
124+ }
125+ }
126+
127+ if len(node.Children) > 0 {
128+ snapshot.Children = make([]*PaneNodeSnapshot, 0, len(node.Children))
129+ for _, child := range node.Children {
130+ snapshot.Children = append(snapshot.Children, snapshotPaneNode(child))
131+ }
132+ }
133+
134+ return snapshot
135+}
136+
137+// SessionInfo provides summary information for the session manager UI.
138+type SessionInfo struct {
139+ Session *Session
140+ State *SessionState
141+ TabCount int
142+ PaneCount int
143+ IsActive bool // Has active lock file
144+ IsCurrent bool // Is the current session
145+ UpdatedAt time.Time // When the state was last saved
146+}
147+
148+// CountPanes returns the total number of panes in the session state.
149+func (s *SessionState) CountPanes() int {
150+ count := 0
151+ for _, tab := range s.Tabs {
152+ count += countPanesInNode(tab.Workspace.Root)
153+ }
154+ return count
155+}
156+
157+func countPanesInNode(node *PaneNodeSnapshot) int {
158+ if node == nil {
159+ return 0
160+ }
161+ if node.Pane != nil {
162+ return 1
163+ }
164+ count := 0
165+ for _, child := range node.Children {
166+ count += countPanesInNode(child)
167+ }
168+ return count
169+}
170+
171+// IDGenerator is a function that generates unique IDs.
172+type IDGenerator func() string
173+
174+// TabListFromSnapshot reconstructs a TabList from a SessionState snapshot.
175+// Generates new IDs for all entities using the provided generator.
176+// This is the inverse of SnapshotFromTabList.
177+func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList {
178+ if state == nil {
179+ return NewTabList()
180+ }
181+
182+ tabs := NewTabList()
183+
184+ for i, tabSnap := range state.Tabs {
185+ tab := tabFromSnapshot(&tabSnap, idGen)
186+ if tab == nil {
187+ continue
188+ }
189+ tabs.Tabs = append(tabs.Tabs, tab)
190+ tab.Position = len(tabs.Tabs) - 1
191+
192+ if i == state.ActiveTabIndex {
193+ tabs.ActiveTabID = tab.ID
194+ }
195+ }
196+
197+ // Ensure we have an active tab
198+ if tabs.ActiveTabID == "" && len(tabs.Tabs) > 0 {
199+ tabs.ActiveTabID = tabs.Tabs[0].ID
200+ }
201+
202+ return tabs
203+}
204+
205+func tabFromSnapshot(snap *TabSnapshot, idGen IDGenerator) *Tab {
206+ if snap == nil {
207+ return nil
208+ }
209+
210+ ws := workspaceFromSnapshot(&snap.Workspace, idGen)
211+ if ws == nil {
212+ return nil
213+ }
214+
215+ return &Tab{
216+ ID: TabID(idGen()),
217+ Name: snap.Name,
218+ Workspace: ws,
219+ Position: snap.Position,
220+ IsPinned: snap.IsPinned,
221+ CreatedAt: time.Now(),
222+ }
223+}
224+
225+func workspaceFromSnapshot(snap *WorkspaceSnapshot, idGen IDGenerator) *Workspace {
226+ if snap == nil {
227+ return nil
228+ }
229+
230+ root := paneNodeFromSnapshot(snap.Root, nil, idGen)
231+ if root == nil {
232+ return nil
233+ }
234+
235+ // Find the active pane ID in the restored tree
236+ // We need to map the snapshot's ActivePaneID to the new pane ID
237+ var activePaneID PaneID
238+ root.Walk(func(node *PaneNode) bool {
239+ if node.Pane != nil {
240+ // Use the first pane as active if we can't find the original
241+ if activePaneID == "" {
242+ activePaneID = node.Pane.ID
243+ }
244+ }
245+ return true
246+ })
247+
248+ return &Workspace{
249+ ID: WorkspaceID(idGen()),
250+ Root: root,
251+ ActivePaneID: activePaneID,
252+ CreatedAt: time.Now(),
253+ }
254+}
255+
256+func paneNodeFromSnapshot(snap *PaneNodeSnapshot, parent *PaneNode, idGen IDGenerator) *PaneNode {
257+ if snap == nil {
258+ return nil
259+ }
260+
261+ node := &PaneNode{
262+ ID: idGen(),
263+ Parent: parent,
264+ SplitDir: snap.SplitDir,
265+ SplitRatio: snap.SplitRatio,
266+ IsStacked: snap.IsStacked,
267+ ActiveStackIndex: snap.ActiveStackIndex,
268+ }
269+
270+ // Restore pane if this is a leaf node
271+ if snap.Pane != nil {
272+ node.Pane = paneFromSnapshot(snap.Pane, idGen)
273+ }
274+
275+ // Restore children recursively
276+ if len(snap.Children) > 0 {
277+ node.Children = make([]*PaneNode, 0, len(snap.Children))
278+ for _, childSnap := range snap.Children {
279+ child := paneNodeFromSnapshot(childSnap, node, idGen)
280+ if child != nil {
281+ node.Children = append(node.Children, child)
282+ }
283+ }
284+ }
285+
286+ return node
287+}
288+
289+func paneFromSnapshot(snap *PaneSnapshot, idGen IDGenerator) *Pane {
290+ if snap == nil {
291+ return nil
292+ }
293+
294+ return &Pane{
295+ ID: PaneID(idGen()),
296+ URI: snap.URI,
297+ Title: snap.Title,
298+ ZoomFactor: snap.ZoomFactor,
299+ WindowType: WindowMain,
300+ CreatedAt: time.Now(),
301+ }
302+}
A internal/domain/entity/session_state_test.go +430 -0
@@ -0,0 +1,430 @@
1+package entity_test
2+
3+import (
4+ "sync/atomic"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// mockIDGenerator creates a simple ID generator for testing.
14+func mockIDGenerator() entity.IDGenerator {
15+ var counter uint64
16+ return func() string {
17+ id := atomic.AddUint64(&counter, 1)
18+ return string(rune('a'-1+id)) + "_new"
19+ }
20+}
21+
22+func TestTabListFromSnapshot_EmptyState(t *testing.T) {
23+ idGen := mockIDGenerator()
24+
25+ // Nil state
26+ tabs := entity.TabListFromSnapshot(nil, idGen)
27+ require.NotNil(t, tabs)
28+ assert.Empty(t, tabs.Tabs)
29+
30+ // Empty state
31+ state := &entity.SessionState{
32+ Version: 1,
33+ Tabs: []entity.TabSnapshot{},
34+ }
35+ tabs = entity.TabListFromSnapshot(state, idGen)
36+ require.NotNil(t, tabs)
37+ assert.Empty(t, tabs.Tabs)
38+}
39+
40+func TestTabListFromSnapshot_SingleTab(t *testing.T) {
41+ idGen := mockIDGenerator()
42+
43+ state := &entity.SessionState{
44+ Version: 1,
45+ SessionID: "test_session",
46+ ActiveTabIndex: 0,
47+ Tabs: []entity.TabSnapshot{
48+ {
49+ ID: "old_tab_1",
50+ Name: "Test Tab",
51+ Position: 0,
52+ IsPinned: true,
53+ Workspace: entity.WorkspaceSnapshot{
54+ ID: "old_ws_1",
55+ ActivePaneID: "old_pane_1",
56+ Root: &entity.PaneNodeSnapshot{
57+ ID: "old_node_1",
58+ Pane: &entity.PaneSnapshot{
59+ ID: "old_pane_1",
60+ URI: "https://example.com",
61+ Title: "Example",
62+ ZoomFactor: 1.25,
63+ },
64+ },
65+ },
66+ },
67+ },
68+ SavedAt: time.Now(),
69+ }
70+
71+ tabs := entity.TabListFromSnapshot(state, idGen)
72+
73+ require.NotNil(t, tabs)
74+ require.Len(t, tabs.Tabs, 1)
75+
76+ tab := tabs.Tabs[0]
77+ assert.NotEqual(t, entity.TabID("old_tab_1"), tab.ID, "should generate new ID")
78+ assert.Equal(t, "Test Tab", tab.Name)
79+ assert.True(t, tab.IsPinned)
80+
81+ require.NotNil(t, tab.Workspace)
82+ require.NotNil(t, tab.Workspace.Root)
83+ require.NotNil(t, tab.Workspace.Root.Pane)
84+
85+ pane := tab.Workspace.Root.Pane
86+ assert.NotEqual(t, entity.PaneID("old_pane_1"), pane.ID, "should generate new pane ID")
87+ assert.Equal(t, "https://example.com", pane.URI)
88+ assert.Equal(t, "Example", pane.Title)
89+ assert.InDelta(t, 1.25, pane.ZoomFactor, 0.001)
90+
91+ // Active tab should be set
92+ assert.Equal(t, tab.ID, tabs.ActiveTabID)
93+}
94+
95+func TestTabListFromSnapshot_MultipleTabs(t *testing.T) {
96+ idGen := mockIDGenerator()
97+
98+ state := &entity.SessionState{
99+ Version: 1,
100+ ActiveTabIndex: 1, // Second tab is active
101+ Tabs: []entity.TabSnapshot{
102+ {
103+ ID: "tab_1",
104+ Name: "First",
105+ Workspace: entity.WorkspaceSnapshot{
106+ Root: &entity.PaneNodeSnapshot{
107+ Pane: &entity.PaneSnapshot{
108+ ID: "pane_1",
109+ URI: "https://first.com",
110+ },
111+ },
112+ },
113+ },
114+ {
115+ ID: "tab_2",
116+ Name: "Second",
117+ Workspace: entity.WorkspaceSnapshot{
118+ Root: &entity.PaneNodeSnapshot{
119+ Pane: &entity.PaneSnapshot{
120+ ID: "pane_2",
121+ URI: "https://second.com",
122+ },
123+ },
124+ },
125+ },
126+ },
127+ }
128+
129+ tabs := entity.TabListFromSnapshot(state, idGen)
130+
131+ require.Len(t, tabs.Tabs, 2)
132+ assert.Equal(t, "First", tabs.Tabs[0].Name)
133+ assert.Equal(t, "Second", tabs.Tabs[1].Name)
134+
135+ // Active tab should be the second one
136+ assert.Equal(t, tabs.Tabs[1].ID, tabs.ActiveTabID)
137+}
138+
139+func TestTabListFromSnapshot_SplitPanes(t *testing.T) {
140+ idGen := mockIDGenerator()
141+
142+ // Horizontal split with two panes
143+ state := &entity.SessionState{
144+ Version: 1,
145+ Tabs: []entity.TabSnapshot{
146+ {
147+ ID: "tab_1",
148+ Name: "Split Tab",
149+ Workspace: entity.WorkspaceSnapshot{
150+ Root: &entity.PaneNodeSnapshot{
151+ ID: "container",
152+ SplitDir: entity.SplitHorizontal,
153+ SplitRatio: 0.5,
154+ Children: []*entity.PaneNodeSnapshot{
155+ {
156+ ID: "left_node",
157+ Pane: &entity.PaneSnapshot{
158+ ID: "left_pane",
159+ URI: "https://left.com",
160+ },
161+ },
162+ {
163+ ID: "right_node",
164+ Pane: &entity.PaneSnapshot{
165+ ID: "right_pane",
166+ URI: "https://right.com",
167+ },
168+ },
169+ },
170+ },
171+ },
172+ },
173+ },
174+ }
175+
176+ tabs := entity.TabListFromSnapshot(state, idGen)
177+
178+ require.Len(t, tabs.Tabs, 1)
179+ ws := tabs.Tabs[0].Workspace
180+ require.NotNil(t, ws.Root)
181+
182+ // Root should be a container with children
183+ assert.Nil(t, ws.Root.Pane)
184+ assert.Equal(t, entity.SplitHorizontal, ws.Root.SplitDir)
185+ assert.InDelta(t, 0.5, ws.Root.SplitRatio, 0.001)
186+ require.Len(t, ws.Root.Children, 2)
187+
188+ // Check children
189+ leftPane := ws.Root.Children[0].Pane
190+ rightPane := ws.Root.Children[1].Pane
191+ require.NotNil(t, leftPane)
192+ require.NotNil(t, rightPane)
193+ assert.Equal(t, "https://left.com", leftPane.URI)
194+ assert.Equal(t, "https://right.com", rightPane.URI)
195+}
196+
197+func TestTabListFromSnapshot_StackedPanes(t *testing.T) {
198+ idGen := mockIDGenerator()
199+
200+ state := &entity.SessionState{
201+ Version: 1,
202+ Tabs: []entity.TabSnapshot{
203+ {
204+ ID: "tab_1",
205+ Workspace: entity.WorkspaceSnapshot{
206+ Root: &entity.PaneNodeSnapshot{
207+ ID: "stack",
208+ IsStacked: true,
209+ ActiveStackIndex: 1,
210+ Children: []*entity.PaneNodeSnapshot{
211+ {Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://a.com"}},
212+ {Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://b.com"}},
213+ {Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://c.com"}},
214+ },
215+ },
216+ },
217+ },
218+ },
219+ }
220+
221+ tabs := entity.TabListFromSnapshot(state, idGen)
222+
223+ ws := tabs.Tabs[0].Workspace
224+ require.NotNil(t, ws.Root)
225+ assert.True(t, ws.Root.IsStacked)
226+ assert.Equal(t, 1, ws.Root.ActiveStackIndex)
227+ require.Len(t, ws.Root.Children, 3)
228+}
229+
230+func TestTabList_ReplaceFrom(t *testing.T) {
231+ // Create original list
232+ original := entity.NewTabList()
233+ pane1 := entity.NewPane("pane1")
234+ pane1.URI = "https://old.com"
235+ tab1 := entity.NewTab("tab1", "ws1", pane1)
236+ original.Add(tab1)
237+ original.SetActive("tab1")
238+
239+ // Create new list
240+ newList := entity.NewTabList()
241+ pane2 := entity.NewPane("pane2")
242+ pane2.URI = "https://new1.com"
243+ pane3 := entity.NewPane("pane3")
244+ pane3.URI = "https://new2.com"
245+ tab2 := entity.NewTab("tab2", "ws2", pane2)
246+ tab3 := entity.NewTab("tab3", "ws3", pane3)
247+ newList.Add(tab2)
248+ newList.Add(tab3)
249+ newList.SetActive("tab3")
250+
251+ // Keep reference to original
252+ ref := original
253+
254+ // Replace contents
255+ original.ReplaceFrom(newList)
256+
257+ // Verify the reference still points to the modified list
258+ assert.Same(t, original, ref)
259+ assert.Len(t, ref.Tabs, 2)
260+ assert.Equal(t, entity.TabID("tab3"), ref.ActiveTabID)
261+ assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI)
262+}
263+
264+func TestTabList_ReplaceFrom_Nil(t *testing.T) {
265+ original := entity.NewTabList()
266+ pane := entity.NewPane("pane1")
267+ tab := entity.NewTab("tab1", "ws1", pane)
268+ original.Add(tab)
269+
270+ original.ReplaceFrom(nil)
271+
272+ assert.Empty(t, original.Tabs)
273+ assert.Empty(t, original.ActiveTabID)
274+}
275+
276+func TestSnapshotRoundTrip(t *testing.T) {
277+ // Create a complex tab structure
278+ idGen := mockIDGenerator()
279+
280+ pane1 := entity.NewPane("p1")
281+ pane1.URI = "https://google.com"
282+ pane1.Title = "Google"
283+ pane1.ZoomFactor = 1.5
284+
285+ pane2 := entity.NewPane("p2")
286+ pane2.URI = "https://github.com"
287+ pane2.Title = "GitHub"
288+
289+ tab1 := entity.NewTab("t1", "ws1", pane1)
290+ tab1.Name = "Search"
291+ tab1.IsPinned = true
292+
293+ tab2 := entity.NewTab("t2", "ws2", pane2)
294+ tab2.Name = "Code"
295+
296+ original := entity.NewTabList()
297+ original.Add(tab1)
298+ original.Add(tab2)
299+ original.SetActive("t2")
300+
301+ // Snapshot
302+ sessionID := entity.SessionID("test_session")
303+ state := entity.SnapshotFromTabList(sessionID, original)
304+
305+ require.NotNil(t, state)
306+ assert.Equal(t, sessionID, state.SessionID)
307+ assert.Len(t, state.Tabs, 2)
308+ assert.Equal(t, 1, state.ActiveTabIndex) // Second tab active
309+
310+ // Restore
311+ restored := entity.TabListFromSnapshot(state, idGen)
312+
313+ require.NotNil(t, restored)
314+ require.Len(t, restored.Tabs, 2)
315+
316+ // Check first tab
317+ restoredTab1 := restored.Tabs[0]
318+ assert.Equal(t, "Search", restoredTab1.Name)
319+ assert.True(t, restoredTab1.IsPinned)
320+ require.NotNil(t, restoredTab1.Workspace.Root.Pane)
321+ assert.Equal(t, "https://google.com", restoredTab1.Workspace.Root.Pane.URI)
322+ assert.Equal(t, "Google", restoredTab1.Workspace.Root.Pane.Title)
323+ assert.InDelta(t, 1.5, restoredTab1.Workspace.Root.Pane.ZoomFactor, 0.001)
324+
325+ // Check second tab
326+ restoredTab2 := restored.Tabs[1]
327+ assert.Equal(t, "Code", restoredTab2.Name)
328+ assert.False(t, restoredTab2.IsPinned)
329+ assert.Equal(t, "https://github.com", restoredTab2.Workspace.Root.Pane.URI)
330+
331+ // Active tab should be second
332+ assert.Equal(t, restoredTab2.ID, restored.ActiveTabID)
333+}
334+
335+func TestFindPaneAcrossTabs(t *testing.T) {
336+ // Create tabs with panes
337+ pane1 := entity.NewPane("pane_in_tab1")
338+ pane1.URI = "https://tab1.com"
339+ tab1 := entity.NewTab("t1", "ws1", pane1)
340+
341+ pane2 := entity.NewPane("pane_in_tab2")
342+ pane2.URI = "https://tab2.com"
343+ tab2 := entity.NewTab("t2", "ws2", pane2)
344+
345+ tabs := entity.NewTabList()
346+ tabs.Add(tab1)
347+ tabs.Add(tab2)
348+
349+ // Helper function to find pane across all tabs (mirrors App.updatePaneURIInAllTabs logic)
350+ findPaneInTabs := func(tabs *entity.TabList, paneID entity.PaneID) *entity.Pane {
351+ for _, tab := range tabs.Tabs {
352+ if tab.Workspace == nil {
353+ continue
354+ }
355+ paneNode := tab.Workspace.FindPane(paneID)
356+ if paneNode != nil && paneNode.Pane != nil {
357+ return paneNode.Pane
358+ }
359+ }
360+ return nil
361+ }
362+
363+ // Find pane in first tab
364+ found := findPaneInTabs(tabs, "pane_in_tab1")
365+ require.NotNil(t, found)
366+ assert.Equal(t, "https://tab1.com", found.URI)
367+
368+ // Find pane in second tab
369+ found = findPaneInTabs(tabs, "pane_in_tab2")
370+ require.NotNil(t, found)
371+ assert.Equal(t, "https://tab2.com", found.URI)
372+
373+ // Update URI and verify
374+ found.URI = "https://updated.com"
375+ foundAgain := findPaneInTabs(tabs, "pane_in_tab2")
376+ assert.Equal(t, "https://updated.com", foundAgain.URI)
377+
378+ // Non-existent pane
379+ notFound := findPaneInTabs(tabs, "non_existent")
380+ assert.Nil(t, notFound)
381+}
382+
383+func TestFindPaneInNestedStructure(t *testing.T) {
384+ // Create a tab with split panes
385+ pane1 := entity.NewPane("left_pane")
386+ pane1.URI = "https://left.com"
387+
388+ pane2 := entity.NewPane("right_pane")
389+ pane2.URI = "https://right.com"
390+
391+ // Build a split structure manually
392+ tab := entity.NewTab("t1", "ws1", pane1)
393+
394+ // Add second pane as sibling (simulating a horizontal split)
395+ rightNode := &entity.PaneNode{
396+ ID: "right_node",
397+ Pane: pane2,
398+ }
399+
400+ // Create container node
401+ containerNode := &entity.PaneNode{
402+ ID: "container",
403+ SplitDir: entity.SplitHorizontal,
404+ Children: []*entity.PaneNode{
405+ tab.Workspace.Root, // left pane
406+ rightNode, // right pane
407+ },
408+ }
409+ tab.Workspace.Root = containerNode
410+
411+ tabs := entity.NewTabList()
412+ tabs.Add(tab)
413+
414+ // Find left pane
415+ leftNode := tab.Workspace.FindPane("left_pane")
416+ require.NotNil(t, leftNode)
417+ assert.Equal(t, "https://left.com", leftNode.Pane.URI)
418+
419+ // Find right pane
420+ rightNodeFound := tab.Workspace.FindPane("right_pane")
421+ require.NotNil(t, rightNodeFound)
422+ assert.Equal(t, "https://right.com", rightNodeFound.Pane.URI)
423+
424+ // Update URI on nested pane
425+ rightNodeFound.Pane.URI = "https://right-updated.com"
426+
427+ // Verify update persisted
428+ rightNodeAgain := tab.Workspace.FindPane("right_pane")
429+ assert.Equal(t, "https://right-updated.com", rightNodeAgain.Pane.URI)
430+}
M internal/domain/entity/tab.go +14 -0
@@ -155,3 +155,17 @@ func (tl *TabList) Move(id TabID, newPos int) bool {
155 }155 }
156 return true156 return true
157 }157 }
158+
159+// ReplaceFrom replaces this TabList's contents with those from another TabList.
160+// This modifies in-place so existing references to this TabList remain valid.
161+func (tl *TabList) ReplaceFrom(other *TabList) {
162+ if other == nil {
163+ tl.Tabs = make([]*Tab, 0)
164+ tl.ActiveTabID = ""
165+ tl.PreviousActiveTabID = ""
166+ return
167+ }
168+ tl.Tabs = other.Tabs
169+ tl.ActiveTabID = other.ActiveTabID
170+ tl.PreviousActiveTabID = other.PreviousActiveTabID
171+}
M internal/domain/repository/mocks/mocks.go +388 -0
@@ -2746,6 +2746,63 @@ func (_m *MockSessionRepository) EXPECT() *MockSessionRepository_Expecter {
2746 return &MockSessionRepository_Expecter{mock: &_m.Mock}2746 return &MockSessionRepository_Expecter{mock: &_m.Mock}
2747 }2747 }
2748 2748
2749+// Delete provides a mock function for the type MockSessionRepository
2750+func (_mock *MockSessionRepository) Delete(ctx context.Context, id entity.SessionID) error {
2751+ ret := _mock.Called(ctx, id)
2752+
2753+ if len(ret) == 0 {
2754+ panic("no return value specified for Delete")
2755+ }
2756+
2757+ var r0 error
2758+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) error); ok {
2759+ r0 = returnFunc(ctx, id)
2760+ } else {
2761+ r0 = ret.Error(0)
2762+ }
2763+ return r0
2764+}
2765+
2766+// MockSessionRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
2767+type MockSessionRepository_Delete_Call struct {
2768+ *mock.Call
2769+}
2770+
2771+// Delete is a helper method to define mock.On call
2772+// - ctx context.Context
2773+// - id entity.SessionID
2774+func (_e *MockSessionRepository_Expecter) Delete(ctx interface{}, id interface{}) *MockSessionRepository_Delete_Call {
2775+ return &MockSessionRepository_Delete_Call{Call: _e.mock.On("Delete", ctx, id)}
2776+}
2777+
2778+func (_c *MockSessionRepository_Delete_Call) Run(run func(ctx context.Context, id entity.SessionID)) *MockSessionRepository_Delete_Call {
2779+ _c.Call.Run(func(args mock.Arguments) {
2780+ var arg0 context.Context
2781+ if args[0] != nil {
2782+ arg0 = args[0].(context.Context)
2783+ }
2784+ var arg1 entity.SessionID
2785+ if args[1] != nil {
2786+ arg1 = args[1].(entity.SessionID)
2787+ }
2788+ run(
2789+ arg0,
2790+ arg1,
2791+ )
2792+ })
2793+ return _c
2794+}
2795+
2796+func (_c *MockSessionRepository_Delete_Call) Return(err error) *MockSessionRepository_Delete_Call {
2797+ _c.Call.Return(err)
2798+ return _c
2799+}
2800+
2801+func (_c *MockSessionRepository_Delete_Call) RunAndReturn(run func(ctx context.Context, id entity.SessionID) error) *MockSessionRepository_Delete_Call {
2802+ _c.Call.Return(run)
2803+ return _c
2804+}
2805+
2749 // FindByID provides a mock function for the type MockSessionRepository2806 // FindByID provides a mock function for the type MockSessionRepository
2750 func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {2807 func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {
2751 ret := _mock.Called(ctx, id)2808 ret := _mock.Called(ctx, id)
@@ -3064,6 +3121,337 @@ func (_c *MockSessionRepository_Save_Call) RunAndReturn(run func(ctx context.Con
3064 return _c3121 return _c
3065 }3122 }
3066 3123
3124+// NewMockSessionStateRepository creates a new instance of MockSessionStateRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
3125+// The first argument is typically a *testing.T value.
3126+func NewMockSessionStateRepository(t interface {
3127+ mock.TestingT
3128+ Cleanup(func())
3129+}) *MockSessionStateRepository {
3130+ mock := &MockSessionStateRepository{}
3131+ mock.Mock.Test(t)
3132+
3133+ t.Cleanup(func() { mock.AssertExpectations(t) })
3134+
3135+ return mock
3136+}
3137+
3138+// MockSessionStateRepository is an autogenerated mock type for the SessionStateRepository type
3139+type MockSessionStateRepository struct {
3140+ mock.Mock
3141+}
3142+
3143+type MockSessionStateRepository_Expecter struct {
3144+ mock *mock.Mock
3145+}
3146+
3147+func (_m *MockSessionStateRepository) EXPECT() *MockSessionStateRepository_Expecter {
3148+ return &MockSessionStateRepository_Expecter{mock: &_m.Mock}
3149+}
3150+
3151+// DeleteSnapshot provides a mock function for the type MockSessionStateRepository
3152+func (_mock *MockSessionStateRepository) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error {
3153+ ret := _mock.Called(ctx, sessionID)
3154+
3155+ if len(ret) == 0 {
3156+ panic("no return value specified for DeleteSnapshot")
3157+ }
3158+
3159+ var r0 error
3160+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) error); ok {
3161+ r0 = returnFunc(ctx, sessionID)
3162+ } else {
3163+ r0 = ret.Error(0)
3164+ }
3165+ return r0
3166+}
3167+
3168+// MockSessionStateRepository_DeleteSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSnapshot'
3169+type MockSessionStateRepository_DeleteSnapshot_Call struct {
3170+ *mock.Call
3171+}
3172+
3173+// DeleteSnapshot is a helper method to define mock.On call
3174+// - ctx context.Context
3175+// - sessionID entity.SessionID
3176+func (_e *MockSessionStateRepository_Expecter) DeleteSnapshot(ctx interface{}, sessionID interface{}) *MockSessionStateRepository_DeleteSnapshot_Call {
3177+ return &MockSessionStateRepository_DeleteSnapshot_Call{Call: _e.mock.On("DeleteSnapshot", ctx, sessionID)}
3178+}
3179+
3180+func (_c *MockSessionStateRepository_DeleteSnapshot_Call) Run(run func(ctx context.Context, sessionID entity.SessionID)) *MockSessionStateRepository_DeleteSnapshot_Call {
3181+ _c.Call.Run(func(args mock.Arguments) {
3182+ var arg0 context.Context
3183+ if args[0] != nil {
3184+ arg0 = args[0].(context.Context)
3185+ }
3186+ var arg1 entity.SessionID
3187+ if args[1] != nil {
3188+ arg1 = args[1].(entity.SessionID)
3189+ }
3190+ run(
3191+ arg0,
3192+ arg1,
3193+ )
3194+ })
3195+ return _c
3196+}
3197+
3198+func (_c *MockSessionStateRepository_DeleteSnapshot_Call) Return(err error) *MockSessionStateRepository_DeleteSnapshot_Call {
3199+ _c.Call.Return(err)
3200+ return _c
3201+}
3202+
3203+func (_c *MockSessionStateRepository_DeleteSnapshot_Call) RunAndReturn(run func(ctx context.Context, sessionID entity.SessionID) error) *MockSessionStateRepository_DeleteSnapshot_Call {
3204+ _c.Call.Return(run)
3205+ return _c
3206+}
3207+
3208+// GetAllSnapshots provides a mock function for the type MockSessionStateRepository
3209+func (_mock *MockSessionStateRepository) GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error) {
3210+ ret := _mock.Called(ctx)
3211+
3212+ if len(ret) == 0 {
3213+ panic("no return value specified for GetAllSnapshots")
3214+ }
3215+
3216+ var r0 []*entity.SessionState
3217+ var r1 error
3218+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*entity.SessionState, error)); ok {
3219+ return returnFunc(ctx)
3220+ }
3221+ if returnFunc, ok := ret.Get(0).(func(context.Context) []*entity.SessionState); ok {
3222+ r0 = returnFunc(ctx)
3223+ } else {
3224+ if ret.Get(0) != nil {
3225+ r0 = ret.Get(0).([]*entity.SessionState)
3226+ }
3227+ }
3228+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
3229+ r1 = returnFunc(ctx)
3230+ } else {
3231+ r1 = ret.Error(1)
3232+ }
3233+ return r0, r1
3234+}
3235+
3236+// MockSessionStateRepository_GetAllSnapshots_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllSnapshots'
3237+type MockSessionStateRepository_GetAllSnapshots_Call struct {
3238+ *mock.Call
3239+}
3240+
3241+// GetAllSnapshots is a helper method to define mock.On call
3242+// - ctx context.Context
3243+func (_e *MockSessionStateRepository_Expecter) GetAllSnapshots(ctx interface{}) *MockSessionStateRepository_GetAllSnapshots_Call {
3244+ return &MockSessionStateRepository_GetAllSnapshots_Call{Call: _e.mock.On("GetAllSnapshots", ctx)}
3245+}
3246+
3247+func (_c *MockSessionStateRepository_GetAllSnapshots_Call) Run(run func(ctx context.Context)) *MockSessionStateRepository_GetAllSnapshots_Call {
3248+ _c.Call.Run(func(args mock.Arguments) {
3249+ var arg0 context.Context
3250+ if args[0] != nil {
3251+ arg0 = args[0].(context.Context)
3252+ }
3253+ run(
3254+ arg0,
3255+ )
3256+ })
3257+ return _c
3258+}
3259+
3260+func (_c *MockSessionStateRepository_GetAllSnapshots_Call) Return(sessionStates []*entity.SessionState, err error) *MockSessionStateRepository_GetAllSnapshots_Call {
3261+ _c.Call.Return(sessionStates, err)
3262+ return _c
3263+}
3264+
3265+func (_c *MockSessionStateRepository_GetAllSnapshots_Call) RunAndReturn(run func(ctx context.Context) ([]*entity.SessionState, error)) *MockSessionStateRepository_GetAllSnapshots_Call {
3266+ _c.Call.Return(run)
3267+ return _c
3268+}
3269+
3270+// GetSnapshot provides a mock function for the type MockSessionStateRepository
3271+func (_mock *MockSessionStateRepository) GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error) {
3272+ ret := _mock.Called(ctx, sessionID)
3273+
3274+ if len(ret) == 0 {
3275+ panic("no return value specified for GetSnapshot")
3276+ }
3277+
3278+ var r0 *entity.SessionState
3279+ var r1 error
3280+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) (*entity.SessionState, error)); ok {
3281+ return returnFunc(ctx, sessionID)
3282+ }
3283+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.SessionID) *entity.SessionState); ok {
3284+ r0 = returnFunc(ctx, sessionID)
3285+ } else {
3286+ if ret.Get(0) != nil {
3287+ r0 = ret.Get(0).(*entity.SessionState)
3288+ }
3289+ }
3290+ if returnFunc, ok := ret.Get(1).(func(context.Context, entity.SessionID) error); ok {
3291+ r1 = returnFunc(ctx, sessionID)
3292+ } else {
3293+ r1 = ret.Error(1)
3294+ }
3295+ return r0, r1
3296+}
3297+
3298+// MockSessionStateRepository_GetSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSnapshot'
3299+type MockSessionStateRepository_GetSnapshot_Call struct {
3300+ *mock.Call
3301+}
3302+
3303+// GetSnapshot is a helper method to define mock.On call
3304+// - ctx context.Context
3305+// - sessionID entity.SessionID
3306+func (_e *MockSessionStateRepository_Expecter) GetSnapshot(ctx interface{}, sessionID interface{}) *MockSessionStateRepository_GetSnapshot_Call {
3307+ return &MockSessionStateRepository_GetSnapshot_Call{Call: _e.mock.On("GetSnapshot", ctx, sessionID)}
3308+}
3309+
3310+func (_c *MockSessionStateRepository_GetSnapshot_Call) Run(run func(ctx context.Context, sessionID entity.SessionID)) *MockSessionStateRepository_GetSnapshot_Call {
3311+ _c.Call.Run(func(args mock.Arguments) {
3312+ var arg0 context.Context
3313+ if args[0] != nil {
3314+ arg0 = args[0].(context.Context)
3315+ }
3316+ var arg1 entity.SessionID
3317+ if args[1] != nil {
3318+ arg1 = args[1].(entity.SessionID)
3319+ }
3320+ run(
3321+ arg0,
3322+ arg1,
3323+ )
3324+ })
3325+ return _c
3326+}
3327+
3328+func (_c *MockSessionStateRepository_GetSnapshot_Call) Return(sessionState *entity.SessionState, err error) *MockSessionStateRepository_GetSnapshot_Call {
3329+ _c.Call.Return(sessionState, err)
3330+ return _c
3331+}
3332+
3333+func (_c *MockSessionStateRepository_GetSnapshot_Call) RunAndReturn(run func(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error)) *MockSessionStateRepository_GetSnapshot_Call {
3334+ _c.Call.Return(run)
3335+ return _c
3336+}
3337+
3338+// GetTotalSnapshotsSize provides a mock function for the type MockSessionStateRepository
3339+func (_mock *MockSessionStateRepository) GetTotalSnapshotsSize(ctx context.Context) (int64, error) {
3340+ ret := _mock.Called(ctx)
3341+
3342+ if len(ret) == 0 {
3343+ panic("no return value specified for GetTotalSnapshotsSize")
3344+ }
3345+
3346+ var r0 int64
3347+ var r1 error
3348+ if returnFunc, ok := ret.Get(0).(func(context.Context) (int64, error)); ok {
3349+ return returnFunc(ctx)
3350+ }
3351+ if returnFunc, ok := ret.Get(0).(func(context.Context) int64); ok {
3352+ r0 = returnFunc(ctx)
3353+ } else {
3354+ r0 = ret.Get(0).(int64)
3355+ }
3356+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
3357+ r1 = returnFunc(ctx)
3358+ } else {
3359+ r1 = ret.Error(1)
3360+ }
3361+ return r0, r1
3362+}
3363+
3364+// MockSessionStateRepository_GetTotalSnapshotsSize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTotalSnapshotsSize'
3365+type MockSessionStateRepository_GetTotalSnapshotsSize_Call struct {
3366+ *mock.Call
3367+}
3368+
3369+// GetTotalSnapshotsSize is a helper method to define mock.On call
3370+// - ctx context.Context
3371+func (_e *MockSessionStateRepository_Expecter) GetTotalSnapshotsSize(ctx interface{}) *MockSessionStateRepository_GetTotalSnapshotsSize_Call {
3372+ return &MockSessionStateRepository_GetTotalSnapshotsSize_Call{Call: _e.mock.On("GetTotalSnapshotsSize", ctx)}
3373+}
3374+
3375+func (_c *MockSessionStateRepository_GetTotalSnapshotsSize_Call) Run(run func(ctx context.Context)) *MockSessionStateRepository_GetTotalSnapshotsSize_Call {
3376+ _c.Call.Run(func(args mock.Arguments) {
3377+ var arg0 context.Context
3378+ if args[0] != nil {
3379+ arg0 = args[0].(context.Context)
3380+ }
3381+ run(
3382+ arg0,
3383+ )
3384+ })
3385+ return _c
3386+}
3387+
3388+func (_c *MockSessionStateRepository_GetTotalSnapshotsSize_Call) Return(n int64, err error) *MockSessionStateRepository_GetTotalSnapshotsSize_Call {
3389+ _c.Call.Return(n, err)
3390+ return _c
3391+}
3392+
3393+func (_c *MockSessionStateRepository_GetTotalSnapshotsSize_Call) RunAndReturn(run func(ctx context.Context) (int64, error)) *MockSessionStateRepository_GetTotalSnapshotsSize_Call {
3394+ _c.Call.Return(run)
3395+ return _c
3396+}
3397+
3398+// SaveSnapshot provides a mock function for the type MockSessionStateRepository
3399+func (_mock *MockSessionStateRepository) SaveSnapshot(ctx context.Context, state *entity.SessionState) error {
3400+ ret := _mock.Called(ctx, state)
3401+
3402+ if len(ret) == 0 {
3403+ panic("no return value specified for SaveSnapshot")
3404+ }
3405+
3406+ var r0 error
3407+ if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.SessionState) error); ok {
3408+ r0 = returnFunc(ctx, state)
3409+ } else {
3410+ r0 = ret.Error(0)
3411+ }
3412+ return r0
3413+}
3414+
3415+// MockSessionStateRepository_SaveSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveSnapshot'
3416+type MockSessionStateRepository_SaveSnapshot_Call struct {
3417+ *mock.Call
3418+}
3419+
3420+// SaveSnapshot is a helper method to define mock.On call
3421+// - ctx context.Context
3422+// - state *entity.SessionState
3423+func (_e *MockSessionStateRepository_Expecter) SaveSnapshot(ctx interface{}, state interface{}) *MockSessionStateRepository_SaveSnapshot_Call {
3424+ return &MockSessionStateRepository_SaveSnapshot_Call{Call: _e.mock.On("SaveSnapshot", ctx, state)}
3425+}
3426+
3427+func (_c *MockSessionStateRepository_SaveSnapshot_Call) Run(run func(ctx context.Context, state *entity.SessionState)) *MockSessionStateRepository_SaveSnapshot_Call {
3428+ _c.Call.Run(func(args mock.Arguments) {
3429+ var arg0 context.Context
3430+ if args[0] != nil {
3431+ arg0 = args[0].(context.Context)
3432+ }
3433+ var arg1 *entity.SessionState
3434+ if args[1] != nil {
3435+ arg1 = args[1].(*entity.SessionState)
3436+ }
3437+ run(
3438+ arg0,
3439+ arg1,
3440+ )
3441+ })
3442+ return _c
3443+}
3444+
3445+func (_c *MockSessionStateRepository_SaveSnapshot_Call) Return(err error) *MockSessionStateRepository_SaveSnapshot_Call {
3446+ _c.Call.Return(err)
3447+ return _c
3448+}
3449+
3450+func (_c *MockSessionStateRepository_SaveSnapshot_Call) RunAndReturn(run func(ctx context.Context, state *entity.SessionState) error) *MockSessionStateRepository_SaveSnapshot_Call {
3451+ _c.Call.Return(run)
3452+ return _c
3453+}
3454+
3067 // NewMockZoomRepository creates a new instance of MockZoomRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.3455 // NewMockZoomRepository creates a new instance of MockZoomRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
3068 // The first argument is typically a *testing.T value.3456 // The first argument is typically a *testing.T value.
3069 func NewMockZoomRepository(t interface {3457 func NewMockZoomRepository(t interface {
M internal/domain/repository/session.go +3 -0
@@ -16,4 +16,7 @@ type SessionRepository interface {
16 GetActive(ctx context.Context) (*entity.Session, error)16 GetActive(ctx context.Context) (*entity.Session, error)
17 GetRecent(ctx context.Context, limit int) ([]*entity.Session, error)17 GetRecent(ctx context.Context, limit int) ([]*entity.Session, error)
18 MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error18 MarkEnded(ctx context.Context, id entity.SessionID, endedAt time.Time) error
19+
20+ // Delete removes a session record.
21+ Delete(ctx context.Context, id entity.SessionID) error
19 }22 }
A internal/domain/repository/session_state.go +26 -0
@@ -0,0 +1,26 @@
1+package repository
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// SessionStateRepository persists session state snapshots.
10+type SessionStateRepository interface {
11+ // SaveSnapshot saves or updates a session state snapshot.
12+ SaveSnapshot(ctx context.Context, state *entity.SessionState) error
13+
14+ // GetSnapshot returns the latest snapshot for a session.
15+ GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error)
16+
17+ // DeleteSnapshot removes a session's snapshot.
18+ DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error
19+
20+ // GetAllSnapshots returns all snapshots with summary info.
21+ // Used by ListSessionsUseCase.
22+ GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error)
23+
24+ // GetTotalSnapshotsSize returns the total size of all session snapshots in bytes.
25+ GetTotalSnapshotsSize(ctx context.Context) (int64, error)
26+}
M internal/infrastructure/config/defaults.go +35 -7
@@ -26,6 +26,13 @@ const (
26 defaultTabBarPosition = "bottom"26 defaultTabBarPosition = "bottom"
27 defaultPopupPlacement = "right"27 defaultPopupPlacement = "right"
28 28
29+ // Session defaults
30+ defaultSessionActivationShortcut = "ctrl+o"
31+ defaultSessionTimeoutMilliseconds = 3000
32+ defaultSnapshotIntervalMs = 5000
33+ defaultMaxExitedSessions = 10
34+ defaultMaxListedSessions = 50
35+
29 // Workspace styling defaults36 // Workspace styling defaults
30 // Active pane border (overlay)37 // Active pane border (overlay)
31 defaultBorderWidth = 138 defaultBorderWidth = 1
@@ -39,6 +46,10 @@ const (
39 defaultTabModeBorderWidth = 446 defaultTabModeBorderWidth = 4
40 defaultTabModeBorderColor = "#FFA500" // Orange for tab mode indicator47 defaultTabModeBorderColor = "#FFA500" // Orange for tab mode indicator
41 48
49+ // Session mode border (Ctrl+O - overlay)
50+ defaultSessionModeBorderWidth = 4
51+ defaultSessionModeBorderColor = "#9B59B6" // Purple for session mode indicator
52+
42 // Other styling53 // Other styling
43 defaultTransitionDuration = 12054 defaultTransitionDuration = 120
44 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)55 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)
@@ -180,13 +191,15 @@ func DefaultConfig() *Config {
180 OAuthAutoClose: true, // Auto-close OAuth popups on success191 OAuthAutoClose: true, // Auto-close OAuth popups on success
181 },192 },
182 Styling: WorkspaceStylingConfig{193 Styling: WorkspaceStylingConfig{
183- BorderWidth: defaultBorderWidth,194+ BorderWidth: defaultBorderWidth,
184- BorderColor: defaultBorderColor,195+ BorderColor: defaultBorderColor,
185- PaneModeBorderWidth: defaultPaneModeBorderWidth,196+ PaneModeBorderWidth: defaultPaneModeBorderWidth,
186- PaneModeBorderColor: defaultPaneModeBorderColor,197+ PaneModeBorderColor: defaultPaneModeBorderColor,
187- TabModeBorderWidth: defaultTabModeBorderWidth,198+ TabModeBorderWidth: defaultTabModeBorderWidth,
188- TabModeBorderColor: defaultTabModeBorderColor,199+ TabModeBorderColor: defaultTabModeBorderColor,
189- TransitionDuration: defaultTransitionDuration,200+ SessionModeBorderWidth: defaultSessionModeBorderWidth,
201+ SessionModeBorderColor: defaultSessionModeBorderColor,
202+ TransitionDuration: defaultTransitionDuration,
190 },203 },
191 },204 },
192 ContentFiltering: ContentFilteringConfig{205 ContentFiltering: ContentFilteringConfig{
@@ -196,6 +209,21 @@ func DefaultConfig() *Config {
196 Omnibox: OmniboxConfig{209 Omnibox: OmniboxConfig{
197 InitialBehavior: defaultOmniboxInitialBehavior,210 InitialBehavior: defaultOmniboxInitialBehavior,
198 },211 },
212+ Session: SessionConfig{
213+ AutoRestore: false,
214+ SnapshotIntervalMs: defaultSnapshotIntervalMs,
215+ MaxExitedSessions: defaultMaxExitedSessions,
216+ MaxListedSessions: defaultMaxListedSessions,
217+ SessionMode: SessionModeConfig{
218+ ActivationShortcut: defaultSessionActivationShortcut,
219+ TimeoutMilliseconds: defaultSessionTimeoutMilliseconds,
220+ Actions: map[string][]string{
221+ "session-manager": {"s", "w"},
222+ "confirm": {"enter"},
223+ "cancel": {"escape"},
224+ },
225+ },
226+ },
199 Media: MediaConfig{227 Media: MediaConfig{
200 HardwareDecodingMode: HardwareDecodingAuto, // auto allows sw fallback228 HardwareDecodingMode: HardwareDecodingAuto, // auto allows sw fallback
201 PreferAV1: false, // Don't force codec preference, let site choose229 PreferAV1: false, // Don't force codec preference, let site choose
M internal/infrastructure/config/loader.go +13 -0
@@ -314,6 +314,7 @@ func (m *Manager) setDefaults() {
314 m.setOmniboxDefaults(defaults)314 m.setOmniboxDefaults(defaults)
315 m.setMediaDefaults(defaults)315 m.setMediaDefaults(defaults)
316 m.setRuntimeDefaults(defaults)316 m.setRuntimeDefaults(defaults)
317+ m.setSessionDefaults(defaults)
317 }318 }
318 319
319 func (m *Manager) setHistoryDefaults(defaults *Config) {320 func (m *Manager) setHistoryDefaults(defaults *Config) {
@@ -402,6 +403,8 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
402 m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)403 m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)
403 m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth)404 m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth)
404 m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)405 m.viper.SetDefault("workspace.styling.tab_mode_border_color", defaults.Workspace.Styling.TabModeBorderColor)
406+ m.viper.SetDefault("workspace.styling.session_mode_border_width", defaults.Workspace.Styling.SessionModeBorderWidth)
407+ m.viper.SetDefault("workspace.styling.session_mode_border_color", defaults.Workspace.Styling.SessionModeBorderColor)
405 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)408 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
406 }409 }
407 410
@@ -429,6 +432,16 @@ func (m *Manager) setRuntimeDefaults(defaults *Config) {
429 m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix)432 m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix)
430 }433 }
431 434
435+func (m *Manager) setSessionDefaults(defaults *Config) {
436+ m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore)
437+ m.viper.SetDefault("session.snapshot_interval_ms", defaults.Session.SnapshotIntervalMs)
438+ m.viper.SetDefault("session.max_exited_sessions", defaults.Session.MaxExitedSessions)
439+ m.viper.SetDefault("session.max_listed_sessions", defaults.Session.MaxListedSessions)
440+ m.viper.SetDefault("session.session_mode.activation_shortcut", defaults.Session.SessionMode.ActivationShortcut)
441+ m.viper.SetDefault("session.session_mode.timeout_ms", defaults.Session.SessionMode.TimeoutMilliseconds)
442+ m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions)
443+}
444+
432 // New returns a new default configuration instance.445 // New returns a new default configuration instance.
433 // This is a convenience function for getting default config without the full manager.446 // This is a convenience function for getting default config without the full manager.
434 func New() *Config {447 func New() *Config {
M internal/infrastructure/config/schema.go +49 -0
@@ -17,6 +17,8 @@ type Config struct {
17 DefaultUIScale float64 `mapstructure:"default_ui_scale" yaml:"default_ui_scale" toml:"default_ui_scale"`17 DefaultUIScale float64 `mapstructure:"default_ui_scale" yaml:"default_ui_scale" toml:"default_ui_scale"`
18 // Workspace defines workspace, pane, and tab handling behavior.18 // Workspace defines workspace, pane, and tab handling behavior.
19 Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace" toml:"workspace"`19 Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace" toml:"workspace"`
20+ // Session controls session persistence and restoration.
21+ Session SessionConfig `mapstructure:"session" yaml:"session" toml:"session"`
20 // ContentFiltering controls ad blocking and content filtering22 // ContentFiltering controls ad blocking and content filtering
21 ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"`23 ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"`
22 // Omnibox controls the omnibox behavior (initial history display)24 // Omnibox controls the omnibox behavior (initial history display)
@@ -401,6 +403,53 @@ type WorkspaceStylingConfig struct {
401 // Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor403 // Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor
402 TabModeBorderColor string `mapstructure:"tab_mode_border_color" yaml:"tab_mode_border_color" toml:"tab_mode_border_color" json:"tab_mode_border_color"` //nolint:lll // struct tags must stay on one line404 TabModeBorderColor string `mapstructure:"tab_mode_border_color" yaml:"tab_mode_border_color" toml:"tab_mode_border_color" json:"tab_mode_border_color"` //nolint:lll // struct tags must stay on one line
403 405
406+ // SessionModeBorderWidth in pixels for session mode indicator border (Ctrl+O overlay)
407+ SessionModeBorderWidth int `mapstructure:"session_mode_border_width" yaml:"session_mode_border_width" toml:"session_mode_border_width" json:"session_mode_border_width"` //nolint:lll // struct tags must stay on one line
408+ // SessionModeBorderColor for the session mode indicator border (CSS color value or theme variable)
409+ // Defaults to "#9B59B6" (purple) if not set - MUST be different from other mode colors
410+ SessionModeBorderColor string `mapstructure:"session_mode_border_color" yaml:"session_mode_border_color" toml:"session_mode_border_color" json:"session_mode_border_color"` //nolint:lll // struct tags must stay on one line
411+
404 // TransitionDuration in milliseconds for border animations412 // TransitionDuration in milliseconds for border animations
405 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line413 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
406 }414 }
415+
416+// SessionConfig holds session persistence settings.
417+type SessionConfig struct {
418+ // AutoRestore automatically restores the last session on startup.
419+ // Default: false
420+ AutoRestore bool `mapstructure:"auto_restore" yaml:"auto_restore" toml:"auto_restore" json:"auto_restore"`
421+
422+ // SnapshotIntervalMs is the minimum interval between snapshots in milliseconds.
423+ // Default: 5000
424+ SnapshotIntervalMs int `mapstructure:"snapshot_interval_ms" yaml:"snapshot_interval_ms" toml:"snapshot_interval_ms" json:"snapshot_interval_ms"` //nolint:lll // struct tags must stay on one line
425+
426+ // MaxExitedSessions is the maximum number of exited sessions to keep.
427+ // Default: 10
428+ MaxExitedSessions int `mapstructure:"max_exited_sessions" yaml:"max_exited_sessions" toml:"max_exited_sessions" json:"max_exited_sessions"` //nolint:lll // struct tags must stay on one line
429+
430+ // MaxListedSessions is the maximum number of sessions to list in CLI/TUI.
431+ // Default: 50
432+ MaxListedSessions int `mapstructure:"max_listed_sessions" yaml:"max_listed_sessions" toml:"max_listed_sessions" json:"max_listed_sessions"` //nolint:lll // struct tags must stay on one line
433+
434+ // SessionMode defines modal behavior for session management (Ctrl+O).
435+ SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"`
436+}
437+
438+// SessionModeConfig defines modal behavior for session management.
439+type SessionModeConfig struct {
440+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
441+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
442+ Actions map[string][]string `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
443+}
444+
445+// GetKeyBindings returns an inverted map for O(1) key→action lookup.
446+// This is built from the action→keys structure in the config.
447+func (s *SessionModeConfig) GetKeyBindings() map[string]string {
448+ keyToAction := make(map[string]string)
449+ for action, keys := range s.Actions {
450+ for _, key := range keys {
451+ keyToAction[key] = action
452+ }
453+ }
454+ return keyToAction
455+}
M internal/infrastructure/desktop/adapter.go +54 -0
@@ -10,6 +10,7 @@ import (
10 "strings"10 "strings"
11 11
12 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
14 )15 )
15 16
@@ -348,3 +349,56 @@ func (a *Adapter) UnsetAsDefaultBrowser(ctx context.Context) error {
348 log.Warn().Msg("dumber was default browser - user should set a new default")349 log.Warn().Msg("dumber was default browser - user should set a new default")
349 return nil350 return nil
350 }351 }
352+
353+// SessionSpawner implements port.SessionSpawner by launching a new dumber process.
354+type SessionSpawner struct {
355+ ctx context.Context
356+}
357+
358+// NewSessionSpawner creates a new session spawner.
359+func NewSessionSpawner(ctx context.Context) *SessionSpawner {
360+ return &SessionSpawner{ctx: ctx}
361+}
362+
363+// RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.
364+const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"
365+
366+// SpawnWithSession starts a new dumber instance to restore a session.
367+func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
368+ log := logging.FromContext(s.ctx)
369+
370+ execPath, err := getExecutablePath()
371+ if err != nil {
372+ return fmt.Errorf("get executable path: %w", err)
373+ }
374+
375+ // Start dumber browse with session ID in environment
376+ cmd := exec.Command(execPath, "browse")
377+
378+ // Inherit environment and add restore session ID
379+ cmd.Env = append(os.Environ(), RestoreSessionEnvVar+"="+string(sessionID))
380+
381+ // Detach from current process group so the new process survives
382+ cmd.Stdin = nil
383+ cmd.Stdout = nil
384+ cmd.Stderr = nil
385+
386+ if err := cmd.Start(); err != nil {
387+ return fmt.Errorf("spawn session: %w", err)
388+ }
389+
390+ // Capture PID before releasing
391+ pid := cmd.Process.Pid
392+
393+ // Release the process so it continues running after we close
394+ if err := cmd.Process.Release(); err != nil {
395+ log.Warn().Err(err).Msg("failed to release spawned process (non-fatal)")
396+ }
397+
398+ log.Info().
399+ Str("session_id", string(sessionID)).
400+ Int("pid", pid).
401+ Msg("spawned dumber with session restoration")
402+
403+ return nil
404+}
A internal/infrastructure/persistence/sqlite/migrations/006_session_states.sql +14 -0
@@ -0,0 +1,14 @@
1+-- +goose Up
2+CREATE TABLE session_states (
3+ session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
4+ state_json TEXT NOT NULL,
5+ version INTEGER NOT NULL DEFAULT 1,
6+ tab_count INTEGER NOT NULL DEFAULT 0,
7+ pane_count INTEGER NOT NULL DEFAULT 0,
8+ updated_at TIMESTAMP NOT NULL
9+);
10+
11+CREATE INDEX idx_session_states_updated_at ON session_states(updated_at DESC);
12+
13+-- +goose Down
14+DROP TABLE session_states;
A internal/infrastructure/persistence/sqlite/queries/session_states.sql +36 -0
@@ -0,0 +1,36 @@
1+-- name: UpsertSessionState :exec
2+INSERT INTO session_states (session_id, state_json, version, tab_count, pane_count, updated_at)
3+VALUES (?, ?, ?, ?, ?, ?)
4+ON CONFLICT(session_id) DO UPDATE SET
5+ state_json = excluded.state_json,
6+ version = excluded.version,
7+ tab_count = excluded.tab_count,
8+ pane_count = excluded.pane_count,
9+ updated_at = excluded.updated_at;
10+
11+-- name: GetSessionState :one
12+SELECT session_id, state_json, version, tab_count, pane_count, updated_at
13+FROM session_states
14+WHERE session_id = ?;
15+
16+-- name: GetAllSessionStates :many
17+SELECT session_id, state_json, version, tab_count, pane_count, updated_at
18+FROM session_states
19+ORDER BY updated_at DESC;
20+
21+-- name: DeleteSessionState :exec
22+DELETE FROM session_states WHERE session_id = ?;
23+
24+-- name: GetSessionsWithState :many
25+SELECT
26+ s.id, s.type, s.started_at, s.ended_at,
27+ ss.state_json, ss.tab_count, ss.pane_count, ss.updated_at as state_updated_at
28+FROM sessions s
29+LEFT JOIN session_states ss ON s.id = ss.session_id
30+WHERE s.type = 'browser'
31+ORDER BY s.started_at DESC
32+LIMIT ?;
33+
34+-- name: GetTotalSessionStatesSize :one
35+SELECT COALESCE(SUM(LENGTH(state_json)), 0) as total_size
36+FROM session_states;
M internal/infrastructure/persistence/sqlite/queries/sessions.sql +3 -0
@@ -20,3 +20,6 @@ LIMIT ?;
20 UPDATE sessions20 UPDATE sessions
21 SET ended_at = ?21 SET ended_at = ?
22 WHERE id = ?;22 WHERE id = ?;
23+
24+-- name: DeleteSession :exec
25+DELETE FROM sessions WHERE id = ?;
M internal/infrastructure/persistence/sqlite/session_repo.go +6 -0
@@ -87,6 +87,12 @@ func (r *sessionRepo) MarkEnded(ctx context.Context, id entity.SessionID, endedA
87 })87 })
88 }88 }
89 89
90+func (r *sessionRepo) Delete(ctx context.Context, id entity.SessionID) error {
91+ log := logging.FromContext(ctx)
92+ log.Debug().Str("session_id", string(id)).Msg("deleting session")
93+ return r.queries.DeleteSession(ctx, string(id))
94+}
95+
90 func sessionFromRow(row sqlc.Session) *entity.Session {96 func sessionFromRow(row sqlc.Session) *entity.Session {
91 var endedAt *time.Time97 var endedAt *time.Time
92 if row.EndedAt.Valid {98 if row.EndedAt.Valid {
A internal/infrastructure/persistence/sqlite/session_state_repo.go +118 -0
@@ -0,0 +1,118 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "encoding/json"
7+ "errors"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/domain/repository"
11+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+type sessionStateRepo struct {
16+ queries *sqlc.Queries
17+}
18+
19+// NewSessionStateRepository creates a new session state repository.
20+func NewSessionStateRepository(db *sql.DB) repository.SessionStateRepository {
21+ return &sessionStateRepo{queries: sqlc.New(db)}
22+}
23+
24+// SaveSnapshot saves or updates a session state snapshot.
25+func (r *sessionStateRepo) SaveSnapshot(ctx context.Context, state *entity.SessionState) error {
26+ log := logging.FromContext(ctx)
27+ if state == nil {
28+ return errors.New("session state cannot be nil")
29+ }
30+
31+ stateJSON, err := json.Marshal(state)
32+ if err != nil {
33+ log.Error().Err(err).Msg("failed to marshal session state")
34+ return err
35+ }
36+
37+ log.Debug().
38+ Str("session_id", string(state.SessionID)).
39+ Int("tab_count", len(state.Tabs)).
40+ Int("pane_count", state.CountPanes()).
41+ Msg("saving session state snapshot")
42+
43+ return r.queries.UpsertSessionState(ctx, sqlc.UpsertSessionStateParams{
44+ SessionID: string(state.SessionID),
45+ StateJson: string(stateJSON),
46+ Version: int64(state.Version),
47+ TabCount: int64(len(state.Tabs)),
48+ PaneCount: int64(state.CountPanes()),
49+ UpdatedAt: state.SavedAt,
50+ })
51+}
52+
53+// GetSnapshot returns the latest snapshot for a session.
54+func (r *sessionStateRepo) GetSnapshot(ctx context.Context, sessionID entity.SessionID) (*entity.SessionState, error) {
55+ row, err := r.queries.GetSessionState(ctx, string(sessionID))
56+ if err != nil {
57+ if errors.Is(err, sql.ErrNoRows) {
58+ return nil, nil
59+ }
60+ return nil, err
61+ }
62+
63+ var state entity.SessionState
64+ if err := json.Unmarshal([]byte(row.StateJson), &state); err != nil {
65+ logging.FromContext(ctx).Error().Err(err).
66+ Str("session_id", string(sessionID)).
67+ Msg("failed to unmarshal session state")
68+ return nil, err
69+ }
70+
71+ return &state, nil
72+}
73+
74+// DeleteSnapshot removes a session's snapshot.
75+func (r *sessionStateRepo) DeleteSnapshot(ctx context.Context, sessionID entity.SessionID) error {
76+ log := logging.FromContext(ctx)
77+ log.Debug().Str("session_id", string(sessionID)).Msg("deleting session state snapshot")
78+ return r.queries.DeleteSessionState(ctx, string(sessionID))
79+}
80+
81+// GetAllSnapshots returns all snapshots with summary info.
82+func (r *sessionStateRepo) GetAllSnapshots(ctx context.Context) ([]*entity.SessionState, error) {
83+ rows, err := r.queries.GetAllSessionStates(ctx)
84+ if err != nil {
85+ return nil, err
86+ }
87+
88+ states := make([]*entity.SessionState, 0, len(rows))
89+ for _, row := range rows {
90+ var state entity.SessionState
91+ if err := json.Unmarshal([]byte(row.StateJson), &state); err != nil {
92+ logging.FromContext(ctx).Warn().Err(err).
93+ Str("session_id", row.SessionID).
94+ Msg("skipping corrupted session state")
95+ continue
96+ }
97+ states = append(states, &state)
98+ }
99+
100+ return states, nil
101+}
102+
103+// GetTotalSnapshotsSize returns the total size of all session snapshots in bytes.
104+func (r *sessionStateRepo) GetTotalSnapshotsSize(ctx context.Context) (int64, error) {
105+ result, err := r.queries.GetTotalSessionStatesSize(ctx)
106+ if err != nil {
107+ return 0, err
108+ }
109+ // SQLite returns int64 for SUM, but COALESCE wraps it as interface{}
110+ switch v := result.(type) {
111+ case int64:
112+ return v, nil
113+ case float64:
114+ return int64(v), nil
115+ default:
116+ return 0, nil
117+ }
118+}
M internal/infrastructure/persistence/sqlite/sqlc/models.go +9 -0
@@ -70,6 +70,15 @@ type Session struct {
70 EndedAt sql.NullTime `json:"ended_at"`70 EndedAt sql.NullTime `json:"ended_at"`
71 }71 }
72 72
73+type SessionState struct {
74+ SessionID string `json:"session_id"`
75+ StateJson string `json:"state_json"`
76+ Version int64 `json:"version"`
77+ TabCount int64 `json:"tab_count"`
78+ PaneCount int64 `json:"pane_count"`
79+ UpdatedAt time.Time `json:"updated_at"`
80+}
81+
73 type ZoomLevel struct {82 type ZoomLevel struct {
74 Domain string `json:"domain"`83 Domain string `json:"domain"`
75 ZoomFactor float64 `json:"zoom_factor"`84 ZoomFactor float64 `json:"zoom_factor"`
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +7 -0
@@ -21,11 +21,14 @@ type Querier interface {
21 DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error21 DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error
22 DeleteHistoryByID(ctx context.Context, id int64) error22 DeleteHistoryByID(ctx context.Context, id int64) error
23 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error23 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
24+ DeleteSession(ctx context.Context, id string) error
25+ DeleteSessionState(ctx context.Context, sessionID string) error
24 DeleteTag(ctx context.Context, id int64) error26 DeleteTag(ctx context.Context, id int64) error
25 DeleteZoomLevel(ctx context.Context, domain string) error27 DeleteZoomLevel(ctx context.Context, domain string) error
26 GetActiveBrowserSession(ctx context.Context) (Session, error)28 GetActiveBrowserSession(ctx context.Context) (Session, error)
27 GetAllFavorites(ctx context.Context) ([]Favorite, error)29 GetAllFavorites(ctx context.Context) ([]Favorite, error)
28 GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)30 GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)
31+ GetAllSessionStates(ctx context.Context) ([]SessionState, error)
29 GetAllTags(ctx context.Context) ([]FavoriteTag, error)32 GetAllTags(ctx context.Context) ([]FavoriteTag, error)
30 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)33 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)
31 GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error)34 GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error)
@@ -44,9 +47,12 @@ type Querier interface {
44 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)47 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
45 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)48 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
46 GetSessionByID(ctx context.Context, id string) (Session, error)49 GetSessionByID(ctx context.Context, id string) (Session, error)
50+ GetSessionState(ctx context.Context, sessionID string) (SessionState, error)
51+ GetSessionsWithState(ctx context.Context, limit int64) ([]GetSessionsWithStateRow, error)
47 GetTagByID(ctx context.Context, id int64) (FavoriteTag, error)52 GetTagByID(ctx context.Context, id int64) (FavoriteTag, error)
48 GetTagByName(ctx context.Context, name string) (FavoriteTag, error)53 GetTagByName(ctx context.Context, name string) (FavoriteTag, error)
49 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)54 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)
55+ GetTotalSessionStatesSize(ctx context.Context) (interface{}, error)
50 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)56 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)
51 IncrementVisitCount(ctx context.Context, url string) error57 IncrementVisitCount(ctx context.Context, url string) error
52 InsertSession(ctx context.Context, arg InsertSessionParams) error58 InsertSession(ctx context.Context, arg InsertSessionParams) error
@@ -66,6 +72,7 @@ type Querier interface {
66 UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error72 UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error
67 UpdateTag(ctx context.Context, arg UpdateTagParams) error73 UpdateTag(ctx context.Context, arg UpdateTagParams) error
68 UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error74 UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error
75+ UpsertSessionState(ctx context.Context, arg UpsertSessionStateParams) error
69 }76 }
70 77
71 var _ Querier = (*Queries)(nil)78 var _ Querier = (*Queries)(nil)
A internal/infrastructure/persistence/sqlite/sqlc/session_states.sql.go +175 -0
@@ -0,0 +1,175 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: session_states.sql
5+
6+package sqlc
7+
8+import (
9+ "context"
10+ "database/sql"
11+ "time"
12+)
13+
14+const DeleteSessionState = `-- name: DeleteSessionState :exec
15+DELETE FROM session_states WHERE session_id = ?
16+`
17+
18+func (q *Queries) DeleteSessionState(ctx context.Context, sessionID string) error {
19+ _, err := q.db.ExecContext(ctx, DeleteSessionState, sessionID)
20+ return err
21+}
22+
23+const GetAllSessionStates = `-- name: GetAllSessionStates :many
24+SELECT session_id, state_json, version, tab_count, pane_count, updated_at
25+FROM session_states
26+ORDER BY updated_at DESC
27+`
28+
29+func (q *Queries) GetAllSessionStates(ctx context.Context) ([]SessionState, error) {
30+ rows, err := q.db.QueryContext(ctx, GetAllSessionStates)
31+ if err != nil {
32+ return nil, err
33+ }
34+ defer rows.Close()
35+ items := []SessionState{}
36+ for rows.Next() {
37+ var i SessionState
38+ if err := rows.Scan(
39+ &i.SessionID,
40+ &i.StateJson,
41+ &i.Version,
42+ &i.TabCount,
43+ &i.PaneCount,
44+ &i.UpdatedAt,
45+ ); err != nil {
46+ return nil, err
47+ }
48+ items = append(items, i)
49+ }
50+ if err := rows.Close(); err != nil {
51+ return nil, err
52+ }
53+ if err := rows.Err(); err != nil {
54+ return nil, err
55+ }
56+ return items, nil
57+}
58+
59+const GetSessionState = `-- name: GetSessionState :one
60+SELECT session_id, state_json, version, tab_count, pane_count, updated_at
61+FROM session_states
62+WHERE session_id = ?
63+`
64+
65+func (q *Queries) GetSessionState(ctx context.Context, sessionID string) (SessionState, error) {
66+ row := q.db.QueryRowContext(ctx, GetSessionState, sessionID)
67+ var i SessionState
68+ err := row.Scan(
69+ &i.SessionID,
70+ &i.StateJson,
71+ &i.Version,
72+ &i.TabCount,
73+ &i.PaneCount,
74+ &i.UpdatedAt,
75+ )
76+ return i, err
77+}
78+
79+const GetSessionsWithState = `-- name: GetSessionsWithState :many
80+SELECT
81+ s.id, s.type, s.started_at, s.ended_at,
82+ ss.state_json, ss.tab_count, ss.pane_count, ss.updated_at as state_updated_at
83+FROM sessions s
84+LEFT JOIN session_states ss ON s.id = ss.session_id
85+WHERE s.type = 'browser'
86+ORDER BY s.started_at DESC
87+LIMIT ?
88+`
89+
90+type GetSessionsWithStateRow struct {
91+ ID string `json:"id"`
92+ Type string `json:"type"`
93+ StartedAt time.Time `json:"started_at"`
94+ EndedAt sql.NullTime `json:"ended_at"`
95+ StateJson sql.NullString `json:"state_json"`
96+ TabCount sql.NullInt64 `json:"tab_count"`
97+ PaneCount sql.NullInt64 `json:"pane_count"`
98+ StateUpdatedAt sql.NullTime `json:"state_updated_at"`
99+}
100+
101+func (q *Queries) GetSessionsWithState(ctx context.Context, limit int64) ([]GetSessionsWithStateRow, error) {
102+ rows, err := q.db.QueryContext(ctx, GetSessionsWithState, limit)
103+ if err != nil {
104+ return nil, err
105+ }
106+ defer rows.Close()
107+ items := []GetSessionsWithStateRow{}
108+ for rows.Next() {
109+ var i GetSessionsWithStateRow
110+ if err := rows.Scan(
111+ &i.ID,
112+ &i.Type,
113+ &i.StartedAt,
114+ &i.EndedAt,
115+ &i.StateJson,
116+ &i.TabCount,
117+ &i.PaneCount,
118+ &i.StateUpdatedAt,
119+ ); err != nil {
120+ return nil, err
121+ }
122+ items = append(items, i)
123+ }
124+ if err := rows.Close(); err != nil {
125+ return nil, err
126+ }
127+ if err := rows.Err(); err != nil {
128+ return nil, err
129+ }
130+ return items, nil
131+}
132+
133+const GetTotalSessionStatesSize = `-- name: GetTotalSessionStatesSize :one
134+SELECT COALESCE(SUM(LENGTH(state_json)), 0) as total_size
135+FROM session_states
136+`
137+
138+func (q *Queries) GetTotalSessionStatesSize(ctx context.Context) (interface{}, error) {
139+ row := q.db.QueryRowContext(ctx, GetTotalSessionStatesSize)
140+ var total_size interface{}
141+ err := row.Scan(&total_size)
142+ return total_size, err
143+}
144+
145+const UpsertSessionState = `-- name: UpsertSessionState :exec
146+INSERT INTO session_states (session_id, state_json, version, tab_count, pane_count, updated_at)
147+VALUES (?, ?, ?, ?, ?, ?)
148+ON CONFLICT(session_id) DO UPDATE SET
149+ state_json = excluded.state_json,
150+ version = excluded.version,
151+ tab_count = excluded.tab_count,
152+ pane_count = excluded.pane_count,
153+ updated_at = excluded.updated_at
154+`
155+
156+type UpsertSessionStateParams struct {
157+ SessionID string `json:"session_id"`
158+ StateJson string `json:"state_json"`
159+ Version int64 `json:"version"`
160+ TabCount int64 `json:"tab_count"`
161+ PaneCount int64 `json:"pane_count"`
162+ UpdatedAt time.Time `json:"updated_at"`
163+}
164+
165+func (q *Queries) UpsertSessionState(ctx context.Context, arg UpsertSessionStateParams) error {
166+ _, err := q.db.ExecContext(ctx, UpsertSessionState,
167+ arg.SessionID,
168+ arg.StateJson,
169+ arg.Version,
170+ arg.TabCount,
171+ arg.PaneCount,
172+ arg.UpdatedAt,
173+ )
174+ return err
175+}
M internal/infrastructure/persistence/sqlite/sqlc/sessions.sql.go +9 -0
@@ -11,6 +11,15 @@ import (
11 "time"11 "time"
12 )12 )
13 13
14+const DeleteSession = `-- name: DeleteSession :exec
15+DELETE FROM sessions WHERE id = ?
16+`
17+
18+func (q *Queries) DeleteSession(ctx context.Context, id string) error {
19+ _, err := q.db.ExecContext(ctx, DeleteSession, id)
20+ return err
21+}
22+
14 const GetActiveBrowserSession = `-- name: GetActiveBrowserSession :one23 const GetActiveBrowserSession = `-- name: GetActiveBrowserSession :one
15 SELECT id, type, started_at, ended_at FROM sessions24 SELECT id, type, started_at, ended_at FROM sessions
16 WHERE type = 'browser' AND ended_at IS NULL25 WHERE type = 'browser' AND ended_at IS NULL
A internal/infrastructure/snapshot/service.go +128 -0
@@ -0,0 +1,128 @@
1+package snapshot
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// Service handles debounced session state snapshots.
14+type Service struct {
15+ snapshotUC *usecase.SnapshotSessionUseCase
16+ provider port.TabListProvider
17+ interval time.Duration
18+
19+ mu sync.Mutex
20+ timer *time.Timer
21+ dirty bool
22+ ctx context.Context
23+ cancel context.CancelFunc
24+}
25+
26+// NewService creates a new snapshot service.
27+func NewService(
28+ snapshotUC *usecase.SnapshotSessionUseCase,
29+ provider port.TabListProvider,
30+ intervalMs int,
31+) *Service {
32+ if intervalMs <= 0 {
33+ intervalMs = 5000 // Default 5 seconds
34+ }
35+ return &Service{
36+ snapshotUC: snapshotUC,
37+ provider: provider,
38+ interval: time.Duration(intervalMs) * time.Millisecond,
39+ }
40+}
41+
42+// Start begins watching for dirty state.
43+func (s *Service) Start(ctx context.Context) {
44+ s.mu.Lock()
45+ defer s.mu.Unlock()
46+
47+ s.ctx, s.cancel = context.WithCancel(ctx)
48+ logging.FromContext(ctx).Debug().Dur("interval", s.interval).Msg("snapshot service started")
49+}
50+
51+// Stop stops the service and saves final state.
52+func (s *Service) Stop(ctx context.Context) error {
53+ s.mu.Lock()
54+ if s.cancel != nil {
55+ s.cancel()
56+ }
57+ if s.timer != nil {
58+ s.timer.Stop()
59+ s.timer = nil
60+ }
61+ s.mu.Unlock()
62+
63+ // Final save on shutdown
64+ return s.SaveNow(ctx)
65+}
66+
67+// MarkDirty signals that state has changed.
68+// Debounces saves to avoid excessive DB writes.
69+func (s *Service) MarkDirty() {
70+ s.mu.Lock()
71+ defer s.mu.Unlock()
72+
73+ s.dirty = true
74+
75+ // Reset or create timer
76+ if s.timer != nil {
77+ s.timer.Stop()
78+ }
79+
80+ s.timer = time.AfterFunc(s.interval, func() {
81+ s.mu.Lock()
82+ ctx := s.ctx
83+ s.mu.Unlock()
84+
85+ if ctx == nil {
86+ return
87+ }
88+
89+ if err := s.saveSnapshot(ctx); err != nil {
90+ logging.FromContext(ctx).Error().Err(err).Msg("failed to save session snapshot")
91+ }
92+ })
93+}
94+
95+// SaveNow forces immediate save (for shutdown).
96+func (s *Service) SaveNow(ctx context.Context) error {
97+ s.mu.Lock()
98+ if s.timer != nil {
99+ s.timer.Stop()
100+ s.timer = nil
101+ }
102+ dirty := s.dirty
103+ s.mu.Unlock()
104+
105+ if !dirty {
106+ return nil
107+ }
108+
109+ return s.saveSnapshot(ctx)
110+}
111+
112+func (s *Service) saveSnapshot(ctx context.Context) error {
113+ s.mu.Lock()
114+ s.dirty = false
115+ s.mu.Unlock()
116+
117+ tabList := s.provider.GetTabList()
118+ sessionID := s.provider.GetSessionID()
119+
120+ if sessionID == "" {
121+ return nil
122+ }
123+
124+ return s.snapshotUC.Execute(ctx, usecase.SnapshotInput{
125+ SessionID: sessionID,
126+ TabList: tabList,
127+ })
128+}
M internal/ui/app.go +254 -16
@@ -11,7 +11,9 @@ import (
11 "github.com/bnema/dumber/internal/application/usecase"11 "github.com/bnema/dumber/internal/application/usecase"
12 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/infrastructure/config"13 "github.com/bnema/dumber/internal/infrastructure/config"
14+ "github.com/bnema/dumber/internal/infrastructure/desktop"
14 "github.com/bnema/dumber/internal/infrastructure/filtering"15 "github.com/bnema/dumber/internal/infrastructure/filtering"
16+ "github.com/bnema/dumber/internal/infrastructure/snapshot"
15 "github.com/bnema/dumber/internal/infrastructure/webkit"17 "github.com/bnema/dumber/internal/infrastructure/webkit"
16 18
17 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"19 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
@@ -84,6 +86,10 @@ type App struct {
84 // App-level toaster for system notifications (filter status, etc.)86 // App-level toaster for system notifications (filter status, etc.)
85 appToaster *component.Toaster87 appToaster *component.Toaster
86 88
89+ // Session management
90+ sessionManager *component.SessionManager
91+ snapshotService *snapshot.Service
92+
87 // ID generator for tabs/panes93 // ID generator for tabs/panes
88 idCounter uint6494 idCounter uint64
89 idMu sync.Mutex95 idMu sync.Mutex
@@ -183,6 +189,8 @@ func (a *App) onActivate(ctx context.Context) {
183 a.initKeyboardHandler(ctx)189 a.initKeyboardHandler(ctx)
184 a.initOmniboxConfig(ctx)190 a.initOmniboxConfig(ctx)
185 a.initFindBarConfig()191 a.initFindBarConfig()
192+ a.initSessionManager(ctx)
193+ a.initSnapshotService(ctx)
186 a.createInitialTab(ctx)194 a.createInitialTab(ctx)
187 a.finalizeActivation(ctx)195 a.finalizeActivation(ctx)
188 }196 }
@@ -295,10 +303,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
295 }303 }
296 304
297 // Create keyboard handler and wire to dispatcher.305 // Create keyboard handler and wire to dispatcher.
298- a.keyboardHandler = input.NewKeyboardHandler(306+ a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)
299- ctx,
300- &a.deps.Config.Workspace,
301- )
302 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {307 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
303 return a.kbDispatcher.Dispatch(ctx, action)308 return a.kbDispatcher.Dispatch(ctx, action)
304 })309 })
@@ -306,6 +311,10 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
306 a.handleModeChange(ctx, from, to)311 a.handleModeChange(ctx, from, to)
307 })312 })
308 a.keyboardHandler.SetShouldBypassInput(func() bool {313 a.keyboardHandler.SetShouldBypassInput(func() bool {
314+ // Bypass keyboard handler when session manager is visible
315+ if a.sessionManager != nil && a.sessionManager.IsVisible() {
316+ return true
317+ }
309 wsView := a.activeWorkspaceView()318 wsView := a.activeWorkspaceView()
310 if wsView == nil {319 if wsView == nil {
311 return false320 return false
@@ -369,9 +378,145 @@ func (a *App) initFindBarConfig() {
369 }378 }
370 }379 }
371 380
381+func (a *App) initSessionManager(ctx context.Context) {
382+ log := logging.FromContext(ctx)
383+
384+ if a.deps == nil {
385+ log.Debug().Msg("deps not available, skipping session manager")
386+ return
387+ }
388+
389+ // Session manager can work without a repo - it just shows an empty list
390+ var listSessionsUC *usecase.ListSessionsUseCase
391+ var deleteSessionUC *usecase.DeleteSessionUseCase
392+ if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
393+ listSessionsUC = usecase.NewListSessionsUseCase(
394+ a.deps.SessionRepo,
395+ a.deps.SessionStateRepo,
396+ a.deps.Config.Logging.LogDir,
397+ )
398+ deleteSessionUC = usecase.NewDeleteSessionUseCase(
399+ a.deps.SessionStateRepo,
400+ a.deps.SessionRepo,
401+ a.deps.Config.Logging.LogDir,
402+ )
403+ }
404+
405+ // Create session spawner for restoration
406+ spawner := desktop.NewSessionSpawner(ctx)
407+
408+ // Create session manager component
409+ a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
410+ ListSessionsUC: listSessionsUC,
411+ DeleteSessionUC: deleteSessionUC,
412+ CurrentSession: a.deps.CurrentSessionID,
413+ UIScale: a.deps.Config.DefaultUIScale,
414+ OnClose: func() {
415+ log.Debug().Msg("session manager closed")
416+ },
417+ OnOpen: func(sessionID entity.SessionID) {
418+ log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
419+ if err := spawner.SpawnWithSession(sessionID); err != nil {
420+ log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
421+ }
422+ },
423+ OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
424+ if a.appToaster != nil {
425+ a.appToaster.Show(ctx, message, level)
426+ }
427+ },
428+ })
429+
430+ if a.sessionManager == nil {
431+ log.Warn().Msg("failed to create session manager")
432+ return
433+ }
434+
435+ // Add session manager to main window overlay
436+ if a.mainWindow != nil {
437+ widget := a.sessionManager.Widget()
438+ if widget != nil {
439+ a.mainWindow.AddOverlay(widget)
440+ }
441+ }
442+
443+ // Wire session manager action to keyboard dispatcher
444+ a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context) error {
445+ a.ToggleSessionManager(ctx)
446+ return nil
447+ })
448+
449+ log.Debug().Msg("session manager initialized")
450+}
451+
452+// ToggleSessionManager shows or hides the session manager.
453+func (a *App) ToggleSessionManager(ctx context.Context) {
454+ if a.sessionManager == nil {
455+ return
456+ }
457+ a.sessionManager.Toggle(ctx)
458+}
459+
460+func (a *App) initSnapshotService(ctx context.Context) {
461+ log := logging.FromContext(ctx)
462+
463+ if a.deps == nil || a.deps.SnapshotUC == nil {
464+ log.Debug().Msg("snapshot use case not available, skipping snapshot service")
465+ return
466+ }
467+
468+ intervalMs := 5000 // default
469+ if a.deps.Config != nil && a.deps.Config.Session.SnapshotIntervalMs > 0 {
470+ intervalMs = a.deps.Config.Session.SnapshotIntervalMs
471+ }
472+
473+ a.snapshotService = snapshot.NewService(a.deps.SnapshotUC, a, intervalMs)
474+ a.snapshotService.Start(ctx)
475+
476+ log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started")
477+}
478+
479+// GetTabList implements port.TabListProvider.
480+func (a *App) GetTabList() *entity.TabList {
481+ return a.tabs
482+}
483+
484+// GetSessionID implements port.TabListProvider.
485+func (a *App) GetSessionID() entity.SessionID {
486+ if a.deps == nil {
487+ return ""
488+ }
489+ return a.deps.CurrentSessionID
490+}
491+
492+// MarkDirty signals that session state has changed and should be saved.
493+func (a *App) MarkDirty() {
494+ if a.snapshotService != nil {
495+ a.snapshotService.MarkDirty()
496+ }
497+}
498+
372 func (a *App) createInitialTab(ctx context.Context) {499 func (a *App) createInitialTab(ctx context.Context) {
373 log := logging.FromContext(ctx)500 log := logging.FromContext(ctx)
374 501
502+ // Check if we should restore a session
503+ if a.deps != nil && a.deps.RestoreSessionID != "" {
504+ if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil {
505+ log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab")
506+ // Show error toast and fall through to create default tab
507+ if a.appToaster != nil {
508+ a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning)
509+ }
510+ } else {
511+ log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored")
512+ // Show success toast for session restoration
513+ if a.appToaster != nil {
514+ a.appToaster.Show(ctx, "Session restored", component.ToastSuccess)
515+ }
516+ return
517+ }
518+ }
519+
375 // Create an initial tab using coordinator.520 // Create an initial tab using coordinator.
376 initialURL := "dumb://home"521 initialURL := "dumb://home"
377 if a.deps != nil && a.deps.InitialURL != "" {522 if a.deps != nil && a.deps.InitialURL != "" {
@@ -382,6 +527,61 @@ func (a *App) createInitialTab(ctx context.Context) {
382 }527 }
383 }528 }
384 529
530+func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {
531+ log := logging.FromContext(ctx)
532+
533+ if a.deps == nil || a.deps.SessionStateRepo == nil {
534+ return fmt.Errorf("session state repo not available")
535+ }
536+
537+ // Load session state
538+ state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID)
539+ if err != nil {
540+ return fmt.Errorf("load session state: %w", err)
541+ }
542+ if state == nil {
543+ return fmt.Errorf("session state not found")
544+ }
545+
546+ log.Info().Int("tabs", len(state.Tabs)).Int("panes", state.CountPanes()).Msg("restoring session state")
547+
548+ // Build complete TabList from snapshot (state-first approach)
549+ // This reconstructs the full pane tree with new IDs
550+ restoredTabs := entity.TabListFromSnapshot(state, a.generateID)
551+ if restoredTabs == nil || len(restoredTabs.Tabs) == 0 {
552+ return fmt.Errorf("failed to build tab list from snapshot")
553+ }
554+
555+ // Replace tabs in-place to preserve references held by TabCoordinator
556+ a.tabs.ReplaceFrom(restoredTabs)
557+
558+ // Create UI for each restored tab and add to tab bar
559+ // Don't attach to content area yet - only the active tab should be attached
560+ tabBar := a.mainWindow.TabBar()
561+ for _, tab := range a.tabs.Tabs {
562+ a.createWorkspaceViewWithoutAttach(ctx, tab)
563+ if tabBar != nil {
564+ tabBar.AddTab(tab)
565+ }
566+ log.Debug().
567+ Str("tab_id", string(tab.ID)).
568+ Str("name", tab.Name).
569+ Int("panes", tab.Workspace.PaneCount()).
570+ Msg("restored tab with workspace")
571+ }
572+
573+ // Switch to active tab - this attaches the active workspace view to content area
574+ activeTab := a.tabs.ActiveTab()
575+ if activeTab != nil {
576+ a.switchWorkspaceView(ctx, activeTab.ID)
577+ if tabBar != nil {
578+ tabBar.SetActive(activeTab.ID)
579+ }
580+ }
581+
582+ return nil
583+}
584+
385 func (a *App) finalizeActivation(ctx context.Context) {585 func (a *App) finalizeActivation(ctx context.Context) {
386 log := logging.FromContext(ctx)586 log := logging.FromContext(ctx)
387 587
@@ -409,6 +609,13 @@ func (a *App) onShutdown(ctx context.Context) {
409 log := logging.FromContext(ctx)609 log := logging.FromContext(ctx)
410 log.Debug().Msg("GTK application shutting down")610 log.Debug().Msg("GTK application shutting down")
411 611
612+ // Save final session state before shutdown
613+ if a.snapshotService != nil {
614+ if err := a.snapshotService.Stop(ctx); err != nil {
615+ log.Warn().Err(err).Msg("failed to save final session state")
616+ }
617+ }
618+
412 // Cancel context to signal all goroutines619 // Cancel context to signal all goroutines
413 a.cancel(errors.New("application shutdown"))620 a.cancel(errors.New("application shutdown"))
414 621
@@ -475,6 +682,7 @@ func (a *App) initCoordinators(ctx context.Context) {
475 a.switchWorkspaceView(ctx, tab.ID)682 a.switchWorkspaceView(ctx, tab.ID)
476 })683 })
477 a.tabCoord.SetOnQuit(a.Quit)684 a.tabCoord.SetOnQuit(a.Quit)
685+ a.tabCoord.SetOnStateChanged(a.MarkDirty)
478 // Wire popup tab WebView attachment686 // Wire popup tab WebView attachment
479 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {687 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
480 a.attachPopupToTab(ctx, tabID, pane, wv)688 a.attachPopupToTab(ctx, tabID, pane, wv)
@@ -502,6 +710,7 @@ func (a *App) initCoordinators(ctx context.Context) {
502 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {710 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
503 return a.tabCoord.Close(ctx)711 return a.tabCoord.Close(ctx)
504 })712 })
713+ a.wsCoord.SetOnStateChanged(a.MarkDirty)
505 714
506 // Wire popup handling715 // Wire popup handling
507 a.webViewFactory = webkit.NewWebViewFactory(716 a.webViewFactory = webkit.NewWebViewFactory(
@@ -555,6 +764,13 @@ func (a *App) initCoordinators(ctx context.Context) {
555 a.navCoord.RecordHistory(ctx, paneID, url)764 a.navCoord.RecordHistory(ctx, paneID, url)
556 })765 })
557 766
767+ // Wire pane URI updates for session snapshots (searches all tabs)
768+ a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {
769+ a.updatePaneURIInAllTabs(paneID, url)
770+ // Mark dirty so snapshot captures the new URI
771+ a.MarkDirty()
772+ })
773+
558 // 5. Keyboard Dispatcher774 // 5. Keyboard Dispatcher
559 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(775 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
560 ctx,776 ctx,
@@ -640,6 +856,24 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
640 856
641 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.857 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
642 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {858 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
859+ a.createWorkspaceViewWithoutAttach(ctx, tab)
860+
861+ // Attach to content area
862+ wsView := a.workspaceViews[tab.ID]
863+ if wsView != nil && a.mainWindow != nil {
864+ widget := wsView.Widget()
865+ if widget != nil {
866+ gtkWidget := widget.GtkWidget()
867+ if gtkWidget != nil {
868+ a.mainWindow.SetContent(gtkWidget)
869+ }
870+ }
871+ }
872+}
873+
874+// createWorkspaceViewWithoutAttach creates a WorkspaceView for a tab without attaching to content area.
875+// Used during session restoration where we create all views first, then attach only the active one.
876+func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.Tab) {
643 log := logging.FromContext(ctx)877 log := logging.FromContext(ctx)
644 878
645 if a.widgetFactory == nil {879 if a.widgetFactory == nil {
@@ -676,18 +910,7 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
676 // Store in map910 // Store in map
677 a.workspaceViews[tab.ID] = wsView911 a.workspaceViews[tab.ID] = wsView
678 912
679- // Attach to content area913+ log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
680- if a.mainWindow != nil {
681- widget := wsView.Widget()
682- if widget != nil {
683- gtkWidget := widget.GtkWidget()
684- if gtkWidget != nil {
685- a.mainWindow.SetContent(gtkWidget)
686- }
687- }
688- }
689-
690- log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created and attached")
691 }914 }
692 915
693 // activeWorkspace returns the workspace of the active tab.916 // activeWorkspace returns the workspace of the active tab.
@@ -699,6 +922,21 @@ func (a *App) activeWorkspace() *entity.Workspace {
699 return activeTab.Workspace922 return activeTab.Workspace
700 }923 }
701 924
925+// updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.
926+// This is necessary because panes in inactive tabs also need URI updates for session snapshots.
927+func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {
928+ for _, tab := range a.tabs.Tabs {
929+ if tab.Workspace == nil {
930+ continue
931+ }
932+ paneNode := tab.Workspace.FindPane(paneID)
933+ if paneNode != nil && paneNode.Pane != nil {
934+ paneNode.Pane.URI = url
935+ return // Pane IDs are unique, no need to continue
936+ }
937+ }
938+}
939+
702 // activeWorkspaceView returns the workspace view for the active tab.940 // activeWorkspaceView returns the workspace view for the active tab.
703 func (a *App) activeWorkspaceView() *component.WorkspaceView {941 func (a *App) activeWorkspaceView() *component.WorkspaceView {
704 activeTab := a.tabs.ActiveTab()942 activeTab := a.tabs.ActiveTab()
A internal/ui/component/modal_sizing.go +123 -0
@@ -0,0 +1,123 @@
1+package component
2+
3+import (
4+ "github.com/bnema/dumber/internal/ui/layout"
5+ "github.com/jwijenbergh/puregotk/v4/gtk"
6+)
7+
8+// ModalSizeConfig holds configuration for modal sizing calculations.
9+type ModalSizeConfig struct {
10+ WidthPct float64 // Percentage of parent width (e.g., 0.6)
11+ MaxWidth int // Maximum width in pixels
12+ TopMarginPct float64 // Top margin as percentage of parent height
13+ FallbackWidth int // Fallback when parent not allocated
14+ FallbackHeight int // Fallback height
15+}
16+
17+// RowHeightDefaults holds default (unscaled) row heights for list-based modals.
18+// These are base pixel values that should be multiplied by UI scale.
19+type RowHeightDefaults struct {
20+ Standard int // Standard list row (e.g., session row, history row)
21+ Compact int // Compact row (e.g., tree row, sub-item)
22+ Divider int // Divider/separator row
23+}
24+
25+// ListDisplayDefaults holds display limits for list-based modals.
26+type ListDisplayDefaults struct {
27+ MaxVisibleRows int // Maximum rows visible before scrolling
28+ MaxResults int // Maximum results to fetch/display
29+}
30+
31+// Package-level defaults for row heights.
32+var DefaultRowHeights = RowHeightDefaults{
33+ Standard: 50,
34+ Compact: 28,
35+ Divider: 30,
36+}
37+
38+// OmniboxSizeDefaults provides default sizing for omnibox modal.
39+var OmniboxSizeDefaults = ModalSizeConfig{
40+ WidthPct: 0.8,
41+ MaxWidth: 800,
42+ TopMarginPct: 0.2,
43+ FallbackWidth: 800,
44+ FallbackHeight: 600,
45+}
46+
47+// OmniboxListDefaults provides display limits for omnibox modal.
48+var OmniboxListDefaults = ListDisplayDefaults{
49+ MaxVisibleRows: 10,
50+ MaxResults: 10,
51+}
52+
53+// SessionManagerSizeDefaults provides default sizing for session manager modal.
54+var SessionManagerSizeDefaults = ModalSizeConfig{
55+ WidthPct: 0.6,
56+ MaxWidth: 600,
57+ TopMarginPct: 0.15,
58+ FallbackWidth: 600,
59+ FallbackHeight: 600,
60+}
61+
62+// SessionManagerListDefaults provides display limits for session manager modal.
63+var SessionManagerListDefaults = ListDisplayDefaults{
64+ MaxVisibleRows: 6,
65+ MaxResults: 50,
66+}
67+
68+// CalculateModalDimensions computes width and top margin based on parent overlay.
69+// Returns calculated width and top margin in pixels.
70+func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) {
71+ var parentWidth, parentHeight int
72+
73+ if parent != nil {
74+ parentWidth = parent.GetAllocatedWidth()
75+ parentHeight = parent.GetAllocatedHeight()
76+ }
77+
78+ if parentWidth <= 0 {
79+ parentWidth = cfg.FallbackWidth
80+ }
81+ if parentHeight <= 0 {
82+ parentHeight = cfg.FallbackHeight
83+ }
84+
85+ width = int(float64(parentWidth) * cfg.WidthPct)
86+ if width > cfg.MaxWidth {
87+ width = cfg.MaxWidth
88+ }
89+
90+ marginTop = int(float64(parentHeight) * cfg.TopMarginPct)
91+ return width, marginTop
92+}
93+
94+// SetScrolledWindowHeight safely sets min/max content height on a ScrolledWindow.
95+// Resets min to -1 first to avoid GTK assertion (min <= max) when shrinking.
96+func SetScrolledWindowHeight(sw *gtk.ScrolledWindow, height int) {
97+ if sw == nil {
98+ return
99+ }
100+ sw.SetMinContentHeight(-1)
101+ sw.SetMaxContentHeight(height)
102+ sw.SetMinContentHeight(height)
103+}
104+
105+// MeasureWidgetHeight returns the natural height of a widget for a given width.
106+// Returns 0 if widget is nil or measurement fails.
107+func MeasureWidgetHeight(widget *gtk.Widget, forWidth int) int {
108+ if widget == nil {
109+ return 0
110+ }
111+ var minH, natH int
112+ widget.Measure(gtk.OrientationVerticalValue, forWidth, &minH, &natH, nil, nil)
113+ return natH
114+}
115+
116+// ScaleValue scales a base pixel value by UI scale factor.
117+// Returns the base value if scale is <= 0.
118+func ScaleValue(base int, uiScale float64) int {
119+ if uiScale <= 0 {
120+ uiScale = 1.0
121+ }
122+ return int(float64(base) * uiScale)
123+}
A internal/ui/component/modal_sizing_test.go +145 -0
@@ -0,0 +1,145 @@
1+package component
2+
3+import (
4+ "testing"
5+
6+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestCalculateModalDimensions_NilParent(t *testing.T) {
11+ cfg := ModalSizeConfig{
12+ WidthPct: 0.6,
13+ MaxWidth: 600,
14+ TopMarginPct: 0.15,
15+ FallbackWidth: 800,
16+ FallbackHeight: 600,
17+ }
18+
19+ width, marginTop := CalculateModalDimensions(nil, cfg)
20+
21+ // Should use fallback values
22+ expectedWidth := int(float64(cfg.FallbackWidth) * cfg.WidthPct)
23+ expectedMargin := int(float64(cfg.FallbackHeight) * cfg.TopMarginPct)
24+
25+ assert.Equal(t, expectedWidth, width)
26+ assert.Equal(t, expectedMargin, marginTop)
27+}
28+
29+func TestCalculateModalDimensions_ZeroAllocatedSize(t *testing.T) {
30+ cfg := ModalSizeConfig{
31+ WidthPct: 0.5,
32+ MaxWidth: 500,
33+ TopMarginPct: 0.2,
34+ FallbackWidth: 1000,
35+ FallbackHeight: 800,
36+ }
37+
38+ parent := layoutmocks.NewMockOverlayWidget(t)
39+ parent.EXPECT().GetAllocatedWidth().Return(0)
40+ parent.EXPECT().GetAllocatedHeight().Return(0)
41+
42+ width, marginTop := CalculateModalDimensions(parent, cfg)
43+
44+ // Should use fallback values when allocated size is 0
45+ expectedWidth := int(float64(cfg.FallbackWidth) * cfg.WidthPct)
46+ expectedMargin := int(float64(cfg.FallbackHeight) * cfg.TopMarginPct)
47+
48+ assert.Equal(t, expectedWidth, width)
49+ assert.Equal(t, expectedMargin, marginTop)
50+}
51+
52+func TestCalculateModalDimensions_WidthCapped(t *testing.T) {
53+ cfg := ModalSizeConfig{
54+ WidthPct: 0.8,
55+ MaxWidth: 600,
56+ TopMarginPct: 0.1,
57+ FallbackWidth: 800,
58+ FallbackHeight: 600,
59+ }
60+
61+ parent := layoutmocks.NewMockOverlayWidget(t)
62+ parent.EXPECT().GetAllocatedWidth().Return(1920) // Large screen
63+ parent.EXPECT().GetAllocatedHeight().Return(1080)
64+
65+ width, marginTop := CalculateModalDimensions(parent, cfg)
66+
67+ // 0.8 * 1920 = 1536, but capped at MaxWidth 600
68+ assert.Equal(t, cfg.MaxWidth, width)
69+ assert.Equal(t, int(float64(1080)*cfg.TopMarginPct), marginTop)
70+}
71+
72+func TestCalculateModalDimensions_WidthPercentage(t *testing.T) {
73+ cfg := ModalSizeConfig{
74+ WidthPct: 0.6,
75+ MaxWidth: 1000, // High cap, won't be hit
76+ TopMarginPct: 0.15,
77+ FallbackWidth: 800,
78+ FallbackHeight: 600,
79+ }
80+
81+ parent := layoutmocks.NewMockOverlayWidget(t)
82+ parent.EXPECT().GetAllocatedWidth().Return(800)
83+ parent.EXPECT().GetAllocatedHeight().Return(600)
84+
85+ width, marginTop := CalculateModalDimensions(parent, cfg)
86+
87+ assert.Equal(t, 480, width)
88+ assert.Equal(t, 90, marginTop)
89+}
90+
91+func TestScaleValue_DefaultScale(t *testing.T) {
92+ // When scale is 0 or negative, should default to 1.0
93+ assert.Equal(t, 50, ScaleValue(50, 0))
94+ assert.Equal(t, 50, ScaleValue(50, -1.0))
95+}
96+
97+func TestScaleValue_NormalScale(t *testing.T) {
98+ assert.Equal(t, 50, ScaleValue(50, 1.0))
99+}
100+
101+func TestScaleValue_Scaling(t *testing.T) {
102+ // 1.5x scale
103+ assert.Equal(t, 75, ScaleValue(50, 1.5))
104+ // 2x scale
105+ assert.Equal(t, 100, ScaleValue(50, 2.0))
106+ // 1.2x scale (common UI scale)
107+ assert.Equal(t, 60, ScaleValue(50, 1.2))
108+}
109+
110+func TestMeasureWidgetHeight_NilWidget(t *testing.T) {
111+ height := MeasureWidgetHeight(nil, 100)
112+ assert.Equal(t, 0, height)
113+}
114+
115+func TestDefaultRowHeights_Values(t *testing.T) {
116+ // Ensure defaults are sensible
117+ assert.Positive(t, DefaultRowHeights.Standard)
118+ assert.Positive(t, DefaultRowHeights.Compact)
119+ assert.Positive(t, DefaultRowHeights.Divider)
120+ // Standard should be larger than compact
121+ assert.Greater(t, DefaultRowHeights.Standard, DefaultRowHeights.Compact)
122+}
123+
124+func TestOmniboxSizeDefaults_Values(t *testing.T) {
125+ assert.Greater(t, OmniboxSizeDefaults.WidthPct, 0.0)
126+ assert.LessOrEqual(t, OmniboxSizeDefaults.WidthPct, 1.0)
127+ assert.Positive(t, OmniboxSizeDefaults.MaxWidth)
128+ assert.Greater(t, OmniboxSizeDefaults.TopMarginPct, 0.0)
129+ assert.LessOrEqual(t, OmniboxSizeDefaults.TopMarginPct, 1.0)
130+}
131+
132+func TestSessionManagerSizeDefaults_Values(t *testing.T) {
133+ assert.Greater(t, SessionManagerSizeDefaults.WidthPct, 0.0)
134+ assert.LessOrEqual(t, SessionManagerSizeDefaults.WidthPct, 1.0)
135+ assert.Positive(t, SessionManagerSizeDefaults.MaxWidth)
136+ assert.Greater(t, SessionManagerSizeDefaults.TopMarginPct, 0.0)
137+ assert.LessOrEqual(t, SessionManagerSizeDefaults.TopMarginPct, 1.0)
138+}
139+
140+func TestListDisplayDefaults_Values(t *testing.T) {
141+ assert.Positive(t, OmniboxListDefaults.MaxVisibleRows)
142+ assert.Positive(t, OmniboxListDefaults.MaxResults)
143+ assert.Positive(t, SessionManagerListDefaults.MaxVisibleRows)
144+ assert.Positive(t, SessionManagerListDefaults.MaxResults)
145+}
M internal/ui/component/omnibox.go +15 -50
@@ -20,13 +20,7 @@ import (
20 )20 )
21 21
22 const (22 const (
23- omniboxWidthPct = 0.8 // 80% of parent window width23+ debounceDelayMs = 150
24- omniboxMaxWidth = 800 // Maximum width in pixels
25- omniboxFallbackHeight = 600 // Fallback height when overlay not yet allocated
26- omniboxHeightPct = 0.6 // 60% of parent window height
27- omniboxMaxResults = 10
28- omniboxTopMarginPct = 0.2 // 20% from top
29- debounceDelayMs = 150
30 )24 )
31 25
32 // ViewMode distinguishes history search from favorites display.26 // ViewMode distinguishes history search from favorites display.
@@ -193,20 +187,12 @@ func (o *Omnibox) resizeAndCenter(rowCount int) {
193 return187 return
194 }188 }
195 189
196- // Use GetAllocatedWidth for actual rendered size (height not needed here)190+ // Calculate width using shared helper
197- parentWidth := o.parentOverlay.GetAllocatedWidth()191+ width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
198- if parentWidth <= 0 {
199- parentWidth = omniboxMaxWidth
200- }
201-
202- width := int(float64(parentWidth) * omniboxWidthPct)
203- if width > omniboxMaxWidth {
204- width = omniboxMaxWidth
205- }
206 192
207 // Cap at max results193 // Cap at max results
208- if rowCount > omniboxMaxResults {194+ if rowCount > OmniboxListDefaults.MaxVisibleRows {
209- rowCount = omniboxMaxResults195+ rowCount = OmniboxListDefaults.MaxVisibleRows
210 }196 }
211 197
212 // Schedule measurement after GTK has laid out widgets198 // Schedule measurement after GTK has laid out widgets
@@ -233,9 +219,8 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
233 // Use measured values from GTK4 Measure API219 // Use measured values from GTK4 Measure API
234 rowHeight = o.measuredHeights.singleRow220 rowHeight = o.measuredHeights.singleRow
235 } else {221 } else {
236- // Fallback to hardcoded estimate (before first measurement)222+ // Fallback to scaled estimate (before first measurement)
237- const fallbackRowHeight = 60223+ rowHeight = ScaleValue(DefaultRowHeights.Standard, o.uiScale)
238- rowHeight = int(fallbackRowHeight * o.uiScale)
239 }224 }
240 225
241 // Calculate content height for the scrolled window226 // Calculate content height for the scrolled window
@@ -246,19 +231,13 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
246 }231 }
247 232
248 // Cap at max results233 // Cap at max results
249- maxContentHeight := omniboxMaxResults * rowHeight234+ maxContentHeight := OmniboxListDefaults.MaxVisibleRows * rowHeight
250 if contentHeight > maxContentHeight {235 if contentHeight > maxContentHeight {
251 contentHeight = maxContentHeight236 contentHeight = maxContentHeight
252 }237 }
253 238
254- // Set size constraints on ScrolledWindow to control dynamic sizing239+ // Set size constraints on ScrolledWindow using shared helper
255- if o.scrolledWin != nil {240+ SetScrolledWindowHeight(o.scrolledWin, contentHeight)
256- // Reset min first to avoid assertion failure when shrinking
257- // GTK requires min <= max, so we reset min before setting new values
258- o.scrolledWin.SetMinContentHeight(-1)
259- o.scrolledWin.SetMaxContentHeight(contentHeight)
260- o.scrolledWin.SetMinContentHeight(contentHeight)
261- }
262 241
263 // Force layout recalculation242 // Force layout recalculation
264 o.outerBox.QueueResize()243 o.outerBox.QueueResize()
@@ -602,7 +581,7 @@ func (o *Omnibox) performSearch() {
602 581
603 input := usecase.SearchInput{582 input := usecase.SearchInput{
604 Query: query,583 Query: query,
605- Limit: omniboxMaxResults,584+ Limit: OmniboxListDefaults.MaxResults,
606 }585 }
607 output, err := o.historyUC.Search(ctx, input)586 output, err := o.historyUC.Search(ctx, input)
608 if err != nil {587 if err != nil {
@@ -641,7 +620,7 @@ func (o *Omnibox) loadInitialHistory() {
641 620
642 case "most_visited", "recent", "":621 case "most_visited", "recent", "":
643 // TODO: Implement GetMostVisited in use case if needed622 // TODO: Implement GetMostVisited in use case if needed
644- results, err := o.historyUC.GetRecent(ctx, omniboxMaxResults, 0)623+ results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0)
645 if err != nil {624 if err != nil {
646 log.Error().Err(err).Msg("failed to load recent history")625 log.Error().Err(err).Msg("failed to load recent history")
647 return626 return
@@ -778,7 +757,7 @@ func (o *Omnibox) rebuildList() {
778 if width <= 0 {757 if width <= 0 {
779 return false // Overlay not allocated yet, skip758 return false // Overlay not allocated yet, skip
780 }759 }
781- forWidth := int(float64(width) * omniboxWidthPct)760+ forWidth := int(float64(width) * OmniboxSizeDefaults.WidthPct)
782 if o.measureComponentHeights(forWidth) {761 if o.measureComponentHeights(forWidth) {
783 // Re-trigger resize with accurate measurements762 // Re-trigger resize with accurate measurements
784 o.mu.RLock()763 o.mu.RLock()
@@ -1234,22 +1213,8 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
1234 o.scrolledWin.SetMaxContentHeight(0)1213 o.scrolledWin.SetMaxContentHeight(0)
1235 }1214 }
1236 1215
1237- // Get parent dimensions with fallback if not yet allocated1216+ // Calculate dimensions using shared helper
1238- parentWidth := o.parentOverlay.GetAllocatedWidth()1217+ width, marginTop := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
1239- parentHeight := o.parentOverlay.GetAllocatedHeight()
1240- if parentWidth <= 0 {
1241- parentWidth = omniboxMaxWidth
1242- }
1243- if parentHeight <= 0 {
1244- parentHeight = omniboxFallbackHeight
1245- }
1246-
1247- // Set width and vertical positioning (20% from top)
1248- width := int(float64(parentWidth) * omniboxWidthPct)
1249- if width > omniboxMaxWidth {
1250- width = omniboxMaxWidth
1251- }
1252- marginTop := int(float64(parentHeight) * omniboxTopMarginPct)
1253 1218
1254 o.mainBox.SetSizeRequest(width, -1)1219 o.mainBox.SetSizeRequest(width, -1)
1255 o.outerBox.SetMarginTop(marginTop)1220 o.outerBox.SetMarginTop(marginTop)
A internal/ui/component/session_manager.go +1028 -0
@@ -0,0 +1,1028 @@
1+package component
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/application/usecase"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/logging"
13+ "github.com/bnema/dumber/internal/ui/layout"
14+ "github.com/jwijenbergh/puregotk/v4/gdk"
15+ "github.com/jwijenbergh/puregotk/v4/glib"
16+ "github.com/jwijenbergh/puregotk/v4/gtk"
17+)
18+
19+const (
20+ sessionIDMaxDisplay = 20
21+ sessionRowBoxSpacing = 8
22+ sessionMaxTitleLen = 45 // Max chars for pane title display
23+)
24+
25+// SessionManager is a modal overlay for managing sessions.
26+type SessionManager struct {
27+ // GTK widgets
28+ outerBox *gtk.Box
29+ mainBox *gtk.Box
30+ headerBox *gtk.Box
31+ titleLabel *gtk.Label
32+ scrolledWindow *gtk.ScrolledWindow
33+ listBox *gtk.ListBox
34+ footerLabel *gtk.Label
35+
36+ // Parent overlay reference for sizing
37+ parentOverlay layout.OverlayWidget
38+
39+ // State
40+ mu sync.RWMutex
41+ visible bool
42+ sessions []entity.SessionInfo
43+ selectedIndex int // Index into sessions slice
44+ expandedIndex int // -1 means none expanded
45+ sessionToListRow []int // Maps session index to list row index
46+ listRowToSession []int // Maps list row index to session index (-1 for non-session rows)
47+ populatingList bool // True while rebuilding list, to ignore selection callbacks
48+
49+ // Dependencies
50+ listSessionsUC *usecase.ListSessionsUseCase
51+ deleteSessionUC *usecase.DeleteSessionUseCase
52+ spawner port.SessionSpawner
53+ currentSession entity.SessionID
54+
55+ // Callbacks
56+ onClose func()
57+ onOpen func(sessionID entity.SessionID)
58+ onToast func(ctx context.Context, message string, level ToastLevel)
59+
60+ // GTK callback retention
61+ retainedCallbacks []interface{}
62+
63+ // Scaling
64+ uiScale float64
65+
66+ // Cached measurements (populated after first layout)
67+ measuredHeights struct {
68+ sessionRow int // Session row natural height
69+ treeRow int // Tree row natural height
70+ dividerRow int // Divider row natural height
71+ valid bool // Whether cache is valid
72+ }
73+
74+ ctx context.Context
75+}
76+
77+// SessionManagerConfig holds configuration for creating a SessionManager.
78+type SessionManagerConfig struct {
79+ ListSessionsUC *usecase.ListSessionsUseCase
80+ DeleteSessionUC *usecase.DeleteSessionUseCase
81+ Spawner port.SessionSpawner
82+ CurrentSession entity.SessionID
83+ UIScale float64
84+ OnClose func()
85+ OnOpen func(sessionID entity.SessionID)
86+ OnToast func(ctx context.Context, message string, level ToastLevel)
87+}
88+
89+// NewSessionManager creates a new SessionManager component.
90+func NewSessionManager(ctx context.Context, cfg SessionManagerConfig) *SessionManager {
91+ log := logging.FromContext(ctx)
92+
93+ uiScale := cfg.UIScale
94+ if uiScale <= 0 {
95+ uiScale = 1.0
96+ }
97+
98+ sm := &SessionManager{
99+ ctx: ctx,
100+ listSessionsUC: cfg.ListSessionsUC,
101+ deleteSessionUC: cfg.DeleteSessionUC,
102+ spawner: cfg.Spawner,
103+ currentSession: cfg.CurrentSession,
104+ onClose: cfg.OnClose,
105+ onOpen: cfg.OnOpen,
106+ onToast: cfg.OnToast,
107+ selectedIndex: -1,
108+ expandedIndex: -1,
109+ uiScale: uiScale,
110+ }
111+
112+ if err := sm.createWidgets(); err != nil {
113+ log.Error().Err(err).Msg("failed to create session manager widgets")
114+ return nil
115+ }
116+
117+ sm.attachKeyController()
118+
119+ log.Debug().Msg("session manager created")
120+ return sm
121+}
122+
123+// SetParentOverlay sets the overlay widget used for sizing calculations.
124+// Must be called before Show().
125+func (sm *SessionManager) SetParentOverlay(overlay layout.OverlayWidget) {
126+ sm.mu.Lock()
127+ defer sm.mu.Unlock()
128+ sm.parentOverlay = overlay
129+}
130+
131+// WidgetAsLayout returns the session manager's outer widget as a layout.Widget.
132+func (sm *SessionManager) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget {
133+ if sm.outerBox == nil {
134+ return nil
135+ }
136+ return factory.WrapWidget(&sm.outerBox.Widget)
137+}
138+
139+// Widget returns the session manager widget for embedding in an overlay.
140+func (sm *SessionManager) Widget() *gtk.Widget {
141+ if sm.outerBox == nil {
142+ return nil
143+ }
144+ return &sm.outerBox.Widget
145+}
146+
147+// Show displays the session manager and loads sessions.
148+func (sm *SessionManager) Show(ctx context.Context) {
149+ log := logging.FromContext(ctx)
150+ log.Debug().Msg("showing session manager")
151+
152+ sm.mu.Lock()
153+ if sm.visible {
154+ sm.mu.Unlock()
155+ return
156+ }
157+ sm.visible = true
158+ sm.mu.Unlock()
159+
160+ // Calculate size and position
161+ sm.resizeAndCenter()
162+
163+ // Show the widget
164+ sm.outerBox.SetVisible(true)
165+
166+ // Load sessions asynchronously
167+ go sm.loadSessions()
168+
169+ // Focus the list
170+ if sm.listBox != nil {
171+ sm.listBox.GrabFocus()
172+ }
173+}
174+
175+// Hide hides the session manager.
176+func (sm *SessionManager) Hide(ctx context.Context) {
177+ log := logging.FromContext(ctx)
178+ log.Debug().Msg("hiding session manager")
179+
180+ sm.mu.Lock()
181+ if !sm.visible {
182+ sm.mu.Unlock()
183+ return
184+ }
185+ sm.visible = false
186+ sm.mu.Unlock()
187+
188+ sm.outerBox.SetVisible(false)
189+
190+ // Clear state
191+ sm.listBox.RemoveAll()
192+
193+ if sm.onClose != nil {
194+ sm.onClose()
195+ }
196+}
197+
198+// IsVisible returns whether the session manager is visible.
199+func (sm *SessionManager) IsVisible() bool {
200+ sm.mu.RLock()
201+ defer sm.mu.RUnlock()
202+ return sm.visible
203+}
204+
205+// Toggle shows if hidden, hides if visible.
206+func (sm *SessionManager) Toggle(ctx context.Context) {
207+ sm.mu.RLock()
208+ visible := sm.visible
209+ sm.mu.RUnlock()
210+
211+ if visible {
212+ sm.Hide(ctx)
213+ } else {
214+ sm.Show(ctx)
215+ }
216+}
217+
218+func (sm *SessionManager) resizeAndCenter() {
219+ if sm.outerBox == nil || sm.mainBox == nil {
220+ return
221+ }
222+
223+ width, marginTop := CalculateModalDimensions(sm.parentOverlay, SessionManagerSizeDefaults)
224+
225+ sm.mainBox.SetSizeRequest(width, -1)
226+ sm.outerBox.SetMarginTop(marginTop)
227+}
228+
229+func (sm *SessionManager) createWidgets() error {
230+ // Outer container for positioning
231+ sm.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
232+ if sm.outerBox == nil {
233+ return errNilWidget("sessionOuterBox")
234+ }
235+ sm.outerBox.AddCssClass("session-manager-outer")
236+ sm.outerBox.SetHalign(gtk.AlignCenterValue)
237+ sm.outerBox.SetValign(gtk.AlignStartValue)
238+ sm.outerBox.SetVisible(false)
239+
240+ // Main container with styling
241+ sm.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
242+ if sm.mainBox == nil {
243+ return errNilWidget("sessionMainBox")
244+ }
245+ sm.mainBox.AddCssClass("session-manager-container")
246+
247+ // Header
248+ if err := sm.initHeader(); err != nil {
249+ return err
250+ }
251+
252+ // Scrolled window for list
253+ if err := sm.initList(); err != nil {
254+ return err
255+ }
256+
257+ // Footer with shortcuts
258+ if err := sm.initFooter(); err != nil {
259+ return err
260+ }
261+
262+ // Assemble
263+ sm.mainBox.Append(&sm.headerBox.Widget)
264+ sm.mainBox.Append(&sm.scrolledWindow.Widget)
265+ sm.mainBox.Append(&sm.footerLabel.Widget)
266+ sm.outerBox.Append(&sm.mainBox.Widget)
267+
268+ return nil
269+}
270+
271+func (sm *SessionManager) initHeader() error {
272+ sm.headerBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
273+ if sm.headerBox == nil {
274+ return errNilWidget("sessionHeaderBox")
275+ }
276+ sm.headerBox.AddCssClass("session-manager-header")
277+
278+ title := "Sessions"
279+ sm.titleLabel = gtk.NewLabel(&title)
280+ if sm.titleLabel == nil {
281+ return errNilWidget("sessionTitleLabel")
282+ }
283+ sm.titleLabel.AddCssClass("session-manager-title")
284+ sm.titleLabel.SetHalign(gtk.AlignStartValue)
285+ sm.titleLabel.SetHexpand(true)
286+ sm.headerBox.Append(&sm.titleLabel.Widget)
287+
288+ // Shortcut badge
289+ shortcutText := "Ctrl+O"
290+ shortcutLabel := gtk.NewLabel(&shortcutText)
291+ if shortcutLabel != nil {
292+ shortcutLabel.AddCssClass("omnibox-shortcut-badge")
293+ sm.headerBox.Append(&shortcutLabel.Widget)
294+ }
295+
296+ return nil
297+}
298+
299+func (sm *SessionManager) initList() error {
300+ sm.scrolledWindow = gtk.NewScrolledWindow()
301+ if sm.scrolledWindow == nil {
302+ return errNilWidget("sessionScrolledWindow")
303+ }
304+ sm.scrolledWindow.AddCssClass("session-manager-scrolled")
305+ sm.scrolledWindow.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
306+ // Don't propagate natural height - we control it via resizeForContent
307+ sm.scrolledWindow.SetPropagateNaturalHeight(false)
308+
309+ sm.listBox = gtk.NewListBox()
310+ if sm.listBox == nil {
311+ return errNilWidget("sessionListBox")
312+ }
313+ sm.listBox.AddCssClass("session-manager-list")
314+ sm.listBox.SetSelectionMode(gtk.SelectionSingleValue)
315+
316+ // Connect row selection - map list row index to session index
317+ rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
318+ if rowPtr == 0 {
319+ return
320+ }
321+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
322+ if row == nil {
323+ return
324+ }
325+ listRowIdx := row.GetIndex()
326+ sm.mu.Lock()
327+ // Ignore selection changes while rebuilding the list
328+ if sm.populatingList {
329+ sm.mu.Unlock()
330+ return
331+ }
332+ if listRowIdx >= 0 && listRowIdx < len(sm.listRowToSession) {
333+ sessionIdx := sm.listRowToSession[listRowIdx]
334+ if sessionIdx >= 0 {
335+ sm.selectedIndex = sessionIdx
336+ }
337+ }
338+ sm.mu.Unlock()
339+ }
340+ sm.retainedCallbacks = append(sm.retainedCallbacks, rowSelectedCb)
341+ sm.listBox.ConnectRowSelected(&rowSelectedCb)
342+
343+ sm.scrolledWindow.SetChild(&sm.listBox.Widget)
344+ return nil
345+}
346+
347+func (sm *SessionManager) initFooter() error {
348+ footerText := "↑↓/jk navigate Enter expand r restore x delete Esc close"
349+ sm.footerLabel = gtk.NewLabel(&footerText)
350+ if sm.footerLabel == nil {
351+ return errNilWidget("sessionFooterLabel")
352+ }
353+ sm.footerLabel.AddCssClass("session-manager-footer")
354+ sm.footerLabel.SetHalign(gtk.AlignCenterValue)
355+ return nil
356+}
357+
358+func (sm *SessionManager) attachKeyController() {
359+ controller := gtk.NewEventControllerKey()
360+ if controller == nil {
361+ return
362+ }
363+ controller.SetPropagationPhase(gtk.PhaseCaptureValue)
364+
365+ keyPressedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, state gdk.ModifierType) bool {
366+ return sm.handleKeyPress(keyval, state)
367+ }
368+ sm.retainedCallbacks = append(sm.retainedCallbacks, keyPressedCb)
369+ controller.ConnectKeyPressed(&keyPressedCb)
370+ sm.outerBox.AddController(&controller.EventController)
371+}
372+
373+func (sm *SessionManager) handleKeyPress(keyval uint, _ gdk.ModifierType) bool {
374+ switch keyval {
375+ case uint(gdk.KEY_Escape):
376+ sm.Hide(sm.ctx)
377+ return true
378+
379+ case uint(gdk.KEY_Up), uint(gdk.KEY_k):
380+ sm.selectPrevious()
381+ return true
382+
383+ case uint(gdk.KEY_Down), uint(gdk.KEY_j):
384+ sm.selectNext()
385+ return true
386+
387+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter), uint(gdk.KEY_space):
388+ // Enter/Space expands/collapses to preview session contents
389+ sm.toggleExpanded()
390+ return true
391+
392+ case uint(gdk.KEY_r), uint(gdk.KEY_o):
393+ // r/o restores the selected session
394+ sm.openSelected()
395+ return true
396+
397+ case uint(gdk.KEY_x), uint(gdk.KEY_d), uint(gdk.KEY_Delete):
398+ sm.deleteSelected()
399+ return true
400+ }
401+ return false
402+}
403+
404+func (sm *SessionManager) loadSessions() {
405+ log := logging.FromContext(sm.ctx)
406+
407+ if sm.listSessionsUC == nil {
408+ log.Warn().Msg("listSessionsUC is nil")
409+ return
410+ }
411+
412+ output, err := sm.listSessionsUC.Execute(sm.ctx, sm.currentSession, SessionManagerListDefaults.MaxResults)
413+ if err != nil {
414+ log.Error().Err(err).Msg("failed to list sessions")
415+ return
416+ }
417+
418+ sm.mu.Lock()
419+ sm.sessions = output.Sessions
420+ sm.selectedIndex = 0
421+ sm.mu.Unlock()
422+
423+ // Update UI on main thread
424+ cb := glib.SourceFunc(func(_ uintptr) bool {
425+ sm.populateList()
426+ return false
427+ })
428+ glib.IdleAdd(&cb, 0)
429+}
430+
431+// populateListState holds state during list population.
432+type populateListState struct {
433+ listRowToSession []int
434+ listRowCount int
435+ sessionRowCount int
436+ treeRowCount int
437+ dividerCount int
438+ expandedIdx int
439+ firstExitedIdx int
440+ hasActive bool
441+}
442+
443+func (sm *SessionManager) populateList() {
444+ sm.mu.Lock()
445+ sessions := sm.sessions
446+ selectedIdx := sm.selectedIndex
447+ sm.populatingList = true
448+ sm.sessionToListRow = make([]int, len(sessions))
449+ sm.listRowToSession = nil
450+ expandedIdx := sm.expandedIndex
451+ sm.mu.Unlock()
452+
453+ if sm.listBox == nil {
454+ sm.mu.Lock()
455+ sm.populatingList = false
456+ sm.mu.Unlock()
457+ return
458+ }
459+
460+ sm.listBox.RemoveAll()
461+
462+ state := &populateListState{expandedIdx: expandedIdx, firstExitedIdx: -1}
463+ for i, info := range sessions {
464+ if info.IsCurrent || info.IsActive {
465+ state.hasActive = true
466+ } else if state.firstExitedIdx == -1 {
467+ state.firstExitedIdx = i
468+ }
469+ }
470+
471+ for i, info := range sessions {
472+ sm.addSessionToList(i, info, state)
473+ }
474+
475+ sm.mu.Lock()
476+ sm.listRowToSession = state.listRowToSession
477+ sm.populatingList = false
478+ sm.mu.Unlock()
479+
480+ sm.selectBySessionIndex(selectedIdx)
481+
482+ // Attempt to measure actual widget heights for accurate sizing
483+ width, _ := CalculateModalDimensions(sm.parentOverlay, SessionManagerSizeDefaults)
484+ sm.measureWidgetHeights(width)
485+
486+ sm.resizeForContent(state.sessionRowCount, state.dividerCount, state.treeRowCount)
487+}
488+
489+func (sm *SessionManager) addSessionToList(i int, info entity.SessionInfo, state *populateListState) {
490+ // Add divider before first exited session
491+ if i == state.firstExitedIdx && state.hasActive {
492+ if divider := sm.createDividerRow("EXITED"); divider != nil {
493+ sm.listBox.Append(&divider.Widget)
494+ state.listRowToSession = append(state.listRowToSession, -1)
495+ state.listRowCount++
496+ state.dividerCount++
497+ }
498+ }
499+
500+ sm.mu.Lock()
501+ sm.sessionToListRow[i] = state.listRowCount
502+ sm.mu.Unlock()
503+ state.listRowToSession = append(state.listRowToSession, i)
504+
505+ if row := sm.createSessionRow(info); row != nil {
506+ sm.listBox.Append(&row.Widget)
507+ state.listRowCount++
508+ state.sessionRowCount++
509+ }
510+
511+ if i == state.expandedIdx && info.State != nil {
512+ for _, treeRow := range sm.createTreeRows(info.State) {
513+ sm.listBox.Append(&treeRow.Widget)
514+ state.listRowToSession = append(state.listRowToSession, -1)
515+ state.listRowCount++
516+ state.treeRowCount++
517+ }
518+ }
519+}
520+
521+func (sm *SessionManager) selectBySessionIndex(sessionIdx int) {
522+ sm.mu.RLock()
523+ if sessionIdx < 0 || sessionIdx >= len(sm.sessionToListRow) {
524+ sm.mu.RUnlock()
525+ return
526+ }
527+ listRow := sm.sessionToListRow[sessionIdx]
528+ sm.mu.RUnlock()
529+
530+ row := sm.listBox.GetRowAtIndex(listRow)
531+ if row == nil {
532+ return
533+ }
534+
535+ sm.listBox.SelectRow(row)
536+
537+ // Scroll the row into view using GrabFocus which triggers scroll
538+ row.GrabFocus()
539+}
540+
541+func (sm *SessionManager) resizeForContent(sessionRowCount, dividerCount, treeRowCount int) {
542+ if sm.scrolledWindow == nil {
543+ return
544+ }
545+
546+ // Get row heights - use measured values if available, otherwise scale defaults
547+ var sessionH, treeH, dividerH int
548+ if sm.measuredHeights.valid {
549+ sessionH = sm.measuredHeights.sessionRow
550+ treeH = sm.measuredHeights.treeRow
551+ dividerH = sm.measuredHeights.dividerRow
552+ } else {
553+ sessionH = ScaleValue(DefaultRowHeights.Standard, sm.uiScale)
554+ treeH = ScaleValue(DefaultRowHeights.Compact, sm.uiScale)
555+ dividerH = ScaleValue(DefaultRowHeights.Divider, sm.uiScale)
556+ }
557+
558+ // Calculate actual content height
559+ contentHeight := sessionRowCount*sessionH + dividerCount*dividerH + treeRowCount*treeH
560+
561+ // Calculate max visible height - limit to prevent oversized modal
562+ maxVisibleHeight := SessionManagerListDefaults.MaxVisibleRows*sessionH + dividerH
563+
564+ // Cap at max visible height - rest will be scrollable
565+ if contentHeight > maxVisibleHeight {
566+ contentHeight = maxVisibleHeight
567+ }
568+
569+ // Ensure minimum height for at least one row
570+ if contentHeight < sessionH {
571+ contentHeight = sessionH
572+ }
573+
574+ // Schedule resize on main thread using shared helper
575+ cb := glib.SourceFunc(func(_ uintptr) bool {
576+ SetScrolledWindowHeight(sm.scrolledWindow, contentHeight)
577+ sm.outerBox.QueueResize()
578+ return false
579+ })
580+ glib.IdleAdd(&cb, 0)
581+}
582+
583+// measureWidgetHeights attempts to measure actual row heights from the list.
584+// Must be called after list is populated. Updates measuredHeights cache if successful.
585+func (sm *SessionManager) measureWidgetHeights(forWidth int) {
586+ if sm.listBox == nil || sm.measuredHeights.valid {
587+ return
588+ }
589+
590+ // We need to find different row types and measure them.
591+ // Session rows have "session-row" class, tree rows have "session-tree-row" class,
592+ // divider rows have "session-divider" class.
593+
594+ // For now, measure using first row and estimate others based on ratio.
595+ // This is simpler and works well enough since we have fallback scaling.
596+ row := sm.listBox.GetRowAtIndex(0)
597+ if row == nil {
598+ return
599+ }
600+
601+ sessionH := MeasureWidgetHeight(&row.Widget, forWidth)
602+ if sessionH <= 0 {
603+ return
604+ }
605+
606+ // Estimate tree and divider heights based on ratio to standard row
607+ // These ratios match the original constants: tree=28/50, divider=30/50
608+ treeH := sessionH * DefaultRowHeights.Compact / DefaultRowHeights.Standard
609+ dividerH := sessionH * DefaultRowHeights.Divider / DefaultRowHeights.Standard
610+
611+ sm.measuredHeights.sessionRow = sessionH
612+ sm.measuredHeights.treeRow = treeH
613+ sm.measuredHeights.dividerRow = dividerH
614+ sm.measuredHeights.valid = true
615+}
616+
617+// createTreeRows creates tree rows showing the tab/pane structure of a session.
618+func (sm *SessionManager) createTreeRows(state *entity.SessionState) []*gtk.ListBoxRow {
619+ if state == nil {
620+ return nil
621+ }
622+
623+ var rows []*gtk.ListBoxRow
624+
625+ for tabIdx, tab := range state.Tabs {
626+ isLastTab := tabIdx == len(state.Tabs)-1
627+
628+ // Tab row
629+ tabRow := sm.createTreeRow(tab.Name, "tab", !isLastTab, 0)
630+ if tabRow != nil {
631+ rows = append(rows, tabRow)
632+ }
633+
634+ // Pane rows under this tab
635+ paneRows := sm.createPaneTreeRows(&tab.Workspace)
636+ rows = append(rows, paneRows...)
637+ }
638+
639+ return rows
640+}
641+
642+// createPaneTreeRows creates rows for the pane tree under a tab.
643+func (sm *SessionManager) createPaneTreeRows(ws *entity.WorkspaceSnapshot) []*gtk.ListBoxRow {
644+ if ws == nil || ws.Root == nil {
645+ return nil
646+ }
647+
648+ var rows []*gtk.ListBoxRow
649+ sm.collectPaneRows(&rows, ws.Root, 1, true)
650+ return rows
651+}
652+
653+// collectPaneRows recursively collects pane tree rows.
654+func (sm *SessionManager) collectPaneRows(
655+ rows *[]*gtk.ListBoxRow,
656+ node *entity.PaneNodeSnapshot,
657+ depth int,
658+ isLast bool,
659+) {
660+ if node == nil {
661+ return
662+ }
663+
664+ if node.Pane != nil {
665+ // Leaf node (actual pane)
666+ title := node.Pane.Title
667+ if title == "" {
668+ title = node.Pane.URI
669+ }
670+ if len(title) > sessionMaxTitleLen {
671+ title = title[:sessionMaxTitleLen-3] + "..."
672+ }
673+
674+ row := sm.createTreeRow(title, "pane", !isLast, depth)
675+ if row != nil {
676+ *rows = append(*rows, row)
677+ }
678+ } else if len(node.Children) > 0 {
679+ // Container node (split)
680+ splitLabel := "split"
681+ if node.IsStacked {
682+ splitLabel = "stacked"
683+ } else if node.SplitRatio > 0 {
684+ splitLabel = fmt.Sprintf("split %.0f%%", node.SplitRatio*100)
685+ }
686+
687+ row := sm.createTreeRow(splitLabel, "split", !isLast, depth)
688+ if row != nil {
689+ *rows = append(*rows, row)
690+ }
691+
692+ // Recurse into children
693+ for i, child := range node.Children {
694+ isLastChild := i == len(node.Children)-1
695+ sm.collectPaneRows(rows, child, depth+1, isLastChild)
696+ }
697+ }
698+}
699+
700+// createTreeRow creates a single tree row with proper indentation and branch characters.
701+func (sm *SessionManager) createTreeRow(text, nodeType string, hasSibling bool, depth int) *gtk.ListBoxRow {
702+ row := gtk.NewListBoxRow()
703+ if row == nil {
704+ return nil
705+ }
706+ row.SetSelectable(false)
707+ row.SetActivatable(false)
708+ row.AddCssClass("session-tree-row")
709+
710+ hbox := gtk.NewBox(gtk.OrientationHorizontalValue, 4)
711+ if hbox == nil {
712+ return nil
713+ }
714+ hbox.AddCssClass("session-tree-content")
715+
716+ // Build indent and branch prefix
717+ var prefix string
718+ indent := " " // Base indent (minimal, tree branches provide visual alignment)
719+ for i := 0; i < depth; i++ {
720+ indent += " " // 3 spaces per depth level
721+ }
722+
723+ // Branch character
724+ if hasSibling {
725+ prefix = "├── "
726+ } else {
727+ prefix = "└── "
728+ }
729+
730+ // Icon based on node type
731+ var icon string
732+ switch nodeType {
733+ case "tab":
734+ icon = "󰓩 " // Tab icon (nerd font)
735+ case "pane":
736+ icon = "󰕮 " // Pane icon
737+ case "split":
738+ icon = "󰯍 " // Split icon
739+ default:
740+ icon = " "
741+ }
742+
743+ // Create the label with all parts
744+ labelText := indent + prefix + icon + text
745+ label := gtk.NewLabel(&labelText)
746+ if label == nil {
747+ return nil
748+ }
749+ label.AddCssClass("session-tree-label")
750+ label.SetHalign(gtk.AlignStartValue)
751+ label.SetEllipsize(2) // PANGO_ELLIPSIZE_END
752+
753+ hbox.Append(&label.Widget)
754+ row.SetChild(&hbox.Widget)
755+
756+ return row
757+}
758+
759+func (sm *SessionManager) createDividerRow(text string) *gtk.ListBoxRow {
760+ row := gtk.NewListBoxRow()
761+ if row == nil {
762+ return nil
763+ }
764+ row.SetSelectable(false)
765+ row.SetActivatable(false)
766+
767+ label := gtk.NewLabel(&text)
768+ if label == nil {
769+ return nil
770+ }
771+ label.AddCssClass("session-divider")
772+ label.SetHalign(gtk.AlignStartValue)
773+ row.SetChild(&label.Widget)
774+
775+ return row
776+}
777+
778+func (sm *SessionManager) createSessionRow(info entity.SessionInfo) *gtk.ListBoxRow {
779+ row := gtk.NewListBoxRow()
780+ if row == nil {
781+ return nil
782+ }
783+ row.AddCssClass("session-manager-row-wrapper")
784+
785+ hbox := gtk.NewBox(gtk.OrientationHorizontalValue, sessionRowBoxSpacing)
786+ if hbox == nil {
787+ return nil
788+ }
789+ hbox.AddCssClass("session-manager-row")
790+
791+ sm.addStatusIndicator(hbox, info)
792+ sm.addSessionInfo(hbox, info)
793+ sm.addRelativeTime(hbox, info)
794+
795+ row.SetChild(&hbox.Widget)
796+ return row
797+}
798+
799+func (sm *SessionManager) addStatusIndicator(hbox *gtk.Box, info entity.SessionInfo) {
800+ var statusText string
801+ switch {
802+ case info.IsCurrent:
803+ statusText = "●" // Filled dot for current
804+ hbox.AddCssClass("session-current")
805+ case info.IsActive:
806+ statusText = "○" // Empty dot for other active instance
807+ hbox.AddCssClass("session-active")
808+ default:
809+ statusText = "◼" // Small square for exited (like stop icon)
810+ hbox.AddCssClass("session-exited")
811+ }
812+ statusLabel := gtk.NewLabel(&statusText)
813+ if statusLabel != nil {
814+ statusLabel.AddCssClass("session-status")
815+ hbox.Append(&statusLabel.Widget)
816+ }
817+}
818+
819+func (sm *SessionManager) addSessionInfo(hbox *gtk.Box, info entity.SessionInfo) {
820+ const infoBoxSpacing = 2
821+ infoBox := gtk.NewBox(gtk.OrientationVerticalValue, infoBoxSpacing)
822+ if infoBox == nil {
823+ return
824+ }
825+ infoBox.SetHexpand(true)
826+
827+ // Session ID row: shortID (accent) + rest of ID (dimmed) + optional (current) badge
828+ idRowBox := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
829+ if idRowBox != nil {
830+ idRowBox.SetHalign(gtk.AlignStartValue)
831+
832+ fullID := string(info.Session.ID)
833+ shortID := info.Session.ShortID() // Last 4 chars
834+
835+ // Short ID with accent color (displayed first)
836+ shortLabel := gtk.NewLabel(&shortID)
837+ if shortLabel != nil {
838+ shortLabel.AddCssClass("session-id-short")
839+ idRowBox.Append(&shortLabel.Widget)
840+ }
841+
842+ // Rest of the ID (prefix before the short suffix), dimmed
843+ // Format: "20251225_083026_7566" -> shortID="7566", prefix="20251225_083026"
844+ // Remove trailing underscore for cleaner display
845+ if len(fullID) > 4 {
846+ prefix := fullID[:len(fullID)-4]
847+ // Trim trailing underscore if present
848+ prefix = strings.TrimSuffix(prefix, "_")
849+ prefix = " " + prefix
850+ prefixLabel := gtk.NewLabel(&prefix)
851+ if prefixLabel != nil {
852+ prefixLabel.AddCssClass("session-id-prefix")
853+ idRowBox.Append(&prefixLabel.Widget)
854+ }
855+ }
856+
857+ // Current badge
858+ if info.IsCurrent {
859+ badge := " (current)"
860+ badgeLabel := gtk.NewLabel(&badge)
861+ if badgeLabel != nil {
862+ badgeLabel.AddCssClass("session-id-badge")
863+ idRowBox.Append(&badgeLabel.Widget)
864+ }
865+ }
866+
867+ infoBox.Append(&idRowBox.Widget)
868+ }
869+
870+ // Tab/pane count
871+ countText := fmt.Sprintf("%d tabs, %d panes", info.TabCount, info.PaneCount)
872+ countLabel := gtk.NewLabel(&countText)
873+ if countLabel != nil {
874+ countLabel.AddCssClass("session-count")
875+ countLabel.SetHalign(gtk.AlignStartValue)
876+ infoBox.Append(&countLabel.Widget)
877+ }
878+
879+ hbox.Append(&infoBox.Widget)
880+}
881+
882+func (sm *SessionManager) addRelativeTime(hbox *gtk.Box, info entity.SessionInfo) {
883+ relTime := usecase.GetRelativeTime(info.UpdatedAt)
884+ timeLabel := gtk.NewLabel(&relTime)
885+ if timeLabel != nil {
886+ timeLabel.AddCssClass("session-time")
887+ timeLabel.SetValign(gtk.AlignCenterValue)
888+ hbox.Append(&timeLabel.Widget)
889+ }
890+}
891+
892+func (sm *SessionManager) selectPrevious() {
893+ sm.mu.Lock()
894+ maxIndex := len(sm.sessions) - 1
895+ if maxIndex < 0 {
896+ sm.mu.Unlock()
897+ return
898+ }
899+ sm.selectedIndex--
900+ if sm.selectedIndex < 0 {
901+ sm.selectedIndex = maxIndex // Wrap to bottom
902+ }
903+ idx := sm.selectedIndex
904+ sm.mu.Unlock()
905+
906+ sm.selectBySessionIndex(idx)
907+}
908+
909+func (sm *SessionManager) selectNext() {
910+ sm.mu.Lock()
911+ maxIndex := len(sm.sessions) - 1
912+ if maxIndex < 0 {
913+ sm.mu.Unlock()
914+ return
915+ }
916+ sm.selectedIndex++
917+ if sm.selectedIndex > maxIndex {
918+ sm.selectedIndex = 0 // Wrap to top
919+ }
920+ idx := sm.selectedIndex
921+ sm.mu.Unlock()
922+
923+ sm.selectBySessionIndex(idx)
924+}
925+
926+func (sm *SessionManager) toggleExpanded() {
927+ sm.mu.Lock()
928+ if sm.expandedIndex == sm.selectedIndex {
929+ sm.expandedIndex = -1 // collapse
930+ } else {
931+ sm.expandedIndex = sm.selectedIndex // expand
932+ }
933+ sm.mu.Unlock()
934+
935+ // Rebuild the list to show/hide tree
936+ sm.populateList()
937+}
938+
939+func (sm *SessionManager) openSelected() {
940+ sm.mu.RLock()
941+ if sm.selectedIndex < 0 || sm.selectedIndex >= len(sm.sessions) {
942+ sm.mu.RUnlock()
943+ return
944+ }
945+ info := sm.sessions[sm.selectedIndex]
946+ sm.mu.RUnlock()
947+
948+ log := logging.FromContext(sm.ctx)
949+
950+ // Don't open current session
951+ if info.IsCurrent {
952+ log.Debug().Msg("cannot open current session")
953+ return
954+ }
955+
956+ log.Info().Str("session_id", string(info.Session.ID)).Msg("opening session")
957+
958+ sm.Hide(sm.ctx)
959+
960+ if sm.onOpen != nil {
961+ sm.onOpen(info.Session.ID)
962+ }
963+}
964+
965+func (sm *SessionManager) deleteSelected() {
966+ sm.mu.RLock()
967+ if sm.selectedIndex < 0 || sm.selectedIndex >= len(sm.sessions) {
968+ sm.mu.RUnlock()
969+ return
970+ }
971+ info := sm.sessions[sm.selectedIndex]
972+ sm.mu.RUnlock()
973+
974+ log := logging.FromContext(sm.ctx)
975+
976+ // Don't delete current or active sessions (early check before async)
977+ if info.IsCurrent || info.IsActive {
978+ log.Debug().Msg("cannot delete current or active session")
979+ if sm.onToast != nil {
980+ sm.onToast(sm.ctx, "Cannot delete active session", ToastWarning)
981+ }
982+ return
983+ }
984+
985+ if sm.deleteSessionUC == nil {
986+ log.Error().Msg("delete session use case not available")
987+ return
988+ }
989+
990+ sessionID := info.Session.ID
991+ log.Info().Str("session_id", string(sessionID)).Msg("deleting session")
992+
993+ go func() {
994+ err := sm.deleteSessionUC.Execute(sm.ctx, usecase.DeleteSessionInput{
995+ SessionID: sessionID,
996+ CurrentSessionID: sm.currentSession,
997+ })
998+
999+ cb := glib.SourceFunc(func(_ uintptr) bool {
1000+ if err != nil {
1001+ log.Error().Err(err).Msg("failed to delete session")
1002+ if sm.onToast != nil {
1003+ sm.onToast(sm.ctx, "Failed to delete session", ToastError)
1004+ }
1005+ } else if sm.onToast != nil {
1006+ sm.onToast(sm.ctx, "Session deleted", ToastSuccess)
1007+ }
1008+ sm.loadSessions()
1009+ return false
1010+ })
1011+ glib.IdleAdd(&cb, 0)
1012+ }()
1013+}
1014+
1015+// Destroy cleans up session manager resources.
1016+func (sm *SessionManager) Destroy() {
1017+ sm.mu.Lock()
1018+ sm.visible = false
1019+ sm.mu.Unlock()
1020+
1021+ sm.parentOverlay = nil
1022+ sm.retainedCallbacks = nil
1023+
1024+ if sm.outerBox != nil {
1025+ sm.outerBox.Unparent()
1026+ sm.outerBox = nil
1027+ }
1028+}
M internal/ui/coordinator/content.go +22 -0
@@ -46,6 +46,9 @@ type ContentCoordinator struct {
46 // Callback when page is committed (for history recording)46 // Callback when page is committed (for history recording)
47 onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)47 onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
48 48
49+ // Callback when pane URI changes (for session snapshots)
50+ onPaneURIUpdated func(paneID entity.PaneID, url string)
51+
49 // Gesture action handler for mouse button navigation52 // Gesture action handler for mouse button navigation
50 gestureActionHandler input.ActionHandler53 gestureActionHandler input.ActionHandler
51 54
@@ -103,6 +106,11 @@ func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, pan
103 c.onHistoryRecord = fn106 c.onHistoryRecord = fn
104 }107 }
105 108
109+// SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
110+func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
111+ c.onPaneURIUpdated = fn
112+}
113+
106 // SetGestureActionHandler sets the callback for mouse button navigation gestures.114 // SetGestureActionHandler sets the callback for mouse button navigation gestures.
107 func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {115 func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {
108 c.gestureActionHandler = handler116 c.gestureActionHandler = handler
@@ -655,6 +663,9 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
655 return663 return
656 }664 }
657 665
666+ // Update domain model with current URI for session snapshots
667+ c.updatePaneURI(paneID, url)
668+
658 // Record history - URI is guaranteed to be correct at LoadCommitted669 // Record history - URI is guaranteed to be correct at LoadCommitted
659 if c.onHistoryRecord != nil {670 if c.onHistoryRecord != nil {
660 c.onHistoryRecord(ctx, paneID, url)671 c.onHistoryRecord(ctx, paneID, url)
@@ -679,12 +690,23 @@ func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.
679 log := logging.FromContext(ctx)690 log := logging.FromContext(ctx)
680 log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected")691 log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected")
681 692
693+ // Update domain model with current URI for session snapshots
694+ c.updatePaneURI(paneID, url)
695+
682 // Record history for SPA navigation696 // Record history for SPA navigation
683 if c.onHistoryRecord != nil {697 if c.onHistoryRecord != nil {
684 c.onHistoryRecord(ctx, paneID, url)698 c.onHistoryRecord(ctx, paneID, url)
685 }699 }
686 }700 }
687 701
702+// updatePaneURI updates the pane's URI in the domain model.
703+// This is called on navigation so that session snapshots capture the current URL.
704+func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, url string) {
705+ if c.onPaneURIUpdated != nil {
706+ c.onPaneURIUpdated(paneID, url)
707+ }
708+}
709+
688 // onLoadStarted shows the progress bar when page loading begins.710 // onLoadStarted shows the progress bar when page loading begins.
689 func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {711 func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {
690 _, wsView := c.getActiveWS()712 _, wsView := c.getActiveWS()
M internal/ui/coordinator/tab.go +21 -0
@@ -24,6 +24,7 @@ type TabCoordinator struct {
24 onTabSwitched func(ctx context.Context, tab *entity.Tab)24 onTabSwitched func(ctx context.Context, tab *entity.Tab)
25 onQuit func()25 onQuit func()
26 onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) // For popup tabs26 onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) // For popup tabs
27+ onStateChanged func() // For session snapshots
27 }28 }
28 29
29 // TabCoordinatorConfig holds configuration for TabCoordinator.30 // TabCoordinatorConfig holds configuration for TabCoordinator.
@@ -63,6 +64,18 @@ func (c *TabCoordinator) SetOnQuit(fn func()) {
63 c.onQuit = fn64 c.onQuit = fn
64 }65 }
65 66
67+// SetOnStateChanged sets the callback for when tab state changes (for session snapshots).
68+func (c *TabCoordinator) SetOnStateChanged(fn func()) {
69+ c.onStateChanged = fn
70+}
71+
72+// notifyStateChanged triggers the state changed callback if set.
73+func (c *TabCoordinator) notifyStateChanged() {
74+ if c.onStateChanged != nil {
75+ c.onStateChanged()
76+ }
77+}
78+
66 // Create creates a new tab with the given initial URL.79 // Create creates a new tab with the given initial URL.
67 func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity.Tab, error) {80 func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity.Tab, error) {
68 log := logging.FromContext(ctx)81 log := logging.FromContext(ctx)
@@ -99,6 +112,9 @@ func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity
99 c.onTabSwitched(ctx, output.Tab)112 c.onTabSwitched(ctx, output.Tab)
100 }113 }
101 114
115+ // Notify state change for session snapshots
116+ c.notifyStateChanged()
117+
102 log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("tab created")118 log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("tab created")
103 return output.Tab, nil119 return output.Tab, nil
104 }120 }
@@ -143,6 +159,11 @@ func (c *TabCoordinator) Close(ctx context.Context) error {
143 }159 }
144 }160 }
145 161
162+ // Notify state change for session snapshots (unless quitting)
163+ if !wasLast {
164+ c.notifyStateChanged()
165+ }
166+
146 log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed")167 log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed")
147 return nil168 return nil
148 }169 }
M internal/ui/coordinator/workspace.go +25 -0
@@ -32,6 +32,7 @@ type WorkspaceCoordinator struct {
32 generateID func() string32 generateID func() string
33 onCloseLastPane func(ctx context.Context) error33 onCloseLastPane func(ctx context.Context) error
34 onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior34 onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior
35+ onStateChanged func() // For session snapshots
35 }36 }
36 37
37 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.38 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
@@ -82,6 +83,18 @@ func (c *WorkspaceCoordinator) SetOnCloseLastPane(fn func(ctx context.Context) e
82 c.onCloseLastPane = fn83 c.onCloseLastPane = fn
83 }84 }
84 85
86+// SetOnStateChanged sets the callback for when workspace state changes (for session snapshots).
87+func (c *WorkspaceCoordinator) SetOnStateChanged(fn func()) {
88+ c.onStateChanged = fn
89+}
90+
91+// notifyStateChanged triggers the state changed callback if set.
92+func (c *WorkspaceCoordinator) notifyStateChanged() {
93+ if c.onStateChanged != nil {
94+ c.onStateChanged()
95+ }
96+}
97+
85 // setupPaneViewHover configures hover-to-focus behavior on a PaneView.98 // setupPaneViewHover configures hover-to-focus behavior on a PaneView.
86 func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *component.WorkspaceView) {99 func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *component.WorkspaceView) {
87 pv.SetOnHover(func(paneID entity.PaneID) {100 pv.SetOnHover(func(paneID entity.PaneID) {
@@ -129,6 +142,9 @@ func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.Spli
129 c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID)142 c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID)
130 }143 }
131 144
145+ // Notify state change for session snapshots
146+ c.notifyStateChanged()
147+
132 log.Info().Str("direction", string(direction)).Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).Msg("pane split completed")148 log.Info().Str("direction", string(direction)).Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).Msg("pane split completed")
133 149
134 return nil150 return nil
@@ -740,6 +756,9 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
740 parentIsStartInGrand,756 parentIsStartInGrand,
741 )757 )
742 758
759+ // Notify state change for session snapshots
760+ c.notifyStateChanged()
761+
743 log.Info().Msg("pane closed")762 log.Info().Msg("pane closed")
744 return nil763 return nil
745 }764 }
@@ -827,6 +846,9 @@ func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity.
827 parentIsStartInGrand,846 parentIsStartInGrand,
828 )847 )
829 848
849+ // Notify state change for session snapshots
850+ c.notifyStateChanged()
851+
830 log.Info().Str("pane_id", string(paneID)).Msg("pane closed by ID")852 log.Info().Str("pane_id", string(paneID)).Msg("pane closed by ID")
831 return nil853 return nil
832 }854 }
@@ -1240,6 +1262,9 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
1240 }1262 }
1241 }1263 }
1242 1264
1265+ // Notify state change for session snapshots
1266+ c.notifyStateChanged()
1267+
1243 log.Info().1268 log.Info().
1244 Str("original_pane", string(stackCtx.activePaneID)).1269 Str("original_pane", string(stackCtx.activePaneID)).
1245 Str("new_pane", string(newPaneID)).1270 Str("new_pane", string(newPaneID)).
M internal/ui/deps.go +11 -3
@@ -6,6 +6,7 @@ import (
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/domain/repository"10 "github.com/bnema/dumber/internal/domain/repository"
10 "github.com/bnema/dumber/internal/infrastructure/config"11 "github.com/bnema/dumber/internal/infrastructure/config"
11 "github.com/bnema/dumber/internal/infrastructure/favicon"12 "github.com/bnema/dumber/internal/infrastructure/favicon"
@@ -18,9 +19,10 @@ import (
18 // This struct is created once at startup and passed to UI components.19 // This struct is created once at startup and passed to UI components.
19 type Dependencies struct {20 type Dependencies struct {
20 // Core context and configuration21 // Core context and configuration
21- Ctx context.Context22+ Ctx context.Context
22- Config *config.Config23+ Config *config.Config
23- InitialURL string // URL to open on startup (optional)24+ InitialURL string // URL to open on startup (optional)
25+ RestoreSessionID string // Session ID to restore on startup (optional)
24 26
25 // Theme management27 // Theme management
26 Theme *theme.Manager28 Theme *theme.Manager
@@ -53,6 +55,12 @@ type Dependencies struct {
53 FaviconService *favicon.Service55 FaviconService *favicon.Service
54 FilterManager *filtering.Manager56 FilterManager *filtering.Manager
55 IdleInhibitor port.IdleInhibitor57 IdleInhibitor port.IdleInhibitor
58+
59+ // Session management
60+ SessionStateRepo repository.SessionStateRepository
61+ SessionRepo repository.SessionRepository
62+ CurrentSessionID entity.SessionID
63+ SnapshotUC *usecase.SnapshotSessionUseCase
56 }64 }
57 65
58 // Validate checks that all required dependencies are set.66 // Validate checks that all required dependencies are set.
M internal/ui/dispatcher/keyboard.go +16 -0
@@ -26,6 +26,7 @@ type KeyboardDispatcher struct {
26 onFindNext func(ctx context.Context) error26 onFindNext func(ctx context.Context) error
27 onFindPrev func(ctx context.Context) error27 onFindPrev func(ctx context.Context) error
28 onFindClose func(ctx context.Context) error28 onFindClose func(ctx context.Context) error
29+ onSessionOpen func(ctx context.Context) error
29 }30 }
30 31
31 // NewKeyboardDispatcher creates a new KeyboardDispatcher.32 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -76,6 +77,11 @@ func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error)
76 d.onFindClose = fn77 d.onFindClose = fn
77 }78 }
78 79
80+// SetOnSessionOpen sets the callback for opening the session manager.
81+func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error) {
82+ d.onSessionOpen = fn
83+}
84+
79 func (d *KeyboardDispatcher) initActionHandlers() {85 func (d *KeyboardDispatcher) initActionHandlers() {
80 const (86 const (
81 firstTabIndex = 087 firstTabIndex = 0
@@ -144,6 +150,8 @@ func (d *KeyboardDispatcher) initActionHandlers() {
144 },150 },
145 // Clipboard151 // Clipboard
146 input.ActionCopyURL: d.handleCopyURL,152 input.ActionCopyURL: d.handleCopyURL,
153+ // Session management
154+ input.ActionOpenSessionManager: d.handleSessionOpen,
147 // Application155 // Application
148 input.ActionQuit: d.handleQuit,156 input.ActionQuit: d.handleQuit,
149 }157 }
@@ -194,6 +202,14 @@ func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error {
194 return nil202 return nil
195 }203 }
196 204
205+func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
206+ if d.onSessionOpen != nil {
207+ return d.onSessionOpen(ctx)
208+ }
209+ logging.FromContext(ctx).Debug().Msg("session open action (no handler)")
210+ return nil
211+}
212+
197 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {213 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
198 if ctx == nil {214 if ctx == nil {
199 return fmt.Errorf("missing context")215 return fmt.Errorf("missing context")
M internal/ui/focus/borders.go +8 -2
@@ -10,8 +10,9 @@ import (
10 10
11 // CSS classes for mode borders.11 // CSS classes for mode borders.
12 const (12 const (
13- paneModeClass = "pane-mode-active"13+ paneModeClass = "pane-mode-active"
14- tabModeClass = "tab-mode-active"14+ tabModeClass = "tab-mode-active"
15+ sessionModeClass = "session-mode-active"
15 )16 )
16 17
17 // BorderManager manages mode indicator borders on the workspace container.18 // BorderManager manages mode indicator borders on the workspace container.
@@ -58,6 +59,8 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode)
58 bm.borderOverlay.RemoveCssClass(paneModeClass)59 bm.borderOverlay.RemoveCssClass(paneModeClass)
59 case input.ModeTab:60 case input.ModeTab:
60 bm.borderOverlay.RemoveCssClass(tabModeClass)61 bm.borderOverlay.RemoveCssClass(tabModeClass)
62+ case input.ModeSession:
63+ bm.borderOverlay.RemoveCssClass(sessionModeClass)
61 }64 }
62 65
63 // Add new mode class and set visibility66 // Add new mode class and set visibility
@@ -68,6 +71,9 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode)
68 case input.ModeTab:71 case input.ModeTab:
69 bm.borderOverlay.AddCssClass(tabModeClass)72 bm.borderOverlay.AddCssClass(tabModeClass)
70 bm.borderOverlay.SetVisible(true)73 bm.borderOverlay.SetVisible(true)
74+ case input.ModeSession:
75+ bm.borderOverlay.AddCssClass(sessionModeClass)
76+ bm.borderOverlay.SetVisible(true)
71 case input.ModeNormal:77 case input.ModeNormal:
72 bm.borderOverlay.SetVisible(false)78 bm.borderOverlay.SetVisible(false)
73 }79 }
M internal/ui/input/global_shortcuts.go +7 -0
@@ -84,6 +84,13 @@ func NewGlobalShortcutHandler(
84 Str("action", string(ActionSwitchLastTab)).84 Str("action", string(ActionSwitchLastTab)).
85 Msg("registered global shortcut")85 Msg("registered global shortcut")
86 86
87+ // Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus)
88+ h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
89+ log.Trace().
90+ Uint("keyval", uint(gdk.KEY_s)).
91+ Str("action", string(ActionOpenSessionManager)).
92+ Msg("registered global shortcut")
93+
87 // Attach to window94 // Attach to window
88 window.AddController(&h.controller.EventController)95 window.AddController(&h.controller.EventController)
89 96
M internal/ui/input/keyboard.go +18 -9
@@ -21,7 +21,7 @@ type ActionHandler func(ctx context.Context, action Action) error
21 type KeyboardHandler struct {21 type KeyboardHandler struct {
22 shortcuts *ShortcutSet22 shortcuts *ShortcutSet
23 modal *ModalState23 modal *ModalState
24- cfg *config.WorkspaceConfig24+ cfg *config.Config
25 25
26 // Action handler callback26 // Action handler callback
27 onAction ActionHandler27 onAction ActionHandler
@@ -39,10 +39,7 @@ type KeyboardHandler struct {
39 }39 }
40 40
41 // NewKeyboardHandler creates a new keyboard handler.41 // NewKeyboardHandler creates a new keyboard handler.
42-func NewKeyboardHandler(42+func NewKeyboardHandler(ctx context.Context, cfg *config.Config) *KeyboardHandler {
43- ctx context.Context,
44- cfg *config.WorkspaceConfig,
45-) *KeyboardHandler {
46 log := logging.FromContext(ctx)43 log := logging.FromContext(ctx)
47 44
48 log.Debug().Msg("creating keyboard handler")45 log.Debug().Msg("creating keyboard handler")
@@ -157,15 +154,20 @@ func (h *KeyboardHandler) handleKeyPress(keyval uint, state gdk.ModifierType) bo
157 // Handle special mode actions first154 // Handle special mode actions first
158 switch action {155 switch action {
159 case ActionEnterTabMode:156 case ActionEnterTabMode:
160- timeout := time.Duration(h.cfg.TabMode.TimeoutMilliseconds) * time.Millisecond157+ timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
161 h.modal.EnterTabMode(h.ctx, timeout)158 h.modal.EnterTabMode(h.ctx, timeout)
162 return true159 return true
163 160
164 case ActionEnterPaneMode:161 case ActionEnterPaneMode:
165- timeout := time.Duration(h.cfg.PaneMode.TimeoutMilliseconds) * time.Millisecond162+ timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
166 h.modal.EnterPaneMode(h.ctx, timeout)163 h.modal.EnterPaneMode(h.ctx, timeout)
167 return true164 return true
168 165
166+ case ActionEnterSessionMode:
167+ timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
168+ h.modal.EnterSessionMode(h.ctx, timeout)
169+ return true
170+
169 case ActionExitMode:171 case ActionExitMode:
170 h.modal.ExitMode(h.ctx)172 h.modal.ExitMode(h.ctx)
171 return true173 return true
@@ -197,17 +199,24 @@ func (h *KeyboardHandler) handleKeyPress(keyval uint, state gdk.ModifierType) bo
197 // EnterTabMode programmatically enters tab mode.199 // EnterTabMode programmatically enters tab mode.
198 // Useful for testing or programmatic mode changes.200 // Useful for testing or programmatic mode changes.
199 func (h *KeyboardHandler) EnterTabMode() {201 func (h *KeyboardHandler) EnterTabMode() {
200- timeout := time.Duration(h.cfg.TabMode.TimeoutMilliseconds) * time.Millisecond202+ timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
201 h.modal.EnterTabMode(h.ctx, timeout)203 h.modal.EnterTabMode(h.ctx, timeout)
202 }204 }
203 205
204 // EnterPaneMode programmatically enters pane mode.206 // EnterPaneMode programmatically enters pane mode.
205 // Useful for testing or programmatic mode changes.207 // Useful for testing or programmatic mode changes.
206 func (h *KeyboardHandler) EnterPaneMode() {208 func (h *KeyboardHandler) EnterPaneMode() {
207- timeout := time.Duration(h.cfg.PaneMode.TimeoutMilliseconds) * time.Millisecond209+ timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
208 h.modal.EnterPaneMode(h.ctx, timeout)210 h.modal.EnterPaneMode(h.ctx, timeout)
209 }211 }
210 212
213+// EnterSessionMode programmatically enters session mode.
214+// Useful for testing or programmatic mode changes.
215+func (h *KeyboardHandler) EnterSessionMode() {
216+ timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
217+ h.modal.EnterSessionMode(h.ctx, timeout)
218+}
219+
211 // ExitMode programmatically exits modal mode.220 // ExitMode programmatically exits modal mode.
212 // Useful for testing or programmatic mode changes.221 // Useful for testing or programmatic mode changes.
213 func (h *KeyboardHandler) ExitMode() {222 func (h *KeyboardHandler) ExitMode() {
M internal/ui/input/modal.go +39 -0
@@ -18,6 +18,8 @@ const (
18 ModeTab18 ModeTab
19 // ModePane is the modal pane management mode.19 // ModePane is the modal pane management mode.
20 ModePane20 ModePane
21+ // ModeSession is the modal session management mode.
22+ ModeSession
21 )23 )
22 24
23 // String returns a human-readable mode name.25 // String returns a human-readable mode name.
@@ -29,6 +31,8 @@ func (m Mode) String() string {
29 return "tab"31 return "tab"
30 case ModePane:32 case ModePane:
31 return "pane"33 return "pane"
34+ case ModeSession:
35+ return "session"
32 default:36 default:
33 return "unknown"37 return "unknown"
34 }38 }
@@ -132,6 +136,41 @@ func (m *ModalState) EnterPaneMode(ctx context.Context, timeout time.Duration) {
132 }136 }
133 }137 }
134 138
139+// EnterSessionMode switches to session mode with an optional timeout.
140+// If timeout is 0, the mode stays until explicitly exited.
141+func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration) {
142+ log := logging.FromContext(ctx)
143+ m.mu.Lock()
144+ defer m.mu.Unlock()
145+
146+ if m.mode == ModeSession {
147+ // Already in session mode, just reset timeout
148+ m.resetTimeoutLocked(ctx, timeout)
149+ return
150+ }
151+
152+ m.cancelTimerLocked()
153+ oldMode := m.mode
154+ m.mode = ModeSession
155+ m.timeout = timeout
156+
157+ if timeout > 0 {
158+ m.timer = time.AfterFunc(timeout, func() {
159+ m.ExitMode(ctx)
160+ })
161+ }
162+
163+ log.Debug().
164+ Str("from", oldMode.String()).
165+ Str("to", m.mode.String()).
166+ Dur("timeout", timeout).
167+ Msg("entered session mode")
168+
169+ if m.onModeChange != nil {
170+ m.onModeChange(oldMode, m.mode)
171+ }
172+}
173+
135 // ExitMode returns to normal mode.174 // ExitMode returns to normal mode.
136 func (m *ModalState) ExitMode(ctx context.Context) {175 func (m *ModalState) ExitMode(ctx context.Context) {
137 log := logging.FromContext(ctx)176 log := logging.FromContext(ctx)
M internal/ui/input/shortcuts.go +58 -21
@@ -93,9 +93,10 @@ type Action string
93 // Predefined actions for the keyboard system.93 // Predefined actions for the keyboard system.
94 const (94 const (
95 // Mode management95 // Mode management
96- ActionEnterTabMode Action = "enter_tab_mode"96+ ActionEnterTabMode Action = "enter_tab_mode"
97- ActionEnterPaneMode Action = "enter_pane_mode"97+ ActionEnterPaneMode Action = "enter_pane_mode"
98- ActionExitMode Action = "exit_mode"98+ ActionEnterSessionMode Action = "enter_session_mode"
99+ ActionExitMode Action = "exit_mode"
99 100
100 // Tab actions (global and modal)101 // Tab actions (global and modal)
101 ActionNewTab Action = "new_tab"102 ActionNewTab Action = "new_tab"
@@ -157,6 +158,9 @@ const (
157 // Clipboard158 // Clipboard
158 ActionCopyURL Action = "copy_url"159 ActionCopyURL Action = "copy_url"
159 160
161+ // Session management
162+ ActionOpenSessionManager Action = "open_session_manager"
163+
160 // Application164 // Application
161 ActionQuit Action = "quit"165 ActionQuit Action = "quit"
162 )166 )
@@ -172,30 +176,39 @@ type ShortcutSet struct {
172 TabMode ShortcutTable176 TabMode ShortcutTable
173 // PaneMode shortcuts are only active in pane mode.177 // PaneMode shortcuts are only active in pane mode.
174 PaneMode ShortcutTable178 PaneMode ShortcutTable
179+ // SessionMode shortcuts are only active in session mode.
180+ SessionMode ShortcutTable
175 }181 }
176 182
177-// NewShortcutSet creates a ShortcutSet from the workspace configuration.183+// NewShortcutSet creates a ShortcutSet from the configuration.
178-func NewShortcutSet(ctx context.Context, cfg *config.WorkspaceConfig) *ShortcutSet {184+func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet {
179 log := logging.FromContext(ctx)185 log := logging.FromContext(ctx)
180 set := &ShortcutSet{186 set := &ShortcutSet{
181- Global: make(ShortcutTable),187+ Global: make(ShortcutTable),
182- TabMode: make(ShortcutTable),188+ TabMode: make(ShortcutTable),
183- PaneMode: make(ShortcutTable),189+ PaneMode: make(ShortcutTable),
190+ SessionMode: make(ShortcutTable),
184 }191 }
185 192
186 set.buildGlobalShortcuts(ctx, cfg)193 set.buildGlobalShortcuts(ctx, cfg)
187- set.buildTabModeShortcuts(ctx, cfg)194+ set.buildTabModeShortcuts(ctx, &cfg.Workspace)
188- set.buildPaneModeShortcuts(ctx, cfg)195+ set.buildPaneModeShortcuts(ctx, &cfg.Workspace)
196+ set.buildSessionModeShortcuts(ctx, &cfg.Session)
189 197
190- log.Debug().Int("global", len(set.Global)).Int("tab", len(set.TabMode)).Int("pane", len(set.PaneMode)).Msg("shortcuts registered")198+ log.Debug().
199+ Int("global", len(set.Global)).
200+ Int("tab", len(set.TabMode)).
201+ Int("pane", len(set.PaneMode)).
202+ Int("session", len(set.SessionMode)).
203+ Msg("shortcuts registered")
191 204
192 return set205 return set
193 }206 }
194 207
195 // buildGlobalShortcuts populates global shortcuts from config.208 // buildGlobalShortcuts populates global shortcuts from config.
196-func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {209+func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Config) {
197 s.registerActivationShortcuts(ctx, cfg)210 s.registerActivationShortcuts(ctx, cfg)
198- s.registerConfiguredShortcuts(cfg)211+ s.registerConfiguredShortcuts(&cfg.Workspace)
199 s.registerStandardShortcuts()212 s.registerStandardShortcuts()
200 s.registerPaneNavigationShortcuts()213 s.registerPaneNavigationShortcuts()
201 s.registerTabSwitchShortcuts()214 s.registerTabSwitchShortcuts()
@@ -211,27 +224,42 @@ func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *config.Wo
211 s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane")224 s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane")
212 }225 }
213 226
214-func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {227+// buildSessionModeShortcuts populates session mode shortcuts from config.
228+func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *config.SessionConfig) {
229+ s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session")
230+}
231+
232+func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.Config) {
215 log := logging.FromContext(ctx)233 log := logging.FromContext(ctx)
216- if binding, ok := ParseKeyString(cfg.TabMode.ActivationShortcut); ok {234+ if binding, ok := ParseKeyString(cfg.Workspace.TabMode.ActivationShortcut); ok {
217 s.Global[binding] = ActionEnterTabMode235 s.Global[binding] = ActionEnterTabMode
218 log.Trace().236 log.Trace().
219- Str("shortcut", cfg.TabMode.ActivationShortcut).237+ Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut).
220 Uint("keyval", binding.Keyval).238 Uint("keyval", binding.Keyval).
221 Uint("mod", uint(binding.Modifiers)).239 Uint("mod", uint(binding.Modifiers)).
222 Msg("tab mode activation registered")240 Msg("tab mode activation registered")
223 } else {241 } else {
224- log.Warn().Str("shortcut", cfg.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")242+ log.Warn().Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
225 }243 }
226- if binding, ok := ParseKeyString(cfg.PaneMode.ActivationShortcut); ok {244+ if binding, ok := ParseKeyString(cfg.Workspace.PaneMode.ActivationShortcut); ok {
227 s.Global[binding] = ActionEnterPaneMode245 s.Global[binding] = ActionEnterPaneMode
228 log.Trace().246 log.Trace().
229- Str("shortcut", cfg.PaneMode.ActivationShortcut).247+ Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut).
230 Uint("keyval", binding.Keyval).248 Uint("keyval", binding.Keyval).
231 Uint("mod", uint(binding.Modifiers)).249 Uint("mod", uint(binding.Modifiers)).
232 Msg("pane mode activation registered")250 Msg("pane mode activation registered")
233 } else {251 } else {
234- log.Warn().Str("shortcut", cfg.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")252+ log.Warn().Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
253+ }
254+ if binding, ok := ParseKeyString(cfg.Session.SessionMode.ActivationShortcut); ok {
255+ s.Global[binding] = ActionEnterSessionMode
256+ log.Trace().
257+ Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).
258+ Uint("keyval", binding.Keyval).
259+ Uint("mod", uint(binding.Modifiers)).
260+ Msg("session mode activation registered")
261+ } else {
262+ log.Warn().Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
235 }263 }
236 }264 }
237 265
@@ -278,6 +306,8 @@ func (s *ShortcutSet) registerStandardShortcuts() {
278 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit306 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
279 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen307 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
280 s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL308 s.Global[KeyBinding{uint(gdk.KEY_C), ModCtrl | ModShift}] = ActionCopyURL
309+ // Session management - direct shortcut to open session manager
310+ s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager
281 }311 }
282 312
283 func (s *ShortcutSet) registerPaneNavigationShortcuts() {313 func (s *ShortcutSet) registerPaneNavigationShortcuts() {
@@ -379,6 +409,10 @@ func mapConfigAction(configAction string) Action {
379 case "stack-nav-down", "stack-down":409 case "stack-nav-down", "stack-down":
380 return ActionStackNavDown410 return ActionStackNavDown
381 411
412+ // Session actions
413+ case "session-manager":
414+ return ActionOpenSessionManager
415+
382 default:416 default:
383 return ""417 return ""
384 }418 }
@@ -454,6 +488,8 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
454 modeTable = s.TabMode488 modeTable = s.TabMode
455 case ModePane:489 case ModePane:
456 modeTable = s.PaneMode490 modeTable = s.PaneMode
491+ case ModeSession:
492+ modeTable = s.SessionMode
457 }493 }
458 494
459 if modeTable != nil {495 if modeTable != nil {
@@ -475,7 +511,8 @@ func ShouldAutoExitMode(action Action) bool {
475 switch action {511 switch action {
476 case ActionNewTab, ActionCloseTab, ActionRenameTab,512 case ActionNewTab, ActionCloseTab, ActionRenameTab,
477 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,513 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
478- ActionClosePane, ActionStackPane:514+ ActionClosePane, ActionStackPane,
515+ ActionOpenSessionManager:
479 return true516 return true
480 default:517 default:
481 return false518 return false
M internal/ui/theme/css.go +166 -0
@@ -106,6 +106,10 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
106 106
107 // Toaster styling107 // Toaster styling
108 sb.WriteString(generateToasterCSS(p))108 sb.WriteString(generateToasterCSS(p))
109+ sb.WriteString("\n")
110+
111+ // Session manager styling
112+ sb.WriteString(generateSessionManagerCSS(p))
109 113
110 return sb.String()114 return sb.String()
111 }115 }
@@ -553,6 +557,168 @@ progressbar.osd progress {
553 `557 `
554 }558 }
555 559
560+// generateSessionManagerCSS creates session manager modal styles.
561+// Uses em units for scalable UI, matches omnibox styling patterns.
562+func generateSessionManagerCSS(p Palette) string {
563+ return `/* ===== Session Manager Styling ===== */
564+
565+/* Session manager outer container - for positioning in overlay */
566+.session-manager-outer {
567+ /* Positioning is handled via SetHalign/SetValign in Go */
568+}
569+
570+/* Session manager main container - the visible popup */
571+.session-manager-container {
572+ background-color: var(--surface-variant);
573+ border: 0.0625em solid var(--border);
574+ border-radius: 0.1875em;
575+ padding: 0;
576+ min-width: 28em;
577+ max-width: 40em;
578+}
579+
580+/* Header with title */
581+.session-manager-header {
582+ background-color: shade(var(--surface-variant), 1.1);
583+ border-bottom: 0.0625em solid var(--border);
584+ padding: 0.5em 0.75em;
585+}
586+
587+.session-manager-title {
588+ font-size: 0.9375em;
589+ font-weight: 600;
590+ color: var(--text);
591+}
592+
593+/* Scrolled window for session list */
594+.session-manager-scrolled {
595+ background-color: shade(var(--surface-variant), 0.95);
596+}
597+
598+/* List box */
599+.session-manager-list {
600+ background-color: transparent;
601+}
602+
603+/* Session rows */
604+.session-manager-row {
605+ padding: 0.5em 0.75em;
606+ margin: 0;
607+ border-radius: 0;
608+ border-left: 0.1875em solid transparent;
609+ border-bottom: 0.0625em solid alpha(var(--border), 0.5);
610+ transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;
611+ min-height: 2.75em;
612+}
613+
614+.session-manager-row:last-child {
615+ border-bottom: none;
616+}
617+
618+.session-manager-row:hover {
619+ background-color: alpha(var(--accent), 0.12);
620+ border-left: 0.1875em solid var(--accent);
621+}
622+
623+.session-manager-row:selected,
624+row:selected .session-manager-row {
625+ background-color: alpha(var(--accent), 0.2);
626+ border-left: 0.1875em solid var(--accent);
627+}
628+
629+/* Status indicator dot */
630+.session-status {
631+ font-size: 0.875em;
632+ min-width: 1em;
633+ margin-right: 0.5em;
634+}
635+
636+/* Current session - accent color dot */
637+.session-current .session-status {
638+ color: var(--accent);
639+}
640+
641+/* Active session (other instance) - muted dot */
642+.session-active .session-status {
643+ color: var(--muted);
644+}
645+
646+/* Exited session - dimmed appearance with subtle stop icon */
647+.session-exited {
648+ opacity: 0.75;
649+}
650+
651+.session-exited .session-status {
652+ color: var(--error);
653+ opacity: 0.5;
654+}
655+
656+/* Session ID labels */
657+.session-id-short {
658+ font-size: 0.875em;
659+ font-weight: 600;
660+ color: var(--accent);
661+ font-family: var(--font-mono);
662+}
663+
664+.session-id-prefix {
665+ font-size: 0.875em;
666+ font-weight: 400;
667+ color: var(--muted);
668+ font-family: var(--font-mono);
669+}
670+
671+.session-id-badge {
672+ font-size: 0.75em;
673+ font-weight: 500;
674+ color: var(--accent);
675+ margin-left: 0.5em;
676+}
677+
678+/* Tab/pane count label */
679+.session-count {
680+ font-size: 0.75em;
681+ color: var(--muted);
682+}
683+
684+/* Relative time label */
685+.session-time {
686+ font-size: 0.75em;
687+ color: var(--muted);
688+ margin-left: auto;
689+}
690+
691+/* Section divider for EXITED sessions */
692+.session-divider {
693+ font-size: 0.6875em;
694+ font-weight: 600;
695+ color: var(--muted);
696+ text-transform: uppercase;
697+ letter-spacing: 0.05em;
698+ padding: 0.5em 0.75em 0.25em 0.75em;
699+ background-color: alpha(var(--border), 0.3);
700+ border-bottom: 0.0625em solid var(--border);
701+}
702+
703+/* Footer with keyboard shortcuts */
704+.session-manager-footer {
705+ background-color: shade(var(--surface-variant), 0.9);
706+ border-top: 0.0625em solid var(--border);
707+ padding: 0.375em 0.75em;
708+ font-size: 0.6875em;
709+ color: var(--muted);
710+ font-family: var(--font-mono);
711+}
712+
713+/* Session mode border (purple) */
714+.session-mode-active {
715+ background-color: transparent;
716+ box-shadow: inset 0 0 0 0.25em #9B59B6;
717+ border-radius: 0;
718+}
719+`
720+}
721+
556 // generateToasterCSS creates toast notification styles.722 // generateToasterCSS creates toast notification styles.
557 // Uses em units for scalable UI.723 // Uses em units for scalable UI.
558 func generateToasterCSS(p Palette) string {724 func generateToasterCSS(p Palette) string {

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