preview_manager.go RawHistoryFolder

1package client
2
3import (
4	"time"
5
6	renderer "github.com/bnema/vev-vt"
7	"github.com/bnema/vev/internal/domain"
8	"github.com/bnema/vev/internal/ports"
9	"github.com/bnema/vev/internal/protocol"
10	pickerusecase "github.com/bnema/vev/internal/usecase/picker"
11)
12
13// pickerPreviewDebounce bounds how long the cursor may rest before the client
14// subscribes to that row's preview. The daemon pushes frames afterwards, so
15// this only throttles cursor movement.
16const pickerPreviewDebounce = 80 * time.Millisecond
17
18// previewCacheSize bounds the last-good frames kept for stale-while-refresh.
19const previewCacheSize = 8
20
21// previewState is how the displayed preview relates to its target.
22type previewState uint8
23
24const (
25	previewFresh previewState = iota
26	previewStale
27	previewLoading
28	previewUnavailable
29	previewMissing
30)
31
32// pickerPreviewHost is the picker half of the preview: it names the selected
33// row's route and viewport and displays whatever the manager decides.
34type pickerPreviewHost interface {
35	PreviewRequest(domain.Size) (ports.BrokerPreviewRoute, protocol.RemotePreviewRequest, bool)
36	SetPreview(protocol.RemotePreview, previewState)
37	ClearPreview()
38}
39
40type previewCacheKey struct {
41	endpoint  string
42	lifecycle domain.SessionLifecycleID
43	tab       domain.TabStableID
44	width     uint16
45	height    uint16
46}
47
48func previewKeyFor(request protocol.RemotePreviewRequest) previewCacheKey {
49	return previewCacheKey{endpoint: request.Target.Endpoint, lifecycle: request.Target.LifecycleID, tab: request.Target.LiveTabID, width: request.Width, height: request.Height}
50}
51
52type previewCacheEntry struct {
53	key   previewCacheKey
54	frame protocol.RemotePreview
55}
56
57// previewCache is a tiny LRU of the last OK frame per target; the most
58// recently used entry is last.
59type previewCache struct {
60	entries []previewCacheEntry
61}
62
63func (c *previewCache) get(key previewCacheKey) (protocol.RemotePreview, bool) {
64	for i, entry := range c.entries {
65		if entry.key == key {
66			c.entries = append(append(c.entries[:i:i], c.entries[i+1:]...), entry)
67			return entry.frame, true
68		}
69	}
70	return protocol.RemotePreview{}, false
71}
72
73func (c *previewCache) put(key previewCacheKey, frame protocol.RemotePreview) {
74	for i, entry := range c.entries {
75		if entry.key == key {
76			c.entries = append(c.entries[:i:i], c.entries[i+1:]...)
77			break
78		}
79	}
80	if len(c.entries) >= previewCacheSize {
81		c.entries = append(c.entries[:0:0], c.entries[len(c.entries)-previewCacheSize+1:]...)
82	}
83	c.entries = append(c.entries, previewCacheEntry{key: key, frame: frame})
84}
85
86// previewManager owns the selected row's broker preview subscription. It is
87// driven only by the supervisor's serialized loops; generation never
88// decreases, including across broker reconnections.
89type previewManager struct {
90	clock      ports.Clock
91	generation ports.BrokerPreviewGeneration
92	sub        ports.BrokerPreviewSubscription
93	request    ports.BrokerPreviewRequest
94	// pending is the debounced selection not yet subscribed. Its wake is
95	// the manager's changed channel until it fires or is replaced.
96	pending *previewDebounce
97	cache   previewCache
98	// frame is the last OK frame shown for the current selection.
99	frame    protocol.RemotePreview
100	hasFrame bool
101}
102
103// previewDebounce is one armed selection. Its goroutine only forwards the
104// clock timer to wake and exits on fire or cancellation.
105type previewDebounce struct {
106	service ports.BrokerNavigator
107	route   ports.BrokerPreviewRoute
108	preview protocol.RemotePreviewRequest
109	wake    chan struct{}
110	stop    chan struct{}
111}
112
113func (d *previewDebounce) cancel() { close(d.stop) }
114
115func (m *previewManager) armDebounce(service ports.BrokerNavigator, route ports.BrokerPreviewRoute, preview protocol.RemotePreviewRequest) {
116	pending := &previewDebounce{service: service, route: route, preview: preview, wake: make(chan struct{}, 1), stop: make(chan struct{})}
117	m.pending = pending
118	if supervisorNil(m.clock) {
119		pending.wake <- struct{}{}
120		return
121	}
122	timer := m.clock.NewTimer(pickerPreviewDebounce)
123	go func() {
124		select {
125		case <-timer.C():
126			pending.wake <- struct{}{}
127		case <-pending.stop:
128			timer.Stop()
129		}
130	}()
131}
132
133// stop ends the current subscription and debounce without touching the
134// display or the cache.
135func (m *previewManager) stop() {
136	if m.pending != nil {
137		m.pending.cancel()
138		m.pending = nil
139	}
140	if m.sub != nil {
141		m.sub.Close()
142		m.sub = nil
143	}
144	m.request = ports.BrokerPreviewRequest{}
145}
146
147func (m *previewManager) close(host pickerHost) {
148	m.stop()
149	m.frame, m.hasFrame = protocol.RemotePreview{}, false
150	if picker, ok := host.(pickerPreviewHost); ok {
151		picker.ClearPreview()
152	}
153}
154
155// selected reports whether route/preview is already subscribed or debounced.
156func (m *previewManager) selected(route ports.BrokerPreviewRoute, preview protocol.RemotePreviewRequest) bool {
157	if m.pending != nil {
158		return m.pending.route == route && m.pending.preview == preview
159	}
160	return m.sub != nil && m.request.Route == route && m.request.Preview == preview
161}
162
163// refresh follows the picker's selected row. A new selection shows its cached
164// frame (stale) or a loading placeholder at once, then subscribes after the
165// cursor rests for the debounce.
166func (m *previewManager) refresh(service ports.BrokerNavigator, host pickerHost, size domain.Size) {
167	picker, ok := host.(pickerPreviewHost)
168	if supervisorNil(service) || !ok {
169		m.close(host)
170		return
171	}
172	route, preview, ok := picker.PreviewRequest(size)
173	if !ok {
174		m.close(host)
175		return
176	}
177	if m.selected(route, preview) {
178		return
179	}
180	m.stop()
181	m.frame, m.hasFrame = m.cache.get(previewKeyFor(preview))
182	if m.hasFrame {
183		picker.SetPreview(m.frame, previewStale)
184	} else {
185		picker.SetPreview(protocol.RemotePreview{}, previewLoading)
186	}
187	m.armDebounce(service, route, preview)
188}
189
190func (m *previewManager) changed() <-chan struct{} {
191	if m.pending != nil {
192		return m.pending.wake
193	}
194	if m.sub == nil {
195		return nil
196	}
197	return m.sub.Changed()
198}
199
200// publish handles one wake of changed and reports whether the display
201// changed.
202func (m *previewManager) publish(host pickerHost) bool {
203	picker, ok := host.(pickerPreviewHost)
204	if !ok {
205		return false
206	}
207	if m.pending != nil {
208		return m.subscribe(picker)
209	}
210	if m.sub == nil {
211		return false
212	}
213	publication := m.sub.Latest()
214	if publication.Validate() != nil ||
215		publication.Epoch != m.request.Epoch || publication.Connection != m.request.Connection ||
216		publication.Generation != m.request.Generation || publication.Target != m.request.Preview.Target ||
217		publication.Width != m.request.Preview.Width || publication.Height != m.request.Preview.Height {
218		return false
219	}
220	if publication.Err == nil && publication.Preview.Status == protocol.RemotePreviewOK {
221		m.frame, m.hasFrame = publication.Preview, true
222		m.cache.put(previewKeyFor(m.request.Preview), publication.Preview)
223		picker.SetPreview(publication.Preview, previewFresh)
224		return true
225	}
226	missing := publication.Err == nil && publication.Preview.Status == protocol.RemotePreviewNoSuchTarget
227	m.showFailure(picker, missing)
228	return true
229}
230
231// subscribe opens the debounced selection's broker subscription.
232func (m *previewManager) subscribe(picker pickerPreviewHost) bool {
233	pending := m.pending
234	m.pending = nil
235	m.generation++
236	request := ports.BrokerPreviewRequest{Epoch: pending.service.Snapshot().Epoch, Connection: pending.service.ConnectionID(), Generation: m.generation, Route: pending.route, Preview: pending.preview}
237	sub, err := pending.service.SubscribePreview(request)
238	if err != nil || supervisorNil(sub) {
239		m.showFailure(picker, false)
240		return true
241	}
242	m.request, m.sub = request, sub
243	return false
244}
245
246// showFailure keeps the last good frame, marked stale, or names why there is
247// none.
248func (m *previewManager) showFailure(picker pickerPreviewHost, missing bool) {
249	switch {
250	case m.hasFrame:
251		picker.SetPreview(m.frame, previewStale)
252	case missing:
253		picker.SetPreview(protocol.RemotePreview{}, previewMissing)
254	default:
255		picker.SetPreview(protocol.RemotePreview{}, previewUnavailable)
256	}
257}
258
259// previewView turns one frame and state into the picker's preview pane. Stale
260// frames are dimmed; frameless states show one short dim label.
261func previewView(preview protocol.RemotePreview, state previewState) pickerusecase.Preview {
262	if state == previewFresh || state == previewStale {
263		rows := preview.FrameRows()
264		if preview.Status != protocol.RemotePreviewOK || rows == nil {
265			return pickerusecase.Preview{}
266		}
267		if state == previewStale {
268			for _, row := range rows {
269				for x := range row {
270					row[x].Style.Attrs |= renderer.AttrDim
271				}
272			}
273		}
274		return pickerusecase.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)}
275	}
276	label := "loading preview…"
277	switch state {
278	case previewUnavailable:
279		label = "preview unavailable"
280	case previewMissing:
281		label = "session gone"
282	}
283	style := renderer.DefaultStyle()
284	style.Attrs |= renderer.AttrDim
285	row := make([]renderer.Cell, 0, len(label))
286	for _, r := range label {
287		row = append(row, renderer.Cell{Rune: r, Style: style})
288	}
289	return pickerusecase.Preview{Rows: [][]renderer.Cell{row}, Width: len(row), Height: 1}
290}

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