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