detached 8a75379

fix: release player resources on close

commit
8a753799e41c9e0f316cd74fd41382746650ec50
author
brice · · 2026-04-10 03:53 UTC
parent
0db6373
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +173 -18
M internal/capi/stream_ops.go +4 -8
@@ -27,9 +27,8 @@ type streamCallbackStorage struct {
2727 // streamOpsImpl implements portout.StreamOps using the real PipeWire C API
2828 // bindings registered via purego.
2929 type streamOpsImpl struct {
30- mu sync.Mutex
31- pinned map[unsafe.Pointer]*streamCallbackStorage // keyed by stream ptr
32- destroyed map[unsafe.Pointer]bool // guard double-destroy
30+ mu sync.Mutex
31+ pinned map[unsafe.Pointer]*streamCallbackStorage // keyed by stream ptr
3332 }
3433
3534 // Verify interface compliance at compile time.
@@ -106,14 +105,11 @@ func (s *streamOpsImpl) DisconnectStream(streamPtr unsafe.Pointer) {
106105
107106 func (s *streamOpsImpl) DestroyStream(streamPtr unsafe.Pointer) {
108107 s.mu.Lock()
109- if s.destroyed == nil {
110- s.destroyed = make(map[unsafe.Pointer]bool)
111- }
112- if s.destroyed[streamPtr] {
108+ if _, alive := s.pinned[streamPtr]; !alive {
109+ // Already destroyed or never tracked — skip.
113110 s.mu.Unlock()
114111 return
115112 }
116- s.destroyed[streamPtr] = true
117113 // Unpin callback storage — safe now that the stream is being destroyed.
118114 delete(s.pinned, streamPtr)
119115 s.mu.Unlock()
@@ -27,9 +27,8 @@ type streamCallbackStorage struct {
27 // streamOpsImpl implements portout.StreamOps using the real PipeWire C API27 // streamOpsImpl implements portout.StreamOps using the real PipeWire C API
28 // bindings registered via purego.28 // bindings registered via purego.
29 type streamOpsImpl struct {29 type streamOpsImpl struct {
30- mu sync.Mutex30+ mu sync.Mutex
31- pinned map[unsafe.Pointer]*streamCallbackStorage // keyed by stream ptr31+ pinned map[unsafe.Pointer]*streamCallbackStorage // keyed by stream ptr
32- destroyed map[unsafe.Pointer]bool // guard double-destroy
33 }32 }
34 33
35 // Verify interface compliance at compile time.34 // Verify interface compliance at compile time.
@@ -106,14 +105,11 @@ func (s *streamOpsImpl) DisconnectStream(streamPtr unsafe.Pointer) {
106 105
107 func (s *streamOpsImpl) DestroyStream(streamPtr unsafe.Pointer) {106 func (s *streamOpsImpl) DestroyStream(streamPtr unsafe.Pointer) {
108 s.mu.Lock()107 s.mu.Lock()
109- if s.destroyed == nil {108+ if _, alive := s.pinned[streamPtr]; !alive {
110- s.destroyed = make(map[unsafe.Pointer]bool)109+ // Already destroyed or never tracked — skip.
111- }
112- if s.destroyed[streamPtr] {
113 s.mu.Unlock()110 s.mu.Unlock()
114 return111 return
115 }112 }
116- s.destroyed[streamPtr] = true
117 // Unpin callback storage — safe now that the stream is being destroyed.113 // Unpin callback storage — safe now that the stream is being destroyed.
118 delete(s.pinned, streamPtr)114 delete(s.pinned, streamPtr)
119 s.mu.Unlock()115 s.mu.Unlock()
M internal/capi/stream_ops_test.go +28 -8
@@ -20,8 +20,7 @@ func TestDestroyStreamCallsPwStreamDestroy(t *testing.T) {
2020 }
2121
2222 ops := &streamOpsImpl{
23- pinned: make(map[unsafe.Pointer]*streamCallbackStorage),
24- destroyed: make(map[unsafe.Pointer]bool),
23+ pinned: make(map[unsafe.Pointer]*streamCallbackStorage),
2524 }
2625
2726 fakePtr := unsafe.Pointer(uintptr(0x1234))
@@ -36,10 +35,7 @@ func TestDestroyStreamCallsPwStreamDestroy(t *testing.T) {
3635 t.Fatalf("pw_stream_destroy received %v, want %v", receivedPtr, fakePtr)
3736 }
3837
39- // Verify internal bookkeeping.
40- if !ops.destroyed[fakePtr] {
41- t.Error("expected stream to be marked as destroyed")
42- }
38+ // Verify internal bookkeeping: pinned entry should be removed.
4339 if _, exists := ops.pinned[fakePtr]; exists {
4440 t.Error("expected callback storage to be unpinned after destroy")
4541 }
@@ -57,8 +53,7 @@ func TestDestroyStreamDoubleDestroySkipsSecondCall(t *testing.T) {
5753 }
5854
5955 ops := &streamOpsImpl{
60- pinned: make(map[unsafe.Pointer]*streamCallbackStorage),
61- destroyed: make(map[unsafe.Pointer]bool),
56+ pinned: make(map[unsafe.Pointer]*streamCallbackStorage),
6257 }
6358
6459 fakePtr := unsafe.Pointer(uintptr(0xABCD))
@@ -72,6 +67,31 @@ func TestDestroyStreamDoubleDestroySkipsSecondCall(t *testing.T) {
7267 }
7368 }
7469
70+// TestDestroyStreamBookkeepingDoesNotLeak verifies that internal tracking
71+// does not retain entries after cleanup across many create/destroy cycles.
72+func TestDestroyStreamBookkeepingDoesNotLeak(t *testing.T) {
73+ origDestroy := pw_stream_destroy
74+ t.Cleanup(func() { pw_stream_destroy = origDestroy })
75+
76+ pw_stream_destroy = func(_ unsafe.Pointer) {}
77+
78+ ops := &streamOpsImpl{
79+ pinned: make(map[unsafe.Pointer]*streamCallbackStorage),
80+ }
81+
82+ const cycles = 100
83+ for i := uintptr(1); i <= cycles; i++ {
84+ ptr := unsafe.Pointer(i)
85+ ops.pinned[ptr] = &streamCallbackStorage{}
86+ ops.DestroyStream(ptr)
87+ }
88+
89+ // After destroying all streams, pinned map should be empty.
90+ if len(ops.pinned) != 0 {
91+ t.Fatalf("pinned map has %d entries after %d cycles; expected 0", len(ops.pinned), cycles)
92+ }
93+}
94+
7595 // TestQuitMainLoopCallsPwMainLoopQuit verifies that QuitMainLoop delegates
7696 // to the generated pw_main_loop_quit binding.
7797 func TestQuitMainLoopCallsPwMainLoopQuit(t *testing.T) {
@@ -20,8 +20,7 @@ func TestDestroyStreamCallsPwStreamDestroy(t *testing.T) {
20 }20 }
21 21
22 ops := &streamOpsImpl{22 ops := &streamOpsImpl{
23- pinned: make(map[unsafe.Pointer]*streamCallbackStorage),23+ pinned: make(map[unsafe.Pointer]*streamCallbackStorage),
24- destroyed: make(map[unsafe.Pointer]bool),
25 }24 }
26 25
27 fakePtr := unsafe.Pointer(uintptr(0x1234))26 fakePtr := unsafe.Pointer(uintptr(0x1234))
@@ -36,10 +35,7 @@ func TestDestroyStreamCallsPwStreamDestroy(t *testing.T) {
36 t.Fatalf("pw_stream_destroy received %v, want %v", receivedPtr, fakePtr)35 t.Fatalf("pw_stream_destroy received %v, want %v", receivedPtr, fakePtr)
37 }36 }
38 37
39- // Verify internal bookkeeping.38+ // Verify internal bookkeeping: pinned entry should be removed.
40- if !ops.destroyed[fakePtr] {
41- t.Error("expected stream to be marked as destroyed")
42- }
43 if _, exists := ops.pinned[fakePtr]; exists {39 if _, exists := ops.pinned[fakePtr]; exists {
44 t.Error("expected callback storage to be unpinned after destroy")40 t.Error("expected callback storage to be unpinned after destroy")
45 }41 }
@@ -57,8 +53,7 @@ func TestDestroyStreamDoubleDestroySkipsSecondCall(t *testing.T) {
57 }53 }
58 54
59 ops := &streamOpsImpl{55 ops := &streamOpsImpl{
60- pinned: make(map[unsafe.Pointer]*streamCallbackStorage),56+ pinned: make(map[unsafe.Pointer]*streamCallbackStorage),
61- destroyed: make(map[unsafe.Pointer]bool),
62 }57 }
63 58
64 fakePtr := unsafe.Pointer(uintptr(0xABCD))59 fakePtr := unsafe.Pointer(uintptr(0xABCD))
@@ -72,6 +67,31 @@ func TestDestroyStreamDoubleDestroySkipsSecondCall(t *testing.T) {
72 }67 }
73 }68 }
74 69
70+// TestDestroyStreamBookkeepingDoesNotLeak verifies that internal tracking
71+// does not retain entries after cleanup across many create/destroy cycles.
72+func TestDestroyStreamBookkeepingDoesNotLeak(t *testing.T) {
73+ origDestroy := pw_stream_destroy
74+ t.Cleanup(func() { pw_stream_destroy = origDestroy })
75+
76+ pw_stream_destroy = func(_ unsafe.Pointer) {}
77+
78+ ops := &streamOpsImpl{
79+ pinned: make(map[unsafe.Pointer]*streamCallbackStorage),
80+ }
81+
82+ const cycles = 100
83+ for i := uintptr(1); i <= cycles; i++ {
84+ ptr := unsafe.Pointer(i)
85+ ops.pinned[ptr] = &streamCallbackStorage{}
86+ ops.DestroyStream(ptr)
87+ }
88+
89+ // After destroying all streams, pinned map should be empty.
90+ if len(ops.pinned) != 0 {
91+ t.Fatalf("pinned map has %d entries after %d cycles; expected 0", len(ops.pinned), cycles)
92+ }
93+}
94+
75 // TestQuitMainLoopCallsPwMainLoopQuit verifies that QuitMainLoop delegates95 // TestQuitMainLoopCallsPwMainLoopQuit verifies that QuitMainLoop delegates
76 // to the generated pw_main_loop_quit binding.96 // to the generated pw_main_loop_quit binding.
77 func TestQuitMainLoopCallsPwMainLoopQuit(t *testing.T) {97 func TestQuitMainLoopCallsPwMainLoopQuit(t *testing.T) {
M internal/core/player.go +18 -2
@@ -213,9 +213,25 @@ func (p *player) deactivateStream() error {
213213 return p.streamOps.SetStreamActive(p.streamPtr, false)
214214 }
215215
216-// teardown performs cleanup (placeholder)
216+// teardown releases player-owned stream and main-loop resources through
217+// StreamOps. Safe to call when fields are nil or on repeated invocation.
217218 func (p *player) teardown() {
218- // Minimal placeholder - will be implemented in future tasks
219+ if p.streamOps == nil {
220+ return
221+ }
222+
223+ // Destroy stream first (it depends on the loop).
224+ if p.streamPtr != nil {
225+ p.streamOps.DestroyStream(p.streamPtr)
226+ p.streamPtr = nil
227+ }
228+
229+ // Quit then destroy the main loop.
230+ if p.loopPtr != nil {
231+ p.streamOps.QuitMainLoop(p.loopPtr)
232+ p.streamOps.DestroyMainLoop(p.loopPtr)
233+ p.loopPtr = nil
234+ }
219235 }
220236
221237 // Player is the exported wrapper for the internal player
@@ -213,9 +213,25 @@ func (p *player) deactivateStream() error {
213 return p.streamOps.SetStreamActive(p.streamPtr, false)213 return p.streamOps.SetStreamActive(p.streamPtr, false)
214 }214 }
215 215
216-// teardown performs cleanup (placeholder)216+// teardown releases player-owned stream and main-loop resources through
217+// StreamOps. Safe to call when fields are nil or on repeated invocation.
217 func (p *player) teardown() {218 func (p *player) teardown() {
218- // Minimal placeholder - will be implemented in future tasks219+ if p.streamOps == nil {
220+ return
221+ }
222+
223+ // Destroy stream first (it depends on the loop).
224+ if p.streamPtr != nil {
225+ p.streamOps.DestroyStream(p.streamPtr)
226+ p.streamPtr = nil
227+ }
228+
229+ // Quit then destroy the main loop.
230+ if p.loopPtr != nil {
231+ p.streamOps.QuitMainLoop(p.loopPtr)
232+ p.streamOps.DestroyMainLoop(p.loopPtr)
233+ p.loopPtr = nil
234+ }
219 }235 }
220 236
221 // Player is the exported wrapper for the internal player237 // Player is the exported wrapper for the internal player
M internal/core/player_test.go +123 -0
@@ -247,5 +247,128 @@ func TestPlayerStopWithoutStreamOpsIsNoop(t *testing.T) {
247247 }
248248 }
249249
250+// TestPlayerCloseTeardownCallsStreamOps verifies that Close() calls
251+// DestroyStream, QuitMainLoop, and DestroyMainLoop through StreamOps
252+// when the corresponding pointers are set.
253+func TestPlayerCloseTeardownCallsStreamOps(t *testing.T) {
254+ mockOps := mocks.NewMockStreamOps(t)
255+ fakeStream := unsafe.Pointer(uintptr(0xAAAA))
256+ fakeLoop := unsafe.Pointer(uintptr(0xBBBB))
257+
258+ // Expect teardown calls in order: DestroyStream, QuitMainLoop, DestroyMainLoop.
259+ mockOps.EXPECT().DestroyStream(fakeStream).Return()
260+ mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
261+ mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
262+
263+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
264+ p.streamOps = mockOps
265+ p.streamPtr = fakeStream
266+ p.loopPtr = fakeLoop
267+
268+ // Move to a state that allows Close.
269+ p.setState(PlayerStatePlaying)
270+
271+ if err := p.Close(); err != nil {
272+ t.Fatalf("Close failed: %v", err)
273+ }
274+ if p.State() != PlayerStateClosed {
275+ t.Fatalf("expected Closed state, got %v", p.State())
276+ }
277+
278+ // Pointers should be nil after teardown so repeated close is safe.
279+ if p.streamPtr != nil {
280+ t.Error("expected streamPtr to be nil after teardown")
281+ }
282+ if p.loopPtr != nil {
283+ t.Error("expected loopPtr to be nil after teardown")
284+ }
285+}
286+
287+// TestPlayerCloseWithNilPointersIsNoop verifies that teardown is safe
288+// when no stream/loop pointers are set.
289+func TestPlayerCloseWithNilPointersIsNoop(t *testing.T) {
290+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
291+ p.setState(PlayerStatePlaying)
292+
293+ // No streamOps, no pointers — Close should succeed without panic.
294+ if err := p.Close(); err != nil {
295+ t.Fatalf("Close failed: %v", err)
296+ }
297+ if p.State() != PlayerStateClosed {
298+ t.Fatalf("expected Closed state, got %v", p.State())
299+ }
300+}
301+
302+// TestPlayerRepeatedCloseIsSafe verifies that calling Close() twice
303+// with teardown resources does not call StreamOps methods a second time.
304+func TestPlayerRepeatedCloseIsSafe(t *testing.T) {
305+ mockOps := mocks.NewMockStreamOps(t)
306+ fakeStream := unsafe.Pointer(uintptr(0xCCCC))
307+ fakeLoop := unsafe.Pointer(uintptr(0xDDDD))
308+
309+ // Only expect one set of teardown calls.
310+ mockOps.EXPECT().DestroyStream(fakeStream).Return()
311+ mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
312+ mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
313+
314+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
315+ p.streamOps = mockOps
316+ p.streamPtr = fakeStream
317+ p.loopPtr = fakeLoop
318+ p.setState(PlayerStatePlaying)
319+
320+ if err := p.Close(); err != nil {
321+ t.Fatalf("first Close failed: %v", err)
322+ }
323+
324+ // Second close should succeed and not call StreamOps again.
325+ if err := p.Close(); err != nil {
326+ t.Fatalf("second Close failed: %v", err)
327+ }
328+}
329+
330+// TestPlayerTeardownWithOnlyStreamPtr verifies teardown works
331+// when only streamPtr is set (no loopPtr).
332+func TestPlayerTeardownWithOnlyStreamPtr(t *testing.T) {
333+ mockOps := mocks.NewMockStreamOps(t)
334+ fakeStream := unsafe.Pointer(uintptr(0xEEEE))
335+
336+ mockOps.EXPECT().DestroyStream(fakeStream).Return()
337+
338+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
339+ p.streamOps = mockOps
340+ p.streamPtr = fakeStream
341+ p.setState(PlayerStatePlaying)
342+
343+ if err := p.Close(); err != nil {
344+ t.Fatalf("Close failed: %v", err)
345+ }
346+ if p.streamPtr != nil {
347+ t.Error("expected streamPtr to be nil after teardown")
348+ }
349+}
350+
351+// TestPlayerTeardownWithOnlyLoopPtr verifies teardown works
352+// when only loopPtr is set (no streamPtr).
353+func TestPlayerTeardownWithOnlyLoopPtr(t *testing.T) {
354+ mockOps := mocks.NewMockStreamOps(t)
355+ fakeLoop := unsafe.Pointer(uintptr(0xFFFF))
356+
357+ mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
358+ mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
359+
360+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
361+ p.streamOps = mockOps
362+ p.loopPtr = fakeLoop
363+ p.setState(PlayerStatePlaying)
364+
365+ if err := p.Close(); err != nil {
366+ t.Fatalf("Close failed: %v", err)
367+ }
368+ if p.loopPtr != nil {
369+ t.Error("expected loopPtr to be nil after teardown")
370+ }
371+}
372+
250373 // Ensure mock.Mock is referenced to satisfy the import.
251374 var _ mock.TestingT = (*testing.T)(nil)
@@ -247,5 +247,128 @@ func TestPlayerStopWithoutStreamOpsIsNoop(t *testing.T) {
247 }247 }
248 }248 }
249 249
250+// TestPlayerCloseTeardownCallsStreamOps verifies that Close() calls
251+// DestroyStream, QuitMainLoop, and DestroyMainLoop through StreamOps
252+// when the corresponding pointers are set.
253+func TestPlayerCloseTeardownCallsStreamOps(t *testing.T) {
254+ mockOps := mocks.NewMockStreamOps(t)
255+ fakeStream := unsafe.Pointer(uintptr(0xAAAA))
256+ fakeLoop := unsafe.Pointer(uintptr(0xBBBB))
257+
258+ // Expect teardown calls in order: DestroyStream, QuitMainLoop, DestroyMainLoop.
259+ mockOps.EXPECT().DestroyStream(fakeStream).Return()
260+ mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
261+ mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
262+
263+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
264+ p.streamOps = mockOps
265+ p.streamPtr = fakeStream
266+ p.loopPtr = fakeLoop
267+
268+ // Move to a state that allows Close.
269+ p.setState(PlayerStatePlaying)
270+
271+ if err := p.Close(); err != nil {
272+ t.Fatalf("Close failed: %v", err)
273+ }
274+ if p.State() != PlayerStateClosed {
275+ t.Fatalf("expected Closed state, got %v", p.State())
276+ }
277+
278+ // Pointers should be nil after teardown so repeated close is safe.
279+ if p.streamPtr != nil {
280+ t.Error("expected streamPtr to be nil after teardown")
281+ }
282+ if p.loopPtr != nil {
283+ t.Error("expected loopPtr to be nil after teardown")
284+ }
285+}
286+
287+// TestPlayerCloseWithNilPointersIsNoop verifies that teardown is safe
288+// when no stream/loop pointers are set.
289+func TestPlayerCloseWithNilPointersIsNoop(t *testing.T) {
290+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
291+ p.setState(PlayerStatePlaying)
292+
293+ // No streamOps, no pointers — Close should succeed without panic.
294+ if err := p.Close(); err != nil {
295+ t.Fatalf("Close failed: %v", err)
296+ }
297+ if p.State() != PlayerStateClosed {
298+ t.Fatalf("expected Closed state, got %v", p.State())
299+ }
300+}
301+
302+// TestPlayerRepeatedCloseIsSafe verifies that calling Close() twice
303+// with teardown resources does not call StreamOps methods a second time.
304+func TestPlayerRepeatedCloseIsSafe(t *testing.T) {
305+ mockOps := mocks.NewMockStreamOps(t)
306+ fakeStream := unsafe.Pointer(uintptr(0xCCCC))
307+ fakeLoop := unsafe.Pointer(uintptr(0xDDDD))
308+
309+ // Only expect one set of teardown calls.
310+ mockOps.EXPECT().DestroyStream(fakeStream).Return()
311+ mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
312+ mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
313+
314+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
315+ p.streamOps = mockOps
316+ p.streamPtr = fakeStream
317+ p.loopPtr = fakeLoop
318+ p.setState(PlayerStatePlaying)
319+
320+ if err := p.Close(); err != nil {
321+ t.Fatalf("first Close failed: %v", err)
322+ }
323+
324+ // Second close should succeed and not call StreamOps again.
325+ if err := p.Close(); err != nil {
326+ t.Fatalf("second Close failed: %v", err)
327+ }
328+}
329+
330+// TestPlayerTeardownWithOnlyStreamPtr verifies teardown works
331+// when only streamPtr is set (no loopPtr).
332+func TestPlayerTeardownWithOnlyStreamPtr(t *testing.T) {
333+ mockOps := mocks.NewMockStreamOps(t)
334+ fakeStream := unsafe.Pointer(uintptr(0xEEEE))
335+
336+ mockOps.EXPECT().DestroyStream(fakeStream).Return()
337+
338+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
339+ p.streamOps = mockOps
340+ p.streamPtr = fakeStream
341+ p.setState(PlayerStatePlaying)
342+
343+ if err := p.Close(); err != nil {
344+ t.Fatalf("Close failed: %v", err)
345+ }
346+ if p.streamPtr != nil {
347+ t.Error("expected streamPtr to be nil after teardown")
348+ }
349+}
350+
351+// TestPlayerTeardownWithOnlyLoopPtr verifies teardown works
352+// when only loopPtr is set (no streamPtr).
353+func TestPlayerTeardownWithOnlyLoopPtr(t *testing.T) {
354+ mockOps := mocks.NewMockStreamOps(t)
355+ fakeLoop := unsafe.Pointer(uintptr(0xFFFF))
356+
357+ mockOps.EXPECT().QuitMainLoop(fakeLoop).Return()
358+ mockOps.EXPECT().DestroyMainLoop(fakeLoop).Return()
359+
360+ p := newPlayer(PlayerConfig{}, PlayerCallbacks{})
361+ p.streamOps = mockOps
362+ p.loopPtr = fakeLoop
363+ p.setState(PlayerStatePlaying)
364+
365+ if err := p.Close(); err != nil {
366+ t.Fatalf("Close failed: %v", err)
367+ }
368+ if p.loopPtr != nil {
369+ t.Error("expected loopPtr to be nil after teardown")
370+ }
371+}
372+
250 // Ensure mock.Mock is referenced to satisfy the import.373 // Ensure mock.Mock is referenced to satisfy the import.
251 var _ mock.TestingT = (*testing.T)(nil)374 var _ mock.TestingT = (*testing.T)(nil)

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 1

Tags 8

↑↓ moveenter openesc close