main ab6686c

event.go RawHistoryFolder

1// Package browser provides the static DOM runtime and strictly validated,
2// transport-neutral browser event values for github.com/bnema/vev-vt/html.
3package browser
4
5import (
6	"bytes"
7	"encoding/json"
8	"errors"
9	"fmt"
10	"io"
11	"math"
12	"unicode/utf8"
13
14	htmlrenderer "github.com/bnema/vev-vt/html"
15)
16
17// EventSchemaVersion identifies the runtime-to-Go event contract.
18const EventSchemaVersion = htmlrenderer.UpdateSchemaVersion
19
20var (
21	// ErrEventLimit reports that an event exceeded a configured resource bound.
22	ErrEventLimit = errors.New("html/browser: event resource limit exceeded")
23	// ErrInvalidEventLimits reports incompatible configured event bounds.
24	ErrInvalidEventLimits = errors.New("html/browser: invalid event limits")
25)
26
27const (
28	maxJSONEscapedByteLength = 6
29	eventEnvelopeOverhead    = 64
30)
31
32// EventLimits bounds untrusted browser event decoding. Zero fields select safe
33// defaults.
34type EventLimits struct {
35	MaxEventBytes int
36	MaxTextBytes  int
37	MaxPasteBytes int
38	MaxColumns    int
39	MaxRows       int
40}
41
42// DefaultEventLimits returns the bounds used for zero-valued event limits.
43func DefaultEventLimits() EventLimits {
44	return EventLimits{
45		MaxEventBytes: 8 << 20,
46		MaxTextBytes:  64 << 10,
47		MaxPasteBytes: 1 << 20,
48		MaxColumns:    10_000,
49		MaxRows:       10_000,
50	}
51}
52
53func normalizeEventLimits(limits EventLimits) (EventLimits, error) {
54	if limits.MaxEventBytes < 0 || limits.MaxTextBytes < 0 || limits.MaxPasteBytes < 0 || limits.MaxColumns < 0 || limits.MaxRows < 0 {
55		return EventLimits{}, fmt.Errorf("%w: event limits must not be negative", ErrInvalidEventLimits)
56	}
57	defaults := DefaultEventLimits()
58	if limits.MaxEventBytes == 0 {
59		limits.MaxEventBytes = defaults.MaxEventBytes
60	}
61	if limits.MaxTextBytes == 0 {
62		limits.MaxTextBytes = defaults.MaxTextBytes
63	}
64	if limits.MaxPasteBytes == 0 {
65		limits.MaxPasteBytes = defaults.MaxPasteBytes
66	}
67	if limits.MaxColumns == 0 {
68		limits.MaxColumns = defaults.MaxColumns
69	}
70	if limits.MaxRows == 0 {
71		limits.MaxRows = defaults.MaxRows
72	}
73	requiredEventBytes, representable := minimumEventBytes(max(limits.MaxTextBytes, limits.MaxPasteBytes))
74	if !representable || limits.MaxEventBytes < requiredEventBytes {
75		return EventLimits{}, fmt.Errorf("%w: MaxEventBytes %d cannot contain the largest payload limit", ErrInvalidEventLimits, limits.MaxEventBytes)
76	}
77	return limits, nil
78}
79
80func minimumEventBytes(payloadBytes int) (int, bool) {
81	maxInt := int(^uint(0) >> 1)
82	if payloadBytes > (maxInt-eventEnvelopeOverhead)/maxJSONEscapedByteLength {
83		return 0, false
84	}
85	return payloadBytes*maxJSONEscapedByteLength + eventEnvelopeOverhead, true
86}
87
88// EventKind identifies one closed browser event payload.
89type EventKind string
90
91// Supported browser event kinds.
92const (
93	EventText    EventKind = "text"
94	EventKey     EventKind = "key"
95	EventPaste   EventKind = "paste"
96	EventPointer EventKind = "pointer"
97	EventWheel   EventKind = "wheel"
98	EventResize  EventKind = "resize"
99	EventFocus   EventKind = "focus"
100)
101
102// Modifiers is the normalized browser modifier-key state.
103type Modifiers struct {
104	Alt   bool `json:"alt"`
105	Ctrl  bool `json:"ctrl"`
106	Meta  bool `json:"meta"`
107	Shift bool `json:"shift"`
108}
109
110// TextEvent carries composed text input.
111type TextEvent struct{ Text string }
112
113// PasteEvent carries bounded plain-text clipboard input. DecodeEvent preserves
114// Text unchanged, including control bytes such as ESC and CR. Consumers that
115// forward it to a PTY must apply their required framing or filtering policy.
116type PasteEvent struct{ Text string }
117
118// KeyEvent carries a non-text key or modified key press.
119type KeyEvent struct {
120	Key       string
121	Code      string
122	Modifiers Modifiers
123	Repeat    bool
124	Location  int
125}
126
127// PointerEvent carries a pointer action mapped to terminal coordinates.
128type PointerEvent struct {
129	Action    string
130	Button    int
131	Buttons   uint16
132	Row       int
133	Column    int
134	X         float64
135	Y         float64
136	Modifiers Modifiers
137}
138
139// WheelEvent carries browser wheel deltas and terminal coordinates.
140type WheelEvent struct {
141	DeltaX    float64
142	DeltaY    float64
143	DeltaMode int
144	Row       int
145	Column    int
146	Modifiers Modifiers
147}
148
149// ResizeEvent carries measured grid and CSS-pixel geometry.
150type ResizeEvent struct {
151	Columns          int
152	Rows             int
153	PixelWidth       float64
154	PixelHeight      float64
155	CellWidth        float64
156	CellHeight       float64
157	DevicePixelRatio float64
158}
159
160// FocusEvent reports input-proxy focus changes.
161type FocusEvent struct{ Focused bool }
162
163// Event is a closed tagged union. Exactly one payload matching Kind is set.
164type Event struct {
165	SchemaVersion uint16
166	Kind          EventKind
167	Text          *TextEvent
168	Key           *KeyEvent
169	Paste         *PasteEvent
170	Pointer       *PointerEvent
171	Wheel         *WheelEvent
172	Resize        *ResizeEvent
173	Focus         *FocusEvent
174}
175
176type envelope struct {
177	SchemaVersion uint16    `json:"schemaVersion"`
178	Kind          EventKind `json:"type"`
179}
180
181type textWire struct {
182	SchemaVersion uint16    `json:"schemaVersion"`
183	Kind          EventKind `json:"type"`
184	Text          *string   `json:"text"`
185}
186
187type keyWire struct {
188	SchemaVersion uint16    `json:"schemaVersion"`
189	Kind          EventKind `json:"type"`
190	Key           *string   `json:"key"`
191	Code          *string   `json:"code"`
192	Alt           bool      `json:"alt"`
193	Ctrl          bool      `json:"ctrl"`
194	Meta          bool      `json:"meta"`
195	Shift         bool      `json:"shift"`
196	Repeat        bool      `json:"repeat"`
197	Location      int       `json:"location"`
198}
199
200type pointerWire struct {
201	SchemaVersion uint16    `json:"schemaVersion"`
202	Kind          EventKind `json:"type"`
203	Action        *string   `json:"action"`
204	Button        *int      `json:"button"`
205	Buttons       *uint16   `json:"buttons"`
206	Row           *int      `json:"row"`
207	Column        *int      `json:"column"`
208	X             *float64  `json:"x"`
209	Y             *float64  `json:"y"`
210	Alt           bool      `json:"alt"`
211	Ctrl          bool      `json:"ctrl"`
212	Meta          bool      `json:"meta"`
213	Shift         bool      `json:"shift"`
214}
215
216type wheelWire struct {
217	SchemaVersion uint16    `json:"schemaVersion"`
218	Kind          EventKind `json:"type"`
219	DeltaX        *float64  `json:"deltaX"`
220	DeltaY        *float64  `json:"deltaY"`
221	DeltaMode     *int      `json:"deltaMode"`
222	Row           *int      `json:"row"`
223	Column        *int      `json:"column"`
224	Alt           bool      `json:"alt"`
225	Ctrl          bool      `json:"ctrl"`
226	Meta          bool      `json:"meta"`
227	Shift         bool      `json:"shift"`
228}
229
230type resizeWire struct {
231	SchemaVersion    uint16    `json:"schemaVersion"`
232	Kind             EventKind `json:"type"`
233	Columns          *int      `json:"columns"`
234	Rows             *int      `json:"rows"`
235	PixelWidth       *float64  `json:"pixelWidth"`
236	PixelHeight      *float64  `json:"pixelHeight"`
237	CellWidth        *float64  `json:"cellWidth"`
238	CellHeight       *float64  `json:"cellHeight"`
239	DevicePixelRatio *float64  `json:"devicePixelRatio"`
240}
241
242type focusWire struct {
243	SchemaVersion uint16    `json:"schemaVersion"`
244	Kind          EventKind `json:"type"`
245	Focused       *bool     `json:"focused"`
246}
247
248// DecodeEvent strictly decodes one complete untrusted browser event.
249func DecodeEvent(data []byte, requested EventLimits) (Event, error) {
250	limits, err := normalizeEventLimits(requested)
251	if err != nil {
252		return Event{}, err
253	}
254	if len(data) > limits.MaxEventBytes {
255		return Event{}, fmt.Errorf("%w: event has %d bytes, limit is %d", ErrEventLimit, len(data), limits.MaxEventBytes)
256	}
257	if !utf8.Valid(data) {
258		return Event{}, fmt.Errorf("html/browser: event is not valid UTF-8")
259	}
260	var header envelope
261	if err := json.Unmarshal(data, &header); err != nil {
262		return Event{}, fmt.Errorf("html/browser: decode event envelope: %w", err)
263	}
264	if header.SchemaVersion != EventSchemaVersion {
265		return Event{}, fmt.Errorf("html/browser: unsupported event schema %d", header.SchemaVersion)
266	}
267
268	event := Event{SchemaVersion: header.SchemaVersion, Kind: header.Kind}
269	switch header.Kind {
270	case EventText, EventPaste:
271		var wire textWire
272		if err := decodeStrict(data, &wire); err != nil {
273			return Event{}, err
274		}
275		if wire.Text == nil {
276			return Event{}, fmt.Errorf("html/browser: event text is required")
277		}
278		if !utf8.ValidString(*wire.Text) {
279			return Event{}, fmt.Errorf("html/browser: event text is not valid UTF-8")
280		}
281		limit := limits.MaxTextBytes
282		if header.Kind == EventPaste {
283			limit = limits.MaxPasteBytes
284		}
285		if len(*wire.Text) > limit {
286			return Event{}, fmt.Errorf("%w: %s has %d bytes, limit is %d", ErrEventLimit, header.Kind, len(*wire.Text), limit)
287		}
288		if header.Kind == EventText {
289			event.Text = &TextEvent{Text: *wire.Text}
290		} else {
291			event.Paste = &PasteEvent{Text: *wire.Text}
292		}
293	case EventKey:
294		var wire keyWire
295		if err := decodeStrict(data, &wire); err != nil {
296			return Event{}, err
297		}
298		if wire.Key == nil || wire.Code == nil || *wire.Key == "" || *wire.Code == "" || len(*wire.Key) > 128 || len(*wire.Code) > 128 || wire.Location < 0 || wire.Location > 3 {
299			return Event{}, fmt.Errorf("html/browser: invalid key event")
300		}
301		event.Key = &KeyEvent{Key: *wire.Key, Code: *wire.Code, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift), Repeat: wire.Repeat, Location: wire.Location}
302	case EventPointer:
303		var wire pointerWire
304		if err := decodeStrict(data, &wire); err != nil {
305			return Event{}, err
306		}
307		if wire.Action == nil || wire.Button == nil || wire.Buttons == nil || wire.Row == nil || wire.Column == nil || wire.X == nil || wire.Y == nil || !oneOf(*wire.Action, "down", "up", "move", "cancel") || *wire.Button < -1 || *wire.Button > 4 || *wire.Buttons > 31 || !validCell(*wire.Row, *wire.Column, limits) || !finiteBounded(*wire.X) || !finiteBounded(*wire.Y) {
308			return Event{}, fmt.Errorf("html/browser: invalid pointer event")
309		}
310		event.Pointer = &PointerEvent{Action: *wire.Action, Button: *wire.Button, Buttons: *wire.Buttons, Row: *wire.Row, Column: *wire.Column, X: *wire.X, Y: *wire.Y, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift)}
311	case EventWheel:
312		var wire wheelWire
313		if err := decodeStrict(data, &wire); err != nil {
314			return Event{}, err
315		}
316		if wire.DeltaX == nil || wire.DeltaY == nil || wire.DeltaMode == nil || wire.Row == nil || wire.Column == nil || *wire.DeltaMode < 0 || *wire.DeltaMode > 2 || !validCell(*wire.Row, *wire.Column, limits) || !finiteBounded(*wire.DeltaX) || !finiteBounded(*wire.DeltaY) {
317			return Event{}, fmt.Errorf("html/browser: invalid wheel event")
318		}
319		event.Wheel = &WheelEvent{DeltaX: *wire.DeltaX, DeltaY: *wire.DeltaY, DeltaMode: *wire.DeltaMode, Row: *wire.Row, Column: *wire.Column, Modifiers: modifiers(wire.Alt, wire.Ctrl, wire.Meta, wire.Shift)}
320	case EventResize:
321		var wire resizeWire
322		if err := decodeStrict(data, &wire); err != nil {
323			return Event{}, err
324		}
325		if wire.Columns == nil || wire.Rows == nil || wire.PixelWidth == nil || wire.PixelHeight == nil || wire.CellWidth == nil || wire.CellHeight == nil || wire.DevicePixelRatio == nil || *wire.Columns <= 0 || *wire.Columns > limits.MaxColumns || *wire.Rows <= 0 || *wire.Rows > limits.MaxRows || !positiveFinite(*wire.PixelWidth) || !positiveFinite(*wire.PixelHeight) || !positiveFinite(*wire.CellWidth) || !positiveFinite(*wire.CellHeight) || !positiveFinite(*wire.DevicePixelRatio) {
326			return Event{}, fmt.Errorf("html/browser: invalid resize event")
327		}
328		event.Resize = &ResizeEvent{Columns: *wire.Columns, Rows: *wire.Rows, PixelWidth: *wire.PixelWidth, PixelHeight: *wire.PixelHeight, CellWidth: *wire.CellWidth, CellHeight: *wire.CellHeight, DevicePixelRatio: *wire.DevicePixelRatio}
329	case EventFocus:
330		var wire focusWire
331		if err := decodeStrict(data, &wire); err != nil {
332			return Event{}, err
333		}
334		if wire.Focused == nil {
335			return Event{}, fmt.Errorf("html/browser: invalid focus event")
336		}
337		event.Focus = &FocusEvent{Focused: *wire.Focused}
338	default:
339		return Event{}, fmt.Errorf("html/browser: unknown event kind %q", header.Kind)
340	}
341	return event, nil
342}
343
344func decodeStrict(data []byte, dst any) error {
345	decoder := json.NewDecoder(bytes.NewReader(data))
346	decoder.DisallowUnknownFields()
347	if err := decoder.Decode(dst); err != nil {
348		return fmt.Errorf("html/browser: decode event: %w", err)
349	}
350	if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) {
351		return fmt.Errorf("html/browser: event must contain one JSON value")
352	}
353	return nil
354}
355
356func modifiers(alt, ctrl, meta, shift bool) Modifiers {
357	return Modifiers{Alt: alt, Ctrl: ctrl, Meta: meta, Shift: shift}
358}
359
360func validCell(row, column int, limits EventLimits) bool {
361	return row >= 0 && row < limits.MaxRows && column >= 0 && column < limits.MaxColumns
362}
363
364func finiteBounded(value float64) bool {
365	return !math.IsNaN(value) && !math.IsInf(value, 0) && math.Abs(value) <= 10_000_000
366}
367
368func positiveFinite(value float64) bool { return value > 0 && finiteBounded(value) }
369
370func oneOf(value string, allowed ...string) bool {
371	for _, candidate := range allowed {
372		if value == candidate {
373			return true
374		}
375	}
376	return false
377}

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 2

Tags 13

↑↓ moveenter openesc close