Add text selection and copy capability to terminal UI
This commit is contained in:
File diff suppressed because one or more lines are too long
17
README.md
17
README.md
@@ -223,6 +223,23 @@ Then run:
|
|||||||
sudo ~/wireguard.sh load-clients
|
sudo ~/wireguard.sh load-clients
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Text Selection & Copying
|
||||||
|
|
||||||
|
To copy client configurations or other text from the terminal UI:
|
||||||
|
|
||||||
|
### Text Selection
|
||||||
|
- Hold **SHIFT key** while dragging your mouse with the left button
|
||||||
|
- This bypasses TUI mouse handling and enables your terminal's native text selection
|
||||||
|
- Then use your terminal's copy shortcut:
|
||||||
|
- **Linux/WSL**: Ctrl+Shift+C (or terminal-specific shortcut)
|
||||||
|
- **macOS**: Cmd+C
|
||||||
|
- **Windows**: Click right (or use terminal copy)
|
||||||
|
|
||||||
|
### Copy Buttons (when available)
|
||||||
|
- Some modals may have explicit copy buttons (e.g., "C" to copy config)
|
||||||
|
- These work when clipboard API is available (native Linux, macOS, WSL)
|
||||||
|
- Over SSH requires X11 forwarding (`ssh -X`) for clipboard access
|
||||||
|
|
||||||
## Client Setup
|
## Client Setup
|
||||||
|
|
||||||
### Importing the config
|
### Importing the config
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package components
|
package components
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
@@ -263,7 +264,7 @@ func (m *SearchModel) HighlightMatches(value string) string {
|
|||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
before := value[:index]
|
before := value[:index]
|
||||||
match := value[index+len(query)]
|
match := value[index : index+len(query)]
|
||||||
after := value[index+len(query):]
|
after := value[index+len(query):]
|
||||||
|
|
||||||
return lipgloss.JoinHorizontal(
|
return lipgloss.JoinHorizontal(
|
||||||
|
|||||||
@@ -152,7 +152,9 @@ func (s *DetailScreen) renderContent() string {
|
|||||||
|
|
||||||
statusText := s.status
|
statusText := s.status
|
||||||
if s.status == wireguard.StatusConnected {
|
if s.status == wireguard.StatusConnected {
|
||||||
statusText = theme.StyleSuccess.Bold(true).Render("● " + s.status)
|
duration := time.Since(s.lastHandshake)
|
||||||
|
quality := wireguard.CalculateQuality(duration)
|
||||||
|
statusText = theme.StyleSuccess.Bold(true).Render("● " + s.status + " (" + quality + ")")
|
||||||
} else {
|
} else {
|
||||||
statusText = theme.StyleError.Bold(true).Render("● " + s.status)
|
statusText = theme.StyleError.Bold(true).Render("● " + s.status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ func (s *HelpScreen) View() string {
|
|||||||
// Breadcrumb: Help
|
// Breadcrumb: Help
|
||||||
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Help", ID: "help"}})
|
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Help", ID: "help"}})
|
||||||
|
|
||||||
|
|
||||||
// Styles
|
// Styles
|
||||||
borderStyle := lipgloss.NewStyle().
|
borderStyle := lipgloss.NewStyle().
|
||||||
BorderStyle(lipgloss.RoundedBorder()).
|
BorderStyle(lipgloss.RoundedBorder()).
|
||||||
@@ -90,6 +89,11 @@ func (s *HelpScreen) View() string {
|
|||||||
keyStyle.Render("/") + descStyle.Render("Search") + "\n" +
|
keyStyle.Render("/") + descStyle.Render("Search") + "\n" +
|
||||||
keyStyle.Render("q") + descStyle.Render("Quit")
|
keyStyle.Render("q") + descStyle.Render("Quit")
|
||||||
|
|
||||||
|
copyGroup := categoryStyle.Render("Text Selection & Copy") + "\n" +
|
||||||
|
keyStyle.Render("SHIFT+drag") + descStyle.Render("Select text") + "\n" +
|
||||||
|
keyStyle.Render("Ctrl+Shift+C") + descStyle.Render("Copy (Linux)") + "\n" +
|
||||||
|
keyStyle.Render("Cmd+C") + descStyle.Render("Copy (macOS)")
|
||||||
|
|
||||||
// Two-column layout
|
// Two-column layout
|
||||||
leftColumn := lipgloss.JoinVertical(lipgloss.Left,
|
leftColumn := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
navigationGroup,
|
navigationGroup,
|
||||||
@@ -99,6 +103,8 @@ func (s *HelpScreen) View() string {
|
|||||||
|
|
||||||
rightColumn := lipgloss.JoinVertical(lipgloss.Left,
|
rightColumn := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
otherGroup,
|
otherGroup,
|
||||||
|
"",
|
||||||
|
copyGroup,
|
||||||
)
|
)
|
||||||
|
|
||||||
content := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", rightColumn)
|
content := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", rightColumn)
|
||||||
|
|||||||
@@ -30,14 +30,20 @@ var (
|
|||||||
once sync.Once
|
once sync.Once
|
||||||
|
|
||||||
// Global styles that can be used throughout the application
|
// Global styles that can be used throughout the application
|
||||||
StylePrimary lipgloss.Style
|
StylePrimary lipgloss.Style
|
||||||
StyleSuccess lipgloss.Style
|
StyleSuccess lipgloss.Style
|
||||||
StyleWarning lipgloss.Style
|
StyleWarning lipgloss.Style
|
||||||
StyleError lipgloss.Style
|
StyleError lipgloss.Style
|
||||||
StyleMuted lipgloss.Style
|
StyleMuted lipgloss.Style
|
||||||
StyleTitle lipgloss.Style
|
StyleTitle lipgloss.Style
|
||||||
StyleSubtitle lipgloss.Style
|
StyleSubtitle lipgloss.Style
|
||||||
StyleHelpKey lipgloss.Style
|
StyleHelpKey lipgloss.Style
|
||||||
|
StyleValue lipgloss.Style
|
||||||
|
StyleDimmed lipgloss.Style
|
||||||
|
StyleTableHeader lipgloss.Style
|
||||||
|
StyleTableSelected lipgloss.Style
|
||||||
|
StyleBorder lipgloss.Color
|
||||||
|
StyleBackground lipgloss.Color
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultTheme is the standard blue-based theme
|
// DefaultTheme is the standard blue-based theme
|
||||||
@@ -176,6 +182,33 @@ func ApplyTheme(theme *Theme) {
|
|||||||
StyleHelpKey = lipgloss.NewStyle().
|
StyleHelpKey = lipgloss.NewStyle().
|
||||||
Foreground(theme.Scheme.Primary).
|
Foreground(theme.Scheme.Primary).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
|
// Value style for content values
|
||||||
|
StyleValue = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("255"))
|
||||||
|
|
||||||
|
// Dimmed style for overlay content
|
||||||
|
StyleDimmed = lipgloss.NewStyle().
|
||||||
|
Foreground(theme.Scheme.Muted)
|
||||||
|
|
||||||
|
// Table header style
|
||||||
|
StyleTableHeader = lipgloss.NewStyle().
|
||||||
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("240")).
|
||||||
|
BorderBottom(true).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
// Table selected style
|
||||||
|
StyleTableSelected = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("229")).
|
||||||
|
Background(lipgloss.Color("57")).
|
||||||
|
Bold(false)
|
||||||
|
|
||||||
|
// Border color
|
||||||
|
StyleBorder = lipgloss.Color("240")
|
||||||
|
|
||||||
|
// Background color for modals
|
||||||
|
StyleBackground = lipgloss.Color("235")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetThemeNames returns a list of available theme names
|
// GetThemeNames returns a list of available theme names
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ const (
|
|||||||
StatusConnected = "Connected"
|
StatusConnected = "Connected"
|
||||||
// StatusDisconnected indicates a peer is not connected
|
// StatusDisconnected indicates a peer is not connected
|
||||||
StatusDisconnected = "Disconnected"
|
StatusDisconnected = "Disconnected"
|
||||||
|
|
||||||
|
// QualityExcellent indicates handshake was very recent (< 30s)
|
||||||
|
QualityExcellent = "Excellent"
|
||||||
|
// QualityGood indicates handshake was recent (< 2m)
|
||||||
|
QualityGood = "Good"
|
||||||
|
// QualityFair indicates handshake was acceptable (< 5m)
|
||||||
|
QualityFair = "Fair"
|
||||||
|
// QualityPoor indicates handshake was old (> 5m)
|
||||||
|
QualityPoor = "Poor"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PeerStatus represents the status of a WireGuard peer
|
// PeerStatus represents the status of a WireGuard peer
|
||||||
@@ -25,7 +34,8 @@ type PeerStatus struct {
|
|||||||
LatestHandshake time.Time `json:"latest_handshake"`
|
LatestHandshake time.Time `json:"latest_handshake"`
|
||||||
TransferRx string `json:"transfer_rx"`
|
TransferRx string `json:"transfer_rx"`
|
||||||
TransferTx string `json:"transfer_tx"`
|
TransferTx string `json:"transfer_tx"`
|
||||||
Status string `json:"status"` // "Connected" or "Disconnected"
|
Status string `json:"status"` // "Connected" or "Disconnected"
|
||||||
|
Quality string `json:"quality,omitempty"` // "Excellent", "Good", "Fair", "Poor" (if connected)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetClientStatus checks if a specific client is connected
|
// GetClientStatus checks if a specific client is connected
|
||||||
@@ -119,6 +129,20 @@ func parsePeersOutput(output string) []PeerStatus {
|
|||||||
return peers
|
return peers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CalculateQuality returns the connection quality based on handshake time
|
||||||
|
func CalculateQuality(timeSinceHandshake time.Duration) string {
|
||||||
|
if timeSinceHandshake < 30*time.Second {
|
||||||
|
return QualityExcellent
|
||||||
|
}
|
||||||
|
if timeSinceHandshake < 2*time.Minute {
|
||||||
|
return QualityGood
|
||||||
|
}
|
||||||
|
if timeSinceHandshake < 5*time.Minute {
|
||||||
|
return QualityFair
|
||||||
|
}
|
||||||
|
return QualityPoor
|
||||||
|
}
|
||||||
|
|
||||||
// finalizePeerStatus determines the peer's status based on handshake time
|
// finalizePeerStatus determines the peer's status based on handshake time
|
||||||
func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) PeerStatus {
|
func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) PeerStatus {
|
||||||
peer.TransferRx = ""
|
peer.TransferRx = ""
|
||||||
@@ -140,13 +164,16 @@ func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) Pee
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine status based on handshake
|
// Determine status and quality based on handshake
|
||||||
if handshake != "" {
|
if handshake != "" {
|
||||||
peer.LatestHandshake = parseHandshake(handshake)
|
peer.LatestHandshake = parseHandshake(handshake)
|
||||||
|
timeSinceHandshake := time.Since(peer.LatestHandshake)
|
||||||
|
|
||||||
// Peer is considered connected if handshake is recent (within 5 minutes)
|
// Peer is considered connected if handshake is recent (within 5 minutes)
|
||||||
// This allows for ~12 missed keepalive intervals (at 25 seconds each)
|
// This allows for ~12 missed keepalive intervals (at 25 seconds each)
|
||||||
if time.Since(peer.LatestHandshake) < 5*time.Minute {
|
if timeSinceHandshake < 5*time.Minute {
|
||||||
peer.Status = StatusConnected
|
peer.Status = StatusConnected
|
||||||
|
peer.Quality = calculateQuality(timeSinceHandshake)
|
||||||
} else {
|
} else {
|
||||||
peer.Status = StatusDisconnected
|
peer.Status = StatusDisconnected
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user