v0.30.0 eb3068f

dom_js.go RawHistoryFolder

1//go:build js && wasm
2
3package systemviews
4
5import (
6	"fmt"
7	"strconv"
8	"strings"
9	"syscall/js"
10)
11
12type browserDOM struct {
13	document                js.Value
14	target                  js.Value
15	bindings                []jsEventBinding
16	historyObserver         js.Value
17	historyObserverCallback js.Func
18	activeConfirmCleanup    func()
19	activeConfirmID         uint64
20}
21
22type jsEventBinding struct {
23	target js.Value
24	event  string
25	fn     js.Func
26}
27
28func NewDOM() DOM {
29	doc := js.Global().Get("document")
30	if !doc.Truthy() {
31		return &browserDOM{}
32	}
33
34	target := doc.Call("getElementById", "app")
35	return &browserDOM{document: doc, target: target}
36}
37
38func (d *browserDOM) Mount(markup string) error {
39	if d == nil || !d.target.Truthy() {
40		return fmt.Errorf("DOM mount target #app not found")
41	}
42	d.target.Set("innerHTML", markup)
43	d.updateDocumentTitle()
44	d.scheduleAlertDismissal()
45	d.syncPerformanceCustomInputStates()
46	d.focusAutofocusTarget()
47	d.setupHistoryInfiniteScroll()
48	return nil
49}
50
51func (d *browserDOM) AppendHistoryTimeline(markup string) error {
52	if d == nil || !d.target.Truthy() {
53		return fmt.Errorf("DOM mount target #app not found")
54	}
55	timeline := d.target.Call("querySelector", "[data-sv-history-timeline]")
56	if !timeline.Truthy() {
57		return fmt.Errorf("history timeline target not found")
58	}
59	if old := timeline.Call("querySelector", "[data-sv-history-load-more-container]"); old.Truthy() && old.Get("remove").Truthy() {
60		old.Call("remove")
61	}
62
63	container := d.document.Call("createElement", "div")
64	container.Set("innerHTML", markup)
65	d.mergeLeadingHistoryGroup(timeline, container)
66	for container.Get("firstChild").Truthy() {
67		timeline.Call("appendChild", container.Get("firstChild"))
68	}
69	d.setupHistoryInfiniteScroll()
70	return nil
71}
72
73func (d *browserDOM) mergeLeadingHistoryGroup(timeline, container js.Value) {
74	if !timeline.Truthy() || !container.Truthy() {
75		return
76	}
77	first := container.Get("firstElementChild")
78	if !first.Truthy() || !first.Get("hasAttribute").Truthy() || !first.Call("hasAttribute", "data-sv-history-merge-date").Bool() {
79		return
80	}
81	date := strings.TrimSpace(first.Call("getAttribute", "data-sv-history-merge-date").String())
82	if date == "" {
83		return
84	}
85	groups := timeline.Call("querySelectorAll", "[data-sv-history-date]")
86	if !groups.Truthy() || groups.Get("length").Int() == 0 {
87		return
88	}
89	lastGroup := groups.Index(groups.Get("length").Int() - 1)
90	if strings.TrimSpace(lastGroup.Call("getAttribute", "data-sv-history-date").String()) != date {
91		return
92	}
93	list := lastGroup.Call("querySelector", "ul")
94	if !list.Truthy() {
95		return
96	}
97	for first.Get("firstChild").Truthy() {
98		list.Call("appendChild", first.Get("firstChild"))
99	}
100	if first.Get("remove").Truthy() {
101		first.Call("remove")
102	}
103}
104
105func (d *browserDOM) updateDocumentTitle() {
106	if d == nil || !d.document.Truthy() || !d.target.Truthy() {
107		return
108	}
109	root := d.target.Call("querySelector", "[data-page-title]")
110	if !root.Truthy() {
111		return
112	}
113	title := strings.TrimSpace(root.Call("getAttribute", "data-page-title").String())
114	if title == "" {
115		return
116	}
117	d.document.Set("title", title)
118}
119
120func (d *browserDOM) focusAutofocusTarget() {
121	if d == nil || !d.target.Truthy() {
122		return
123	}
124	target := d.target.Call("querySelector", "[data-sv-autofocus]")
125	if !target.Truthy() || !target.Get("focus").Truthy() {
126		return
127	}
128	target.Call("focus")
129}
130
131func (d *browserDOM) scheduleAlertDismissal() {
132	if d == nil || !d.target.Truthy() {
133		return
134	}
135	alerts := d.target.Call("querySelectorAll", ".sv-alert")
136	length := alerts.Get("length").Int()
137	if length == 0 {
138		return
139	}
140	setTimeout := js.Global().Get("setTimeout")
141	if !setTimeout.Truthy() {
142		return
143	}
144	for i := 0; i < length; i++ {
145		alert := alerts.Index(i)
146		if !alert.Truthy() {
147			continue
148		}
149		scheduleAlertRemoval(setTimeout, alert)
150	}
151}
152
153func scheduleAlertRemoval(setTimeout, alert js.Value) {
154	var fadeFn js.Func
155	fadeFn = js.FuncOf(func(js.Value, []js.Value) any {
156		defer fadeFn.Release()
157		if !alert.Truthy() || !alert.Get("classList").Truthy() {
158			return nil
159		}
160		alert.Get("classList").Call("add", "sv-alert-dismiss")
161
162		var removeFn js.Func
163		removeFn = js.FuncOf(func(js.Value, []js.Value) any {
164			defer removeFn.Release()
165			if alert.Truthy() && alert.Get("remove").Truthy() {
166				alert.Call("remove")
167			}
168			return nil
169		})
170		setTimeout.Invoke(removeFn, 220)
171		return nil
172	})
173	setTimeout.Invoke(fadeFn, 4200)
174}
175
176func (d *browserDOM) BindActions(handler DOMActionHandler) error {
177	if d == nil || !d.target.Truthy() || handler == nil {
178		return fmt.Errorf("DOM action binding target not available")
179	}
180	d.Release()
181
182	clickHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
183		event := firstJSArg(args)
184		if !event.Truthy() {
185			return nil
186		}
187		element := closestActionElement(event)
188		if !element.Truthy() {
189			return nil
190		}
191		if strings.EqualFold(element.Get("tagName").String(), "form") {
192			target := event.Get("target")
193			if !target.Truthy() {
194				target = event.Get("srcElement")
195			}
196			if !target.Truthy() || !element.Equal(target) {
197				return nil
198			}
199		}
200		action := element.Call("getAttribute", "data-sv-action").String()
201		if action == "" {
202			return nil
203		}
204		event.Call("preventDefault")
205		data := collectActionData(element)
206		d.confirmAction(element, func() {
207			handler(DOMAction{Action: action, Data: data})
208		})
209		return nil
210	})
211	d.addEventBinding(d.target, "click", clickHandler)
212
213	submitHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
214		event := firstJSArg(args)
215		if !event.Truthy() {
216			return nil
217		}
218		form := event.Get("target")
219		if !form.Truthy() {
220			return nil
221		}
222		action := form.Call("getAttribute", "data-sv-action").String()
223		if action == "" {
224			return nil
225		}
226		event.Call("preventDefault")
227		updatePerformanceCustomInputs(form)
228		data := collectActionData(form)
229		for key, value := range collectFormData(form) {
230			data[key] = value
231		}
232		d.confirmAction(form, func() {
233			handler(DOMAction{Action: action, Data: data})
234		})
235		return nil
236	})
237	d.addEventBinding(d.target, "submit", submitHandler)
238
239	changeHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
240		event := firstJSArg(args)
241		if !event.Truthy() {
242			return nil
243		}
244		target := event.Get("target")
245		if !target.Truthy() || !target.Get("matches").Truthy() || !target.Call("matches", "[data-sv-performance-profile]").Bool() {
246			return nil
247		}
248		form := target.Get("form")
249		if form.Truthy() {
250			updatePerformanceCustomInputs(form)
251		}
252		return nil
253	})
254	d.addEventBinding(d.target, "change", changeHandler)
255
256	keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
257		event := firstJSArg(args)
258		if !d.eventTargetInsideMount(event) {
259			return nil
260		}
261		d.handleKeyboard(event)
262		return nil
263	})
264	d.addEventBinding(d.document, "keydown", keydownHandler)
265
266	return nil
267}
268
269func (d *browserDOM) eventTargetInsideMount(event js.Value) bool {
270	if d == nil || !d.target.Truthy() || !event.Truthy() {
271		return false
272	}
273	target := event.Get("target")
274	if !target.Truthy() {
275		target = event.Get("srcElement")
276	}
277	if !target.Truthy() {
278		return false
279	}
280	if d.document.Truthy() && target.Equal(d.document) {
281		return true
282	}
283	if strings.EqualFold(target.Get("nodeName").String(), "body") {
284		return true
285	}
286	return d.target.Call("contains", target).Bool()
287}
288
289func (d *browserDOM) addEventBinding(target js.Value, event string, fn js.Func) {
290	if !target.Truthy() {
291		fn.Release()
292		return
293	}
294	target.Call("addEventListener", event, fn)
295	d.bindings = append(d.bindings, jsEventBinding{target: target, event: event, fn: fn})
296}
297
298func (d *browserDOM) setupHistoryInfiniteScroll() {
299	if d == nil || !d.target.Truthy() {
300		return
301	}
302	// setupHistoryInfiniteScroll callbacks close over the current load-more
303	// button. disconnectHistoryObserver owns the cleanup chain: it calls
304	// d.historyObserver.disconnect(), releases d.historyObserverCallback, and
305	// lets setupHistoryInfiniteScroll overwrite d.historyObserver with the new
306	// observer so old button closures are not retained.
307	d.disconnectHistoryObserver()
308	observerCtor := js.Global().Get("IntersectionObserver")
309	if !observerCtor.Truthy() {
310		return
311	}
312	button := d.target.Call("querySelector", "[data-sv-history-load-more]")
313	if !button.Truthy() {
314		return
315	}
316	callback := js.FuncOf(func(_ js.Value, args []js.Value) any {
317		if len(args) == 0 || !button.Truthy() {
318			return nil
319		}
320		entries := args[0]
321		for i := 0; i < entries.Get("length").Int(); i++ {
322			entry := entries.Index(i)
323			if !entry.Truthy() || !entry.Get("isIntersecting").Bool() {
324				continue
325			}
326			if button.Get("disabled").Bool() || button.Get("dataset").Get("svLoading").String() == "true" {
327				return nil
328			}
329			// A disabled button does not dispatch click events. Dispatch first, then
330			// mark the control busy so the observer cannot enqueue duplicate loads
331			// while the WASM action worker fetches the next history window.
332			button.Call("click")
333			button.Get("dataset").Set("svLoading", "true")
334			button.Set("disabled", true)
335			button.Set("textContent", "Loading older visits…")
336			return nil
337		}
338		return nil
339	})
340	d.historyObserverCallback = callback
341	options := js.Global().Get("Object").New()
342	options.Set("rootMargin", "400px 0px")
343	d.historyObserver = observerCtor.New(callback, options)
344	d.historyObserver.Call("observe", button)
345}
346
347func (d *browserDOM) disconnectHistoryObserver() {
348	if d == nil {
349		return
350	}
351	if d.historyObserver.Truthy() {
352		d.historyObserver.Call("disconnect")
353		d.historyObserver = js.Value{}
354	}
355	if d.historyObserverCallback.Truthy() {
356		d.historyObserverCallback.Release()
357		d.historyObserverCallback = js.Func{}
358	}
359}
360
361func (d *browserDOM) Release() {
362	if d == nil {
363		return
364	}
365	d.disconnectHistoryObserver()
366	d.cleanupActiveConfirmDialog()
367	for _, binding := range d.bindings {
368		if binding.target.Truthy() {
369			binding.target.Call("removeEventListener", binding.event, binding.fn)
370		}
371		binding.fn.Release()
372	}
373	d.bindings = nil
374}
375
376func firstJSArg(args []js.Value) js.Value {
377	if len(args) == 0 {
378		return js.Value{}
379	}
380	return args[0]
381}
382
383func closestActionElement(event js.Value) js.Value {
384	target := event.Get("target")
385	if !target.Truthy() || !target.Get("closest").Truthy() {
386		return js.Value{}
387	}
388	return target.Call("closest", "[data-sv-action]")
389}
390
391func collectActionData(element js.Value) map[string]string {
392	data := map[string]string{}
393	if !element.Truthy() {
394		return data
395	}
396	dataset := element.Get("dataset")
397	if !dataset.Truthy() {
398		return data
399	}
400	keys := js.Global().Get("Object").Call("keys", dataset)
401	for i := 0; i < keys.Get("length").Int(); i++ {
402		key := keys.Index(i).String()
403		if key == "svAction" || key == "svConfirm" {
404			continue
405		}
406		data[key] = dataset.Get(key).String()
407	}
408	return data
409}
410
411func (d *browserDOM) syncPerformanceCustomInputStates() {
412	if d == nil || !d.target.Truthy() || !d.target.Get("querySelectorAll").Truthy() {
413		return
414	}
415	forms := d.target.Call("querySelectorAll", "[data-sv-performance-form]")
416	for i := 0; i < forms.Get("length").Int(); i++ {
417		updatePerformanceCustomInputs(forms.Index(i))
418	}
419}
420
421func updatePerformanceCustomInputs(form js.Value) {
422	if !form.Truthy() || !form.Get("querySelector").Truthy() {
423		return
424	}
425	selectEl := form.Call("querySelector", "[data-sv-performance-profile]")
426	if !selectEl.Truthy() {
427		return
428	}
429	enabled := strings.EqualFold(selectEl.Get("value").String(), "custom")
430	inputs := form.Call("querySelectorAll", "[data-sv-performance-custom]")
431	for i := 0; i < inputs.Get("length").Int(); i++ {
432		inputs.Index(i).Set("disabled", !enabled)
433	}
434}
435
436func collectFormData(form js.Value) map[string]string {
437	data := map[string]string{}
438	if !form.Truthy() || !js.Global().Get("FormData").Truthy() {
439		return data
440	}
441	formData := js.Global().Get("FormData").New(form)
442	entries := js.Global().Get("Array").Call("from", formData.Call("entries"))
443	for i := 0; i < entries.Get("length").Int(); i++ {
444		pair := entries.Index(i)
445		if pair.Get("length").Int() < 2 {
446			continue
447		}
448		key := pair.Index(0).String()
449		value := pair.Index(1).String()
450		if existing := data[key]; existing != "" {
451			data[key] = existing + "," + value
452			continue
453		}
454		data[key] = value
455	}
456	return data
457}
458
459func (d *browserDOM) confirmAction(element js.Value, onConfirm func()) {
460	if onConfirm == nil {
461		return
462	}
463	confirmAttr := element.Call("getAttribute", "data-sv-confirm")
464	if confirmAttr.Type() != js.TypeString {
465		onConfirm()
466		return
467	}
468	message := strings.TrimSpace(confirmAttr.String())
469	if message == "" {
470		onConfirm()
471		return
472	}
473	if !d.showConfirmDialog(message, onConfirm) {
474		onConfirm()
475	}
476}
477
478func (d *browserDOM) cleanupActiveConfirmDialog() {
479	if d == nil || d.activeConfirmCleanup == nil {
480		return
481	}
482	cleanup := d.activeConfirmCleanup
483	d.activeConfirmCleanup = nil
484	cleanup()
485}
486
487func (d *browserDOM) showConfirmDialog(message string, onConfirm func()) bool {
488	if d == nil || !d.document.Truthy() {
489		return false
490	}
491	body := d.document.Get("body")
492	if !body.Truthy() {
493		return false
494	}
495	// Always close an existing modal through its cleanup path so key handlers and
496	// js.Func values are released before the replacement is mounted.
497	d.cleanupActiveConfirmDialog()
498
499	overlay := d.document.Call("createElement", "div")
500	overlay.Get("classList").Call("add", "sv-confirm-backdrop")
501	overlay.Set("role", "presentation")
502
503	dialog := d.document.Call("createElement", "div")
504	dialog.Get("classList").Call("add", "sv-confirm-dialog")
505	dialog.Set("role", "dialog")
506	dialog.Call("setAttribute", "aria-modal", "true")
507	dialog.Call("setAttribute", "aria-labelledby", "sv-confirm-title")
508	dialog.Call("setAttribute", "aria-describedby", "sv-confirm-message")
509	overlay.Call("appendChild", dialog)
510
511	title := d.document.Call("createElement", "h3")
512	title.Set("id", "sv-confirm-title")
513	title.Set("textContent", "Confirm action")
514	dialog.Call("appendChild", title)
515
516	text := d.document.Call("createElement", "p")
517	text.Set("id", "sv-confirm-message")
518	text.Set("textContent", message)
519	dialog.Call("appendChild", text)
520
521	actions := d.document.Call("createElement", "div")
522	actions.Get("classList").Call("add", "sv-confirm-actions")
523	dialog.Call("appendChild", actions)
524
525	cancelButton := d.document.Call("createElement", "button")
526	cancelButton.Set("type", "button")
527	cancelButton.Set("className", "sv-button sv-button-secondary")
528	cancelButton.Set("textContent", "Cancel")
529	actions.Call("appendChild", cancelButton)
530
531	confirmButton := d.document.Call("createElement", "button")
532	confirmButton.Set("type", "button")
533	confirmButton.Set("className", "sv-button sv-button-danger")
534	confirmButton.Set("textContent", "Confirm")
535	actions.Call("appendChild", confirmButton)
536
537	d.activeConfirmID++
538	confirmID := d.activeConfirmID
539
540	var cleanup func()
541	var cancelFn js.Func
542	var confirmFn js.Func
543	var keyFn js.Func
544	var backdropFn js.Func
545	cleaned := false
546	confirmed := false
547	cleanup = func() {
548		if cleaned {
549			return
550		}
551		cleaned = true
552		if d.activeConfirmID == confirmID {
553			d.activeConfirmCleanup = nil
554		}
555		if overlay.Truthy() {
556			overlay.Call("remove")
557		}
558		if d.document.Truthy() && keyFn.Value.Truthy() {
559			d.document.Call("removeEventListener", "keydown", keyFn)
560		}
561		cancelFn.Release()
562		confirmFn.Release()
563		keyFn.Release()
564		backdropFn.Release()
565	}
566	runConfirm := func() {
567		if confirmed {
568			return
569		}
570		confirmed = true
571		cleanup()
572		onConfirm()
573	}
574	cancelFn = js.FuncOf(func(js.Value, []js.Value) any {
575		cleanup()
576		return nil
577	})
578	confirmFn = js.FuncOf(func(js.Value, []js.Value) any {
579		runConfirm()
580		return nil
581	})
582	keyFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
583		event := firstJSArg(args)
584		if !event.Truthy() {
585			return nil
586		}
587		switch strings.ToLower(event.Get("key").String()) {
588		case "escape":
589			event.Call("preventDefault")
590			event.Call("stopPropagation")
591			cleanup()
592		case "enter":
593			if confirmDialogEnterShouldAutoConfirm(d.document, dialog, confirmButton) {
594				event.Call("preventDefault")
595				event.Call("stopPropagation")
596				runConfirm()
597			}
598		}
599		return nil
600	})
601	backdropFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
602		event := firstJSArg(args)
603		if event.Truthy() && event.Get("target").Equal(overlay) {
604			cleanup()
605		}
606		return nil
607	})
608
609	cancelButton.Call("addEventListener", "click", cancelFn)
610	confirmButton.Call("addEventListener", "click", confirmFn)
611	document := d.document
612	document.Call("addEventListener", "keydown", keyFn)
613	overlay.Call("addEventListener", "click", backdropFn)
614	d.activeConfirmCleanup = cleanup
615	body.Call("appendChild", overlay)
616	confirmButton.Call("focus")
617	return true
618}
619
620func confirmDialogEnterShouldAutoConfirm(document, dialog, confirmButton js.Value) bool {
621	if !document.Truthy() || !dialog.Truthy() || !confirmButton.Truthy() {
622		return false
623	}
624	active := document.Get("activeElement")
625	if !active.Truthy() {
626		return true
627	}
628	if active.Equal(confirmButton) {
629		return true
630	}
631	if dialog.Call("contains", active).Bool() {
632		return false
633	}
634	return true
635}
636
637func (d *browserDOM) handleKeyboard(event js.Value) {
638	if d == nil || !d.target.Truthy() || !event.Truthy() {
639		return
640	}
641	if d.isHistoryRoute() {
642		d.handleHistoryKeyboard(event)
643		return
644	}
645	if d.isFavoritesRoute() {
646		d.handleFavoritesKeyboard(event)
647	}
648}
649
650func (d *browserDOM) handleHistoryKeyboard(event js.Value) {
651	if d == nil || !d.target.Truthy() || !event.Truthy() {
652		return
653	}
654	key := strings.ToLower(event.Get("key").String())
655	target := event.Get("target")
656	if isEditableTarget(target) && key != "escape" {
657		return
658	}
659
660	switch key {
661	case "/":
662		if input := d.target.Call("querySelector", `[data-sv-history-search]`); input.Truthy() {
663			event.Call("preventDefault")
664			input.Call("focus")
665		}
666	case "j", "arrowdown":
667		event.Call("preventDefault")
668		d.focusHistoryRow(1)
669	case "k", "arrowup":
670		event.Call("preventDefault")
671		d.focusHistoryRow(-1)
672	case "enter":
673		if row := d.activeHistoryRow(); row.Truthy() {
674			event.Call("preventDefault")
675			if link := row.Call("querySelector", ".sv-history-open"); link.Truthy() {
676				link.Call("click")
677			}
678		}
679	case "d", "delete", "backspace":
680		// Delete the focused row through the same delegated click path as pointer users,
681		// including the data-sv-confirm guard on the delete button.
682		if row := d.activeHistoryRow(); row.Truthy() {
683			event.Call("preventDefault")
684			if button := row.Call("querySelector", `[data-sv-action="history.deleteEntry"]`); button.Truthy() {
685				button.Call("click")
686			}
687		}
688	case "escape":
689		if target.Truthy() && isEditableTarget(target) {
690			target.Call("blur")
691		}
692	}
693}
694
695func (d *browserDOM) isHistoryRoute() bool {
696	return d.target.Call("querySelector", `[data-route="history"]`).Truthy()
697}
698
699func (d *browserDOM) isFavoritesRoute() bool {
700	return d.target.Call("querySelector", `[data-route="favorites"]`).Truthy()
701}
702
703func (d *browserDOM) handleFavoritesKeyboard(event js.Value) {
704	key := strings.ToLower(event.Get("key").String())
705	target := event.Get("target")
706	if isEditableTarget(target) && key != "escape" {
707		return
708	}
709
710	switch key {
711	case "j", "arrowdown":
712		event.Call("preventDefault")
713		d.focusFavoriteRow(1)
714	case "k", "arrowup":
715		event.Call("preventDefault")
716		d.focusFavoriteRow(-1)
717	case "enter":
718		if row := d.activeFavoriteRow(); row.Truthy() {
719			event.Call("preventDefault")
720			if link := row.Call("querySelector", ".sv-favorite-open"); link.Truthy() {
721				link.Call("click")
722			}
723		}
724	case "e":
725		if row := d.activeFavoriteRow(); row.Truthy() {
726			event.Call("preventDefault")
727			if input := row.Call("querySelector", `[name="title"]`); input.Truthy() {
728				input.Call("focus")
729			}
730		}
731	case "d", "delete", "backspace":
732		if row := d.activeFavoriteRow(); row.Truthy() {
733			event.Call("preventDefault")
734			if button := row.Call("querySelector", `[data-sv-action="favorite.delete"]`); button.Truthy() {
735				button.Call("click")
736			}
737		}
738	case "escape":
739		if target.Truthy() && isEditableTarget(target) {
740			target.Call("blur")
741		}
742	}
743}
744
745func isEditableTarget(target js.Value) bool {
746	if !target.Truthy() {
747		return false
748	}
749	tag := strings.ToLower(target.Get("tagName").String())
750	role := strings.ToLower(target.Get("role").String())
751	return tag == "input" || tag == "textarea" || tag == "select" || tag == "button" ||
752		(tag == "a" && target.Get("href").String() != "") || role == "button" || role == "link" ||
753		target.Get("isContentEditable").Bool()
754}
755
756func (d *browserDOM) focusHistoryRow(delta int) {
757	d.focusRow("[data-sv-history-row]", "svActiveHistoryIndex", delta)
758}
759
760func (d *browserDOM) activeHistoryRow() js.Value {
761	return d.activeRow("[data-sv-history-row]", "svActiveHistoryIndex")
762}
763
764func (d *browserDOM) focusFavoriteRow(delta int) {
765	d.focusRow("[data-sv-favorite-row]", "svActiveFavoriteIndex", delta)
766}
767
768func (d *browserDOM) activeFavoriteRow() js.Value {
769	return d.activeRow("[data-sv-favorite-row]", "svActiveFavoriteIndex")
770}
771
772func (d *browserDOM) focusRow(selector, datasetKey string, delta int) {
773	rows := d.target.Call("querySelectorAll", selector)
774	length := rows.Get("length").Int()
775	if length == 0 {
776		return
777	}
778	idx := d.activeIndex(datasetKey)
779	if idx < 0 {
780		idx = 0
781	} else {
782		idx += delta
783	}
784	if idx < 0 {
785		idx = 0
786	}
787	if idx >= length {
788		idx = length - 1
789	}
790	for i := 0; i < length; i++ {
791		rows.Index(i).Get("classList").Call("remove", "sv-focused")
792	}
793	row := rows.Index(idx)
794	row.Get("classList").Call("add", "sv-focused")
795	d.target.Get("dataset").Set(datasetKey, strconv.Itoa(idx))
796	row.Call("scrollIntoView", map[string]any{"block": "nearest"})
797}
798
799func (d *browserDOM) activeRow(selector, datasetKey string) js.Value {
800	rows := d.target.Call("querySelectorAll", selector)
801	length := rows.Get("length").Int()
802	if length <= 0 {
803		return js.Value{}
804	}
805	idx := d.activeIndex(datasetKey)
806	if idx < 0 || idx >= length {
807		return js.Value{}
808	}
809	return rows.Index(idx)
810}
811
812func (d *browserDOM) activeIndex(datasetKey string) int {
813	value := d.target.Get("dataset").Get(datasetKey).String()
814	idx, err := strconv.Atoi(value)
815	if err != nil {
816		return -1
817	}
818	return idx
819}

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 9

Tags 42

↑↓ moveenter openesc close