v0.27.2 5960dd8

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 prefix strings.Builder
357
358	// Header
359	prefix.WriteString(m.renderHeader())
360	prefix.WriteString("\n")
361
362	// Error display
363	if m.err != nil {
364		prefix.WriteString("\n")
365		prefix.WriteString(t.ErrorStyle.Render(fmt.Sprintf("%s Error: %v", styles.IconX, m.err)))
366		prefix.WriteString("\n")
367	}
368
369	// Status message
370	if m.statusMessage != "" {
371		prefix.WriteString("\n")
372		prefix.WriteString(t.Subtle.Render(m.statusMessage))
373		prefix.WriteString("\n")
374	}
375
376	// Help
377	helpView := m.help.View(m.keys)
378
379	// Compute available height for list so the view never exceeds terminal height.
380	// This prevents Bubble Tea from truncating the bottom of the UI when there
381	// are many sessions.
382	prefixH := lipgloss.Height(prefix.String())
383	helpH := lipgloss.Height(helpView)
384	listH := m.height - prefixH - 1 - helpH // one spacer line between list and help
385	if listH < 1 {
386		listH = 1
387	}
388
389	listView := m.renderSessionsList(listH)
390
391	// Compose final output with a single spacer line above help.
392	return strings.TrimRight(prefix.String(), "\n") + "\n" + listView + "\n" + helpView
393}
394
395func (m SessionsModel) renderHeader() string {
396	t := m.theme
397
398	iconStyle := lipgloss.NewStyle().Foreground(t.Accent)
399	titleStyle := t.Title.MarginLeft(1)
400
401	icon := iconStyle.Render(styles.IconSession)
402	title := titleStyle.Render("Sessions")
403
404	// Count stats
405	var activeCount, exitedCount int
406	for _, s := range m.sessions {
407		if s.IsCurrent || s.IsActive {
408			activeCount++
409		} else {
410			exitedCount++
411		}
412	}
413
414	stats := t.Subtle.Render(fmt.Sprintf("  %s %d active  %s %d exited",
415		styles.IconPlay, activeCount,
416		styles.IconStop, exitedCount,
417	))
418
419	return icon + title + stats
420}
421
422func (m SessionsModel) renderSessionsList(maxHeight int) string {
423	t := m.theme
424
425	if maxHeight <= 0 {
426		return ""
427	}
428	if len(m.sessions) == 0 {
429		return t.Subtle.Render("  No saved sessions found.")
430	}
431
432	// Determine starting index so the selected row stays visible even when
433	// rows above it are expanded and consume multiple lines.
434	start := m.selectedIdx
435	if start < 0 {
436		start = 0
437	}
438	if start >= len(m.sessions) {
439		start = len(m.sessions) - 1
440	}
441	used := m.rowHeight(start)
442	for start > 0 {
443		prevHeight := m.rowHeight(start - 1)
444		if used+prevHeight > maxHeight {
445			break
446		}
447		start--
448		used += prevHeight
449	}
450
451	var b strings.Builder
452	remaining := maxHeight
453
454	for i := start; i < len(m.sessions) && remaining > 0; i++ {
455		info := m.sessions[i]
456		isSelected := i == m.selectedIdx
457		isExpanded := i == m.expandedIdx
458
459		b.WriteString(m.renderSessionRow(info, isSelected, isExpanded))
460		remaining--
461		if remaining <= 0 {
462			break
463		}
464
465		// Render expanded details, but never exceed available height.
466		if isExpanded && remaining > 0 {
467			details := m.renderSessionDetails(info)
468			details = truncateLines(details, remaining)
469			if details != "" {
470				b.WriteString("\n")
471				b.WriteString(details)
472				remaining -= lipgloss.Height(details)
473			}
474		}
475
476		if remaining > 0 {
477			b.WriteString("\n")
478		}
479	}
480
481	return strings.TrimRight(b.String(), "\n")
482}
483
484func (m SessionsModel) rowHeight(idx int) int {
485	if idx < 0 || idx >= len(m.sessions) {
486		return 0
487	}
488
489	height := 1 // always render one row line
490	if idx != m.expandedIdx {
491		return height
492	}
493
494	info := m.sessions[idx]
495	details := m.renderSessionDetails(info)
496	if details == "" {
497		return height
498	}
499
500	return height + lipgloss.Height(details)
501}
502
503func (m SessionsModel) renderSessionRow(info entity.SessionInfo, isSelected, isExpanded bool) string {
504	t := m.theme
505
506	// Cursor
507	cursor := "  "
508	if isSelected {
509		cursor = t.Highlight.Render(styles.IconCursor + " ")
510	}
511
512	// Status icon
513	var statusIcon string
514	var statusStyle lipgloss.Style
515	switch {
516	case info.IsCurrent:
517		statusIcon = styles.IconPlay
518		statusStyle = lipgloss.NewStyle().Foreground(t.Accent)
519	case info.IsActive:
520		statusIcon = styles.IconPlay
521		statusStyle = lipgloss.NewStyle().Foreground(t.Warning)
522	default:
523		statusIcon = styles.IconStop
524		statusStyle = t.Subtle
525	}
526
527	// Session ID
528	idStyle := t.Normal
529	if isSelected {
530		idStyle = t.Highlight
531	}
532
533	// Label for current/active
534	label := ""
535	if info.IsCurrent {
536		label = t.Badge.Render("current")
537	} else if info.IsActive {
538		label = t.BadgeMuted.Render("active")
539	}
540
541	// Expand indicator
542	expandIcon := styles.IconExpand
543	if isExpanded {
544		expandIcon = styles.IconCollapse
545	}
546	expandStyle := t.Subtle
547
548	// Tab/pane counts
549	counts := t.Subtle.Render(fmt.Sprintf("%s %d  %s %d",
550		styles.IconTab, info.TabCount,
551		styles.IconPane, info.PaneCount,
552	))
553
554	// Time
555	relTime := usecase.GetRelativeTime(info.UpdatedAt)
556	timeStr := t.Subtle.Render(fmt.Sprintf("%s %s", styles.IconClock, relTime))
557
558	// Build row
559	row := fmt.Sprintf("%s%s %s %s  %s  %s  %s",
560		cursor,
561		statusStyle.Render(statusIcon),
562		idStyle.Render(string(info.Session.ID)),
563		label,
564		expandStyle.Render(expandIcon),
565		counts,
566		timeStr,
567	)
568
569	return row
570}
571
572func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string {
573	t := m.theme
574	var b strings.Builder
575
576	if info.State == nil {
577		b.WriteString(t.Subtle.Render("      No state data available"))
578		return b.String()
579	}
580
581	// Tree prefix styles
582	treeStyle := lipgloss.NewStyle().Foreground(t.Border)
583	leafStyle := lipgloss.NewStyle().Foreground(t.Muted)
584
585	// Render tabs as tree
586	for tabIdx, tab := range info.State.Tabs {
587		isLastTab := tabIdx == len(info.State.Tabs)-1
588
589		// Tab branch
590		branch := "├── "
591		if isLastTab {
592			branch = "└── "
593		}
594
595		tabIcon := styles.IconTab
596		if tab.IsPinned {
597			tabIcon = styles.IconCheckboxChecked
598		}
599
600		tabName := tab.Name
601		if tabName == "" {
602			tabName = "Tab"
603		}
604
605		b.WriteString(fmt.Sprintf("      %s%s %s\n",
606			treeStyle.Render(branch),
607			leafStyle.Render(tabIcon),
608			t.Normal.Render(tabName),
609		))
610
611		// Render panes under this tab
612		m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle)
613	}
614
615	return b.String()
616}
617
618func truncateLines(s string, maxLines int) string {
619	if maxLines <= 0 || s == "" {
620		return ""
621	}
622	lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
623	if len(lines) <= maxLines {
624		return strings.Join(lines, "\n")
625	}
626	return strings.Join(lines[:maxLines], "\n")
627}
628
629func (m SessionsModel) renderPaneTree(
630	b *strings.Builder,
631	ws *entity.WorkspaceSnapshot,
632	isLastTab bool,
633	t *styles.Theme,
634	treeStyle, leafStyle lipgloss.Style,
635) {
636	if ws == nil || ws.Root == nil {
637		return
638	}
639
640	// Determine prefix for children
641	childPrefix := "      │   "
642	if isLastTab {
643		childPrefix = "          "
644	}
645
646	m.renderPaneNode(b, ws.Root, childPrefix, true, t, treeStyle, leafStyle)
647}
648
649func (m SessionsModel) renderPaneNode(
650	b *strings.Builder,
651	node *entity.PaneNodeSnapshot,
652	prefix string,
653	isLast bool,
654	t *styles.Theme,
655	treeStyle, leafStyle lipgloss.Style,
656) {
657	if node == nil {
658		return
659	}
660
661	branch := "├── "
662	if isLast {
663		branch = "└── "
664	}
665
666	if node.Pane != nil {
667		// Leaf node (actual pane)
668		pane := node.Pane
669		title := pane.Title
670		if title == "" {
671			title = pane.URI
672		}
673		// Truncate long titles
674		const maxTitleLen = 50
675		if len(title) > maxTitleLen {
676			title = title[:maxTitleLen-3] + "..."
677		}
678
679		fmt.Fprintf(b, "%s%s%s %s\n",
680			prefix,
681			treeStyle.Render(branch),
682			leafStyle.Render(styles.IconPane),
683			t.Subtle.Render(title),
684		)
685	} else if len(node.Children) > 0 {
686		// Container node (split or stacked)
687		splitIcon := styles.IconSessionStack
688		splitType := "split"
689		if node.IsStacked {
690			splitType = "stacked"
691		} else if node.SplitRatio > 0 {
692			splitType = fmt.Sprintf("split %.0f%%", node.SplitRatio*100)
693		}
694
695		fmt.Fprintf(b, "%s%s%s %s\n",
696			prefix,
697			treeStyle.Render(branch),
698			leafStyle.Render(splitIcon),
699			t.Subtle.Render(splitType),
700		)
701
702		// Child prefix
703		childPrefix := prefix + "│   "
704		if isLast {
705			childPrefix = prefix + "    "
706		}
707
708		// Render all children
709		for i, child := range node.Children {
710			isLastChild := i == len(node.Children)-1
711			m.renderPaneNode(b, child, childPrefix, isLastChild, t, treeStyle, leafStyle)
712		}
713	}
714}
715
716// Ensure interface compliance at compile time.
717var _ 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