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

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