v2.31.0-dev17 bd898f0

images_test.go RawHistoryFolder

1package cli
2
3import (
4	"bytes"
5	"context"
6	"errors"
7	"regexp"
8	"strings"
9	"testing"
10	"time"
11
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/require"
14
15	"github.com/bnema/gordon/internal/adapters/dto"
16	"github.com/bnema/gordon/internal/domain"
17)
18
19type fakeImagesClient struct {
20	listImagesResp []dto.Image
21	listImagesErr  error
22	pruneResp      *dto.ImagePruneResponse
23	pruneErr       error
24
25	listImagesCalls int
26	pruneCalls      int
27	lastPruneOpts   dto.ImagePruneRequest
28}
29
30func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) {
31	f.listImagesCalls++
32	if f.listImagesErr != nil {
33		return nil, f.listImagesErr
34	}
35	return f.listImagesResp, nil
36}
37
38func (f *fakeImagesClient) PruneImages(_ context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) {
39	f.pruneCalls++
40	f.lastPruneOpts = req
41	if f.pruneErr != nil {
42		return nil, f.pruneErr
43	}
44	return f.pruneResp, nil
45}
46
47// ---------------------------------------------------------------------------
48// images list tests (unchanged)
49// ---------------------------------------------------------------------------
50
51func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {
52	createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
53	client := &fakeImagesClient{
54		listImagesResp: []dto.Image{
55			{Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false},
56			{Repository: "<none>", Tag: "<none>", Size: 512_000, Created: createdAt, ID: "sha256:2222", Dangling: true},
57		},
58	}
59
60	var out bytes.Buffer
61	err := runImagesList(context.Background(), client, &out, false)
62	require.NoError(t, err)
63
64	text := out.String()
65	assert.Contains(t, text, "REPOSITORY")
66	assert.Contains(t, text, "registry.example.com/app")
67	assert.Contains(t, text, "latest")
68	assert.Contains(t, text, "Total images: 2 (dangling: 1)")
69	assert.Equal(t, 1, client.listImagesCalls)
70}
71
72func TestRunImagesList_HeaderOrderAndSummaryLine(t *testing.T) {
73	createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
74	client := &fakeImagesClient{
75		listImagesResp: []dto.Image{
76			{Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false},
77		},
78	}
79
80	var out bytes.Buffer
81	err := runImagesList(context.Background(), client, &out, false)
82	require.NoError(t, err)
83
84	text := stripANSI(out.String())
85	header := findHeaderLine(text)
86	assert.NotEmpty(t, header)
87	assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG"))
88	assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE"))
89	assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED"))
90	assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID"))
91	assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING"))
92	assert.Contains(t, text, "Total images: 1 (dangling: 0)")
93}
94
95func TestRunImagesList_TruncatesLongValuesAndKeepsBoundedRowShape(t *testing.T) {
96	createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
97	longRepository := "registry.example.com/team/this-is-a-very-long-service-name-with-extra-segments/and/more/segments/than/anyone/should/reasonably/use"
98	longImageID := "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra-suffix-for-overflow"
99	client := &fakeImagesClient{
100		listImagesResp: []dto.Image{
101			{Repository: longRepository, Tag: "latest", Size: 12_000_000, Created: createdAt, ID: longImageID, Dangling: false},
102		},
103	}
104
105	var out bytes.Buffer
106	err := runImagesList(context.Background(), client, &out, false)
107	require.NoError(t, err)
108
109	text := stripANSI(out.String())
110	header := findHeaderLine(text)
111	assert.NotEmpty(t, header)
112	assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG"))
113	assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE"))
114	assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED"))
115	assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID"))
116	assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING"))
117
118	row := findImageListRowLine(text, "latest")
119	assert.NotEmpty(t, row)
120
121	cols := splitTableColumns(row)
122	require.Len(t, cols, 6)
123
124	repositoryField := cols[0]
125	imageIDField := cols[4]
126
127	assert.NotEqual(t, longRepository, repositoryField)
128	assert.NotEqual(t, longImageID, imageIDField)
129	assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis")
130	assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis")
131	assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "...")))
132	assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "...")))
133
134	assert.Contains(t, text, "Total images: 1 (dangling: 0)")
135}
136
137func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T) {
138	longRepository := "registry.example.com/very/long/repository/path/with/deep/nesting/and-digest-like-ref@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra"
139	longTag := "release-2026-02-12-build-metadata-with-a-very-very-long-tag-name"
140	longImageID := "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
141	client := &fakeImagesClient{
142		listImagesResp: []dto.Image{
143			{Repository: longRepository, Tag: longTag, Size: 0, Created: time.Time{}, ID: longImageID, Dangling: true},
144		},
145	}
146
147	var out bytes.Buffer
148	err := runImagesList(context.Background(), client, &out, false)
149	require.NoError(t, err)
150
151	text := stripANSI(out.String())
152	rows := findImageListRows(text)
153	require.Len(t, rows, 1, "expected a single, no-wrap data row")
154
155	cols := rows[0]
156	require.Len(t, cols, 6)
157
158	repositoryField := cols[0]
159	tagField := cols[1]
160	sizeField := cols[2]
161	createdField := cols[3]
162	imageIDField := cols[4]
163
164	assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis")
165	assert.True(t, strings.HasSuffix(tagField, "..."), "tag should be truncated with ellipsis")
166	assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis")
167	assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "...")))
168	assert.True(t, strings.HasPrefix(longTag, strings.TrimSuffix(tagField, "...")))
169	assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "...")))
170
171	assert.Equal(t, "0 B", sizeField)
172	assert.Equal(t, "-", createdField)
173	assert.Contains(t, text, "Total images: 1 (dangling: 1)")
174}
175
176func TestRunImagesList_EmptyOutput(t *testing.T) {
177	client := &fakeImagesClient{listImagesResp: []dto.Image{}}
178
179	var out bytes.Buffer
180	err := runImagesList(context.Background(), client, &out, false)
181	require.NoError(t, err)
182
183	text := out.String()
184	assert.Contains(t, text, "No images found")
185	assert.Contains(t, text, "Total images: 0")
186	assert.Equal(t, 1, client.listImagesCalls)
187}
188
189// ---------------------------------------------------------------------------
190// images prune: default scope behavior
191// ---------------------------------------------------------------------------
192
193func TestRunImagesPrune_DefaultScopeIsBothDanglingAndRegistry(t *testing.T) {
194	// No scope flags → both scopes enabled, keep-releases defaults to domain default.
195	client := &fakeImagesClient{
196		pruneResp: &dto.ImagePruneResponse{
197			Runtime:  dto.RuntimePruneResult{DeletedCount: 1, SpaceReclaimed: 512},
198			Registry: dto.RegistryPruneResult{TagsRemoved: 2, BlobsRemoved: 1, SpaceReclaimed: 1024},
199		},
200	}
201
202	var out bytes.Buffer
203	opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast, NoConfirm: true}
204	err := runImagesPrune(context.Background(), client, opts, &out)
205	require.NoError(t, err)
206
207	assert.Equal(t, 1, client.pruneCalls)
208	require.NotNil(t, client.lastPruneOpts.KeepLast)
209	assert.Equal(t, domain.DefaultImagePruneKeepLast, *client.lastPruneOpts.KeepLast)
210	require.NotNil(t, client.lastPruneOpts.PruneDangling)
211	assert.True(t, *client.lastPruneOpts.PruneDangling)
212	require.NotNil(t, client.lastPruneOpts.PruneRegistry)
213	assert.True(t, *client.lastPruneOpts.PruneRegistry)
214
215	text := out.String()
216	assert.Contains(t, text, "Runtime: deleted=1")
217	assert.Contains(t, text, "Registry: tags_removed=2")
218}
219
220func TestRunImagesPrune_DanglingOnlyScopeSkipsRegistry(t *testing.T) {
221	client := &fakeImagesClient{
222		pruneResp: &dto.ImagePruneResponse{
223			Runtime: dto.RuntimePruneResult{DeletedCount: 3, SpaceReclaimed: 2048},
224		},
225	}
226
227	var out bytes.Buffer
228	opts := imagesPruneOptions{Dangling: true, NoConfirm: true}
229	err := runImagesPrune(context.Background(), client, opts, &out)
230	require.NoError(t, err)
231
232	assert.Equal(t, 1, client.pruneCalls)
233	require.NotNil(t, client.lastPruneOpts.PruneDangling)
234	assert.True(t, *client.lastPruneOpts.PruneDangling)
235	require.NotNil(t, client.lastPruneOpts.PruneRegistry)
236	assert.False(t, *client.lastPruneOpts.PruneRegistry)
237
238	text := out.String()
239	assert.Contains(t, text, "Runtime: deleted=3")
240	assert.Contains(t, text, "Registry cleanup skipped")
241}
242
243func TestRunImagesPrune_RegistryOnlyScopeSkipsRuntime(t *testing.T) {
244	client := &fakeImagesClient{
245		pruneResp: &dto.ImagePruneResponse{
246			Registry: dto.RegistryPruneResult{TagsRemoved: 4, BlobsRemoved: 2, SpaceReclaimed: 4096},
247		},
248	}
249
250	var out bytes.Buffer
251	opts := imagesPruneOptions{Registry: true, KeepReleases: 5, NoConfirm: true}
252	err := runImagesPrune(context.Background(), client, opts, &out)
253	require.NoError(t, err)
254
255	assert.Equal(t, 1, client.pruneCalls)
256	require.NotNil(t, client.lastPruneOpts.PruneDangling)
257	assert.False(t, *client.lastPruneOpts.PruneDangling)
258	require.NotNil(t, client.lastPruneOpts.PruneRegistry)
259	assert.True(t, *client.lastPruneOpts.PruneRegistry)
260	require.NotNil(t, client.lastPruneOpts.KeepLast)
261	assert.Equal(t, 5, *client.lastPruneOpts.KeepLast)
262
263	text := out.String()
264	assert.Contains(t, text, "Runtime cleanup skipped")
265	assert.Contains(t, text, "Registry: tags_removed=4")
266}
267
268func TestRunImagesPrune_BothScopeFlagsExplicit(t *testing.T) {
269	client := &fakeImagesClient{
270		pruneResp: &dto.ImagePruneResponse{
271			Runtime:  dto.RuntimePruneResult{DeletedCount: 1, SpaceReclaimed: 100},
272			Registry: dto.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 0, SpaceReclaimed: 200},
273		},
274	}
275
276	var out bytes.Buffer
277	opts := imagesPruneOptions{Dangling: true, Registry: true, KeepReleases: 2, NoConfirm: true}
278	err := runImagesPrune(context.Background(), client, opts, &out)
279	require.NoError(t, err)
280
281	assert.Equal(t, 1, client.pruneCalls)
282	require.NotNil(t, client.lastPruneOpts.PruneDangling)
283	assert.True(t, *client.lastPruneOpts.PruneDangling)
284	require.NotNil(t, client.lastPruneOpts.PruneRegistry)
285	assert.True(t, *client.lastPruneOpts.PruneRegistry)
286	require.NotNil(t, client.lastPruneOpts.KeepLast)
287	assert.Equal(t, 2, *client.lastPruneOpts.KeepLast)
288
289	text := out.String()
290	assert.Contains(t, text, "Runtime: deleted=1")
291	assert.Contains(t, text, "Registry: tags_removed=1")
292}
293
294// ---------------------------------------------------------------------------
295// images prune: dry-run with scope flags
296// ---------------------------------------------------------------------------
297
298func TestRunImagesPrune_DryRunBothScopes(t *testing.T) {
299	client := &fakeImagesClient{
300		listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}},
301	}
302
303	var out bytes.Buffer
304	opts := imagesPruneOptions{DryRun: true, KeepReleases: 3}
305	err := runImagesPrune(context.Background(), client, opts, &out)
306	require.NoError(t, err)
307
308	text := out.String()
309	assert.Contains(t, text, "Dry run")
310	assert.Contains(t, text, "would prune 2 dangling runtime images")
311	assert.Contains(t, text, "would keep latest + 3 previous tags")
312	assert.Equal(t, 1, client.listImagesCalls)
313	assert.Equal(t, 0, client.pruneCalls)
314}
315
316func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) {
317	client := &fakeImagesClient{
318		listImagesResp: []dto.Image{{Dangling: true}},
319	}
320
321	var out bytes.Buffer
322	opts := imagesPruneOptions{DryRun: true, Dangling: true}
323	err := runImagesPrune(context.Background(), client, opts, &out)
324	require.NoError(t, err)
325
326	text := out.String()
327	assert.Contains(t, text, "would prune 1 dangling runtime images")
328	assert.Contains(t, text, "Registry cleanup skipped")
329	assert.Equal(t, 0, client.pruneCalls)
330}
331
332func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) {
333	client := &fakeImagesClient{
334		listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}},
335	}
336
337	var out bytes.Buffer
338	opts := imagesPruneOptions{DryRun: true, Registry: true, KeepReleases: 5}
339	err := runImagesPrune(context.Background(), client, opts, &out)
340	require.NoError(t, err)
341
342	text := out.String()
343	assert.Contains(t, text, "Runtime cleanup skipped")
344	assert.Contains(t, text, "would keep latest + 5 previous tags")
345	assert.Equal(t, 0, client.pruneCalls)
346}
347
348// ---------------------------------------------------------------------------
349// images prune: keep-releases semantics
350// ---------------------------------------------------------------------------
351
352func TestRunImagesPrune_KeepReleasesControlsRetention(t *testing.T) {
353	client := &fakeImagesClient{
354		pruneResp: &dto.ImagePruneResponse{
355			Runtime:  dto.RuntimePruneResult{DeletedCount: 0},
356			Registry: dto.RegistryPruneResult{TagsRemoved: 7, BlobsRemoved: 3, SpaceReclaimed: 9000},
357		},
358	}
359
360	var out bytes.Buffer
361	opts := imagesPruneOptions{KeepReleases: 10, NoConfirm: true}
362	err := runImagesPrune(context.Background(), client, opts, &out)
363	require.NoError(t, err)
364
365	require.NotNil(t, client.lastPruneOpts.KeepLast)
366	assert.Equal(t, 10, *client.lastPruneOpts.KeepLast)
367}
368
369func TestRunImagesPrune_RejectsNegativeKeepReleases(t *testing.T) {
370	err := runImagesPrune(context.Background(), &fakeImagesClient{}, imagesPruneOptions{KeepReleases: -1}, &bytes.Buffer{})
371	require.Error(t, err)
372	assert.Contains(t, err.Error(), "--keep-releases must be >= 0")
373}
374
375// ---------------------------------------------------------------------------
376// images prune: confirmation behavior
377// ---------------------------------------------------------------------------
378
379func TestRunImagesPrune_NonDryRunCallsConfirm(t *testing.T) {
380	confirmCalled := false
381	origConfirm := pruneConfirmFunc
382	pruneConfirmFunc = func(_ string) (bool, error) {
383		confirmCalled = true
384		return true, nil
385	}
386	t.Cleanup(func() { pruneConfirmFunc = origConfirm })
387
388	client := &fakeImagesClient{
389		pruneResp: &dto.ImagePruneResponse{
390			Runtime:  dto.RuntimePruneResult{DeletedCount: 1},
391			Registry: dto.RegistryPruneResult{TagsRemoved: 1},
392		},
393	}
394
395	var out bytes.Buffer
396	opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast}
397	err := runImagesPrune(context.Background(), client, opts, &out)
398	require.NoError(t, err)
399
400	assert.True(t, confirmCalled, "confirmation should have been called")
401	assert.Equal(t, 1, client.pruneCalls)
402}
403
404func TestRunImagesPrune_ConfirmPromptIncludesScopePreviewCounts(t *testing.T) {
405	created := time.Date(2026, 2, 13, 7, 0, 0, 0, time.UTC)
406	var prompt string
407	origConfirm := pruneConfirmFunc
408	pruneConfirmFunc = func(value string) (bool, error) {
409		prompt = value
410		return false, nil
411	}
412	t.Cleanup(func() { pruneConfirmFunc = origConfirm })
413
414	client := &fakeImagesClient{
415		listImagesResp: []dto.Image{
416			{Repository: "registry.example.com/app", Tag: "latest", Created: created},
417			{Repository: "registry.example.com/app", Tag: "v3", Created: created.Add(-1 * time.Hour)},
418			{Repository: "registry.example.com/app", Tag: "v2", Created: created.Add(-2 * time.Hour)},
419			{Repository: "registry.example.com/app", Tag: "v1", Created: created.Add(-3 * time.Hour)},
420			{Repository: "<none>", Tag: "<none>", Dangling: true},
421		},
422	}
423
424	var out bytes.Buffer
425	opts := imagesPruneOptions{KeepReleases: 2}
426	err := runImagesPrune(context.Background(), client, opts, &out)
427	require.NoError(t, err)
428
429	assert.Contains(t, prompt, "Runtime: would prune 1 dangling runtime images")
430	assert.Contains(t, prompt, "Registry: would prune 1 tags")
431	assert.Equal(t, 1, client.listImagesCalls)
432	assert.Equal(t, 0, client.pruneCalls)
433}
434
435func TestRunImagesPrune_ConfirmRejectCancelsOperation(t *testing.T) {
436	origConfirm := pruneConfirmFunc
437	pruneConfirmFunc = func(_ string) (bool, error) {
438		return false, nil
439	}
440	t.Cleanup(func() { pruneConfirmFunc = origConfirm })
441
442	client := &fakeImagesClient{pruneResp: &dto.ImagePruneResponse{}}
443
444	var out bytes.Buffer
445	opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast}
446	err := runImagesPrune(context.Background(), client, opts, &out)
447	require.NoError(t, err)
448
449	assert.Equal(t, 0, client.pruneCalls, "prune should not be called after rejection")
450	assert.Contains(t, out.String(), "cancelled")
451}
452
453func TestRunImagesPrune_NoConfirmSkipsPrompt(t *testing.T) {
454	confirmCalled := false
455	origConfirm := pruneConfirmFunc
456	pruneConfirmFunc = func(_ string) (bool, error) {
457		confirmCalled = true
458		return true, nil
459	}
460	t.Cleanup(func() { pruneConfirmFunc = origConfirm })
461
462	client := &fakeImagesClient{
463		pruneResp: &dto.ImagePruneResponse{
464			Runtime: dto.RuntimePruneResult{DeletedCount: 1},
465		},
466	}
467
468	var out bytes.Buffer
469	opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast, NoConfirm: true}
470	err := runImagesPrune(context.Background(), client, opts, &out)
471	require.NoError(t, err)
472
473	assert.False(t, confirmCalled, "confirmation should not be called with --no-confirm")
474	assert.Equal(t, 1, client.pruneCalls)
475}
476
477func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) {
478	confirmCalled := false
479	origConfirm := pruneConfirmFunc
480	pruneConfirmFunc = func(_ string) (bool, error) {
481		confirmCalled = true
482		return true, nil
483	}
484	t.Cleanup(func() { pruneConfirmFunc = origConfirm })
485
486	client := &fakeImagesClient{
487		listImagesResp: []dto.Image{{Dangling: true}},
488	}
489
490	var out bytes.Buffer
491	opts := imagesPruneOptions{DryRun: true, KeepReleases: 3}
492	err := runImagesPrune(context.Background(), client, opts, &out)
493	require.NoError(t, err)
494
495	assert.False(t, confirmCalled, "confirmation should not be called during dry-run")
496}
497
498// ---------------------------------------------------------------------------
499// images prune: error handling
500// ---------------------------------------------------------------------------
501
502func TestRunImagesPrune_ReturnsRemoteErrors(t *testing.T) {
503	origConfirm := pruneConfirmFunc
504	pruneConfirmFunc = func(_ string) (bool, error) { return true, nil }
505	t.Cleanup(func() { pruneConfirmFunc = origConfirm })
506
507	client := &fakeImagesClient{pruneErr: errors.New("request failed")}
508
509	err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 2}, &bytes.Buffer{})
510	require.Error(t, err)
511	assert.Contains(t, err.Error(), "failed to prune images")
512}
513
514func TestRunImagesPrune_RejectsEmptyResponse(t *testing.T) {
515	origConfirm := pruneConfirmFunc
516	pruneConfirmFunc = func(_ string) (bool, error) { return true, nil }
517	t.Cleanup(func() { pruneConfirmFunc = origConfirm })
518
519	client := &fakeImagesClient{pruneResp: nil}
520	err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 1}, &bytes.Buffer{})
521	require.Error(t, err)
522	assert.Contains(t, err.Error(), "empty response")
523}
524
525// ---------------------------------------------------------------------------
526// test helpers
527// ---------------------------------------------------------------------------
528
529func stripANSI(value string) string {
530	ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
531	return ansiRegex.ReplaceAllString(value, "")
532}
533
534func findHeaderLine(text string) string {
535	for _, line := range strings.Split(text, "\n") {
536		if strings.Contains(line, "REPOSITORY") && strings.Contains(line, "DANGLING") {
537			return line
538		}
539	}
540
541	return ""
542}
543
544func findImageListRowLine(text string, tag string) string {
545	for _, line := range strings.Split(text, "\n") {
546		trimmed := strings.TrimSpace(line)
547		if trimmed == "" {
548			continue
549		}
550		if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") {
551			continue
552		}
553
554		cols := splitTableColumns(line)
555		if len(cols) == 6 && cols[1] == tag {
556			return line
557		}
558	}
559
560	return ""
561}
562
563func findImageListRows(text string) [][]string {
564	rows := make([][]string, 0)
565	for _, line := range strings.Split(text, "\n") {
566		trimmed := strings.TrimSpace(line)
567		if trimmed == "" {
568			continue
569		}
570		if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") {
571			continue
572		}
573
574		cols := splitTableColumns(line)
575		if len(cols) == 6 && cols[0] != "REPOSITORY" {
576			rows = append(rows, cols)
577		}
578	}
579
580	return rows
581}
582
583func splitTableColumns(line string) []string {
584	if strings.Contains(line, "│") {
585		parts := strings.Split(line, "│")
586		cols := make([]string, 0, len(parts))
587		for _, part := range parts {
588			cell := strings.TrimSpace(part)
589			if cell != "" {
590				cols = append(cols, cell)
591			}
592		}
593		return cols
594	}
595
596	return regexp.MustCompile(`\s{2,}`).Split(strings.TrimSpace(line), -1)
597}

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 106

↑↓ moveenter openesc close