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

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