v0.26.0 ae3a77a

sessions.go RawHistoryFolder

1// Package model provides Bubble Tea models for CLI commands.
2package model
3
4import (
5	"context"
6	"fmt"
7	"strings"
8
9	"github.com/charmbracelet/bubbles/help"
10	"github.com/charmbracelet/bubbles/key"
11	tea "github.com/charmbracelet/bubbletea"
12	"github.com/charmbracelet/lipgloss"
13
14	"github.com/bnema/dumber/internal/application/usecase"
15	"github.com/bnema/dumber/internal/cli/styles"
16	"github.com/bnema/dumber/internal/domain/entity"
17	"github.com/bnema/dumber/internal/infrastructure/desktop"
18	"github.com/bnema/dumber/internal/logging"
19)
20
21// SessionsModel is the Bubble Tea model for interactive session browser.
22type SessionsModel struct {
23	// UI components
24	help    help.Model
25	keys    sessionsKeyMap
26	confirm *styles.ConfirmModel
27
28	// State
29	sessions       []entity.SessionInfo
30	selectedIdx    int
31	expandedIdx    int // -1 means none expanded
32	width          int
33	height         int
34	err            error
35	statusMessage  string
36	currentSession entity.SessionID
37
38	// Config
39	maxListedSessions int
40
41	// Dependencies
42	ctx             context.Context
43	listSessionsUC  *usecase.ListSessionsUseCase
44	restoreUC       *usecase.RestoreSessionUseCase
45	deleteSessionUC *usecase.DeleteSessionUseCase
46	theme           *styles.Theme
47}
48
49// sessionsKeyMap defines keybindings for the sessions browser.
50type sessionsKeyMap struct {
51	Up      key.Binding
52	Down    key.Binding
53	Expand  key.Binding
54	Restore key.Binding
55	Delete  key.Binding
56	Refresh key.Binding
57	Help    key.Binding
58	Quit    key.Binding
59}
60
61// ShortHelp returns keybindings for the short help view.
62func (k sessionsKeyMap) ShortHelp() []key.Binding {
63	return []key.Binding{k.Up, k.Down, k.Expand, k.Restore, k.Delete, k.Quit}
64}
65
66// FullHelp returns keybindings for the full help view.
67func (k sessionsKeyMap) FullHelp() [][]key.Binding {
68	return [][]key.Binding{
69		{k.Up, k.Down, k.Expand},
70		{k.Restore, k.Delete, k.Refresh},
71		{k.Help, k.Quit},
72	}
73}
74
75func defaultSessionsKeyMap() sessionsKeyMap {
76	return sessionsKeyMap{
77		Up: key.NewBinding(
78			key.WithKeys("k", "up"),
79			key.WithHelp("↑/k", "up"),
80		),
81		Down: key.NewBinding(
82			key.WithKeys("j", "down"),
83			key.WithHelp("↓/j", "down"),
84		),
85		Expand: key.NewBinding(
86			key.WithKeys("enter", "tab"),
87			key.WithHelp("enter", "expand/collapse"),
88		),
89		Restore: key.NewBinding(
90			key.WithKeys("r"),
91			key.WithHelp("r", "restore"),
92		),
93		Delete: key.NewBinding(
94			key.WithKeys("x", "d"),
95			key.WithHelp("x", "delete"),
96		),
97		Refresh: key.NewBinding(
98			key.WithKeys("R"),
99			key.WithHelp("R", "refresh"),
100		),
101		Help: key.NewBinding(
102			key.WithKeys("?"),
103			key.WithHelp("?", "help"),
104		),
105		Quit: key.NewBinding(
106			key.WithKeys("q", "esc", "ctrl+c"),
107			key.WithHelp("q", "quit"),
108		),
109	}
110}
111
112// SessionsModelConfig holds configuration for the sessions model.
113type SessionsModelConfig struct {
114	ListSessionsUC    *usecase.ListSessionsUseCase
115	RestoreUC         *usecase.RestoreSessionUseCase
116	DeleteSessionUC   *usecase.DeleteSessionUseCase
117	CurrentSession    entity.SessionID
118	MaxExitedSessions int
119}
120
121// NewSessionsModel creates a new sessions browser model.
122func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsModelConfig) SessionsModel {
123	maxListed := cfg.MaxExitedSessions
124	if maxListed <= 0 {
125		maxListed = 50 // fallback default
126	}
127
128	return SessionsModel{
129		help:              help.New(),
130		keys:              defaultSessionsKeyMap(),
131		selectedIdx:       0,
132		expandedIdx:       -1,
133		width:             80,
134		height:            24,
135		maxListedSessions: maxListed,
136		ctx:               ctx,
137		listSessionsUC:    cfg.ListSessionsUC,
138		restoreUC:         cfg.RestoreUC,
139		deleteSessionUC:   cfg.DeleteSessionUC,
140		currentSession:    cfg.CurrentSession,
141		theme:             theme,
142	}
143}
144
145// Init implements tea.Model.
146func (m SessionsModel) Init() tea.Cmd {
147	return m.loadSessions
148}
149
150// sessionsLoadedMsg is sent when sessions are loaded.
151type sessionsLoadedMsg struct {
152	sessions []entity.SessionInfo
153	err      error
154}
155
156// sessionDeletedMsg is sent when a session is deleted.
157type sessionDeletedMsg struct {
158	sessionID entity.SessionID
159	err       error
160}
161
162// sessionRestoredMsg is sent when a session restoration is triggered.
163type sessionRestoredMsg struct {
164	sessionID entity.SessionID
165	err       error
166}
167
168func (m SessionsModel) loadSessions() tea.Msg {
169	log := logging.FromContext(m.ctx)
170	log.Debug().Msg("loading sessions")
171
172	if m.listSessionsUC == nil {
173		return sessionsLoadedMsg{err: fmt.Errorf("session management not available")}
174	}
175
176	output, err := m.listSessionsUC.Execute(m.ctx, m.currentSession, m.maxListedSessions)
177	if err != nil {
178		log.Error().Err(err).Msg("failed to load sessions")
179		return sessionsLoadedMsg{err: err}
180	}
181
182	log.Debug().Int("count", len(output.Sessions)).Msg("loaded sessions")
183	return sessionsLoadedMsg{sessions: output.Sessions}
184}
185
186// Update implements tea.Model.
187func (m SessionsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
188	// Handle confirm modal
189	if m.confirm != nil {
190		return m.handleConfirmModal(msg)
191	}
192
193	switch msg := msg.(type) {
194	case tea.WindowSizeMsg:
195		m.width = msg.Width
196		m.height = msg.Height
197		m.help.Width = msg.Width
198		return m, nil
199
200	case tea.KeyMsg:
201		return m.handleKeyMsg(msg)
202
203	case sessionsLoadedMsg:
204		if msg.err != nil {
205			m.err = msg.err
206		} else {
207			m.sessions = msg.sessions
208			m.err = nil
209		}
210		return m, nil
211
212	case sessionDeletedMsg:
213		if msg.err != nil {
214			m.statusMessage = fmt.Sprintf("Error: %v", msg.err)
215		} else {
216			m.statusMessage = fmt.Sprintf("Session %s deleted", msg.sessionID)
217		}
218		return m, m.loadSessions
219
220	case sessionRestoredMsg:
221		if msg.err != nil {
222			m.statusMessage = fmt.Sprintf("Error: %v", msg.err)
223		} else {
224			m.statusMessage = fmt.Sprintf("Restoring session %s...", msg.sessionID)
225			return m, tea.Quit
226		}
227		return m, nil
228	}
229
230	return m, nil
231}
232
233func (m SessionsModel) handleConfirmModal(msg tea.Msg) (tea.Model, tea.Cmd) {
234	confirm, cmd := m.confirm.Update(msg)
235	m.confirm = &confirm
236	if m.confirm.Done() {
237		if m.confirm.Result() {
238			// User confirmed deletion
239			if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
240				session := m.sessions[m.selectedIdx]
241				cmd = m.deleteSession(session.Session.ID)
242			}
243		}
244		m.confirm = nil
245		return m, cmd
246	}
247	return m, cmd
248}
249
250func (m SessionsModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
251	switch {
252	case key.Matches(msg, m.keys.Quit):
253		return m, tea.Quit
254
255	case key.Matches(msg, m.keys.Up):
256		if m.selectedIdx > 0 {
257			m.selectedIdx--
258		}
259		return m, nil
260
261	case key.Matches(msg, m.keys.Down):
262		if m.selectedIdx < len(m.sessions)-1 {
263			m.selectedIdx++
264		}
265		return m, nil
266
267	case key.Matches(msg, m.keys.Expand):
268		if m.expandedIdx == m.selectedIdx {
269			m.expandedIdx = -1 // collapse
270		} else {
271			m.expandedIdx = m.selectedIdx // expand
272		}
273		return m, nil
274
275	case key.Matches(msg, m.keys.Restore):
276		if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
277			session := m.sessions[m.selectedIdx]
278			if session.IsCurrent {
279				m.statusMessage = "Cannot restore current session"
280				return m, nil
281			}
282			return m, m.restoreSession(session.Session.ID)
283		}
284		return m, nil
285
286	case key.Matches(msg, m.keys.Delete):
287		if m.selectedIdx >= 0 && m.selectedIdx < len(m.sessions) {
288			session := m.sessions[m.selectedIdx]
289			if session.IsCurrent || session.IsActive {
290				m.statusMessage = "Cannot delete active session"
291				return m, nil
292			}
293			confirm := styles.NewConfirm(m.theme, fmt.Sprintf("Delete session %s?", session.Session.ID))
294			m.confirm = &confirm
295		}
296		return m, nil
297
298	case key.Matches(msg, m.keys.Refresh):
299		return m, m.loadSessions
300
301	case key.Matches(msg, m.keys.Help):
302		m.help.ShowAll = !m.help.ShowAll
303		return m, nil
304	}
305
306	return m, nil
307}
308
309func (m SessionsModel) deleteSession(sessionID entity.SessionID) tea.Cmd {
310	return func() tea.Msg {
311		if m.deleteSessionUC == nil {
312			return sessionDeletedMsg{sessionID: sessionID, err: fmt.Errorf("delete session use case not available")}
313		}
314
315		err := m.deleteSessionUC.Execute(m.ctx, usecase.DeleteSessionInput{
316			SessionID:        sessionID,
317			CurrentSessionID: m.currentSession,
318		})
319		return sessionDeletedMsg{sessionID: sessionID, err: err}
320	}
321}
322
323func (m SessionsModel) restoreSession(sessionID entity.SessionID) tea.Cmd {
324	return func() tea.Msg {
325		log := logging.FromContext(m.ctx)
326		log.Info().Str("session_id", string(sessionID)).Msg("restoring session")
327
328		if m.restoreUC == nil {
329			return sessionRestoredMsg{sessionID: sessionID, err: fmt.Errorf("restore use case not available")}
330		}
331
332		// Validate the session has restorable state
333		_, err := m.restoreUC.Execute(m.ctx, usecase.RestoreInput{SessionID: sessionID})
334		if err != nil {
335			return sessionRestoredMsg{sessionID: sessionID, err: err}
336		}
337
338		// Spawn a new dumber instance
339		spawner := desktop.NewSessionSpawner(m.ctx)
340		if err := spawner.SpawnWithSession(sessionID); err != nil {
341			return sessionRestoredMsg{sessionID: sessionID, err: err}
342		}
343
344		return sessionRestoredMsg{sessionID: sessionID}
345	}
346}
347
348// View implements tea.Model.
349func (m SessionsModel) View() string {
350	// Handle confirm modal
351	if m.confirm != nil {
352		return m.confirm.View()
353	}
354
355	t := m.theme
356	var b strings.Builder
357
358	// Header
359	header := m.renderHeader()
360	b.WriteString(header)
361	b.WriteString("\n\n")
362
363	// Error display
364	if m.err != nil {
365		b.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err)))
366		b.WriteString("\n\n")
367	}
368
369	// Status message
370	if m.statusMessage != "" {
371		b.WriteString(t.Subtle.Render(m.statusMessage))
372		b.WriteString("\n\n")
373	}
374
375	// Sessions list
376	if len(m.sessions) == 0 {
377		b.WriteString(t.Subtle.Render("  No saved sessions found."))
378		b.WriteString("\n")
379	} else {
380		b.WriteString(m.renderSessionsList())
381	}
382
383	b.WriteString("\n")
384
385	// Help
386	helpView := m.help.View(m.keys)
387	b.WriteString(helpView)
388
389	return b.String()
390}
391
392func (m SessionsModel) renderHeader() string {
393	t := m.theme
394
395	iconStyle := lipgloss.NewStyle().Foreground(t.Accent)
396	titleStyle := t.Title.MarginLeft(1)
397
398	icon := iconStyle.Render(styles.IconSession)
399	title := titleStyle.Render("Sessions")
400
401	// Count stats
402	var activeCount, exitedCount int
403	for _, s := range m.sessions {
404		if s.IsCurrent || s.IsActive {
405			activeCount++
406		} else {
407			exitedCount++
408		}
409	}
410
411	stats := t.Subtle.Render(fmt.Sprintf("  %s %d active  %s %d exited",
412		styles.IconPlay, activeCount,
413		styles.IconStop, exitedCount,
414	))
415
416	return icon + title + stats
417}
418
419func (m SessionsModel) renderSessionsList() string {
420	var b strings.Builder
421
422	for i, info := range m.sessions {
423		isSelected := i == m.selectedIdx
424		isExpanded := i == m.expandedIdx
425
426		b.WriteString(m.renderSessionRow(info, isSelected, isExpanded))
427		b.WriteString("\n")
428
429		// Render expanded details
430		if isExpanded && info.State != nil {
431			b.WriteString(m.renderSessionDetails(info))
432		}
433	}
434
435	return b.String()
436}
437
438func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string {
439	t := m.theme
440
441	// Cursor
442	cursor := "  "
443	if isSelected {
444		cursor = t.Highlight.Render(styles.IconCursor + " ")
445	}
446
447	// Status icon
448	var statusIcon string
449	var statusStyle lipgloss.Style
450	switch {
451	case info.IsCurrent:
452		statusIcon = styles.IconPlay
453		statusStyle = lipgloss.NewStyle().Foreground(t.Accent)
454	case info.IsActive:
455		statusIcon = styles.IconPlay
456		statusStyle = lipgloss.NewStyle().Foreground(t.Warning)
457	default:
458		statusIcon = styles.IconStop
459		statusStyle = t.Subtle
460	}
461
462	// Session ID
463	idStyle := t.Normal
464	if isSelected {
465		idStyle = t.Highlight
466	}
467
468	// Label for current/active
469	label := ""
470	if info.IsCurrent {
471		label = t.Badge.Render("current")
472	} else if info.IsActive {
473		label = t.BadgeMuted.Render("active")
474	}
475
476	// Expand indicator
477	expandIcon := styles.IconExpand
478	if isExpanded {
479		expandIcon = styles.IconCollapse
480	}
481	expandStyle := t.Subtle
482
483	// Tab/pane counts
484	counts := t.Subtle.Render(fmt.Sprintf("%s %d  %s %d",
485		styles.IconTab, info.TabCount,
486		styles.IconPane, info.PaneCount,
487	))
488
489	// Time
490	relTime := usecase.GetRelativeTime(info.UpdatedAt)
491	timeStr := t.Subtle.Render(fmt.Sprintf("%s %s", styles.IconClock, relTime))
492
493	// Build row
494	row := fmt.Sprintf("%s%s %s %s  %s  %s  %s",
495		cursor,
496		statusStyle.Render(statusIcon),
497		idStyle.Render(string(info.Session.ID)),
498		label,
499		expandStyle.Render(expandIcon),
500		counts,
501		timeStr,
502	)
503
504	return row
505}
506
507func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string {
508	t := m.theme
509	var b strings.Builder
510
511	if info.State == nil {
512		b.WriteString(t.Subtle.Render("      No state data available\n"))
513		return b.String()
514	}
515
516	// Tree prefix styles
517	treeStyle := lipgloss.NewStyle().Foreground(t.Border)
518	leafStyle := lipgloss.NewStyle().Foreground(t.Muted)
519
520	// Render tabs as tree
521	for tabIdx, tab := range info.State.Tabs {
522		isLastTab := tabIdx == len(info.State.Tabs)-1
523
524		// Tab branch
525		branch := "├── "
526		if isLastTab {
527			branch = "└── "
528		}
529
530		tabIcon := styles.IconTab
531		if tab.IsPinned {
532			tabIcon = styles.IconCheckboxChecked
533		}
534
535		tabName := tab.Name
536		if tabName == "" {
537			tabName = "Tab"
538		}
539
540		b.WriteString(fmt.Sprintf("      %s%s %s\n",
541			treeStyle.Render(branch),
542			leafStyle.Render(tabIcon),
543			t.Normal.Render(tabName),
544		))
545
546		// Render panes under this tab
547		m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle)
548	}
549
550	b.WriteString("\n")
551	return b.String()
552}
553
554func (m SessionsModel) renderPaneTree(
555	b *strings.Builder,
556	ws *entity.WorkspaceSnapshot,
557	isLastTab bool,
558	t *styles.Theme,
559	treeStyle, leafStyle lipgloss.Style,
560) {
561	if ws == nil || ws.Root == nil {
562		return
563	}
564
565	// Determine prefix for children
566	childPrefix := "      │   "
567	if isLastTab {
568		childPrefix = "          "
569	}
570
571	m.renderPaneNode(b, ws.Root, childPrefix, true, t, treeStyle, leafStyle)
572}
573
574func (m SessionsModel) renderPaneNode(
575	b *strings.Builder,
576	node *entity.PaneNodeSnapshot,
577	prefix string,
578	isLast bool,
579	t *styles.Theme,
580	treeStyle, leafStyle lipgloss.Style,
581) {
582	if node == nil {
583		return
584	}
585
586	branch := "├── "
587	if isLast {
588		branch = "└── "
589	}
590
591	if node.Pane != nil {
592		// Leaf node (actual pane)
593		pane := node.Pane
594		title := pane.Title
595		if title == "" {
596			title = pane.URI
597		}
598		// Truncate long titles
599		const maxTitleLen = 50
600		if len(title) > maxTitleLen {
601			title = title[:maxTitleLen-3] + "..."
602		}
603
604		fmt.Fprintf(b, "%s%s%s %s\n",
605			prefix,
606			treeStyle.Render(branch),
607			leafStyle.Render(styles.IconPane),
608			t.Subtle.Render(title),
609		)
610	} else if len(node.Children) > 0 {
611		// Container node (split or stacked)
612		splitIcon := styles.IconSessionStack
613		splitType := "split"
614		if node.IsStacked {
615			splitType = "stacked"
616		} else if node.SplitRatio > 0 {
617			splitType = fmt.Sprintf("split %.0f%%", node.SplitRatio*100)
618		}
619
620		fmt.Fprintf(b, "%s%s%s %s\n",
621			prefix,
622			treeStyle.Render(branch),
623			leafStyle.Render(splitIcon),
624			t.Subtle.Render(splitType),
625		)
626
627		// Child prefix
628		childPrefix := prefix + "│   "
629		if isLast {
630			childPrefix = prefix + "    "
631		}
632
633		// Render all children
634		for i, child := range node.Children {
635			isLastChild := i == len(node.Children)-1
636			m.renderPaneNode(b, child, childPrefix, isLastChild, t, treeStyle, leafStyle)
637		}
638	}
639}
640
641// Ensure interface compliance at compile time.
642var _ tea.Model = (*SessionsModel)(nil)

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