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

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