Skip to content

Commit 3bd2b34

Browse files
feat: show current git branch in status bar, and make it responsive (anomalyco#1339)
Co-authored-by: adamdotdevin <2363879+adamdottv@users.noreply.github.com>
1 parent df03e18 commit 3bd2b34

6 files changed

Lines changed: 349 additions & 38 deletions

File tree

packages/tui/cmd/opencode/main.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,9 @@ func main() {
101101
panic(err)
102102
}
103103

104+
tuiModel := tui.NewModel(app_).(*tui.Model)
104105
program := tea.NewProgram(
105-
tui.NewModel(app_),
106+
tuiModel,
106107
tea.WithAltScreen(),
107108
tea.WithMouseCellMotion(),
108109
)
@@ -132,6 +133,7 @@ func main() {
132133
go func() {
133134
sig := <-sigChan
134135
slog.Info("Received signal, shutting down gracefully", "signal", sig)
136+
tuiModel.Cleanup()
135137
program.Quit()
136138
}()
137139

@@ -141,5 +143,6 @@ func main() {
141143
slog.Error("TUI error", "error", err)
142144
}
143145

146+
tuiModel.Cleanup()
144147
slog.Info("TUI exited", "result", result)
145148
}

packages/tui/go.mod

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ go 1.24.0
55
require (
66
github.com/BurntSushi/toml v1.5.0
77
github.com/alecthomas/chroma/v2 v2.18.0
8-
github.com/charmbracelet/bubbles v0.21.0
98
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
109
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4
1110
github.com/charmbracelet/glamour v0.10.0
1211
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3
1312
github.com/charmbracelet/x/ansi v0.9.3
13+
github.com/fsnotify/fsnotify v1.8.0
1414
github.com/google/uuid v1.6.0
1515
github.com/lithammer/fuzzysearch v1.1.8
1616
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
@@ -37,7 +37,6 @@ require (
3737
github.com/charmbracelet/x/input v0.3.7 // indirect
3838
github.com/charmbracelet/x/windows v0.2.1 // indirect
3939
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
40-
github.com/fsnotify/fsnotify v1.8.0 // indirect
4140
github.com/getkin/kin-openapi v0.127.0 // indirect
4241
github.com/go-openapi/jsonpointer v0.21.0 // indirect
4342
github.com/go-openapi/swag v0.23.0 // indirect

packages/tui/go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp
2020
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
2121
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
2222
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
23-
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
24-
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
2523
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
2624
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
2725
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno=

packages/tui/internal/components/status/status.go

Lines changed: 235 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,63 @@ package status
22

33
import (
44
"os"
5+
"os/exec"
6+
"path/filepath"
57
"strings"
8+
"time"
69

710
tea "github.com/charmbracelet/bubbletea/v2"
811
"github.com/charmbracelet/lipgloss/v2"
912
"github.com/charmbracelet/lipgloss/v2/compat"
13+
"github.com/fsnotify/fsnotify"
1014
"github.com/sst/opencode/internal/app"
1115
"github.com/sst/opencode/internal/commands"
16+
"github.com/sst/opencode/internal/layout"
1217
"github.com/sst/opencode/internal/styles"
1318
"github.com/sst/opencode/internal/theme"
19+
"github.com/sst/opencode/internal/util"
1420
)
1521

22+
type GitBranchUpdatedMsg struct {
23+
Branch string
24+
}
25+
1626
type StatusComponent interface {
1727
tea.Model
1828
tea.ViewModel
29+
Cleanup()
1930
}
2031

2132
type statusComponent struct {
22-
app *app.App
23-
width int
24-
cwd string
33+
app *app.App
34+
width int
35+
cwd string
36+
branch string
37+
watcher *fsnotify.Watcher
38+
done chan struct{}
39+
lastUpdate time.Time
2540
}
2641

27-
func (m statusComponent) Init() tea.Cmd {
28-
return nil
42+
func (m *statusComponent) Init() tea.Cmd {
43+
return m.startGitWatcher()
2944
}
3045

31-
func (m statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
46+
func (m *statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3247
switch msg := msg.(type) {
3348
case tea.WindowSizeMsg:
3449
m.width = msg.Width
3550
return m, nil
51+
case GitBranchUpdatedMsg:
52+
if m.branch != msg.Branch {
53+
m.branch = msg.Branch
54+
}
55+
// Continue watching for changes (persistent watcher)
56+
return m, m.watchForGitChanges()
3657
}
3758
return m, nil
3859
}
3960

40-
func (m statusComponent) logo() string {
61+
func (m *statusComponent) logo() string {
4162
t := theme.CurrentTheme()
4263
base := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()).Render
4364
emphasis := styles.NewStyle().
@@ -47,23 +68,56 @@ func (m statusComponent) logo() string {
4768
Render
4869

4970
open := base("open")
50-
code := emphasis("code ")
51-
version := base(m.app.Version)
71+
code := emphasis("code")
72+
version := base(" " + m.app.Version)
73+
74+
content := open + code
75+
if m.width > 40 {
76+
content += version
77+
}
5278
return styles.NewStyle().
5379
Background(t.BackgroundElement()).
5480
Padding(0, 1).
55-
Render(open + code + version)
81+
Render(content)
5682
}
5783

58-
func (m statusComponent) View() string {
84+
func (m *statusComponent) collapsePath(path string, maxWidth int) string {
85+
if lipgloss.Width(path) <= maxWidth {
86+
return path
87+
}
88+
89+
const ellipsis = ".."
90+
ellipsisLen := len(ellipsis)
91+
92+
if maxWidth <= ellipsisLen {
93+
if maxWidth > 0 {
94+
return "..."[:maxWidth]
95+
}
96+
return ""
97+
}
98+
99+
separator := string(filepath.Separator)
100+
parts := strings.Split(path, separator)
101+
102+
if len(parts) == 1 {
103+
return path[:maxWidth-ellipsisLen] + ellipsis
104+
}
105+
106+
truncatedPath := parts[len(parts)-1]
107+
for i := len(parts) - 2; i >= 0; i-- {
108+
part := parts[i]
109+
if len(truncatedPath)+len(separator)+len(part)+ellipsisLen > maxWidth {
110+
return ellipsis + separator + truncatedPath
111+
}
112+
truncatedPath = part + separator + truncatedPath
113+
}
114+
return truncatedPath
115+
}
116+
117+
func (m *statusComponent) View() string {
59118
t := theme.CurrentTheme()
60119
logo := m.logo()
61-
62-
cwd := styles.NewStyle().
63-
Foreground(t.TextMuted()).
64-
Background(t.BackgroundPanel()).
65-
Padding(0, 1).
66-
Render(m.cwd)
120+
logoWidth := lipgloss.Width(logo)
67121

68122
var modeBackground compat.AdaptiveColor
69123
var modeForeground compat.AdaptiveColor
@@ -113,28 +167,182 @@ func (m statusComponent) View() string {
113167
BorderBackground(t.BackgroundPanel()).
114168
Render(mode)
115169

116-
mode = styles.NewStyle().
170+
faintStyle := styles.NewStyle().
117171
Faint(true).
118172
Background(t.BackgroundPanel()).
173+
Foreground(t.TextMuted())
174+
mode = faintStyle.Render(key+" ") + mode
175+
modeWidth := lipgloss.Width(mode)
176+
177+
availableWidth := m.width - logoWidth - modeWidth
178+
branchSuffix := ""
179+
if m.branch != "" {
180+
branchSuffix = ":" + m.branch
181+
}
182+
183+
maxCwdWidth := availableWidth - lipgloss.Width(branchSuffix)
184+
cwdDisplay := m.collapsePath(m.cwd, maxCwdWidth)
185+
186+
if m.branch != "" && availableWidth > lipgloss.Width(cwdDisplay)+lipgloss.Width(branchSuffix) {
187+
cwdDisplay += faintStyle.Render(branchSuffix)
188+
}
189+
190+
cwd := styles.NewStyle().
119191
Foreground(t.TextMuted()).
120-
Render(key+" ") +
121-
mode
192+
Background(t.BackgroundPanel()).
193+
Padding(0, 1).
194+
Render(cwdDisplay)
122195

123-
space := max(
124-
0,
125-
m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(mode),
196+
background := t.BackgroundPanel()
197+
status := layout.Render(
198+
layout.FlexOptions{
199+
Background: &background,
200+
Direction: layout.Row,
201+
Justify: layout.JustifySpaceBetween,
202+
Align: layout.AlignStretch,
203+
Width: m.width,
204+
},
205+
layout.FlexItem{
206+
View: logo + cwd,
207+
},
208+
layout.FlexItem{
209+
View: mode,
210+
},
126211
)
127-
spacer := styles.NewStyle().Background(t.BackgroundPanel()).Width(space).Render("")
128-
129-
status := logo + cwd + spacer + mode
130212

131213
blank := styles.NewStyle().Background(t.Background()).Width(m.width).Render("")
132214
return blank + "\n" + status
133215
}
134216

217+
func (m *statusComponent) startGitWatcher() tea.Cmd {
218+
cmd := util.CmdHandler(
219+
GitBranchUpdatedMsg{Branch: getCurrentGitBranch(m.app.Info.Path.Root)},
220+
)
221+
if err := m.initWatcher(); err != nil {
222+
return cmd
223+
}
224+
return tea.Batch(cmd, m.watchForGitChanges())
225+
}
226+
227+
func (m *statusComponent) initWatcher() error {
228+
gitDir := filepath.Join(m.app.Info.Path.Root, ".git")
229+
headFile := filepath.Join(gitDir, "HEAD")
230+
if info, err := os.Stat(gitDir); err != nil || !info.IsDir() {
231+
return err
232+
}
233+
234+
watcher, err := fsnotify.NewWatcher()
235+
if err != nil {
236+
return err
237+
}
238+
239+
if err := watcher.Add(headFile); err != nil {
240+
watcher.Close()
241+
return err
242+
}
243+
244+
// Also watch the ref file if HEAD points to a ref
245+
refFile := getGitRefFile(m.app.Info.Path.Cwd)
246+
if refFile != headFile && refFile != "" {
247+
if _, err := os.Stat(refFile); err == nil {
248+
watcher.Add(refFile) // Ignore error, HEAD watching is sufficient
249+
}
250+
}
251+
252+
m.watcher = watcher
253+
m.done = make(chan struct{})
254+
return nil
255+
}
256+
257+
func (m *statusComponent) watchForGitChanges() tea.Cmd {
258+
if m.watcher == nil {
259+
return nil
260+
}
261+
262+
return tea.Cmd(func() tea.Msg {
263+
for {
264+
select {
265+
case event, ok := <-m.watcher.Events:
266+
branch := getCurrentGitBranch(m.app.Info.Path.Root)
267+
if !ok {
268+
return GitBranchUpdatedMsg{Branch: branch}
269+
}
270+
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
271+
// Debounce updates to prevent excessive refreshes
272+
now := time.Now()
273+
if now.Sub(m.lastUpdate) < 100*time.Millisecond {
274+
continue
275+
}
276+
m.lastUpdate = now
277+
if strings.HasSuffix(event.Name, "HEAD") {
278+
m.updateWatchedFiles()
279+
}
280+
return GitBranchUpdatedMsg{Branch: branch}
281+
}
282+
case <-m.watcher.Errors:
283+
// Continue watching even on errors
284+
case <-m.done:
285+
return GitBranchUpdatedMsg{Branch: ""}
286+
}
287+
}
288+
})
289+
}
290+
291+
func (m *statusComponent) updateWatchedFiles() {
292+
if m.watcher == nil {
293+
return
294+
}
295+
refFile := getGitRefFile(m.app.Info.Path.Root)
296+
headFile := filepath.Join(m.app.Info.Path.Root, ".git", "HEAD")
297+
if refFile != headFile && refFile != "" {
298+
if _, err := os.Stat(refFile); err == nil {
299+
// Try to add the new ref file (ignore error if already watching)
300+
m.watcher.Add(refFile)
301+
}
302+
}
303+
}
304+
305+
func getCurrentGitBranch(cwd string) string {
306+
cmd := exec.Command("git", "branch", "--show-current")
307+
cmd.Dir = cwd
308+
output, err := cmd.Output()
309+
if err != nil {
310+
return ""
311+
}
312+
return strings.TrimSpace(string(output))
313+
}
314+
315+
func getGitRefFile(cwd string) string {
316+
headFile := filepath.Join(cwd, ".git", "HEAD")
317+
content, err := os.ReadFile(headFile)
318+
if err != nil {
319+
return ""
320+
}
321+
322+
headContent := strings.TrimSpace(string(content))
323+
if after, ok := strings.CutPrefix(headContent, "ref: "); ok {
324+
// HEAD points to a ref file
325+
refPath := after
326+
return filepath.Join(cwd, ".git", refPath)
327+
}
328+
329+
// HEAD contains a direct commit hash
330+
return headFile
331+
}
332+
333+
func (m *statusComponent) Cleanup() {
334+
if m.done != nil {
335+
close(m.done)
336+
}
337+
if m.watcher != nil {
338+
m.watcher.Close()
339+
}
340+
}
341+
135342
func NewStatusCmp(app *app.App) StatusComponent {
136343
statusComponent := &statusComponent{
137-
app: app,
344+
app: app,
345+
lastUpdate: time.Now(),
138346
}
139347

140348
homePath, err := os.UserHomeDir()

0 commit comments

Comments
 (0)