v2.4.0 79b3461

remotes.go RawHistoryFolder

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

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