From 88d0afe54ad57c8082f4ddb3da6b3e090ad9c2c6 Mon Sep 17 00:00:00 2001 From: tcsenpai Date: Thu, 22 Oct 2026 09:01:47 +0200 Subject: [PATCH] feat(ui): key browser and IdentitiesOnly next to Keys Add a file browser (Ctrl+O / Pick key button) to choose a host's private key, with ssh-keygen fingerprint preview. Picking a key sets IdentitiesOnly=yes when unset, so ssh uses only that key instead of trying every identity in the agent. IdentitiesOnly moves from the Authentication to the Basic tab, next to Keys. --- README.md | 2 + internal/adapters/ui/field_help.go | 4 +- internal/adapters/ui/key_picker.go | 337 ++++++++++++++++++++++++ internal/adapters/ui/key_picker_test.go | 107 ++++++++ internal/adapters/ui/server_form.go | 92 ++++++- 5 files changed, 530 insertions(+), 12 deletions(-) create mode 100644 internal/adapters/ui/key_picker.go create mode 100644 internal/adapters/ui/key_picker_test.go diff --git a/README.md b/README.md index 4b50d7fd..c63f9542 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ With lazyssh, you can quickly navigate, connect, manage, and transfer files betw ### Key Management - 🔑 SSH key autocomplete with automatic detection of available keys. - 📝 Smart key selection with support for multiple keys. +- 📂 Key browser (`Ctrl+O` in the form): pick a private key from a folder tree with its fingerprint. Picking a key also sets `IdentitiesOnly yes`, so the host uses only that key instead of trying every key in your agent. ### Upcoming @@ -185,6 +186,7 @@ make run | Ctrl+H | Previous tab | | Ctrl+L | Next tab | | Ctrl+S | Save | +| Ctrl+O | Browse for an SSH key | | Esc | Cancel | Tip: The hint bar at the top of the list shows the most useful shortcuts. diff --git a/internal/adapters/ui/field_help.go b/internal/adapters/ui/field_help.go index b55210a3..0b6f2ee4 100644 --- a/internal/adapters/ui/field_help.go +++ b/internal/adapters/ui/field_help.go @@ -129,7 +129,7 @@ var fieldHelpData = map[string]FieldHelp{ }, "Keys": { Field: "Keys", - Description: "Path to SSH private key files for authentication. Multiple keys can be specified.", + Description: "Path to SSH private key files for authentication. Multiple keys can be specified. Press Ctrl+O (or the Pick key button) to browse for a key; picking one sets IdentitiesOnly to yes so ssh uses only the chosen key.", Syntax: "path[,path,...]", Examples: []string{"~/.ssh/id_ed25519", "~/.ssh/id_rsa,~/.ssh/id_ed25519"}, Default: "~/.ssh/id_rsa, ~/.ssh/id_ed25519, etc.", @@ -266,7 +266,7 @@ var fieldHelpData = map[string]FieldHelp{ }, "IdentitiesOnly": { Field: "IdentitiesOnly", - Description: "Only use authentication identity files configured in ssh_config, ignore ssh-agent.", + Description: "Only use the identity files configured here, ignoring other keys offered by ssh-agent and the default key names. Set to yes when a host is bound to a specific key, to avoid 'Too many authentication failures'.", Syntax: "yes | no", Examples: []string{"yes", "no"}, Default: "no", diff --git a/internal/adapters/ui/key_picker.go b/internal/adapters/ui/key_picker.go new file mode 100644 index 00000000..2368e82d --- /dev/null +++ b/internal/adapters/ui/key_picker.go @@ -0,0 +1,337 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ui + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +const ( + // maxKeySniffSize skips big files: private keys are a few KB at most. + maxKeySniffSize = 64 * 1024 + // keygenTimeout bounds the ssh-keygen fingerprint lookup. + keygenTimeout = 2 * time.Second + + keyPickerHint = "[white]Enter[-] Use as only key • [white]+[-] Add to keys • [white]r[-] ~/.ssh ↔ ~ • " + + "[white]a[-] All files • [white].[-] Hidden • [white]Esc[-] Cancel" +) + +// looksLikePrivateKey reports whether path is a regular file whose first line is a +// PEM/OpenSSH private key header. Only the header line is read, never the key material. +func looksLikePrivateKey(path string) bool { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() || info.Size() > maxKeySniffSize { + return false + } + // #nosec G304 - path comes from directory enumeration of the picker + file, err := os.Open(path) + if err != nil { + return false + } + defer func() { _ = file.Close() }() + + buf := make([]byte, 64) + n, _ := file.Read(buf) + first, _, _ := bytes.Cut(buf[:n], []byte("\n")) + return bytes.HasPrefix(first, []byte("-----BEGIN")) && bytes.Contains(first, []byte("PRIVATE KEY")) +} + +// tildePath shortens a path under the home directory to the "~/..." form used in ssh configs. +func tildePath(path string) string { + home, err := os.UserHomeDir() + if err != nil { + return path + } + rel, err := filepath.Rel(home, path) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return path + } + if rel == "." { + return "~" + } + return "~/" + filepath.ToSlash(rel) +} + +// expandTilde resolves a leading "~" to the home directory. +func expandTilde(path string) string { + if path != "~" && !strings.HasPrefix(path, "~/") { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + return filepath.Join(home, strings.TrimPrefix(path, "~")) +} + +// mergeKeys returns the new value of the Keys field after picking key. +// With add=false the picked key replaces the field; with add=true it is appended (no duplicates). +func mergeKeys(current, picked string, add bool) string { + if !add { + return picked + } + var keys []string + for _, k := range strings.Split(current, ",") { + if k = strings.TrimSpace(k); k != "" { + keys = append(keys, k) + } + } + for _, k := range keys { + if k == picked { + return strings.Join(keys, ", ") + } + } + return strings.Join(append(keys, picked), ", ") +} + +// keyFingerprint describes a key through ssh-keygen. The .pub sibling is preferred, and an +// empty passphrase is always passed so ssh-keygen can never prompt on the terminal. +func keyFingerprint(ctx context.Context, path string) (string, error) { + target := path + if !strings.HasSuffix(path, ".pub") { + if st, err := os.Stat(path + ".pub"); err == nil && st.Mode().IsRegular() { + target = path + ".pub" + } + } + ctx, cancel := context.WithTimeout(ctx, keygenTimeout) + defer cancel() + // #nosec G204 - fixed binary; target is passed as a single argument, not through a shell + out, err := exec.CommandContext(ctx, "ssh-keygen", "-l", "-f", target, "-P", "").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +type pickerNode struct { + path string + isDir bool +} + +// KeyPicker is a full-screen file browser that lists private keys (and folders) so a host +// can be bound to a specific key without typing its path. +type KeyPicker struct { + *tview.Flex + app *tview.Application + tree *tview.TreeView + info *tview.TextView + + root string + showAll bool + showHidden bool + seq int // incremented on every highlight; drops stale fingerprint results + + onPick func(path string, add bool) + onCancel func() +} + +// NewKeyPicker builds the picker rooted at startDir (falls back to ~/.ssh, then ~). +// onPick receives the path in "~/..." form; add=true means "append to the existing keys". +func NewKeyPicker(app *tview.Application, startDir string, onPick func(string, bool), onCancel func()) *KeyPicker { + kp := &KeyPicker{ + Flex: tview.NewFlex().SetDirection(tview.FlexRow), + app: app, + tree: tview.NewTreeView(), + info: tview.NewTextView().SetDynamicColors(true), + onPick: onPick, + onCancel: onCancel, + } + kp.tree.SetBorder(true).SetTitle(" Pick SSH key ").SetTitleAlign(tview.AlignCenter) + kp.tree.SetGraphics(true).SetTopLevel(0) + kp.tree.SetChangedFunc(kp.describe) + kp.tree.SetSelectedFunc(kp.selected) + kp.info.SetBorder(true).SetTitle(" Key ").SetBorderPadding(0, 0, 1, 1) + + hint := tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignCenter) + hint.SetBackgroundColor(tcell.Color235) + hint.SetText(keyPickerHint) + + kp.AddItem(kp.tree, 0, 1, true).AddItem(kp.info, 4, 0, false).AddItem(hint, 1, 0, false) + kp.tree.SetInputCapture(kp.handleKey) + + kp.setRoot(kp.initialRoot(startDir)) + return kp +} + +func (kp *KeyPicker) initialRoot(startDir string) string { + if st, err := os.Stat(startDir); err == nil && st.IsDir() { + return startDir + } + home, err := os.UserHomeDir() + if err != nil { + return "." + } + if sshDir := filepath.Join(home, ".ssh"); isDir(sshDir) { + return sshDir + } + return home +} + +func isDir(path string) bool { + st, err := os.Stat(path) + return err == nil && st.IsDir() +} + +func (kp *KeyPicker) setRoot(dir string) { + kp.root = dir + rootNode := tview.NewTreeNode(tview.Escape(tildePath(dir))). + SetReference(&pickerNode{path: dir, isDir: true}). + SetColor(tcell.ColorYellow) + kp.loadChildren(rootNode) + rootNode.SetExpanded(true) + kp.tree.SetRoot(rootNode) + if children := rootNode.GetChildren(); len(children) > 0 { + kp.tree.SetCurrentNode(children[0]) + } else { + kp.tree.SetCurrentNode(rootNode) + } + kp.describe(kp.tree.GetCurrentNode()) +} + +// reload rebuilds the tree after a filter toggle, keeping the current root. +func (kp *KeyPicker) reload() { kp.setRoot(kp.root) } + +func (kp *KeyPicker) loadChildren(node *tview.TreeNode) { + ref, ok := node.GetReference().(*pickerNode) + if !ok || !ref.isDir { + return + } + node.ClearChildren() + entries, err := os.ReadDir(ref.path) + if err != nil { + node.AddChild(tview.NewTreeNode("[red](cannot read folder)[-]").SetSelectable(false)) + return + } + sort.SliceStable(entries, func(i, j int) bool { + di, dj := isDir(filepath.Join(ref.path, entries[i].Name())), isDir(filepath.Join(ref.path, entries[j].Name())) + return di && !dj + }) + for _, e := range entries { + name := e.Name() + if !kp.showHidden && strings.HasPrefix(name, ".") { + continue + } + full := filepath.Join(ref.path, name) + switch { + case isDir(full): + node.AddChild(tview.NewTreeNode(tview.Escape(name) + "/"). + SetReference(&pickerNode{path: full, isDir: true}). + SetColor(tcell.ColorSteelBlue). + SetExpanded(false)) + case kp.showAll || looksLikePrivateKey(full): + node.AddChild(tview.NewTreeNode(tview.Escape(name)). + SetReference(&pickerNode{path: full}). + SetColor(tcell.ColorGreen)) + } + } + if len(node.GetChildren()) == 0 { + node.AddChild(tview.NewTreeNode("[gray](no keys here - 'a' shows all files)[-]").SetSelectable(false)) + } +} + +func (kp *KeyPicker) selected(node *tview.TreeNode) { + ref, ok := node.GetReference().(*pickerNode) + if !ok { + return + } + if ref.isDir { + if node.IsExpanded() { + node.Collapse() + return + } + kp.loadChildren(node) + node.Expand() + return + } + kp.onPick(tildePath(ref.path), false) +} + +func (kp *KeyPicker) handleKey(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + kp.onCancel() + return nil + } + if event.Key() != tcell.KeyRune { + return event + } + switch event.Rune() { + case '+': + if ref, ok := kp.tree.GetCurrentNode().GetReference().(*pickerNode); ok && !ref.isDir { + kp.onPick(tildePath(ref.path), true) + } + return nil + case 'r': + home, err := os.UserHomeDir() + if err != nil { + return nil + } + if sshDir := filepath.Join(home, ".ssh"); kp.root != sshDir && isDir(sshDir) { + kp.setRoot(sshDir) + } else { + kp.setRoot(home) + } + return nil + case 'a': + kp.showAll = !kp.showAll + kp.reload() + return nil + case '.': + kp.showHidden = !kp.showHidden + kp.reload() + return nil + } + return event +} + +// describe fills the info box for the highlighted node; fingerprints are computed off the UI thread. +func (kp *KeyPicker) describe(node *tview.TreeNode) { + kp.seq++ + seq := kp.seq + ref, ok := node.GetReference().(*pickerNode) + if !ok { + kp.info.SetText("") + return + } + if ref.isDir { + kp.info.SetText(tview.Escape(tildePath(ref.path)) + "\n[gray]Enter opens/closes the folder[-]") + return + } + kp.info.SetText(tview.Escape(tildePath(ref.path)) + "\n[gray]reading fingerprint...[-]") + go func() { + fp, err := keyFingerprint(context.Background(), ref.path) + kp.app.QueueUpdateDraw(func() { + if seq != kp.seq { + return + } + text := tview.Escape(tildePath(ref.path)) + "\n" + if err != nil { + text += "[yellow]not recognized as an SSH key (or ssh-keygen unavailable)[-]" + } else { + text += "[green]" + tview.Escape(fp) + "[-]" + } + kp.info.SetText(text) + }) + }() +} diff --git a/internal/adapters/ui/key_picker_test.go b/internal/adapters/ui/key_picker_test.go new file mode 100644 index 00000000..fec3a741 --- /dev/null +++ b/internal/adapters/ui/key_picker_test.go @@ -0,0 +1,107 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ui + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLooksLikePrivateKey(t *testing.T) { + dir := t.TempDir() + write := func(name, content string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return p + } + + tests := []struct { + name string + path string + want bool + }{ + {"openssh key", write("id_ed25519", "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n"), true}, + {"pem rsa key", write("id_rsa", "-----BEGIN RSA PRIVATE KEY-----\nAAAA\n"), true}, + {"public key", write("id.pub", "ssh-ed25519 AAAA user@host\n"), false}, + {"certificate", write("cert.pem", "-----BEGIN CERTIFICATE-----\nAAAA\n"), false}, + {"known_hosts", write("known_hosts", "host ssh-ed25519 AAAA\n"), false}, + {"empty file", write("empty", ""), false}, + {"oversized file", write("big", "-----BEGIN OPENSSH PRIVATE KEY-----\n"+strings.Repeat("A", maxKeySniffSize)), false}, + {"directory", dir, false}, + {"missing", filepath.Join(dir, "nope"), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := looksLikePrivateKey(tt.path); got != tt.want { + t.Errorf("looksLikePrivateKey() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTildePathAndExpandTilde(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + tests := []struct { + abs, tilde string + }{ + {filepath.Join(home, ".ssh", "id_ed25519"), "~/.ssh/id_ed25519"}, + {home, "~"}, + } + for _, tt := range tests { + if got := tildePath(tt.abs); got != tt.tilde { + t.Errorf("tildePath(%q) = %q, want %q", tt.abs, got, tt.tilde) + } + if got := expandTilde(tt.tilde); got != tt.abs { + t.Errorf("expandTilde(%q) = %q, want %q", tt.tilde, got, tt.abs) + } + } + + outside := filepath.Join(filepath.Dir(home), "elsewhere", "key") + if got := tildePath(outside); got != outside { + t.Errorf("tildePath(%q) = %q, want unchanged", outside, got) + } + if got := expandTilde("~other/key"); got != "~other/key" { + t.Errorf("expandTilde must not touch ~user forms, got %q", got) + } +} + +func TestMergeKeys(t *testing.T) { + tests := []struct { + name, current, picked string + add bool + want string + }{ + {"replace", "~/.ssh/a, ~/.ssh/b", "~/.ssh/c", false, "~/.ssh/c"}, + {"add to empty", "", "~/.ssh/c", true, "~/.ssh/c"}, + {"add appends", "~/.ssh/a", "~/.ssh/c", true, "~/.ssh/a, ~/.ssh/c"}, + {"add normalizes separators", "~/.ssh/a,~/.ssh/b", "~/.ssh/c", true, "~/.ssh/a, ~/.ssh/b, ~/.ssh/c"}, + {"add skips duplicate", "~/.ssh/a, ~/.ssh/c", "~/.ssh/c", true, "~/.ssh/a, ~/.ssh/c"}, + {"add ignores empty items", "~/.ssh/a, ,", "~/.ssh/c", true, "~/.ssh/a, ~/.ssh/c"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mergeKeys(tt.current, tt.picked, tt.add); got != tt.want { + t.Errorf("mergeKeys() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/adapters/ui/server_form.go b/internal/adapters/ui/server_form.go index 286b47f5..efcf79a7 100644 --- a/internal/adapters/ui/server_form.go +++ b/internal/adapters/ui/server_form.go @@ -16,6 +16,7 @@ package ui import ( "fmt" + "path/filepath" "reflect" "strconv" "strings" @@ -65,6 +66,12 @@ type ServerForm struct { helpMode HelpDisplayMode // Current help display mode currentField string // Currently focused field mainContainer *tview.Flex // Container for form and help panel + + keysField *tview.InputField // Keys input on the Basic tab + keysIndex int // Position of the Keys input in the Basic form + identitiesOnly *tview.DropDown // IdentitiesOnly dropdown on the Basic tab + identitiesOnlyOptions []string + pickingKey bool // true while a picked key is applied (suppresses the Keys autocomplete popup) } func NewServerForm(mode ServerFormMode, original *domain.Server) *ServerForm { @@ -172,7 +179,7 @@ func (sf *ServerForm) build() { hintBar := tview.NewTextView().SetDynamicColors(true) hintBar.SetBackgroundColor(tcell.Color235) hintBar.SetTextAlign(tview.AlignCenter) - hintBar.SetText("[white]^H/^L[-] Navigate • [white]^S[-] Save • [white]Esc[-] Cancel") + hintBar.SetText("[white]^H/^L[-] Navigate • [white]^S[-] Save • [white]^O[-] Pick key • [white]Esc[-] Cancel") // Setup main container - header at top, hint bar at bottom sf.Flex.AddItem(sf.header, 2, 0, false). @@ -563,6 +570,10 @@ func (sf *ServerForm) setupKeyboardShortcuts() { // Ctrl+L: Next tab (backup handler) sf.nextTab() return nil + case tcell.KeyCtrlO: + // Ctrl+O: Open the SSH key browser + sf.openKeyPicker() + return nil default: // Pass through all other keys } @@ -601,6 +612,9 @@ func (sf *ServerForm) setupFormShortcuts(form *tview.Form) { case tcell.KeyCtrlL: sf.nextTab() return nil + case tcell.KeyCtrlO: + sf.openKeyPicker() + return nil case tcell.KeyCtrlS: sf.handleSave() return nil @@ -947,7 +961,9 @@ func (sf *ServerForm) validateField(fieldName, value string) string { } // addDropDownWithHelp adds a dropdown field with help support -func (sf *ServerForm) addDropDownWithHelp(form *tview.Form, label, fieldName string, options []string, initialOption int) { +func (sf *ServerForm) addDropDownWithHelp( + form *tview.Form, label, fieldName string, options []string, initialOption int, +) *tview.DropDown { dropdown := tview.NewDropDown(). SetLabel(label). SetOptions(options, nil). @@ -959,6 +975,52 @@ func (sf *ServerForm) addDropDownWithHelp(form *tview.Form, label, fieldName str }) form.AddFormItem(dropdown) + return dropdown +} + +// openKeyPicker shows the key browser. Picking a key fills the Keys field and, when +// IdentitiesOnly is still at its default, sets it to "yes" so ssh stops trying every other key. +func (sf *ServerForm) openKeyPicker() { + if sf.app == nil || sf.keysField == nil { + return + } + startDir := "" + if first := strings.TrimSpace(strings.Split(sf.keysField.GetText(), ",")[0]); first != "" { + startDir = filepath.Dir(expandTilde(first)) + } + picker := NewKeyPicker(sf.app, startDir, func(path string, add bool) { + sf.applyPickedKey(path, add) + sf.closeKeyPicker() + }, sf.closeKeyPicker) + sf.app.SetRoot(picker, true).SetFocus(picker.tree) +} + +func (sf *ServerForm) closeKeyPicker() { + sf.app.SetRoot(sf.Flex, true) + sf.switchToTab("Basic") + if form, ok := sf.forms["Basic"]; ok { + form.SetFocus(sf.keysIndex) + sf.app.SetFocus(form) + } +} + +func (sf *ServerForm) applyPickedKey(path string, add bool) { + sf.keysField.SetText(mergeKeys(sf.keysField.GetText(), path, add)) + if _, current := sf.identitiesOnly.GetCurrentOption(); parseOptionValue(current) == "" { + sf.identitiesOnly.SetCurrentOption(sf.findOptionIndex(sf.identitiesOnlyOptions, "yes")) + } + sf.hideKeysAutocomplete() + // The event that triggered the pick may re-open the popup once it returns: close it again afterwards. + // QueueUpdateDraw blocks until it ran, so it must not be called from the UI goroutine. + go sf.app.QueueUpdateDraw(sf.hideKeysAutocomplete) +} + +// hideKeysAutocomplete closes the Keys autocomplete popup (it would cover the IdentitiesOnly field). +// Autocomplete() refreshes the list through the callback, which returns nothing while pickingKey is set. +func (sf *ServerForm) hideKeysAutocomplete() { + sf.pickingKey = true + sf.keysField.Autocomplete() + sf.pickingKey = false } // addInputFieldWithHelp adds a regular input field with help support @@ -1247,14 +1309,29 @@ func (sf *ServerForm) createBasicForm() { sf.addValidatedInputField(form, "User:", "User", defaultValues.User, 20, GetFieldPlaceholder("User")) sf.addValidatedInputField(form, "Port:", "Port", defaultValues.Port, 20, GetFieldPlaceholder("Port")) - // Keys field with autocomplete - keysField := sf.addValidatedInputField(form, "Keys:", "Keys", defaultValues.Key, 40, GetFieldPlaceholder("Keys")) - keysField.SetAutocompleteFunc(sf.createSSHKeyAutocomplete()) + // Keys field with autocomplete (^O or the "Pick key" button opens the key browser) + sf.keysField = sf.addValidatedInputField(form, "Keys:", "Keys", defaultValues.Key, 40, GetFieldPlaceholder("Keys")) + keysAutocomplete := sf.createSSHKeyAutocomplete() + sf.keysField.SetAutocompleteFunc(func(text string) []string { + if sf.pickingKey { + return nil + } + return keysAutocomplete(text) + }) + sf.keysIndex = form.GetFormItemCount() - 1 + + // IdentitiesOnly sits next to Keys: binding a host to a key only works if ssh is + // told not to try every other identity offered by the agent / default key names. + sf.identitiesOnlyOptions = createOptionsWithDefault("IdentitiesOnly", []string{"", "yes", "no"}) + identitiesOnlyIndex := sf.findOptionIndex(sf.identitiesOnlyOptions, defaultValues.IdentitiesOnly) + sf.identitiesOnly = sf.addDropDownWithHelp(form, "IdentitiesOnly:", "IdentitiesOnly", + sf.identitiesOnlyOptions, identitiesOnlyIndex) // Tags field sf.addValidatedInputField(form, "Tags:", "Tags", defaultValues.Tags, 30, GetFieldPlaceholder("Tags")) // Add save and cancel buttons + form.AddButton("Pick key", sf.openKeyPicker) form.AddButton("Save", sf.handleSaveButton) form.AddButton("Cancel", sf.handleCancel) @@ -1486,11 +1563,6 @@ func (sf *ServerForm) createAuthenticationForm() { pubkeyIndex := sf.findOptionIndex(pubkeyOptions, defaultValues.PubkeyAuthentication) sf.addDropDownWithHelp(form, "PubkeyAuthentication:", "PubkeyAuthentication", pubkeyOptions, pubkeyIndex) - // IdentitiesOnly dropdown - controls whether to use only specified identity files - identitiesOnlyOptions := createOptionsWithDefault("IdentitiesOnly", []string{"", "yes", "no"}) - identitiesOnlyIndex := sf.findOptionIndex(identitiesOnlyOptions, defaultValues.IdentitiesOnly) - sf.addDropDownWithHelp(form, "IdentitiesOnly:", "IdentitiesOnly", identitiesOnlyOptions, identitiesOnlyIndex) - // SSH Agent settings form.AddTextView("\n[yellow]▶ SSH Agent[-]", "", 0, 1, true, false)