detached 2bb8b58

Merge pull request #156 from bnema/refactor/human-readable-sizes

refactor: add bytesize.Format helper, use human-readable sizes everywhere
commit
2bb8b5832c9adc0b85ed6f34a60ee7333956c2cb
author
bnema · · 2026-04-03 18:00 UTC
committer
GitHub · · 2026-04-03 18:00 UTC
merge of
d913a6e + 64b95f6 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +41 -28
M internal/adapters/in/cli/backup.go +3 -2
@@ -8,6 +8,7 @@ import (
88 "time"
99
1010 "github.com/bnema/gordon/internal/adapters/dto"
11+ "github.com/bnema/gordon/pkg/bytesize"
1112 "github.com/spf13/cobra"
1213 )
1314
@@ -120,10 +121,10 @@ func newBackupRunCmd() *cobra.Command {
120121 }
121122
122123 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
123- if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE_BYTES"); err != nil {
124+ if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE"); err != nil {
124125 return err
125126 }
126- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n", result.Backup.Domain, result.Backup.DBName, result.Backup.Status, formatBackupTime(result.Backup.StartedAt), result.Backup.ID, result.Backup.SizeBytes); err != nil {
127+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", result.Backup.Domain, result.Backup.DBName, result.Backup.Status, formatBackupTime(result.Backup.StartedAt), result.Backup.ID, bytesize.Format(result.Backup.SizeBytes)); err != nil {
127128 return err
128129 }
129130 if err := w.Flush(); err != nil {
@@ -8,6 +8,7 @@ import (
8 "time"8 "time"
9 9
10 "github.com/bnema/gordon/internal/adapters/dto"10 "github.com/bnema/gordon/internal/adapters/dto"
11+ "github.com/bnema/gordon/pkg/bytesize"
11 "github.com/spf13/cobra"12 "github.com/spf13/cobra"
12 )13 )
13 14
@@ -120,10 +121,10 @@ func newBackupRunCmd() *cobra.Command {
120 }121 }
121 122
122 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)123 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
123- if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE_BYTES"); err != nil {124+ if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE"); err != nil {
124 return err125 return err
125 }126 }
126- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n", result.Backup.Domain, result.Backup.DBName, result.Backup.Status, formatBackupTime(result.Backup.StartedAt), result.Backup.ID, result.Backup.SizeBytes); err != nil {127+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", result.Backup.Domain, result.Backup.DBName, result.Backup.Status, formatBackupTime(result.Backup.StartedAt), result.Backup.ID, bytesize.Format(result.Backup.SizeBytes)); err != nil {
127 return err128 return err
128 }129 }
129 if err := w.Flush(); err != nil {130 if err := w.Flush(); err != nil {
M internal/adapters/in/cli/images.go +4 -20
@@ -15,6 +15,7 @@ import (
1515 "github.com/bnema/gordon/internal/adapters/dto"
1616 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
1717 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/pkg/bytesize"
1819 )
1920
2021 type imagesClient interface {
@@ -301,7 +302,7 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer, json
301302 rows = append(rows, []string{
302303 img.Repository,
303304 img.Tag,
304- formatImageSize(img.Size),
305+ bytesize.Format(img.Size),
305306 formatImageCreatedAt(img.Created),
306307 formatImageID(img.ID),
307308 dangling,
@@ -361,7 +362,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
361362 }
362363
363364 if pruneDangling {
364- if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil {
365+ if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%s\n", resp.Runtime.DeletedCount, bytesize.Format(resp.Runtime.SpaceReclaimed)); err != nil {
365366 return err
366367 }
367368 } else {
@@ -371,7 +372,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
371372 }
372373
373374 if pruneRegistry {
374- err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed)
375+ err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%s\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, bytesize.Format(resp.Registry.SpaceReclaimed))
375376 return err
376377 }
377378
@@ -569,23 +570,6 @@ func formatImageCreatedAt(t time.Time) string {
569570 return t.UTC().Format("2006-01-02T15:04:05Z")
570571 }
571572
572-func formatImageSize(size int64) string {
573- if size <= 0 {
574- return "-"
575- }
576-
577- const unit = 1024
578- if size < unit {
579- return fmt.Sprintf("%d B", size)
580- }
581- div, exp := int64(unit), 0
582- for n := size / unit; n >= unit; n /= unit {
583- div *= unit
584- exp++
585- }
586- return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
587-}
588-
589573 func formatImageID(id string) string {
590574 if strings.TrimSpace(id) == "" {
591575 return "-"
@@ -15,6 +15,7 @@ import (
15 "github.com/bnema/gordon/internal/adapters/dto"15 "github.com/bnema/gordon/internal/adapters/dto"
16 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"16 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
17 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/pkg/bytesize"
18 )19 )
19 20
20 type imagesClient interface {21 type imagesClient interface {
@@ -301,7 +302,7 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer, json
301 rows = append(rows, []string{302 rows = append(rows, []string{
302 img.Repository,303 img.Repository,
303 img.Tag,304 img.Tag,
304- formatImageSize(img.Size),305+ bytesize.Format(img.Size),
305 formatImageCreatedAt(img.Created),306 formatImageCreatedAt(img.Created),
306 formatImageID(img.ID),307 formatImageID(img.ID),
307 dangling,308 dangling,
@@ -361,7 +362,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
361 }362 }
362 363
363 if pruneDangling {364 if pruneDangling {
364- if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil {365+ if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%s\n", resp.Runtime.DeletedCount, bytesize.Format(resp.Runtime.SpaceReclaimed)); err != nil {
365 return err366 return err
366 }367 }
367 } else {368 } else {
@@ -371,7 +372,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
371 }372 }
372 373
373 if pruneRegistry {374 if pruneRegistry {
374- err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed)375+ err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%s\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, bytesize.Format(resp.Registry.SpaceReclaimed))
375 return err376 return err
376 }377 }
377 378
@@ -569,23 +570,6 @@ func formatImageCreatedAt(t time.Time) string {
569 return t.UTC().Format("2006-01-02T15:04:05Z")570 return t.UTC().Format("2006-01-02T15:04:05Z")
570 }571 }
571 572
572-func formatImageSize(size int64) string {
573- if size <= 0 {
574- return "-"
575- }
576-
577- const unit = 1024
578- if size < unit {
579- return fmt.Sprintf("%d B", size)
580- }
581- div, exp := int64(unit), 0
582- for n := size / unit; n >= unit; n /= unit {
583- div *= unit
584- exp++
585- }
586- return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
587-}
588-
589 func formatImageID(id string) string {573 func formatImageID(id string) string {
590 if strings.TrimSpace(id) == "" {574 if strings.TrimSpace(id) == "" {
591 return "-"575 return "-"
M internal/adapters/in/cli/images_test.go +1 -1
@@ -168,7 +168,7 @@ func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T)
168168 assert.True(t, strings.HasPrefix(longTag, strings.TrimSuffix(tagField, "...")))
169169 assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "...")))
170170
171- assert.Equal(t, "-", sizeField)
171+ assert.Equal(t, "0 B", sizeField)
172172 assert.Equal(t, "-", createdField)
173173 assert.Contains(t, text, "Total images: 1 (dangling: 1)")
174174 }
@@ -168,7 +168,7 @@ func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T)
168 assert.True(t, strings.HasPrefix(longTag, strings.TrimSuffix(tagField, "...")))168 assert.True(t, strings.HasPrefix(longTag, strings.TrimSuffix(tagField, "...")))
169 assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "...")))169 assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "...")))
170 170
171- assert.Equal(t, "-", sizeField)171+ assert.Equal(t, "0 B", sizeField)
172 assert.Equal(t, "-", createdField)172 assert.Equal(t, "-", createdField)
173 assert.Contains(t, text, "Total images: 1 (dangling: 1)")173 assert.Contains(t, text, "Total images: 1 (dangling: 1)")
174 }174 }
M internal/adapters/in/cli/volumes.go +5 -4
@@ -9,6 +9,7 @@ import (
99
1010 "github.com/bnema/gordon/internal/adapters/dto"
1111 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
12+ "github.com/bnema/gordon/pkg/bytesize"
1213 )
1314
1415 type volumesClient interface {
@@ -98,7 +99,7 @@ func runVolumesList(ctx context.Context, client volumesClient, out io.Writer, js
9899 if len(v.Containers) > 0 {
99100 containers = fmt.Sprintf("%v", v.Containers)
100101 }
101- if err := cliWritef(out, " %-30s %-10s %-10s %s\n", v.Name, status, formatImageSize(v.Size), containers); err != nil {
102+ if err := cliWritef(out, " %-30s %-10s %-10s %s\n", v.Name, status, bytesize.Format(v.Size), containers); err != nil {
102103 return err
103104 }
104105 }
@@ -119,11 +120,11 @@ func renderPrunePreview(out io.Writer, resp *dto.VolumePruneResponse) error {
119120 return err
120121 }
121122 for _, v := range resp.Volumes {
122- if err := cliWritef(out, " %s (%s)\n", v.Name, formatImageSize(v.Size)); err != nil {
123+ if err := cliWritef(out, " %s (%s)\n", v.Name, bytesize.Format(v.Size)); err != nil {
123124 return err
124125 }
125126 }
126- return cliWritef(out, "\n%d volumes, %s total\n", resp.VolumesRemoved, formatImageSize(resp.SpaceReclaimed))
127+ return cliWritef(out, "\n%d volumes, %s total\n", resp.VolumesRemoved, bytesize.Format(resp.SpaceReclaimed))
127128 }
128129
129130 func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPruneOptions, out io.Writer) error {
@@ -171,5 +172,5 @@ func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPrun
171172 return writeJSON(out, result)
172173 }
173174
174- return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Removed %d volumes, reclaimed %s", result.VolumesRemoved, formatImageSize(result.SpaceReclaimed))))
175+ return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Removed %d volumes, reclaimed %s", result.VolumesRemoved, bytesize.Format(result.SpaceReclaimed))))
175176 }
@@ -9,6 +9,7 @@ import (
9 9
10 "github.com/bnema/gordon/internal/adapters/dto"10 "github.com/bnema/gordon/internal/adapters/dto"
11 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"11 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
12+ "github.com/bnema/gordon/pkg/bytesize"
12 )13 )
13 14
14 type volumesClient interface {15 type volumesClient interface {
@@ -98,7 +99,7 @@ func runVolumesList(ctx context.Context, client volumesClient, out io.Writer, js
98 if len(v.Containers) > 0 {99 if len(v.Containers) > 0 {
99 containers = fmt.Sprintf("%v", v.Containers)100 containers = fmt.Sprintf("%v", v.Containers)
100 }101 }
101- if err := cliWritef(out, " %-30s %-10s %-10s %s\n", v.Name, status, formatImageSize(v.Size), containers); err != nil {102+ if err := cliWritef(out, " %-30s %-10s %-10s %s\n", v.Name, status, bytesize.Format(v.Size), containers); err != nil {
102 return err103 return err
103 }104 }
104 }105 }
@@ -119,11 +120,11 @@ func renderPrunePreview(out io.Writer, resp *dto.VolumePruneResponse) error {
119 return err120 return err
120 }121 }
121 for _, v := range resp.Volumes {122 for _, v := range resp.Volumes {
122- if err := cliWritef(out, " %s (%s)\n", v.Name, formatImageSize(v.Size)); err != nil {123+ if err := cliWritef(out, " %s (%s)\n", v.Name, bytesize.Format(v.Size)); err != nil {
123 return err124 return err
124 }125 }
125 }126 }
126- return cliWritef(out, "\n%d volumes, %s total\n", resp.VolumesRemoved, formatImageSize(resp.SpaceReclaimed))127+ return cliWritef(out, "\n%d volumes, %s total\n", resp.VolumesRemoved, bytesize.Format(resp.SpaceReclaimed))
127 }128 }
128 129
129 func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPruneOptions, out io.Writer) error {130 func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPruneOptions, out io.Writer) error {
@@ -171,5 +172,5 @@ func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPrun
171 return writeJSON(out, result)172 return writeJSON(out, result)
172 }173 }
173 174
174- return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Removed %d volumes, reclaimed %s", result.VolumesRemoved, formatImageSize(result.SpaceReclaimed))))175+ return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Removed %d volumes, reclaimed %s", result.VolumesRemoved, bytesize.Format(result.SpaceReclaimed))))
175 }176 }
M pkg/bytesize/parse.go +28 -1
@@ -1,4 +1,4 @@
1-// Package bytesize provides human-friendly byte size parsing.
1+// Package bytesize provides human-friendly byte size parsing and formatting.
22 package bytesize
33
44 import (
@@ -17,6 +17,33 @@ var unitMultipliers = map[string]int64{
1717 "TB": 1 << 40, // 1099511627776
1818 }
1919
20+// Format converts a byte count into a human-friendly string.
21+//
22+// Uses binary (1024-based) units: B, KB, MB, GB, TB.
23+// Returns one decimal place for KB and above.
24+//
25+// Examples:
26+//
27+// Format(512) // "512 B"
28+// Format(1048576) // "1.0 MB"
29+// Format(3609629152) // "3.4 GB"
30+func Format(bytes int64) string {
31+ if bytes <= 0 {
32+ return "0 B"
33+ }
34+ units := []string{"B", "KB", "MB", "GB", "TB"}
35+ size := float64(bytes)
36+ unitIdx := 0
37+ for size >= 1024 && unitIdx < len(units)-1 {
38+ size /= 1024
39+ unitIdx++
40+ }
41+ if unitIdx == 0 {
42+ return fmt.Sprintf("%d B", bytes)
43+ }
44+ return fmt.Sprintf("%.1f %s", size, units[unitIdx])
45+}
46+
2047 // Parse parses a human-friendly byte size string.
2148 //
2249 // Supported units: B, KB, MB, GB, TB (case-insensitive).
@@ -1,4 +1,4 @@
1-// Package bytesize provides human-friendly byte size parsing.1+// Package bytesize provides human-friendly byte size parsing and formatting.
2 package bytesize2 package bytesize
3 3
4 import (4 import (
@@ -17,6 +17,33 @@ var unitMultipliers = map[string]int64{
17 "TB": 1 << 40, // 109951162777617 "TB": 1 << 40, // 1099511627776
18 }18 }
19 19
20+// Format converts a byte count into a human-friendly string.
21+//
22+// Uses binary (1024-based) units: B, KB, MB, GB, TB.
23+// Returns one decimal place for KB and above.
24+//
25+// Examples:
26+//
27+// Format(512) // "512 B"
28+// Format(1048576) // "1.0 MB"
29+// Format(3609629152) // "3.4 GB"
30+func Format(bytes int64) string {
31+ if bytes <= 0 {
32+ return "0 B"
33+ }
34+ units := []string{"B", "KB", "MB", "GB", "TB"}
35+ size := float64(bytes)
36+ unitIdx := 0
37+ for size >= 1024 && unitIdx < len(units)-1 {
38+ size /= 1024
39+ unitIdx++
40+ }
41+ if unitIdx == 0 {
42+ return fmt.Sprintf("%d B", bytes)
43+ }
44+ return fmt.Sprintf("%.1f %s", size, units[unitIdx])
45+}
46+
20 // Parse parses a human-friendly byte size string.47 // Parse parses a human-friendly byte size string.
21 //48 //
22 // Supported units: B, KB, MB, GB, TB (case-insensitive).49 // Supported units: B, KB, MB, GB, TB (case-insensitive).

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 7

Tags 106

↑↓ moveenter openesc close