detached e24e5cf

feat(cli): integrate session purging into purge command

commit
e24e5cf5bda464684ff3cbae115a54665ee4f657
author
brice · · 2025-12-25 10:17 UTC
parent
3630652
signature
unsigned
2 files · +276 -74
M internal/cli/cmd/purge.go +34 -5
@@ -24,11 +24,12 @@ var purgeCmd = &cobra.Command{
2424
2525 This can remove:
2626 - Config directory
27- - Data directory
27+ - Data directory (includes database)
2828 - State directory
2929 - Cache directory
3030 - Content filter caches
3131 - Desktop integration files
32+ - Inactive browser sessions
3233
3334 Use --force to remove everything without prompting.`,
3435 RunE: runPurge,
@@ -54,20 +55,48 @@ func runPurge(_ *cobra.Command, _ []string) error {
5455 return runPurgeForce(app, purgeUC)
5556 }
5657
57- m := model.NewPurgeModel(app.Theme, purgeUC)
58+ // Build session config from app dependencies
59+ cfg := model.PurgeModelConfig{
60+ ListSessionsUC: app.ListSessionsUC,
61+ DeleteSessionUC: app.DeleteSessionUC,
62+ }
63+
64+ m := model.NewPurgeModel(app.Ctx(), app.Theme, purgeUC, cfg)
5865 p := tea.NewProgram(m, tea.WithAltScreen())
5966 _, err := p.Run()
6067 return err
6168 }
6269
6370 func runPurgeForce(app *cli.App, purgeUC *usecase.PurgeDataUseCase) error {
64- out, err := purgeUC.PurgeAll(app.Ctx())
71+ ctx := app.Ctx()
72+ theme := app.Theme
73+
74+ // First, delete all inactive sessions (before we delete the database)
75+ if app.ListSessionsUC != nil && app.DeleteSessionUC != nil {
76+ output, err := app.ListSessionsUC.GetPurgeableSessions(ctx)
77+ if err == nil && output != nil {
78+ for _, s := range output.Sessions {
79+ sessionID := s.Info.Session.ID
80+ if delErr := app.DeleteSessionUC.Execute(ctx, usecase.DeleteSessionInput{
81+ SessionID: sessionID,
82+ CurrentSessionID: "", // CLI has no current session
83+ }); delErr != nil {
84+ fmt.Printf("%s Session %s: %v\n", theme.ErrorStyle.Render(styles.IconX), sessionID, delErr)
85+ } else {
86+ fmt.Printf("%s Session %s\n", theme.SuccessStyle.Render(styles.IconCheck), sessionID)
87+ }
88+ }
89+ }
90+ }
91+
92+ // Then purge all directories
93+ out, err := purgeUC.PurgeAll(ctx)
6594 if out != nil {
6695 for _, r := range out.Results {
6796 if r.Success {
68- fmt.Printf("%s %s\n", app.Theme.SuccessStyle.Render(styles.IconCheck), r.Target.Path)
97+ fmt.Printf("%s %s\n", theme.SuccessStyle.Render(styles.IconCheck), r.Target.Path)
6998 } else {
70- fmt.Printf("%s %s: %v\n", app.Theme.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error)
99+ fmt.Printf("%s %s: %v\n", theme.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error)
71100 }
72101 }
73102 }
@@ -24,11 +24,12 @@ var purgeCmd = &cobra.Command{
24 24
25 This can remove:25 This can remove:
26 - Config directory26 - Config directory
27- - Data directory27+ - Data directory (includes database)
28 - State directory28 - State directory
29 - Cache directory29 - Cache directory
30 - Content filter caches30 - Content filter caches
31 - Desktop integration files31 - Desktop integration files
32+ - Inactive browser sessions
32 33
33 Use --force to remove everything without prompting.`,34 Use --force to remove everything without prompting.`,
34 RunE: runPurge,35 RunE: runPurge,
@@ -54,20 +55,48 @@ func runPurge(_ *cobra.Command, _ []string) error {
54 return runPurgeForce(app, purgeUC)55 return runPurgeForce(app, purgeUC)
55 }56 }
56 57
57- m := model.NewPurgeModel(app.Theme, purgeUC)58+ // Build session config from app dependencies
59+ cfg := model.PurgeModelConfig{
60+ ListSessionsUC: app.ListSessionsUC,
61+ DeleteSessionUC: app.DeleteSessionUC,
62+ }
63+
64+ m := model.NewPurgeModel(app.Ctx(), app.Theme, purgeUC, cfg)
58 p := tea.NewProgram(m, tea.WithAltScreen())65 p := tea.NewProgram(m, tea.WithAltScreen())
59 _, err := p.Run()66 _, err := p.Run()
60 return err67 return err
61 }68 }
62 69
63 func runPurgeForce(app *cli.App, purgeUC *usecase.PurgeDataUseCase) error {70 func runPurgeForce(app *cli.App, purgeUC *usecase.PurgeDataUseCase) error {
64- out, err := purgeUC.PurgeAll(app.Ctx())71+ ctx := app.Ctx()
72+ theme := app.Theme
73+
74+ // First, delete all inactive sessions (before we delete the database)
75+ if app.ListSessionsUC != nil && app.DeleteSessionUC != nil {
76+ output, err := app.ListSessionsUC.GetPurgeableSessions(ctx)
77+ if err == nil && output != nil {
78+ for _, s := range output.Sessions {
79+ sessionID := s.Info.Session.ID
80+ if delErr := app.DeleteSessionUC.Execute(ctx, usecase.DeleteSessionInput{
81+ SessionID: sessionID,
82+ CurrentSessionID: "", // CLI has no current session
83+ }); delErr != nil {
84+ fmt.Printf("%s Session %s: %v\n", theme.ErrorStyle.Render(styles.IconX), sessionID, delErr)
85+ } else {
86+ fmt.Printf("%s Session %s\n", theme.SuccessStyle.Render(styles.IconCheck), sessionID)
87+ }
88+ }
89+ }
90+ }
91+
92+ // Then purge all directories
93+ out, err := purgeUC.PurgeAll(ctx)
65 if out != nil {94 if out != nil {
66 for _, r := range out.Results {95 for _, r := range out.Results {
67 if r.Success {96 if r.Success {
68- fmt.Printf("%s %s\n", app.Theme.SuccessStyle.Render(styles.IconCheck), r.Target.Path)97+ fmt.Printf("%s %s\n", theme.SuccessStyle.Render(styles.IconCheck), r.Target.Path)
69 } else {98 } else {
70- fmt.Printf("%s %s: %v\n", app.Theme.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error)99+ fmt.Printf("%s %s: %v\n", theme.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error)
71 }100 }
72 }101 }
73 }102 }
M internal/cli/model/purge.go +242 -69
@@ -14,23 +14,57 @@ import (
1414
1515 // PurgeModel wraps styles.PurgeModel for standalone CLI use.
1616 type PurgeModel struct {
17- selector styles.PurgeModel
18- purgeUC *usecase.PurgeDataUseCase
17+ selector styles.PurgeModel
18+ purgeUC *usecase.PurgeDataUseCase
19+ listSessionsUC *usecase.ListSessionsUseCase
20+ deleteSessionUC *usecase.DeleteSessionUseCase
1921
20- loading bool
21- purging bool
22- done bool
22+ loading bool
23+ loadingSessions bool
24+ purging bool
25+ done bool
2326
24- results *usecase.PurgeOutput
25- info string
26- err error
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
2738
2839 theme *styles.Theme
40+ ctx context.Context
41+}
42+
43+// sessionPurgeResult tracks the result of deleting a session.
44+type sessionPurgeResult struct {
45+ SessionID entity.SessionID
46+ Success bool
47+ Error error
48+}
49+
50+// PurgeModelConfig contains optional dependencies for session purging.
51+type PurgeModelConfig struct {
52+ ListSessionsUC *usecase.ListSessionsUseCase
53+ DeleteSessionUC *usecase.DeleteSessionUseCase
2954 }
3055
3156 // NewPurgeModel creates a new purge command model.
32-func NewPurgeModel(theme *styles.Theme, purgeUC *usecase.PurgeDataUseCase) PurgeModel {
33- return PurgeModel{theme: theme, purgeUC: purgeUC, loading: true}
57+func 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
3468 }
3569
3670 type purgeTargetsLoadedMsg struct {
@@ -38,14 +72,25 @@ type purgeTargetsLoadedMsg struct {
3872 err error
3973 }
4074
75+type purgeSessionsLoadedMsg struct {
76+ sessions []entity.SessionPurgeItem
77+ size int64
78+ err error
79+}
80+
4181 type purgeCompleteMsg struct {
42- out *usecase.PurgeOutput
43- err error
82+ targetResults []entity.PurgeResult
83+ sessionResults []sessionPurgeResult
84+ err error
4485 }
4586
4687 // Init implements tea.Model.
4788 func (m PurgeModel) Init() tea.Cmd {
48- return m.loadTargets()
89+ cmds := []tea.Cmd{m.loadTargets()}
90+ if m.listSessionsUC != nil {
91+ cmds = append(cmds, m.loadSessions())
92+ }
93+ return tea.Batch(cmds...)
4994 }
5095
5196 func (m PurgeModel) loadTargets() tea.Cmd {
@@ -55,68 +100,172 @@ func (m PurgeModel) loadTargets() tea.Cmd {
55100 }
56101 }
57102
103+func (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+
58116 // Update implements tea.Model.
59117 func (m PurgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
60118 switch msg := msg.(type) {
61119 case purgeTargetsLoadedMsg:
62- m.loading = false
63- if msg.err != nil {
64- m.err = msg.err
65- m.done = true
66- return m, nil
67- }
68-
69- m.selector = styles.NewPurge(m.theme, msg.targets)
70- return m, nil
71-
120+ return m.handleTargetsLoaded(msg), nil
121+ case purgeSessionsLoadedMsg:
122+ return m.handleSessionsLoaded(msg), nil
72123 case purgeCompleteMsg:
73- m.purging = false
74- m.done = true
75- m.results = msg.out
76- if msg.err != nil {
77- m.err = msg.err
78- }
79- return m, nil
80-
124+ return m.handlePurgeComplete(msg), nil
81125 case tea.KeyMsg:
82126 if m.done {
83127 return m, tea.Quit
84128 }
85- if m.loading || m.purging {
129+ if m.isBlocked() {
86130 return m, nil
87131 }
132+ // Fall through to updateSelector
88133 }
89134
90- if m.loading || m.purging {
135+ if m.isBlocked() {
91136 return m, nil
92137 }
93138
139+ return m.updateSelector(msg)
140+}
141+
142+func (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+
154+func (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+
167+func (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+
187+func (m PurgeModel) isBlocked() bool {
188+ return m.loading || m.loadingSessions || m.purging
189+}
190+
191+func (m PurgeModel) updateSelector(msg tea.Msg) (tea.Model, tea.Cmd) {
94192 selector, cmd := m.selector.Update(msg)
95193 m.selector = selector
96194
97- if m.selector.Done() {
98- if m.selector.Canceled {
99- return m, tea.Quit
100- }
101- if m.selector.Confirmed {
102- targetTypes := m.selector.SelectedTypes()
103- if len(targetTypes) == 0 {
104- m.done = true
105- m.info = "Nothing selected"
106- return m, nil
107- }
108- m.purging = true
109- return m, m.performPurge(targetTypes)
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
110210 }
211+ m.purging = true
212+ return m, m.performPurge(targetTypes, sessionIDs)
111213 }
112214
113215 return m, cmd
114216 }
115217
116-func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType) tea.Cmd {
218+func (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+
229+func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType, sessionIDs []entity.SessionID) tea.Cmd {
117230 return func() tea.Msg {
118- out, err := m.purgeUC.Execute(context.Background(), usecase.PurgeInput{TargetTypes: targetTypes})
119- return purgeCompleteMsg{out: out, err: err}
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+ }
120269 }
121270 }
122271
@@ -124,7 +273,7 @@ func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType) tea.Cmd {
124273 func (m PurgeModel) View() string {
125274 t := m.theme
126275
127- if m.loading {
276+ if m.loading || m.loadingSessions {
128277 spinner := styles.NewLoading(t, "Scanning purge targets...")
129278 return t.Box.Render(spinner.View())
130279 }
@@ -145,7 +294,7 @@ func (m PurgeModel) View() string {
145294 return t.Box.Render(content)
146295 }
147296
148- if m.err != nil && m.results == nil {
297+ if m.err != nil && m.results == nil && len(m.sessionResults) == 0 {
149298 content := lipgloss.JoinVertical(
150299 lipgloss.Left,
151300 t.ErrorStyle.Render("Error: "+m.err.Error()),
@@ -155,28 +304,52 @@ func (m PurgeModel) View() string {
155304 return t.Box.Render(content)
156305 }
157306
158- contentLines := []string{t.Title.Render("Purge complete")}
159- if m.results != nil {
160- for _, r := range m.results.Results {
161- if r.Success {
162- contentLines = append(contentLines, fmt.Sprintf("%s %s", t.SuccessStyle.Render(styles.IconCheck), r.Target.Path))
163- } else {
164- contentLines = append(contentLines, fmt.Sprintf("%s %s: %v", t.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error))
165- }
307+ return m.renderResults()
308+ }
309+
310+ return m.selector.View()
311+}
312+
313+func (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++
166329 }
167- contentLines = append(
168- contentLines,
169- "",
170- t.Subtle.Render(
171- fmt.Sprintf("%d succeeded, %d failed", m.results.SuccessCount, m.results.FailureCount),
172- ),
173- )
174330 }
175- contentLines = append(contentLines, "", t.Subtle.Render("Press any key to exit"))
176- return t.Box.Render(lipgloss.JoinVertical(lipgloss.Left, contentLines...))
177331 }
178332
179- return m.selector.View()
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...))
180353 }
181354
182355 var _ tea.Model = (*PurgeModel)(nil)
@@ -14,23 +14,57 @@ import (
14 14
15 // PurgeModel wraps styles.PurgeModel for standalone CLI use.15 // PurgeModel wraps styles.PurgeModel for standalone CLI use.
16 type PurgeModel struct {16 type PurgeModel struct {
17- selector styles.PurgeModel17+ selector styles.PurgeModel
18- purgeUC *usecase.PurgeDataUseCase18+ purgeUC *usecase.PurgeDataUseCase
19+ listSessionsUC *usecase.ListSessionsUseCase
20+ deleteSessionUC *usecase.DeleteSessionUseCase
19 21
20- loading bool22+ loading bool
21- purging bool23+ loadingSessions bool
22- done bool24+ purging bool
25+ done bool
23 26
24- results *usecase.PurgeOutput27+ // Track loading state for parallel loading
25- info string28+ targetsLoaded bool
26- err error29+ 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
27 38
28 theme *styles.Theme39 theme *styles.Theme
40+ ctx context.Context
41+}
42+
43+// sessionPurgeResult tracks the result of deleting a session.
44+type sessionPurgeResult struct {
45+ SessionID entity.SessionID
46+ Success bool
47+ Error error
48+}
49+
50+// PurgeModelConfig contains optional dependencies for session purging.
51+type PurgeModelConfig struct {
52+ ListSessionsUC *usecase.ListSessionsUseCase
53+ DeleteSessionUC *usecase.DeleteSessionUseCase
29 }54 }
30 55
31 // NewPurgeModel creates a new purge command model.56 // NewPurgeModel creates a new purge command model.
32-func NewPurgeModel(theme *styles.Theme, purgeUC *usecase.PurgeDataUseCase) PurgeModel {57+func NewPurgeModel(ctx context.Context, theme *styles.Theme, purgeUC *usecase.PurgeDataUseCase, cfg PurgeModelConfig) PurgeModel {
33- return PurgeModel{theme: theme, purgeUC: purgeUC, loading: true}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
34 }68 }
35 69
36 type purgeTargetsLoadedMsg struct {70 type purgeTargetsLoadedMsg struct {
@@ -38,14 +72,25 @@ type purgeTargetsLoadedMsg struct {
38 err error72 err error
39 }73 }
40 74
75+type purgeSessionsLoadedMsg struct {
76+ sessions []entity.SessionPurgeItem
77+ size int64
78+ err error
79+}
80+
41 type purgeCompleteMsg struct {81 type purgeCompleteMsg struct {
42- out *usecase.PurgeOutput82+ targetResults []entity.PurgeResult
43- err error83+ sessionResults []sessionPurgeResult
84+ err error
44 }85 }
45 86
46 // Init implements tea.Model.87 // Init implements tea.Model.
47 func (m PurgeModel) Init() tea.Cmd {88 func (m PurgeModel) Init() tea.Cmd {
48- return m.loadTargets()89+ cmds := []tea.Cmd{m.loadTargets()}
90+ if m.listSessionsUC != nil {
91+ cmds = append(cmds, m.loadSessions())
92+ }
93+ return tea.Batch(cmds...)
49 }94 }
50 95
51 func (m PurgeModel) loadTargets() tea.Cmd {96 func (m PurgeModel) loadTargets() tea.Cmd {
@@ -55,68 +100,172 @@ func (m PurgeModel) loadTargets() tea.Cmd {
55 }100 }
56 }101 }
57 102
103+func (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+
58 // Update implements tea.Model.116 // Update implements tea.Model.
59 func (m PurgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {117 func (m PurgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
60 switch msg := msg.(type) {118 switch msg := msg.(type) {
61 case purgeTargetsLoadedMsg:119 case purgeTargetsLoadedMsg:
62- m.loading = false120+ return m.handleTargetsLoaded(msg), nil
63- if msg.err != nil {121+ case purgeSessionsLoadedMsg:
64- m.err = msg.err122+ return m.handleSessionsLoaded(msg), nil
65- m.done = true
66- return m, nil
67- }
68-
69- m.selector = styles.NewPurge(m.theme, msg.targets)
70- return m, nil
71-
72 case purgeCompleteMsg:123 case purgeCompleteMsg:
73- m.purging = false124+ return m.handlePurgeComplete(msg), nil
74- m.done = true
75- m.results = msg.out
76- if msg.err != nil {
77- m.err = msg.err
78- }
79- return m, nil
80-
81 case tea.KeyMsg:125 case tea.KeyMsg:
82 if m.done {126 if m.done {
83 return m, tea.Quit127 return m, tea.Quit
84 }128 }
85- if m.loading || m.purging {129+ if m.isBlocked() {
86 return m, nil130 return m, nil
87 }131 }
132+ // Fall through to updateSelector
88 }133 }
89 134
90- if m.loading || m.purging {135+ if m.isBlocked() {
91 return m, nil136 return m, nil
92 }137 }
93 138
139+ return m.updateSelector(msg)
140+}
141+
142+func (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+
154+func (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+
167+func (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+
187+func (m PurgeModel) isBlocked() bool {
188+ return m.loading || m.loadingSessions || m.purging
189+}
190+
191+func (m PurgeModel) updateSelector(msg tea.Msg) (tea.Model, tea.Cmd) {
94 selector, cmd := m.selector.Update(msg)192 selector, cmd := m.selector.Update(msg)
95 m.selector = selector193 m.selector = selector
96 194
97- if m.selector.Done() {195+ if !m.selector.Done() {
98- if m.selector.Canceled {196+ return m, cmd
99- return m, tea.Quit197+ }
100- }198+
101- if m.selector.Confirmed {199+ if m.selector.Canceled {
102- targetTypes := m.selector.SelectedTypes()200+ return m, tea.Quit
103- if len(targetTypes) == 0 {201+ }
104- m.done = true202+
105- m.info = "Nothing selected"203+ if m.selector.Confirmed {
106- return m, nil204+ targetTypes := m.selector.SelectedTypes()
107- }205+ sessionIDs := m.selector.SelectedSessionIDs()
108- m.purging = true206+ if len(targetTypes) == 0 && len(sessionIDs) == 0 {
109- return m, m.performPurge(targetTypes)207+ m.done = true
208+ m.info = "Nothing selected"
209+ return m, nil
110 }210 }
211+ m.purging = true
212+ return m, m.performPurge(targetTypes, sessionIDs)
111 }213 }
112 214
113 return m, cmd215 return m, cmd
114 }216 }
115 217
116-func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType) tea.Cmd {218+func (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+
229+func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType, sessionIDs []entity.SessionID) tea.Cmd {
117 return func() tea.Msg {230 return func() tea.Msg {
118- out, err := m.purgeUC.Execute(context.Background(), usecase.PurgeInput{TargetTypes: targetTypes})231+ var targetResults []entity.PurgeResult
119- return purgeCompleteMsg{out: out, err: err}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+ }
120 }269 }
121 }270 }
122 271
@@ -124,7 +273,7 @@ func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType) tea.Cmd {
124 func (m PurgeModel) View() string {273 func (m PurgeModel) View() string {
125 t := m.theme274 t := m.theme
126 275
127- if m.loading {276+ if m.loading || m.loadingSessions {
128 spinner := styles.NewLoading(t, "Scanning purge targets...")277 spinner := styles.NewLoading(t, "Scanning purge targets...")
129 return t.Box.Render(spinner.View())278 return t.Box.Render(spinner.View())
130 }279 }
@@ -145,7 +294,7 @@ func (m PurgeModel) View() string {
145 return t.Box.Render(content)294 return t.Box.Render(content)
146 }295 }
147 296
148- if m.err != nil && m.results == nil {297+ if m.err != nil && m.results == nil && len(m.sessionResults) == 0 {
149 content := lipgloss.JoinVertical(298 content := lipgloss.JoinVertical(
150 lipgloss.Left,299 lipgloss.Left,
151 t.ErrorStyle.Render("Error: "+m.err.Error()),300 t.ErrorStyle.Render("Error: "+m.err.Error()),
@@ -155,28 +304,52 @@ func (m PurgeModel) View() string {
155 return t.Box.Render(content)304 return t.Box.Render(content)
156 }305 }
157 306
158- contentLines := []string{t.Title.Render("Purge complete")}307+ return m.renderResults()
159- if m.results != nil {308+ }
160- for _, r := range m.results.Results {309+
161- if r.Success {310+ return m.selector.View()
162- contentLines = append(contentLines, fmt.Sprintf("%s %s", t.SuccessStyle.Render(styles.IconCheck), r.Target.Path))311+}
163- } else {312+
164- contentLines = append(contentLines, fmt.Sprintf("%s %s: %v", t.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error))313+func (m PurgeModel) renderResults() string {
165- }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++
166 }329 }
167- contentLines = append(
168- contentLines,
169- "",
170- t.Subtle.Render(
171- fmt.Sprintf("%d succeeded, %d failed", m.results.SuccessCount, m.results.FailureCount),
172- ),
173- )
174 }330 }
175- contentLines = append(contentLines, "", t.Subtle.Render("Press any key to exit"))
176- return t.Box.Render(lipgloss.JoinVertical(lipgloss.Left, contentLines...))
177 }331 }
178 332
179- return m.selector.View()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...))
180 }353 }
181 354
182 var _ tea.Model = (*PurgeModel)(nil)355 var _ 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