detached 495ecb8

Merge pull request #44 from bnema/feature/cli-self-update

feat(cli): add self-update command with --force flag
commit
495ecb80393200c04e214bedb7b188b0782caeaa
author
bnema · · 2025-12-27 18:04 UTC
committer
GitHub · · 2025-12-27 18:04 UTC
merge of
3b36d17 + 0232110 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +415 -0
M CHANGELOG.md +5 -0
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Added
8+- **CLI self-update command**: New `dumber update` command to check for and install updates from the command line.
9+ - Interactive spinner animation during check and download phases.
10+ - `--force` / `-f` flag to skip version check and reinstall the current version.
11+ - Reuses existing auto-update infrastructure (check, download, stage, apply on exit).
12+ - Theme-aware styling consistent with other CLI commands.
813 - **Move pane to tab**: New pane-mode actions to move the active pane to another tab.
914 - `Ctrl+P → m`: Opens a tab picker modal.
1015 - `Ctrl+P → M`: Moves the active pane to the next tab (creates a new tab if needed).
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Added7 ### Added
8+- **CLI self-update command**: New `dumber update` command to check for and install updates from the command line.
9+ - Interactive spinner animation during check and download phases.
10+ - `--force` / `-f` flag to skip version check and reinstall the current version.
11+ - Reuses existing auto-update infrastructure (check, download, stage, apply on exit).
12+ - Theme-aware styling consistent with other CLI commands.
8 - **Move pane to tab**: New pane-mode actions to move the active pane to another tab.13 - **Move pane to tab**: New pane-mode actions to move the active pane to another tab.
9 - `Ctrl+P → m`: Opens a tab picker modal.14 - `Ctrl+P → m`: Opens a tab picker modal.
10 - `Ctrl+P → M`: Moves the active pane to the next tab (creates a new tab if needed).15 - `Ctrl+P → M`: Moves the active pane to the next tab (creates a new tab if needed).
M README.md +3 -0
@@ -244,6 +244,9 @@ sudo apt install va-driver-all
244244 - `dumber browse "!g golang"` # search via bang shortcut
245245 - Show version information:
246246 - `dumber about` # version, commit, and build date
247+- Check for and install updates:
248+ - `dumber update` # check and install if available
249+ - `dumber update --force` # force reinstall (skips version check)
247250 - Launcher integration (dmenu‑style examples with favicon support):
248251 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`
249252 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`
@@ -244,6 +244,9 @@ sudo apt install va-driver-all
244 - `dumber browse "!g golang"` # search via bang shortcut244 - `dumber browse "!g golang"` # search via bang shortcut
245 - Show version information:245 - Show version information:
246 - `dumber about` # version, commit, and build date246 - `dumber about` # version, commit, and build date
247+- Check for and install updates:
248+ - `dumber update` # check and install if available
249+ - `dumber update --force` # force reinstall (skips version check)
247 - Launcher integration (dmenu‑style examples with favicon support):250 - Launcher integration (dmenu‑style examples with favicon support):
248 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`251 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`
249 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`252 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`
A internal/cli/cmd/update.go +289 -0
@@ -0,0 +1,289 @@
1+package cmd
2+
3+import (
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+
19+var updateForce bool
20+
21+var updateCmd = &cobra.Command{
22+ Use: "update",
23+ Short: "Check for and install updates",
24+ Long: `Check for available updates and download/install them.
25+
26+Use --force to reinstall even if already up-to-date (skips version check).`,
27+ RunE: runUpdate,
28+}
29+
30+func 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.
36+type updateState int
37+
38+const (
39+ stateChecking updateState = iota
40+ stateDownloading
41+ stateDone
42+)
43+
44+// updateModel is the bubbletea model for the update command.
45+type 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.
67+type checkResultMsg struct {
68+ output *usecase.CheckUpdateOutput
69+ err error
70+}
71+
72+// downloadResultMsg is sent when the download completes.
73+type downloadResultMsg struct {
74+ output *usecase.ApplyUpdateOutput
75+ err error
76+}
77+
78+func 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+
91+func (m updateModel) Init() tea.Cmd {
92+ return tea.Batch(m.spinner.Tick, m.checkForUpdates())
93+}
94+
95+func (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+
169+func (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+
196+func (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+ app := GetApp()
207+ if app == nil {
208+ return checkResultMsg{err: fmt.Errorf("app not initialized")}
209+ }
210+
211+ checkUC := usecase.NewCheckUpdateUseCase(checker, applier, app.BuildInfo)
212+ result, err := checkUC.Execute(ctx, usecase.CheckUpdateInput{})
213+
214+ return checkResultMsg{output: result, err: err}
215+ }
216+}
217+
218+func (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+
242+func runUpdate(_ *cobra.Command, _ []string) error {
243+ app := GetApp()
244+ if app == nil {
245+ return fmt.Errorf("app not initialized")
246+ }
247+
248+ renderer := styles.NewUpdateRenderer(app.Theme)
249+
250+ // Create and run the bubbletea program.
251+ m := newUpdateModel(renderer, app.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.
273+func 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+}
@@ -0,0 +1,289 @@
1+package cmd
2+
3+import (
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+
19+var updateForce bool
20+
21+var updateCmd = &cobra.Command{
22+ Use: "update",
23+ Short: "Check for and install updates",
24+ Long: `Check for available updates and download/install them.
25+
26+Use --force to reinstall even if already up-to-date (skips version check).`,
27+ RunE: runUpdate,
28+}
29+
30+func 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.
36+type updateState int
37+
38+const (
39+ stateChecking updateState = iota
40+ stateDownloading
41+ stateDone
42+)
43+
44+// updateModel is the bubbletea model for the update command.
45+type 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.
67+type checkResultMsg struct {
68+ output *usecase.CheckUpdateOutput
69+ err error
70+}
71+
72+// downloadResultMsg is sent when the download completes.
73+type downloadResultMsg struct {
74+ output *usecase.ApplyUpdateOutput
75+ err error
76+}
77+
78+func 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+
91+func (m updateModel) Init() tea.Cmd {
92+ return tea.Batch(m.spinner.Tick, m.checkForUpdates())
93+}
94+
95+func (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+
169+func (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+
196+func (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+ app := GetApp()
207+ if app == nil {
208+ return checkResultMsg{err: fmt.Errorf("app not initialized")}
209+ }
210+
211+ checkUC := usecase.NewCheckUpdateUseCase(checker, applier, app.BuildInfo)
212+ result, err := checkUC.Execute(ctx, usecase.CheckUpdateInput{})
213+
214+ return checkResultMsg{output: result, err: err}
215+ }
216+}
217+
218+func (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+
242+func runUpdate(_ *cobra.Command, _ []string) error {
243+ app := GetApp()
244+ if app == nil {
245+ return fmt.Errorf("app not initialized")
246+ }
247+
248+ renderer := styles.NewUpdateRenderer(app.Theme)
249+
250+ // Create and run the bubbletea program.
251+ m := newUpdateModel(renderer, app.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.
273+func 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+}
M internal/cli/styles/icons.go +5 -0
@@ -53,4 +53,9 @@ const (
5353 IconRestore = "\uf0e2" // rotate-left (restore)
5454 IconExpand = "\uf065" // expand
5555 IconCollapse = "\uf066" // compress
56+
57+ // Update / download
58+ IconDownload = "\uf019" // download
59+ IconUpdate = "\uf021" // refresh/sync
60+ IconRocket = "\uf135" // rocket (upgrade)
5661 )
@@ -53,4 +53,9 @@ const (
53 IconRestore = "\uf0e2" // rotate-left (restore)53 IconRestore = "\uf0e2" // rotate-left (restore)
54 IconExpand = "\uf065" // expand54 IconExpand = "\uf065" // expand
55 IconCollapse = "\uf066" // compress55 IconCollapse = "\uf066" // compress
56+
57+ // Update / download
58+ IconDownload = "\uf019" // download
59+ IconUpdate = "\uf021" // refresh/sync
60+ IconRocket = "\uf135" // rocket (upgrade)
56 )61 )
A internal/cli/styles/update.go +113 -0
@@ -0,0 +1,113 @@
1+package styles
2+
3+import (
4+ "fmt"
5+
6+ "github.com/charmbracelet/lipgloss"
7+)
8+
9+// UpdateRenderer renders update status messages with styled output.
10+type UpdateRenderer struct {
11+ theme *Theme
12+}
13+
14+// NewUpdateRenderer creates a new update renderer with the given theme.
15+func NewUpdateRenderer(theme *Theme) *UpdateRenderer {
16+ return &UpdateRenderer{theme: theme}
17+}
18+
19+// RenderChecking renders the "checking for updates" message.
20+func (*UpdateRenderer) RenderChecking(spinner string) string {
21+ return fmt.Sprintf("\n %s Checking for updates...\n", spinner)
22+}
23+
24+// RenderUpToDate renders the "already up to date" message.
25+func (r *UpdateRenderer) RenderUpToDate(version string) string {
26+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
27+ versionStyle := r.theme.Highlight
28+
29+ return fmt.Sprintf(
30+ "\n %s Already up to date (%s)\n",
31+ iconStyle.Render(IconCheck),
32+ versionStyle.Render(version),
33+ )
34+}
35+
36+// RenderAvailable renders the "update available" message.
37+func (r *UpdateRenderer) RenderAvailable(current, latest, releaseURL string) string {
38+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
39+ versionStyle := r.theme.Highlight
40+ urlStyle := r.theme.Subtle
41+
42+ return fmt.Sprintf(
43+ "\n %s Update available: %s %s %s\n %s\n",
44+ iconStyle.Render(IconRocket),
45+ versionStyle.Render(current),
46+ iconStyle.Render(IconArrow),
47+ versionStyle.Render(latest),
48+ urlStyle.Render(releaseURL),
49+ )
50+}
51+
52+// RenderDownloading renders the "downloading" message with spinner.
53+func (r *UpdateRenderer) RenderDownloading(spinner, version string) string {
54+ versionStyle := r.theme.Highlight
55+
56+ return fmt.Sprintf(
57+ "\n %s Downloading %s...\n",
58+ spinner,
59+ versionStyle.Render(version),
60+ )
61+}
62+
63+// RenderStaged renders the "update staged" success message.
64+func (r *UpdateRenderer) RenderStaged(version string) string {
65+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
66+ versionStyle := r.theme.Highlight
67+
68+ return fmt.Sprintf(
69+ "\n %s Update %s downloaded and staged\n %s Will be applied when this command exits\n",
70+ iconStyle.Render(IconCheck),
71+ versionStyle.Render(version),
72+ iconStyle.Render(IconCheck),
73+ )
74+}
75+
76+// RenderError renders an error message.
77+func (r *UpdateRenderer) RenderError(err error) string {
78+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Error)
79+
80+ return fmt.Sprintf(
81+ "\n %s Update failed: %v\n",
82+ iconStyle.Render(IconX),
83+ err,
84+ )
85+}
86+
87+// RenderCannotAutoUpdate renders the "cannot auto-update" message.
88+func (r *UpdateRenderer) RenderCannotAutoUpdate(current, latest, releaseURL string) string {
89+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
90+ versionStyle := r.theme.Highlight
91+ urlStyle := r.theme.Subtle
92+
93+ return fmt.Sprintf(
94+ "\n %s Update available: %s %s %s\n"+
95+ " %s Cannot auto-update: binary is not writable\n"+
96+ " Download manually: %s\n",
97+ iconStyle.Render(IconRocket),
98+ versionStyle.Render(current),
99+ iconStyle.Render(IconArrow),
100+ versionStyle.Render(latest),
101+ iconStyle.Render(IconWarning),
102+ urlStyle.Render(releaseURL),
103+ )
104+}
105+
106+// RenderDevBuild renders the "dev build" skip message.
107+func (r *UpdateRenderer) RenderDevBuild() string {
108+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
109+ return fmt.Sprintf(
110+ "\n %s Development build - update check skipped\n",
111+ iconStyle.Render(IconInfo),
112+ )
113+}
@@ -0,0 +1,113 @@
1+package styles
2+
3+import (
4+ "fmt"
5+
6+ "github.com/charmbracelet/lipgloss"
7+)
8+
9+// UpdateRenderer renders update status messages with styled output.
10+type UpdateRenderer struct {
11+ theme *Theme
12+}
13+
14+// NewUpdateRenderer creates a new update renderer with the given theme.
15+func NewUpdateRenderer(theme *Theme) *UpdateRenderer {
16+ return &UpdateRenderer{theme: theme}
17+}
18+
19+// RenderChecking renders the "checking for updates" message.
20+func (*UpdateRenderer) RenderChecking(spinner string) string {
21+ return fmt.Sprintf("\n %s Checking for updates...\n", spinner)
22+}
23+
24+// RenderUpToDate renders the "already up to date" message.
25+func (r *UpdateRenderer) RenderUpToDate(version string) string {
26+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
27+ versionStyle := r.theme.Highlight
28+
29+ return fmt.Sprintf(
30+ "\n %s Already up to date (%s)\n",
31+ iconStyle.Render(IconCheck),
32+ versionStyle.Render(version),
33+ )
34+}
35+
36+// RenderAvailable renders the "update available" message.
37+func (r *UpdateRenderer) RenderAvailable(current, latest, releaseURL string) string {
38+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
39+ versionStyle := r.theme.Highlight
40+ urlStyle := r.theme.Subtle
41+
42+ return fmt.Sprintf(
43+ "\n %s Update available: %s %s %s\n %s\n",
44+ iconStyle.Render(IconRocket),
45+ versionStyle.Render(current),
46+ iconStyle.Render(IconArrow),
47+ versionStyle.Render(latest),
48+ urlStyle.Render(releaseURL),
49+ )
50+}
51+
52+// RenderDownloading renders the "downloading" message with spinner.
53+func (r *UpdateRenderer) RenderDownloading(spinner, version string) string {
54+ versionStyle := r.theme.Highlight
55+
56+ return fmt.Sprintf(
57+ "\n %s Downloading %s...\n",
58+ spinner,
59+ versionStyle.Render(version),
60+ )
61+}
62+
63+// RenderStaged renders the "update staged" success message.
64+func (r *UpdateRenderer) RenderStaged(version string) string {
65+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
66+ versionStyle := r.theme.Highlight
67+
68+ return fmt.Sprintf(
69+ "\n %s Update %s downloaded and staged\n %s Will be applied when this command exits\n",
70+ iconStyle.Render(IconCheck),
71+ versionStyle.Render(version),
72+ iconStyle.Render(IconCheck),
73+ )
74+}
75+
76+// RenderError renders an error message.
77+func (r *UpdateRenderer) RenderError(err error) string {
78+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Error)
79+
80+ return fmt.Sprintf(
81+ "\n %s Update failed: %v\n",
82+ iconStyle.Render(IconX),
83+ err,
84+ )
85+}
86+
87+// RenderCannotAutoUpdate renders the "cannot auto-update" message.
88+func (r *UpdateRenderer) RenderCannotAutoUpdate(current, latest, releaseURL string) string {
89+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
90+ versionStyle := r.theme.Highlight
91+ urlStyle := r.theme.Subtle
92+
93+ return fmt.Sprintf(
94+ "\n %s Update available: %s %s %s\n"+
95+ " %s Cannot auto-update: binary is not writable\n"+
96+ " Download manually: %s\n",
97+ iconStyle.Render(IconRocket),
98+ versionStyle.Render(current),
99+ iconStyle.Render(IconArrow),
100+ versionStyle.Render(latest),
101+ iconStyle.Render(IconWarning),
102+ urlStyle.Render(releaseURL),
103+ )
104+}
105+
106+// RenderDevBuild renders the "dev build" skip message.
107+func (r *UpdateRenderer) RenderDevBuild() string {
108+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
109+ return fmt.Sprintf(
110+ "\n %s Development build - update check skipped\n",
111+ iconStyle.Render(IconInfo),
112+ )
113+}

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