status.go RawHistoryFolder

1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5	"sort"
6	"strconv"
7	"time"
8
9	renderer "github.com/bnema/vev-vt"
10	"github.com/bnema/vev/internal/domain"
11	"github.com/bnema/vev/internal/protocol"
12	"github.com/bnema/vev/internal/usecase/command"
13	"github.com/bnema/vev/internal/usecase/palette"
14	themeui "github.com/bnema/vev/internal/usecase/theme"
15	"github.com/bnema/vev/internal/usecase/ui"
16)
17
18const (
19	maxMRUSessions     = 9
20	pulseFrameCount    = 30
21	pulseFrameInterval = 120 * time.Millisecond
22)
23
24// pulseVisible reports whether the attention bell glyph is showing (as
25// opposed to its blank beat) at frame.
26func pulseVisible(frame int) bool {
27	return frame%pulseFrameCount != 0
28}
29
30// pulseStyle returns the style for the attention bell glyph at frame, built
31// on top of base so the bell always keeps the caller's background (the tab's
32// accent/statusBar, or a faded MRU entry's blended colors) instead of
33// punching a default-background hole in a themed bar. On the invisible beat
34// it returns (base, false) unchanged.
35func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {
36	if !pulseVisible(frame) {
37		return base, false
38	}
39	f := frame % pulseFrameCount
40	peak := pulseFrameCount / 2
41	distance := f - peak
42	if distance < 0 {
43		distance = -distance
44	}
45	intensity := 1 - float64(distance)/float64(peak)
46	return themeui.PulseColor(base, intensity), true
47}
48
49func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeui.Styles) {
50	clearStatusRow(row, styles.SurfaceBar)
51	x := 0
52	labels := fitTabLabels(status.tabs, len(row), topRight)
53	for i, w := range status.tabs {
54		baseStyle := styles.SurfaceInactive
55		nameStyle := styles.TabInactive
56		titleStyle := styles.TabInactiveTitle
57		if w.active {
58			baseStyle = styles.SurfaceActive
59			nameStyle = styles.TabActive
60			titleStyle = styles.TabActiveTitle
61		}
62		label := labels[i]
63		writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
64		if w.attention {
65			writeStatusText(row, &x, " ", baseStyle)
66			writeBell(row, &x, frame, baseStyle)
67		}
68		writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
69		writeStatusText(row, &x, " ", baseStyle)
70	}
71	drawRightPlainText(row, topRight, x, styles.SurfaceBar)
72}
73
74func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styles) {
75	clearStatusRow(row, styles.SurfaceBar)
76	x := 0
77	rightText := composeBottomRightText(state.bottomRight, state.statusFeedback)
78	writeStatusText(row, &x, " "+state.status.session+" ", styles.SurfaceActive)
79	if state.rankedRecent != nil {
80		for _, sess := range fitRankedRecent(state.rankedRecent, len(row), x, rightText) {
81			style := styles.SurfaceInactive // contextual ranks deliberately do not fade.
82			if sess.selected {
83				style = styles.SurfaceActive
84			}
85			drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame)
86		}
87	} else {
88		fittedMRU := fitMRU(state.mru, len(row), x, rightText)
89		for i, sess := range fittedMRU {
90			style := styles.MRUStyle(i, len(fittedMRU))
91			drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame)
92		}
93	}
94	drawRightPlainText(row, rightText, x, styles.SurfaceBar)
95}
96
97type rankedRecent struct {
98	rank      int
99	name      string
100	kind      recentRouteKind
101	ephemeral bool
102	attention bool
103	selected  bool
104}
105
106func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int) {
107	prefixStyle := style
108	prefixStyle.Bold = true
109	writeStatusText(row, x, " "+strconv.Itoa(sess.rank)+":", prefixStyle)
110	writeStatusText(row, x, sess.name, style)
111	if sess.attention {
112		writeStatusText(row, x, " ", style)
113		writeBell(row, x, attentionFrame, style)
114	}
115	writeStatusText(row, x, " ", style)
116}
117
118func drawStatusSessionEntry(row []renderer.Cell, x *int, entry recentRouteDisplay, style renderer.Style, attentionFrame int) {
119	writeStatusText(row, x, " "+entry.name, style)
120	if entry.attention {
121		writeStatusText(row, x, " ", style)
122		writeBell(row, x, attentionFrame, style)
123	}
124	writeStatusText(row, x, " ", style)
125}
126
127func composeBottomRightText(scriptText, statusFeedback string) string {
128	if scriptText == "" {
129		return statusFeedback
130	}
131	if statusFeedback == "" {
132		return scriptText
133	}
134	return scriptText + " " + statusFeedback
135}
136
137func drawRightPlainText(row []renderer.Cell, text string, reservedLeft int, style renderer.Style) {
138	textWidth := statusTextWidth(text)
139	if text == "" || textWidth+1+reservedLeft > len(row) {
140		return
141	}
142	x := len(row) - textWidth - 1
143	writeStatusText(row, &x, " "+text, style)
144}
145
146func statusTextWidth(text string) int {
147	width := 0
148	for _, r := range text {
149		width += renderer.RuneWidth(r)
150	}
151	return width
152}
153
154func clearStatusRow(row []renderer.Cell, style renderer.Style) {
155	blank := renderer.Cell{Rune: ' ', Style: style}
156	for i := range row {
157		row[i] = blank
158	}
159}
160
161type barState struct {
162	status         statusSnapshot
163	topRight       string
164	bottomRight    string
165	statusFeedback string
166	mru            []recentRouteDisplay
167	rankedRecent   []rankedRecent
168	attentionFrame int
169	// theme is the client's terminal theme, if reported. Its zero value
170	// (Theme{}, Known: false) is a valid "no theme" default that resolves to
171	// the pre-theme fallback styles (see themeui.NewStyles / theme.usable).
172	theme themeui.Theme
173}
174
175type statusSnapshot struct {
176	session string
177	tabs    []statusTab
178}
179
180type statusTab struct {
181	name      string // tab display name (degradation target)
182	paneTitle string // formatPaneTitle output of the focused pane
183	active    bool
184	attention bool
185}
186
187func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot {
188	return s.statusSegmentsFor(nil, includeTerminalTitle)
189}
190
191func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle bool) statusSnapshot {
192	var routeSnapshot protocol.RecentRouteSnapshot
193	if ac != nil {
194		routeSnapshot = ac.routeSnapshotCopy()
195	}
196
197	s.mu.Lock()
198	defer s.mu.Unlock()
199	presentation := recentRoutePresentation{name: s.name, ephemeral: s.ephemeral}
200	if active, ok := activeRouteEntryForLifecycle(routeSnapshot, s.incarnation); ok {
201		presentation.hostLabel = domain.RemoteDisplayOrigin(active.HostLabel)
202		if active.Kind == protocol.RouteKindRemote {
203			presentation.kind = recentRouteRemote
204		}
205	}
206	name := formatRecentRouteName(presentation)
207	snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
208	activeIndex := 0
209	// An unfocused client leaves the bell of its own tab pending, so it shows
210	// it like a background tab's bell.
211	seen := ac.terminalFocus().MaySee()
212	if ac != nil {
213		view := ac.viewSnapshot()
214		for i, tb := range s.tabs {
215			if domain.TabStableID(tb.stableID) == view.tabID {
216				activeIndex = i
217				break
218			}
219		}
220	}
221	for i, tb := range s.tabs {
222		name := tabDisplayName(tb, i)
223		active := i == activeIndex
224		attention := tb.attention && (!active || !seen || tb.attentionVisiblePaint)
225		snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention}
226	}
227	return snap
228}
229
230func tabDisplayName(tb *tab, index int) string {
231	if tb.name != "" {
232		return tb.name
233	}
234	return strconv.Itoa(index + 1)
235}
236
237func rankedRecentForHintsWithSnapshot(hints *palette.ContextualHints, snapshot protocol.RecentRouteSnapshot) []rankedRecent {
238	if hints == nil || hints.Kind != command.ContextHintRecentSessions {
239		return nil
240	}
241	var formatted []recentRouteDisplay
242	if snapshot.Generation != 0 {
243		formatted = formatRecentRouteSnapshot(historyRouteSnapshot(snapshot))
244	}
245	entries := make([]rankedRecent, 0, len(hints.Recent))
246	for i, hint := range hints.Recent {
247		display := recentRouteDisplay{name: hint.Name}
248		// The hint and route snapshot are immutable interaction data. The
249		// formatted snapshot is authoritative when available, keeping ranked
250		// JRS labels identical to normal MRU labels without a live lookup.
251		if i < len(formatted) {
252			display = formatted[i]
253		}
254		entries = append(entries, rankedRecent{
255			rank:      hint.Rank,
256			name:      display.name,
257			kind:      display.kind,
258			ephemeral: display.ephemeral,
259			attention: display.attention,
260			selected:  hint.Rank == hints.SelectedRank,
261		})
262	}
263	return entries
264}
265
266// barStateForPaletteHints selects snapshot-only contextual composition when a
267// palette interaction has captured recent-route hints.
268func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState {
269	return d.barStateForAttachmentPaletteHints(cur, statusFeedback, hints)
270}
271
272// barStateForAttachmentPaletteHints composes daemon-owned chrome for an
273// attachment. Bar scripts remain local-session-only because
274// their existing execution contract depends on local tabs and PTYs.
275func (d *Daemon) barStateForAttachmentPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState {
276	return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, protocol.RecentRouteSnapshot{})
277}
278
279func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, capturedRouteSnapshot protocol.RecentRouteSnapshot) barState {
280	routeSnapshot := capturedRouteSnapshot
281	if routeSnapshot.Generation == 0 && ac != nil {
282		routeSnapshot = ac.routeSnapshotCopy()
283	}
284	ranked := rankedRecentForHintsWithSnapshot(hints, routeSnapshot)
285	state := barState{statusFeedback: statusFeedback}
286	if d != nil {
287		state.attentionFrame = d.attentionFrame()
288	}
289	if cur != nil {
290		includeTerminalTitle := true
291		if d != nil {
292			includeTerminalTitle = d.currentTabsConfig().TerminalTitle
293		}
294		state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
295	}
296	if cur != nil && d != nil {
297		state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
298	}
299	if ranked != nil {
300		state.rankedRecent = ranked
301	} else if routeSnapshot.Generation != 0 {
302		state.mru = formatRecentRouteSnapshot(historyRouteSnapshot(routeSnapshot))
303	}
304	return state
305}
306
307func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState {
308	return d.barStateBase(cur, statusFeedback)
309}
310
311func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {
312	return d.barStateBaseFor(cur, nil, statusFeedback)
313}
314
315func (d *Daemon) barStateBaseFor(cur *session, ac *attachedClient, statusFeedback string) barState {
316	state := barState{statusFeedback: statusFeedback}
317	if d != nil {
318		state.attentionFrame = d.attentionFrame()
319	}
320	if cur != nil {
321		includeTerminalTitle := true
322		if d != nil {
323			includeTerminalTitle = d.currentTabsConfig().TerminalTitle
324		}
325		state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
326	}
327	if d != nil {
328		state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
329	}
330	return state
331}
332
333// fittedTabLabel is one tab's drawable label; text[:nameLen] is the tab-name
334// segment, text[nameLen:] the pane-title segment (possibly empty). nameLen is
335// always a byte offset landing on a rune boundary, since it is derived from
336// the byte length of the (possibly truncated) name prefix.
337type fittedTabLabel struct {
338	text    string
339	nameLen int
340}
341
342// fitTabLabels returns, per tab, the text drawn between its surrounding
343// spaces (attention glyph handled by the caller), guaranteeing all tabs fit.
344func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLabel {
345	reserve := 1
346	if rightText != "" {
347		reserve = statusTextWidth(rightText) + 2
348	}
349	budget := rowLen - reserve
350
351	full := make([]string, len(tabs))
352	overhead := make([]int, len(tabs))
353	widths := make([]int, len(tabs))
354	total := 0
355	for i, t := range tabs {
356		full[i] = composeTabTitle(t.name, t.paneTitle)
357		o := 2
358		if t.attention {
359			o += 1 + renderer.RuneWidth(ui.AttentionGlyph)
360		}
361		overhead[i] = o
362		widths[i] = statusTextWidth(full[i])
363		total += o + widths[i]
364	}
365	labels := make([]fittedTabLabel, len(tabs))
366	if total <= budget {
367		for i, t := range tabs {
368			labels[i] = fittedTabLabel{text: full[i], nameLen: len(t.name)}
369		}
370		return labels
371	}
372
373	// Overflow: greedy water-filling in ascending width order (shortest tabs
374	// first) so unused budget from short tabs flows to longer ones, while
375	// output order (labels indexed by original tab index) stays stable.
376	order := make([]int, len(tabs))
377	for i := range order {
378		order[i] = i
379	}
380	sort.SliceStable(order, func(a, b int) bool {
381		return widths[order[a]] < widths[order[b]]
382	})
383
384	remaining := budget
385	remainingTabs := len(tabs)
386	for _, i := range order {
387		share := remaining / remainingTabs
388		textBudget := share - overhead[i]
389		switch {
390		case textBudget <= 0:
391			labels[i] = fittedTabLabel{}
392		case widths[i] <= textBudget:
393			labels[i] = fittedTabLabel{text: full[i], nameLen: len(tabs[i].name)}
394		case textBudget >= statusTextWidth(tabs[i].name)+4:
395			labels[i] = fittedTabLabel{text: ui.TruncateText(full[i], textBudget), nameLen: len(tabs[i].name)}
396		default:
397			text := ui.TruncateText(tabs[i].name, textBudget)
398			labels[i] = fittedTabLabel{text: text, nameLen: len(text)}
399		}
400		consumed := overhead[i] + min(widths[i], max(textBudget, 0))
401		remaining -= consumed
402		remainingTabs--
403	}
404	return labels
405}
406
407func recentRouteDisplayWidth(entry recentRouteDisplay) int {
408	width := 2 + statusTextWidth(entry.name)
409	if entry.attention {
410		width += 1 + renderer.RuneWidth(ui.AttentionGlyph)
411	}
412	return width
413}
414
415func fitWholeRecentEntries[T any](entries []T, budget int, cost func(T) int) []T {
416	if budget <= 0 || len(entries) == 0 {
417		return nil
418	}
419	used := 0
420	for i, entry := range entries {
421		used += cost(entry)
422		if used > budget {
423			return entries[:i]
424		}
425	}
426	return entries
427}
428
429func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback string) []rankedRecent {
430	reserve := 1
431	if feedback != "" {
432		reserve = statusTextWidth(feedback) + 2
433	}
434	budget := rowLen - leftUsed - reserve
435	return fitWholeRecentEntries(entries, budget, func(entry rankedRecent) int {
436		return recentRouteDisplayWidth(recentRouteDisplay{
437			name:      entry.name,
438			kind:      entry.kind,
439			ephemeral: entry.ephemeral,
440			attention: entry.attention,
441		}) + statusTextWidth(strconv.Itoa(entry.rank)+":")
442	})
443}
444
445func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string) []recentRouteDisplay {
446	// With no feedback, keep one blank trailing cell; with feedback, reserve
447	// its " text" width plus a one-cell gap so drawRightPlainText always fits.
448	copyReserve := 1
449	if feedback != "" {
450		copyReserve = statusTextWidth(feedback) + 2
451	}
452	physicalBudget := rowLen - leftUsed - copyReserve
453	if physicalBudget <= 0 || len(entries) == 0 {
454		return nil
455	}
456
457	budget := physicalBudget
458	if feedback == "" {
459		budget -= mruFutureRightReserve(rowLen)
460		// Keep at least one recent session when it physically fits; the reserved
461		// right side is only a budget preference, not a reason to hide all recents.
462		if firstCost := recentRouteDisplayWidth(entries[0]); firstCost <= physicalBudget && budget < firstCost {
463			budget = firstCost
464		}
465	}
466	return fitWholeRecentEntries(entries, budget, recentRouteDisplayWidth)
467}
468
469const (
470	mruReserveMinRow  = 40
471	mruReserveDivisor = 4
472	mruReserveMin     = 12
473	mruReserveMax     = 24
474)
475
476func mruFutureRightReserve(rowLen int) int {
477	if rowLen < mruReserveMinRow {
478		return 0
479	}
480	reserve := rowLen / mruReserveDivisor
481	if reserve < mruReserveMin {
482		return mruReserveMin
483	}
484	if reserve > mruReserveMax {
485		return mruReserveMax
486	}
487	return reserve
488}
489
490// writeBell draws the attention glyph on its visible beat, or a plain cell in
491// base's style on its blank beat, so the bell never leaves a bare
492// default-background hole in a themed bar.
493func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) {
494	style, visible := pulseStyle(frame, base)
495	if !visible {
496		writeStatusText(row, x, " ", base)
497		return
498	}
499	writeStatusText(row, x, string(ui.AttentionGlyph), style)
500}
501
502func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
503	for _, r := range text {
504		width := renderer.RuneWidth(r)
505		if width == 0 {
506			continue
507		}
508		if *x >= len(row) || *x+width > len(row) {
509			return
510		}
511		row[*x] = renderer.Cell{Rune: r, Style: style}
512		(*x)++
513		if width == 2 {
514			row[*x] = renderer.Cell{Style: style, Continuation: true}
515			(*x)++
516		}
517	}
518}

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