v0.2.0 80c61e3

input.go RawHistoryFolder

1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5	"bytes"
6	"errors"
7	"strconv"
8
9	"github.com/bnema/vev/internal/domain"
10	"github.com/bnema/vev/internal/usecase/keys"
11	"github.com/bnema/vev/internal/usecase/layout"
12	"github.com/bnema/vev/internal/usecase/mouse"
13)
14
15func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint64, data []byte) {
16	// Do not acknowledge client-side echo prediction here: input has only been
17	// accepted/routed, not necessarily echoed by the PTY and incorporated into a
18	// rendered screen state. Until prediction is implemented against rendered
19	// output state, Echo must remain conservative.
20	d.handleInput(sess, ac, data)
21}
22
23func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, _ uint64, data []byte) {
24	if !token.attachmentEffectCurrent() {
25		return
26	}
27	d.handleInputForAttachment(token, data)
28}
29
30func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
31	ac.initOverlays()
32	ac.mouseScan.Scan(data,
33		func(ev mouse.Event) { d.handleMouse(ac, ev) },
34		func(b []byte) {
35			if ac.overlays.HandleInput(d, b) {
36				return
37			}
38			ac.keys.Route(b)
39		},
40	)
41}
42
43func (d *Daemon) handleInputForAttachment(token attachmentConnectionToken, data []byte) {
44	ac := token.ac
45	ac.initOverlays()
46	ac.mouseScan.Scan(data,
47		func(ev mouse.Event) {
48			if token.attachmentEffectCurrent() {
49				d.handleMouse(ac, ev)
50			}
51		},
52		func(b []byte) {
53			if !token.attachmentEffectCurrent() {
54				return
55			}
56			if ac.overlays.HandleInput(d, b, token.effect) {
57				return
58			}
59			if !token.attachmentEffectCurrent() {
60				return
61			}
62			ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, connectionToken: token})
63		},
64	)
65}
66
67func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
68	d.handleMouseMutation(ac, ev)
69}
70
71func (d *Daemon) handleMouseMutation(ac *attachedClient, ev mouse.Event) {
72	frameEvent := ev
73	ac.initOverlays()
74	rt := ac.overlays
75	if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.resizeModeActive() {
76		return
77	}
78	sess := ac.currentSession()
79	if sess == nil {
80		invalidateRejectedLeftPointer(rt, ev)
81		return
82	}
83	tb := sess.tabForAttachment(ac)
84	if tb == nil {
85		invalidateRejectedLeftPointer(rt, ev)
86		return
87	}
88
89	if d.handleCopyMouse(sess, ac, tb, ev) {
90		return
91	}
92	// A fresh drag is pinned to its press geometry. Handle later events before
93	// normal terminal routing so crossing a split cannot retarget its document.
94	if ev.Button == mouse.Left && ev.Type != mouse.Press && d.handleFreshCopyPointer(sess, ac, ev) {
95		return
96	}
97
98	// Copy-mode publication deliberately revalidates outside the dispatch lock:
99	// a concurrent release or pane close must be able to invalidate its candidate.
100	var routed mouseRoute
101	_ = sess.runMutation(func() error {
102		routed = d.routeMouseMutation(sess, ac, ev)
103		return nil
104	})
105	if routed.handled {
106		return
107	}
108	if routed.p == nil {
109		invalidateRejectedLeftPointer(rt, ev)
110		return
111	}
112	if ev.Button == mouse.Left && ev.Type == mouse.Press && d.handleFreshCopyPress(sess, ac, routed.tb, routed.p, frameEvent) {
113		return
114	}
115	d.handleTerminalMouse(sess, ac, routed.p, routed.event, routed.translated, routed.hoveredFocused)
116}
117
118type mouseRoute struct {
119	tb             *tab
120	p              *pane
121	event          mouse.Event
122	translated     bool
123	hoveredFocused bool
124	handled        bool
125}
126
127func (d *Daemon) routeMouseMutation(sess *session, ac *attachedClient, ev mouse.Event) mouseRoute {
128	route := mouseRoute{event: ev, hoveredFocused: true}
129	tb := sess.tabForAttachment(ac)
130	if tb == nil {
131		route.handled = true
132		return route
133	}
134	route.tb = tb
135	contentRow := ev.Row - clientTopBarRows
136	tb.mu.Lock()
137	floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
138	if floatingVisible {
139		if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
140			tb.mu.Unlock()
141			route.handled = true
142			return route
143		}
144		tb.mu.Unlock()
145		route.p = floating
146		route.event = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y)
147		route.translated = true
148		return route
149	}
150	pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)
151	multi := len(tb.panes) > 1
152	focusedID := mouseFocusedPaneIDLocked(ac, tb)
153	if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) {
154		if !isMouseFocusPress(ev) {
155			tb.mu.Unlock()
156			route.handled = true
157			return route
158		}
159		tb.mu.Unlock()
160		_, focused := d.focusMousePane(sess, ac, tb, pl.ID)
161		if focused == nil {
162			route.handled = true
163			return route
164		}
165		invalidateRejectedLeftPointer(ac.overlays, ev)
166		if pl.ID != focusedID {
167			d.exitCopyMode(ac)
168			d.refreshPaneTitleOnFocus(sess, pl.ID)
169		}
170		d.invalidateRender(sess, ac, true, "input.go")
171		route.handled = true
172		return route
173	}
174	if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) {
175		oldFocus := focusedID
176		shouldFocus := isMouseFocusPress(ev)
177		p := tb.panes[pl.ID]
178		hoveredFocused := pl.ID == oldFocus
179		tb.mu.Unlock()
180		if shouldFocus {
181			_, p = d.focusMousePane(sess, ac, tb, pl.ID)
182		}
183		if p == nil {
184			route.handled = true
185			return route
186		}
187		if shouldFocus && pl.ID != oldFocus {
188			d.exitCopyMode(ac)
189			d.refreshPaneTitleOnFocus(sess, pl.ID)
190			d.invalidateRender(sess, ac, true, "input.go")
191		}
192		route.p = p
193		route.hoveredFocused = hoveredFocused
194		if multi {
195			route.event = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
196			route.translated = true
197		}
198		return route
199	}
200	if multi {
201		tb.mu.Unlock()
202		route.handled = true
203		return route
204	}
205	tb.mu.Unlock()
206	_, route.p = sess.paneForAttachment(ac)
207	return route
208}
209
210func mouseFocusedPaneIDLocked(ac *attachedClient, tb *tab) layout.PaneID {
211	if ac != nil && tb != nil {
212		view := ac.viewSnapshot()
213		if domain.TabStableID(tb.stableID) == view.tabID {
214			for _, p := range tb.panes {
215				if p != nil && domain.PaneStableID(p.stableID) == view.paneID {
216					return p.id
217				}
218			}
219		}
220	}
221	if tb == nil || tb.tree == nil {
222		return ""
223	}
224	return tb.tree.Focus
225}
226
227// focusMousePane runs inside the caller's session mutation boundary.
228func (d *Daemon) focusMousePane(sess *session, ac *attachedClient, tb *tab, id layout.PaneID) (bool, *pane) {
229	var changed bool
230	var focused *pane
231	tb.mu.Lock()
232	if tb.tree != nil {
233		focused = tb.panes[id]
234		if focused != nil {
235			changed = focusPlacementLocked(tb, id)
236		}
237	}
238	tb.mu.Unlock()
239	if focused == nil {
240		return false, nil
241	}
242	sess.mu.Lock()
243	sess.setAttachmentPaneLocked(ac, tb, focused)
244	sess.mu.Unlock()
245	if changed {
246		d.applyTabLayout(sess, tb)
247	}
248	return changed, focused
249}
250
251func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) {
252	if p == nil {
253		return
254	}
255	p.mu.Lock()
256	childRows := p.screen.Frame.Height
257	mouseMode, mouseSGR := p.screen.MouseMode()
258	altScreen := p.screen.AltScreenActive()
259	p.mu.Unlock()
260
261	if mouseMode != 0 {
262		if !mouseSGR || ev.Row == 0 || ev.Row > childRows {
263			return
264		}
265		_ = sess.runMutation(func() error {
266			if translated {
267				d.writeToPane(sess, p, ev.Raw)
268			} else {
269				d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows))
270			}
271			return nil
272		})
273		return
274	}
275
276	switch ev.Button {
277	case mouse.WheelUp:
278		if altScreen {
279			_ = sess.runMutation(func() error {
280				d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A"))
281				return nil
282			})
283			return
284		}
285		if !hoveredFocused {
286			return
287		}
288		d.enterCopyMode(sess, ac)
289		d.copyWheel(sess, ac, -3)
290	case mouse.WheelDown:
291		if altScreen {
292			_ = sess.runMutation(func() error {
293				d.writeToPane(sess, p, []byte("\x1b[B\x1b[B\x1b[B"))
294				return nil
295			})
296		}
297	}
298}
299
300func isMouseFocusPress(ev mouse.Event) bool {
301	return ev.Type == mouse.Press && (ev.Button == mouse.Left || ev.Button == mouse.Middle || ev.Button == mouse.Right)
302}
303
304func clampInt(v, lo, hi int) int {
305	if v < lo {
306		return lo
307	}
308	if v > hi {
309		return hi
310	}
311	return v
312}
313
314func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) {
315	if p == nil || p.pty == nil {
316		return
317	}
318	if _, err := p.pty.Write(data); err != nil {
319		name := ""
320		if sess != nil {
321			sess.mu.Lock()
322			name = sess.name
323			sess.mu.Unlock()
324		}
325		d.log.Error("pty write failed", "err", err, "session", name)
326		d.notify(sess, domain.NoticeError, domain.NoticeInputDropped,
327			"input not delivered to pane", err)
328	}
329}
330
331func sgrRowOffset(raw []byte, delta int) []byte {
332	return sgrOffset(raw, 0, delta)
333}
334
335func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
336	if len(raw) < len("\x1b[<0;1;1M") {
337		return raw
338	}
339	end := len(raw) - 1
340	if raw[0] != '\x1b' || raw[1] != '[' || raw[2] != '<' || (raw[end] != 'M' && raw[end] != 'm') {
341		return raw
342	}
343
344	parts := bytes.Split(raw[3:end], []byte(";"))
345	if len(parts) != 3 {
346		return raw
347	}
348	cx, err := strconv.Atoi(string(parts[1]))
349	if err != nil {
350		return raw
351	}
352	cy, err := strconv.Atoi(string(parts[2]))
353	if err != nil {
354		return raw
355	}
356	cx += colDelta
357	cy += rowDelta
358	if cx < 1 || cy < 1 {
359		return raw
360	}
361
362	out := make([]byte, 0, len(raw)+4)
363	out = append(out, raw[:3]...)
364	out = append(out, parts[0]...)
365	out = append(out, ';')
366	out = strconv.AppendInt(out, int64(cx), 10)
367	out = append(out, ';')
368	out = strconv.AppendInt(out, int64(cy), 10)
369	out = append(out, raw[end])
370	return out
371}
372
373type daemonKeyHandler struct {
374	d               *Daemon
375	ac              *attachedClient
376	actions         daemonActionRunner
377	connectionToken attachmentConnectionToken
378}
379
380// acquireAttachmentEffect preserves a synchronous frame's existing admission and
381// gives delayed router callbacks a fresh ticket for the exact captured attachment.
382// Direct/headless callers without an attachment token retain their existing behavior.
383func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
384	if h.connectionToken.ac != nil {
385		if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
386			return h.connectionToken.sess, effect, false
387		}
388		effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
389		if h.d.afterDelayedKeyEffectAttempt != nil {
390			h.d.afterDelayedKeyEffectAttempt(admitted)
391		}
392		if !admitted {
393			return nil, nil, false
394		}
395		if h.d.afterAttachmentEffectAdmitted != nil {
396			token := h.connectionToken
397			token.effect = effect
398			h.d.afterAttachmentEffectAdmitted(token)
399		}
400		// Keep this defensive check even though token admission normally requires a session.
401		if h.connectionToken.sess == nil {
402			effect.End()
403			return nil, nil, false
404		}
405		return h.connectionToken.sess, effect, true
406	}
407	sess := h.ac.currentSession()
408	if sess == nil {
409		return nil, nil, false
410	}
411	return sess, nil, false
412}
413
414func (h daemonKeyHandler) Forward(data []byte) {
415	sess, effect, owned := h.acquireAttachmentEffect()
416	if sess == nil {
417		return
418	}
419	if owned {
420		defer effect.End()
421	}
422	_ = sess.runMutation(func() error {
423		tb := sess.tabForAttachment(h.ac)
424		if tb == nil {
425			return nil
426		}
427		tb.mu.Lock()
428		p := tb.terminalTargetLocked()
429		tb.mu.Unlock()
430		h.d.writeToPane(sess, p, data)
431		return nil
432	})
433}
434
435func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
436	sess, effect, owned := h.acquireAttachmentEffect()
437	if sess == nil {
438		return
439	}
440	if owned {
441		defer effect.End()
442	}
443	runAction := func(request daemonActionRequest) {
444		request.effect = effect
445		runner := h.actions
446		if runner == nil {
447			runner = daemonActions{d: h.d}
448		}
449		if err := sess.runMutation(func() error {
450			request.target = resolveDaemonActionTargetForAttachment(sess, h.ac)
451			return runner.Run(request)
452		}); err != nil {
453			if errors.Is(err, errDaemonActionNoChange) {
454				return
455			}
456			var userErr *domain.UserError
457			if !errors.As(err, &userErr) {
458				err = resizeUserError(err)
459			}
460			h.d.reportError(sess, err)
461			return
462		}
463		if h.actions == nil {
464			finishDaemonActionForClient(h.d, request, h.ac, "input.go")
465		}
466	}
467	switch action {
468	case keys.ActionOpenPalette:
469		h.d.enterPalette(sess, h.ac)
470	case keys.ActionJumpAttention:
471		if effect == nil {
472			if err := h.d.jumpAttention(sess, h.ac); err != nil {
473				h.d.reportError(sess, err)
474			}
475			return
476		}
477		effect.bindActionEnd(h.d, "jump-attention")
478		token := h.connectionToken
479		token.effect = effect
480		if err := h.d.jumpAttentionForAttachment(sess, h.ac, token); err != nil {
481			h.d.reportError(sess, err)
482		}
483	case keys.ActionToggleFloatingPane:
484		if err := sess.runMutation(func() error { return h.d.toggleFloating(sess, h.ac) }); err != nil {
485			h.d.log.Warn("toggle floating pane failed", "err", err)
486			h.d.reportError(sess, err)
487		}
488	case keys.ActionFocusPaneLeft:
489		h.focus(sess, layout.Left, effect)
490	case keys.ActionFocusPaneRight:
491		h.focus(sess, layout.Right, effect)
492	case keys.ActionFocusPaneUp:
493		h.focus(sess, layout.Up, effect)
494	case keys.ActionFocusPaneDown:
495		h.focus(sess, layout.Down, effect)
496	case keys.ActionGrowPaneWidth:
497		runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})
498	case keys.ActionShrinkPaneWidth:
499		runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols})
500	case keys.ActionGrowPaneHeight:
501		runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows})
502	case keys.ActionShrinkPaneHeight:
503		runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows})
504	case keys.ActionEqualizePanes:
505		runAction(daemonActionRequest{kind: daemonActionEqualizePanes})
506	case keys.ActionConsumeOrExpelPaneLeft:
507		runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Left})
508	case keys.ActionConsumeOrExpelPaneRight:
509		runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Right})
510	case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
511		keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
512		keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
513		idx := int(action - keys.ActionSwitchTab1)
514		_ = sess.runMutation(func() error {
515			if sess.switchAttachmentTabForDispatch(h.ac, idx) {
516				h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil)
517				h.d.invalidateRender(sess, h.ac, true, "input.go")
518			}
519			return nil
520		})
521	}
522}
523
524func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *attachmentEffectTicket) {
525	err := h.d.focusDir(sess, h.ac, dir, effect)
526	if err != nil && !errors.Is(err, errAttachmentTransition) && !errors.Is(err, errNoNeighbor) {
527		h.d.reportError(sess, err)
528	}
529}

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