main 5041353

update.go RawHistoryFolder

1package cmd
2
3import (
4	"context"
5	"fmt"
6
7	"github.com/charmbracelet/bubbles/spinner"
8	tea "github.com/charmbracelet/bubbletea"
9	"github.com/charmbracelet/lipgloss"
10	"github.com/spf13/cobra"
11
12	"github.com/bnema/dumber/internal/application/usecase"
13	"github.com/bnema/dumber/internal/cli/styles"
14	"github.com/bnema/dumber/internal/domain/entity"
15	"github.com/bnema/dumber/internal/infrastructure/config"
16	"github.com/bnema/dumber/internal/infrastructure/updater"
17)
18
19var updateForce bool
20
21var updateCmd = &cobra.Command{
22	Use:   "update",
23	Short: "Check for and install updates",
24	Long: `Check for available updates and download/install them.
25
26Use --force to reinstall even if already up-to-date (skips version check).`,
27	RunE: runUpdate,
28}
29
30func init() {
31	rootCmd.AddCommand(updateCmd)
32	updateCmd.Flags().BoolVarP(&updateForce, "force", "f", false, "force reinstall (skips version check)")
33}
34
35// updateState represents the current state of the update process.
36type updateState int
37
38const (
39	stateChecking updateState = iota
40	stateDownloading
41	stateDone
42)
43
44// updateModel is the bubbletea model for the update command.
45type updateModel struct {
46	spinner  spinner.Model
47	renderer *styles.UpdateRenderer
48	state    updateState
49	force    bool
50
51	// Results from check.
52	updateAvailable bool
53	canAutoUpdate   bool
54	currentVersion  string
55	latestVersion   string
56	releaseURL      string
57	downloadURL     string
58
59	// Final result.
60	result       string
61	err          error
62	quitting     bool
63	updateStaged bool // tracks if an update was actually downloaded and staged
64}
65
66// checkResultMsg is sent when the update check completes.
67type checkResultMsg struct {
68	output *usecase.CheckUpdateOutput
69	err    error
70}
71
72// downloadResultMsg is sent when the download completes.
73type downloadResultMsg struct {
74	output *usecase.ApplyUpdateOutput
75	err    error
76}
77
78func newUpdateModel(renderer *styles.UpdateRenderer, accentColor lipgloss.Color, force bool) updateModel {
79	s := spinner.New()
80	s.Spinner = spinner.Dot
81	s.Style = lipgloss.NewStyle().Foreground(accentColor)
82
83	return updateModel{
84		spinner:  s,
85		renderer: renderer,
86		state:    stateChecking,
87		force:    force,
88	}
89}
90
91func (m updateModel) Init() tea.Cmd {
92	return tea.Batch(m.spinner.Tick, m.checkForUpdates())
93}
94
95func (m updateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
96	switch msg := msg.(type) {
97	case tea.KeyMsg:
98		switch msg.String() {
99		case "q", "ctrl+c":
100			m.quitting = true
101			return m, tea.Quit
102		}
103
104	case spinner.TickMsg:
105		var cmd tea.Cmd
106		m.spinner, cmd = m.spinner.Update(msg)
107		return m, cmd
108
109	case checkResultMsg:
110		if msg.err != nil {
111			m.err = msg.err
112			m.state = stateDone
113			return m, tea.Quit
114		}
115
116		m.updateAvailable = msg.output.UpdateAvailable
117		m.canAutoUpdate = msg.output.CanAutoUpdate
118		m.currentVersion = msg.output.CurrentVersion
119		m.latestVersion = msg.output.LatestVersion
120		m.releaseURL = msg.output.ReleaseURL
121		m.downloadURL = msg.output.DownloadURL
122
123		// Handle dev builds when force is used.
124		if m.force && (m.currentVersion == "" || m.currentVersion == "dev") {
125			m.result = m.renderer.RenderDevBuild()
126			m.state = stateDone
127			return m, tea.Quit
128		}
129
130		// Decide next action.
131		switch {
132		case !m.updateAvailable && !m.force:
133			// Already up to date.
134			m.result = m.renderer.RenderUpToDate(m.currentVersion)
135			m.state = stateDone
136			return m, tea.Quit
137
138		case m.updateAvailable && !m.canAutoUpdate:
139			// Update available but can't auto-update.
140			m.result = m.renderer.RenderCannotAutoUpdate(m.currentVersion, m.latestVersion, m.releaseURL)
141			m.state = stateDone
142			return m, tea.Quit
143
144		default:
145			// Proceed with download (either update available or force mode).
146			m.state = stateDownloading
147			return m, m.downloadUpdate()
148		}
149
150	case downloadResultMsg:
151		m.state = stateDone
152		if msg.err != nil {
153			m.err = msg.err
154			return m, tea.Quit
155		}
156
157		if msg.output.Status == entity.UpdateStatusFailed {
158			m.result = m.renderer.RenderError(fmt.Errorf("%s", msg.output.Message))
159		} else {
160			m.result = m.renderer.RenderStaged(m.latestVersion)
161			m.updateStaged = true
162		}
163		return m, tea.Quit
164	}
165
166	return m, nil
167}
168
169func (m updateModel) View() string {
170	if m.quitting {
171		return ""
172	}
173
174	if m.err != nil {
175		return m.renderer.RenderError(m.err)
176	}
177
178	if m.state == stateDone {
179		return m.result
180	}
181
182	switch m.state {
183	case stateChecking:
184		return m.renderer.RenderChecking(m.spinner.View())
185	case stateDownloading:
186		version := m.latestVersion
187		if m.force && !m.updateAvailable {
188			version = m.currentVersion
189		}
190		return m.renderer.RenderDownloading(m.spinner.View(), version)
191	default:
192		return ""
193	}
194}
195
196func (updateModel) checkForUpdates() tea.Cmd {
197	return func() tea.Msg {
198		ctx := context.Background()
199
200		checker := updater.NewGitHubChecker()
201		applier, err := updater.NewApplierFromXDG()
202		if err != nil {
203			return checkResultMsg{err: fmt.Errorf("failed to create applier: %w", err)}
204		}
205
206		cliApp := GetApp()
207		if cliApp == nil {
208			return checkResultMsg{err: fmt.Errorf("app not initialized")}
209		}
210
211		checkUC := usecase.NewCheckUpdateUseCase(checker, applier, cliApp.BuildInfo)
212		result, err := checkUC.Execute(ctx, usecase.CheckUpdateInput{})
213
214		return checkResultMsg{output: result, err: err}
215	}
216}
217
218func (m updateModel) downloadUpdate() tea.Cmd {
219	return func() tea.Msg {
220		ctx := context.Background()
221
222		downloader := updater.NewGitHubDownloader()
223		applier, err := updater.NewApplierFromXDG()
224		if err != nil {
225			return downloadResultMsg{err: fmt.Errorf("failed to create applier: %w", err)}
226		}
227
228		dirs, err := config.GetXDGDirs()
229		if err != nil {
230			return downloadResultMsg{err: fmt.Errorf("failed to get cache dir: %w", err)}
231		}
232
233		applyUC := usecase.NewApplyUpdateUseCase(downloader, applier, dirs.CacheHome)
234		result, err := applyUC.Execute(ctx, usecase.ApplyUpdateInput{
235			DownloadURL: m.downloadURL,
236		})
237
238		return downloadResultMsg{output: result, err: err}
239	}
240}
241
242func runUpdate(_ *cobra.Command, _ []string) error {
243	cliApp := GetApp()
244	if cliApp == nil {
245		return fmt.Errorf("app not initialized")
246	}
247
248	renderer := styles.NewUpdateRenderer(cliApp.Theme)
249
250	// Create and run the bubbletea program.
251	m := newUpdateModel(renderer, cliApp.Theme.Accent, updateForce)
252	p := tea.NewProgram(m)
253
254	finalModel, err := p.Run()
255	if err != nil {
256		return fmt.Errorf("update failed: %w", err)
257	}
258
259	// Check if the update was staged and we need to finalize on exit.
260	if model, ok := finalModel.(updateModel); ok {
261		if model.updateStaged {
262			// If update was successfully staged, finalize it.
263			if err := finalizeUpdate(); err != nil {
264				fmt.Println(renderer.RenderError(err))
265			}
266		}
267	}
268
269	return nil
270}
271
272// finalizeUpdate applies any staged update.
273func finalizeUpdate() error {
274	ctx := context.Background()
275
276	downloader := updater.NewGitHubDownloader()
277	applier, err := updater.NewApplierFromXDG()
278	if err != nil {
279		return fmt.Errorf("failed to create applier: %w", err)
280	}
281
282	dirs, err := config.GetXDGDirs()
283	if err != nil {
284		return fmt.Errorf("failed to get cache dir: %w", err)
285	}
286
287	applyUC := usecase.NewApplyUpdateUseCase(downloader, applier, dirs.CacheHome)
288	return applyUC.FinalizeOnExit(ctx)
289}

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