@@ -2,42 +2,63 @@ package status
22
33import (
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+
1626type StatusComponent interface {
1727 tea.Model
1828 tea.ViewModel
29+ Cleanup ()
1930}
2031
2132type 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+
135342func 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