detached 13fbcf4

feat(permission): add domain entities and repository interfaces

Add PermissionType, PermissionDecision, and PermissionRecord entities
with W3C-compliant persistence rules (CanPersist, IsAutoAllow).
Add PermissionRepository interface for storage abstraction.
commit
13fbcf4c782a87355b9efd58fcc8faf3e0874589
author
brice · · 2026-01-30 05:01 UTC
committer
brice · · 2026-02-07 11:53 UTC
parent
64ad4fc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +308 -0
A internal/domain/entity/permission.go +118 -0
@@ -0,0 +1,118 @@
1+package entity
2+
3+import (
4+ "time"
5+)
6+
7+// PermissionType represents the type of permission being requested.
8+type PermissionType string
9+
10+const (
11+ // PermissionTypeMicrophone represents microphone access permission.
12+ PermissionTypeMicrophone PermissionType = "microphone"
13+
14+ // PermissionTypeCamera represents camera access permission.
15+ PermissionTypeCamera PermissionType = "camera"
16+
17+ // PermissionTypeMicrophoneAndCamera represents combined mic and camera permission.
18+ PermissionTypeMicrophoneAndCamera PermissionType = "microphone_and_camera"
19+
20+ // PermissionTypeDisplay represents screen sharing/display capture permission.
21+ PermissionTypeDisplay PermissionType = "display"
22+
23+ // PermissionTypeDeviceInfo represents device enumeration permission.
24+ PermissionTypeDeviceInfo PermissionType = "device_info"
25+
26+ // PermissionTypeClipboard represents clipboard access permission.
27+ PermissionTypeClipboard PermissionType = "clipboard"
28+
29+ // PermissionTypeNotification represents notification permission.
30+ PermissionTypeNotification PermissionType = "notification"
31+
32+ // PermissionTypeGeolocation represents geolocation permission.
33+ PermissionTypeGeolocation PermissionType = "geolocation"
34+
35+ // PermissionTypePointerLock represents pointer lock permission.
36+ PermissionTypePointerLock PermissionType = "pointer_lock"
37+
38+ // PermissionTypeMediaKeySystem represents DRM/media key system permission.
39+ PermissionTypeMediaKeySystem PermissionType = "media_key_system"
40+
41+ // PermissionTypeWebsiteDataAccess represents 3rd party cookie/data access permission.
42+ PermissionTypeWebsiteDataAccess PermissionType = "website_data_access"
43+)
44+
45+// PermissionDecision represents the user's decision for a permission.
46+type PermissionDecision string
47+
48+const (
49+ // PermissionGranted means the permission was allowed.
50+ PermissionGranted PermissionDecision = "granted"
51+
52+ // PermissionDenied means the permission was denied.
53+ PermissionDenied PermissionDecision = "denied"
54+
55+ // PermissionPrompt means no decision has been made yet (default state).
56+ PermissionPrompt PermissionDecision = "prompt"
57+)
58+
59+// PermissionRecord stores a permission decision for a specific origin and type.
60+type PermissionRecord struct {
61+ Origin string // The origin (domain) this permission applies to
62+ Type PermissionType // The type of permission
63+ Decision PermissionDecision // The decision: granted, denied, or prompt
64+ UpdatedAt time.Time // When this record was last updated
65+}
66+
67+// IsGranted returns true if the permission is granted.
68+func (p *PermissionRecord) IsGranted() bool {
69+ return p.Decision == PermissionGranted
70+}
71+
72+// IsDenied returns true if the permission is denied.
73+func (p *PermissionRecord) IsDenied() bool {
74+ return p.Decision == PermissionDenied
75+}
76+
77+// PermissionSet represents multiple permission decisions for a single request.
78+// This is used when handling combined permission requests (e.g., mic + camera).
79+type PermissionSet struct {
80+ Types []PermissionType
81+ Decision PermissionDecision
82+}
83+
84+// CanPersist returns true if this permission type can be persisted.
85+// Per W3C spec, display capture permissions cannot be persisted.
86+func CanPersist(permType PermissionType) bool {
87+ switch permType {
88+ case PermissionTypeDisplay:
89+ return false // W3C forbids persisting display capture permissions
90+ case PermissionTypeDeviceInfo, PermissionTypePointerLock:
91+ return false // Auto-allowed, no need to persist
92+ default:
93+ return true
94+ }
95+}
96+
97+// IsAutoAllow returns true if this permission type should be auto-allowed.
98+func IsAutoAllow(permType PermissionType) bool {
99+ switch permType {
100+ case PermissionTypeDisplay:
101+ return true // Portal handles the UI
102+ case PermissionTypeDeviceInfo:
103+ return true // Low risk, just lists devices
104+ case PermissionTypePointerLock:
105+ return true // Low risk, default WebKit behavior
106+ default:
107+ return false
108+ }
109+}
110+
111+// PermissionTypesToStrings converts permission types to strings for logging.
112+func PermissionTypesToStrings(types []PermissionType) []string {
113+ result := make([]string, len(types))
114+ for i, t := range types {
115+ result[i] = string(t)
116+ }
117+ return result
118+}
@@ -0,0 +1,118 @@
1+package entity
2+
3+import (
4+ "time"
5+)
6+
7+// PermissionType represents the type of permission being requested.
8+type PermissionType string
9+
10+const (
11+ // PermissionTypeMicrophone represents microphone access permission.
12+ PermissionTypeMicrophone PermissionType = "microphone"
13+
14+ // PermissionTypeCamera represents camera access permission.
15+ PermissionTypeCamera PermissionType = "camera"
16+
17+ // PermissionTypeMicrophoneAndCamera represents combined mic and camera permission.
18+ PermissionTypeMicrophoneAndCamera PermissionType = "microphone_and_camera"
19+
20+ // PermissionTypeDisplay represents screen sharing/display capture permission.
21+ PermissionTypeDisplay PermissionType = "display"
22+
23+ // PermissionTypeDeviceInfo represents device enumeration permission.
24+ PermissionTypeDeviceInfo PermissionType = "device_info"
25+
26+ // PermissionTypeClipboard represents clipboard access permission.
27+ PermissionTypeClipboard PermissionType = "clipboard"
28+
29+ // PermissionTypeNotification represents notification permission.
30+ PermissionTypeNotification PermissionType = "notification"
31+
32+ // PermissionTypeGeolocation represents geolocation permission.
33+ PermissionTypeGeolocation PermissionType = "geolocation"
34+
35+ // PermissionTypePointerLock represents pointer lock permission.
36+ PermissionTypePointerLock PermissionType = "pointer_lock"
37+
38+ // PermissionTypeMediaKeySystem represents DRM/media key system permission.
39+ PermissionTypeMediaKeySystem PermissionType = "media_key_system"
40+
41+ // PermissionTypeWebsiteDataAccess represents 3rd party cookie/data access permission.
42+ PermissionTypeWebsiteDataAccess PermissionType = "website_data_access"
43+)
44+
45+// PermissionDecision represents the user's decision for a permission.
46+type PermissionDecision string
47+
48+const (
49+ // PermissionGranted means the permission was allowed.
50+ PermissionGranted PermissionDecision = "granted"
51+
52+ // PermissionDenied means the permission was denied.
53+ PermissionDenied PermissionDecision = "denied"
54+
55+ // PermissionPrompt means no decision has been made yet (default state).
56+ PermissionPrompt PermissionDecision = "prompt"
57+)
58+
59+// PermissionRecord stores a permission decision for a specific origin and type.
60+type PermissionRecord struct {
61+ Origin string // The origin (domain) this permission applies to
62+ Type PermissionType // The type of permission
63+ Decision PermissionDecision // The decision: granted, denied, or prompt
64+ UpdatedAt time.Time // When this record was last updated
65+}
66+
67+// IsGranted returns true if the permission is granted.
68+func (p *PermissionRecord) IsGranted() bool {
69+ return p.Decision == PermissionGranted
70+}
71+
72+// IsDenied returns true if the permission is denied.
73+func (p *PermissionRecord) IsDenied() bool {
74+ return p.Decision == PermissionDenied
75+}
76+
77+// PermissionSet represents multiple permission decisions for a single request.
78+// This is used when handling combined permission requests (e.g., mic + camera).
79+type PermissionSet struct {
80+ Types []PermissionType
81+ Decision PermissionDecision
82+}
83+
84+// CanPersist returns true if this permission type can be persisted.
85+// Per W3C spec, display capture permissions cannot be persisted.
86+func CanPersist(permType PermissionType) bool {
87+ switch permType {
88+ case PermissionTypeDisplay:
89+ return false // W3C forbids persisting display capture permissions
90+ case PermissionTypeDeviceInfo, PermissionTypePointerLock:
91+ return false // Auto-allowed, no need to persist
92+ default:
93+ return true
94+ }
95+}
96+
97+// IsAutoAllow returns true if this permission type should be auto-allowed.
98+func IsAutoAllow(permType PermissionType) bool {
99+ switch permType {
100+ case PermissionTypeDisplay:
101+ return true // Portal handles the UI
102+ case PermissionTypeDeviceInfo:
103+ return true // Low risk, just lists devices
104+ case PermissionTypePointerLock:
105+ return true // Low risk, default WebKit behavior
106+ default:
107+ return false
108+ }
109+}
110+
111+// PermissionTypesToStrings converts permission types to strings for logging.
112+func PermissionTypesToStrings(types []PermissionType) []string {
113+ result := make([]string, len(types))
114+ for i, t := range types {
115+ result[i] = string(t)
116+ }
117+ return result
118+}
A internal/domain/entity/permission_test.go +166 -0
@@ -0,0 +1,166 @@
1+package entity_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestPermissionType_Constants(t *testing.T) {
12+ tests := []struct {
13+ permType entity.PermissionType
14+ expected string
15+ }{
16+ {entity.PermissionTypeMicrophone, "microphone"},
17+ {entity.PermissionTypeCamera, "camera"},
18+ {entity.PermissionTypeMicrophoneAndCamera, "microphone_and_camera"},
19+ {entity.PermissionTypeDisplay, "display"},
20+ {entity.PermissionTypeDeviceInfo, "device_info"},
21+ {entity.PermissionTypeClipboard, "clipboard"},
22+ {entity.PermissionTypeNotification, "notification"},
23+ {entity.PermissionTypeGeolocation, "geolocation"},
24+ {entity.PermissionTypePointerLock, "pointer_lock"},
25+ {entity.PermissionTypeMediaKeySystem, "media_key_system"},
26+ {entity.PermissionTypeWebsiteDataAccess, "website_data_access"},
27+ }
28+
29+ for _, tt := range tests {
30+ t.Run(string(tt.permType), func(t *testing.T) {
31+ assert.Equal(t, tt.expected, string(tt.permType))
32+ })
33+ }
34+}
35+
36+func TestPermissionDecision_Constants(t *testing.T) {
37+ tests := []struct {
38+ decision entity.PermissionDecision
39+ expected string
40+ }{
41+ {entity.PermissionGranted, "granted"},
42+ {entity.PermissionDenied, "denied"},
43+ {entity.PermissionPrompt, "prompt"},
44+ }
45+
46+ for _, tt := range tests {
47+ t.Run(string(tt.decision), func(t *testing.T) {
48+ assert.Equal(t, tt.expected, string(tt.decision))
49+ })
50+ }
51+}
52+
53+func TestPermissionRecord_IsGranted(t *testing.T) {
54+ tests := []struct {
55+ name string
56+ decision entity.PermissionDecision
57+ expected bool
58+ }{
59+ {"granted", entity.PermissionGranted, true},
60+ {"denied", entity.PermissionDenied, false},
61+ {"prompt", entity.PermissionPrompt, false},
62+ }
63+
64+ for _, tt := range tests {
65+ t.Run(tt.name, func(t *testing.T) {
66+ record := &entity.PermissionRecord{
67+ Origin: "https://example.com",
68+ Type: entity.PermissionTypeMicrophone,
69+ Decision: tt.decision,
70+ UpdatedAt: time.Now(),
71+ }
72+ assert.Equal(t, tt.expected, record.IsGranted())
73+ })
74+ }
75+}
76+
77+func TestPermissionRecord_IsDenied(t *testing.T) {
78+ tests := []struct {
79+ name string
80+ decision entity.PermissionDecision
81+ expected bool
82+ }{
83+ {"granted", entity.PermissionGranted, false},
84+ {"denied", entity.PermissionDenied, true},
85+ {"prompt", entity.PermissionPrompt, false},
86+ }
87+
88+ for _, tt := range tests {
89+ t.Run(tt.name, func(t *testing.T) {
90+ record := &entity.PermissionRecord{
91+ Origin: "https://example.com",
92+ Type: entity.PermissionTypeCamera,
93+ Decision: tt.decision,
94+ UpdatedAt: time.Now(),
95+ }
96+ assert.Equal(t, tt.expected, record.IsDenied())
97+ })
98+ }
99+}
100+
101+func TestCanPersist(t *testing.T) {
102+ tests := []struct {
103+ name string
104+ permType entity.PermissionType
105+ expected bool
106+ }{
107+ // Non-persistable per W3C spec
108+ {"display capture", entity.PermissionTypeDisplay, false},
109+ {"device info", entity.PermissionTypeDeviceInfo, false},
110+ {"pointer lock", entity.PermissionTypePointerLock, false},
111+
112+ // Persistable
113+ {"microphone", entity.PermissionTypeMicrophone, true},
114+ {"camera", entity.PermissionTypeCamera, true},
115+ {"microphone and camera", entity.PermissionTypeMicrophoneAndCamera, true},
116+ {"clipboard", entity.PermissionTypeClipboard, true},
117+ {"notification", entity.PermissionTypeNotification, true},
118+ {"geolocation", entity.PermissionTypeGeolocation, true},
119+ {"media key system", entity.PermissionTypeMediaKeySystem, true},
120+ {"website data access", entity.PermissionTypeWebsiteDataAccess, true},
121+ }
122+
123+ for _, tt := range tests {
124+ t.Run(tt.name, func(t *testing.T) {
125+ assert.Equal(t, tt.expected, entity.CanPersist(tt.permType))
126+ })
127+ }
128+}
129+
130+func TestIsAutoAllow(t *testing.T) {
131+ tests := []struct {
132+ name string
133+ permType entity.PermissionType
134+ expected bool
135+ }{
136+ // Auto-allowed (portal handles UI or low risk)
137+ {"display capture", entity.PermissionTypeDisplay, true},
138+ {"device info", entity.PermissionTypeDeviceInfo, true},
139+ {"pointer lock", entity.PermissionTypePointerLock, true},
140+
141+ // Not auto-allowed (require user consent)
142+ {"microphone", entity.PermissionTypeMicrophone, false},
143+ {"camera", entity.PermissionTypeCamera, false},
144+ {"clipboard", entity.PermissionTypeClipboard, false},
145+ {"notification", entity.PermissionTypeNotification, false},
146+ {"geolocation", entity.PermissionTypeGeolocation, false},
147+ }
148+
149+ for _, tt := range tests {
150+ t.Run(tt.name, func(t *testing.T) {
151+ assert.Equal(t, tt.expected, entity.IsAutoAllow(tt.permType))
152+ })
153+ }
154+}
155+
156+func TestPermissionSet(t *testing.T) {
157+ set := entity.PermissionSet{
158+ Types: []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeCamera},
159+ Decision: entity.PermissionGranted,
160+ }
161+
162+ assert.Len(t, set.Types, 2)
163+ assert.Contains(t, set.Types, entity.PermissionTypeMicrophone)
164+ assert.Contains(t, set.Types, entity.PermissionTypeCamera)
165+ assert.Equal(t, entity.PermissionGranted, set.Decision)
166+}
@@ -0,0 +1,166 @@
1+package entity_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestPermissionType_Constants(t *testing.T) {
12+ tests := []struct {
13+ permType entity.PermissionType
14+ expected string
15+ }{
16+ {entity.PermissionTypeMicrophone, "microphone"},
17+ {entity.PermissionTypeCamera, "camera"},
18+ {entity.PermissionTypeMicrophoneAndCamera, "microphone_and_camera"},
19+ {entity.PermissionTypeDisplay, "display"},
20+ {entity.PermissionTypeDeviceInfo, "device_info"},
21+ {entity.PermissionTypeClipboard, "clipboard"},
22+ {entity.PermissionTypeNotification, "notification"},
23+ {entity.PermissionTypeGeolocation, "geolocation"},
24+ {entity.PermissionTypePointerLock, "pointer_lock"},
25+ {entity.PermissionTypeMediaKeySystem, "media_key_system"},
26+ {entity.PermissionTypeWebsiteDataAccess, "website_data_access"},
27+ }
28+
29+ for _, tt := range tests {
30+ t.Run(string(tt.permType), func(t *testing.T) {
31+ assert.Equal(t, tt.expected, string(tt.permType))
32+ })
33+ }
34+}
35+
36+func TestPermissionDecision_Constants(t *testing.T) {
37+ tests := []struct {
38+ decision entity.PermissionDecision
39+ expected string
40+ }{
41+ {entity.PermissionGranted, "granted"},
42+ {entity.PermissionDenied, "denied"},
43+ {entity.PermissionPrompt, "prompt"},
44+ }
45+
46+ for _, tt := range tests {
47+ t.Run(string(tt.decision), func(t *testing.T) {
48+ assert.Equal(t, tt.expected, string(tt.decision))
49+ })
50+ }
51+}
52+
53+func TestPermissionRecord_IsGranted(t *testing.T) {
54+ tests := []struct {
55+ name string
56+ decision entity.PermissionDecision
57+ expected bool
58+ }{
59+ {"granted", entity.PermissionGranted, true},
60+ {"denied", entity.PermissionDenied, false},
61+ {"prompt", entity.PermissionPrompt, false},
62+ }
63+
64+ for _, tt := range tests {
65+ t.Run(tt.name, func(t *testing.T) {
66+ record := &entity.PermissionRecord{
67+ Origin: "https://example.com",
68+ Type: entity.PermissionTypeMicrophone,
69+ Decision: tt.decision,
70+ UpdatedAt: time.Now(),
71+ }
72+ assert.Equal(t, tt.expected, record.IsGranted())
73+ })
74+ }
75+}
76+
77+func TestPermissionRecord_IsDenied(t *testing.T) {
78+ tests := []struct {
79+ name string
80+ decision entity.PermissionDecision
81+ expected bool
82+ }{
83+ {"granted", entity.PermissionGranted, false},
84+ {"denied", entity.PermissionDenied, true},
85+ {"prompt", entity.PermissionPrompt, false},
86+ }
87+
88+ for _, tt := range tests {
89+ t.Run(tt.name, func(t *testing.T) {
90+ record := &entity.PermissionRecord{
91+ Origin: "https://example.com",
92+ Type: entity.PermissionTypeCamera,
93+ Decision: tt.decision,
94+ UpdatedAt: time.Now(),
95+ }
96+ assert.Equal(t, tt.expected, record.IsDenied())
97+ })
98+ }
99+}
100+
101+func TestCanPersist(t *testing.T) {
102+ tests := []struct {
103+ name string
104+ permType entity.PermissionType
105+ expected bool
106+ }{
107+ // Non-persistable per W3C spec
108+ {"display capture", entity.PermissionTypeDisplay, false},
109+ {"device info", entity.PermissionTypeDeviceInfo, false},
110+ {"pointer lock", entity.PermissionTypePointerLock, false},
111+
112+ // Persistable
113+ {"microphone", entity.PermissionTypeMicrophone, true},
114+ {"camera", entity.PermissionTypeCamera, true},
115+ {"microphone and camera", entity.PermissionTypeMicrophoneAndCamera, true},
116+ {"clipboard", entity.PermissionTypeClipboard, true},
117+ {"notification", entity.PermissionTypeNotification, true},
118+ {"geolocation", entity.PermissionTypeGeolocation, true},
119+ {"media key system", entity.PermissionTypeMediaKeySystem, true},
120+ {"website data access", entity.PermissionTypeWebsiteDataAccess, true},
121+ }
122+
123+ for _, tt := range tests {
124+ t.Run(tt.name, func(t *testing.T) {
125+ assert.Equal(t, tt.expected, entity.CanPersist(tt.permType))
126+ })
127+ }
128+}
129+
130+func TestIsAutoAllow(t *testing.T) {
131+ tests := []struct {
132+ name string
133+ permType entity.PermissionType
134+ expected bool
135+ }{
136+ // Auto-allowed (portal handles UI or low risk)
137+ {"display capture", entity.PermissionTypeDisplay, true},
138+ {"device info", entity.PermissionTypeDeviceInfo, true},
139+ {"pointer lock", entity.PermissionTypePointerLock, true},
140+
141+ // Not auto-allowed (require user consent)
142+ {"microphone", entity.PermissionTypeMicrophone, false},
143+ {"camera", entity.PermissionTypeCamera, false},
144+ {"clipboard", entity.PermissionTypeClipboard, false},
145+ {"notification", entity.PermissionTypeNotification, false},
146+ {"geolocation", entity.PermissionTypeGeolocation, false},
147+ }
148+
149+ for _, tt := range tests {
150+ t.Run(tt.name, func(t *testing.T) {
151+ assert.Equal(t, tt.expected, entity.IsAutoAllow(tt.permType))
152+ })
153+ }
154+}
155+
156+func TestPermissionSet(t *testing.T) {
157+ set := entity.PermissionSet{
158+ Types: []entity.PermissionType{entity.PermissionTypeMicrophone, entity.PermissionTypeCamera},
159+ Decision: entity.PermissionGranted,
160+ }
161+
162+ assert.Len(t, set.Types, 2)
163+ assert.Contains(t, set.Types, entity.PermissionTypeMicrophone)
164+ assert.Contains(t, set.Types, entity.PermissionTypeCamera)
165+ assert.Equal(t, entity.PermissionGranted, set.Decision)
166+}
A internal/domain/repository/permission.go +24 -0
@@ -0,0 +1,24 @@
1+package repository
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// PermissionRepository defines operations for permission persistence.
10+// Only microphone and camera permissions are persisted per W3C spec.
11+type PermissionRepository interface {
12+ // Get retrieves the permission record for a specific origin and permission type.
13+ // Returns nil if no record exists (treat as "prompt" state).
14+ Get(ctx context.Context, origin string, permType entity.PermissionType) (*entity.PermissionRecord, error)
15+
16+ // Set saves or updates a permission record.
17+ Set(ctx context.Context, record *entity.PermissionRecord) error
18+
19+ // Delete removes a permission record for a specific origin and type.
20+ Delete(ctx context.Context, origin string, permType entity.PermissionType) error
21+
22+ // GetAll retrieves all permission records for an origin.
23+ GetAll(ctx context.Context, origin string) ([]*entity.PermissionRecord, error)
24+}
@@ -0,0 +1,24 @@
1+package repository
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// PermissionRepository defines operations for permission persistence.
10+// Only microphone and camera permissions are persisted per W3C spec.
11+type PermissionRepository interface {
12+ // Get retrieves the permission record for a specific origin and permission type.
13+ // Returns nil if no record exists (treat as "prompt" state).
14+ Get(ctx context.Context, origin string, permType entity.PermissionType) (*entity.PermissionRecord, error)
15+
16+ // Set saves or updates a permission record.
17+ Set(ctx context.Context, record *entity.PermissionRecord) error
18+
19+ // Delete removes a permission record for a specific origin and type.
20+ Delete(ctx context.Context, origin string, permType entity.PermissionType) error
21+
22+ // GetAll retrieves all permission records for an origin.
23+ GetAll(ctx context.Context, origin string) ([]*entity.PermissionRecord, error)
24+}

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