Explorar o código

feat: show current git branch in status bar, and make it responsive (#1339)

Co-authored-by: adamdotdevin <[email protected]>
Andrea Grandi hai 6 meses
pai
achega
3bd2b340c8

+ 4 - 1
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)
 }

+ 1 - 2
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

+ 0 - 2
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=

+ 235 - 27
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()

+ 100 - 0
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")
+	}
+}

+ 9 - 6
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(