v2.6.0 fe25b4f

remotes.go RawHistoryFolder

1package cli
2
3import (
4	"fmt"
5	"strings"
6
7	"gordon/internal/adapters/in/cli/remote"
8	"gordon/internal/adapters/in/cli/ui/components"
9	"gordon/internal/adapters/in/cli/ui/styles"
10
11	"github.com/spf13/cobra"
12)
13
14// newRemotesCmd creates the remotes command group.
15func newRemotesCmd() *cobra.Command {
16	cmd := &cobra.Command{
17		Use:   "remotes",
18		Short: "Manage saved remote Gordon instances",
19		Long: `Manage saved remote Gordon instances.
20
21Remotes allow you to save frequently used remote Gordon instances
22and quickly switch between them.
23
24Configuration is stored in ~/.config/gordon/remotes.toml`,
25	}
26
27	cmd.AddCommand(newRemotesListCmd())
28	cmd.AddCommand(newRemotesAddCmd())
29	cmd.AddCommand(newRemotesRemoveCmd())
30	cmd.AddCommand(newRemotesUseCmd())
31
32	return cmd
33}
34
35// newRemotesListCmd creates the remotes list command.
36func newRemotesListCmd() *cobra.Command {
37	return &cobra.Command{
38		Use:   "list",
39		Short: "List saved remotes",
40		RunE: func(cmd *cobra.Command, args []string) error {
41			remotes, active, err := remote.ListRemotes()
42			if err != nil {
43				return fmt.Errorf("failed to list remotes: %w", err)
44			}
45
46			if len(remotes) == 0 {
47				fmt.Println(styles.Theme.Muted.Render("No remotes configured"))
48				fmt.Println()
49				fmt.Println("Add a remote with:")
50				fmt.Println(styles.Theme.Bold.Render("  gordon remotes add <name> <url>"))
51				return nil
52			}
53
54			fmt.Println(styles.Theme.Title.Render("Saved Remotes"))
55			fmt.Println()
56
57			// Build table rows
58			rows := make([][]string, 0, len(remotes))
59			for name, r := range remotes {
60				status := ""
61				if name == active {
62					status = styles.Theme.Success.Render("active")
63				}
64
65				// Mask token if present
66				tokenStatus := styles.Theme.Muted.Render("none")
67				if r.Token != "" {
68					tokenStatus = styles.Theme.Success.Render("set")
69				} else if r.TokenEnv != "" {
70					tokenStatus = fmt.Sprintf("$%s", r.TokenEnv)
71				}
72
73				rows = append(rows, []string{name, r.URL, tokenStatus, status})
74			}
75
76			// Render table
77			table := components.NewTable(
78				components.WithColumns([]components.TableColumn{
79					{Title: "Name", Width: 15},
80					{Title: "URL", Width: 35},
81					{Title: "Token", Width: 15},
82					{Title: "Status", Width: 10},
83				}),
84				components.WithRows(rows),
85			)
86
87			fmt.Println(table.View())
88
89			return nil
90		},
91	}
92}
93
94// newRemotesAddCmd creates the remotes add command.
95func newRemotesAddCmd() *cobra.Command {
96	var token string
97	var tokenEnv string
98
99	cmd := &cobra.Command{
100		Use:   "add <name> <url>",
101		Short: "Add a new remote",
102		Long: `Add a new saved remote.
103
104Examples:
105  gordon remotes add prod https://gordon.mydomain.com
106  gordon remotes add prod https://gordon.mydomain.com --token eyJ...
107  gordon remotes add staging https://staging.mydomain.com --token-env STAGING_TOKEN`,
108		Args: cobra.ExactArgs(2),
109		RunE: func(cmd *cobra.Command, args []string) error {
110			name := args[0]
111			url := normalizeURL(args[1])
112
113			// If token-env is provided, use that instead of token
114			finalToken := token
115			if tokenEnv != "" {
116				// Store token_env reference instead of actual token
117				if err := addRemoteWithEnv(name, url, tokenEnv); err != nil {
118					return fmt.Errorf("failed to add remote: %w", err)
119				}
120			} else {
121				if err := remote.AddRemote(name, url, finalToken); err != nil {
122					return fmt.Errorf("failed to add remote: %w", err)
123				}
124			}
125
126			fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote added: %s -> %s", name, url)))
127
128			if token == "" && tokenEnv == "" {
129				fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token or --token-env for authenticated access"))
130			}
131
132			return nil
133		},
134	}
135
136	cmd.Flags().StringVar(&token, "token", "", "Authentication token")
137	cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token")
138
139	return cmd
140}
141
142// addRemoteWithEnv adds a remote with a token environment variable reference.
143func addRemoteWithEnv(name, url, tokenEnv string) error {
144	config, err := remote.LoadRemotes("")
145	if err != nil {
146		return err
147	}
148
149	config.Remotes[name] = remote.RemoteEntry{
150		URL:      url,
151		TokenEnv: tokenEnv,
152	}
153
154	return remote.SaveRemotes("", config)
155}
156
157// newRemotesRemoveCmd creates the remotes remove command.
158func newRemotesRemoveCmd() *cobra.Command {
159	var force bool
160
161	cmd := &cobra.Command{
162		Use:     "remove <name>",
163		Aliases: []string{"rm"},
164		Short:   "Remove a remote",
165		Args:    cobra.ExactArgs(1),
166		RunE: func(cmd *cobra.Command, args []string) error {
167			name := args[0]
168
169			// Check if remote exists
170			remotes, active, err := remote.ListRemotes()
171			if err != nil {
172				return fmt.Errorf("failed to list remotes: %w", err)
173			}
174
175			if _, exists := remotes[name]; !exists {
176				fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name)))
177				return nil
178			}
179
180			// Confirm unless --force
181			if !force {
182				message := fmt.Sprintf("Remove remote '%s'?", name)
183				if name == active {
184					message = fmt.Sprintf("Remove remote '%s'? (currently active)", name)
185				}
186
187				confirmed, err := components.RunConfirm(message)
188				if err != nil {
189					return err
190				}
191				if !confirmed {
192					fmt.Println(styles.Theme.Muted.Render("Cancelled"))
193					return nil
194				}
195			}
196
197			if err := remote.RemoveRemote(name); err != nil {
198				return fmt.Errorf("failed to remove remote: %w", err)
199			}
200
201			fmt.Println(styles.RenderSuccess(fmt.Sprintf("Remote removed: %s", name)))
202			return nil
203		},
204	}
205
206	cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
207
208	return cmd
209}
210
211// newRemotesUseCmd creates the remotes use command.
212func newRemotesUseCmd() *cobra.Command {
213	return &cobra.Command{
214		Use:   "use <name>",
215		Short: "Set the active remote",
216		Long: `Set a remote as the active default.
217
218When a remote is active, it will be used automatically for remote commands
219without needing to specify --remote.
220
221Examples:
222  gordon remotes use prod
223  gordon routes list  # Uses prod remote automatically`,
224		Args: cobra.ExactArgs(1),
225		RunE: func(cmd *cobra.Command, args []string) error {
226			name := args[0]
227
228			if err := remote.SetActiveRemote(name); err != nil {
229				if err.Error() == fmt.Sprintf("remote '%s' not found", name) {
230					fmt.Println(styles.RenderError(fmt.Sprintf("Remote '%s' not found", name)))
231					fmt.Println()
232					fmt.Println("Available remotes:")
233
234					remotes, _, _ := remote.ListRemotes()
235					for remoteName := range remotes {
236						fmt.Printf("  %s\n", remoteName)
237					}
238					return nil
239				}
240				return fmt.Errorf("failed to set active remote: %w", err)
241			}
242
243			fmt.Println(styles.RenderSuccess(fmt.Sprintf("Active remote set to '%s'", name)))
244			return nil
245		},
246	}
247}
248
249// normalizeURL ensures the URL has a protocol scheme, defaulting to https.
250func normalizeURL(url string) string {
251	if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
252		return url
253	}
254	return "https://" + url
255}

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