feat: replace clipboard copy with config display for SSH sessions

Created ConfigDisplay component that shows full client configuration
in a scrollable modal window, replacing non-functional clipboard copy.

Benefits:
- Works over SSH sessions (no clipboard API needed)
- Shows complete configuration, not just public key
- Scrollable for long configs with keyboard navigation
- Users can select and copy text directly in terminal

Changes:
- Created internal/tui/components/config-display.go
- Updated detail.go to replace copyPublicKey with loadConfig
- Removed clipboard-related fields and message type
- Updated help text: 'c' now shows config
- Key bindings for scrolling: ↑↓, pgup/pgdn, g/G, Esc/q to close

Fixes: wg-admin-qtb
This commit is contained in:
Calmcacil
2026-01-12 22:36:40 +01:00
parent 8fb3fe7031
commit fd0a1c45e7
3 changed files with 196 additions and 37 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,149 @@
package components
import (
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ConfigDisplayModel represents a configuration display modal
type ConfigDisplayModel struct {
config string
viewport viewport.Model
Visible bool
Width int
Height int
scrollPos int
}
// Styles
var (
configModalStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
Padding(1, 2).
Background(lipgloss.Color("235"))
configTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true).
MarginBottom(1)
configHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
configContentStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Width(76)
)
// NewConfigDisplay creates a new configuration display modal
func NewConfigDisplay(config string, width, height int) *ConfigDisplayModel {
vp := viewport.New(76, 24)
vp.SetContent(config)
return &ConfigDisplayModel{
config: config,
viewport: vp,
Visible: true,
Width: width,
Height: height,
scrollPos: 0,
}
}
// Init initializes the configuration display modal
func (m *ConfigDisplayModel) Init() tea.Cmd {
return nil
}
// Update handles messages for the configuration display modal
func (m *ConfigDisplayModel) Update(msg tea.Msg) (*ConfigDisplayModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc", "q":
m.Visible = false
return m, nil
case "up", "k":
m.viewport.LineUp(1)
case "down", "j":
m.viewport.LineDown(1)
case "pgup", "b":
m.viewport.HalfViewUp()
case "pgdown", "f", " ":
m.viewport.HalfViewDown()
case "g", "home":
m.viewport.GotoTop()
case "G", "end":
m.viewport.GotoBottom()
}
case tea.WindowSizeMsg:
// Update modal dimensions on resize
m.Width = msg.Width
m.Height = msg.Height
}
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
}
// View renders the configuration display modal
func (m *ConfigDisplayModel) View() string {
if !m.Visible {
return ""
}
// Build modal content
title := configTitleStyle.Render("📋 Client Configuration")
content := m.viewport.View()
help := configHelpStyle.Render("↑/j: down • ↓/k: up • pgup/pgdn: page • g/G: top/bottom • Esc: close")
fullContent := lipgloss.JoinVertical(
lipgloss.Left,
title,
"",
content,
help,
)
// Apply modal style
modal := configModalStyle.Render(fullContent)
// Center modal on screen
modalWidth := lipgloss.Width(modal)
modalHeight := lipgloss.Height(modal)
x := (m.Width - modalWidth) / 2
if x < 0 {
x = 0
}
y := (m.Height - modalHeight) / 2
if y < 0 {
y = 0
}
return lipgloss.Place(m.Width, m.Height,
lipgloss.Left, lipgloss.Top,
modal,
lipgloss.WithWhitespaceChars(" "),
lipgloss.WithWhitespaceForeground(lipgloss.Color("235")),
)
}
// IsVisible returns true if the modal is visible
func (m *ConfigDisplayModel) IsVisible() bool {
return m.Visible
}
// Hide hides the modal
func (m *ConfigDisplayModel) Hide() {
m.Visible = false
}
// Show shows the modal
func (m *ConfigDisplayModel) Show(config string) {
m.config = config
m.viewport.SetContent(config)
m.viewport.GotoTop()
m.Visible = true
}

View File

@@ -19,8 +19,8 @@ type DetailScreen struct {
transferTx string transferTx string
confirmModal *components.DeleteConfirmModel confirmModal *components.DeleteConfirmModel
showConfirm bool showConfirm bool
clipboardCopied bool configDisplay *components.ConfigDisplayModel
clipboardTimer int showConfig bool
} }
// Styles // Styles
@@ -71,15 +71,6 @@ func (s *DetailScreen) Init() tea.Cmd {
func (s *DetailScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { func (s *DetailScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
// Handle clipboard copy timeout
if s.clipboardCopied {
s.clipboardTimer++
if s.clipboardTimer > 2 {
s.clipboardCopied = false
s.clipboardTimer = 0
}
}
// Handle confirmation modal // Handle confirmation modal
if s.showConfirm && s.confirmModal != nil { if s.showConfirm && s.confirmModal != nil {
_, cmd = s.confirmModal.Update(msg) _, cmd = s.confirmModal.Update(msg)
@@ -100,10 +91,21 @@ func (s *DetailScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
return s, cmd return s, cmd
} }
// Handle config display modal
if s.showConfig && s.configDisplay != nil {
_, cmd = s.configDisplay.Update(msg)
// Handle modal close
if !s.configDisplay.IsVisible() {
s.showConfig = false
return s, nil
}
return s, cmd
}
// Handle normal screen messages // Handle normal screen messages
switch msg := msg.(type) { switch msg := msg.(type) {
case clipboardCopiedMsg:
s.clipboardCopied = true
case clientStatusLoadedMsg: case clientStatusLoadedMsg:
s.status = msg.status s.status = msg.status
s.lastHandshake = msg.lastHandshake s.lastHandshake = msg.lastHandshake
@@ -123,8 +125,8 @@ func (s *DetailScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
) )
s.showConfirm = true s.showConfirm = true
case "c": case "c":
// Copy public key to clipboard // Show client configuration
return s, s.copyPublicKey() return s, s.loadConfig()
} }
} }
@@ -133,6 +135,12 @@ func (s *DetailScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
// View renders the detail screen // View renders the detail screen
func (s *DetailScreen) View() string { func (s *DetailScreen) View() string {
// Handle config display modal
if s.showConfig && s.configDisplay != nil {
return s.configDisplay.View()
}
// Handle confirmation modal
if s.showConfirm && s.confirmModal != nil { if s.showConfirm && s.confirmModal != nil {
// Render underlying content dimmed // Render underlying content dimmed
content := s.renderContent() content := s.renderContent()
@@ -186,14 +194,9 @@ func (s *DetailScreen) renderContent() string {
) )
// Add help text // Add help text
helpText := detailHelpStyle.Render("Actions: [d] Delete • [c] Copy Public Key • [b] Back") helpText := detailHelpStyle.Render("Actions: [d] Delete • [c] View Config • [b] Back")
content = lipgloss.JoinVertical(lipgloss.Left, content, helpText) content = lipgloss.JoinVertical(lipgloss.Left, content, helpText)
// Show clipboard confirmation
if s.clipboardCopied {
content += "\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("✓ Public key copied to clipboard!")
}
return content return content
} }
@@ -252,13 +255,22 @@ func (s *DetailScreen) loadClientStatus() tea.Msg {
} }
} }
// copyPublicKey copies the public key to clipboard // loadConfig loads and displays the client configuration
func (s *DetailScreen) copyPublicKey() tea.Cmd { func (s *DetailScreen) loadConfig() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
// Note: In a real implementation, you would use a clipboard library like config, err := wireguard.GetClientConfigContent(s.client.Name)
// github.com/atotto/clipboard or implement platform-specific clipboard access if err != nil {
// For now, we'll just simulate the action return errMsg{fmt.Errorf("failed to load client config: %w", err)}
return clipboardCopiedMsg{} }
// Create or update config display modal
if s.configDisplay == nil {
s.configDisplay = components.NewConfigDisplay(config, 80, 24)
} else {
s.configDisplay.Show(config)
}
s.showConfig = true
return nil
} }
} }
@@ -284,6 +296,3 @@ type clientStatusLoadedMsg struct {
transferRx string transferRx string
transferTx string transferTx string
} }
// clipboardCopiedMsg is sent when public key is copied to clipboard
type clipboardCopiedMsg struct{}