settings.go RawHistoryFolder

1package webterm
2
3import (
4	"errors"
5	"net"
6	"net/netip"
7	"net/url"
8	"strconv"
9	"strings"
10)
11
12// Settings separates the local HTTP listener from the browser-facing origin.
13// HTTPS, when selected, is terminated by a proxy preserving Host and Origin.
14type Settings struct {
15	Listen string `json:"listen"`
16	Origin string `json:"origin"`
17}
18
19func ParseSettings(listen, origin string) (Settings, error) {
20	if listen == "" {
21		listen = Address
22	}
23	addr, err := netip.ParseAddrPort(listen)
24	if err != nil || addr.Port() == 0 || addr.Addr().Zone() != "" || addr.Addr().IsMulticast() {
25		return Settings{}, errors.New("webterm: web.listen requires an IP address and port (1–65535)")
26	}
27	listen = addr.String()
28	if origin == "" {
29		if !addr.Addr().IsLoopback() {
30			return Settings{}, errors.New("webterm: web.origin is required for a non-loopback listener")
31		}
32		origin = "http://" + listen
33	}
34	u, err := url.Parse(origin)
35	if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.Opaque != "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(origin, "#") {
36		return Settings{}, errors.New("webterm: web.origin requires an HTTP(S) origin without credentials, path, query or fragment")
37	}
38	host := strings.ToLower(u.Hostname())
39	if ip, err := netip.ParseAddr(host); err == nil {
40		if ip.IsUnspecified() || ip.IsMulticast() || ip.Zone() != "" {
41			return Settings{}, errors.New("webterm: web.origin requires a concrete host")
42		}
43		host = ip.String()
44	} else {
45		if len(host) > 253 {
46			return Settings{}, errors.New("webterm: invalid web.origin hostname")
47		}
48		for _, label := range strings.Split(host, ".") {
49			if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
50				return Settings{}, errors.New("webterm: invalid web.origin hostname")
51			}
52			for _, c := range label {
53				if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
54					return Settings{}, errors.New("webterm: invalid web.origin hostname")
55				}
56			}
57		}
58	}
59	port := u.Port()
60	if port != "" {
61		n, err := strconv.Atoi(port)
62		if err != nil || n < 1 || n > 65535 {
63			return Settings{}, errors.New("webterm: invalid web.origin port")
64		}
65		port = strconv.Itoa(n)
66		if u.Scheme == "http" && port == "80" || u.Scheme == "https" && port == "443" {
67			port = ""
68		}
69	} else if strings.HasSuffix(u.Host, ":") {
70		return Settings{}, errors.New("webterm: invalid web.origin port")
71	}
72	if port != "" {
73		host = net.JoinHostPort(host, port)
74	} else if strings.Contains(host, ":") {
75		host = "[" + host + "]"
76	}
77	return Settings{Listen: listen, Origin: u.Scheme + "://" + host}, nil
78}
79
80// ProbeAddress never resolves or contacts the public origin. Wildcard listeners
81// are reached through the matching loopback family on this machine.
82// A zero-value Settings yields an empty address instead of panicking.
83func (s Settings) ProbeAddress() string {
84	addr, err := netip.ParseAddrPort(s.Listen)
85	if err != nil {
86		return ""
87	}
88	if addr.Addr().IsUnspecified() {
89		ip := netip.IPv6Loopback()
90		if addr.Addr().Is4() {
91			ip = netip.MustParseAddr("127.0.0.1")
92		}
93		addr = netip.AddrPortFrom(ip, addr.Port())
94	}
95	return addr.String()
96}
97
98func (s Settings) Host() string {
99	_, host, _ := strings.Cut(s.Origin, "://")
100	return host
101}

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