detached 75ca6b4

fix(config): clone runtime config snapshots

commit
75ca6b4b9ac7f0160a95a0386a02397e63280e50
author
brice · · 2026-06-23 14:11 UTC
committer
brice · · 2026-06-23 20:01 UTC
parent
608a18d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +173 -0
M internal/bootstrap/runtime_config.go +50 -0
@@ -4,6 +4,7 @@ import (
44 "sync"
55
66 "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/domain/entity"
78 "github.com/bnema/dumber/internal/infrastructure/config"
89 )
910
@@ -115,6 +116,8 @@ func runtimeSearchShortcutsFromConfig(in map[string]config.SearchShortcut) map[s
115116
116117 func cloneRuntimeConfigSnapshot(snapshot port.RuntimeConfigSnapshot) port.RuntimeConfigSnapshot {
117118 snapshot.UI.SearchShortcuts = cloneRuntimeSearchShortcuts(snapshot.UI.SearchShortcuts)
119+ snapshot.UI.Workspace = cloneWorkspaceConfig(snapshot.UI.Workspace)
120+ snapshot.UI.Session = cloneSessionConfig(snapshot.UI.Session)
118121 return snapshot
119122 }
120123
@@ -129,6 +132,53 @@ func cloneRuntimeSearchShortcuts(in map[string]port.RuntimeSearchShortcut) map[s
129132 return out
130133 }
131134
135+func cloneWorkspaceConfig(in entity.WorkspaceConfig) entity.WorkspaceConfig {
136+ in.PaneMode.Actions = cloneActionBindings(in.PaneMode.Actions)
137+ in.TabMode.Actions = cloneActionBindings(in.TabMode.Actions)
138+ in.ResizeMode.Actions = cloneActionBindings(in.ResizeMode.Actions)
139+ in.Shortcuts.Actions = cloneActionBindings(in.Shortcuts.Actions)
140+ in.FloatingPane.Profiles = cloneFloatingPaneProfiles(in.FloatingPane.Profiles)
141+ return in
142+}
143+
144+func cloneSessionConfig(in entity.SessionConfig) entity.SessionConfig {
145+ in.SessionMode.Actions = cloneActionBindings(in.SessionMode.Actions)
146+ return in
147+}
148+
149+func cloneActionBindings(in map[string]entity.ActionBinding) map[string]entity.ActionBinding {
150+ if in == nil {
151+ return nil
152+ }
153+ out := make(map[string]entity.ActionBinding, len(in))
154+ for action, binding := range in {
155+ binding.Keys = cloneStringSlice(binding.Keys)
156+ out[action] = binding
157+ }
158+ return out
159+}
160+
161+func cloneFloatingPaneProfiles(in map[string]entity.FloatingPaneProfile) map[string]entity.FloatingPaneProfile {
162+ if in == nil {
163+ return nil
164+ }
165+ out := make(map[string]entity.FloatingPaneProfile, len(in))
166+ for name, profile := range in {
167+ profile.Keys = cloneStringSlice(profile.Keys)
168+ out[name] = profile
169+ }
170+ return out
171+}
172+
173+func cloneStringSlice(in []string) []string {
174+ if in == nil {
175+ return nil
176+ }
177+ out := make([]string, len(in))
178+ copy(out, in)
179+ return out
180+}
181+
132182 func engineHardwareDecodingModeFromConfig(mode config.HardwareDecodingMode) port.EngineHardwareDecodingMode {
133183 switch mode {
134184 case config.HardwareDecodingForce:
@@ -4,6 +4,7 @@ import (
4 "sync"4 "sync"
5 5
6 "github.com/bnema/dumber/internal/application/port"6 "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/domain/entity"
7 "github.com/bnema/dumber/internal/infrastructure/config"8 "github.com/bnema/dumber/internal/infrastructure/config"
8 )9 )
9 10
@@ -115,6 +116,8 @@ func runtimeSearchShortcutsFromConfig(in map[string]config.SearchShortcut) map[s
115 116
116 func cloneRuntimeConfigSnapshot(snapshot port.RuntimeConfigSnapshot) port.RuntimeConfigSnapshot {117 func cloneRuntimeConfigSnapshot(snapshot port.RuntimeConfigSnapshot) port.RuntimeConfigSnapshot {
117 snapshot.UI.SearchShortcuts = cloneRuntimeSearchShortcuts(snapshot.UI.SearchShortcuts)118 snapshot.UI.SearchShortcuts = cloneRuntimeSearchShortcuts(snapshot.UI.SearchShortcuts)
119+ snapshot.UI.Workspace = cloneWorkspaceConfig(snapshot.UI.Workspace)
120+ snapshot.UI.Session = cloneSessionConfig(snapshot.UI.Session)
118 return snapshot121 return snapshot
119 }122 }
120 123
@@ -129,6 +132,53 @@ func cloneRuntimeSearchShortcuts(in map[string]port.RuntimeSearchShortcut) map[s
129 return out132 return out
130 }133 }
131 134
135+func cloneWorkspaceConfig(in entity.WorkspaceConfig) entity.WorkspaceConfig {
136+ in.PaneMode.Actions = cloneActionBindings(in.PaneMode.Actions)
137+ in.TabMode.Actions = cloneActionBindings(in.TabMode.Actions)
138+ in.ResizeMode.Actions = cloneActionBindings(in.ResizeMode.Actions)
139+ in.Shortcuts.Actions = cloneActionBindings(in.Shortcuts.Actions)
140+ in.FloatingPane.Profiles = cloneFloatingPaneProfiles(in.FloatingPane.Profiles)
141+ return in
142+}
143+
144+func cloneSessionConfig(in entity.SessionConfig) entity.SessionConfig {
145+ in.SessionMode.Actions = cloneActionBindings(in.SessionMode.Actions)
146+ return in
147+}
148+
149+func cloneActionBindings(in map[string]entity.ActionBinding) map[string]entity.ActionBinding {
150+ if in == nil {
151+ return nil
152+ }
153+ out := make(map[string]entity.ActionBinding, len(in))
154+ for action, binding := range in {
155+ binding.Keys = cloneStringSlice(binding.Keys)
156+ out[action] = binding
157+ }
158+ return out
159+}
160+
161+func cloneFloatingPaneProfiles(in map[string]entity.FloatingPaneProfile) map[string]entity.FloatingPaneProfile {
162+ if in == nil {
163+ return nil
164+ }
165+ out := make(map[string]entity.FloatingPaneProfile, len(in))
166+ for name, profile := range in {
167+ profile.Keys = cloneStringSlice(profile.Keys)
168+ out[name] = profile
169+ }
170+ return out
171+}
172+
173+func cloneStringSlice(in []string) []string {
174+ if in == nil {
175+ return nil
176+ }
177+ out := make([]string, len(in))
178+ copy(out, in)
179+ return out
180+}
181+
132 func engineHardwareDecodingModeFromConfig(mode config.HardwareDecodingMode) port.EngineHardwareDecodingMode {182 func engineHardwareDecodingModeFromConfig(mode config.HardwareDecodingMode) port.EngineHardwareDecodingMode {
133 switch mode {183 switch mode {
134 case config.HardwareDecodingForce:184 case config.HardwareDecodingForce:
M internal/bootstrap/runtime_config_test.go +123 -0
@@ -5,6 +5,7 @@ import (
55 "testing"
66
77 "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
89 "github.com/bnema/dumber/internal/infrastructure/config"
910 )
1011
@@ -106,6 +107,30 @@ func TestRuntimeConfigProviderCurrentReturnsMapClone(t *testing.T) {
106107 }
107108 }
108109
110+func TestRuntimeConfigProviderCurrentReturnsNestedConfigClone(t *testing.T) {
111+ cfg := runtimeConfigWithNestedMutableFields()
112+ provider := NewRuntimeConfigProvider(cfg, &fakeConfigManager{})
113+
114+ first := provider.Current()
115+ mutateNestedRuntimeConfigSnapshot(first)
116+ second := provider.Current()
117+
118+ assertNestedRuntimeConfigSnapshotUnchanged(t, second)
119+}
120+
121+func TestRuntimeConfigProviderOnChangePassesNestedConfigClone(t *testing.T) {
122+ manager := &fakeConfigManager{}
123+ provider := NewRuntimeConfigProvider(config.DefaultConfig(), manager)
124+
125+ provider.OnChange(func(snapshot port.RuntimeConfigSnapshot) {
126+ mutateNestedRuntimeConfigSnapshot(snapshot)
127+ })
128+
129+ manager.callbacks[0](runtimeConfigWithNestedMutableFields())
130+
131+ assertNestedRuntimeConfigSnapshotUnchanged(t, provider.Current())
132+}
133+
109134 func TestEngineSettingsPayloadFromNilConfigReturnsZeroPayload(t *testing.T) {
110135 got := EngineSettingsPayloadFromConfig(nil)
111136 if got != (port.EngineSettingsPayload{}) {
@@ -113,6 +138,104 @@ func TestEngineSettingsPayloadFromNilConfigReturnsZeroPayload(t *testing.T) {
113138 }
114139 }
115140
141+func runtimeConfigWithNestedMutableFields() *config.Config {
142+ cfg := config.DefaultConfig()
143+ cfg.Workspace.PaneMode.Actions = map[string]entity.ActionBinding{
144+ "split-right": {Keys: []string{"r"}, Desc: "Split right"},
145+ }
146+ cfg.Workspace.TabMode.Actions = map[string]entity.ActionBinding{
147+ "next-tab": {Keys: []string{"l"}, Desc: "Next tab"},
148+ }
149+ cfg.Workspace.ResizeMode.Actions = map[string]entity.ActionBinding{
150+ "grow-right": {Keys: []string{"right"}, Desc: "Grow right"},
151+ }
152+ cfg.Workspace.Shortcuts.Actions = map[string]entity.ActionBinding{
153+ "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "History"},
154+ }
155+ cfg.Workspace.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
156+ "docs": {Keys: []string{"alt+d"}, URL: "https://docs.example", Desc: "Docs"},
157+ }
158+ cfg.Session.SessionMode.Actions = map[string]entity.ActionBinding{
159+ "session-manager": {Keys: []string{"s"}, Desc: "Session manager"},
160+ }
161+ return cfg
162+}
163+
164+func mutateNestedRuntimeConfigSnapshot(snapshot port.RuntimeConfigSnapshot) {
165+ mutateActionBinding(snapshot.UI.Workspace.PaneMode.Actions, "split-right", "mutated-pane")
166+ snapshot.UI.Workspace.PaneMode.Actions["added-pane"] = entity.ActionBinding{Keys: []string{"added"}}
167+
168+ mutateActionBinding(snapshot.UI.Workspace.TabMode.Actions, "next-tab", "mutated-tab")
169+ snapshot.UI.Workspace.TabMode.Actions["added-tab"] = entity.ActionBinding{Keys: []string{"added"}}
170+
171+ mutateActionBinding(snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "mutated-resize")
172+ snapshot.UI.Workspace.ResizeMode.Actions["added-resize"] = entity.ActionBinding{Keys: []string{"added"}}
173+
174+ mutateActionBinding(snapshot.UI.Workspace.Shortcuts.Actions, "toggle-history-systemview", "mutated-shortcut")
175+ snapshot.UI.Workspace.Shortcuts.Actions["added-shortcut"] = entity.ActionBinding{Keys: []string{"added"}}
176+
177+ profile := snapshot.UI.Workspace.FloatingPane.Profiles["docs"]
178+ profile.Keys[0] = "mutated-floating"
179+ snapshot.UI.Workspace.FloatingPane.Profiles["docs"] = profile
180+ snapshot.UI.Workspace.FloatingPane.Profiles["added-floating"] = entity.FloatingPaneProfile{Keys: []string{"added"}}
181+
182+ mutateActionBinding(snapshot.UI.Session.SessionMode.Actions, "session-manager", "mutated-session")
183+ snapshot.UI.Session.SessionMode.Actions["added-session"] = entity.ActionBinding{Keys: []string{"added"}}
184+}
185+
186+func mutateActionBinding(actions map[string]entity.ActionBinding, action string, key string) {
187+ binding := actions[action]
188+ binding.Keys[0] = key
189+ actions[action] = binding
190+}
191+
192+func assertNestedRuntimeConfigSnapshotUnchanged(t *testing.T, snapshot port.RuntimeConfigSnapshot) {
193+ t.Helper()
194+
195+ assertActionBinding(t, snapshot.UI.Workspace.PaneMode.Actions, "split-right", "r")
196+ assertMapEntryAbsent(t, snapshot.UI.Workspace.PaneMode.Actions, "added-pane")
197+
198+ assertActionBinding(t, snapshot.UI.Workspace.TabMode.Actions, "next-tab", "l")
199+ assertMapEntryAbsent(t, snapshot.UI.Workspace.TabMode.Actions, "added-tab")
200+
201+ assertActionBinding(t, snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "right")
202+ assertMapEntryAbsent(t, snapshot.UI.Workspace.ResizeMode.Actions, "added-resize")
203+
204+ assertActionBinding(t, snapshot.UI.Workspace.Shortcuts.Actions, "toggle-history-systemview", "ctrl+h")
205+ assertMapEntryAbsent(t, snapshot.UI.Workspace.Shortcuts.Actions, "added-shortcut")
206+
207+ profile := snapshot.UI.Workspace.FloatingPane.Profiles["docs"]
208+ if got := profile.Keys[0]; got != "alt+d" {
209+ t.Fatalf("floating profile key=%q, want %q", got, "alt+d")
210+ }
211+ if _, ok := snapshot.UI.Workspace.FloatingPane.Profiles["added-floating"]; ok {
212+ t.Fatal("floating pane profiles must not include entries added through a returned snapshot")
213+ }
214+
215+ assertActionBinding(t, snapshot.UI.Session.SessionMode.Actions, "session-manager", "s")
216+ assertMapEntryAbsent(t, snapshot.UI.Session.SessionMode.Actions, "added-session")
217+}
218+
219+func assertActionBinding(t *testing.T, actions map[string]entity.ActionBinding, action string, wantKey string) {
220+ t.Helper()
221+
222+ binding, ok := actions[action]
223+ if !ok {
224+ t.Fatalf("action %q missing", action)
225+ }
226+ if got := binding.Keys[0]; got != wantKey {
227+ t.Fatalf("%s key=%q, want %q", action, got, wantKey)
228+ }
229+}
230+
231+func assertMapEntryAbsent(t *testing.T, actions map[string]entity.ActionBinding, action string) {
232+ t.Helper()
233+
234+ if _, ok := actions[action]; ok {
235+ t.Fatalf("actions must not include entry %q added through a returned snapshot", action)
236+ }
237+}
238+
116239 func TestRuntimeConfigSnapshotFromConfigMapsUIRuntimeFields(t *testing.T) {
117240 cfg := config.DefaultConfig()
118241 cfg.DefaultUIScale = 1.4
@@ -5,6 +5,7 @@ import (
5 "testing"5 "testing"
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/bnema/dumber/internal/infrastructure/config"9 "github.com/bnema/dumber/internal/infrastructure/config"
9 )10 )
10 11
@@ -106,6 +107,30 @@ func TestRuntimeConfigProviderCurrentReturnsMapClone(t *testing.T) {
106 }107 }
107 }108 }
108 109
110+func TestRuntimeConfigProviderCurrentReturnsNestedConfigClone(t *testing.T) {
111+ cfg := runtimeConfigWithNestedMutableFields()
112+ provider := NewRuntimeConfigProvider(cfg, &fakeConfigManager{})
113+
114+ first := provider.Current()
115+ mutateNestedRuntimeConfigSnapshot(first)
116+ second := provider.Current()
117+
118+ assertNestedRuntimeConfigSnapshotUnchanged(t, second)
119+}
120+
121+func TestRuntimeConfigProviderOnChangePassesNestedConfigClone(t *testing.T) {
122+ manager := &fakeConfigManager{}
123+ provider := NewRuntimeConfigProvider(config.DefaultConfig(), manager)
124+
125+ provider.OnChange(func(snapshot port.RuntimeConfigSnapshot) {
126+ mutateNestedRuntimeConfigSnapshot(snapshot)
127+ })
128+
129+ manager.callbacks[0](runtimeConfigWithNestedMutableFields())
130+
131+ assertNestedRuntimeConfigSnapshotUnchanged(t, provider.Current())
132+}
133+
109 func TestEngineSettingsPayloadFromNilConfigReturnsZeroPayload(t *testing.T) {134 func TestEngineSettingsPayloadFromNilConfigReturnsZeroPayload(t *testing.T) {
110 got := EngineSettingsPayloadFromConfig(nil)135 got := EngineSettingsPayloadFromConfig(nil)
111 if got != (port.EngineSettingsPayload{}) {136 if got != (port.EngineSettingsPayload{}) {
@@ -113,6 +138,104 @@ func TestEngineSettingsPayloadFromNilConfigReturnsZeroPayload(t *testing.T) {
113 }138 }
114 }139 }
115 140
141+func runtimeConfigWithNestedMutableFields() *config.Config {
142+ cfg := config.DefaultConfig()
143+ cfg.Workspace.PaneMode.Actions = map[string]entity.ActionBinding{
144+ "split-right": {Keys: []string{"r"}, Desc: "Split right"},
145+ }
146+ cfg.Workspace.TabMode.Actions = map[string]entity.ActionBinding{
147+ "next-tab": {Keys: []string{"l"}, Desc: "Next tab"},
148+ }
149+ cfg.Workspace.ResizeMode.Actions = map[string]entity.ActionBinding{
150+ "grow-right": {Keys: []string{"right"}, Desc: "Grow right"},
151+ }
152+ cfg.Workspace.Shortcuts.Actions = map[string]entity.ActionBinding{
153+ "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "History"},
154+ }
155+ cfg.Workspace.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
156+ "docs": {Keys: []string{"alt+d"}, URL: "https://docs.example", Desc: "Docs"},
157+ }
158+ cfg.Session.SessionMode.Actions = map[string]entity.ActionBinding{
159+ "session-manager": {Keys: []string{"s"}, Desc: "Session manager"},
160+ }
161+ return cfg
162+}
163+
164+func mutateNestedRuntimeConfigSnapshot(snapshot port.RuntimeConfigSnapshot) {
165+ mutateActionBinding(snapshot.UI.Workspace.PaneMode.Actions, "split-right", "mutated-pane")
166+ snapshot.UI.Workspace.PaneMode.Actions["added-pane"] = entity.ActionBinding{Keys: []string{"added"}}
167+
168+ mutateActionBinding(snapshot.UI.Workspace.TabMode.Actions, "next-tab", "mutated-tab")
169+ snapshot.UI.Workspace.TabMode.Actions["added-tab"] = entity.ActionBinding{Keys: []string{"added"}}
170+
171+ mutateActionBinding(snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "mutated-resize")
172+ snapshot.UI.Workspace.ResizeMode.Actions["added-resize"] = entity.ActionBinding{Keys: []string{"added"}}
173+
174+ mutateActionBinding(snapshot.UI.Workspace.Shortcuts.Actions, "toggle-history-systemview", "mutated-shortcut")
175+ snapshot.UI.Workspace.Shortcuts.Actions["added-shortcut"] = entity.ActionBinding{Keys: []string{"added"}}
176+
177+ profile := snapshot.UI.Workspace.FloatingPane.Profiles["docs"]
178+ profile.Keys[0] = "mutated-floating"
179+ snapshot.UI.Workspace.FloatingPane.Profiles["docs"] = profile
180+ snapshot.UI.Workspace.FloatingPane.Profiles["added-floating"] = entity.FloatingPaneProfile{Keys: []string{"added"}}
181+
182+ mutateActionBinding(snapshot.UI.Session.SessionMode.Actions, "session-manager", "mutated-session")
183+ snapshot.UI.Session.SessionMode.Actions["added-session"] = entity.ActionBinding{Keys: []string{"added"}}
184+}
185+
186+func mutateActionBinding(actions map[string]entity.ActionBinding, action string, key string) {
187+ binding := actions[action]
188+ binding.Keys[0] = key
189+ actions[action] = binding
190+}
191+
192+func assertNestedRuntimeConfigSnapshotUnchanged(t *testing.T, snapshot port.RuntimeConfigSnapshot) {
193+ t.Helper()
194+
195+ assertActionBinding(t, snapshot.UI.Workspace.PaneMode.Actions, "split-right", "r")
196+ assertMapEntryAbsent(t, snapshot.UI.Workspace.PaneMode.Actions, "added-pane")
197+
198+ assertActionBinding(t, snapshot.UI.Workspace.TabMode.Actions, "next-tab", "l")
199+ assertMapEntryAbsent(t, snapshot.UI.Workspace.TabMode.Actions, "added-tab")
200+
201+ assertActionBinding(t, snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "right")
202+ assertMapEntryAbsent(t, snapshot.UI.Workspace.ResizeMode.Actions, "added-resize")
203+
204+ assertActionBinding(t, snapshot.UI.Workspace.Shortcuts.Actions, "toggle-history-systemview", "ctrl+h")
205+ assertMapEntryAbsent(t, snapshot.UI.Workspace.Shortcuts.Actions, "added-shortcut")
206+
207+ profile := snapshot.UI.Workspace.FloatingPane.Profiles["docs"]
208+ if got := profile.Keys[0]; got != "alt+d" {
209+ t.Fatalf("floating profile key=%q, want %q", got, "alt+d")
210+ }
211+ if _, ok := snapshot.UI.Workspace.FloatingPane.Profiles["added-floating"]; ok {
212+ t.Fatal("floating pane profiles must not include entries added through a returned snapshot")
213+ }
214+
215+ assertActionBinding(t, snapshot.UI.Session.SessionMode.Actions, "session-manager", "s")
216+ assertMapEntryAbsent(t, snapshot.UI.Session.SessionMode.Actions, "added-session")
217+}
218+
219+func assertActionBinding(t *testing.T, actions map[string]entity.ActionBinding, action string, wantKey string) {
220+ t.Helper()
221+
222+ binding, ok := actions[action]
223+ if !ok {
224+ t.Fatalf("action %q missing", action)
225+ }
226+ if got := binding.Keys[0]; got != wantKey {
227+ t.Fatalf("%s key=%q, want %q", action, got, wantKey)
228+ }
229+}
230+
231+func assertMapEntryAbsent(t *testing.T, actions map[string]entity.ActionBinding, action string) {
232+ t.Helper()
233+
234+ if _, ok := actions[action]; ok {
235+ t.Fatalf("actions must not include entry %q added through a returned snapshot", action)
236+ }
237+}
238+
116 func TestRuntimeConfigSnapshotFromConfigMapsUIRuntimeFields(t *testing.T) {239 func TestRuntimeConfigSnapshotFromConfigMapsUIRuntimeFields(t *testing.T) {
117 cfg := config.DefaultConfig()240 cfg := config.DefaultConfig()
118 cfg.DefaultUIScale = 1.4241 cfg.DefaultUIScale = 1.4

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