detached 3679601

refactor(cli): unify non-interactive command presentation

Route root, backup, and images command output through shared presentation helpers and add UI adoption guardrails to prevent regressions in styling usage.
commit
3679601727de6d8ac0cee16623e03cace0d1ce0d
author
brice · · 2026-02-12 17:38 UTC
parent
83ea58e
signature
unsigned
7 files · +404 -31
M internal/adapters/in/cli/backup.go +21 -3
@@ -52,9 +52,14 @@ func newBackupListCmd() *cobra.Command {
5252 }
5353
5454 if len(jobs) == 0 {
55- fmt.Println("No backups found")
55+ if err := cliWriteLine(os.Stdout, cliRenderMuted("No backups found")); err != nil {
56+ return err
57+ }
5658 return nil
5759 }
60+ if err := cliWriteLine(os.Stdout, cliRenderTitle("Backups")); err != nil {
61+ return err
62+ }
5863 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
5964 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {
6065 return err
@@ -96,6 +101,9 @@ func newBackupRunCmd() *cobra.Command {
96101 if result.Backup == nil {
97102 return fmt.Errorf("backup run completed without backup payload")
98103 }
104+ if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Result")); err != nil {
105+ return err
106+ }
99107
100108 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
101109 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE_BYTES"); err != nil {
@@ -133,9 +141,14 @@ func newBackupDetectCmd() *cobra.Command {
133141 }
134142
135143 if len(dbs) == 0 {
136- fmt.Println("No supported databases detected")
144+ if err := cliWriteLine(os.Stdout, cliRenderMuted("No supported databases detected")); err != nil {
145+ return err
146+ }
137147 return nil
138148 }
149+ if err := cliWriteLine(os.Stdout, cliRenderTitle("Detected Databases")); err != nil {
150+ return err
151+ }
139152 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
140153 if _, err := fmt.Fprintln(w, "NAME\tTYPE\tHOST\tPORT\tIMAGE"); err != nil {
141154 return err
@@ -172,9 +185,14 @@ func newBackupStatusCmd() *cobra.Command {
172185 }
173186
174187 if len(jobs) == 0 {
175- fmt.Println("No backup status available")
188+ if err := cliWriteLine(os.Stdout, cliRenderMuted("No backup status available")); err != nil {
189+ return err
190+ }
176191 return nil
177192 }
193+ if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Status")); err != nil {
194+ return err
195+ }
178196 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
179197 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT"); err != nil {
180198 return err
@@ -52,9 +52,14 @@ func newBackupListCmd() *cobra.Command {
52 }52 }
53 53
54 if len(jobs) == 0 {54 if len(jobs) == 0 {
55- fmt.Println("No backups found")55+ if err := cliWriteLine(os.Stdout, cliRenderMuted("No backups found")); err != nil {
56+ return err
57+ }
56 return nil58 return nil
57 }59 }
60+ if err := cliWriteLine(os.Stdout, cliRenderTitle("Backups")); err != nil {
61+ return err
62+ }
58 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)63 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
59 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {64 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {
60 return err65 return err
@@ -96,6 +101,9 @@ func newBackupRunCmd() *cobra.Command {
96 if result.Backup == nil {101 if result.Backup == nil {
97 return fmt.Errorf("backup run completed without backup payload")102 return fmt.Errorf("backup run completed without backup payload")
98 }103 }
104+ if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Result")); err != nil {
105+ return err
106+ }
99 107
100 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)108 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
101 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE_BYTES"); err != nil {109 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE_BYTES"); err != nil {
@@ -133,9 +141,14 @@ func newBackupDetectCmd() *cobra.Command {
133 }141 }
134 142
135 if len(dbs) == 0 {143 if len(dbs) == 0 {
136- fmt.Println("No supported databases detected")144+ if err := cliWriteLine(os.Stdout, cliRenderMuted("No supported databases detected")); err != nil {
145+ return err
146+ }
137 return nil147 return nil
138 }148 }
149+ if err := cliWriteLine(os.Stdout, cliRenderTitle("Detected Databases")); err != nil {
150+ return err
151+ }
139 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)152 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
140 if _, err := fmt.Fprintln(w, "NAME\tTYPE\tHOST\tPORT\tIMAGE"); err != nil {153 if _, err := fmt.Fprintln(w, "NAME\tTYPE\tHOST\tPORT\tIMAGE"); err != nil {
141 return err154 return err
@@ -172,9 +185,14 @@ func newBackupStatusCmd() *cobra.Command {
172 }185 }
173 186
174 if len(jobs) == 0 {187 if len(jobs) == 0 {
175- fmt.Println("No backup status available")188+ if err := cliWriteLine(os.Stdout, cliRenderMuted("No backup status available")); err != nil {
189+ return err
190+ }
176 return nil191 return nil
177 }192 }
193+ if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Status")); err != nil {
194+ return err
195+ }
178 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)196 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
179 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT"); err != nil {197 if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT"); err != nil {
180 return err198 return err
M internal/adapters/in/cli/images.go +15 -11
@@ -84,10 +84,14 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro
8484 }
8585
8686 if len(images) == 0 {
87- if _, err := fmt.Fprintln(out, "No images found"); err != nil {
87+ if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil {
8888 return err
8989 }
90- _, err := fmt.Fprintln(out, "Total images: 0 (dangling: 0)")
90+ err := cliWriteLine(out, cliRenderInfo("Total images: 0 (dangling: 0)"))
91+ return err
92+ }
93+
94+ if err := cliWriteLine(out, cliRenderTitle("Images")); err != nil {
9195 return err
9296 }
9397
@@ -113,7 +117,7 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro
113117 return err
114118 }
115119
116- _, err = fmt.Fprintf(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount)
120+ err = cliWritef(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount)
117121 return err
118122 }
119123
@@ -140,22 +144,22 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
140144 }
141145 }
142146
143- if _, err := fmt.Fprintln(out, "Dry run: no changes applied"); err != nil {
147+ if err := cliWriteLine(out, cliRenderWarning("Dry run: no changes applied")); err != nil {
144148 return err
145149 }
146- if _, err := fmt.Fprintf(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil {
150+ if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil {
147151 return err
148152 }
149153 if opts.RuntimeOnly {
150- _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)")
154+ err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--runtime-only)"))
151155 return err
152156 }
153157 if keepLast == 0 {
154- _, err = fmt.Fprintln(out, "Registry cleanup skipped (--keep=0)")
158+ err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--keep=0)"))
155159 return err
156160 }
157161
158- _, err = fmt.Fprintf(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast)
162+ err = cliWritef(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast)
159163 return err
160164 }
161165
@@ -167,16 +171,16 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
167171 return fmt.Errorf("failed to prune images: empty response")
168172 }
169173
170- if _, err := fmt.Fprintf(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil {
174+ if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil {
171175 return err
172176 }
173177
174178 if opts.RuntimeOnly {
175- _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)")
179+ err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--runtime-only)"))
176180 return err
177181 }
178182
179- _, err = fmt.Fprintf(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed)
183+ err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed)
180184 return err
181185 }
182186
@@ -84,10 +84,14 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro
84 }84 }
85 85
86 if len(images) == 0 {86 if len(images) == 0 {
87- if _, err := fmt.Fprintln(out, "No images found"); err != nil {87+ if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil {
88 return err88 return err
89 }89 }
90- _, err := fmt.Fprintln(out, "Total images: 0 (dangling: 0)")90+ err := cliWriteLine(out, cliRenderInfo("Total images: 0 (dangling: 0)"))
91+ return err
92+ }
93+
94+ if err := cliWriteLine(out, cliRenderTitle("Images")); err != nil {
91 return err95 return err
92 }96 }
93 97
@@ -113,7 +117,7 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro
113 return err117 return err
114 }118 }
115 119
116- _, err = fmt.Fprintf(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount)120+ err = cliWritef(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount)
117 return err121 return err
118 }122 }
119 123
@@ -140,22 +144,22 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
140 }144 }
141 }145 }
142 146
143- if _, err := fmt.Fprintln(out, "Dry run: no changes applied"); err != nil {147+ if err := cliWriteLine(out, cliRenderWarning("Dry run: no changes applied")); err != nil {
144 return err148 return err
145 }149 }
146- if _, err := fmt.Fprintf(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil {150+ if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil {
147 return err151 return err
148 }152 }
149 if opts.RuntimeOnly {153 if opts.RuntimeOnly {
150- _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)")154+ err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--runtime-only)"))
151 return err155 return err
152 }156 }
153 if keepLast == 0 {157 if keepLast == 0 {
154- _, err = fmt.Fprintln(out, "Registry cleanup skipped (--keep=0)")158+ err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--keep=0)"))
155 return err159 return err
156 }160 }
157 161
158- _, err = fmt.Fprintf(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast)162+ err = cliWritef(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast)
159 return err163 return err
160 }164 }
161 165
@@ -167,16 +171,16 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
167 return fmt.Errorf("failed to prune images: empty response")171 return fmt.Errorf("failed to prune images: empty response")
168 }172 }
169 173
170- if _, err := fmt.Fprintf(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil {174+ if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil {
171 return err175 return err
172 }176 }
173 177
174 if opts.RuntimeOnly {178 if opts.RuntimeOnly {
175- _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)")179+ err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--runtime-only)"))
176 return err180 return err
177 }181 }
178 182
179- _, err = fmt.Fprintf(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed)183+ err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed)
180 return err184 return err
181 }185 }
182 186
M internal/adapters/in/cli/parity_matrix_test.go +52 -0
@@ -6,6 +6,35 @@ import (
66 "testing"
77 )
88
9+type uiAdoptionExpectation struct {
10+ family string
11+ file string
12+ functions []string
13+}
14+
15+var uiAdoptionExpectations = []uiAdoptionExpectation{
16+ {
17+ family: "server",
18+ file: "serve.go",
19+ functions: []string{"newStartCmd"},
20+ },
21+ {
22+ family: "root/server",
23+ file: "root.go",
24+ functions: []string{"newVersionCmd", "runReloadRemote", "runLogsRemote", "streamLogsRemote", "showContainerLogsLocal"},
25+ },
26+ {
27+ family: "backups",
28+ file: "backup.go",
29+ functions: []string{"newBackupListCmd", "newBackupRunCmd", "newBackupDetectCmd", "newBackupStatusCmd"},
30+ },
31+ {
32+ family: "images",
33+ file: "images.go",
34+ functions: []string{"runImagesList", "runImagesPrune"},
35+ },
36+}
37+
938 func TestLocalParityMatrix(t *testing.T) {
1039 checks := []struct {
1140 file string
@@ -29,3 +58,26 @@ func TestLocalParityMatrix(t *testing.T) {
2958 }
3059 }
3160 }
61+
62+func TestUIAdoptionMatrixCoverage(t *testing.T) {
63+ for _, expect := range uiAdoptionExpectations {
64+ expect := expect
65+ t.Run(expect.family, func(t *testing.T) {
66+ if len(expect.functions) == 0 {
67+ t.Fatalf("ui adoption matrix for %s is empty", expect.family)
68+ }
69+
70+ content, err := os.ReadFile(expect.file)
71+ if err != nil {
72+ t.Fatalf("failed to read %s: %v", expect.file, err)
73+ }
74+
75+ source := string(content)
76+ for _, fn := range expect.functions {
77+ if !strings.Contains(source, "func "+fn+"(") {
78+ t.Fatalf("ui adoption matrix references missing function %s in %s", fn, expect.file)
79+ }
80+ }
81+ })
82+ }
83+}
@@ -6,6 +6,35 @@ import (
6 "testing"6 "testing"
7 )7 )
8 8
9+type uiAdoptionExpectation struct {
10+ family string
11+ file string
12+ functions []string
13+}
14+
15+var uiAdoptionExpectations = []uiAdoptionExpectation{
16+ {
17+ family: "server",
18+ file: "serve.go",
19+ functions: []string{"newStartCmd"},
20+ },
21+ {
22+ family: "root/server",
23+ file: "root.go",
24+ functions: []string{"newVersionCmd", "runReloadRemote", "runLogsRemote", "streamLogsRemote", "showContainerLogsLocal"},
25+ },
26+ {
27+ family: "backups",
28+ file: "backup.go",
29+ functions: []string{"newBackupListCmd", "newBackupRunCmd", "newBackupDetectCmd", "newBackupStatusCmd"},
30+ },
31+ {
32+ family: "images",
33+ file: "images.go",
34+ functions: []string{"runImagesList", "runImagesPrune"},
35+ },
36+}
37+
9 func TestLocalParityMatrix(t *testing.T) {38 func TestLocalParityMatrix(t *testing.T) {
10 checks := []struct {39 checks := []struct {
11 file string40 file string
@@ -29,3 +58,26 @@ func TestLocalParityMatrix(t *testing.T) {
29 }58 }
30 }59 }
31 }60 }
61+
62+func TestUIAdoptionMatrixCoverage(t *testing.T) {
63+ for _, expect := range uiAdoptionExpectations {
64+ expect := expect
65+ t.Run(expect.family, func(t *testing.T) {
66+ if len(expect.functions) == 0 {
67+ t.Fatalf("ui adoption matrix for %s is empty", expect.family)
68+ }
69+
70+ content, err := os.ReadFile(expect.file)
71+ if err != nil {
72+ t.Fatalf("failed to read %s: %v", expect.file, err)
73+ }
74+
75+ source := string(content)
76+ for _, fn := range expect.functions {
77+ if !strings.Contains(source, "func "+fn+"(") {
78+ t.Fatalf("ui adoption matrix references missing function %s in %s", fn, expect.file)
79+ }
80+ }
81+ })
82+ }
83+}
A internal/adapters/in/cli/presentation.go +50 -0
@@ -0,0 +1,50 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "io"
6+
7+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
8+)
9+
10+var cliWriteLine = func(w io.Writer, msg string) error {
11+ _, err := fmt.Fprintln(w, msg)
12+ return err
13+}
14+
15+var cliWritef = func(w io.Writer, format string, args ...any) error {
16+ _, err := fmt.Fprintf(w, format, args...)
17+ return err
18+}
19+
20+func cliRenderTitle(msg string) string {
21+ return styles.Theme.Title.Render(msg)
22+}
23+
24+func cliRenderMuted(msg string) string {
25+ return styles.Theme.Muted.Render(msg)
26+}
27+
28+func cliRenderEmptyState(msg string) string {
29+ return styles.Theme.Muted.Render(msg)
30+}
31+
32+func cliRenderListItem(msg string) string {
33+ return styles.RenderListItem(msg, false)
34+}
35+
36+func cliRenderMeta(label, value string) string {
37+ return styles.Theme.Bold.Render(label) + " " + styles.Theme.Muted.Render(value)
38+}
39+
40+func cliRenderSuccess(msg string) string {
41+ return styles.RenderSuccess(msg)
42+}
43+
44+func cliRenderWarning(msg string) string {
45+ return styles.RenderWarning(msg)
46+}
47+
48+func cliRenderInfo(msg string) string {
49+ return styles.RenderInfo(msg)
50+}
@@ -0,0 +1,50 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "io"
6+
7+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
8+)
9+
10+var cliWriteLine = func(w io.Writer, msg string) error {
11+ _, err := fmt.Fprintln(w, msg)
12+ return err
13+}
14+
15+var cliWritef = func(w io.Writer, format string, args ...any) error {
16+ _, err := fmt.Fprintf(w, format, args...)
17+ return err
18+}
19+
20+func cliRenderTitle(msg string) string {
21+ return styles.Theme.Title.Render(msg)
22+}
23+
24+func cliRenderMuted(msg string) string {
25+ return styles.Theme.Muted.Render(msg)
26+}
27+
28+func cliRenderEmptyState(msg string) string {
29+ return styles.Theme.Muted.Render(msg)
30+}
31+
32+func cliRenderListItem(msg string) string {
33+ return styles.RenderListItem(msg, false)
34+}
35+
36+func cliRenderMeta(label, value string) string {
37+ return styles.Theme.Bold.Render(label) + " " + styles.Theme.Muted.Render(value)
38+}
39+
40+func cliRenderSuccess(msg string) string {
41+ return styles.RenderSuccess(msg)
42+}
43+
44+func cliRenderWarning(msg string) string {
45+ return styles.RenderWarning(msg)
46+}
47+
48+func cliRenderInfo(msg string) string {
49+ return styles.RenderInfo(msg)
50+}
M internal/adapters/in/cli/root.go +48 -15
@@ -195,10 +195,18 @@ func newVersionCmd() *cobra.Command {
195195 return &cobra.Command{
196196 Use: "version",
197197 Short: "Print version information",
198- Run: func(cmd *cobra.Command, args []string) {
199- cmd.Printf("Gordon %s\n", Version)
200- cmd.Printf("Commit: %s\n", Commit)
201- cmd.Printf("Build Date: %s\n", BuildDate)
198+ RunE: func(cmd *cobra.Command, args []string) error {
199+ out := cmd.OutOrStdout()
200+ if err := cliWriteLine(out, cliRenderTitle("Gordon "+Version)); err != nil {
201+ return err
202+ }
203+ if err := cliWriteLine(out, cliRenderMeta("Commit:", Commit)); err != nil {
204+ return err
205+ }
206+ if err := cliWriteLine(out, cliRenderMeta("Build Date:", BuildDate)); err != nil {
207+ return err
208+ }
209+ return nil
202210 },
203211 }
204212 }
@@ -214,15 +222,21 @@ func runReloadRemote(ctx context.Context, client *remote.Client) error {
214222 if shouldFallbackToLocal(err) {
215223 localErr := runReload()
216224 if localErr == nil {
217- fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err)
218- fmt.Println("Configuration reloaded successfully")
225+ if writeErr := cliWriteLine(os.Stdout, cliRenderWarning(fmt.Sprintf("Remote reload failed (%v), used local signal fallback", err))); writeErr != nil {
226+ return writeErr
227+ }
228+ if writeErr := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); writeErr != nil {
229+ return writeErr
230+ }
219231 return nil
220232 }
221233 return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr)
222234 }
223235 return fmt.Errorf("failed to reload: %w", err)
224236 }
225- fmt.Println("Configuration reloaded successfully")
237+ if err := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); err != nil {
238+ return err
239+ }
226240 return nil
227241 }
228242
@@ -291,7 +305,9 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i
291305 return fmt.Errorf("failed to get process logs: %w", err)
292306 }
293307 for _, line := range logLines {
294- fmt.Println(line)
308+ if err := cliWriteLine(os.Stdout, line); err != nil {
309+ return err
310+ }
295311 }
296312 } else {
297313 // Container logs
@@ -300,7 +316,9 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i
300316 return fmt.Errorf("failed to get container logs: %w", err)
301317 }
302318 for _, line := range logLines {
303- fmt.Println(line)
319+ if err := cliWriteLine(os.Stdout, line); err != nil {
320+ return err
321+ }
304322 }
305323 }
306324 return nil
@@ -321,7 +339,9 @@ func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain stri
321339 }
322340
323341 for line := range ch {
324- fmt.Println(line)
342+ if err := cliWriteLine(os.Stdout, line); err != nil {
343+ return err
344+ }
325345 }
326346 return nil
327347 }
@@ -342,13 +362,26 @@ func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines
342362 // For local container logs, we need Docker access which requires
343363 // the runtime to be initialized. For now, suggest using remote mode
344364 // or direct docker logs command.
345- fmt.Printf("Container logs for %s\n", logDomain)
346- fmt.Println("To view container logs locally, use:")
347- fmt.Printf(" docker logs --tail %d %s\n", lines, logDomain)
365+ if err := cliWriteLine(os.Stdout, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil {
366+ return err
367+ }
368+ if err := cliWriteLine(os.Stdout, cliRenderInfo("To view container logs locally, use:")); err != nil {
369+ return err
370+ }
371+ if err := cliWritef(os.Stdout, " docker logs --tail %d %s\n", lines, logDomain); err != nil {
372+ return err
373+ }
348374 if follow {
349- fmt.Printf(" docker logs -f --tail %d %s\n", lines, logDomain)
375+ if err := cliWritef(os.Stdout, " docker logs -f --tail %d %s\n", lines, logDomain); err != nil {
376+ return err
377+ }
378+ }
379+ if err := cliWriteLine(os.Stdout, ""); err != nil {
380+ return err
381+ }
382+ if err := cliWriteLine(os.Stdout, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil {
383+ return err
350384 }
351- fmt.Println("\nOr use remote mode to access logs via the admin API.")
352385 return nil
353386 }
354387
@@ -195,10 +195,18 @@ func newVersionCmd() *cobra.Command {
195 return &cobra.Command{195 return &cobra.Command{
196 Use: "version",196 Use: "version",
197 Short: "Print version information",197 Short: "Print version information",
198- Run: func(cmd *cobra.Command, args []string) {198+ RunE: func(cmd *cobra.Command, args []string) error {
199- cmd.Printf("Gordon %s\n", Version)199+ out := cmd.OutOrStdout()
200- cmd.Printf("Commit: %s\n", Commit)200+ if err := cliWriteLine(out, cliRenderTitle("Gordon "+Version)); err != nil {
201- cmd.Printf("Build Date: %s\n", BuildDate)201+ return err
202+ }
203+ if err := cliWriteLine(out, cliRenderMeta("Commit:", Commit)); err != nil {
204+ return err
205+ }
206+ if err := cliWriteLine(out, cliRenderMeta("Build Date:", BuildDate)); err != nil {
207+ return err
208+ }
209+ return nil
202 },210 },
203 }211 }
204 }212 }
@@ -214,15 +222,21 @@ func runReloadRemote(ctx context.Context, client *remote.Client) error {
214 if shouldFallbackToLocal(err) {222 if shouldFallbackToLocal(err) {
215 localErr := runReload()223 localErr := runReload()
216 if localErr == nil {224 if localErr == nil {
217- fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err)225+ if writeErr := cliWriteLine(os.Stdout, cliRenderWarning(fmt.Sprintf("Remote reload failed (%v), used local signal fallback", err))); writeErr != nil {
218- fmt.Println("Configuration reloaded successfully")226+ return writeErr
227+ }
228+ if writeErr := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); writeErr != nil {
229+ return writeErr
230+ }
219 return nil231 return nil
220 }232 }
221 return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr)233 return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr)
222 }234 }
223 return fmt.Errorf("failed to reload: %w", err)235 return fmt.Errorf("failed to reload: %w", err)
224 }236 }
225- fmt.Println("Configuration reloaded successfully")237+ if err := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); err != nil {
238+ return err
239+ }
226 return nil240 return nil
227 }241 }
228 242
@@ -291,7 +305,9 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i
291 return fmt.Errorf("failed to get process logs: %w", err)305 return fmt.Errorf("failed to get process logs: %w", err)
292 }306 }
293 for _, line := range logLines {307 for _, line := range logLines {
294- fmt.Println(line)308+ if err := cliWriteLine(os.Stdout, line); err != nil {
309+ return err
310+ }
295 }311 }
296 } else {312 } else {
297 // Container logs313 // Container logs
@@ -300,7 +316,9 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i
300 return fmt.Errorf("failed to get container logs: %w", err)316 return fmt.Errorf("failed to get container logs: %w", err)
301 }317 }
302 for _, line := range logLines {318 for _, line := range logLines {
303- fmt.Println(line)319+ if err := cliWriteLine(os.Stdout, line); err != nil {
320+ return err
321+ }
304 }322 }
305 }323 }
306 return nil324 return nil
@@ -321,7 +339,9 @@ func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain stri
321 }339 }
322 340
323 for line := range ch {341 for line := range ch {
324- fmt.Println(line)342+ if err := cliWriteLine(os.Stdout, line); err != nil {
343+ return err
344+ }
325 }345 }
326 return nil346 return nil
327 }347 }
@@ -342,13 +362,26 @@ func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines
342 // For local container logs, we need Docker access which requires362 // For local container logs, we need Docker access which requires
343 // the runtime to be initialized. For now, suggest using remote mode363 // the runtime to be initialized. For now, suggest using remote mode
344 // or direct docker logs command.364 // or direct docker logs command.
345- fmt.Printf("Container logs for %s\n", logDomain)365+ if err := cliWriteLine(os.Stdout, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil {
346- fmt.Println("To view container logs locally, use:")366+ return err
347- fmt.Printf(" docker logs --tail %d %s\n", lines, logDomain)367+ }
368+ if err := cliWriteLine(os.Stdout, cliRenderInfo("To view container logs locally, use:")); err != nil {
369+ return err
370+ }
371+ if err := cliWritef(os.Stdout, " docker logs --tail %d %s\n", lines, logDomain); err != nil {
372+ return err
373+ }
348 if follow {374 if follow {
349- fmt.Printf(" docker logs -f --tail %d %s\n", lines, logDomain)375+ if err := cliWritef(os.Stdout, " docker logs -f --tail %d %s\n", lines, logDomain); err != nil {
376+ return err
377+ }
378+ }
379+ if err := cliWriteLine(os.Stdout, ""); err != nil {
380+ return err
381+ }
382+ if err := cliWriteLine(os.Stdout, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil {
383+ return err
350 }384 }
351- fmt.Println("\nOr use remote mode to access logs via the admin API.")
352 return nil385 return nil
353 }386 }
354 387
M internal/adapters/in/cli/serve.go +3 -2
@@ -2,7 +2,6 @@ package cli
22
33 import (
44 "context"
5- "fmt"
65 "os"
76
87 "github.com/bnema/gordon/internal/app"
@@ -40,7 +39,9 @@ func newStartCmd() *cobra.Command {
4039 Deprecated: "use 'gordon serve' instead",
4140 Hidden: true, // Hide from help but still works
4241 RunE: func(cmd *cobra.Command, args []string) error {
43- fmt.Fprintln(os.Stderr, "\nWarning: 'gordon start' is deprecated, use 'gordon serve' instead")
42+ if err := cliWriteLine(os.Stderr, cliRenderWarning("Warning: 'gordon start' is deprecated, use 'gordon serve' instead")); err != nil {
43+ return err
44+ }
4445 return app.Run(context.Background(), configPath)
4546 },
4647 }
@@ -2,7 +2,6 @@ package cli
2 2
3 import (3 import (
4 "context"4 "context"
5- "fmt"
6 "os"5 "os"
7 6
8 "github.com/bnema/gordon/internal/app"7 "github.com/bnema/gordon/internal/app"
@@ -40,7 +39,9 @@ func newStartCmd() *cobra.Command {
40 Deprecated: "use 'gordon serve' instead",39 Deprecated: "use 'gordon serve' instead",
41 Hidden: true, // Hide from help but still works40 Hidden: true, // Hide from help but still works
42 RunE: func(cmd *cobra.Command, args []string) error {41 RunE: func(cmd *cobra.Command, args []string) error {
43- fmt.Fprintln(os.Stderr, "\nWarning: 'gordon start' is deprecated, use 'gordon serve' instead")42+ if err := cliWriteLine(os.Stderr, cliRenderWarning("Warning: 'gordon start' is deprecated, use 'gordon serve' instead")); err != nil {
43+ return err
44+ }
44 return app.Run(context.Background(), configPath)45 return app.Run(context.Background(), configPath)
45 },46 },
46 }47 }
A internal/adapters/in/cli/ui_adoption_test.go +215 -0
@@ -0,0 +1,215 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "go/ast"
7+ "go/parser"
8+ "go/token"
9+ "io"
10+ "strings"
11+ "sync"
12+ "testing"
13+
14+ "github.com/bnema/gordon/internal/adapters/dto"
15+)
16+
17+var uiAdoptionHelperCalls = map[string]struct{}{
18+ "cliWriteLine": {},
19+ "cliWritef": {},
20+ "cliRenderTitle": {},
21+ "cliRenderMuted": {},
22+ "cliRenderEmptyState": {},
23+ "cliRenderListItem": {},
24+ "cliRenderMeta": {},
25+ "cliRenderSuccess": {},
26+ "cliRenderWarning": {},
27+ "cliRenderInfo": {},
28+}
29+
30+var uiAdoptionSeamMu sync.Mutex
31+
32+func TestPresentationHelpers(t *testing.T) {
33+ if got := cliRenderTitle("Title"); got == "" {
34+ t.Fatal("cliRenderTitle returned empty output")
35+ }
36+ if got := cliRenderEmptyState("none"); got == "" {
37+ t.Fatal("cliRenderEmptyState returned empty output")
38+ }
39+ if got := cliRenderSuccess("ok"); got == "" {
40+ t.Fatal("cliRenderSuccess returned empty output")
41+ }
42+ if got := cliRenderWarning("warn"); got == "" {
43+ t.Fatal("cliRenderWarning returned empty output")
44+ }
45+ if got := cliRenderInfo("info"); got == "" {
46+ t.Fatal("cliRenderInfo returned empty output")
47+ }
48+ if got := cliRenderListItem("item"); got == "" {
49+ t.Fatal("cliRenderListItem returned empty output")
50+ }
51+}
52+
53+func TestUIAdoption(t *testing.T) {
54+ for _, expect := range uiAdoptionExpectations {
55+ expect := expect
56+ t.Run(expect.family, func(t *testing.T) {
57+ fset := token.NewFileSet()
58+ fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors)
59+ if err != nil {
60+ t.Fatalf("failed to parse %s: %v", expect.file, err)
61+ }
62+
63+ for _, fnName := range expect.functions {
64+ fnName := fnName
65+ t.Run(fnName, func(t *testing.T) {
66+ fn := findFuncDecl(fileNode, fnName)
67+ if fn == nil {
68+ t.Fatalf("function %s not found in %s", fnName, expect.file)
69+ }
70+
71+ hasHelperCall := false
72+ hasSharedUIUsage := false
73+ hasForbiddenRawPrint := false
74+
75+ ast.Inspect(fn.Body, func(n ast.Node) bool {
76+ call, ok := n.(*ast.CallExpr)
77+ if !ok {
78+ return true
79+ }
80+
81+ switch fun := call.Fun.(type) {
82+ case *ast.Ident:
83+ if _, ok := uiAdoptionHelperCalls[fun.Name]; ok {
84+ hasHelperCall = true
85+ }
86+ case *ast.SelectorExpr:
87+ if isForbiddenRawPrintCall(fun) {
88+ hasForbiddenRawPrint = true
89+ }
90+ pkgIdent, ok := fun.X.(*ast.Ident)
91+ if !ok {
92+ return true
93+ }
94+ if pkgIdent.Name == "styles" || pkgIdent.Name == "components" {
95+ hasSharedUIUsage = true
96+ }
97+ }
98+
99+ return true
100+ })
101+
102+ if !hasHelperCall && !hasSharedUIUsage {
103+ t.Fatalf("%s in %s does not use presentation helpers or shared ui styles/components", fnName, expect.file)
104+ }
105+
106+ if hasForbiddenRawPrint {
107+ t.Fatalf("%s in %s still uses forbidden raw print calls", fnName, expect.file)
108+ }
109+ })
110+ }
111+ })
112+ }
113+}
114+
115+func isForbiddenRawPrintCall(sel *ast.SelectorExpr) bool {
116+ pkgIdent, ok := sel.X.(*ast.Ident)
117+ if !ok {
118+ return false
119+ }
120+
121+ if pkgIdent.Name == "fmt" {
122+ switch sel.Sel.Name {
123+ case "Print", "Printf", "Println":
124+ return true
125+ }
126+ }
127+
128+ if pkgIdent.Name == "cmd" && strings.HasPrefix(sel.Sel.Name, "Print") {
129+ return true
130+ }
131+
132+ return false
133+}
134+
135+type imagesClientStub struct {
136+ listResp []dto.Image
137+ pruneResp *dto.ImagePruneResponse
138+ err error
139+}
140+
141+func (s imagesClientStub) ListImages(context.Context) ([]dto.Image, error) {
142+ if s.err != nil {
143+ return nil, s.err
144+ }
145+ return s.listResp, nil
146+}
147+
148+func (s imagesClientStub) PruneImages(context.Context, int) (*dto.ImagePruneResponse, error) {
149+ if s.err != nil {
150+ return nil, s.err
151+ }
152+ return s.pruneResp, nil
153+}
154+
155+func TestUIAdoptionRuntimeSeams(t *testing.T) {
156+ uiAdoptionSeamMu.Lock()
157+ defer uiAdoptionSeamMu.Unlock()
158+
159+ origWriteLine := cliWriteLine
160+ origWritef := cliWritef
161+ defer func() {
162+ cliWriteLine = origWriteLine
163+ cliWritef = origWritef
164+ }()
165+
166+ lineCalls := 0
167+ writefCalls := 0
168+ cliWriteLine = func(w io.Writer, msg string) error {
169+ lineCalls++
170+ _, err := io.WriteString(w, msg+"\n")
171+ return err
172+ }
173+ cliWritef = func(w io.Writer, format string, args ...any) error {
174+ writefCalls++
175+ _, err := io.WriteString(w, "formatted\n")
176+ return err
177+ }
178+
179+ versionCmd := newVersionCmd()
180+ versionCmd.SetArgs(nil)
181+ versionCmd.SetOut(new(bytes.Buffer))
182+ if err := versionCmd.Execute(); err != nil {
183+ t.Fatalf("version command execution failed: %v", err)
184+ }
185+
186+ imgBuf := new(bytes.Buffer)
187+ if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf); err != nil {
188+ t.Fatalf("runImagesList failed: %v", err)
189+ }
190+
191+ pruneBuf := new(bytes.Buffer)
192+ if err := runImagesPrune(context.Background(), imagesClientStub{listResp: []dto.Image{}, pruneResp: &dto.ImagePruneResponse{}}, imagesPruneOptions{DryRun: true}, pruneBuf); err != nil {
193+ t.Fatalf("runImagesPrune failed: %v", err)
194+ }
195+
196+ if lineCalls == 0 {
197+ t.Fatal("expected cliWriteLine to be called")
198+ }
199+ if writefCalls == 0 {
200+ t.Fatal("expected cliWritef to be called")
201+ }
202+}
203+
204+func findFuncDecl(file *ast.File, name string) *ast.FuncDecl {
205+ for _, decl := range file.Decls {
206+ fn, ok := decl.(*ast.FuncDecl)
207+ if !ok {
208+ continue
209+ }
210+ if fn.Name.Name == name {
211+ return fn
212+ }
213+ }
214+ return nil
215+}
@@ -0,0 +1,215 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "go/ast"
7+ "go/parser"
8+ "go/token"
9+ "io"
10+ "strings"
11+ "sync"
12+ "testing"
13+
14+ "github.com/bnema/gordon/internal/adapters/dto"
15+)
16+
17+var uiAdoptionHelperCalls = map[string]struct{}{
18+ "cliWriteLine": {},
19+ "cliWritef": {},
20+ "cliRenderTitle": {},
21+ "cliRenderMuted": {},
22+ "cliRenderEmptyState": {},
23+ "cliRenderListItem": {},
24+ "cliRenderMeta": {},
25+ "cliRenderSuccess": {},
26+ "cliRenderWarning": {},
27+ "cliRenderInfo": {},
28+}
29+
30+var uiAdoptionSeamMu sync.Mutex
31+
32+func TestPresentationHelpers(t *testing.T) {
33+ if got := cliRenderTitle("Title"); got == "" {
34+ t.Fatal("cliRenderTitle returned empty output")
35+ }
36+ if got := cliRenderEmptyState("none"); got == "" {
37+ t.Fatal("cliRenderEmptyState returned empty output")
38+ }
39+ if got := cliRenderSuccess("ok"); got == "" {
40+ t.Fatal("cliRenderSuccess returned empty output")
41+ }
42+ if got := cliRenderWarning("warn"); got == "" {
43+ t.Fatal("cliRenderWarning returned empty output")
44+ }
45+ if got := cliRenderInfo("info"); got == "" {
46+ t.Fatal("cliRenderInfo returned empty output")
47+ }
48+ if got := cliRenderListItem("item"); got == "" {
49+ t.Fatal("cliRenderListItem returned empty output")
50+ }
51+}
52+
53+func TestUIAdoption(t *testing.T) {
54+ for _, expect := range uiAdoptionExpectations {
55+ expect := expect
56+ t.Run(expect.family, func(t *testing.T) {
57+ fset := token.NewFileSet()
58+ fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors)
59+ if err != nil {
60+ t.Fatalf("failed to parse %s: %v", expect.file, err)
61+ }
62+
63+ for _, fnName := range expect.functions {
64+ fnName := fnName
65+ t.Run(fnName, func(t *testing.T) {
66+ fn := findFuncDecl(fileNode, fnName)
67+ if fn == nil {
68+ t.Fatalf("function %s not found in %s", fnName, expect.file)
69+ }
70+
71+ hasHelperCall := false
72+ hasSharedUIUsage := false
73+ hasForbiddenRawPrint := false
74+
75+ ast.Inspect(fn.Body, func(n ast.Node) bool {
76+ call, ok := n.(*ast.CallExpr)
77+ if !ok {
78+ return true
79+ }
80+
81+ switch fun := call.Fun.(type) {
82+ case *ast.Ident:
83+ if _, ok := uiAdoptionHelperCalls[fun.Name]; ok {
84+ hasHelperCall = true
85+ }
86+ case *ast.SelectorExpr:
87+ if isForbiddenRawPrintCall(fun) {
88+ hasForbiddenRawPrint = true
89+ }
90+ pkgIdent, ok := fun.X.(*ast.Ident)
91+ if !ok {
92+ return true
93+ }
94+ if pkgIdent.Name == "styles" || pkgIdent.Name == "components" {
95+ hasSharedUIUsage = true
96+ }
97+ }
98+
99+ return true
100+ })
101+
102+ if !hasHelperCall && !hasSharedUIUsage {
103+ t.Fatalf("%s in %s does not use presentation helpers or shared ui styles/components", fnName, expect.file)
104+ }
105+
106+ if hasForbiddenRawPrint {
107+ t.Fatalf("%s in %s still uses forbidden raw print calls", fnName, expect.file)
108+ }
109+ })
110+ }
111+ })
112+ }
113+}
114+
115+func isForbiddenRawPrintCall(sel *ast.SelectorExpr) bool {
116+ pkgIdent, ok := sel.X.(*ast.Ident)
117+ if !ok {
118+ return false
119+ }
120+
121+ if pkgIdent.Name == "fmt" {
122+ switch sel.Sel.Name {
123+ case "Print", "Printf", "Println":
124+ return true
125+ }
126+ }
127+
128+ if pkgIdent.Name == "cmd" && strings.HasPrefix(sel.Sel.Name, "Print") {
129+ return true
130+ }
131+
132+ return false
133+}
134+
135+type imagesClientStub struct {
136+ listResp []dto.Image
137+ pruneResp *dto.ImagePruneResponse
138+ err error
139+}
140+
141+func (s imagesClientStub) ListImages(context.Context) ([]dto.Image, error) {
142+ if s.err != nil {
143+ return nil, s.err
144+ }
145+ return s.listResp, nil
146+}
147+
148+func (s imagesClientStub) PruneImages(context.Context, int) (*dto.ImagePruneResponse, error) {
149+ if s.err != nil {
150+ return nil, s.err
151+ }
152+ return s.pruneResp, nil
153+}
154+
155+func TestUIAdoptionRuntimeSeams(t *testing.T) {
156+ uiAdoptionSeamMu.Lock()
157+ defer uiAdoptionSeamMu.Unlock()
158+
159+ origWriteLine := cliWriteLine
160+ origWritef := cliWritef
161+ defer func() {
162+ cliWriteLine = origWriteLine
163+ cliWritef = origWritef
164+ }()
165+
166+ lineCalls := 0
167+ writefCalls := 0
168+ cliWriteLine = func(w io.Writer, msg string) error {
169+ lineCalls++
170+ _, err := io.WriteString(w, msg+"\n")
171+ return err
172+ }
173+ cliWritef = func(w io.Writer, format string, args ...any) error {
174+ writefCalls++
175+ _, err := io.WriteString(w, "formatted\n")
176+ return err
177+ }
178+
179+ versionCmd := newVersionCmd()
180+ versionCmd.SetArgs(nil)
181+ versionCmd.SetOut(new(bytes.Buffer))
182+ if err := versionCmd.Execute(); err != nil {
183+ t.Fatalf("version command execution failed: %v", err)
184+ }
185+
186+ imgBuf := new(bytes.Buffer)
187+ if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf); err != nil {
188+ t.Fatalf("runImagesList failed: %v", err)
189+ }
190+
191+ pruneBuf := new(bytes.Buffer)
192+ if err := runImagesPrune(context.Background(), imagesClientStub{listResp: []dto.Image{}, pruneResp: &dto.ImagePruneResponse{}}, imagesPruneOptions{DryRun: true}, pruneBuf); err != nil {
193+ t.Fatalf("runImagesPrune failed: %v", err)
194+ }
195+
196+ if lineCalls == 0 {
197+ t.Fatal("expected cliWriteLine to be called")
198+ }
199+ if writefCalls == 0 {
200+ t.Fatal("expected cliWritef to be called")
201+ }
202+}
203+
204+func findFuncDecl(file *ast.File, name string) *ast.FuncDecl {
205+ for _, decl := range file.Decls {
206+ fn, ok := decl.(*ast.FuncDecl)
207+ if !ok {
208+ continue
209+ }
210+ if fn.Name.Name == name {
211+ return fn
212+ }
213+ }
214+ return nil
215+}

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