1package daemon
2
3import (
4 "strings"
5 "time"
6
7 "github.com/bnema/vev/internal/usecase/layout"
8)
9
10const paneTitleCacheTTL = time.Second
11
12// paneTitleState is guarded by pane.mu. generation changes whenever the
13// displayed title can change, allowing cached composition to damage only a
14// title row rather than the entire stacked layout.
15type paneTitleState struct {
16 processName string
17 processNameAt time.Time
18 processNameValid bool
19 terminalTitle string
20 displayFallback string
21 generation uint64
22}
23
24func formatPaneTitle(processName, terminalTitle, fallback string) string {
25 if processName != "" && terminalTitle != "" {
26 prefix := processName + ": "
27 if strings.HasPrefix(terminalTitle, prefix) {
28 return terminalTitle
29 }
30 return prefix + terminalTitle
31 }
32 if processName != "" {
33 return processName
34 }
35 if terminalTitle != "" {
36 return terminalTitle
37 }
38 return fallback
39}
40
41func (p *pane) formattedTitleLocked(fallback string) string {
42 return formatPaneTitle(p.title.processName, p.title.terminalTitle, fallback)
43}
44
45func (p *pane) displayTitleLocked() string {
46 return p.formattedTitleLocked(p.title.displayFallback)
47}
48
49// setDisplayFallback updates the fallback owned by a pane. It must not damage
50// cached composition unless that fallback changes the displayed title.
51func (p *pane) setDisplayFallback(fallback string) {
52 p.mu.Lock()
53 if p.title.displayFallback == fallback {
54 p.mu.Unlock()
55 return
56 }
57 oldTitle := p.displayTitleLocked()
58 p.title.displayFallback = fallback
59 if oldTitle != p.displayTitleLocked() {
60 p.title.generation++
61 }
62 p.mu.Unlock()
63}
64
65// refreshTerminalTitleLocked synchronizes the title retained by the VT parser.
66// It must be called while p.mu is held, including by ptyReader around Write.
67func (p *pane) refreshTerminalTitleLocked() {
68 title := p.screen.TerminalTitle()
69 if p.title.terminalTitle != title {
70 p.title.terminalTitle = title
71 p.title.generation++
72 }
73}
74
75// composeTabTitle renders "name (paneTitle)". An empty paneTitle, or one equal
76// to the tab name, collapses to just the name.
77func composeTabTitle(tabName, paneTitle string) string {
78 return tabName + tabTitleDetail(tabName, paneTitle)
79}
80
81// tabTitleDetail returns the parenthesized " (paneTitle)" suffix for
82// tabName/paneTitle, or "" when paneTitle is empty or equals tabName. This is
83// the single place the name/detail composition rule lives; composeTabTitle
84// and the picker's TabEntry.Detail both build on it.
85func tabTitleDetail(tabName, paneTitle string) string {
86 if paneTitle == "" || paneTitle == tabName {
87 return ""
88 }
89 return " (" + paneTitle + ")"
90}
91
92// focusedPaneTitle returns the focused pane's display title for tab labels.
93// Caller must hold the owning session.mu (tb.mu then pane.mu obey lock order).
94// When includeTerminalTitle is false the OSC title is omitted; the process
95// name (or fallback) still shows.
96func (tb *tab) focusedPaneTitle(includeTerminalTitle bool) string {
97 tb.mu.Lock()
98 p := tb.focusedPane()
99 tb.mu.Unlock()
100 if p == nil {
101 return ""
102 }
103 p.mu.Lock()
104 defer p.mu.Unlock()
105 if includeTerminalTitle {
106 return p.displayTitleLocked()
107 }
108 return formatPaneTitle(p.title.processName, "", p.title.displayFallback)
109}
110
111// refreshSessionFocusedTitles opportunistically refreshes the process-name half
112// of every tab's focused pane. Snapshot panes under locks, then refresh with no
113// lock held (refreshPaneDisplayTitle re-enters p.mu itself). TTL-throttled.
114func (d *Daemon) refreshSessionFocusedTitles(sess *session) {
115 if sess == nil {
116 return
117 }
118 sess.mu.Lock()
119 panes := make([]*pane, 0, len(sess.tabs))
120 for _, tb := range sess.tabs {
121 tb.mu.Lock()
122 if p := tb.focusedPane(); p != nil {
123 panes = append(panes, p)
124 }
125 tb.mu.Unlock()
126 }
127 sess.mu.Unlock()
128 for _, p := range panes {
129 d.refreshPaneDisplayTitle(sess, p, false)
130 }
131}
132
133func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID, owningTab ...*tab) string {
134 return d.refreshPaneTitleCached(sess, id, false, owningTab...)
135}
136
137func (d *Daemon) refreshPaneTitleOnFocus(sess *session, id layout.PaneID, owningTab ...*tab) string {
138 return d.refreshPaneTitleCached(sess, id, true, owningTab...)
139}
140
141// refreshPaneTitleCached resolves the pane in its owning tab. A caller with a
142// current tab supplies it explicitly; direct callers refresh every matching
143// tab because layout pane IDs are local to a tab and may repeat.
144func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool, owningTab ...*tab) string {
145 if sess == nil {
146 return defaultShellTitle
147 }
148 tabs := owningTab
149 if len(tabs) == 0 || tabs[0] == nil {
150 sess.mu.Lock()
151 tabs = append([]*tab(nil), sess.tabs...)
152 sess.mu.Unlock()
153 }
154 title := ""
155 for _, tb := range tabs {
156 if tb == nil {
157 continue
158 }
159 tb.mu.Lock()
160 p := tb.panes[id]
161 tb.mu.Unlock()
162 if p == nil {
163 continue
164 }
165 title = d.refreshPaneDisplayTitle(sess, p, force)
166 }
167 return title
168}
169
170// refreshPaneDisplayTitle refreshes only the process-name portion of p's
171// display title. It deliberately releases pane.mu before querying the PTY so
172// callers that already render under pane locking cannot recurse on that lock.
173func (d *Daemon) refreshPaneDisplayTitle(_ *session, p *pane, force bool) string {
174 if p == nil {
175 return defaultShellTitle
176 }
177 now := d.clock.Now()
178 p.mu.Lock()
179 if !force && p.title.processNameValid && now.Sub(p.title.processNameAt) < paneTitleCacheTTL {
180 title := p.displayTitleLocked()
181 p.mu.Unlock()
182 return title
183 }
184 p.mu.Unlock()
185
186 // A failed lookup means no process name was discovered. Keep that state
187 // distinct from the display fallback: an OSC title must then stand alone,
188 // and future floating panes can supply their command fallback at format time.
189 processName := ""
190 if d.procComm != nil && p.pty != nil {
191 if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 {
192 if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" {
193 processName = strings.TrimSpace(comm)
194 }
195 }
196 }
197
198 p.mu.Lock()
199 oldTitle := p.displayTitleLocked()
200 p.title.processName = processName
201 p.title.processNameAt = now
202 p.title.processNameValid = true
203 title := p.displayTitleLocked()
204 if oldTitle != title {
205 p.title.generation++
206 }
207 p.mu.Unlock()
208 return title
209}