v0.27.2 5960dd8

purge.go RawHistoryFolder

1package model
2
3import (
4	"context"
5	"fmt"
6
7	tea "github.com/charmbracelet/bubbletea"
8	"github.com/charmbracelet/lipgloss"
9
10	"github.com/bnema/dumber/internal/application/usecase"
11	"github.com/bnema/dumber/internal/cli/styles"
12	"github.com/bnema/dumber/internal/domain/entity"
13)
14
15// PurgeModel wraps styles.PurgeModel for standalone CLI use.
16type PurgeModel struct {
17	selector        styles.PurgeModel
18	purgeUC         *usecase.PurgeDataUseCase
19	listSessionsUC  *usecase.ListSessionsUseCase
20	deleteSessionUC *usecase.DeleteSessionUseCase
21
22	loading         bool
23	loadingSessions bool
24	purging         bool
25	done            bool
26
27	// Track loading state for parallel loading
28	targetsLoaded  bool
29	sessionsLoaded bool
30	targets        []entity.PurgeTarget
31	sessions       []entity.SessionPurgeItem
32	sessionsSize   int64
33
34	results        *usecase.PurgeOutput
35	sessionResults []sessionPurgeResult
36	info           string
37	err            error
38
39	theme *styles.Theme
40	ctx   context.Context
41}
42
43// sessionPurgeResult tracks the result of deleting a session.
44type sessionPurgeResult struct {
45	SessionID entity.SessionID
46	Success   bool
47	Error     error
48}
49
50// PurgeModelConfig contains optional dependencies for session purging.
51type PurgeModelConfig struct {
52	ListSessionsUC  *usecase.ListSessionsUseCase
53	DeleteSessionUC *usecase.DeleteSessionUseCase
54}
55
56// NewPurgeModel creates a new purge command model.
57func NewPurgeModel(ctx context.Context, theme *styles.Theme, purgeUC *usecase.PurgeDataUseCase, cfg PurgeModelConfig) PurgeModel {
58	m := PurgeModel{
59		theme:           theme,
60		purgeUC:         purgeUC,
61		listSessionsUC:  cfg.ListSessionsUC,
62		deleteSessionUC: cfg.DeleteSessionUC,
63		loading:         true,
64		loadingSessions: cfg.ListSessionsUC != nil,
65		ctx:             ctx,
66	}
67	return m
68}
69
70type purgeTargetsLoadedMsg struct {
71	targets []entity.PurgeTarget
72	err     error
73}
74
75type purgeSessionsLoadedMsg struct {
76	sessions []entity.SessionPurgeItem
77	size     int64
78	err      error
79}
80
81type purgeCompleteMsg struct {
82	targetResults  []entity.PurgeResult
83	sessionResults []sessionPurgeResult
84	err            error
85}
86
87// Init implements tea.Model.
88func (m PurgeModel) Init() tea.Cmd {
89	cmds := []tea.Cmd{m.loadTargets()}
90	if m.listSessionsUC != nil {
91		cmds = append(cmds, m.loadSessions())
92	}
93	return tea.Batch(cmds...)
94}
95
96func (m PurgeModel) loadTargets() tea.Cmd {
97	return func() tea.Msg {
98		targets, err := m.purgeUC.GetPurgeTargets(context.Background())
99		return purgeTargetsLoadedMsg{targets: targets, err: err}
100	}
101}
102
103func (m PurgeModel) loadSessions() tea.Cmd {
104	return func() tea.Msg {
105		output, err := m.listSessionsUC.GetPurgeableSessions(context.Background())
106		if err != nil {
107			return purgeSessionsLoadedMsg{err: err}
108		}
109		return purgeSessionsLoadedMsg{
110			sessions: output.Sessions,
111			size:     output.TotalSize,
112		}
113	}
114}
115
116// Update implements tea.Model.
117func (m PurgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
118	switch msg := msg.(type) {
119	case purgeTargetsLoadedMsg:
120		return m.handleTargetsLoaded(msg), nil
121	case purgeSessionsLoadedMsg:
122		return m.handleSessionsLoaded(msg), nil
123	case purgeCompleteMsg:
124		return m.handlePurgeComplete(msg), nil
125	case tea.KeyMsg:
126		if m.done {
127			return m, tea.Quit
128		}
129		if m.isBlocked() {
130			return m, nil
131		}
132		// Fall through to updateSelector
133	}
134
135	if m.isBlocked() {
136		return m, nil
137	}
138
139	return m.updateSelector(msg)
140}
141
142func (m PurgeModel) handleTargetsLoaded(msg purgeTargetsLoadedMsg) PurgeModel {
143	if msg.err != nil {
144		m.err = msg.err
145		m.done = true
146		m.loading = false
147		return m
148	}
149	m.targetsLoaded = true
150	m.targets = msg.targets
151	return m.checkLoadingComplete()
152}
153
154func (m PurgeModel) handleSessionsLoaded(msg purgeSessionsLoadedMsg) PurgeModel {
155	if msg.err != nil {
156		// Session loading failure is non-fatal, continue without sessions
157		m.sessionsLoaded = true
158		m.loadingSessions = false
159		return m.checkLoadingComplete()
160	}
161	m.sessionsLoaded = true
162	m.sessions = msg.sessions
163	m.sessionsSize = msg.size
164	return m.checkLoadingComplete()
165}
166
167func (m PurgeModel) handlePurgeComplete(msg purgeCompleteMsg) PurgeModel {
168	m.purging = false
169	m.done = true
170	if msg.targetResults != nil {
171		m.results = &usecase.PurgeOutput{Results: msg.targetResults}
172		for _, r := range msg.targetResults {
173			if r.Success {
174				m.results.SuccessCount++
175			} else {
176				m.results.FailureCount++
177			}
178		}
179	}
180	m.sessionResults = msg.sessionResults
181	if msg.err != nil {
182		m.err = msg.err
183	}
184	return m
185}
186
187func (m PurgeModel) isBlocked() bool {
188	return m.loading || m.loadingSessions || m.purging
189}
190
191func (m PurgeModel) updateSelector(msg tea.Msg) (tea.Model, tea.Cmd) {
192	selector, cmd := m.selector.Update(msg)
193	m.selector = selector
194
195	if !m.selector.Done() {
196		return m, cmd
197	}
198
199	if m.selector.Canceled {
200		return m, tea.Quit
201	}
202
203	if m.selector.Confirmed {
204		targetTypes := m.selector.SelectedTypes()
205		sessionIDs := m.selector.SelectedSessionIDs()
206		if len(targetTypes) == 0 && len(sessionIDs) == 0 {
207			m.done = true
208			m.info = "Nothing selected"
209			return m, nil
210		}
211		m.purging = true
212		return m, m.performPurge(targetTypes, sessionIDs)
213	}
214
215	return m, cmd
216}
217
218func (m PurgeModel) checkLoadingComplete() PurgeModel {
219	// If sessions aren't being loaded, only wait for targets
220	needSessions := m.listSessionsUC != nil
221	if m.targetsLoaded && (!needSessions || m.sessionsLoaded) {
222		m.loading = false
223		m.loadingSessions = false
224		m.selector = styles.NewPurgeWithSessions(m.theme, m.targets, m.sessions, m.sessionsSize)
225	}
226	return m
227}
228
229func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType, sessionIDs []entity.SessionID) tea.Cmd {
230	return func() tea.Msg {
231		var targetResults []entity.PurgeResult
232		var sessionResults []sessionPurgeResult
233		var lastErr error
234
235		// Delete sessions first (if any selected and Data not selected)
236		if m.deleteSessionUC != nil {
237			for _, sid := range sessionIDs {
238				err := m.deleteSessionUC.Execute(m.ctx, usecase.DeleteSessionInput{
239					SessionID:        sid,
240					CurrentSessionID: "", // CLI has no current session
241				})
242				sessionResults = append(sessionResults, sessionPurgeResult{
243					SessionID: sid,
244					Success:   err == nil,
245					Error:     err,
246				})
247				if err != nil {
248					lastErr = err
249				}
250			}
251		}
252
253		// Then delete target directories
254		if len(targetTypes) > 0 {
255			out, err := m.purgeUC.Execute(m.ctx, usecase.PurgeInput{TargetTypes: targetTypes})
256			if out != nil {
257				targetResults = out.Results
258			}
259			if err != nil {
260				lastErr = err
261			}
262		}
263
264		return purgeCompleteMsg{
265			targetResults:  targetResults,
266			sessionResults: sessionResults,
267			err:            lastErr,
268		}
269	}
270}
271
272// View implements tea.Model.
273func (m PurgeModel) View() string {
274	t := m.theme
275
276	if m.loading || m.loadingSessions {
277		spinner := styles.NewLoading(t, "Scanning purge targets...")
278		return t.Box.Render(spinner.View())
279	}
280
281	if m.purging {
282		spinner := styles.NewLoading(t, "Purging...")
283		return t.Box.Render(spinner.View())
284	}
285
286	if m.done {
287		if m.info != "" {
288			content := lipgloss.JoinVertical(
289				lipgloss.Left,
290				t.Subtle.Render(m.info),
291				"",
292				t.Subtle.Render("Press any key to exit"),
293			)
294			return t.Box.Render(content)
295		}
296
297		if m.err != nil && m.results == nil && len(m.sessionResults) == 0 {
298			content := lipgloss.JoinVertical(
299				lipgloss.Left,
300				t.ErrorStyle.Render("Error: "+m.err.Error()),
301				"",
302				t.Subtle.Render("Press any key to exit"),
303			)
304			return t.Box.Render(content)
305		}
306
307		return m.renderResults()
308	}
309
310	return m.selector.View()
311}
312
313func (m PurgeModel) renderResults() string {
314	t := m.theme
315	contentLines := []string{t.Title.Render("Purge complete")}
316
317	successCount := 0
318	failureCount := 0
319
320	// Target results
321	if m.results != nil {
322		for _, r := range m.results.Results {
323			if r.Success {
324				contentLines = append(contentLines, fmt.Sprintf("%s %s", t.SuccessStyle.Render(styles.IconCheck), r.Target.Path))
325				successCount++
326			} else {
327				contentLines = append(contentLines, fmt.Sprintf("%s %s: %v", t.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error))
328				failureCount++
329			}
330		}
331	}
332
333	// Session results
334	for _, r := range m.sessionResults {
335		if r.Success {
336			contentLines = append(contentLines, fmt.Sprintf("%s Session %s", t.SuccessStyle.Render(styles.IconCheck), r.SessionID))
337			successCount++
338		} else {
339			contentLines = append(contentLines, fmt.Sprintf("%s Session %s: %v", t.ErrorStyle.Render(styles.IconX), r.SessionID, r.Error))
340			failureCount++
341		}
342	}
343
344	contentLines = append(
345		contentLines,
346		"",
347		t.Subtle.Render(fmt.Sprintf("%d succeeded, %d failed", successCount, failureCount)),
348		"",
349		t.Subtle.Render("Press any key to exit"),
350	)
351
352	return t.Box.Render(lipgloss.JoinVertical(lipgloss.Left, contentLines...))
353}
354
355var _ tea.Model = (*PurgeModel)(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