1package cmd
2
3import (
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
19const defaultSessionsLimit = 20
20
21var (
22 sessionsJSON bool
23 sessionsLimit int
24)
25
26var sessionsCmd = &cobra.Command{
27 Use: "sessions",
28 Short: "Manage browser sessions",
29 Long: `View, restore, and manage browser sessions.
30
31Sessions are automatically saved when the browser runs. You can list
32past sessions and restore them to continue where you left off.
33
34Run without arguments to open the interactive session browser.`,
35 RunE: runSessions,
36}
37
38func init() {
39 rootCmd.AddCommand(sessionsCmd)
40}
41
42func 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 MaxExitedSessions: app.Config.Session.MaxExitedSessions,
65 })
66
67 p := tea.NewProgram(m, tea.WithAltScreen())
68 _, err := p.Run()
69 return err
70}
71
72// sessions list
73var sessionsListCmd = &cobra.Command{
74 Use: "list",
75 Short: "List saved sessions",
76 Long: `List all saved browser sessions with their tab/pane counts.
77
78Sessions 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
85func 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
91func 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
119func outputSessionsJSON(sessions []entity.SessionInfo) error {
120 enc := json.NewEncoder(os.Stdout)
121 enc.SetIndent("", " ")
122 return enc.Encode(sessions)
123}
124
125func 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>
157var sessionsRestoreCmd = &cobra.Command{
158 Use: "restore <session-id>",
159 Short: "Restore a saved session",
160 Long: `Restore a previously saved browser session.
161
162This launches a new browser window with all tabs and panes from the
163saved session. The session ID can be found using 'dumber sessions list'.
164
165You can use a short suffix of the session ID as long as it's unique.
166
167Example:
168 dumber sessions restore 20251224_143022_abc1
169 dumber sessions restore abc1`,
170 Args: cobra.ExactArgs(1),
171 RunE: runSessionsRestore,
172}
173
174func init() {
175 sessionsCmd.AddCommand(sessionsRestoreCmd)
176}
177
178func 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>
213var 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
218This permanently removes the session snapshot. Active sessions cannot
219be deleted - close the browser first.
220
221You can use a short suffix of the session ID as long as it's unique.
222
223Example:
224 dumber sessions delete 20251224_143022_abc1
225 dumber sessions delete abc1`,
226 Args: cobra.ExactArgs(1),
227 RunE: runSessionsDelete,
228}
229
230func init() {
231 sessionsCmd.AddCommand(sessionsDeleteCmd)
232}
233
234func 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").
270func 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}