v0.3.0 99dc266

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/ports"
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	s.mu.Lock()
193	defer s.mu.Unlock()
194	name := s.name
195	if s.ephemeral {
196		name += "*"
197	}
198	if ac != nil && ac.remoteOrigin != "" {
199		name += "@" + ac.remoteOrigin
200	}
201	snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
202	activeIndex := 0
203	if ac != nil {
204		view := ac.viewSnapshot()
205		for i, tb := range s.tabs {
206			if domain.TabStableID(tb.stableID) == view.tabID {
207				activeIndex = i
208				break
209			}
210		}
211	}
212	for i, tb := range s.tabs {
213		name := tabDisplayName(tb, i)
214		active := i == activeIndex
215		attention := tb.attention && (!active || tb.attentionVisiblePaint)
216		snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention}
217	}
218	return snap
219}
220
221func tabDisplayName(tb *tab, index int) string {
222	if tb.name != "" {
223		return tb.name
224	}
225	return strconv.Itoa(index + 1)
226}
227
228func rankedRecentForHintsWithSnapshot(hints *palette.ContextualHints, snapshot ports.RecentRouteSnapshot) []rankedRecent {
229	if hints == nil || hints.Kind != command.ContextHintRecentSessions {
230		return nil
231	}
232	var formatted []recentRouteDisplay
233	if snapshot.Generation != 0 {
234		formatted = formatRecentRouteSnapshot(snapshot)
235	}
236	entries := make([]rankedRecent, 0, len(hints.Recent))
237	for i, hint := range hints.Recent {
238		display := recentRouteDisplay{name: hint.Name}
239		// The hint and route snapshot are immutable interaction data. The
240		// formatted snapshot is authoritative when available, keeping ranked
241		// JRS labels identical to normal MRU labels without a live lookup.
242		if i < len(formatted) {
243			display = formatted[i]
244		}
245		entries = append(entries, rankedRecent{
246			rank:      hint.Rank,
247			name:      display.name,
248			kind:      display.kind,
249			ephemeral: display.ephemeral,
250			attention: display.attention,
251			selected:  hint.Rank == hints.SelectedRank,
252		})
253	}
254	return entries
255}
256
257// barStateForPaletteHints selects snapshot-only contextual composition when a
258// palette interaction has captured recent-route hints.
259func (d *Daemon) barStateForPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState {
260	return d.barStateForAttachmentPaletteHints(cur, statusFeedback, hints)
261}
262
263// barStateForAttachmentPaletteHints composes daemon-owned chrome for an
264// attachment. Bar scripts remain local-session-only because
265// their existing execution contract depends on local tabs and PTYs.
266func (d *Daemon) barStateForAttachmentPaletteHints(cur *session, statusFeedback string, hints *palette.ContextualHints) barState {
267	return d.barStateForAttachmentPaletteHintsFor(cur, nil, statusFeedback, hints, ports.RecentRouteSnapshot{})
268}
269
270func (d *Daemon) barStateForAttachmentPaletteHintsFor(cur *session, ac *attachedClient, statusFeedback string, hints *palette.ContextualHints, capturedRouteSnapshot ports.RecentRouteSnapshot) barState {
271	routeSnapshot := capturedRouteSnapshot
272	if routeSnapshot.Generation == 0 && ac != nil {
273		routeSnapshot = ac.routeSnapshotCopy()
274	}
275	ranked := rankedRecentForHintsWithSnapshot(hints, routeSnapshot)
276	state := barState{statusFeedback: statusFeedback}
277	if d != nil {
278		state.attentionFrame = d.attentionFrame()
279	}
280	if cur != nil {
281		includeTerminalTitle := true
282		if d != nil {
283			includeTerminalTitle = d.currentTabsConfig().TerminalTitle
284		}
285		state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
286	}
287	if cur != nil && d != nil {
288		state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
289	}
290	if ranked != nil {
291		state.rankedRecent = ranked
292	} else if routeSnapshot.Generation != 0 {
293		state.mru = d.formatRecentRouteSnapshotForAttachment(ac, routeSnapshot)
294	}
295	return state
296}
297
298func (d *Daemon) barStateFor(cur *session, statusFeedback string) barState {
299	return d.barStateBase(cur, statusFeedback)
300}
301
302func (d *Daemon) barStateBase(cur *session, statusFeedback string) barState {
303	return d.barStateBaseFor(cur, nil, statusFeedback)
304}
305
306func (d *Daemon) barStateBaseFor(cur *session, ac *attachedClient, statusFeedback string) barState {
307	state := barState{statusFeedback: statusFeedback}
308	if d != nil {
309		state.attentionFrame = d.attentionFrame()
310	}
311	if cur != nil {
312		includeTerminalTitle := true
313		if d != nil {
314			includeTerminalTitle = d.currentTabsConfig().TerminalTitle
315		}
316		state.status = cur.statusSegmentsFor(ac, includeTerminalTitle)
317	}
318	if d != nil {
319		state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
320	}
321	return state
322}
323
324// fittedTabLabel is one tab's drawable label; text[:nameLen] is the tab-name
325// segment, text[nameLen:] the pane-title segment (possibly empty). nameLen is
326// always a byte offset landing on a rune boundary, since it is derived from
327// the byte length of the (possibly truncated) name prefix.
328type fittedTabLabel struct {
329	text    string
330	nameLen int
331}
332
333// fitTabLabels returns, per tab, the text drawn between its surrounding
334// spaces (attention glyph handled by the caller), guaranteeing all tabs fit.
335func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []fittedTabLabel {
336	reserve := 1
337	if rightText != "" {
338		reserve = statusTextWidth(rightText) + 2
339	}
340	budget := rowLen - reserve
341
342	full := make([]string, len(tabs))
343	overhead := make([]int, len(tabs))
344	widths := make([]int, len(tabs))
345	total := 0
346	for i, t := range tabs {
347		full[i] = composeTabTitle(t.name, t.paneTitle)
348		o := 2
349		if t.attention {
350			o += 1 + renderer.RuneWidth(ui.AttentionGlyph)
351		}
352		overhead[i] = o
353		widths[i] = statusTextWidth(full[i])
354		total += o + widths[i]
355	}
356	labels := make([]fittedTabLabel, len(tabs))
357	if total <= budget {
358		for i, t := range tabs {
359			labels[i] = fittedTabLabel{text: full[i], nameLen: len(t.name)}
360		}
361		return labels
362	}
363
364	// Overflow: greedy water-filling in ascending width order (shortest tabs
365	// first) so unused budget from short tabs flows to longer ones, while
366	// output order (labels indexed by original tab index) stays stable.
367	order := make([]int, len(tabs))
368	for i := range order {
369		order[i] = i
370	}
371	sort.SliceStable(order, func(a, b int) bool {
372		return widths[order[a]] < widths[order[b]]
373	})
374
375	remaining := budget
376	remainingTabs := len(tabs)
377	for _, i := range order {
378		share := remaining / remainingTabs
379		textBudget := share - overhead[i]
380		switch {
381		case textBudget <= 0:
382			labels[i] = fittedTabLabel{}
383		case widths[i] <= textBudget:
384			labels[i] = fittedTabLabel{text: full[i], nameLen: len(tabs[i].name)}
385		case textBudget >= statusTextWidth(tabs[i].name)+4:
386			labels[i] = fittedTabLabel{text: ui.TruncateText(full[i], textBudget), nameLen: len(tabs[i].name)}
387		default:
388			text := ui.TruncateText(tabs[i].name, textBudget)
389			labels[i] = fittedTabLabel{text: text, nameLen: len(text)}
390		}
391		consumed := overhead[i] + min(widths[i], max(textBudget, 0))
392		remaining -= consumed
393		remainingTabs--
394	}
395	return labels
396}
397
398func recentRouteDisplayWidth(entry recentRouteDisplay) int {
399	width := 2 + statusTextWidth(entry.name)
400	if entry.attention {
401		width += 1 + renderer.RuneWidth(ui.AttentionGlyph)
402	}
403	return width
404}
405
406func fitWholeRecentEntries[T any](entries []T, budget int, cost func(T) int) []T {
407	if budget <= 0 || len(entries) == 0 {
408		return nil
409	}
410	used := 0
411	for i, entry := range entries {
412		used += cost(entry)
413		if used > budget {
414			return entries[:i]
415		}
416	}
417	return entries
418}
419
420func fitRankedRecent(entries []rankedRecent, rowLen, leftUsed int, feedback string) []rankedRecent {
421	reserve := 1
422	if feedback != "" {
423		reserve = statusTextWidth(feedback) + 2
424	}
425	budget := rowLen - leftUsed - reserve
426	return fitWholeRecentEntries(entries, budget, func(entry rankedRecent) int {
427		return recentRouteDisplayWidth(recentRouteDisplay{
428			name:      entry.name,
429			kind:      entry.kind,
430			ephemeral: entry.ephemeral,
431			attention: entry.attention,
432		}) + statusTextWidth(strconv.Itoa(entry.rank)+":")
433	})
434}
435
436func fitMRU(entries []recentRouteDisplay, rowLen, leftUsed int, feedback string) []recentRouteDisplay {
437	// With no feedback, keep one blank trailing cell; with feedback, reserve
438	// its " text" width plus a one-cell gap so drawRightPlainText always fits.
439	copyReserve := 1
440	if feedback != "" {
441		copyReserve = statusTextWidth(feedback) + 2
442	}
443	physicalBudget := rowLen - leftUsed - copyReserve
444	if physicalBudget <= 0 || len(entries) == 0 {
445		return nil
446	}
447
448	budget := physicalBudget
449	if feedback == "" {
450		budget -= mruFutureRightReserve(rowLen)
451		// Keep at least one recent session when it physically fits; the reserved
452		// right side is only a budget preference, not a reason to hide all recents.
453		if firstCost := recentRouteDisplayWidth(entries[0]); firstCost <= physicalBudget && budget < firstCost {
454			budget = firstCost
455		}
456	}
457	return fitWholeRecentEntries(entries, budget, recentRouteDisplayWidth)
458}
459
460const (
461	mruReserveMinRow  = 40
462	mruReserveDivisor = 4
463	mruReserveMin     = 12
464	mruReserveMax     = 24
465)
466
467func mruFutureRightReserve(rowLen int) int {
468	if rowLen < mruReserveMinRow {
469		return 0
470	}
471	reserve := rowLen / mruReserveDivisor
472	if reserve < mruReserveMin {
473		return mruReserveMin
474	}
475	if reserve > mruReserveMax {
476		return mruReserveMax
477	}
478	return reserve
479}
480
481// writeBell draws the attention glyph on its visible beat, or a plain cell in
482// base's style on its blank beat, so the bell never leaves a bare
483// default-background hole in a themed bar.
484func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) {
485	style, visible := pulseStyle(frame, base)
486	if !visible {
487		writeStatusText(row, x, " ", base)
488		return
489	}
490	writeStatusText(row, x, string(ui.AttentionGlyph), style)
491}
492
493func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
494	for _, r := range text {
495		width := renderer.RuneWidth(r)
496		if width == 0 {
497			continue
498		}
499		if *x >= len(row) || *x+width > len(row) {
500			return
501		}
502		row[*x] = renderer.Cell{Rune: r, Style: style}
503		(*x)++
504		if width == 2 {
505			row[*x] = renderer.Cell{Style: style, Continuation: true}
506			(*x)++
507		}
508	}
509}

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