v0.27.2 5960dd8

sessions.go RawHistoryFolder

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}

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