v0.26.2 3cd9d2e

sessions.go RawHistoryFolder

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}

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