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:
99 pkgname: mocks
1010 interfaces:
1111 SessionLogger: {}
12+ SessionSpawner: {}
13+ TabListProvider: {}
1214 WebView: {}
1315 WebViewFactory: {}
1416 WebViewPool: {}
@@ -23,6 +25,7 @@ packages:
2325 FolderRepository: {}
2426 HistoryRepository: {}
2527 SessionRepository: {}
28+ SessionStateRepository: {}
2629 TagRepository: {}
2730 ZoomRepository: {}
2831 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.
44
55 ## [Unreleased]
66
7+### Added
8+- **Session management & resurrection**: Zellij-inspired session management system allowing users to save, list, and restore browser sessions.
9+ - **Automatic session snapshots**: Debounced state saving (configurable interval, default 5s) captures tabs, panes, splits, stacks, URLs, titles, and zoom levels.
10+ - **Session Manager modal**: Access via `Ctrl+O → s/w` or `Ctrl+Shift+S` to browse and restore sessions with inline preview of tabs and panes.
11+ - **Session mode**: New modal mode (`Ctrl+O`) with purple border indicator, similar to pane/tab modes.
12+ - **CLI session commands**: `dumber sessions` opens interactive TUI browser; `dumber sessions list` shows saved sessions; `dumber sessions restore <id>` resurrects a session in a new window; `dumber sessions delete <id>` removes a session.
13+ - **Session purging**: Integrated into `dumber purge` command for cleaning up old session data.
14+ - **Configuration options**: `session.auto_restore`, `session.snapshot_interval_ms`, `session.max_exited_sessions`, `session.max_listed_sessions`, plus session mode shortcuts and styling.
15+
716 ### Fixed
817 - **Idle inhibition leak on pane close**: Fixed D-Bus idle inhibit request not being released when closing a pane/tab while in fullscreen or playing audio. The inhibition would remain active until the app exited. Now properly tracks fullscreen and audio state and releases inhibition before destroying WebView.
918 - **Audio-based idle inhibition**: Extended idle inhibition to also activate when a page is playing audio (e.g., video/music playback), not just in fullscreen mode. Uses WebKit's `notify::is-playing-audio` signal. The inhibitor uses refcounting, so both fullscreen and audio can be active simultaneously.
M README.md +28 -0
@@ -23,6 +23,7 @@ A dumb browser that works like your favorite terminal multiplexer.
2323 - **Customizable themes**: Light and dark palettes with semantic color tokens.
2424 - **Persistent storage**: SQLite for history, zoom levels, and settings.
2525 - **Live configuration**: Single config file with hot reload when possible.
26+- **Session management**: Zellij-style session save/restore with automatic snapshots and resurrection.
2627
2728 ## Status
2829 Dumber recently completed a full rewrite in pure Go using the `puregotk` and `puregotk-webkit` libraries.
@@ -79,6 +80,14 @@ Early development with regular releases. Core features work well for daily use b
7980 | **Alt+Arrow Keys** | Navigate Panes | Move focus between panes |
8081 | **Alt+Up/Down** | Navigate Stack | Navigate between stacked panes |
8182
83+#### Session Management
84+| Shortcut | Action | Notes |
85+|----------|--------|-------|
86+| **Ctrl+O** | Enter Session Mode | Modal mode for session operations |
87+| **Ctrl+Shift+S** | Open Session Manager | Direct access to session browser |
88+| **S** / **W** (in session mode) | Open Session Manager | Browse and restore sessions |
89+| **Escape** (in session mode) | Exit Session Mode | Return to normal navigation |
90+
8291 ### Mouse Controls
8392 | Action | Result | Notes |
8493 |--------|---------|-------|
@@ -231,6 +240,12 @@ sudo apt install va-driver-all
231240 - `dumber purge --force` # purge all data (no confirmation)
232241 - `dumber purge -d -H -c` # purge database and both caches
233242 - `dumber purge --browser-data` # purge WebKit data (cookies, etc.)
243+- Manage sessions:
244+ - `dumber sessions` # interactive session browser (TUI)
245+ - `dumber sessions list` # list saved sessions
246+ - `dumber sessions list --json` # output sessions as JSON
247+ - `dumber sessions restore <id>` # restore a session in new window
248+ - `dumber sessions delete <id>` # delete a saved session
234249 - Manage logs:
235250 - `dumber logs list` # list available log files
236251 - `dumber logs tail` # tail current log file
@@ -276,6 +291,19 @@ Split your browser window into multiple panes, each running independent web sess
276291 3. **Navigate**: Use `Alt+Arrow Keys` to move focus between split panes, or `Alt+Up/Down` to navigate within stacked panes
277292 4. **Close**: Press `X` in pane mode to close the current pane
278293
294+## Session Management
295+
296+Dumber automatically saves your browser session (tabs, panes, splits, URLs) and allows you to restore previous sessions.
297+
298+### How it Works
299+1. **Automatic Snapshots**: Session state is saved automatically (debounced, every 5s by default) and on graceful shutdown
300+2. **Session Manager**: Press `Ctrl+O → s` or `Ctrl+Shift+S` to open the session manager modal
301+3. **Browse Sessions**: View active and exited sessions with tab/pane previews
302+4. **Restore**: Select an exited session to restore it in a new browser window
303+5. **CLI Access**: Use `dumber sessions` for an interactive TUI, or `dumber sessions list/restore/delete` for scripting
304+
305+Session mode provides visual feedback with a purple border indicator.
306+
279307 ## Configuration
280308
281309 Configuration file: `~/.config/dumber/config.toml` (supports TOML, JSON, YAML)
M cmd/dumber/main.go +99 -60
@@ -41,6 +41,9 @@ var (
4141 // initialURL holds the URL to open on startup (from browse command).
4242 var initialURL string
4343
44+// restoreSessionID holds the session ID to restore on startup.
45+var restoreSessionID string
46+
4447 func main() {
4548 // Run GUI mode for browse command
4649 if len(os.Args) > 1 && os.Args[1] == "browse" {
@@ -48,6 +51,8 @@ func main() {
4851 if len(os.Args) > 2 {
4952 initialURL = os.Args[2]
5053 }
54+ // Check for session restoration via environment variable
55+ restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
5156 // Strip "browse" and URL from args so GTK doesn't see them
5257 os.Args = os.Args[:1]
5358 os.Exit(runGUI())
@@ -102,27 +107,47 @@ func runGUI() int {
102107 _ = browserSession.End(sessionCtx)
103108 }()
104109
105- logger := browserSession.Logger
106110 ctx = sessionCtx
111+ log := logging.FromContext(ctx)
107112
108- checkRuntimeRequirements(ctx, cfg, logger)
109- checkMediaRequirements(ctx, cfg, logger)
113+ checkRuntimeRequirements(ctx, cfg, *log)
114+ checkMediaRequirements(ctx, cfg, *log)
110115
111116 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)
113118 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+
115140 idleInhibitor := idle.NewPortalInhibitor(ctx)
116141 defer func() {
117142 if idleInhibitor != nil {
118143 _ = idleInhibitor.Close()
119144 }
120145 }()
121- uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor)
146+ uiDeps := buildUIDependencies(ctx, cfg, themeManager, &stack, repos, useCases, idleInhibitor, browserSession.Session.ID)
122147
123148 app, err := ui.New(uiDeps)
124149 if err != nil {
125- logger.Error().Err(err).Msg("failed to create application")
150+ log.Error().Err(err).Msg("failed to create application")
126151 return 1
127152 }
128153
@@ -134,7 +159,7 @@ func runGUI() int {
134159 defer signal.Stop(sigCh)
135160 go func() {
136161 <-sigCh
137- logger.Info().Msg("received interrupt, quitting")
162+ log.Info().Msg("received interrupt, quitting")
138163 app.Quit()
139164 }()
140165
@@ -226,37 +251,43 @@ func openDatabase(ctx context.Context, logger zerolog.Logger, dataDir string) (*
226251
227252 // repositories groups infrastructure layer repository implementations.
228253 type repositories struct {
229- history repository.HistoryRepository
230- favorite repository.FavoriteRepository
231- folder repository.FolderRepository
232- tag repository.TagRepository
233- zoom repository.ZoomRepository
254+ history repository.HistoryRepository
255+ favorite repository.FavoriteRepository
256+ folder repository.FolderRepository
257+ tag repository.TagRepository
258+ zoom repository.ZoomRepository
259+ session repository.SessionRepository
260+ sessionState repository.SessionStateRepository
234261 }
235262
236263 func createRepositories(db *sql.DB) *repositories {
237264 return &repositories{
238- history: sqlite.NewHistoryRepository(db),
239- favorite: sqlite.NewFavoriteRepository(db),
240- folder: sqlite.NewFolderRepository(db),
241- tag: sqlite.NewTagRepository(db),
242- zoom: sqlite.NewZoomRepository(db),
265+ history: sqlite.NewHistoryRepository(db),
266+ favorite: sqlite.NewFavoriteRepository(db),
267+ folder: sqlite.NewFolderRepository(db),
268+ tag: sqlite.NewTagRepository(db),
269+ zoom: sqlite.NewZoomRepository(db),
270+ session: sqlite.NewSessionRepository(db),
271+ sessionState: sqlite.NewSessionStateRepository(db),
243272 }
244273 }
245274
246275 // useCases groups application layer use case implementations.
247276 type useCases struct {
248- tabs *usecase.ManageTabsUseCase
249- panes *usecase.ManagePanesUseCase
250- history *usecase.SearchHistoryUseCase
251- favorites *usecase.ManageFavoritesUseCase
252- zoom *usecase.ManageZoomUseCase
253- navigate *usecase.NavigateUseCase
254- copyURL *usecase.CopyURLUseCase
255- clipboard port.Clipboard
256- favicon *favicon.Service
277+ tabs *usecase.ManageTabsUseCase
278+ panes *usecase.ManagePanesUseCase
279+ history *usecase.SearchHistoryUseCase
280+ favorites *usecase.ManageFavoritesUseCase
281+ zoom *usecase.ManageZoomUseCase
282+ navigate *usecase.NavigateUseCase
283+ copyURL *usecase.CopyURLUseCase
284+ snapshot *usecase.SnapshotSessionUseCase
285+ lastRestorable *usecase.GetLastRestorableSessionUseCase
286+ clipboard port.Clipboard
287+ favicon *favicon.Service
257288 }
258289
259-func createUseCases(repos *repositories, cfg *config.Config) *useCases {
290+func createUseCases(repos *repositories, cfg *config.Config, stateDir string) *useCases {
260291 const idAlphabetSize = 26
261292 idCounter := uint64(0)
262293 idGenerator := func() string {
@@ -272,15 +303,17 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
272303 zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)
273304
274305 return &useCases{
275- tabs: usecase.NewManageTabsUseCase(idGenerator),
276- panes: usecase.NewManagePanesUseCase(idGenerator),
277- history: usecase.NewSearchHistoryUseCase(repos.history),
278- favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
279- zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
280- navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),
281- copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),
282- clipboard: clipboardAdapter,
283- favicon: favicon.NewService(faviconCacheDir),
306+ tabs: usecase.NewManageTabsUseCase(idGenerator),
307+ panes: usecase.NewManagePanesUseCase(idGenerator),
308+ history: usecase.NewSearchHistoryUseCase(repos.history),
309+ favorites: usecase.NewManageFavoritesUseCase(repos.favorite, repos.folder, repos.tag),
310+ zoom: usecase.NewManageZoomUseCase(repos.zoom, defaultZoom, zoomCache),
311+ navigate: usecase.NewNavigateUseCase(repos.history, repos.zoom, defaultZoom),
312+ copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),
313+ snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),
314+ lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir),
315+ clipboard: clipboardAdapter,
316+ favicon: favicon.NewService(faviconCacheDir),
284317 }
285318 }
286319
@@ -292,30 +325,36 @@ func buildUIDependencies(
292325 repos *repositories,
293326 uc *useCases,
294327 idleInhibitor port.IdleInhibitor,
328+ currentSessionID entity.SessionID,
295329 ) *ui.Dependencies {
296330 return &ui.Dependencies{
297- Ctx: ctx,
298- Config: cfg,
299- InitialURL: initialURL,
300- Theme: themeManager,
301- WebContext: stack.Context,
302- Pool: stack.Pool,
303- Settings: stack.Settings,
304- Injector: stack.Injector,
305- MessageRouter: stack.MessageRouter,
306- FilterManager: stack.FilterManager,
307- HistoryRepo: repos.history,
308- FavoriteRepo: repos.favorite,
309- ZoomRepo: repos.zoom,
310- TabsUC: uc.tabs,
311- PanesUC: uc.panes,
312- HistoryUC: uc.history,
313- FavoritesUC: uc.favorites,
314- ZoomUC: uc.zoom,
315- NavigateUC: uc.navigate,
316- CopyURLUC: uc.copyURL,
317- Clipboard: uc.clipboard,
318- FaviconService: uc.favicon,
319- IdleInhibitor: idleInhibitor,
331+ Ctx: ctx,
332+ Config: cfg,
333+ InitialURL: initialURL,
334+ RestoreSessionID: restoreSessionID,
335+ Theme: themeManager,
336+ WebContext: stack.Context,
337+ Pool: stack.Pool,
338+ Settings: stack.Settings,
339+ Injector: stack.Injector,
340+ MessageRouter: stack.MessageRouter,
341+ FilterManager: stack.FilterManager,
342+ HistoryRepo: repos.history,
343+ FavoriteRepo: repos.favorite,
344+ ZoomRepo: repos.zoom,
345+ TabsUC: uc.tabs,
346+ PanesUC: uc.panes,
347+ HistoryUC: uc.history,
348+ FavoritesUC: uc.favorites,
349+ ZoomUC: uc.zoom,
350+ NavigateUC: uc.navigate,
351+ CopyURLUC: uc.copyURL,
352+ Clipboard: uc.clipboard,
353+ FaviconService: uc.favicon,
354+ IdleInhibitor: idleInhibitor,
355+ SessionRepo: repos.session,
356+ SessionStateRepo: repos.sessionState,
357+ CurrentSessionID: currentSessionID,
358+ SnapshotUC: uc.snapshot,
320359 }
321360 }
M docs/CONFIG.md +36 -0
@@ -275,8 +275,44 @@ cancel = ["escape"]
275275 | `workspace.styling.pane_mode_border_color` | string | `"#4A90E2"` | Pane mode border color (blue) |
276276 | `workspace.styling.tab_mode_border_width` | int | `4` | Tab mode border width (px) - Ctrl+P T overlay |
277277 | `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) |
278+| `workspace.styling.session_mode_border_width` | int | `4` | Session mode border width (px) - Ctrl+O overlay |
279+| `workspace.styling.session_mode_border_color` | string | `"#9B59B6"` | Session mode border color (purple) |
278280 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |
279281
282+## Session
283+
284+| Key | Type | Default | Description |
285+|-----|------|---------|-------------|
286+| `session.auto_restore` | bool | `false` | Automatically restore the last session on startup |
287+| `session.snapshot_interval_ms` | int | `5000` | Minimum interval between snapshots in milliseconds |
288+| `session.max_exited_sessions` | int | `10` | Maximum number of exited sessions to keep |
289+| `session.max_listed_sessions` | int | `50` | Maximum number of sessions to list in CLI/TUI |
290+
291+### Session Mode
292+
293+| Key | Type | Default | Description |
294+|-----|------|---------|-------------|
295+| `session.session_mode.activation_shortcut` | string | `"ctrl+o"` | Session mode activation key |
296+| `session.session_mode.timeout_ms` | int | `3000` | Session mode timeout (ms) |
297+| `session.session_mode.actions` | map | See below | Action to keys mappings |
298+
299+**Default session mode actions:**
300+```toml
301+[session.session_mode.actions]
302+session-manager = ["s", "w"]
303+confirm = ["enter"]
304+cancel = ["escape"]
305+```
306+
307+**Example:**
308+```toml
309+[session]
310+auto_restore = false # Don't auto-restore on startup
311+snapshot_interval_ms = 5000 # Save state every 5 seconds (debounced)
312+max_exited_sessions = 10 # Keep last 10 exited sessions
313+max_listed_sessions = 50 # Show up to 50 sessions in CLI/TUI
314+```
315+
280316 ## Media
281317
282318 | Key | Type | Default | Valid Values | Description |
M internal/application/port/mocks/mocks.go +195 -0
@@ -120,6 +120,201 @@ func (_c *MockSessionLogger_CreateLogger_Call) RunAndReturn(run func(ctx context
120120 return _c
121121 }
122122
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+
123318 // 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.
124319 // The first argument is typically a *testing.T value.
125320 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 {
3131 // Use cases
3232 SearchHistoryUC *usecase.SearchHistoryUseCase
3333 SessionUC *usecase.ManageSessionUseCase
34+ ListSessionsUC *usecase.ListSessionsUseCase
35+ RestoreUC *usecase.RestoreSessionUseCase
36+ DeleteSessionUC *usecase.DeleteSessionUseCase
3437
3538 // Services
3639 FaviconService *favicon.Service
@@ -105,9 +108,13 @@ func NewApp() (*App, error) {
105108
106109 // Create repositories
107110 historyRepo := sqlite.NewHistoryRepository(db)
111+ sessionStateRepo := sqlite.NewSessionStateRepository(db)
108112
109113 // Create use cases
110114 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)
111118
112119 // Create favicon service for CLI (path resolution for dmenu/fuzzel)
113120 faviconCacheDir, _ := config.GetFaviconCacheDir()
@@ -120,6 +127,9 @@ func NewApp() (*App, error) {
120127 History: historyRepo,
121128 SearchHistoryUC: searchHistoryUC,
122129 SessionUC: sessionUC,
130+ ListSessionsUC: listSessionsUC,
131+ RestoreUC: restoreUC,
132+ DeleteSessionUC: deleteSessionUC,
123133 FaviconService: faviconService,
124134 ctx: ctx,
125135 logCleanup: logCleanup,
M internal/cli/cmd/purge.go +34 -5
@@ -24,11 +24,12 @@ var purgeCmd = &cobra.Command{
2424
2525 This can remove:
2626 - Config directory
27- - Data directory
27+ - Data directory (includes database)
2828 - State directory
2929 - Cache directory
3030 - Content filter caches
3131 - Desktop integration files
32+ - Inactive browser sessions
3233
3334 Use --force to remove everything without prompting.`,
3435 RunE: runPurge,
@@ -54,20 +55,48 @@ func runPurge(_ *cobra.Command, _ []string) error {
5455 return runPurgeForce(app, purgeUC)
5556 }
5657
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)
5865 p := tea.NewProgram(m, tea.WithAltScreen())
5966 _, err := p.Run()
6067 return err
6168 }
6269
6370 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)
6594 if out != nil {
6695 for _, r := range out.Results {
6796 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)
6998 } 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)
71100 }
72101 }
73102 }
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 (
1414
1515 // PurgeModel wraps styles.PurgeModel for standalone CLI use.
1616 type PurgeModel struct {
17- selector styles.PurgeModel
18- purgeUC *usecase.PurgeDataUseCase
17+ selector styles.PurgeModel
18+ purgeUC *usecase.PurgeDataUseCase
19+ listSessionsUC *usecase.ListSessionsUseCase
20+ deleteSessionUC *usecase.DeleteSessionUseCase
1921
20- loading bool
21- purging bool
22- done bool
22+ loading bool
23+ loadingSessions bool
24+ purging bool
25+ done bool
2326
24- results *usecase.PurgeOutput
25- info string
26- err error
27+ // Track loading state for parallel loading
28+ targetsLoaded bool
29+ sessionsLoaded bool
30+ targets []entity.PurgeTarget
31+ sessions []entity.SessionPurgeItem
32+ sessionsSize int64
33+
34+ results *usecase.PurgeOutput
35+ sessionResults []sessionPurgeResult
36+ info string
37+ err error
2738
2839 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
2954 }
3055
3156 // NewPurgeModel creates a new purge command model.
32-func NewPurgeModel(theme *styles.Theme, purgeUC *usecase.PurgeDataUseCase) PurgeModel {
33- return PurgeModel{theme: theme, purgeUC: purgeUC, loading: true}
57+func NewPurgeModel(ctx context.Context, theme *styles.Theme, purgeUC *usecase.PurgeDataUseCase, cfg PurgeModelConfig) PurgeModel {
58+ m := PurgeModel{
59+ theme: theme,
60+ purgeUC: purgeUC,
61+ listSessionsUC: cfg.ListSessionsUC,
62+ deleteSessionUC: cfg.DeleteSessionUC,
63+ loading: true,
64+ loadingSessions: cfg.ListSessionsUC != nil,
65+ ctx: ctx,
66+ }
67+ return m
3468 }
3569
3670 type purgeTargetsLoadedMsg struct {
@@ -38,14 +72,25 @@ type purgeTargetsLoadedMsg struct {
3872 err error
3973 }
4074
75+type purgeSessionsLoadedMsg struct {
76+ sessions []entity.SessionPurgeItem
77+ size int64
78+ err error
79+}
80+
4181 type purgeCompleteMsg struct {
42- out *usecase.PurgeOutput
43- err error
82+ targetResults []entity.PurgeResult
83+ sessionResults []sessionPurgeResult
84+ err error
4485 }
4586
4687 // Init implements tea.Model.
4788 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...)
4994 }
5095
5196 func (m PurgeModel) loadTargets() tea.Cmd {
@@ -55,68 +100,172 @@ func (m PurgeModel) loadTargets() tea.Cmd {
55100 }
56101 }
57102
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+
58116 // Update implements tea.Model.
59117 func (m PurgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
60118 switch msg := msg.(type) {
61119 case purgeTargetsLoadedMsg:
62- m.loading = false
63- if msg.err != nil {
64- m.err = msg.err
65- m.done = true
66- return m, nil
67- }
68-
69- m.selector = styles.NewPurge(m.theme, msg.targets)
70- return m, nil
71-
120+ return m.handleTargetsLoaded(msg), nil
121+ case purgeSessionsLoadedMsg:
122+ return m.handleSessionsLoaded(msg), nil
72123 case purgeCompleteMsg:
73- m.purging = false
74- m.done = true
75- m.results = msg.out
76- if msg.err != nil {
77- m.err = msg.err
78- }
79- return m, nil
80-
124+ return m.handlePurgeComplete(msg), nil
81125 case tea.KeyMsg:
82126 if m.done {
83127 return m, tea.Quit
84128 }
85- if m.loading || m.purging {
129+ if m.isBlocked() {
86130 return m, nil
87131 }
132+ // Fall through to updateSelector
88133 }
89134
90- if m.loading || m.purging {
135+ if m.isBlocked() {
91136 return m, nil
92137 }
93138
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) {
94192 selector, cmd := m.selector.Update(msg)
95193 m.selector = selector
96194
97- if m.selector.Done() {
98- if m.selector.Canceled {
99- return m, tea.Quit
100- }
101- if m.selector.Confirmed {
102- targetTypes := m.selector.SelectedTypes()
103- if len(targetTypes) == 0 {
104- m.done = true
105- m.info = "Nothing selected"
106- return m, nil
107- }
108- m.purging = true
109- return m, m.performPurge(targetTypes)
195+ if !m.selector.Done() {
196+ return m, cmd
197+ }
198+
199+ if m.selector.Canceled {
200+ return m, tea.Quit
201+ }
202+
203+ if m.selector.Confirmed {
204+ targetTypes := m.selector.SelectedTypes()
205+ sessionIDs := m.selector.SelectedSessionIDs()
206+ if len(targetTypes) == 0 && len(sessionIDs) == 0 {
207+ m.done = true
208+ m.info = "Nothing selected"
209+ return m, nil
110210 }
211+ m.purging = true
212+ return m, m.performPurge(targetTypes, sessionIDs)
111213 }
112214
113215 return m, cmd
114216 }
115217
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 {
117230 return func() tea.Msg {
118- out, err := m.purgeUC.Execute(context.Background(), usecase.PurgeInput{TargetTypes: targetTypes})
119- return purgeCompleteMsg{out: out, err: err}
231+ var targetResults []entity.PurgeResult
232+ var sessionResults []sessionPurgeResult
233+ var lastErr error
234+
235+ // Delete sessions first (if any selected and Data not selected)
236+ if m.deleteSessionUC != nil {
237+ for _, sid := range sessionIDs {
238+ err := m.deleteSessionUC.Execute(m.ctx, usecase.DeleteSessionInput{
239+ SessionID: sid,
240+ CurrentSessionID: "", // CLI has no current session
241+ })
242+ sessionResults = append(sessionResults, sessionPurgeResult{
243+ SessionID: sid,
244+ Success: err == nil,
245+ Error: err,
246+ })
247+ if err != nil {
248+ lastErr = err
249+ }
250+ }
251+ }
252+
253+ // Then delete target directories
254+ if len(targetTypes) > 0 {
255+ out, err := m.purgeUC.Execute(m.ctx, usecase.PurgeInput{TargetTypes: targetTypes})
256+ if out != nil {
257+ targetResults = out.Results
258+ }
259+ if err != nil {
260+ lastErr = err
261+ }
262+ }
263+
264+ return purgeCompleteMsg{
265+ targetResults: targetResults,
266+ sessionResults: sessionResults,
267+ err: lastErr,
268+ }
120269 }
121270 }
122271
@@ -124,7 +273,7 @@ func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType) tea.Cmd {
124273 func (m PurgeModel) View() string {
125274 t := m.theme
126275
127- if m.loading {
276+ if m.loading || m.loadingSessions {
128277 spinner := styles.NewLoading(t, "Scanning purge targets...")
129278 return t.Box.Render(spinner.View())
130279 }
@@ -145,7 +294,7 @@ func (m PurgeModel) View() string {
145294 return t.Box.Render(content)
146295 }
147296
148- if m.err != nil && m.results == nil {
297+ if m.err != nil && m.results == nil && len(m.sessionResults) == 0 {
149298 content := lipgloss.JoinVertical(
150299 lipgloss.Left,
151300 t.ErrorStyle.Render("Error: "+m.err.Error()),
@@ -155,28 +304,52 @@ func (m PurgeModel) View() string {
155304 return t.Box.Render(content)
156305 }
157306
158- contentLines := []string{t.Title.Render("Purge complete")}
159- if m.results != nil {
160- for _, r := range m.results.Results {
161- if r.Success {
162- contentLines = append(contentLines, fmt.Sprintf("%s %s", t.SuccessStyle.Render(styles.IconCheck), r.Target.Path))
163- } else {
164- contentLines = append(contentLines, fmt.Sprintf("%s %s: %v", t.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error))
165- }
307+ return m.renderResults()
308+ }
309+
310+ return m.selector.View()
311+}
312+
313+func (m PurgeModel) renderResults() string {
314+ t := m.theme
315+ contentLines := []string{t.Title.Render("Purge complete")}
316+
317+ successCount := 0
318+ failureCount := 0
319+
320+ // Target results
321+ if m.results != nil {
322+ for _, r := range m.results.Results {
323+ if r.Success {
324+ contentLines = append(contentLines, fmt.Sprintf("%s %s", t.SuccessStyle.Render(styles.IconCheck), r.Target.Path))
325+ successCount++
326+ } else {
327+ contentLines = append(contentLines, fmt.Sprintf("%s %s: %v", t.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error))
328+ failureCount++
166329 }
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- )
174330 }
175- contentLines = append(contentLines, "", t.Subtle.Render("Press any key to exit"))
176- return t.Box.Render(lipgloss.JoinVertical(lipgloss.Left, contentLines...))
177331 }
178332
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...))
180353 }
181354
182355 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 (
4040
4141 // UI
4242 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
4356 )
M internal/cli/styles/purge.go +327 -41
@@ -3,6 +3,7 @@ package styles
33 import (
44 "fmt"
55 "strings"
6+ "time"
67
78 "github.com/charmbracelet/bubbles/key"
89 tea "github.com/charmbracelet/bubbletea"
@@ -17,34 +18,41 @@ type PurgeItem struct {
1718 Selected bool
1819 }
1920
20-// PurgeModel is the multi-select purge modal.
21+// PurgeModel is the multi-select purge modal with sessions support.
2122 type PurgeModel struct {
22- Items []PurgeItem
23- Cursor int
24- Confirmed bool
25- Canceled bool
26- theme *Theme
23+ Items []PurgeItem
24+ Sessions []entity.SessionPurgeItem
25+ SessionsSize int64 // Total size of all session snapshots
26+ Cursor int
27+ Confirmed bool
28+ Canceled bool
29+ theme *Theme
30+
31+ // Track cursor position: false = items section, true = sessions section
32+ inSessionsSection bool
2733 }
2834
2935 // PurgeKeyMap defines keybindings for purge modal.
3036 type PurgeKeyMap struct {
31- Up key.Binding
32- Down key.Binding
33- Toggle key.Binding
34- ToggleAll key.Binding
35- Confirm key.Binding
36- Cancel key.Binding
37+ Up key.Binding
38+ Down key.Binding
39+ Toggle key.Binding
40+ ToggleAll key.Binding
41+ ToggleSessions key.Binding
42+ Confirm key.Binding
43+ Cancel key.Binding
3744 }
3845
3946 // DefaultPurgeKeyMap returns default keybindings.
4047 func DefaultPurgeKeyMap() PurgeKeyMap {
4148 return PurgeKeyMap{
42- Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
43- Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
44- Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),
45- ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")),
46- Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
47- Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")),
49+ Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
50+ Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
51+ Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),
52+ ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")),
53+ ToggleSessions: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "toggle sessions")),
54+ Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")),
55+ Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")),
4856 }
4957 }
5058
@@ -60,6 +68,14 @@ func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel {
6068 return m
6169 }
6270
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+
6379 // Init implements tea.Model.
6480 func (m PurgeModel) Init() tea.Cmd {
6581 _ = m
@@ -81,6 +97,8 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) {
8197 m.toggleCurrent()
8298 case key.Matches(msg, keys.ToggleAll):
8399 m.toggleAll()
100+ case key.Matches(msg, keys.ToggleSessions):
101+ m.ToggleAllSessions()
84102 case key.Matches(msg, keys.Confirm):
85103 m.Confirmed = true
86104 case key.Matches(msg, keys.Cancel):
@@ -91,19 +109,78 @@ func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) {
91109 return m, nil
92110 }
93111
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+
94142 func (m *PurgeModel) moveCursor(delta int) {
95- if len(m.Items) == 0 {
143+ total := m.totalSelectableCount()
144+ if total == 0 {
96145 return
97146 }
98147
99- idx := m.Cursor
100- for i := 0; i < len(m.Items); i++ {
101- idx = (idx + delta + len(m.Items)) % len(m.Items)
102- if m.Items[idx].Exists {
103- m.Cursor = idx
104- return
148+ // Build a flat list of selectable indices
149+ // Items first, then sessions (if enabled)
150+ type position struct {
151+ inSessions bool
152+ index int
153+ }
154+ var positions []position
155+
156+ for i, it := range m.Items {
157+ if it.Exists {
158+ positions = append(positions, position{inSessions: false, index: i})
105159 }
106160 }
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
107184 }
108185
109186 func (m PurgeModel) firstSelectableIndex() int {
@@ -116,6 +193,16 @@ func (m PurgeModel) firstSelectableIndex() int {
116193 }
117194
118195 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+
119206 if m.Cursor < 0 || m.Cursor >= len(m.Items) {
120207 return
121208 }
@@ -126,6 +213,7 @@ func (m *PurgeModel) toggleCurrent() {
126213 }
127214
128215 func (m *PurgeModel) toggleAll() {
216+ // Toggle all items
129217 anyUnselected := false
130218 for _, it := range m.Items {
131219 if it.Exists && !it.Selected {
@@ -133,6 +221,15 @@ func (m *PurgeModel) toggleAll() {
133221 break
134222 }
135223 }
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+ }
136233
137234 for i := range m.Items {
138235 if !m.Items[i].Exists {
@@ -140,6 +237,30 @@ func (m *PurgeModel) toggleAll() {
140237 }
141238 m.Items[i].Selected = anyUnselected
142239 }
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+ }
143264 }
144265
145266 // View implements tea.Model.
@@ -149,12 +270,33 @@ func (m PurgeModel) View() string {
149270 header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash))
150271 subtitle := t.Subtle.Render("Select items to remove")
151272
152- var rows []string
273+ // Application Data section
274+ dataSectionHeader := t.Subtle.Render("── Application Data ──")
275+ var itemRows []string
153276 for i, it := range m.Items {
154- rows = append(rows, m.renderRow(i, it))
277+ itemRows = append(itemRows, m.renderItemRow(i, it))
155278 }
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+ }
156292
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+ }
158300
159301 selectedCount := m.SelectedCount()
160302 size := m.SelectedSize()
@@ -172,28 +314,35 @@ func (m PurgeModel) View() string {
172314 summary = t.Subtle.Render("0 selected")
173315 }
174316
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)
176323
177- content := lipgloss.JoinVertical(
178- lipgloss.Left,
324+ contentParts := []string{
179325 header,
180326 subtitle,
181327 "",
182- list,
183- "",
184- summary,
185- "",
186- help,
187- )
328+ dataSectionHeader,
329+ itemsList,
330+ }
331+ if sessionsList != "" {
332+ contentParts = append(contentParts, "", sessionsList)
333+ }
334+ contentParts = append(contentParts, "", summary, "", help)
335+
336+ content := lipgloss.JoinVertical(lipgloss.Left, contentParts...)
188337
189338 return t.Box.Render(content)
190339 }
191340
192-func (m PurgeModel) renderRow(i int, it PurgeItem) string {
341+func (m PurgeModel) renderItemRow(i int, it PurgeItem) string {
193342 t := m.theme
194343
195344 cursor := " "
196- if i == m.Cursor {
345+ if !m.inSessionsSection && i == m.Cursor {
197346 cursor = IconCursor + " "
198347 }
199348
@@ -242,6 +391,62 @@ func (m PurgeModel) renderRow(i int, it PurgeItem) string {
242391 return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing)
243392 }
244393
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+
245450 func purgeLabel(t entity.PurgeTargetType) string {
246451 switch t {
247452 case entity.PurgeTargetConfig:
@@ -288,6 +493,45 @@ func formatSize(bytes int64) string {
288493 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
289494 }
290495
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+
291535 // Done returns true if the modal is complete.
292536 func (m PurgeModel) Done() bool {
293537 return m.Confirmed || m.Canceled
@@ -309,7 +553,7 @@ func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType {
309553 return out
310554 }
311555
312-// SelectedCount returns the number of selected, existing items.
556+// SelectedCount returns the number of selected items (targets + sessions).
313557 func (m PurgeModel) SelectedCount() int {
314558 count := 0
315559 for _, it := range m.Items {
@@ -317,10 +561,19 @@ func (m PurgeModel) SelectedCount() int {
317561 count++
318562 }
319563 }
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+ }
320572 return count
321573 }
322574
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.
324577 func (m PurgeModel) SelectedSize() int64 {
325578 var total int64
326579 for _, it := range m.Items {
@@ -328,5 +581,38 @@ func (m PurgeModel) SelectedSize() int64 {
328581 total += it.Size
329582 }
330583 }
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+ }
331601 return total
332602 }
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 {
3030 Success bool
3131 Error error
3232 }
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 {
155155 }
156156 return true
157157 }
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 {
27462746 return &MockSessionRepository_Expecter{mock: &_m.Mock}
27472747 }
27482748
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+
27492806 // FindByID provides a mock function for the type MockSessionRepository
27502807 func (_mock *MockSessionRepository) FindByID(ctx context.Context, id entity.SessionID) (*entity.Session, error) {
27512808 ret := _mock.Called(ctx, id)
@@ -3064,6 +3121,337 @@ func (_c *MockSessionRepository_Save_Call) RunAndReturn(run func(ctx context.Con
30643121 return _c
30653122 }
30663123
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+
30673455 // 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.
30683456 // The first argument is typically a *testing.T value.
30693457 func NewMockZoomRepository(t interface {
M internal/domain/repository/session.go +3 -0
@@ -16,4 +16,7 @@ type SessionRepository interface {
1616 GetActive(ctx context.Context) (*entity.Session, error)
1717 GetRecent(ctx context.Context, limit int) ([]*entity.Session, error)
1818 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
1922 }
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 (
2626 defaultTabBarPosition = "bottom"
2727 defaultPopupPlacement = "right"
2828
29+ // Session defaults
30+ defaultSessionActivationShortcut = "ctrl+o"
31+ defaultSessionTimeoutMilliseconds = 3000
32+ defaultSnapshotIntervalMs = 5000
33+ defaultMaxExitedSessions = 10
34+ defaultMaxListedSessions = 50
35+
2936 // Workspace styling defaults
3037 // Active pane border (overlay)
3138 defaultBorderWidth = 1
@@ -39,6 +46,10 @@ const (
3946 defaultTabModeBorderWidth = 4
4047 defaultTabModeBorderColor = "#FFA500" // Orange for tab mode indicator
4148
49+ // Session mode border (Ctrl+O - overlay)
50+ defaultSessionModeBorderWidth = 4
51+ defaultSessionModeBorderColor = "#9B59B6" // Purple for session mode indicator
52+
4253 // Other styling
4354 defaultTransitionDuration = 120
4455 defaultUIScale = 1.0 // UI scale multiplier (1.0 = 100%, 1.2 = 120%)
@@ -180,13 +191,15 @@ func DefaultConfig() *Config {
180191 OAuthAutoClose: true, // Auto-close OAuth popups on success
181192 },
182193 Styling: WorkspaceStylingConfig{
183- BorderWidth: defaultBorderWidth,
184- BorderColor: defaultBorderColor,
185- PaneModeBorderWidth: defaultPaneModeBorderWidth,
186- PaneModeBorderColor: defaultPaneModeBorderColor,
187- TabModeBorderWidth: defaultTabModeBorderWidth,
188- TabModeBorderColor: defaultTabModeBorderColor,
189- TransitionDuration: defaultTransitionDuration,
194+ BorderWidth: defaultBorderWidth,
195+ BorderColor: defaultBorderColor,
196+ PaneModeBorderWidth: defaultPaneModeBorderWidth,
197+ PaneModeBorderColor: defaultPaneModeBorderColor,
198+ TabModeBorderWidth: defaultTabModeBorderWidth,
199+ TabModeBorderColor: defaultTabModeBorderColor,
200+ SessionModeBorderWidth: defaultSessionModeBorderWidth,
201+ SessionModeBorderColor: defaultSessionModeBorderColor,
202+ TransitionDuration: defaultTransitionDuration,
190203 },
191204 },
192205 ContentFiltering: ContentFilteringConfig{
@@ -196,6 +209,21 @@ func DefaultConfig() *Config {
196209 Omnibox: OmniboxConfig{
197210 InitialBehavior: defaultOmniboxInitialBehavior,
198211 },
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+ },
199227 Media: MediaConfig{
200228 HardwareDecodingMode: HardwareDecodingAuto, // auto allows sw fallback
201229 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() {
314314 m.setOmniboxDefaults(defaults)
315315 m.setMediaDefaults(defaults)
316316 m.setRuntimeDefaults(defaults)
317+ m.setSessionDefaults(defaults)
317318 }
318319
319320 func (m *Manager) setHistoryDefaults(defaults *Config) {
@@ -402,6 +403,8 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
402403 m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)
403404 m.viper.SetDefault("workspace.styling.tab_mode_border_width", defaults.Workspace.Styling.TabModeBorderWidth)
404405 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)
405408 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
406409 }
407410
@@ -429,6 +432,16 @@ func (m *Manager) setRuntimeDefaults(defaults *Config) {
429432 m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix)
430433 }
431434
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+
432445 // New returns a new default configuration instance.
433446 // This is a convenience function for getting default config without the full manager.
434447 func New() *Config {
M internal/infrastructure/config/schema.go +49 -0
@@ -17,6 +17,8 @@ type Config struct {
1717 DefaultUIScale float64 `mapstructure:"default_ui_scale" yaml:"default_ui_scale" toml:"default_ui_scale"`
1818 // Workspace defines workspace, pane, and tab handling behavior.
1919 Workspace WorkspaceConfig `mapstructure:"workspace" yaml:"workspace" toml:"workspace"`
20+ // Session controls session persistence and restoration.
21+ Session SessionConfig `mapstructure:"session" yaml:"session" toml:"session"`
2022 // ContentFiltering controls ad blocking and content filtering
2123 ContentFiltering ContentFilteringConfig `mapstructure:"content_filtering" yaml:"content_filtering" toml:"content_filtering"`
2224 // Omnibox controls the omnibox behavior (initial history display)
@@ -401,6 +403,53 @@ type WorkspaceStylingConfig struct {
401403 // Defaults to "#FFA500" (orange) if not set - MUST be different from PaneModeBorderColor
402404 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
403405
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+
404412 // TransitionDuration in milliseconds for border animations
405413 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
406414 }
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 (
1010 "strings"
1111
1212 "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/domain/entity"
1314 "github.com/bnema/dumber/internal/logging"
1415 )
1516
@@ -348,3 +349,56 @@ func (a *Adapter) UnsetAsDefaultBrowser(ctx context.Context) error {
348349 log.Warn().Msg("dumber was default browser - user should set a new default")
349350 return nil
350351 }
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 ?;
2020 UPDATE sessions
2121 SET ended_at = ?
2222 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
8787 })
8888 }
8989
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+
9096 func sessionFromRow(row sqlc.Session) *entity.Session {
9197 var endedAt *time.Time
9298 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 {
7070 EndedAt sql.NullTime `json:"ended_at"`
7171 }
7272
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+
7382 type ZoomLevel struct {
7483 Domain string `json:"domain"`
7584 ZoomFactor float64 `json:"zoom_factor"`
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +7 -0
@@ -21,11 +21,14 @@ type Querier interface {
2121 DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error
2222 DeleteHistoryByID(ctx context.Context, id int64) error
2323 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
24+ DeleteSession(ctx context.Context, id string) error
25+ DeleteSessionState(ctx context.Context, sessionID string) error
2426 DeleteTag(ctx context.Context, id int64) error
2527 DeleteZoomLevel(ctx context.Context, domain string) error
2628 GetActiveBrowserSession(ctx context.Context) (Session, error)
2729 GetAllFavorites(ctx context.Context) ([]Favorite, error)
2830 GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)
31+ GetAllSessionStates(ctx context.Context) ([]SessionState, error)
2932 GetAllTags(ctx context.Context) ([]FavoriteTag, error)
3033 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)
3134 GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error)
@@ -44,9 +47,12 @@ type Querier interface {
4447 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
4548 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
4649 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)
4752 GetTagByID(ctx context.Context, id int64) (FavoriteTag, error)
4853 GetTagByName(ctx context.Context, name string) (FavoriteTag, error)
4954 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)
55+ GetTotalSessionStatesSize(ctx context.Context) (interface{}, error)
5056 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)
5157 IncrementVisitCount(ctx context.Context, url string) error
5258 InsertSession(ctx context.Context, arg InsertSessionParams) error
@@ -66,6 +72,7 @@ type Querier interface {
6672 UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error
6773 UpdateTag(ctx context.Context, arg UpdateTagParams) error
6874 UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error
75+ UpsertSessionState(ctx context.Context, arg UpsertSessionStateParams) error
6976 }
7077
7178 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 (
1111 "time"
1212 )
1313
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+
1423 const GetActiveBrowserSession = `-- name: GetActiveBrowserSession :one
1524 SELECT id, type, started_at, ended_at FROM sessions
1625 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 (
1111 "github.com/bnema/dumber/internal/application/usecase"
1212 "github.com/bnema/dumber/internal/domain/entity"
1313 "github.com/bnema/dumber/internal/infrastructure/config"
14+ "github.com/bnema/dumber/internal/infrastructure/desktop"
1415 "github.com/bnema/dumber/internal/infrastructure/filtering"
16+ "github.com/bnema/dumber/internal/infrastructure/snapshot"
1517 "github.com/bnema/dumber/internal/infrastructure/webkit"
1618
1719 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
@@ -84,6 +86,10 @@ type App struct {
8486 // App-level toaster for system notifications (filter status, etc.)
8587 appToaster *component.Toaster
8688
89+ // Session management
90+ sessionManager *component.SessionManager
91+ snapshotService *snapshot.Service
92+
8793 // ID generator for tabs/panes
8894 idCounter uint64
8995 idMu sync.Mutex
@@ -183,6 +189,8 @@ func (a *App) onActivate(ctx context.Context) {
183189 a.initKeyboardHandler(ctx)
184190 a.initOmniboxConfig(ctx)
185191 a.initFindBarConfig()
192+ a.initSessionManager(ctx)
193+ a.initSnapshotService(ctx)
186194 a.createInitialTab(ctx)
187195 a.finalizeActivation(ctx)
188196 }
@@ -295,10 +303,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
295303 }
296304
297305 // Create keyboard handler and wire to dispatcher.
298- a.keyboardHandler = input.NewKeyboardHandler(
299- ctx,
300- &a.deps.Config.Workspace,
301- )
306+ a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)
302307 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
303308 return a.kbDispatcher.Dispatch(ctx, action)
304309 })
@@ -306,6 +311,10 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
306311 a.handleModeChange(ctx, from, to)
307312 })
308313 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+ }
309318 wsView := a.activeWorkspaceView()
310319 if wsView == nil {
311320 return false
@@ -369,9 +378,145 @@ func (a *App) initFindBarConfig() {
369378 }
370379 }
371380
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+
372499 func (a *App) createInitialTab(ctx context.Context) {
373500 log := logging.FromContext(ctx)
374501
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+
375520 // Create an initial tab using coordinator.
376521 initialURL := "dumb://home"
377522 if a.deps != nil && a.deps.InitialURL != "" {
@@ -382,6 +527,61 @@ func (a *App) createInitialTab(ctx context.Context) {
382527 }
383528 }
384529
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+
385585 func (a *App) finalizeActivation(ctx context.Context) {
386586 log := logging.FromContext(ctx)
387587
@@ -409,6 +609,13 @@ func (a *App) onShutdown(ctx context.Context) {
409609 log := logging.FromContext(ctx)
410610 log.Debug().Msg("GTK application shutting down")
411611
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+
412619 // Cancel context to signal all goroutines
413620 a.cancel(errors.New("application shutdown"))
414621
@@ -475,6 +682,7 @@ func (a *App) initCoordinators(ctx context.Context) {
475682 a.switchWorkspaceView(ctx, tab.ID)
476683 })
477684 a.tabCoord.SetOnQuit(a.Quit)
685+ a.tabCoord.SetOnStateChanged(a.MarkDirty)
478686 // Wire popup tab WebView attachment
479687 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
480688 a.attachPopupToTab(ctx, tabID, pane, wv)
@@ -502,6 +710,7 @@ func (a *App) initCoordinators(ctx context.Context) {
502710 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
503711 return a.tabCoord.Close(ctx)
504712 })
713+ a.wsCoord.SetOnStateChanged(a.MarkDirty)
505714
506715 // Wire popup handling
507716 a.webViewFactory = webkit.NewWebViewFactory(
@@ -555,6 +764,13 @@ func (a *App) initCoordinators(ctx context.Context) {
555764 a.navCoord.RecordHistory(ctx, paneID, url)
556765 })
557766
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+
558774 // 5. Keyboard Dispatcher
559775 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
560776 ctx,
@@ -640,6 +856,24 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
640856
641857 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
642858 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) {
643877 log := logging.FromContext(ctx)
644878
645879 if a.widgetFactory == nil {
@@ -676,18 +910,7 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
676910 // Store in map
677911 a.workspaceViews[tab.ID] = wsView
678912
679- // Attach to content area
680- if a.mainWindow != nil {
681- widget := wsView.Widget()
682- if widget != nil {
683- gtkWidget := widget.GtkWidget()
684- if gtkWidget != nil {
685- a.mainWindow.SetContent(gtkWidget)
686- }
687- }
688- }
689-
690- log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created and attached")
913+ log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
691914 }
692915
693916 // activeWorkspace returns the workspace of the active tab.
@@ -699,6 +922,21 @@ func (a *App) activeWorkspace() *entity.Workspace {
699922 return activeTab.Workspace
700923 }
701924
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+
702940 // activeWorkspaceView returns the workspace view for the active tab.
703941 func (a *App) activeWorkspaceView() *component.WorkspaceView {
704942 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 (
2020 )
2121
2222 const (
23- omniboxWidthPct = 0.8 // 80% of parent window width
24- omniboxMaxWidth = 800 // Maximum width in pixels
25- omniboxFallbackHeight = 600 // Fallback height when overlay not yet allocated
26- omniboxHeightPct = 0.6 // 60% of parent window height
27- omniboxMaxResults = 10
28- omniboxTopMarginPct = 0.2 // 20% from top
29- debounceDelayMs = 150
23+ debounceDelayMs = 150
3024 )
3125
3226 // ViewMode distinguishes history search from favorites display.
@@ -193,20 +187,12 @@ func (o *Omnibox) resizeAndCenter(rowCount int) {
193187 return
194188 }
195189
196- // Use GetAllocatedWidth for actual rendered size (height not needed here)
197- parentWidth := o.parentOverlay.GetAllocatedWidth()
198- if parentWidth <= 0 {
199- parentWidth = omniboxMaxWidth
200- }
201-
202- width := int(float64(parentWidth) * omniboxWidthPct)
203- if width > omniboxMaxWidth {
204- width = omniboxMaxWidth
205- }
190+ // Calculate width using shared helper
191+ width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
206192
207193 // Cap at max results
208- if rowCount > omniboxMaxResults {
209- rowCount = omniboxMaxResults
194+ if rowCount > OmniboxListDefaults.MaxVisibleRows {
195+ rowCount = OmniboxListDefaults.MaxVisibleRows
210196 }
211197
212198 // Schedule measurement after GTK has laid out widgets
@@ -233,9 +219,8 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
233219 // Use measured values from GTK4 Measure API
234220 rowHeight = o.measuredHeights.singleRow
235221 } else {
236- // Fallback to hardcoded estimate (before first measurement)
237- const fallbackRowHeight = 60
238- rowHeight = int(fallbackRowHeight * o.uiScale)
222+ // Fallback to scaled estimate (before first measurement)
223+ rowHeight = ScaleValue(DefaultRowHeights.Standard, o.uiScale)
239224 }
240225
241226 // Calculate content height for the scrolled window
@@ -246,19 +231,13 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
246231 }
247232
248233 // Cap at max results
249- maxContentHeight := omniboxMaxResults * rowHeight
234+ maxContentHeight := OmniboxListDefaults.MaxVisibleRows * rowHeight
250235 if contentHeight > maxContentHeight {
251236 contentHeight = maxContentHeight
252237 }
253238
254- // Set size constraints on ScrolledWindow to control dynamic sizing
255- if o.scrolledWin != nil {
256- // Reset min first to avoid assertion failure when shrinking
257- // GTK requires min <= max, so we reset min before setting new values
258- o.scrolledWin.SetMinContentHeight(-1)
259- o.scrolledWin.SetMaxContentHeight(contentHeight)
260- o.scrolledWin.SetMinContentHeight(contentHeight)
261- }
239+ // Set size constraints on ScrolledWindow using shared helper
240+ SetScrolledWindowHeight(o.scrolledWin, contentHeight)
262241
263242 // Force layout recalculation
264243 o.outerBox.QueueResize()
@@ -602,7 +581,7 @@ func (o *Omnibox) performSearch() {
602581
603582 input := usecase.SearchInput{
604583 Query: query,
605- Limit: omniboxMaxResults,
584+ Limit: OmniboxListDefaults.MaxResults,
606585 }
607586 output, err := o.historyUC.Search(ctx, input)
608587 if err != nil {
@@ -641,7 +620,7 @@ func (o *Omnibox) loadInitialHistory() {
641620
642621 case "most_visited", "recent", "":
643622 // 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)
645624 if err != nil {
646625 log.Error().Err(err).Msg("failed to load recent history")
647626 return
@@ -778,7 +757,7 @@ func (o *Omnibox) rebuildList() {
778757 if width <= 0 {
779758 return false // Overlay not allocated yet, skip
780759 }
781- forWidth := int(float64(width) * omniboxWidthPct)
760+ forWidth := int(float64(width) * OmniboxSizeDefaults.WidthPct)
782761 if o.measureComponentHeights(forWidth) {
783762 // Re-trigger resize with accurate measurements
784763 o.mu.RLock()
@@ -1234,22 +1213,8 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
12341213 o.scrolledWin.SetMaxContentHeight(0)
12351214 }
12361215
1237- // Get parent dimensions with fallback if not yet allocated
1238- parentWidth := o.parentOverlay.GetAllocatedWidth()
1239- parentHeight := o.parentOverlay.GetAllocatedHeight()
1240- if parentWidth <= 0 {
1241- parentWidth = omniboxMaxWidth
1242- }
1243- if parentHeight <= 0 {
1244- parentHeight = omniboxFallbackHeight
1245- }
1246-
1247- // Set width and vertical positioning (20% from top)
1248- width := int(float64(parentWidth) * omniboxWidthPct)
1249- if width > omniboxMaxWidth {
1250- width = omniboxMaxWidth
1251- }
1252- marginTop := int(float64(parentHeight) * omniboxTopMarginPct)
1216+ // Calculate dimensions using shared helper
1217+ width, marginTop := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
12531218
12541219 o.mainBox.SetSizeRequest(width, -1)
12551220 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 {
4646 // Callback when page is committed (for history recording)
4747 onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
4848
49+ // Callback when pane URI changes (for session snapshots)
50+ onPaneURIUpdated func(paneID entity.PaneID, url string)
51+
4952 // Gesture action handler for mouse button navigation
5053 gestureActionHandler input.ActionHandler
5154
@@ -103,6 +106,11 @@ func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, pan
103106 c.onHistoryRecord = fn
104107 }
105108
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+
106114 // SetGestureActionHandler sets the callback for mouse button navigation gestures.
107115 func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {
108116 c.gestureActionHandler = handler
@@ -655,6 +663,9 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
655663 return
656664 }
657665
666+ // Update domain model with current URI for session snapshots
667+ c.updatePaneURI(paneID, url)
668+
658669 // Record history - URI is guaranteed to be correct at LoadCommitted
659670 if c.onHistoryRecord != nil {
660671 c.onHistoryRecord(ctx, paneID, url)
@@ -679,12 +690,23 @@ func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.
679690 log := logging.FromContext(ctx)
680691 log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected")
681692
693+ // Update domain model with current URI for session snapshots
694+ c.updatePaneURI(paneID, url)
695+
682696 // Record history for SPA navigation
683697 if c.onHistoryRecord != nil {
684698 c.onHistoryRecord(ctx, paneID, url)
685699 }
686700 }
687701
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+
688710 // onLoadStarted shows the progress bar when page loading begins.
689711 func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {
690712 _, wsView := c.getActiveWS()
M internal/ui/coordinator/tab.go +21 -0
@@ -24,6 +24,7 @@ type TabCoordinator struct {
2424 onTabSwitched func(ctx context.Context, tab *entity.Tab)
2525 onQuit func()
2626 onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) // For popup tabs
27+ onStateChanged func() // For session snapshots
2728 }
2829
2930 // TabCoordinatorConfig holds configuration for TabCoordinator.
@@ -63,6 +64,18 @@ func (c *TabCoordinator) SetOnQuit(fn func()) {
6364 c.onQuit = fn
6465 }
6566
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+
6679 // Create creates a new tab with the given initial URL.
6780 func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity.Tab, error) {
6881 log := logging.FromContext(ctx)
@@ -99,6 +112,9 @@ func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity
99112 c.onTabSwitched(ctx, output.Tab)
100113 }
101114
115+ // Notify state change for session snapshots
116+ c.notifyStateChanged()
117+
102118 log.Debug().Str("tab_id", string(output.Tab.ID)).Msg("tab created")
103119 return output.Tab, nil
104120 }
@@ -143,6 +159,11 @@ func (c *TabCoordinator) Close(ctx context.Context) error {
143159 }
144160 }
145161
162+ // Notify state change for session snapshots (unless quitting)
163+ if !wasLast {
164+ c.notifyStateChanged()
165+ }
166+
146167 log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed")
147168 return nil
148169 }
M internal/ui/coordinator/workspace.go +25 -0
@@ -32,6 +32,7 @@ type WorkspaceCoordinator struct {
3232 generateID func() string
3333 onCloseLastPane func(ctx context.Context) error
3434 onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior
35+ onStateChanged func() // For session snapshots
3536 }
3637
3738 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
@@ -82,6 +83,18 @@ func (c *WorkspaceCoordinator) SetOnCloseLastPane(fn func(ctx context.Context) e
8283 c.onCloseLastPane = fn
8384 }
8485
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+
8598 // setupPaneViewHover configures hover-to-focus behavior on a PaneView.
8699 func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *component.WorkspaceView) {
87100 pv.SetOnHover(func(paneID entity.PaneID) {
@@ -129,6 +142,9 @@ func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.Spli
129142 c.applySplitToView(ctx, splitCtx.wsView, splitCtx.ws, output, direction, splitCtx.existingWidget, splitCtx.isStackSplit, oldActivePaneID)
130143 }
131144
145+ // Notify state change for session snapshots
146+ c.notifyStateChanged()
147+
132148 log.Info().Str("direction", string(direction)).Str("new_pane_id", string(output.NewPaneNode.Pane.ID)).Msg("pane split completed")
133149
134150 return nil
@@ -740,6 +756,9 @@ func (c *WorkspaceCoordinator) ClosePane(ctx context.Context) error {
740756 parentIsStartInGrand,
741757 )
742758
759+ // Notify state change for session snapshots
760+ c.notifyStateChanged()
761+
743762 log.Info().Msg("pane closed")
744763 return nil
745764 }
@@ -827,6 +846,9 @@ func (c *WorkspaceCoordinator) ClosePaneByID(ctx context.Context, paneID entity.
827846 parentIsStartInGrand,
828847 )
829848
849+ // Notify state change for session snapshots
850+ c.notifyStateChanged()
851+
830852 log.Info().Str("pane_id", string(paneID)).Msg("pane closed by ID")
831853 return nil
832854 }
@@ -1240,6 +1262,9 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
12401262 }
12411263 }
12421264
1265+ // Notify state change for session snapshots
1266+ c.notifyStateChanged()
1267+
12431268 log.Info().
12441269 Str("original_pane", string(stackCtx.activePaneID)).
12451270 Str("new_pane", string(newPaneID)).
M internal/ui/deps.go +11 -3
@@ -6,6 +6,7 @@ import (
66
77 "github.com/bnema/dumber/internal/application/port"
88 "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
910 "github.com/bnema/dumber/internal/domain/repository"
1011 "github.com/bnema/dumber/internal/infrastructure/config"
1112 "github.com/bnema/dumber/internal/infrastructure/favicon"
@@ -18,9 +19,10 @@ import (
1819 // This struct is created once at startup and passed to UI components.
1920 type Dependencies struct {
2021 // Core context and configuration
21- Ctx context.Context
22- Config *config.Config
23- InitialURL string // URL to open on startup (optional)
22+ Ctx context.Context
23+ Config *config.Config
24+ InitialURL string // URL to open on startup (optional)
25+ RestoreSessionID string // Session ID to restore on startup (optional)
2426
2527 // Theme management
2628 Theme *theme.Manager
@@ -53,6 +55,12 @@ type Dependencies struct {
5355 FaviconService *favicon.Service
5456 FilterManager *filtering.Manager
5557 IdleInhibitor port.IdleInhibitor
58+
59+ // Session management
60+ SessionStateRepo repository.SessionStateRepository
61+ SessionRepo repository.SessionRepository
62+ CurrentSessionID entity.SessionID
63+ SnapshotUC *usecase.SnapshotSessionUseCase
5664 }
5765
5866 // Validate checks that all required dependencies are set.
M internal/ui/dispatcher/keyboard.go +16 -0
@@ -26,6 +26,7 @@ type KeyboardDispatcher struct {
2626 onFindNext func(ctx context.Context) error
2727 onFindPrev func(ctx context.Context) error
2828 onFindClose func(ctx context.Context) error
29+ onSessionOpen func(ctx context.Context) error
2930 }
3031
3132 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
@@ -76,6 +77,11 @@ func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error)
7677 d.onFindClose = fn
7778 }
7879
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+
7985 func (d *KeyboardDispatcher) initActionHandlers() {
8086 const (
8187 firstTabIndex = 0
@@ -144,6 +150,8 @@ func (d *KeyboardDispatcher) initActionHandlers() {
144150 },
145151 // Clipboard
146152 input.ActionCopyURL: d.handleCopyURL,
153+ // Session management
154+ input.ActionOpenSessionManager: d.handleSessionOpen,
147155 // Application
148156 input.ActionQuit: d.handleQuit,
149157 }
@@ -194,6 +202,14 @@ func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error {
194202 return nil
195203 }
196204
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+
197213 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
198214 if ctx == nil {
199215 return fmt.Errorf("missing context")
M internal/ui/focus/borders.go +8 -2
@@ -10,8 +10,9 @@ import (
1010
1111 // CSS classes for mode borders.
1212 const (
13- paneModeClass = "pane-mode-active"
14- tabModeClass = "tab-mode-active"
13+ paneModeClass = "pane-mode-active"
14+ tabModeClass = "tab-mode-active"
15+ sessionModeClass = "session-mode-active"
1516 )
1617
1718 // BorderManager manages mode indicator borders on the workspace container.
@@ -58,6 +59,8 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode)
5859 bm.borderOverlay.RemoveCssClass(paneModeClass)
5960 case input.ModeTab:
6061 bm.borderOverlay.RemoveCssClass(tabModeClass)
62+ case input.ModeSession:
63+ bm.borderOverlay.RemoveCssClass(sessionModeClass)
6164 }
6265
6366 // Add new mode class and set visibility
@@ -68,6 +71,9 @@ func (bm *BorderManager) OnModeChange(ctx context.Context, from, to input.Mode)
6871 case input.ModeTab:
6972 bm.borderOverlay.AddCssClass(tabModeClass)
7073 bm.borderOverlay.SetVisible(true)
74+ case input.ModeSession:
75+ bm.borderOverlay.AddCssClass(sessionModeClass)
76+ bm.borderOverlay.SetVisible(true)
7177 case input.ModeNormal:
7278 bm.borderOverlay.SetVisible(false)
7379 }
M internal/ui/input/global_shortcuts.go +7 -0
@@ -84,6 +84,13 @@ func NewGlobalShortcutHandler(
8484 Str("action", string(ActionSwitchLastTab)).
8585 Msg("registered global shortcut")
8686
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+
8794 // Attach to window
8895 window.AddController(&h.controller.EventController)
8996
M internal/ui/input/keyboard.go +18 -9
@@ -21,7 +21,7 @@ type ActionHandler func(ctx context.Context, action Action) error
2121 type KeyboardHandler struct {
2222 shortcuts *ShortcutSet
2323 modal *ModalState
24- cfg *config.WorkspaceConfig
24+ cfg *config.Config
2525
2626 // Action handler callback
2727 onAction ActionHandler
@@ -39,10 +39,7 @@ type KeyboardHandler struct {
3939 }
4040
4141 // NewKeyboardHandler creates a new keyboard handler.
42-func NewKeyboardHandler(
43- ctx context.Context,
44- cfg *config.WorkspaceConfig,
45-) *KeyboardHandler {
42+func NewKeyboardHandler(ctx context.Context, cfg *config.Config) *KeyboardHandler {
4643 log := logging.FromContext(ctx)
4744
4845 log.Debug().Msg("creating keyboard handler")
@@ -157,15 +154,20 @@ func (h *KeyboardHandler) handleKeyPress(keyval uint, state gdk.ModifierType) bo
157154 // Handle special mode actions first
158155 switch action {
159156 case ActionEnterTabMode:
160- timeout := time.Duration(h.cfg.TabMode.TimeoutMilliseconds) * time.Millisecond
157+ timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
161158 h.modal.EnterTabMode(h.ctx, timeout)
162159 return true
163160
164161 case ActionEnterPaneMode:
165- timeout := time.Duration(h.cfg.PaneMode.TimeoutMilliseconds) * time.Millisecond
162+ timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
166163 h.modal.EnterPaneMode(h.ctx, timeout)
167164 return true
168165
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+
169171 case ActionExitMode:
170172 h.modal.ExitMode(h.ctx)
171173 return true
@@ -197,17 +199,24 @@ func (h *KeyboardHandler) handleKeyPress(keyval uint, state gdk.ModifierType) bo
197199 // EnterTabMode programmatically enters tab mode.
198200 // Useful for testing or programmatic mode changes.
199201 func (h *KeyboardHandler) EnterTabMode() {
200- timeout := time.Duration(h.cfg.TabMode.TimeoutMilliseconds) * time.Millisecond
202+ timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
201203 h.modal.EnterTabMode(h.ctx, timeout)
202204 }
203205
204206 // EnterPaneMode programmatically enters pane mode.
205207 // Useful for testing or programmatic mode changes.
206208 func (h *KeyboardHandler) EnterPaneMode() {
207- timeout := time.Duration(h.cfg.PaneMode.TimeoutMilliseconds) * time.Millisecond
209+ timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
208210 h.modal.EnterPaneMode(h.ctx, timeout)
209211 }
210212
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+
211220 // ExitMode programmatically exits modal mode.
212221 // Useful for testing or programmatic mode changes.
213222 func (h *KeyboardHandler) ExitMode() {
M internal/ui/input/modal.go +39 -0
@@ -18,6 +18,8 @@ const (
1818 ModeTab
1919 // ModePane is the modal pane management mode.
2020 ModePane
21+ // ModeSession is the modal session management mode.
22+ ModeSession
2123 )
2224
2325 // String returns a human-readable mode name.
@@ -29,6 +31,8 @@ func (m Mode) String() string {
2931 return "tab"
3032 case ModePane:
3133 return "pane"
34+ case ModeSession:
35+ return "session"
3236 default:
3337 return "unknown"
3438 }
@@ -132,6 +136,41 @@ func (m *ModalState) EnterPaneMode(ctx context.Context, timeout time.Duration) {
132136 }
133137 }
134138
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+
135174 // ExitMode returns to normal mode.
136175 func (m *ModalState) ExitMode(ctx context.Context) {
137176 log := logging.FromContext(ctx)
M internal/ui/input/shortcuts.go +58 -21
@@ -93,9 +93,10 @@ type Action string
9393 // Predefined actions for the keyboard system.
9494 const (
9595 // Mode management
96- ActionEnterTabMode Action = "enter_tab_mode"
97- ActionEnterPaneMode Action = "enter_pane_mode"
98- ActionExitMode Action = "exit_mode"
96+ ActionEnterTabMode Action = "enter_tab_mode"
97+ ActionEnterPaneMode Action = "enter_pane_mode"
98+ ActionEnterSessionMode Action = "enter_session_mode"
99+ ActionExitMode Action = "exit_mode"
99100
100101 // Tab actions (global and modal)
101102 ActionNewTab Action = "new_tab"
@@ -157,6 +158,9 @@ const (
157158 // Clipboard
158159 ActionCopyURL Action = "copy_url"
159160
161+ // Session management
162+ ActionOpenSessionManager Action = "open_session_manager"
163+
160164 // Application
161165 ActionQuit Action = "quit"
162166 )
@@ -172,30 +176,39 @@ type ShortcutSet struct {
172176 TabMode ShortcutTable
173177 // PaneMode shortcuts are only active in pane mode.
174178 PaneMode ShortcutTable
179+ // SessionMode shortcuts are only active in session mode.
180+ SessionMode ShortcutTable
175181 }
176182
177-// NewShortcutSet creates a ShortcutSet from the workspace configuration.
178-func NewShortcutSet(ctx context.Context, cfg *config.WorkspaceConfig) *ShortcutSet {
183+// NewShortcutSet creates a ShortcutSet from the configuration.
184+func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet {
179185 log := logging.FromContext(ctx)
180186 set := &ShortcutSet{
181- Global: make(ShortcutTable),
182- TabMode: make(ShortcutTable),
183- PaneMode: make(ShortcutTable),
187+ Global: make(ShortcutTable),
188+ TabMode: make(ShortcutTable),
189+ PaneMode: make(ShortcutTable),
190+ SessionMode: make(ShortcutTable),
184191 }
185192
186193 set.buildGlobalShortcuts(ctx, cfg)
187- set.buildTabModeShortcuts(ctx, cfg)
188- set.buildPaneModeShortcuts(ctx, cfg)
194+ set.buildTabModeShortcuts(ctx, &cfg.Workspace)
195+ set.buildPaneModeShortcuts(ctx, &cfg.Workspace)
196+ set.buildSessionModeShortcuts(ctx, &cfg.Session)
189197
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")
191204
192205 return set
193206 }
194207
195208 // 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) {
197210 s.registerActivationShortcuts(ctx, cfg)
198- s.registerConfiguredShortcuts(cfg)
211+ s.registerConfiguredShortcuts(&cfg.Workspace)
199212 s.registerStandardShortcuts()
200213 s.registerPaneNavigationShortcuts()
201214 s.registerTabSwitchShortcuts()
@@ -211,27 +224,42 @@ func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *config.Wo
211224 s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane")
212225 }
213226
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) {
215233 log := logging.FromContext(ctx)
216- if binding, ok := ParseKeyString(cfg.TabMode.ActivationShortcut); ok {
234+ if binding, ok := ParseKeyString(cfg.Workspace.TabMode.ActivationShortcut); ok {
217235 s.Global[binding] = ActionEnterTabMode
218236 log.Trace().
219- Str("shortcut", cfg.TabMode.ActivationShortcut).
237+ Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut).
220238 Uint("keyval", binding.Keyval).
221239 Uint("mod", uint(binding.Modifiers)).
222240 Msg("tab mode activation registered")
223241 } 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")
225243 }
226- if binding, ok := ParseKeyString(cfg.PaneMode.ActivationShortcut); ok {
244+ if binding, ok := ParseKeyString(cfg.Workspace.PaneMode.ActivationShortcut); ok {
227245 s.Global[binding] = ActionEnterPaneMode
228246 log.Trace().
229- Str("shortcut", cfg.PaneMode.ActivationShortcut).
247+ Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut).
230248 Uint("keyval", binding.Keyval).
231249 Uint("mod", uint(binding.Modifiers)).
232250 Msg("pane mode activation registered")
233251 } 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")
235263 }
236264 }
237265
@@ -278,6 +306,8 @@ func (s *ShortcutSet) registerStandardShortcuts() {
278306 s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
279307 s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
280308 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
281311 }
282312
283313 func (s *ShortcutSet) registerPaneNavigationShortcuts() {
@@ -379,6 +409,10 @@ func mapConfigAction(configAction string) Action {
379409 case "stack-nav-down", "stack-down":
380410 return ActionStackNavDown
381411
412+ // Session actions
413+ case "session-manager":
414+ return ActionOpenSessionManager
415+
382416 default:
383417 return ""
384418 }
@@ -454,6 +488,8 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
454488 modeTable = s.TabMode
455489 case ModePane:
456490 modeTable = s.PaneMode
491+ case ModeSession:
492+ modeTable = s.SessionMode
457493 }
458494
459495 if modeTable != nil {
@@ -475,7 +511,8 @@ func ShouldAutoExitMode(action Action) bool {
475511 switch action {
476512 case ActionNewTab, ActionCloseTab, ActionRenameTab,
477513 ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
478- ActionClosePane, ActionStackPane:
514+ ActionClosePane, ActionStackPane,
515+ ActionOpenSessionManager:
479516 return true
480517 default:
481518 return false
M internal/ui/theme/css.go +166 -0
@@ -106,6 +106,10 @@ func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) st
106106
107107 // Toaster styling
108108 sb.WriteString(generateToasterCSS(p))
109+ sb.WriteString("\n")
110+
111+ // Session manager styling
112+ sb.WriteString(generateSessionManagerCSS(p))
109113
110114 return sb.String()
111115 }
@@ -553,6 +557,168 @@ progressbar.osd progress {
553557 `
554558 }
555559
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+
556722 // generateToasterCSS creates toast notification styles.
557723 // Uses em units for scalable UI.
558724 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