1package cmd
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "strings"
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/cli/styles"
16 "github.com/bnema/dumber/internal/domain/entity"
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 cliApp := GetApp()
44 if cliApp == nil {
45 return fmt.Errorf("app not initialized")
46 }
47
48 if cliApp.ListSessionsUC == nil || cliApp.SessionUC == 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 := cliApp.SessionUC.GetActiveSession(cliApp.Ctx()); err == nil && active != nil {
55 currentSessionID = active.ID
56 }
57
58 // Run interactive TUI
59 m := model.NewSessionsModel(cliApp.Ctx(), cliApp.Theme, model.SessionsModelConfig{
60 ListSessionsUC: cliApp.ListSessionsUC,
61 RestoreUC: cliApp.RestoreUC,
62 DeleteSessionUC: cliApp.DeleteSessionUC,
63 SessionSpawner: cliApp.SessionSpawner,
64 CurrentSession: currentSessionID,
65 MaxExitedSessions: cliApp.Config.Session.MaxExitedSessions,
66 })
67
68 p := tea.NewProgram(m, tea.WithAltScreen())
69 _, err := p.Run()
70 return err
71}
72
73// sessions list
74var sessionsListCmd = &cobra.Command{
75 Use: "list",
76 Short: "List saved sessions",
77 Long: `List all saved browser sessions with their tab/pane counts.
78
79Sessions are marked as:
80 ● current - the currently running session
81 ○ active - another running dumber instance
82 (blank) - exited session available for restoration`,
83 RunE: runSessionsList,
84}
85
86func init() {
87 sessionsCmd.AddCommand(sessionsListCmd)
88 sessionsListCmd.Flags().BoolVar(&sessionsJSON, "json", false, "output as JSON")
89 sessionsListCmd.Flags().IntVar(&sessionsLimit, "limit", defaultSessionsLimit, "maximum sessions to show")
90}
91
92func runSessionsList(_ *cobra.Command, _ []string) error {
93 cliApp := GetApp()
94 if cliApp == nil {
95 return fmt.Errorf("app not initialized")
96 }
97 renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)
98
99 if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
100 err := fmt.Errorf("session management not available")
101 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
102 return wrapPrintedError(err)
103 }
104
105 // Get current session ID (empty if not running as browser)
106 var currentSessionID entity.SessionID
107 if active, err := cliApp.SessionUC.GetActiveSession(cliApp.Ctx()); err == nil && active != nil {
108 currentSessionID = active.ID
109 }
110
111 output, err := cliApp.ListSessionsUC.Execute(cliApp.Ctx(), currentSessionID, sessionsLimit)
112 if err != nil {
113 wrappedErr := fmt.Errorf("list sessions: %w", err)
114 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
115 return wrapPrintedError(wrappedErr)
116 }
117
118 if sessionsJSON {
119 return outputSessionsJSON(output.Sessions)
120 }
121
122 fmt.Println(renderer.RenderList(output.Sessions, sessionsLimit))
123 return nil
124}
125
126func outputSessionsJSON(sessions []entity.SessionInfo) error {
127 enc := json.NewEncoder(os.Stdout)
128 enc.SetIndent("", " ")
129 return enc.Encode(sessions)
130}
131
132// sessions restore <id>
133var sessionsRestoreCmd = &cobra.Command{
134 Use: "restore <session-id>",
135 Short: "Restore a saved session",
136 Long: `Restore a previously saved browser session.
137
138This launches a new browser window with all tabs and panes from the
139saved session. The session ID can be found using 'dumber sessions list'.
140
141You can use a short suffix of the session ID as long as it's unique.
142
143Example:
144 dumber sessions restore 20251224_143022_abc1
145 dumber sessions restore abc1`,
146 Args: cobra.ExactArgs(1),
147 RunE: runSessionsRestore,
148}
149
150func init() {
151 sessionsCmd.AddCommand(sessionsRestoreCmd)
152}
153
154func runSessionsRestore(_ *cobra.Command, args []string) error {
155 cliApp := GetApp()
156 if cliApp == nil {
157 return fmt.Errorf("app not initialized")
158 }
159 renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)
160
161 if cliApp.RestoreUC == nil || cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
162 err := fmt.Errorf("session restoration not available")
163 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
164 return wrapPrintedError(err)
165 }
166
167 // Find session by ID or suffix
168 sessionInfo, err := findSessionByIDOrSuffix(args[0])
169 if err != nil {
170 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
171 return wrapPrintedError(err)
172 }
173
174 sessionID := sessionInfo.Session.ID
175
176 // Validate the session has restorable state
177 _, err = cliApp.RestoreUC.Execute(cliApp.Ctx(), usecase.RestoreInput{SessionID: sessionID})
178 if err != nil {
179 wrappedErr := fmt.Errorf("restore session: %w", err)
180 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
181 return wrapPrintedError(wrappedErr)
182 }
183
184 // Spawn a new dumber instance with the session
185 if cliApp.SessionSpawner == nil {
186 err := fmt.Errorf("session spawner not available")
187 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
188 return wrapPrintedError(err)
189 }
190 if err := cliApp.SessionSpawner.SpawnWithSession(sessionID); err != nil {
191 wrappedErr := fmt.Errorf("spawn browser: %w", err)
192 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
193 return wrapPrintedError(wrappedErr)
194 }
195
196 fmt.Println(renderer.RenderRestoreStarted(sessionID))
197 return nil
198}
199
200// sessions delete <id>
201var sessionsDeleteCmd = &cobra.Command{
202 Use: "delete <session-id>",
203 Short: "Delete a saved session",
204 Long: `Delete a saved browser session and its state.
205
206This permanently removes the session snapshot. Active sessions cannot
207be deleted - close the browser first.
208
209You can use a short suffix of the session ID as long as it's unique.
210
211Example:
212 dumber sessions delete 20251224_143022_abc1
213 dumber sessions delete abc1`,
214 Args: cobra.ExactArgs(1),
215 RunE: runSessionsDelete,
216}
217
218func init() {
219 sessionsCmd.AddCommand(sessionsDeleteCmd)
220}
221
222func runSessionsDelete(_ *cobra.Command, args []string) error {
223 cliApp := GetApp()
224 if cliApp == nil {
225 return fmt.Errorf("app not initialized")
226 }
227 renderer := styles.NewSessionsCLIRenderer(cliApp.Theme)
228
229 if cliApp.DeleteSessionUC == nil || cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
230 err := fmt.Errorf("session management not available")
231 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
232 return wrapPrintedError(err)
233 }
234
235 // Find session by ID or suffix
236 sessionInfo, err := findSessionByIDOrSuffix(args[0])
237 if err != nil {
238 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
239 return wrapPrintedError(err)
240 }
241
242 // Get current session ID
243 var currentSessionID entity.SessionID
244 if active, activeErr := cliApp.SessionUC.GetActiveSession(cliApp.Ctx()); activeErr == nil && active != nil {
245 currentSessionID = active.ID
246 }
247
248 // Delete using use case (handles validation internally)
249 if err := cliApp.DeleteSessionUC.Execute(cliApp.Ctx(), usecase.DeleteSessionInput{
250 SessionID: sessionInfo.Session.ID,
251 CurrentSessionID: currentSessionID,
252 }); err != nil {
253 wrappedErr := fmt.Errorf("delete session: %w", err)
254 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
255 return wrapPrintedError(wrappedErr)
256 }
257
258 fmt.Println(renderer.RenderDeleted(sessionInfo.Session.ID))
259 return nil
260}
261
262// findSessionByIDOrSuffix finds a session by exact ID or unique suffix.
263// Users typically identify sessions by the last few characters (e.g., "dee5").
264func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) {
265 cliApp := GetApp()
266 if cliApp == nil {
267 return nil, fmt.Errorf("app not initialized")
268 }
269 if cliApp.ListSessionsUC == nil || cliApp.SessionUC == nil {
270 return nil, fmt.Errorf("session management not available")
271 }
272
273 ctx := cliApp.Ctx()
274 if ctx == nil {
275 ctx = context.Background()
276 }
277
278 // Get current session ID (empty if not running as browser)
279 var currentSessionID entity.SessionID
280 if active, err := cliApp.SessionUC.GetActiveSession(ctx); err == nil && active != nil {
281 currentSessionID = active.ID
282 }
283
284 // List all sessions so restore/delete suffix lookup can find older sessions.
285 output, err := cliApp.ListSessionsUC.Execute(ctx, currentSessionID, usecase.ListAllSessionsLimit)
286 if err != nil {
287 return nil, fmt.Errorf("list sessions: %w", err)
288 }
289
290 // Find matching sessions
291 var matches []entity.SessionInfo
292 for _, info := range output.Sessions {
293 if string(info.Session.ID) == idOrSuffix {
294 // Exact match - return immediately
295 return &info, nil
296 }
297 if strings.HasSuffix(string(info.Session.ID), idOrSuffix) {
298 matches = append(matches, info)
299 }
300 }
301
302 switch len(matches) {
303 case 0:
304 return nil, fmt.Errorf("no session found matching '%s'", idOrSuffix)
305 case 1:
306 return &matches[0], nil
307 default:
308 return nil, fmt.Errorf("ambiguous session ID '%s' matches %d sessions - be more specific", idOrSuffix, len(matches))
309 }
310}