mirror of
https://github.com/sst/opencode.git
synced 2025-08-24 15:04:10 +00:00
340 lines
7.9 KiB
Go
340 lines
7.9 KiB
Go
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
|
|
branch string
|
|
watcher *fsnotify.Watcher
|
|
done chan struct{}
|
|
lastUpdate time.Time
|
|
}
|
|
|
|
func (m *statusComponent) Init() tea.Cmd {
|
|
return m.startGitWatcher()
|
|
}
|
|
|
|
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 {
|
|
t := theme.CurrentTheme()
|
|
base := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()).Render
|
|
emphasis := styles.NewStyle().
|
|
Foreground(t.Text()).
|
|
Background(t.BackgroundElement()).
|
|
Bold(true).
|
|
Render
|
|
|
|
open := base("open")
|
|
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(content)
|
|
}
|
|
|
|
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()
|
|
logoWidth := lipgloss.Width(logo)
|
|
|
|
var modeBackground compat.AdaptiveColor
|
|
var modeForeground compat.AdaptiveColor
|
|
|
|
agentColor := util.GetAgentColor(m.app.AgentIndex)
|
|
|
|
if m.app.AgentIndex == 0 {
|
|
modeBackground = t.BackgroundElement()
|
|
modeForeground = agentColor
|
|
} else {
|
|
modeBackground = agentColor
|
|
modeForeground = t.BackgroundPanel()
|
|
}
|
|
|
|
command := m.app.Commands[commands.AgentCycleCommand]
|
|
kb := command.Keybindings[0]
|
|
key := kb.Key
|
|
if kb.RequiresLeader {
|
|
key = m.app.Config.Keybinds.Leader + " " + kb.Key
|
|
}
|
|
|
|
agentStyle := styles.NewStyle().Background(modeBackground).Foreground(modeForeground)
|
|
agentNameStyle := agentStyle.Bold(true).Render
|
|
agentDescStyle := agentStyle.Render
|
|
agent := agentNameStyle(strings.ToUpper(m.app.Agent().Name)) + agentDescStyle(" AGENT")
|
|
agent = agentStyle.
|
|
Padding(0, 1).
|
|
BorderLeft(true).
|
|
BorderStyle(lipgloss.ThickBorder()).
|
|
BorderForeground(modeBackground).
|
|
BorderBackground(t.BackgroundPanel()).
|
|
Render(agent)
|
|
|
|
faintStyle := styles.NewStyle().
|
|
Faint(true).
|
|
Background(t.BackgroundPanel()).
|
|
Foreground(t.TextMuted())
|
|
agent = faintStyle.Render(key+" ") + agent
|
|
modeWidth := lipgloss.Width(agent)
|
|
|
|
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()).
|
|
Background(t.BackgroundPanel()).
|
|
Padding(0, 1).
|
|
Render(cwdDisplay)
|
|
|
|
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: agent,
|
|
},
|
|
)
|
|
|
|
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,
|
|
lastUpdate: time.Now(),
|
|
}
|
|
|
|
homePath, err := os.UserHomeDir()
|
|
cwdPath := app.Info.Path.Cwd
|
|
if err == nil && homePath != "" && strings.HasPrefix(cwdPath, homePath) {
|
|
cwdPath = "~" + cwdPath[len(homePath):]
|
|
}
|
|
statusComponent.cwd = cwdPath
|
|
|
|
return statusComponent
|
|
}
|