v0.2.0 80c61e3

pane_titles.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close