detached 13bd0f0

feat(cli): wire preview commands — build/push, list, delete, extend

Connect the preview CLI scaffold to the server. The `preview create`
command reuses push infrastructure (build, tag with preview-{name},
push to registry) and lets the server-side autopreview handler create
the route and container. List/delete/extend call the admin API via
the remote client.
commit
13bd0f09a8e5b08532d33cf47bae19f953abb536
author
brice · · 2026-03-19 05:39 UTC
parent
a4b282f
signature
unsigned
2 files · +211 -20
M internal/adapters/in/cli/preview.go +155 -20
@@ -1,12 +1,18 @@
11 package cli
22
33 import (
4+ "encoding/json"
45 "fmt"
6+ "os"
57 "os/exec"
68 "strings"
9+ "text/tabwriter"
10+ "time"
711
8- "github.com/bnema/gordon/internal/usecase/auto/preview"
912 "github.com/spf13/cobra"
13+
14+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
15+ "github.com/bnema/gordon/internal/usecase/auto/preview"
1016 )
1117
1218 func newPreviewCmd() *cobra.Command {
@@ -34,15 +40,22 @@ func newPreviewCmd() *cobra.Command {
3440 }
3541
3642 func newPreviewCreateCmd() *cobra.Command {
37- var ttl string
38- var noBuild bool
39- var noData bool
43+ var (
44+ ttl string
45+ noBuild bool
46+ noData bool
47+ platform string
48+ )
4049
4150 cmd := &cobra.Command{
4251 Use: "create [name]",
4352 Short: "Create a preview environment",
4453 Args: cobra.MaximumNArgs(1),
4554 RunE: func(cmd *cobra.Command, args []string) error {
55+ ctx := cmd.Context()
56+ out := cmd.OutOrStdout()
57+
58+ // Resolve preview name from arg or git branch.
4659 name := ""
4760 if len(args) > 0 {
4861 name = args[0]
@@ -54,12 +67,71 @@ func newPreviewCreateCmd() *cobra.Command {
5467 name = preview.SanitizeBranchName(branch)
5568 }
5669
57- fmt.Fprintf(cmd.OutOrStdout(), "Creating preview: %s\n", name)
58- // TODO: Reuse push mechanics (build, tag with preview-{name}, push)
59- // TODO: Wire to remote API or local service
60- _ = ttl
61- _ = noBuild
62- _ = noData
70+ previewTag := "preview-" + name
71+
72+ fmt.Fprintf(out, "Creating preview: %s\n", styles.Theme.Bold.Render(name))
73+
74+ // Resolve control plane (remote or local) — same as push.
75+ handle, err := resolveControlPlane(configPath)
76+ if err != nil {
77+ return err
78+ }
79+ defer handle.close()
80+
81+ // Detect image name and registry using the same logic as push.
82+ dockerfile := "Dockerfile"
83+ imageName, err := detectImageName(dockerfile)
84+ if err != nil {
85+ return err
86+ }
87+
88+ fmt.Fprintf(out, "Image: %s\n", styles.Theme.Bold.Render(imageName))
89+
90+ // Resolve registry from routes.
91+ registry, _, _, err := resolveRoute(ctx, handle.plane, "", "", dockerfile)
92+ if err != nil {
93+ return fmt.Errorf("failed to resolve registry: %w", err)
94+ }
95+
96+ // Build the image ref with the preview tag.
97+ previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag)
98+ fmt.Fprintf(out, "Tag: %s\n", styles.Theme.Bold.Render(previewRef))
99+
100+ // Build and push.
101+ imageOps, err := newImageOpsFn()
102+ if err != nil {
103+ return err
104+ }
105+
106+ if !noBuild {
107+ if _, statErr := os.Stat(dockerfile); os.IsNotExist(statErr) {
108+ return fmt.Errorf("dockerfile not found: %s", dockerfile)
109+ }
110+
111+ buildPlatform := platform
112+ buildArgs := buildImageArgs(ctx, previewTag, buildPlatform, dockerfile, nil, previewRef, previewRef)
113+ fmt.Fprintln(out, "\nBuilding image...")
114+ if err := imageOps.Build(ctx, buildArgs); err != nil {
115+ return err
116+ }
117+ }
118+
119+ fmt.Fprintln(out, "Pushing...")
120+ if err := imageOps.Push(ctx, previewRef); err != nil {
121+ return fmt.Errorf("failed to push %s: %w", previewRef, err)
122+ }
123+
124+ fmt.Fprintln(out, styles.RenderSuccess("Push complete"))
125+ fmt.Fprintf(out, "Preview %s will be created by the server's autopreview handler.\n",
126+ styles.Theme.Bold.Render(name))
127+
128+ if ttl != "" {
129+ fmt.Fprintf(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl)
130+ }
131+ if noData {
132+ fmt.Fprintf(out, "Data copy: skipped (--no-data)\n")
133+ }
134+
63135 return nil
64136 },
65137 }
@@ -67,6 +139,7 @@ func newPreviewCreateCmd() *cobra.Command {
67139 cmd.Flags().StringVar(&ttl, "ttl", "", "Override TTL (e.g., 72h)")
68140 cmd.Flags().BoolVar(&noBuild, "no-build", false, "Skip image build")
69141 cmd.Flags().BoolVar(&noData, "no-data", false, "Skip volume cloning")
142+ cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform for build")
70143 return cmd
71144 }
72145
@@ -76,14 +149,52 @@ func newPreviewListCmd() *cobra.Command {
76149 Use: "list",
77150 Short: "List active preview environments",
78151 RunE: func(cmd *cobra.Command, args []string) error {
79- // TODO: Wire to local service or admin API
152+ ctx := cmd.Context()
153+ out := cmd.OutOrStdout()
154+
155+ client, isRemote := GetRemoteClient()
156+ if !isRemote {
157+ return fmt.Errorf("preview list requires --remote (local preview listing is not yet supported)")
158+ }
159+
160+ previews, err := client.ListPreviews(ctx)
161+ if err != nil {
162+ return fmt.Errorf("failed to list previews: %w", err)
163+ }
164+
80165 if jsonOutput {
81- // Output empty JSON array for now (will be wired later)
82- fmt.Fprintln(cmd.OutOrStdout(), "[]")
83- } else {
84- fmt.Fprintln(cmd.OutOrStdout(), "No active previews")
166+ enc := json.NewEncoder(out)
167+ enc.SetIndent("", " ")
168+ return enc.Encode(previews)
85169 }
86- return nil
170+
171+ if len(previews) == 0 {
172+ fmt.Fprintln(out, "No active previews")
173+ return nil
174+ }
175+
176+ w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
177+ if _, err := fmt.Fprintln(w, "NAME\tDOMAIN\tIMAGE\tCREATED\tEXPIRES"); err != nil {
178+ return err
179+ }
180+ now := time.Now()
181+ for _, p := range previews {
182+ remaining := time.Until(p.ExpiresAt).Truncate(time.Minute)
183+ expiresStr := remaining.String()
184+ if p.IsExpired(now) {
185+ expiresStr = "expired"
186+ }
187+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
188+ p.Name,
189+ p.Domain,
190+ truncateImage(p.Image, 40),
191+ p.CreatedAt.Format(time.DateTime),
192+ expiresStr,
193+ ); err != nil {
194+ return err
195+ }
196+ }
197+ return w.Flush()
87198 },
88199 }
89200 cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON")
@@ -96,8 +207,20 @@ func newPreviewDeleteCmd() *cobra.Command {
96207 Short: "Delete a preview environment",
97208 Args: cobra.ExactArgs(1),
98209 RunE: func(cmd *cobra.Command, args []string) error {
99- // TODO: Wire to local service or admin API
100- fmt.Fprintf(cmd.OutOrStdout(), "Deleting preview: %s\n", args[0])
210+ ctx := cmd.Context()
211+ out := cmd.OutOrStdout()
212+ name := args[0]
213+
214+ client, isRemote := GetRemoteClient()
215+ if !isRemote {
216+ return fmt.Errorf("preview delete requires --remote (local preview deletion is not yet supported)")
217+ }
218+
219+ if err := client.DeletePreview(ctx, name); err != nil {
220+ return fmt.Errorf("failed to delete preview %q: %w", name, err)
221+ }
222+
223+ fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Preview %q deleted", name)))
101224 return nil
102225 },
103226 }
@@ -110,8 +233,20 @@ func newPreviewExtendCmd() *cobra.Command {
110233 Short: "Extend a preview environment's TTL",
111234 Args: cobra.ExactArgs(1),
112235 RunE: func(cmd *cobra.Command, args []string) error {
113- // TODO: Wire to local service or admin API
114- fmt.Fprintf(cmd.OutOrStdout(), "Extending preview %s by %s\n", args[0], ttl)
236+ ctx := cmd.Context()
237+ out := cmd.OutOrStdout()
238+ name := args[0]
239+
240+ client, isRemote := GetRemoteClient()
241+ if !isRemote {
242+ return fmt.Errorf("preview extend requires --remote (local preview extend is not yet supported)")
243+ }
244+
245+ if err := client.ExtendPreview(ctx, name, ttl); err != nil {
246+ return fmt.Errorf("failed to extend preview %q: %w", name, err)
247+ }
248+
249+ fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Preview %q extended by %s", name, ttl)))
115250 return nil
116251 },
117252 }
@@ -1,12 +1,18 @@
1 package cli1 package cli
2 2
3 import (3 import (
4+ "encoding/json"
4 "fmt"5 "fmt"
6+ "os"
5 "os/exec"7 "os/exec"
6 "strings"8 "strings"
9+ "text/tabwriter"
10+ "time"
7 11
8- "github.com/bnema/gordon/internal/usecase/auto/preview"
9 "github.com/spf13/cobra"12 "github.com/spf13/cobra"
13+
14+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
15+ "github.com/bnema/gordon/internal/usecase/auto/preview"
10 )16 )
11 17
12 func newPreviewCmd() *cobra.Command {18 func newPreviewCmd() *cobra.Command {
@@ -34,15 +40,22 @@ func newPreviewCmd() *cobra.Command {
34 }40 }
35 41
36 func newPreviewCreateCmd() *cobra.Command {42 func newPreviewCreateCmd() *cobra.Command {
37- var ttl string43+ var (
38- var noBuild bool44+ ttl string
39- var noData bool45+ noBuild bool
46+ noData bool
47+ platform string
48+ )
40 49
41 cmd := &cobra.Command{50 cmd := &cobra.Command{
42 Use: "create [name]",51 Use: "create [name]",
43 Short: "Create a preview environment",52 Short: "Create a preview environment",
44 Args: cobra.MaximumNArgs(1),53 Args: cobra.MaximumNArgs(1),
45 RunE: func(cmd *cobra.Command, args []string) error {54 RunE: func(cmd *cobra.Command, args []string) error {
55+ ctx := cmd.Context()
56+ out := cmd.OutOrStdout()
57+
58+ // Resolve preview name from arg or git branch.
46 name := ""59 name := ""
47 if len(args) > 0 {60 if len(args) > 0 {
48 name = args[0]61 name = args[0]
@@ -54,12 +67,71 @@ func newPreviewCreateCmd() *cobra.Command {
54 name = preview.SanitizeBranchName(branch)67 name = preview.SanitizeBranchName(branch)
55 }68 }
56 69
57- fmt.Fprintf(cmd.OutOrStdout(), "Creating preview: %s\n", name)70+ previewTag := "preview-" + name
58- // TODO: Reuse push mechanics (build, tag with preview-{name}, push)71+
59- // TODO: Wire to remote API or local service72+ fmt.Fprintf(out, "Creating preview: %s\n", styles.Theme.Bold.Render(name))
60- _ = ttl73+
61- _ = noBuild74+ // Resolve control plane (remote or local) — same as push.
62- _ = noData75+ handle, err := resolveControlPlane(configPath)
76+ if err != nil {
77+ return err
78+ }
79+ defer handle.close()
80+
81+ // Detect image name and registry using the same logic as push.
82+ dockerfile := "Dockerfile"
83+ imageName, err := detectImageName(dockerfile)
84+ if err != nil {
85+ return err
86+ }
87+
88+ fmt.Fprintf(out, "Image: %s\n", styles.Theme.Bold.Render(imageName))
89+
90+ // Resolve registry from routes.
91+ registry, _, _, err := resolveRoute(ctx, handle.plane, "", "", dockerfile)
92+ if err != nil {
93+ return fmt.Errorf("failed to resolve registry: %w", err)
94+ }
95+
96+ // Build the image ref with the preview tag.
97+ previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag)
98+ fmt.Fprintf(out, "Tag: %s\n", styles.Theme.Bold.Render(previewRef))
99+
100+ // Build and push.
101+ imageOps, err := newImageOpsFn()
102+ if err != nil {
103+ return err
104+ }
105+
106+ if !noBuild {
107+ if _, statErr := os.Stat(dockerfile); os.IsNotExist(statErr) {
108+ return fmt.Errorf("dockerfile not found: %s", dockerfile)
109+ }
110+
111+ buildPlatform := platform
112+ buildArgs := buildImageArgs(ctx, previewTag, buildPlatform, dockerfile, nil, previewRef, previewRef)
113+ fmt.Fprintln(out, "\nBuilding image...")
114+ if err := imageOps.Build(ctx, buildArgs); err != nil {
115+ return err
116+ }
117+ }
118+
119+ fmt.Fprintln(out, "Pushing...")
120+ if err := imageOps.Push(ctx, previewRef); err != nil {
121+ return fmt.Errorf("failed to push %s: %w", previewRef, err)
122+ }
123+
124+ fmt.Fprintln(out, styles.RenderSuccess("Push complete"))
125+ fmt.Fprintf(out, "Preview %s will be created by the server's autopreview handler.\n",
126+ styles.Theme.Bold.Render(name))
127+
128+ if ttl != "" {
129+ fmt.Fprintf(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl)
130+ }
131+ if noData {
132+ fmt.Fprintf(out, "Data copy: skipped (--no-data)\n")
133+ }
134+
63 return nil135 return nil
64 },136 },
65 }137 }
@@ -67,6 +139,7 @@ func newPreviewCreateCmd() *cobra.Command {
67 cmd.Flags().StringVar(&ttl, "ttl", "", "Override TTL (e.g., 72h)")139 cmd.Flags().StringVar(&ttl, "ttl", "", "Override TTL (e.g., 72h)")
68 cmd.Flags().BoolVar(&noBuild, "no-build", false, "Skip image build")140 cmd.Flags().BoolVar(&noBuild, "no-build", false, "Skip image build")
69 cmd.Flags().BoolVar(&noData, "no-data", false, "Skip volume cloning")141 cmd.Flags().BoolVar(&noData, "no-data", false, "Skip volume cloning")
142+ cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform for build")
70 return cmd143 return cmd
71 }144 }
72 145
@@ -76,14 +149,52 @@ func newPreviewListCmd() *cobra.Command {
76 Use: "list",149 Use: "list",
77 Short: "List active preview environments",150 Short: "List active preview environments",
78 RunE: func(cmd *cobra.Command, args []string) error {151 RunE: func(cmd *cobra.Command, args []string) error {
79- // TODO: Wire to local service or admin API152+ ctx := cmd.Context()
153+ out := cmd.OutOrStdout()
154+
155+ client, isRemote := GetRemoteClient()
156+ if !isRemote {
157+ return fmt.Errorf("preview list requires --remote (local preview listing is not yet supported)")
158+ }
159+
160+ previews, err := client.ListPreviews(ctx)
161+ if err != nil {
162+ return fmt.Errorf("failed to list previews: %w", err)
163+ }
164+
80 if jsonOutput {165 if jsonOutput {
81- // Output empty JSON array for now (will be wired later)166+ enc := json.NewEncoder(out)
82- fmt.Fprintln(cmd.OutOrStdout(), "[]")167+ enc.SetIndent("", " ")
83- } else {168+ return enc.Encode(previews)
84- fmt.Fprintln(cmd.OutOrStdout(), "No active previews")
85 }169 }
86- return nil170+
171+ if len(previews) == 0 {
172+ fmt.Fprintln(out, "No active previews")
173+ return nil
174+ }
175+
176+ w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
177+ if _, err := fmt.Fprintln(w, "NAME\tDOMAIN\tIMAGE\tCREATED\tEXPIRES"); err != nil {
178+ return err
179+ }
180+ now := time.Now()
181+ for _, p := range previews {
182+ remaining := time.Until(p.ExpiresAt).Truncate(time.Minute)
183+ expiresStr := remaining.String()
184+ if p.IsExpired(now) {
185+ expiresStr = "expired"
186+ }
187+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
188+ p.Name,
189+ p.Domain,
190+ truncateImage(p.Image, 40),
191+ p.CreatedAt.Format(time.DateTime),
192+ expiresStr,
193+ ); err != nil {
194+ return err
195+ }
196+ }
197+ return w.Flush()
87 },198 },
88 }199 }
89 cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON")200 cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON")
@@ -96,8 +207,20 @@ func newPreviewDeleteCmd() *cobra.Command {
96 Short: "Delete a preview environment",207 Short: "Delete a preview environment",
97 Args: cobra.ExactArgs(1),208 Args: cobra.ExactArgs(1),
98 RunE: func(cmd *cobra.Command, args []string) error {209 RunE: func(cmd *cobra.Command, args []string) error {
99- // TODO: Wire to local service or admin API210+ ctx := cmd.Context()
100- fmt.Fprintf(cmd.OutOrStdout(), "Deleting preview: %s\n", args[0])211+ out := cmd.OutOrStdout()
212+ name := args[0]
213+
214+ client, isRemote := GetRemoteClient()
215+ if !isRemote {
216+ return fmt.Errorf("preview delete requires --remote (local preview deletion is not yet supported)")
217+ }
218+
219+ if err := client.DeletePreview(ctx, name); err != nil {
220+ return fmt.Errorf("failed to delete preview %q: %w", name, err)
221+ }
222+
223+ fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Preview %q deleted", name)))
101 return nil224 return nil
102 },225 },
103 }226 }
@@ -110,8 +233,20 @@ func newPreviewExtendCmd() *cobra.Command {
110 Short: "Extend a preview environment's TTL",233 Short: "Extend a preview environment's TTL",
111 Args: cobra.ExactArgs(1),234 Args: cobra.ExactArgs(1),
112 RunE: func(cmd *cobra.Command, args []string) error {235 RunE: func(cmd *cobra.Command, args []string) error {
113- // TODO: Wire to local service or admin API236+ ctx := cmd.Context()
114- fmt.Fprintf(cmd.OutOrStdout(), "Extending preview %s by %s\n", args[0], ttl)237+ out := cmd.OutOrStdout()
238+ name := args[0]
239+
240+ client, isRemote := GetRemoteClient()
241+ if !isRemote {
242+ return fmt.Errorf("preview extend requires --remote (local preview extend is not yet supported)")
243+ }
244+
245+ if err := client.ExtendPreview(ctx, name, ttl); err != nil {
246+ return fmt.Errorf("failed to extend preview %q: %w", name, err)
247+ }
248+
249+ fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Preview %q extended by %s", name, ttl)))
115 return nil250 return nil
116 },251 },
117 }252 }
A internal/adapters/in/cli/remote/preview.go +56 -0
@@ -0,0 +1,56 @@
1+package remote
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "net/url"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+// ListPreviews returns all active preview environments.
13+func (c *Client) ListPreviews(ctx context.Context) ([]domain.PreviewRoute, error) {
14+ resp, err := c.request(ctx, http.MethodGet, "/previews", nil)
15+ if err != nil {
16+ return nil, err
17+ }
18+
19+ var result struct {
20+ Previews []domain.PreviewRoute `json:"previews"`
21+ }
22+ if err := parseResponse(resp, &result); err != nil {
23+ return nil, err
24+ }
25+
26+ return result.Previews, nil
27+}
28+
29+// DeletePreview tears down a preview environment by name.
30+func (c *Client) DeletePreview(ctx context.Context, name string) error {
31+ if name == "" {
32+ return fmt.Errorf("preview name cannot be empty")
33+ }
34+ resp, err := c.request(ctx, http.MethodDelete, "/preview/"+url.PathEscape(name), nil)
35+ if err != nil {
36+ return err
37+ }
38+ return parseResponse(resp, nil)
39+}
40+
41+// ExtendPreview extends the TTL of a preview environment.
42+func (c *Client) ExtendPreview(ctx context.Context, name string, ttl string) error {
43+ if name == "" {
44+ return fmt.Errorf("preview name cannot be empty")
45+ }
46+ if ttl == "" {
47+ return fmt.Errorf("ttl cannot be empty")
48+ }
49+ resp, err := c.request(ctx, http.MethodPatch, "/preview/"+url.PathEscape(name), struct {
50+ TTL string `json:"ttl"`
51+ }{TTL: ttl})
52+ if err != nil {
53+ return err
54+ }
55+ return parseResponse(resp, nil)
56+}
@@ -0,0 +1,56 @@
1+package remote
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "net/url"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+// ListPreviews returns all active preview environments.
13+func (c *Client) ListPreviews(ctx context.Context) ([]domain.PreviewRoute, error) {
14+ resp, err := c.request(ctx, http.MethodGet, "/previews", nil)
15+ if err != nil {
16+ return nil, err
17+ }
18+
19+ var result struct {
20+ Previews []domain.PreviewRoute `json:"previews"`
21+ }
22+ if err := parseResponse(resp, &result); err != nil {
23+ return nil, err
24+ }
25+
26+ return result.Previews, nil
27+}
28+
29+// DeletePreview tears down a preview environment by name.
30+func (c *Client) DeletePreview(ctx context.Context, name string) error {
31+ if name == "" {
32+ return fmt.Errorf("preview name cannot be empty")
33+ }
34+ resp, err := c.request(ctx, http.MethodDelete, "/preview/"+url.PathEscape(name), nil)
35+ if err != nil {
36+ return err
37+ }
38+ return parseResponse(resp, nil)
39+}
40+
41+// ExtendPreview extends the TTL of a preview environment.
42+func (c *Client) ExtendPreview(ctx context.Context, name string, ttl string) error {
43+ if name == "" {
44+ return fmt.Errorf("preview name cannot be empty")
45+ }
46+ if ttl == "" {
47+ return fmt.Errorf("ttl cannot be empty")
48+ }
49+ resp, err := c.request(ctx, http.MethodPatch, "/preview/"+url.PathEscape(name), struct {
50+ TTL string `json:"ttl"`
51+ }{TTL: ttl})
52+ if err != nil {
53+ return err
54+ }
55+ return parseResponse(resp, nil)
56+}

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