main 5041353

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

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 8

Tags 42

↑↓ moveenter openesc close