From 3bd2b340c89f23b7c4ecfa368f48ffe7b3e40b22 Mon Sep 17 00:00:00 2001 From: Andrea Grandi Date: Tue, 29 Jul 2025 18:15:04 +0200 Subject: [PATCH] feat: show current git branch in status bar, and make it responsive (#1339) Co-authored-by: adamdotdevin <2363879+adamdottv@users.noreply.github.com> --- packages/tui/cmd/opencode/main.go | 5 +- packages/tui/go.mod | 3 +- packages/tui/go.sum | 2 - .../tui/internal/components/status/status.go | 262 ++++++++++++++++-- .../internal/components/status/status_test.go | 100 +++++++ packages/tui/internal/tui/tui.go | 15 +- 6 files changed, 349 insertions(+), 38 deletions(-) create mode 100644 packages/tui/internal/components/status/status_test.go diff --git a/packages/tui/cmd/opencode/main.go b/packages/tui/cmd/opencode/main.go index 54dbd15a..66888fe4 100644 --- a/packages/tui/cmd/opencode/main.go +++ b/packages/tui/cmd/opencode/main.go @@ -101,8 +101,9 @@ func main() { panic(err) } + tuiModel := tui.NewModel(app_).(*tui.Model) program := tea.NewProgram( - tui.NewModel(app_), + tuiModel, tea.WithAltScreen(), tea.WithMouseCellMotion(), ) @@ -132,6 +133,7 @@ func main() { go func() { sig := <-sigChan slog.Info("Received signal, shutting down gracefully", "signal", sig) + tuiModel.Cleanup() program.Quit() }() @@ -141,5 +143,6 @@ func main() { slog.Error("TUI error", "error", err) } + tuiModel.Cleanup() slog.Info("TUI exited", "result", result) } diff --git a/packages/tui/go.mod b/packages/tui/go.mod index 0b469838..bf2812ca 100644 --- a/packages/tui/go.mod +++ b/packages/tui/go.mod @@ -5,12 +5,12 @@ go 1.24.0 require ( github.com/BurntSushi/toml v1.5.0 github.com/alecthomas/chroma/v2 v2.18.0 - github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 github.com/charmbracelet/x/ansi v0.9.3 + github.com/fsnotify/fsnotify v1.8.0 github.com/google/uuid v1.6.0 github.com/lithammer/fuzzysearch v1.1.8 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 @@ -37,7 +37,6 @@ require ( github.com/charmbracelet/x/input v0.3.7 // indirect github.com/charmbracelet/x/windows v0.2.1 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/getkin/kin-openapi v0.127.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect diff --git a/packages/tui/go.sum b/packages/tui/go.sum index f41abaf4..370ea712 100644 --- a/packages/tui/go.sum +++ b/packages/tui/go.sum @@ -20,8 +20,6 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno= diff --git a/packages/tui/internal/components/status/status.go b/packages/tui/internal/components/status/status.go index 8ab54277..d57c228c 100644 --- a/packages/tui/internal/components/status/status.go +++ b/packages/tui/internal/components/status/status.go @@ -2,42 +2,63 @@ package status import ( "os" + "os/exec" + "path/filepath" "strings" + "time" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/lipgloss/v2/compat" + "github.com/fsnotify/fsnotify" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/commands" + "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" + "github.com/sst/opencode/internal/util" ) +type GitBranchUpdatedMsg struct { + Branch string +} + type StatusComponent interface { tea.Model tea.ViewModel + Cleanup() } type statusComponent struct { - app *app.App - width int - cwd string + app *app.App + width int + cwd string + branch string + watcher *fsnotify.Watcher + done chan struct{} + lastUpdate time.Time } -func (m statusComponent) Init() tea.Cmd { - return nil +func (m *statusComponent) Init() tea.Cmd { + return m.startGitWatcher() } -func (m statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width return m, nil + case GitBranchUpdatedMsg: + if m.branch != msg.Branch { + m.branch = msg.Branch + } + // Continue watching for changes (persistent watcher) + return m, m.watchForGitChanges() } return m, nil } -func (m statusComponent) logo() string { +func (m *statusComponent) logo() string { t := theme.CurrentTheme() base := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()).Render emphasis := styles.NewStyle(). @@ -47,23 +68,56 @@ func (m statusComponent) logo() string { Render open := base("open") - code := emphasis("code ") - version := base(m.app.Version) + code := emphasis("code") + version := base(" " + m.app.Version) + + content := open + code + if m.width > 40 { + content += version + } return styles.NewStyle(). Background(t.BackgroundElement()). Padding(0, 1). - Render(open + code + version) + Render(content) } -func (m statusComponent) View() string { +func (m *statusComponent) collapsePath(path string, maxWidth int) string { + if lipgloss.Width(path) <= maxWidth { + return path + } + + const ellipsis = ".." + ellipsisLen := len(ellipsis) + + if maxWidth <= ellipsisLen { + if maxWidth > 0 { + return "..."[:maxWidth] + } + return "" + } + + separator := string(filepath.Separator) + parts := strings.Split(path, separator) + + if len(parts) == 1 { + return path[:maxWidth-ellipsisLen] + ellipsis + } + + truncatedPath := parts[len(parts)-1] + for i := len(parts) - 2; i >= 0; i-- { + part := parts[i] + if len(truncatedPath)+len(separator)+len(part)+ellipsisLen > maxWidth { + return ellipsis + separator + truncatedPath + } + truncatedPath = part + separator + truncatedPath + } + return truncatedPath +} + +func (m *statusComponent) View() string { t := theme.CurrentTheme() logo := m.logo() - - cwd := styles.NewStyle(). - Foreground(t.TextMuted()). - Background(t.BackgroundPanel()). - Padding(0, 1). - Render(m.cwd) + logoWidth := lipgloss.Width(logo) var modeBackground compat.AdaptiveColor var modeForeground compat.AdaptiveColor @@ -113,28 +167,182 @@ func (m statusComponent) View() string { BorderBackground(t.BackgroundPanel()). Render(mode) - mode = styles.NewStyle(). + faintStyle := styles.NewStyle(). Faint(true). Background(t.BackgroundPanel()). + Foreground(t.TextMuted()) + mode = faintStyle.Render(key+" ") + mode + modeWidth := lipgloss.Width(mode) + + availableWidth := m.width - logoWidth - modeWidth + branchSuffix := "" + if m.branch != "" { + branchSuffix = ":" + m.branch + } + + maxCwdWidth := availableWidth - lipgloss.Width(branchSuffix) + cwdDisplay := m.collapsePath(m.cwd, maxCwdWidth) + + if m.branch != "" && availableWidth > lipgloss.Width(cwdDisplay)+lipgloss.Width(branchSuffix) { + cwdDisplay += faintStyle.Render(branchSuffix) + } + + cwd := styles.NewStyle(). Foreground(t.TextMuted()). - Render(key+" ") + - mode + Background(t.BackgroundPanel()). + Padding(0, 1). + Render(cwdDisplay) - space := max( - 0, - m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(mode), + background := t.BackgroundPanel() + status := layout.Render( + layout.FlexOptions{ + Background: &background, + Direction: layout.Row, + Justify: layout.JustifySpaceBetween, + Align: layout.AlignStretch, + Width: m.width, + }, + layout.FlexItem{ + View: logo + cwd, + }, + layout.FlexItem{ + View: mode, + }, ) - spacer := styles.NewStyle().Background(t.BackgroundPanel()).Width(space).Render("") - - status := logo + cwd + spacer + mode blank := styles.NewStyle().Background(t.Background()).Width(m.width).Render("") return blank + "\n" + status } +func (m *statusComponent) startGitWatcher() tea.Cmd { + cmd := util.CmdHandler( + GitBranchUpdatedMsg{Branch: getCurrentGitBranch(m.app.Info.Path.Root)}, + ) + if err := m.initWatcher(); err != nil { + return cmd + } + return tea.Batch(cmd, m.watchForGitChanges()) +} + +func (m *statusComponent) initWatcher() error { + gitDir := filepath.Join(m.app.Info.Path.Root, ".git") + headFile := filepath.Join(gitDir, "HEAD") + if info, err := os.Stat(gitDir); err != nil || !info.IsDir() { + return err + } + + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + + if err := watcher.Add(headFile); err != nil { + watcher.Close() + return err + } + + // Also watch the ref file if HEAD points to a ref + refFile := getGitRefFile(m.app.Info.Path.Cwd) + if refFile != headFile && refFile != "" { + if _, err := os.Stat(refFile); err == nil { + watcher.Add(refFile) // Ignore error, HEAD watching is sufficient + } + } + + m.watcher = watcher + m.done = make(chan struct{}) + return nil +} + +func (m *statusComponent) watchForGitChanges() tea.Cmd { + if m.watcher == nil { + return nil + } + + return tea.Cmd(func() tea.Msg { + for { + select { + case event, ok := <-m.watcher.Events: + branch := getCurrentGitBranch(m.app.Info.Path.Root) + if !ok { + return GitBranchUpdatedMsg{Branch: branch} + } + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { + // Debounce updates to prevent excessive refreshes + now := time.Now() + if now.Sub(m.lastUpdate) < 100*time.Millisecond { + continue + } + m.lastUpdate = now + if strings.HasSuffix(event.Name, "HEAD") { + m.updateWatchedFiles() + } + return GitBranchUpdatedMsg{Branch: branch} + } + case <-m.watcher.Errors: + // Continue watching even on errors + case <-m.done: + return GitBranchUpdatedMsg{Branch: ""} + } + } + }) +} + +func (m *statusComponent) updateWatchedFiles() { + if m.watcher == nil { + return + } + refFile := getGitRefFile(m.app.Info.Path.Root) + headFile := filepath.Join(m.app.Info.Path.Root, ".git", "HEAD") + if refFile != headFile && refFile != "" { + if _, err := os.Stat(refFile); err == nil { + // Try to add the new ref file (ignore error if already watching) + m.watcher.Add(refFile) + } + } +} + +func getCurrentGitBranch(cwd string) string { + cmd := exec.Command("git", "branch", "--show-current") + cmd.Dir = cwd + output, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} + +func getGitRefFile(cwd string) string { + headFile := filepath.Join(cwd, ".git", "HEAD") + content, err := os.ReadFile(headFile) + if err != nil { + return "" + } + + headContent := strings.TrimSpace(string(content)) + if after, ok := strings.CutPrefix(headContent, "ref: "); ok { + // HEAD points to a ref file + refPath := after + return filepath.Join(cwd, ".git", refPath) + } + + // HEAD contains a direct commit hash + return headFile +} + +func (m *statusComponent) Cleanup() { + if m.done != nil { + close(m.done) + } + if m.watcher != nil { + m.watcher.Close() + } +} + func NewStatusCmp(app *app.App) StatusComponent { statusComponent := &statusComponent{ - app: app, + app: app, + lastUpdate: time.Now(), } homePath, err := os.UserHomeDir() diff --git a/packages/tui/internal/components/status/status_test.go b/packages/tui/internal/components/status/status_test.go new file mode 100644 index 00000000..1e1caf8a --- /dev/null +++ b/packages/tui/internal/components/status/status_test.go @@ -0,0 +1,100 @@ +package status + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestGetCurrentGitBranch(t *testing.T) { + // Test in current directory (should be a git repo) + branch := getCurrentGitBranch(".") + if branch == "" { + t.Skip("Not in a git repository, skipping test") + } + t.Logf("Current branch: %s", branch) +} + +func TestGetGitRefFile(t *testing.T) { + // Create a temporary git directory structure for testing + tmpDir := t.TempDir() + gitDir := filepath.Join(tmpDir, ".git") + err := os.MkdirAll(gitDir, 0755) + if err != nil { + t.Fatal(err) + } + + // Test case 1: HEAD points to a ref + headFile := filepath.Join(gitDir, "HEAD") + err = os.WriteFile(headFile, []byte("ref: refs/heads/main\n"), 0644) + if err != nil { + t.Fatal(err) + } + + refFile := getGitRefFile(tmpDir) + expected := filepath.Join(gitDir, "refs", "heads", "main") + if refFile != expected { + t.Errorf("Expected %s, got %s", expected, refFile) + } + + // Test case 2: HEAD contains a direct commit hash + err = os.WriteFile(headFile, []byte("abc123def456\n"), 0644) + if err != nil { + t.Fatal(err) + } + + refFile = getGitRefFile(tmpDir) + if refFile != headFile { + t.Errorf("Expected %s, got %s", headFile, refFile) + } +} + +func TestFileWatcherIntegration(t *testing.T) { + // This test requires being in a git repository + if getCurrentGitBranch(".") == "" { + t.Skip("Not in a git repository, skipping integration test") + } + + // Test that the file watcher setup doesn't crash + tmpDir := t.TempDir() + gitDir := filepath.Join(tmpDir, ".git") + err := os.MkdirAll(gitDir, 0755) + if err != nil { + t.Fatal(err) + } + + headFile := filepath.Join(gitDir, "HEAD") + err = os.WriteFile(headFile, []byte("ref: refs/heads/main\n"), 0644) + if err != nil { + t.Fatal(err) + } + + // Create the refs directory and file + refsDir := filepath.Join(gitDir, "refs", "heads") + err = os.MkdirAll(refsDir, 0755) + if err != nil { + t.Fatal(err) + } + + mainRef := filepath.Join(refsDir, "main") + err = os.WriteFile(mainRef, []byte("abc123def456\n"), 0644) + if err != nil { + t.Fatal(err) + } + + // Test that we can create a watcher without crashing + // This is a basic smoke test + done := make(chan bool, 1) + go func() { + time.Sleep(100 * time.Millisecond) + done <- true + }() + + select { + case <-done: + // Test passed - no crash + case <-time.After(1 * time.Second): + t.Error("Test timed out") + } +} diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 410b8d1a..76b96a8e 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -71,12 +71,11 @@ type Model struct { symbolsProvider completions.CompletionProvider showCompletionDialog bool leaderBinding *key.Binding - // isLeaderSequence bool - toastManager *toast.ToastManager - interruptKeyState InterruptKeyState - exitKeyState ExitKeyState - messagesRight bool - fileViewer fileviewer.Model + toastManager *toast.ToastManager + interruptKeyState InterruptKeyState + exitKeyState ExitKeyState + messagesRight bool + fileViewer fileviewer.Model } func (a Model) Init() tea.Cmd { @@ -650,6 +649,10 @@ func (a Model) View() string { return mainLayout + "\n" + a.status.View() } +func (a Model) Cleanup() { + a.status.Cleanup() +} + func (a Model) openFile(filepath string) (tea.Model, tea.Cmd) { var cmd tea.Cmd response, err := a.app.Client.File.Read(