1package cmd
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "strings"
8
9 tea "github.com/charmbracelet/bubbletea"
10 "github.com/spf13/cobra"
11
12 "github.com/bnema/dumber/internal/application/usecase"
13 "github.com/bnema/dumber/internal/cli/model"
14 "github.com/bnema/dumber/internal/cli/styles"
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 renderer := styles.NewSessionsCLIRenderer(app.Theme)
97
98 if app.ListSessionsUC == nil {
99 err := fmt.Errorf("session management not available")
100 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
101 return wrapPrintedError(err)
102 }
103
104 // Get current session ID (empty if not running as browser)
105 var currentSessionID entity.SessionID
106 if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil {
107 currentSessionID = active.ID
108 }
109
110 output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, sessionsLimit)
111 if err != nil {
112 wrappedErr := fmt.Errorf("list sessions: %w", err)
113 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
114 return wrapPrintedError(wrappedErr)
115 }
116
117 if sessionsJSON {
118 return outputSessionsJSON(output.Sessions)
119 }
120
121 fmt.Println(renderer.RenderList(output.Sessions, sessionsLimit))
122 return nil
123}
124
125func outputSessionsJSON(sessions []entity.SessionInfo) error {
126 enc := json.NewEncoder(os.Stdout)
127 enc.SetIndent("", " ")
128 return enc.Encode(sessions)
129}
130
131// sessions restore <id>
132var sessionsRestoreCmd = &cobra.Command{
133 Use: "restore <session-id>",
134 Short: "Restore a saved session",
135 Long: `Restore a previously saved browser session.
136
137This launches a new browser window with all tabs and panes from the
138saved session. The session ID can be found using 'dumber sessions list'.
139
140You can use a short suffix of the session ID as long as it's unique.
141
142Example:
143 dumber sessions restore 20251224_143022_abc1
144 dumber sessions restore abc1`,
145 Args: cobra.ExactArgs(1),
146 RunE: runSessionsRestore,
147}
148
149func init() {
150 sessionsCmd.AddCommand(sessionsRestoreCmd)
151}
152
153func runSessionsRestore(_ *cobra.Command, args []string) error {
154 app := GetApp()
155 if app == nil {
156 return fmt.Errorf("app not initialized")
157 }
158 renderer := styles.NewSessionsCLIRenderer(app.Theme)
159
160 if app.RestoreUC == nil || app.ListSessionsUC == nil {
161 err := fmt.Errorf("session restoration not available")
162 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
163 return wrapPrintedError(err)
164 }
165
166 // Find session by ID or suffix
167 sessionInfo, err := findSessionByIDOrSuffix(args[0])
168 if err != nil {
169 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
170 return wrapPrintedError(err)
171 }
172
173 sessionID := sessionInfo.Session.ID
174
175 // Validate the session has restorable state
176 _, err = app.RestoreUC.Execute(app.Ctx(), usecase.RestoreInput{SessionID: sessionID})
177 if err != nil {
178 wrappedErr := fmt.Errorf("restore session: %w", err)
179 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
180 return wrapPrintedError(wrappedErr)
181 }
182
183 // Spawn a new dumber instance with the session
184 spawner := desktop.NewSessionSpawner(app.Ctx())
185 if err := spawner.SpawnWithSession(sessionID); err != nil {
186 wrappedErr := fmt.Errorf("spawn browser: %w", err)
187 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
188 return wrapPrintedError(wrappedErr)
189 }
190
191 fmt.Println(renderer.RenderRestoreStarted(sessionID))
192 return nil
193}
194
195// sessions delete <id>
196var sessionsDeleteCmd = &cobra.Command{
197 Use: "delete <session-id>",
198 Short: "Delete a saved session",
199 Long: `Delete a saved browser session and its state.
200
201This permanently removes the session snapshot. Active sessions cannot
202be deleted - close the browser first.
203
204You can use a short suffix of the session ID as long as it's unique.
205
206Example:
207 dumber sessions delete 20251224_143022_abc1
208 dumber sessions delete abc1`,
209 Args: cobra.ExactArgs(1),
210 RunE: runSessionsDelete,
211}
212
213func init() {
214 sessionsCmd.AddCommand(sessionsDeleteCmd)
215}
216
217func runSessionsDelete(_ *cobra.Command, args []string) error {
218 app := GetApp()
219 if app == nil {
220 return fmt.Errorf("app not initialized")
221 }
222 renderer := styles.NewSessionsCLIRenderer(app.Theme)
223
224 if app.DeleteSessionUC == nil || app.ListSessionsUC == nil {
225 err := fmt.Errorf("session management not available")
226 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
227 return wrapPrintedError(err)
228 }
229
230 // Find session by ID or suffix
231 sessionInfo, err := findSessionByIDOrSuffix(args[0])
232 if err != nil {
233 fmt.Fprintln(os.Stderr, renderer.RenderError(err))
234 return wrapPrintedError(err)
235 }
236
237 // Get current session ID
238 var currentSessionID entity.SessionID
239 if active, activeErr := app.SessionUC.GetActiveSession(app.Ctx()); activeErr == nil && active != nil {
240 currentSessionID = active.ID
241 }
242
243 // Delete using use case (handles validation internally)
244 if err := app.DeleteSessionUC.Execute(app.Ctx(), usecase.DeleteSessionInput{
245 SessionID: sessionInfo.Session.ID,
246 CurrentSessionID: currentSessionID,
247 }); err != nil {
248 wrappedErr := fmt.Errorf("delete session: %w", err)
249 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
250 return wrapPrintedError(wrappedErr)
251 }
252
253 fmt.Println(renderer.RenderDeleted(sessionInfo.Session.ID))
254 return nil
255}
256
257// findSessionByIDOrSuffix finds a session by exact ID or unique suffix.
258// Users typically identify sessions by the last few characters (e.g., "dee5").
259func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) {
260 app := GetApp()
261 if app == nil || app.ListSessionsUC == nil {
262 return nil, fmt.Errorf("app not initialized")
263 }
264
265 // Get current session ID (empty if not running as browser)
266 var currentSessionID entity.SessionID
267 if active, err := app.SessionUC.GetActiveSession(app.Ctx()); err == nil && active != nil {
268 currentSessionID = active.ID
269 }
270
271 // List all sessions
272 output, err := app.ListSessionsUC.Execute(app.Ctx(), currentSessionID, defaultSessionsLimit*5)
273 if err != nil {
274 return nil, fmt.Errorf("list sessions: %w", err)
275 }
276
277 // Find matching sessions
278 var matches []entity.SessionInfo
279 for _, info := range output.Sessions {
280 if string(info.Session.ID) == idOrSuffix {
281 // Exact match - return immediately
282 return &info, nil
283 }
284 if strings.HasSuffix(string(info.Session.ID), idOrSuffix) {
285 matches = append(matches, info)
286 }
287 }
288
289 switch len(matches) {
290 case 0:
291 return nil, fmt.Errorf("no session found matching '%s'", idOrSuffix)
292 case 1:
293 return &matches[0], nil
294 default:
295 return nil, fmt.Errorf("ambiguous session ID '%s' matches %d sessions - be more specific", idOrSuffix, len(matches))
296 }
297}