sessions.go RawHistoryFolder

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}

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