Skip to content

Commit fa46cf7

Browse files
claudealexkucherenko
authored andcommitted
feat: add Git SSH key configuration feature (issue #91)
This commit implements the feature requested in issue #91 to help users configure which SSH key Git should use for repository operations. Changes: - Added GitService with methods to detect git repositories, list SSH keys, and configure git to use specific SSH keys - Added SSHKey struct to represent SSH key metadata - Created GitSSHSetup UI modal for interactive SSH key selection - Added keyboard shortcut 'G' to trigger Git SSH setup modal - Updated status bar to show the new Git SSH shortcut - Integrated git service into TUI application The feature allows users to: - Detect if they are in a git repository - List available SSH keys from ~/.ssh/ - Select an SSH key to use for git operations - Configure git via 'git config core.sshCommand' at local or global scope - View current SSH configuration for the repository This addresses the common scenario of managing multiple SSH identities (company, personal, vendor accounts) by making it easy to configure which key Git should use for a specific repository. fix: discover SSH keys from config files, not just ~/.ssh/ This fix addresses the issue where SSH keys stored outside of ~/.ssh/ directory were not being discovered by the Git SSH setup feature. Changes: - Updated ListSSHKeys to accept ServerRepository parameter - Added logic to extract IdentityFile paths from SSH config - Keys from SSH config are now the primary source - ~/.ssh/ directory is scanned as a secondary source - Refactored complex function into smaller helpers to reduce cyclomatic complexity - Added proper error handling for missing ~/.ssh/ when config keys exist The feature now correctly discovers SSH keys from: 1. SSH config file (primary source) - reads IdentityFile directives 2. ~/.ssh/ directory (secondary source) - scans for additional keys This ensures that SSH keys stored in custom locations (e.g., /home/user/workspace/_keys_/) are properly discovered and can be configured for Git operations. feat: add keychain/ssh-agent integration and git repo indicator This commit enhances the Git SSH key configuration feature with: 1. Keychain/ssh-agent integration: - Detect which SSH keys are currently loaded in ssh-agent/keychain - Mark loaded keys with "in agent" indicator (green) - Sort keys to show agent-loaded keys first - Added GetLoadedAgentKeys() method to check ssh-add -l 2. Git repository indicator panel: - New GitInfo panel displays when in a git repository - Shows repository name and SSH configuration status - Appears at bottom of server list (4 lines) - Prompts user to press 'G' if SSH not configured 3. Enhanced UI feedback: - Keys in agent shown with [green](in agent)[-] tag - Count of keys loaded in agent displayed in setup dialog - Keys without .pub file shown with [red](no .pub)[-] - Encrypted keys shown with [yellow](encrypted)[-] 4. Improved key discovery: - Keys from SSH config (primary source) checked against agent - Keys from ~/.ssh/ (secondary source) also checked - Full key path matching against ssh-add output This addresses the keychain integration requirements from issue #91, making it easier to see which keys are ready to use and providing better visibility when working in git repositories. feat: add "Clear Configuration" button to reset Git SSH config This commit adds the ability to reset Git SSH configuration to default with a single click from the UI. Changes: 1. Added ClearGitSSHConfig() method to GitService - Supports clearing local, global, or both configurations - Handles "key not found" gracefully (exit code 5) - Uses `git config --unset core.sshCommand` 2. Added scope constants (ScopeLocal, ScopeGlobal, ScopeBoth) - Exported constants for consistent scope handling - Used across service and UI layers 3. Enhanced Git SSH Setup UI - "Clear Configuration" button appears when config exists - Confirmation modal with three options: * Cancel - return to setup * Clear Local - remove repository-level config only * Clear Both - remove both local and global configs - Success/error modals with clear feedback User Experience: - Press 'G' to open Git SSH setup - If SSH config exists, "Clear Configuration" button is shown - Click to confirm which scope to clear - Git reverts to default SSH behavior (ssh-agent, ~/.ssh/config, default keys) This addresses the user request to easily reset Git configuration back to default without running manual git commands. feat: display keychain-loaded SSH keys in servers list Add virtual server entries for SSH keys loaded in ssh-agent/keychain. These entries appear in the main servers list with a "keychain" tag and are read-only (cannot be edited or deleted). Changes: - Add GetKeychainServers() method to GitService that parses ssh-add -L - Modify ServerService to accept GitService dependency - Update ListServers() to merge keychain servers with repository servers - Add isKeychainServer() helper function in UI handlers - Guard edit/delete operations to prevent modification of keychain servers - Display error message when attempting to edit/delete keychain servers Addresses issue #91 comment about showing keychain-loaded keys in the list. fix: linter error and Git info panel refresh issues Fix two issues: 1. Linter error (prealloc): Pre-allocate servers slice with capacity in GetKeychainServers() to avoid prealloc lint error. 2. Git info panel not refreshing: Update handleModalClose() to call updateGitInfoPanel() so the panel refreshes after Git SSH config is cleared, showing the correct status instead of stale "SSH configured". This ensures the Git Repository panel displays accurate information after configuration changes. feat: improve keychain server display and prevent invalid operations Improvements to keychain server handling: 1. Display format: Show full key info like "rsa:4096 SHA256:5NUhY... user@email" - Parse ssh-add -l output (fingerprint list) instead of -L - Include key type, size, truncated fingerprint, and comment - Makes keychain entries clearly distinguishable from regular servers 2. Prevent invalid operations on keychain servers: - SSH connection: Show informative message that keys are auto-used - Ping: Cannot ping virtual keychain entries - Copy SSH command: Not applicable for keychain keys - Port forwarding: Cannot forward through keychain keys - Stop forwarding: Cannot stop non-existent forwards All operations now check isKeychainServer() and display appropriate error messages, preventing app hangs and confusing behavior. Addresses user feedback about app hanging on Enter and unclear display. feat: add keychain visibility toggle (K key) Add opt-in visibility toggle for keychain-loaded SSH keys: 1. Toggle behavior: - Press 'K' to toggle keychain keys visibility - Default: hidden (keychain keys not shown) - When enabled: keychain keys appear in servers list - Status message shows current state: "Keychain keys: visible/hidden" 2. Implementation: - Add showKeychainKeys boolean field to TUI - Filter keychain servers in refreshServerList based on toggle - Add handleKeychainToggle() to flip state and refresh - Bind 'K' key to toggle handler - Update status bar to show 'K Keychain' command 3. Design rationale: - Keychain keys shown only when explicitly toggled on - Prevents clutter in normal server list - Users can enable when needed for reference - Maintains view-only nature (no edit/delete/SSH operations) This addresses user feedback about keychain keys needing opt-in visibility rather than always being displayed. feat: add SSH key management service layer Add foundational service layer for SSH key discovery and management: 1. Domain model (domain/sshkey.go): - SSHKey struct with comprehensive key information - Fields: Path, Name, Comment, Type, Size, Fingerprint - Status flags: LoadedInAgent, IsEncrypted, HasPublicKey - Source tracking: "config" or "agent" 2. Service interface (ports/services.go): - ListAllSSHKeys() - List all SSH keys from config + agent - LoadKeyToAgent() - Load key into ssh-agent - UnloadKeyFromAgent() - Remove key from ssh-agent 3. Service implementation (git_service.go): - Discover keys from SSH config IdentityFiles - Scan ~/.ssh/ directory for additional keys - Parse ssh-add -l output to identify loaded keys - Match keys by fingerprint to determine agent status - Parse key files to detect type and encryption - Use ssh-keygen to extract fingerprints - Sort keys: loaded first, then alphabetically 4. Load/Unload operations: - ssh-add <keyfile> for loading (interactive passphrase) - ssh-add -d <keyfile> for unloading Preparation for SSH Keys panel UI feature. Service layer is complete and ready for UI integration. fix: address linter warnings in SSH key parsing Fix gosec and gocritic linter warnings: 1. Add #nosec comments for validated file reads: - Reading SSH private key files from config - Reading public key files derived from private keys - ssh-keygen command with validated paths 2. Refactor if-else chains to switch statements: - Key type detection from ssh-add output - Private key format detection - Modern OpenSSH key type inference All file paths are validated before use - either from SSH config or scanned from ~/.ssh directory. feat: add SSH Keys panel to TUI (WIP) Add dedicated SSH Keys panel alongside Servers panel: 1. New UI Components: - SSHKeysList: List component for SSH keys with status indicators - SSHKeyDetails: Details panel showing key info and commands - Format: "[●] 🔒 keyname (type:size)" - green dot for loaded keys 2. TUI Layout Changes: - Left panel now contains: SearchBar, Servers, SSH Keys, Git Info - Right panel dynamically shows either ServerDetails or SSHKeyDetails - Removed keychain toggle (keys now in separate panel always visible) 3. Key Discovery: - Load SSH keys on startup using ListAllSSHKeys() - Show keys from SSH config + ~/.ssh/ + ssh-agent - Display loaded status, encryption, and public key availability 4. Filter Changes: - Remove ALL keychain servers from Servers list - They're now only in SSH Keys panel (no mixing) Still TODO: - Tab navigation between Servers and SSH Keys panels - Load/Unload key handlers (l/u keys) - Update status bar to reflect new commands - Focus management and visual feedback Build succeeds. Next phase: Add Tab navigation and load/unload functionality. fix: remove redundant sprintf in SSH keys list formatting Remove redundant fmt.Sprintf for key.Type since it's already a string. Addresses gocritic linter warning. feat: add Tab navigation between Servers and SSH Keys panels - Add Tab key handler to switch focus between panels - Implement visual feedback with border color changes (blue for active, gray for inactive) - Update j/k navigation to work with both Servers and SSH Keys panels - Add updatePanelBorders() and updateRightPanel() methods - Dynamically switch right panel details based on focus feat: implement load/unload SSH key handlers - Add 'l' key to load selected SSH key into ssh-agent - Add 'u' key to unload selected SSH key from ssh-agent - Keys only respond to l/u when SSH Keys panel is focused - Suspend TUI during load to allow passphrase entry - Automatically refresh SSH keys list after load/unload - Show status messages for success/failure - Add refreshSSHKeysList() helper method feat: update status bar with new SSH key commands - Add 'Tab' to switch between Servers and SSH Keys panels - Add 'l/u' for Load/Unload SSH key operations - Remove obsolete 'K Keychain' command feat: add Shift+Tab and mouse click support for panel navigation - Add Shift+Tab (KeyBacktab) support to switch between panels - Add mouse click handlers to both Servers and SSH Keys panels - Clicking on a panel switches focus to that panel - Both Tab and Shift+Tab toggle between the two panels feat: add separate methods for config and agent SSH keys - Add ListSSHKeysFromConfig() to get keys from SSH config files and ~/.ssh directory - Add ListSSHKeysFromAgent() to get keys currently loaded in ssh-agent - Add constants for SSH key types and file names to fix linter warnings - Convert if-else chains to switch statements per linter recommendations - These methods enable separate panels for config keys vs agent keys feat: split SSH keys into separate Config and Agent panels - Rename sshKeysList to sshConfigKeysList for config-based keys - Add sshAgentKeysList for keys loaded in ssh-agent - Update TUI to support 3 panels: Servers, SSH Keys (Config), SSH Agent - Tab/Shift+Tab cycle through all 3 panels (0→1→2→0) - Update navigation (j/k) to work with all panels - Update mouse click handlers for all 3 panels - Update load/unload handlers to get key from correct panel - Refresh both key lists separately after load/unload - Right panel shows server details for panel 0, key details for panels 1-2 - Border colors highlight active panel feat: consolidate to 2-panel layout and add SSH key details to server panel - Remove SSH Keys (Config) panel, keeping only Servers and SSH Agent panels - Update panel navigation to support 2-panel layout (Tab/Shift+Tab cycling) - Remove GetKeychainServers() and all keychain virtual server code - Enhance server details to show SSH key information (path, type, size, status, commands) - Update ServerDetails to fetch and display key details for configured identity files This simplifies the UI by consolidating SSH key information directly into the server details panel, eliminating the need for a separate config keys panel. fix: use fingerprint-based removal for unloading SSH keys from agent - Change UnloadKeyFromAgent to accept fingerprint instead of keyPath - Use `ssh-add -d -E sha256 <fingerprint>` for reliable key removal - Keys loaded in ssh-agent may not have the original file path available - Strip SHA256:/MD5: prefix from fingerprint before passing to ssh-add - Update handler to pass key.Fingerprint instead of key.Path This fixes the "Failed to unload key: exit status 1" error that occurred when trying to remove keys from ssh-agent using file paths. fix: load/unload ssh-add logic feat(ui): display push remote URL in git info panel - Add GetPushRemoteURL service method to retrieve remote name and URL - Prefer "origin" remote, falling back to first available push remote - Update git info display to show formatted remote URL on first line - Add visual highlighting for remote name and URL - Enhance panel focus with selected background color changes feat(ssh): add key comment editing functionality Implement ability to edit SSH key comments directly from the UI using the Shift+C key binding. This allows users to update key comments stored in public key files without manual command-line operations. The feature includes: - New modal dialog for editing key comments with validation - Integration with GitService for comment updates via ssh-keygen - Automatic handling of file permissions for read-only keys - Support for both server keys and agent-loaded keys - Visual feedback showing the comment field in details panels This resolves issue #91 by providing a complete UI workflow for managing SSH key metadata. fix(ui): resolve thread safety issues in SSH key loading handlers - Remove unnecessary goroutines from handleLoadKey and handleLoadServerKey - Suspend is blocking, allowing synchronous execution without UI freezing - Fix potential race conditions by executing UI updates on main thread - Add tilde expansion in getSSHKeyForServer to match identity files with home directory paths - Update status bar help text to include comment editing shortcut fix(ssh): handle encrypted key passphrase prompts when editing comments - Add TUI suspension for encrypted SSH keys to allow passphrase input - Connect stdin/stdout/stderr to ssh-keygen command for interactive prompts - Improve encryption detection by checking for "bcrypt" cipher indicator - Propagate encryption and public key status when merging agent keys - Update help text formatting in server details view - Change warning icon for missing public keys
1 parent 07cb2ab commit fa46cf7

File tree

14 files changed

+2856
-44
lines changed

14 files changed

+2856
-44
lines changed

cmd/main.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@ func main() {
5252
metaDataFile := filepath.Join(home, ".lazyssh", "metadata.json")
5353

5454
serverRepo := ssh_config_file.NewRepository(log, sshConfigFile, metaDataFile)
55-
serverService := services.NewServerService(log, serverRepo)
56-
tui := ui.NewTUI(log, serverService, version, gitCommit)
55+
gitService := services.NewGitService(log)
56+
gitService.SetServerRepository(serverRepo)
57+
serverService := services.NewServerService(log, serverRepo, gitService)
58+
tui := ui.NewTUI(log, serverService, serverRepo, gitService, version, gitCommit)
5759

5860
rootCmd := &cobra.Command{
5961
Use: ui.AppName,
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright 2025.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package ui
16+
17+
import (
18+
"fmt"
19+
"strings"
20+
21+
"github.com/gdamore/tcell/v2"
22+
"github.com/rivo/tview"
23+
)
24+
25+
// EditKeyComment is a modal dialog for editing SSH key comments.
26+
type EditKeyComment struct {
27+
*tview.Flex
28+
app *tview.Application
29+
form *tview.Form
30+
infoText *tview.TextView
31+
keyPath string
32+
keyName string
33+
initialComment string
34+
onSave func(comment string)
35+
onCancel func()
36+
}
37+
38+
// NewEditKeyComment creates a new comment edit modal.
39+
func NewEditKeyComment(app *tview.Application) *EditKeyComment {
40+
form := tview.NewForm()
41+
form.SetBorderPadding(1, 1, 2, 2)
42+
43+
infoText := tview.NewTextView()
44+
infoText.SetDynamicColors(true).
45+
SetTextAlign(tview.AlignLeft).
46+
SetBorderPadding(1, 1, 2, 2)
47+
48+
edit := &EditKeyComment{
49+
Flex: tview.NewFlex().SetDirection(tview.FlexRow),
50+
app: app,
51+
form: form,
52+
infoText: infoText,
53+
}
54+
55+
edit.AddItem(edit.infoText, 0, 1, false).
56+
AddItem(edit.form, 0, 2, true)
57+
58+
edit.SetBorder(true).
59+
SetTitle(" Edit SSH Key Comment ").
60+
SetTitleAlign(tview.AlignLeft)
61+
62+
return edit
63+
}
64+
65+
// SetKey sets the key name, path, and initial comment for editing.
66+
func (e *EditKeyComment) SetKey(name, path, comment string) *EditKeyComment {
67+
e.keyName = name
68+
e.keyPath = path
69+
e.initialComment = comment
70+
return e
71+
}
72+
73+
// OnSave sets the callback for when the user saves the comment.
74+
func (e *EditKeyComment) OnSave(fn func(comment string)) *EditKeyComment {
75+
e.onSave = fn
76+
return e
77+
}
78+
79+
// OnCancel sets the callback for when the user cancels.
80+
func (e *EditKeyComment) OnCancel(fn func()) *EditKeyComment {
81+
e.onCancel = fn
82+
return e
83+
}
84+
85+
// Show displays the modal dialog.
86+
func (e *EditKeyComment) Show() error {
87+
// Build info text
88+
infoText := fmt.Sprintf("Editing comment for SSH key:\n\n[yellow]%s[-]\n[dim]%s[-]", e.keyName, e.keyPath)
89+
e.infoText.SetText(infoText)
90+
91+
// Clear and rebuild form
92+
e.form.Clear(true)
93+
94+
// Add comment input field with validation
95+
e.form.AddInputField("Comment:", e.initialComment, 0,
96+
func(textToCheck string, lastChar rune) bool {
97+
// Limit to 220 characters
98+
return len(textToCheck) <= 220
99+
}, nil).
100+
SetLabelColor(tcell.ColorWhite).
101+
SetFieldBackgroundColor(tcell.Color236)
102+
103+
// Add buttons
104+
e.form.AddButton("Save", func() {
105+
e.save()
106+
})
107+
e.form.AddButton("Cancel", func() {
108+
e.cancel()
109+
})
110+
111+
// Set up key bindings
112+
e.form.SetInputCapture(e.handleInput)
113+
114+
// Focus the form
115+
e.app.SetFocus(e.form)
116+
117+
// Set as modal
118+
e.app.SetRoot(e, true)
119+
120+
return nil
121+
}
122+
123+
func (e *EditKeyComment) handleInput(event *tcell.EventKey) *tcell.EventKey {
124+
//nolint:exhaustive // We only handle specific keys, default handles the rest
125+
switch event.Key() {
126+
case tcell.KeyEscape, tcell.KeyCtrlC:
127+
e.cancel()
128+
return nil
129+
case tcell.KeyCtrlS:
130+
e.save()
131+
return nil
132+
default:
133+
// Let the form handle all other input
134+
return event
135+
}
136+
}
137+
138+
func (e *EditKeyComment) save() {
139+
// Get the comment from the input field (index 0)
140+
comment := e.form.GetFormItem(0).(*tview.InputField).GetText()
141+
142+
// Trim whitespace
143+
comment = strings.TrimSpace(comment)
144+
145+
// Enforce max length
146+
if len(comment) > 220 {
147+
comment = comment[:220]
148+
}
149+
150+
// Call the save callback
151+
if e.onSave != nil {
152+
e.onSave(comment)
153+
}
154+
155+
e.cancel()
156+
}
157+
158+
func (e *EditKeyComment) cancel() {
159+
if e.onCancel != nil {
160+
e.onCancel()
161+
}
162+
}

internal/adapters/ui/git_info.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright 2025.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package ui
16+
17+
import (
18+
"fmt"
19+
"os"
20+
"path/filepath"
21+
22+
"github.com/Adembc/lazyssh/internal/core/ports"
23+
"github.com/gdamore/tcell/v2"
24+
"github.com/rivo/tview"
25+
)
26+
27+
type GitInfo struct {
28+
*tview.TextView
29+
gitService ports.GitService
30+
visible bool
31+
}
32+
33+
func NewGitInfo(gitService ports.GitService) *GitInfo {
34+
info := &GitInfo{
35+
TextView: tview.NewTextView(),
36+
gitService: gitService,
37+
visible: false,
38+
}
39+
40+
info.
41+
SetDynamicColors(true).
42+
SetTextAlign(tview.AlignLeft).
43+
SetBorder(true).
44+
SetTitle(" Git Repository ").
45+
SetTitleAlign(tview.AlignLeft).
46+
SetBackgroundColor(tcell.Color232).
47+
SetBorderColor(tcell.Color238)
48+
49+
return info
50+
}
51+
52+
func (g *GitInfo) Update() {
53+
if g.gitService == nil {
54+
g.visible = false
55+
return
56+
}
57+
58+
cwd, err := os.Getwd()
59+
if err != nil {
60+
g.visible = false
61+
return
62+
}
63+
64+
if !g.gitService.IsGitRepository(cwd) {
65+
g.visible = false
66+
return
67+
}
68+
69+
repoPath, err := g.gitService.GetGitRootPath(cwd)
70+
if err != nil {
71+
g.visible = false
72+
return
73+
}
74+
75+
g.visible = true
76+
77+
// Get push remote URL with remote name
78+
remoteName, remoteURL, err := g.gitService.GetPushRemoteURL(repoPath)
79+
80+
// Get current SSH config
81+
sshConfig, _ := g.gitService.GetCurrentGitSSHConfig(repoPath)
82+
83+
// Build info text
84+
repoName := filepath.Base(repoPath)
85+
86+
// First line: repo name with remote info
87+
if err == nil && remoteURL != "" {
88+
text := fmt.Sprintf("[yellow]%s[-] ([dim]%s:[-][blue]%s[-])", repoName, remoteName, remoteURL)
89+
g.SetText(text)
90+
} else {
91+
g.SetText(fmt.Sprintf("[yellow]%s[-]", repoName))
92+
}
93+
94+
// Second line: SSH configured message
95+
if sshConfig != "" {
96+
g.SetText(g.GetText(false) + "\n[green]✓[-] SSH configured")
97+
} else {
98+
g.SetText(g.GetText(false) + "\n[dim]Press G to configure SSH[-]")
99+
}
100+
}
101+
102+
func (g *GitInfo) IsVisible() bool {
103+
return g.visible
104+
}

0 commit comments

Comments
 (0)