detached 264138f

feat: add browser launch relay transport

commit
264138f4b951823b9e8a8b3565e34246314aa37e
author
brice · · 2026-04-14 12:17 UTC
parent
2710fd5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +347 -0
A internal/infrastructure/desktop/browser_launch_relay.go +180 -0
@@ -0,0 +1,180 @@
1+package desktop
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "net"
10+ "os"
11+ "path/filepath"
12+ "sync"
13+ "syscall"
14+
15+ "github.com/bnema/dumber/internal/application/port"
16+)
17+
18+const browserLaunchSocketName = "browser-launch.sock"
19+
20+type browserLaunchRelay struct {
21+ xdg port.XDGPaths
22+}
23+
24+type browserLaunchRequest struct {
25+ URL string `json:"url"`
26+}
27+
28+type browserLaunchResponse struct {
29+ Delivered bool `json:"delivered"`
30+ Error string `json:"error,omitempty"`
31+}
32+
33+type browserLaunchRelayListener struct {
34+ listener *net.UnixListener
35+ socketPath string
36+ once sync.Once
37+ err error
38+}
39+
40+func NewBrowserLaunchRelay(xdg port.XDGPaths) port.BrowserLaunchRelay {
41+ return &browserLaunchRelay{xdg: xdg}
42+}
43+
44+func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {
45+ socketPath, err := r.socketPath()
46+ if err != nil {
47+ return false, err
48+ }
49+
50+ if _, statErr := os.Stat(socketPath); statErr != nil {
51+ if os.IsNotExist(statErr) {
52+ return false, nil
53+ }
54+ return false, statErr
55+ }
56+
57+ conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
58+ if err != nil {
59+ if isMissingRelayListener(err) {
60+ return false, nil
61+ }
62+ return false, err
63+ }
64+ defer conn.Close()
65+
66+ if err := json.NewEncoder(conn).Encode(browserLaunchRequest{URL: url}); err != nil {
67+ return false, err
68+ }
69+
70+ var response browserLaunchResponse
71+ if err := json.NewDecoder(conn).Decode(&response); err != nil {
72+ return false, err
73+ }
74+ if response.Error != "" {
75+ return true, errors.New(response.Error)
76+ }
77+
78+ return true, nil
79+}
80+
81+func isMissingRelayListener(err error) bool {
82+ return os.IsNotExist(err) || errors.Is(err, syscall.ECONNREFUSED)
83+}
84+
85+func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
86+ socketPath, err := r.socketPath()
87+ if err != nil {
88+ return nil, err
89+ }
90+
91+ if err := os.MkdirAll(filepath.Dir(socketPath), 0o700); err != nil {
92+ return nil, fmt.Errorf("create browser launch dir: %w", err)
93+ }
94+ if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
95+ return nil, fmt.Errorf("remove stale browser launch socket: %w", err)
96+ }
97+
98+ listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
99+ if err != nil {
100+ return nil, fmt.Errorf("listen browser launch socket: %w", err)
101+ }
102+
103+ relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath}
104+ go relayListener.serve(ctx, opener)
105+ go func() {
106+ <-ctx.Done()
107+ _ = relayListener.Close()
108+ }()
109+
110+ return relayListener, nil
111+}
112+
113+func (r *browserLaunchRelay) socketPath() (string, error) {
114+ if r == nil || r.xdg == nil {
115+ return "", errors.New("browser launch relay missing XDG paths")
116+ }
117+
118+ runtimeDir, err := r.xdg.RuntimeDir()
119+ if err != nil {
120+ return "", fmt.Errorf("get runtime dir: %w", err)
121+ }
122+ if runtimeDir != "" {
123+ return filepath.Join(runtimeDir, browserLaunchSocketName), nil
124+ }
125+
126+ stateDir, err := r.xdg.StateDir()
127+ if err != nil {
128+ return "", fmt.Errorf("get state dir: %w", err)
129+ }
130+ if stateDir == "" {
131+ return "", errors.New("browser launch relay needs runtime or state dir")
132+ }
133+
134+ return filepath.Join(stateDir, "runtime", browserLaunchSocketName), nil
135+}
136+
137+func (l *browserLaunchRelayListener) Close() error {
138+ l.once.Do(func() {
139+ if l.listener != nil {
140+ l.err = l.listener.Close()
141+ }
142+ _ = os.Remove(l.socketPath)
143+ })
144+
145+ return l.err
146+}
147+
148+func (l *browserLaunchRelayListener) serve(ctx context.Context, opener port.BrowserWindowOpener) {
149+ defer l.Close()
150+
151+ for {
152+ conn, err := l.listener.AcceptUnix()
153+ if err != nil {
154+ if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
155+ return
156+ }
157+ continue
158+ }
159+
160+ l.handleConnection(ctx, conn, opener)
161+ }
162+}
163+
164+func (l *browserLaunchRelayListener) handleConnection(ctx context.Context, conn *net.UnixConn, opener port.BrowserWindowOpener) {
165+ defer conn.Close()
166+
167+ var request browserLaunchRequest
168+ if err := json.NewDecoder(conn).Decode(&request); err != nil {
169+ return
170+ }
171+
172+ response := browserLaunchResponse{Delivered: true}
173+ if err := opener.OpenFreshWindow(ctx, request.URL); err != nil {
174+ response.Error = err.Error()
175+ }
176+
177+ _ = json.NewEncoder(conn).Encode(response)
178+}
179+
180+var _ port.BrowserLaunchRelay = (*browserLaunchRelay)(nil)
@@ -0,0 +1,180 @@
1+package desktop
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "net"
10+ "os"
11+ "path/filepath"
12+ "sync"
13+ "syscall"
14+
15+ "github.com/bnema/dumber/internal/application/port"
16+)
17+
18+const browserLaunchSocketName = "browser-launch.sock"
19+
20+type browserLaunchRelay struct {
21+ xdg port.XDGPaths
22+}
23+
24+type browserLaunchRequest struct {
25+ URL string `json:"url"`
26+}
27+
28+type browserLaunchResponse struct {
29+ Delivered bool `json:"delivered"`
30+ Error string `json:"error,omitempty"`
31+}
32+
33+type browserLaunchRelayListener struct {
34+ listener *net.UnixListener
35+ socketPath string
36+ once sync.Once
37+ err error
38+}
39+
40+func NewBrowserLaunchRelay(xdg port.XDGPaths) port.BrowserLaunchRelay {
41+ return &browserLaunchRelay{xdg: xdg}
42+}
43+
44+func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {
45+ socketPath, err := r.socketPath()
46+ if err != nil {
47+ return false, err
48+ }
49+
50+ if _, statErr := os.Stat(socketPath); statErr != nil {
51+ if os.IsNotExist(statErr) {
52+ return false, nil
53+ }
54+ return false, statErr
55+ }
56+
57+ conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
58+ if err != nil {
59+ if isMissingRelayListener(err) {
60+ return false, nil
61+ }
62+ return false, err
63+ }
64+ defer conn.Close()
65+
66+ if err := json.NewEncoder(conn).Encode(browserLaunchRequest{URL: url}); err != nil {
67+ return false, err
68+ }
69+
70+ var response browserLaunchResponse
71+ if err := json.NewDecoder(conn).Decode(&response); err != nil {
72+ return false, err
73+ }
74+ if response.Error != "" {
75+ return true, errors.New(response.Error)
76+ }
77+
78+ return true, nil
79+}
80+
81+func isMissingRelayListener(err error) bool {
82+ return os.IsNotExist(err) || errors.Is(err, syscall.ECONNREFUSED)
83+}
84+
85+func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
86+ socketPath, err := r.socketPath()
87+ if err != nil {
88+ return nil, err
89+ }
90+
91+ if err := os.MkdirAll(filepath.Dir(socketPath), 0o700); err != nil {
92+ return nil, fmt.Errorf("create browser launch dir: %w", err)
93+ }
94+ if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
95+ return nil, fmt.Errorf("remove stale browser launch socket: %w", err)
96+ }
97+
98+ listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
99+ if err != nil {
100+ return nil, fmt.Errorf("listen browser launch socket: %w", err)
101+ }
102+
103+ relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath}
104+ go relayListener.serve(ctx, opener)
105+ go func() {
106+ <-ctx.Done()
107+ _ = relayListener.Close()
108+ }()
109+
110+ return relayListener, nil
111+}
112+
113+func (r *browserLaunchRelay) socketPath() (string, error) {
114+ if r == nil || r.xdg == nil {
115+ return "", errors.New("browser launch relay missing XDG paths")
116+ }
117+
118+ runtimeDir, err := r.xdg.RuntimeDir()
119+ if err != nil {
120+ return "", fmt.Errorf("get runtime dir: %w", err)
121+ }
122+ if runtimeDir != "" {
123+ return filepath.Join(runtimeDir, browserLaunchSocketName), nil
124+ }
125+
126+ stateDir, err := r.xdg.StateDir()
127+ if err != nil {
128+ return "", fmt.Errorf("get state dir: %w", err)
129+ }
130+ if stateDir == "" {
131+ return "", errors.New("browser launch relay needs runtime or state dir")
132+ }
133+
134+ return filepath.Join(stateDir, "runtime", browserLaunchSocketName), nil
135+}
136+
137+func (l *browserLaunchRelayListener) Close() error {
138+ l.once.Do(func() {
139+ if l.listener != nil {
140+ l.err = l.listener.Close()
141+ }
142+ _ = os.Remove(l.socketPath)
143+ })
144+
145+ return l.err
146+}
147+
148+func (l *browserLaunchRelayListener) serve(ctx context.Context, opener port.BrowserWindowOpener) {
149+ defer l.Close()
150+
151+ for {
152+ conn, err := l.listener.AcceptUnix()
153+ if err != nil {
154+ if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
155+ return
156+ }
157+ continue
158+ }
159+
160+ l.handleConnection(ctx, conn, opener)
161+ }
162+}
163+
164+func (l *browserLaunchRelayListener) handleConnection(ctx context.Context, conn *net.UnixConn, opener port.BrowserWindowOpener) {
165+ defer conn.Close()
166+
167+ var request browserLaunchRequest
168+ if err := json.NewDecoder(conn).Decode(&request); err != nil {
169+ return
170+ }
171+
172+ response := browserLaunchResponse{Delivered: true}
173+ if err := opener.OpenFreshWindow(ctx, request.URL); err != nil {
174+ response.Error = err.Error()
175+ }
176+
177+ _ = json.NewEncoder(conn).Encode(response)
178+}
179+
180+var _ port.BrowserLaunchRelay = (*browserLaunchRelay)(nil)
A internal/infrastructure/desktop/browser_launch_relay_test.go +167 -0
@@ -0,0 +1,167 @@
1+package desktop
2+
3+import (
4+ "context"
5+ "net"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+type testXDGPaths struct {
17+ runtimeDir string
18+ stateDir string
19+}
20+
21+var _ port.XDGPaths = testXDGPaths{}
22+
23+func (x testXDGPaths) ConfigDir() (string, error) { return "", nil }
24+func (x testXDGPaths) DataDir() (string, error) { return "", nil }
25+func (x testXDGPaths) StateDir() (string, error) { return x.stateDir, nil }
26+func (x testXDGPaths) RuntimeDir() (string, error) { return x.runtimeDir, nil }
27+func (x testXDGPaths) CacheDir() (string, error) { return "", nil }
28+func (x testXDGPaths) FilterJSONDir() (string, error) { return "", nil }
29+func (x testXDGPaths) FilterStoreDir() (string, error) { return "", nil }
30+func (x testXDGPaths) FilterCacheDir() (string, error) { return "", nil }
31+func (x testXDGPaths) ManDir() (string, error) { return "", nil }
32+func (x testXDGPaths) DownloadDir() (string, error) { return "", nil }
33+
34+type browserWindowOpenerFunc func(context.Context, string) error
35+
36+func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error {
37+ return f(ctx, url)
38+}
39+
40+func waitForSocket(t *testing.T, path string) {
41+ t.Helper()
42+
43+ require.Eventually(t, func() bool {
44+ _, err := os.Stat(path)
45+ return err == nil
46+ }, time.Second, 10*time.Millisecond)
47+}
48+
49+func shortTempDir(t *testing.T) string {
50+ t.Helper()
51+
52+ dir, err := os.MkdirTemp("", "dbr-")
53+ require.NoError(t, err)
54+ t.Cleanup(func() {
55+ _ = os.RemoveAll(dir)
56+ })
57+
58+ return dir
59+}
60+
61+func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNil(t *testing.T) {
62+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: shortTempDir(t)})
63+
64+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com")
65+
66+ require.NoError(t, err)
67+ assert.False(t, delivered)
68+}
69+
70+func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {
71+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
72+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
73+
74+ received := make(chan string, 1)
75+ ctx, cancel := context.WithCancel(context.Background())
76+ defer cancel()
77+
78+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(ctx context.Context, url string) error {
79+ received <- url
80+ return nil
81+ }))
82+ require.NoError(t, err)
83+ defer closer.Close()
84+
85+ waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock"))
86+
87+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new")
88+
89+ require.NoError(t, err)
90+ assert.True(t, delivered)
91+
92+ select {
93+ case got := <-received:
94+ assert.Equal(t, "https://example.com/new", got)
95+ case <-time.After(time.Second):
96+ t.Fatal("expected opener to receive the URL")
97+ }
98+}
99+
100+func TestBrowserLaunchRelay_UsesStateFallbackWhenRuntimeDirMissing(t *testing.T) {
101+ stateDir := shortTempDir(t)
102+ relay := NewBrowserLaunchRelay(testXDGPaths{stateDir: stateDir})
103+
104+ received := make(chan string, 1)
105+ ctx, cancel := context.WithCancel(context.Background())
106+ defer cancel()
107+
108+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(ctx context.Context, url string) error {
109+ received <- url
110+ return nil
111+ }))
112+ require.NoError(t, err)
113+ defer closer.Close()
114+
115+ socketPath := filepath.Join(stateDir, "runtime", "browser-launch.sock")
116+ waitForSocket(t, socketPath)
117+
118+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/fallback")
119+
120+ require.NoError(t, err)
121+ assert.True(t, delivered)
122+
123+ select {
124+ case got := <-received:
125+ assert.Equal(t, "https://example.com/fallback", got)
126+ case <-time.After(time.Second):
127+ t.Fatal("expected opener to receive the URL")
128+ }
129+}
130+
131+func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
132+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
133+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
134+
135+ received := make(chan string, 1)
136+ ctx, cancel := context.WithCancel(context.Background())
137+ defer cancel()
138+
139+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(ctx context.Context, url string) error {
140+ received <- url
141+ return nil
142+ }))
143+ require.NoError(t, err)
144+ defer closer.Close()
145+
146+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
147+ waitForSocket(t, socketPath)
148+
149+ conn, err := net.Dial("unix", socketPath)
150+ require.NoError(t, err)
151+
152+ _, err = conn.Write([]byte("not-json"))
153+ require.NoError(t, err)
154+ require.NoError(t, conn.Close())
155+
156+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/still-works")
157+
158+ require.NoError(t, err)
159+ assert.True(t, delivered)
160+
161+ select {
162+ case got := <-received:
163+ assert.Equal(t, "https://example.com/still-works", got)
164+ case <-time.After(time.Second):
165+ t.Fatal("expected opener to receive the URL after malformed payload")
166+ }
167+}
@@ -0,0 +1,167 @@
1+package desktop
2+
3+import (
4+ "context"
5+ "net"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+type testXDGPaths struct {
17+ runtimeDir string
18+ stateDir string
19+}
20+
21+var _ port.XDGPaths = testXDGPaths{}
22+
23+func (x testXDGPaths) ConfigDir() (string, error) { return "", nil }
24+func (x testXDGPaths) DataDir() (string, error) { return "", nil }
25+func (x testXDGPaths) StateDir() (string, error) { return x.stateDir, nil }
26+func (x testXDGPaths) RuntimeDir() (string, error) { return x.runtimeDir, nil }
27+func (x testXDGPaths) CacheDir() (string, error) { return "", nil }
28+func (x testXDGPaths) FilterJSONDir() (string, error) { return "", nil }
29+func (x testXDGPaths) FilterStoreDir() (string, error) { return "", nil }
30+func (x testXDGPaths) FilterCacheDir() (string, error) { return "", nil }
31+func (x testXDGPaths) ManDir() (string, error) { return "", nil }
32+func (x testXDGPaths) DownloadDir() (string, error) { return "", nil }
33+
34+type browserWindowOpenerFunc func(context.Context, string) error
35+
36+func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error {
37+ return f(ctx, url)
38+}
39+
40+func waitForSocket(t *testing.T, path string) {
41+ t.Helper()
42+
43+ require.Eventually(t, func() bool {
44+ _, err := os.Stat(path)
45+ return err == nil
46+ }, time.Second, 10*time.Millisecond)
47+}
48+
49+func shortTempDir(t *testing.T) string {
50+ t.Helper()
51+
52+ dir, err := os.MkdirTemp("", "dbr-")
53+ require.NoError(t, err)
54+ t.Cleanup(func() {
55+ _ = os.RemoveAll(dir)
56+ })
57+
58+ return dir
59+}
60+
61+func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNil(t *testing.T) {
62+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: shortTempDir(t)})
63+
64+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com")
65+
66+ require.NoError(t, err)
67+ assert.False(t, delivered)
68+}
69+
70+func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {
71+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
72+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
73+
74+ received := make(chan string, 1)
75+ ctx, cancel := context.WithCancel(context.Background())
76+ defer cancel()
77+
78+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(ctx context.Context, url string) error {
79+ received <- url
80+ return nil
81+ }))
82+ require.NoError(t, err)
83+ defer closer.Close()
84+
85+ waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock"))
86+
87+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new")
88+
89+ require.NoError(t, err)
90+ assert.True(t, delivered)
91+
92+ select {
93+ case got := <-received:
94+ assert.Equal(t, "https://example.com/new", got)
95+ case <-time.After(time.Second):
96+ t.Fatal("expected opener to receive the URL")
97+ }
98+}
99+
100+func TestBrowserLaunchRelay_UsesStateFallbackWhenRuntimeDirMissing(t *testing.T) {
101+ stateDir := shortTempDir(t)
102+ relay := NewBrowserLaunchRelay(testXDGPaths{stateDir: stateDir})
103+
104+ received := make(chan string, 1)
105+ ctx, cancel := context.WithCancel(context.Background())
106+ defer cancel()
107+
108+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(ctx context.Context, url string) error {
109+ received <- url
110+ return nil
111+ }))
112+ require.NoError(t, err)
113+ defer closer.Close()
114+
115+ socketPath := filepath.Join(stateDir, "runtime", "browser-launch.sock")
116+ waitForSocket(t, socketPath)
117+
118+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/fallback")
119+
120+ require.NoError(t, err)
121+ assert.True(t, delivered)
122+
123+ select {
124+ case got := <-received:
125+ assert.Equal(t, "https://example.com/fallback", got)
126+ case <-time.After(time.Second):
127+ t.Fatal("expected opener to receive the URL")
128+ }
129+}
130+
131+func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
132+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
133+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
134+
135+ received := make(chan string, 1)
136+ ctx, cancel := context.WithCancel(context.Background())
137+ defer cancel()
138+
139+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(ctx context.Context, url string) error {
140+ received <- url
141+ return nil
142+ }))
143+ require.NoError(t, err)
144+ defer closer.Close()
145+
146+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
147+ waitForSocket(t, socketPath)
148+
149+ conn, err := net.Dial("unix", socketPath)
150+ require.NoError(t, err)
151+
152+ _, err = conn.Write([]byte("not-json"))
153+ require.NoError(t, err)
154+ require.NoError(t, conn.Close())
155+
156+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/still-works")
157+
158+ require.NoError(t, err)
159+ assert.True(t, delivered)
160+
161+ select {
162+ case got := <-received:
163+ assert.Equal(t, "https://example.com/still-works", got)
164+ case <-time.After(time.Second):
165+ t.Fatal("expected opener to receive the URL after malformed payload")
166+ }
167+}

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