Compare commits

...

26 Commits

Author SHA1 Message Date
Calmcacil
9c0da13a19 bd sync: 2026-01-12 23:44:57 2026-01-12 23:44:57 +01:00
Calmcacil
99b9dc17db Enhance search with match highlighting, count display, Ctrl+U to clear 2026-01-12 23:43:24 +01:00
Calmcacil
6629598574 bd sync: 2026-01-12 23:42:48 2026-01-12 23:42:48 +01:00
Calmcacil
50321a8471 Add connection quality indicators based on handshake time 2026-01-12 23:42:38 +01:00
Calmcacil
bf71a7a659 bd sync: 2026-01-12 23:41:27 2026-01-12 23:41:27 +01:00
Calmcacil
f154c7ff69 Standardize TUI formatting and styling across all screens 2026-01-12 23:41:07 +01:00
Calmcacil
0476f1e227 bd sync: 2026-01-12 23:40:16 2026-01-12 23:40:16 +01:00
Calmcacil
1187ae0046 Add screen transition animations for polished UX 2026-01-12 23:40:01 +01:00
Calmcacil
b7ddd54cf6 bd sync: 2026-01-12 23:34:33 2026-01-12 23:34:33 +01:00
Calmcacil
5b8b9b66f5 Add keyboard shortcut discoverability hints on each screen 2026-01-12 23:34:14 +01:00
Calmcacil
f0e26e4a0a bd sync: 2026-01-12 23:24:02 2026-01-12 23:24:02 +01:00
Calmcacil
dd62458515 Add text selection and copy capability to terminal UI 2026-01-12 23:23:48 +01:00
Calmcacil
17f4d52c8a bd sync: 2026-01-12 23:21:29 2026-01-12 23:21:29 +01:00
Calmcacil
4787f3b863 bd sync: 2026-01-12 23:19:12 2026-01-12 23:19:12 +01:00
Calmcacil
3631339f8b Add loading spinners for async operations 2026-01-12 23:18:57 +01:00
Calmcacil
1c03a706d1 bd sync: 2026-01-12 23:12:04 2026-01-12 23:12:04 +01:00
Calmcacil
a3c2828ec2 Integrate theme system across all screens 2026-01-12 23:11:53 +01:00
Calmcacil
d669adc094 bd sync: 2026-01-12 23:07:02 2026-01-12 23:07:02 +01:00
Calmcacil
ea36f03393 bd sync: 2026-01-12 23:05:10 2026-01-12 23:05:10 +01:00
Calmcacil
aadcfbf810 Create dedicated error screen with user-friendly messages and recovery options 2026-01-12 23:04:58 +01:00
Calmcacil
34951221d3 bd sync: 2026-01-12 23:04:56 2026-01-12 23:04:56 +01:00
Calmcacil
68939cdc08 Reduce status refresh interval to 3 seconds and add last updated indicator 2026-01-12 23:04:48 +01:00
Calmcacil
5136484cd2 bd sync: 2026-01-12 23:03:09 2026-01-12 23:03:09 +01:00
Calmcacil
575faa8c68 Improve empty state messages with actionable guidance 2026-01-12 23:03:00 +01:00
Calmcacil
8b49fbfd3a bd sync: 2026-01-12 23:02:08 2026-01-12 23:02:08 +01:00
Calmcacil
78a100112c Fix q key behavior in client details view 2026-01-12 23:01:59 +01:00
17 changed files with 1204 additions and 276 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -13,6 +14,16 @@ import (
const version = "0.1.0" const version = "0.1.0"
// TickMsg is sent for transition animation frames
type TickMsg time.Time
// Transition settings
const (
transitionDuration = 200 * time.Millisecond
transitionFPS = 60
)
var ( var (
styleTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("62")).Bold(true) styleTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("62")).Bold(true)
styleSubtitle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) styleSubtitle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
@@ -22,10 +33,18 @@ var (
) )
type model struct { type model struct {
transitionFrom screens.Screen
transitionTo screens.Screen
transitionProgress float64
transitionAnimating bool
transitionStartTime time.Time
transitionType screens.TransitionType
transitionDuration time.Duration
currentScreen screens.Screen currentScreen screens.Screen
previousScreen screens.Screen previousScreen screens.Screen
quitting bool quitting bool
initialized bool initialized bool
errorScreen *screens.ErrorScreen
} }
func (m model) Init() tea.Cmd { func (m model) Init() tea.Cmd {
@@ -37,6 +56,8 @@ func (m model) Init() tea.Cmd {
return nil return nil
} }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Handle initialization on first update after Init // Handle initialization on first update after Init
if !m.initialized { if !m.initialized {
@@ -45,86 +66,114 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.currentScreen.Init() return m, m.currentScreen.Init()
} }
// Handle transition animation updates
if m.transitionAnimating {
switch msg.(type) {
case TickMsg:
// Update transition progress
elapsed := time.Since(m.transitionStartTime)
m.transitionProgress = float64(elapsed) / float64(m.transitionDuration)
// Ease-out cubic function for smoother animation
easeProgress := 1 - ((1 - m.transitionProgress) * (1 - m.transitionProgress) * (1 - m.transitionProgress))
if m.transitionProgress >= 1 || easeProgress >= 1 {
// Transition complete
m.transitionAnimating = false
m.currentScreen = m.transitionTo
m.previousScreen = m.transitionFrom
m.transitionFrom = nil
m.transitionTo = nil
return m, nil
}
// Continue animation
return m, tea.Tick(time.Second/time.Duration(transitionFPS), func(t time.Time) tea.Msg {
return TickMsg(t)
})
}
// During transition, don't process other messages
return m, nil
}
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
// Only quit on 'q' or ctrl+c when on list screen // Only quit on 'q' or ctrl+c when on list screen
// Other screens handle their own navigation keys
if msg.String() == "q" || msg.String() == "ctrl+c" { if msg.String() == "q" || msg.String() == "ctrl+c" {
if _, ok := m.currentScreen.(*screens.ListScreen); ok { if _, ok := m.currentScreen.(*screens.ListScreen); ok {
m.quitting = true m.quitting = true
return m, tea.Quit return m, tea.Quit
} }
// For other screens, let them handle the key
} }
switch msg.String() { switch msg.String() {
case "?": case "?":
// Switch to help screen cmd := m.startTransition(screens.NewHelpScreen(m.previousScreen), screens.TransitionFade)
m.previousScreen = m.currentScreen return m, cmd
m.currentScreen = screens.NewHelpScreen(m.previousScreen)
return m, m.currentScreen.Init()
case "l": case "l":
// Switch to list screen cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
m.currentScreen = screens.NewListScreen() return m, cmd
return m, m.currentScreen.Init()
case "a": case "a":
// Switch to add screen cmd := m.startTransition(screens.NewAddScreen(), screens.TransitionSlideLeft)
m.previousScreen = m.currentScreen return m, cmd
m.currentScreen = screens.NewAddScreen()
return m, m.currentScreen.Init()
} }
case screens.ClientSelectedMsg: case screens.ClientSelectedMsg:
// User selected a client - show detail screen cmd := m.startTransition(screens.NewDetailScreen(msg.Client.Client), screens.TransitionSlideLeft)
m.previousScreen = m.currentScreen return m, cmd
m.currentScreen = screens.NewDetailScreen(msg.Client.Client)
return m, m.currentScreen.Init()
case screens.ClientDeletedMsg: case screens.ClientDeletedMsg:
// Client was deleted - show success message and return to list cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
m.currentScreen = screens.NewListScreen() return m, cmd
return m, m.currentScreen.Init()
case screens.ClientCreatedMsg: case screens.ClientCreatedMsg:
// Client was created - return to list screen cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
m.currentScreen = screens.NewListScreen() return m, cmd
return m, m.currentScreen.Init()
case screens.RestoreCompletedMsg: case screens.RestoreCompletedMsg:
// Restore completed - return to list screen to refresh clients cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
m.currentScreen = screens.NewListScreen() return m, cmd
return m, m.currentScreen.Init()
case screens.CloseDetailScreenMsg: case screens.CloseDetailScreenMsg:
// Detail screen closed - go back to previous screen
if m.previousScreen != nil { if m.previousScreen != nil {
m.currentScreen = m.previousScreen cmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight)
m.previousScreen = nil return m, cmd
return m, m.currentScreen.Init()
} }
m.currentScreen = screens.NewListScreen() cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
return m, m.currentScreen.Init() return m, cmd
case screens.ErrMsg:
m.previousScreen = m.currentScreen
m.errorScreen = screens.NewErrorScreen(msg.Err)
cmd := m.startTransition(m.errorScreen, screens.TransitionFade)
return m, cmd
} }
// Pass messages to current screen // Pass messages to current screen
if m.currentScreen != nil { if m.currentScreen != nil {
newScreen, cmd := m.currentScreen.Update(msg) newScreen, cmd := m.currentScreen.Update(msg)
// If screen returns nil, go back to previous screen
if newScreen == nil { if newScreen == nil {
if m.previousScreen != nil { if m.previousScreen != nil {
m.currentScreen = m.previousScreen transitionCmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight)
m.previousScreen = nil return m, tea.Batch(cmd, transitionCmd)
} }
} else if newScreen != m.currentScreen { } else if newScreen != m.currentScreen {
// Screen is switching to a different screen
m.previousScreen = m.currentScreen m.previousScreen = m.currentScreen
m.currentScreen = newScreen transitionCmd := m.startTransition(newScreen, screens.TransitionSlideLeft)
return m, tea.Batch(cmd, transitionCmd)
} }
return m, cmd return m, cmd
} }
return m, nil return m, nil
} }
}
func (m model) View() string {
func (m model) View() string { func (m model) View() string {
if m.quitting { if m.quitting {
return "\nGoodbye!\n" return "\nGoodbye!\n"
} }
// If transitioning, render the transition animation
if m.transitionAnimating {
return renderTransition(m)
}
if m.currentScreen != nil && m.initialized { if m.currentScreen != nil && m.initialized {
return m.currentScreen.View() return m.currentScreen.View()
} }
@@ -135,6 +184,7 @@ func (m model) View() string {
return title + " " + subtitle + "\n\nInitializing...\n\n" + bar return title + " " + subtitle + "\n\nInitializing...\n\n" + bar
} }
}
func main() { func main() {
p := tea.NewProgram(model{}, tea.WithAltScreen()) p := tea.NewProgram(model{}, tea.WithAltScreen())
@@ -143,3 +193,193 @@ func main() {
os.Exit(1) os.Exit(1)
} }
} }
// startTransition begins a screen transition animation
func (m *model) startTransition(toScreen screens.Screen, ttype screens.TransitionType) tea.Cmd {
// Initialize new screen
var initCmd tea.Cmd
if toScreen != nil {
initCmd = toScreen.Init()
}
// Set up transition
m.transitionFrom = m.currentScreen
m.transitionTo = toScreen
m.transitionType = ttype
m.transitionDuration = transitionDuration
m.transitionProgress = 0
m.transitionAnimating = true
m.transitionStartTime = time.Now()
// Return initialization command and tick command
tickCmd := tea.Tick(time.Second/time.Duration(transitionFPS), func(t time.Time) tea.Msg {
return TickMsg(t)
})
if initCmd != nil {
return tea.Batch(initCmd, tickCmd)
}
return tickCmd
}
func renderTransition(m model) string {
if m.transitionFrom == nil || m.transitionTo == nil {
return m.transitionTo.View()
}
fromView := m.transitionFrom.View()
toView := m.transitionTo.View()
// Apply easing to progress
easeProgress := 1 - ((1 - m.transitionProgress) * (1 - m.transitionProgress) * (1 - m.transitionProgress))
switch m.transitionType {
case screens.TransitionFade:
return renderFadeTransition(fromView, toView, easeProgress)
case screens.TransitionSlideLeft:
return renderSlideTransition(fromView, toView, easeProgress, true)
case screens.TransitionSlideRight:
return renderSlideTransition(fromView, toView, easeProgress, false)
default:
return toView
}
}
func renderFadeTransition(fromView, toView string, progress float64) string {
fromLines := splitLines(fromView)
toLines := splitLines(toView)
maxLines := max(len(fromLines), len(toLines))
for len(fromLines) < maxLines {
fromLines = append(fromLines, "")
}
for len(toLines) < maxLines {
toLines = append(toLines, "")
}
var result []string
for i := 0; i < maxLines; i++ {
threshold := progress * float64(maxLines)
if float64(i) < threshold {
result = append(result, toLines[i])
} else {
result = append(result, fromLines[i])
}
}
return joinLines(result)
}
func renderSlideTransition(fromView, toView string, progress float64, slideLeft bool) string {
fromLines := splitLines(fromView)
toLines := splitLines(toView)
width := 80
for i := range fromLines {
fromLines[i] = padLine(fromLines[i], width)
}
for i := range toLines {
toLines[i] = padLine(toLines[i], width)
}
offset := int(float64(width) * progress)
var result []string
maxLines := max(len(fromLines), len(toLines))
for i := 0; i < maxLines; i++ {
var fromLine, toLine string
if i < len(fromLines) {
fromLine = fromLines[i]
} else {
fromLine = ""
}
if i < len(toLines) {
toLine = toLines[i]
} else {
toLine = ""
}
if slideLeft {
fromOffset := -offset
toOffset := width - offset
combined := combineSlidingLines(fromLine, toLine, fromOffset, toOffset, width)
result = append(result, combined)
} else {
fromOffset := offset
toOffset := -width + offset
combined := combineSlidingLines(fromLine, toLine, fromOffset, toOffset, width)
result = append(result, combined)
}
}
return joinLines(result)
}
func combineSlidingLines(from, to string, fromOffset, toOffset, width int) string {
result := make([]byte, width)
for i := range result {
result[i] = ' '
}
if fromOffset >= -len(from) && fromOffset < width {
for i := 0; i < len(from) && i+fromOffset < width && i+fromOffset >= 0; i++ {
if i+fromOffset >= 0 && i+fromOffset < width {
result[i+fromOffset] = from[i]
}
}
}
if toOffset >= -len(to) && toOffset < width {
for i := 0; i < len(to) && i+toOffset < width && i+toOffset >= 0; i++ {
if i+toOffset >= 0 && i+toOffset < width {
result[i+toOffset] = to[i]
}
}
}
return string(result)
}
func splitLines(s string) []string {
lines := make([]string, 0)
current := ""
for _, c := range s {
if c == '\n' {
lines = append(lines, current)
current = ""
} else {
current += string(c)
}
}
if current != "" {
lines = append(lines, current)
}
return lines
}
func joinLines(lines []string) string {
result := ""
for i, line := range lines {
if i > 0 {
result += "\n"
}
result += line
}
return result
}
func padLine(line string, width int) string {
if len(line) >= width {
return line[:width]
}
return fmt.Sprintf("%-*s", width, line)
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

View File

@@ -18,7 +18,7 @@ var (
Foreground(lipgloss.Color("241")). Foreground(lipgloss.Color("241")).
MarginBottom(1) MarginBottom(1)
breadcrumbSeparatorStyle = lipgloss.NewStyle(). breadcrumbSeparatorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("240")) Foreground(lipgloss.Color("240"))
breadcrumbItemStyle = lipgloss.NewStyle(). breadcrumbItemStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")) Foreground(lipgloss.Color("241"))
breadcrumbCurrentStyle = lipgloss.NewStyle(). breadcrumbCurrentStyle = lipgloss.NewStyle().

View File

@@ -3,6 +3,7 @@ package components
import ( import (
"fmt" "fmt"
"github.com/calmcacil/wg-admin/internal/tui/theme"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -18,37 +19,12 @@ type DeleteConfirmModel struct {
showErrorMessage bool showErrorMessage bool
} }
// Styles // Local styles for modal
var ( var (
deleteModalStyle = lipgloss.NewStyle(). modalBaseStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("196")).Background(lipgloss.Color("235"))
Border(lipgloss.RoundedBorder()). modalTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
BorderForeground(lipgloss.Color("196")). modalMessageStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
Padding(1, 3). modalHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
Background(lipgloss.Color("235"))
deleteTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true)
deleteMessageStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Width(60)
deleteWarningStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true).
MarginTop(1)
deleteInputStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Width(60)
deleteHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
deleteErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true).
MarginTop(1)
deleteSuccessStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("46")).
Bold(true).
MarginTop(1)
) )
// NewDeleteConfirm creates a new deletion confirmation modal // NewDeleteConfirm creates a new deletion confirmation modal
@@ -108,12 +84,12 @@ func (m *DeleteConfirmModel) View() string {
matches := m.input.Value() == m.clientName matches := m.input.Value() == m.clientName
// Build warning section // Build warning section
warningText := deleteWarningStyle.Render( warningText := theme.StyleError.Bold(true).MarginTop(1).Render(
fmt.Sprintf("⚠️ This will permanently delete client '%s'", m.clientName), fmt.Sprintf("⚠️ This will permanently delete client '%s'", m.clientName),
) )
// Build message section // Build message section
messageText := deleteMessageStyle.Render( messageText := modalMessageStyle.Width(60).Render(
fmt.Sprintf("This action cannot be undone. Please type the client name to confirm deletion."), fmt.Sprintf("This action cannot be undone. Please type the client name to confirm deletion."),
) )
@@ -121,29 +97,29 @@ func (m *DeleteConfirmModel) View() string {
inputSection := lipgloss.JoinVertical( inputSection := lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
"", "",
deleteInputStyle.Render("Client name:"), theme.StyleValue.Width(60).Render("Client name:"),
m.input.View(), m.input.View(),
) )
// Build status section // Build status section
var statusText string var statusText string
if matches { if matches {
statusText = deleteSuccessStyle.Render("✓ Client name matches. Press Enter to confirm deletion.") statusText = theme.StyleSuccess.Bold(true).MarginTop(1).Render("✓ Client name matches. Press Enter to confirm deletion.")
} else if m.showErrorMessage { } else if m.showErrorMessage {
statusText = deleteErrorStyle.Render("✗ Client name does not match. Please try again.") statusText = theme.StyleError.Bold(true).MarginTop(1).Render("✗ Client name does not match. Please try again.")
} else if m.input.Value() != "" { } else if m.input.Value() != "" {
statusText = deleteHelpStyle.Render("Client name does not match yet...") statusText = modalHelpStyle.Render("Client name does not match yet...")
} else { } else {
statusText = deleteHelpStyle.Render("Type the client name to enable confirmation.") statusText = modalHelpStyle.Render("Type the client name to enable confirmation.")
} }
// Build help section // Build help section
helpText := deleteHelpStyle.Render("Esc to cancel • Enter to confirm (when name matches)") helpText := modalHelpStyle.Render("Esc to cancel • Enter to confirm (when name matches)")
// Build modal content // Build modal content
content := lipgloss.JoinVertical( content := lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
deleteTitleStyle.Render("🗑️ Delete Client"), modalTitleStyle.Render("🗑️ Delete Client"),
"", "",
warningText, warningText,
"", "",
@@ -155,7 +131,7 @@ func (m *DeleteConfirmModel) View() string {
) )
// Apply modal style // Apply modal style
modal := deleteModalStyle.Render(content) modal := modalBaseStyle.Padding(1, 3).Render(content)
// Center modal on screen // Center modal on screen
modalWidth := lipgloss.Width(modal) modalWidth := lipgloss.Width(modal)
@@ -174,7 +150,7 @@ func (m *DeleteConfirmModel) View() string {
lipgloss.Left, lipgloss.Top, lipgloss.Left, lipgloss.Top,
modal, modal,
lipgloss.WithWhitespaceChars(" "), lipgloss.WithWhitespaceChars(" "),
lipgloss.WithWhitespaceForeground(lipgloss.Color("235")), lipgloss.WithWhitespaceForeground(theme.StyleBackground),
) )
} }

View File

@@ -1,6 +1,9 @@
package components package components
import ( import (
"fmt"
"github.com/calmcacil/wg-admin/internal/tui/theme"
"strings" "strings"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
@@ -29,13 +32,15 @@ type SearchModel struct {
} }
// Styles // Styles
// Styles (using theme package)
var ( var (
searchBarStyle = lipgloss.NewStyle(). searchBarStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")). Foreground(lipgloss.Color("255")).
Background(lipgloss.Color("235")). Background(theme.StyleBackground).
Padding(0, 1). Padding(0, 1).
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240")) BorderForeground(theme.StyleBorder)
searchPromptStyle = lipgloss.NewStyle(). searchPromptStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("226")). Foreground(lipgloss.Color("226")).
Bold(true) Bold(true)
@@ -87,6 +92,10 @@ func (m *SearchModel) Update(msg tea.Msg) (*SearchModel, tea.Cmd) {
m.input.Reset() m.input.Reset()
m.matchCount = m.totalCount m.matchCount = m.totalCount
return m, nil return m, nil
case "ctrl+u":
m.input.Reset()
m.matchCount = m.totalCount
return m, nil
case "tab": case "tab":
m.cycleFilterType() m.cycleFilterType()
return m, nil return m, nil
@@ -140,7 +149,7 @@ func (m *SearchModel) View() string {
helpText := "" helpText := ""
if m.active { if m.active {
helpText = searchHelpStyle.Render(" | Tab: filter | Esc: clear") helpText = searchHelpStyle.Render(" | Tab: filter | Ctrl+U: clear | Esc: exit")
} else { } else {
helpText = searchHelpStyle.Render(" | /: search") helpText = searchHelpStyle.Render(" | /: search")
} }
@@ -263,13 +272,13 @@ 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(
lipgloss.Left, lipgloss.Left,
before, before,
matchStyle.Render(string(match)), matchStyle.Render(match),
after, after,
) )
} }
@@ -295,7 +304,7 @@ func (m *SearchModel) renderCount(count int) string {
Foreground(lipgloss.Color("196")). Foreground(lipgloss.Color("196")).
Render("No matches") Render("No matches")
} }
return searchCountStyle.Render(string(rune('0' + count))) return searchCountStyle.Render(fmt.Sprintf("%d", count))
} }
// ClientData represents client data for filtering // ClientData represents client data for filtering

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"github.com/calmcacil/wg-admin/internal/config" "github.com/calmcacil/wg-admin/internal/config"
"github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/calmcacil/wg-admin/internal/validation" "github.com/calmcacil/wg-admin/internal/validation"
"github.com/calmcacil/wg-admin/internal/wireguard" "github.com/calmcacil/wg-admin/internal/wireguard"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
@@ -29,6 +30,10 @@ var (
addHelpStyle = lipgloss.NewStyle(). addHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")). Foreground(lipgloss.Color("241")).
MarginTop(1) MarginTop(1)
addLoadingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true).
MarginTop(1)
) )
// NewAddScreen creates a new add screen // NewAddScreen creates a new add screen
@@ -71,14 +76,24 @@ func NewAddScreen() *AddScreen {
), ),
) )
// Create spinner for loading states
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
return &AddScreen{ return &AddScreen{
form: form, form: form,
quitting: false, quitting: false,
spinner: s,
isCreating: false,
} }
} }
// Init initializes the add screen // Init initializes the add screen
func (s *AddScreen) Init() tea.Cmd { func (s *AddScreen) Init() tea.Cmd {
if s.isCreating {
return s.spinner.Tick
}
return s.form.Init() return s.form.Init()
} }
@@ -90,11 +105,20 @@ func (s *AddScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "q", "ctrl+c", "esc": case "q", "ctrl+c", "esc":
// Cancel and return to list // Cancel and return to list (only if not creating)
return nil, nil if !s.isCreating {
return nil, nil
}
} }
} }
// If creating, update spinner instead of form
if s.isCreating {
var cmd tea.Cmd
s.spinner, cmd = s.spinner.Update(msg)
return s, cmd
}
// Update the form // Update the form
form, cmd := s.form.Update(msg) form, cmd := s.form.Update(msg)
if f, ok := form.(*huh.Form); ok { if f, ok := form.(*huh.Form); ok {
@@ -108,6 +132,8 @@ func (s *AddScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
dns := s.form.GetString("dns") dns := s.form.GetString("dns")
usePSK := s.form.GetBool("use_psk") usePSK := s.form.GetBool("use_psk")
// Set creating state and start spinner
s.isCreating = true
// Create the client // Create the client
return s, s.createClient(name, dns, usePSK) return s, s.createClient(name, dns, usePSK)
} }
@@ -117,12 +143,31 @@ func (s *AddScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
// View renders the add screen // View renders the add screen
func (s *AddScreen) View() string { func (s *AddScreen) View() string {
// Breadcrumb: Clients > Add
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{
{Label: "Clients", ID: "list"},
{Label: "Add", ID: "add"},
})
if s.quitting { if s.quitting {
return "" return ""
} }
if s.isCreating {
return addLoadingStyle.Render(
lipgloss.JoinVertical(
lipgloss.Left,
addTitleStyle.Render("Add New WireGuard Client"),
s.spinner.View()+" Creating client configuration, please wait...",
),
)
}
content := lipgloss.JoinVertical( content := lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
breadcrumb,
"",
addTitleStyle.Render("Add New WireGuard Client"), addTitleStyle.Render("Add New WireGuard Client"),
s.form.View(), s.form.View(),
addHelpStyle.Render("Press Enter to submit • Esc to cancel"), addHelpStyle.Render("Press Enter to submit • Esc to cancel"),
@@ -137,7 +182,7 @@ func (s *AddScreen) createClient(name, dns string, usePSK bool) tea.Cmd {
// Create the client via wireguard package // Create the client via wireguard package
err := wireguard.CreateClient(name, dns, usePSK) err := wireguard.CreateClient(name, dns, usePSK)
if err != nil { if err != nil {
return errMsg{err: fmt.Errorf("failed to create client: %w", err)} return ErrMsg{Err: fmt.Errorf("failed to create client: %w", err)}
} }
// Return success message // Return success message

View File

@@ -5,6 +5,7 @@ import (
"time" "time"
"github.com/calmcacil/wg-admin/internal/tui/components" "github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/calmcacil/wg-admin/internal/tui/theme"
"github.com/calmcacil/wg-admin/internal/wireguard" "github.com/calmcacil/wg-admin/internal/wireguard"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -25,33 +26,8 @@ type DetailScreen struct {
// Styles // Styles
var ( var (
detailTitleStyle = lipgloss.NewStyle(). detailValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
Foreground(lipgloss.Color("62")). dimmedContentStyle = theme.StyleMuted
Bold(true)
detailSectionStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Bold(true).
MarginTop(1)
detailLabelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Width(18)
detailValueStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255"))
detailConnectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("46")).
Bold(true)
detailDisconnectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true)
detailWarningStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true)
detailHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("63")).
MarginTop(1)
detailErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
MarginTop(1)
) )
// NewDetailScreen creates a new detail screen for a client // NewDetailScreen creates a new detail screen for a client
@@ -139,9 +115,7 @@ func (s *DetailScreen) View() string {
if s.showConfig && s.configDisplay != nil { if s.showConfig && s.configDisplay != nil {
// Render underlying content dimmed // Render underlying content dimmed
content := s.renderContent() content := s.renderContent()
dimmedContent := lipgloss.NewStyle(). dimmedContent := dimmedContentStyle.Render(content)
Foreground(lipgloss.Color("244")).
Render(content)
// Overlay config display modal // Overlay config display modal
return lipgloss.JoinVertical( return lipgloss.JoinVertical(
@@ -155,9 +129,7 @@ func (s *DetailScreen) View() string {
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()
dimmedContent := lipgloss.NewStyle(). dimmedContent := dimmedContentStyle.Render(content)
Foreground(lipgloss.Color("244")).
Render(content)
// Overlay confirmation modal // Overlay confirmation modal
return lipgloss.JoinVertical( return lipgloss.JoinVertical(
@@ -172,23 +144,33 @@ func (s *DetailScreen) View() string {
// renderContent renders the main detail screen content // renderContent renders the main detail screen content
func (s *DetailScreen) renderContent() string { func (s *DetailScreen) renderContent() string {
// Breadcrumb: Clients > Client Name
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{
{Label: "Clients", ID: "list"},
{Label: s.client.Name, ID: "detail"},
})
statusText := s.status statusText := s.status
if s.status == wireguard.StatusConnected { if s.status == wireguard.StatusConnected {
statusText = detailConnectedStyle.Render("● " + s.status) duration := time.Since(s.lastHandshake)
quality := wireguard.CalculateQuality(duration)
statusText = theme.StyleSuccess.Bold(true).Render("● " + s.status + " (" + quality + ")")
} else { } else {
statusText = detailDisconnectedStyle.Render("● " + s.status) statusText = theme.StyleError.Bold(true).Render("● " + s.status)
} }
// Build content // Build content
content := lipgloss.JoinVertical( content := lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
detailTitleStyle.Render(fmt.Sprintf("Client Details: %s", s.client.Name)), breadcrumb,
"",
theme.StyleTitle.Render(fmt.Sprintf("Client Details: %s", s.client.Name)),
"", "",
s.renderField("Status", statusText), s.renderField("Status", statusText),
s.renderField("IPv4 Address", detailValueStyle.Render(s.client.IPv4)), s.renderField("IPv4 Address", detailValueStyle.Render(s.client.IPv4)),
s.renderField("IPv6 Address", detailValueStyle.Render(s.client.IPv6)), s.renderField("IPv6 Address", detailValueStyle.Render(s.client.IPv6)),
"", "",
detailSectionStyle.Render("WireGuard Configuration"), theme.StyleSubtitle.Bold(true).MarginTop(1).Render("WireGuard Configuration"),
s.renderField("Public Key", detailValueStyle.Render(s.client.PublicKey)), s.renderField("Public Key", detailValueStyle.Render(s.client.PublicKey)),
s.renderField("Preshared Key", detailValueStyle.Render(func() string { s.renderField("Preshared Key", detailValueStyle.Render(func() string {
if s.client.HasPSK { if s.client.HasPSK {
@@ -197,15 +179,15 @@ func (s *DetailScreen) renderContent() string {
return "Not configured" return "Not configured"
}())), }())),
"", "",
detailSectionStyle.Render("Connection Info"), theme.StyleSubtitle.Bold(true).MarginTop(1).Render("Connection Info"),
s.renderField("Last Handshake", detailValueStyle.Render(s.formatHandshake())), s.renderField("Last Handshake", detailValueStyle.Render(s.formatHandshake())),
s.renderField("Transfer (Rx/Tx)", detailValueStyle.Render(fmt.Sprintf("%s / %s", s.transferRx, s.transferTx))), s.renderField("Transfer (Rx/Tx)", detailValueStyle.Render(fmt.Sprintf("%s / %s", s.transferRx, s.transferTx))),
s.renderField("Config Path", detailValueStyle.Render(s.client.ConfigPath)), s.renderField("Config Path", detailValueStyle.Render(s.client.ConfigPath)),
"", "",
) )
// Add help text // Add help text with all keyboard shortcuts
helpText := detailHelpStyle.Render("Actions: [d] Delete • [c] View Config • [q/b] Back") helpText := theme.StyleHelpKey.MarginTop(1).Render("Shortcuts: [d] Delete • [c] View Config • [q/b/esc] Back")
content = lipgloss.JoinVertical(lipgloss.Left, content, helpText) content = lipgloss.JoinVertical(lipgloss.Left, content, helpText)
return content return content
@@ -214,7 +196,7 @@ func (s *DetailScreen) renderContent() string {
// renderField renders a label-value pair // renderField renders a label-value pair
func (s *DetailScreen) renderField(label string, value string) string { func (s *DetailScreen) renderField(label string, value string) string {
return lipgloss.JoinHorizontal(lipgloss.Left, return lipgloss.JoinHorizontal(lipgloss.Left,
detailLabelStyle.Render(label), theme.StyleSubtitle.Width(18).Render(label),
value, value,
) )
} }
@@ -242,7 +224,7 @@ func (s *DetailScreen) formatHandshake() string {
func (s *DetailScreen) loadClientStatus() tea.Msg { func (s *DetailScreen) loadClientStatus() tea.Msg {
peers, err := wireguard.GetAllPeers() peers, err := wireguard.GetAllPeers()
if err != nil { if err != nil {
return errMsg{err: err} return ErrMsg{Err: err}
} }
// Find peer by public key // Find peer by public key
@@ -271,7 +253,7 @@ func (s *DetailScreen) loadConfig() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
config, err := wireguard.GetClientConfigContent(s.client.Name) config, err := wireguard.GetClientConfigContent(s.client.Name)
if err != nil { if err != nil {
return errMsg{fmt.Errorf("failed to load client config: %w", err)} return ErrMsg{Err: fmt.Errorf("failed to load client config: %w", err)}
} }
// Create or update config display modal // Create or update config display modal
@@ -290,7 +272,7 @@ func (s *DetailScreen) deleteClient() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
err := wireguard.DeleteClient(s.client.Name) err := wireguard.DeleteClient(s.client.Name)
if err != nil { if err != nil {
return errMsg{fmt.Errorf("failed to delete client: %w", err)} return ErrMsg{fmt.Errorf("failed to delete client: %w", err)}
} }
return ClientDeletedMsg{ return ClientDeletedMsg{
Name: s.client.Name, Name: s.client.Name,

View File

@@ -0,0 +1,242 @@
package screens
import (
"strings"
"github.com/calmcacil/wg-admin/internal/tui/theme"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ErrorScreen displays user-friendly error messages with recovery options
type ErrorScreen struct {
err error
friendly string
actions []ErrorAction
quitting bool
}
// ErrorAction represents a recovery action
type ErrorAction struct {
Key string
Label string
Description string
}
// NewErrorScreen creates a new error screen with mapped error information
func NewErrorScreen(err error) *ErrorScreen {
screen := &ErrorScreen{
err: err,
friendly: err.Error(), // Fallback to raw error
actions: []ErrorAction{{Key: "enter", Label: "OK", Description: "Dismiss and return"}},
quitting: false,
}
// Map error to user-friendly message and recovery options
screen.mapError(err)
return screen
}
// Init initializes the error screen
func (s *ErrorScreen) Init() tea.Cmd {
return nil
}
// Update handles messages for the error screen
func (s *ErrorScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
// Quit application
s.quitting = true
return s, tea.Quit
case "enter", "esc":
// Dismiss error screen
return nil, nil
}
}
return s, nil
}
// View renders the error screen
func (s *ErrorScreen) View() string {
if s.quitting {
return ""
}
titleStyle := theme.StyleTitle.Copy().MarginBottom(1)
errorStyle := theme.StyleError.Copy().Bold(true)
msgStyle := theme.StyleSubtitle.Copy().MarginTop(1).MarginBottom(2)
actionTitleStyle := theme.StylePrimary.Copy().Bold(true).MarginTop(1)
actionStyle := theme.StyleMuted.Copy().MarginLeft(2)
// Build the error display
content := lipgloss.JoinVertical(
lipgloss.Left,
titleStyle.Render("Error Occurred"),
errorStyle.Render("⚠ "+s.friendly),
msgStyle.Render("Technical Details: "+s.err.Error()),
)
// Add recovery actions
if len(s.actions) > 1 {
content += "\n" + actionTitleStyle.Render("Recovery Options:")
for _, action := range s.actions {
key := theme.StyleHelpKey.Render("[" + action.Key + "]")
content += "\n" + actionStyle.Render(
lipgloss.JoinHorizontal(lipgloss.Left,
key+" ",
action.Label+" - "+action.Description,
),
)
}
}
content += "\n\n" + theme.StyleMuted.Render("Press Enter to dismiss • Press q to quit")
return lipgloss.NewStyle().
Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(theme.GetCurrentTheme().Scheme.Error).
Render(content)
}
// mapError converts technical errors to user-friendly messages and recovery options
func (s *ErrorScreen) mapError(err error) {
if err == nil {
return
}
errStr := strings.ToLower(err.Error())
// Permission errors
if strings.Contains(errStr, "permission") ||
strings.Contains(errStr, "denied") ||
strings.Contains(errStr, "operation not permitted") {
s.friendly = "Permission Denied"
s.actions = []ErrorAction{
{Key: "r", Label: "Run with sudo", Description: "Restart with elevated privileges"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// File not found errors
if strings.Contains(errStr, "no such file") ||
strings.Contains(errStr, "file not found") ||
strings.Contains(errStr, "does not exist") {
s.friendly = "Configuration File Missing"
s.actions = []ErrorAction{
{Key: "r", Label: "Restore", Description: "Restore from backup (if available)"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// Client already exists errors
if strings.Contains(errStr, "already exists") ||
strings.Contains(errStr, "duplicate") {
s.friendly = "Client Already Exists"
s.actions = []ErrorAction{
{Key: "n", Label: "New Name", Description: "Try a different client name"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// IP address exhaustion
if strings.Contains(errStr, "no available") ||
strings.Contains(errStr, "exhausted") ||
strings.Contains(errStr, "out of") {
s.friendly = "No Available IP Addresses"
s.actions = []ErrorAction{
{Key: "d", Label: "Delete Client", Description: "Remove an unused client to free IPs"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// Config file parsing errors
if strings.Contains(errStr, "parse") ||
strings.Contains(errStr, "invalid") ||
strings.Contains(errStr, "malformed") {
s.friendly = "Invalid Configuration"
s.actions = []ErrorAction{
{Key: "r", Label: "Restore", Description: "Restore from backup"},
{Key: "m", Label: "Manual Fix", Description: "Edit configuration manually"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// WireGuard command failures
if strings.Contains(errStr, "wg genkey") ||
strings.Contains(errStr, "wg pubkey") ||
strings.Contains(errStr, "wg genpsk") ||
strings.Contains(errStr, "wg set") {
s.friendly = "WireGuard Command Failed"
s.actions = []ErrorAction{
{Key: "i", Label: "Install WG", Description: "Ensure WireGuard is installed"},
{Key: "s", Label: "Check Service", Description: "Verify WireGuard service is running"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// Network errors
if strings.Contains(errStr, "network") ||
strings.Contains(errStr, "connection") ||
strings.Contains(errStr, "timeout") {
s.friendly = "Network Error"
s.actions = []ErrorAction{
{Key: "r", Label: "Retry", Description: "Attempt the operation again"},
{Key: "c", Label: "Check Connection", Description: "Verify network connectivity"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// Config directory not found
if strings.Contains(errStr, "config directory") ||
strings.Contains(errStr, "wireguard") {
s.friendly = "WireGuard Not Configured"
s.actions = []ErrorAction{
{Key: "i", Label: "Install WireGuard", Description: "Set up WireGuard on this server"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// DNS validation errors
if strings.Contains(errStr, "dns") ||
strings.Contains(errStr, "invalid address") {
s.friendly = "Invalid DNS Configuration"
s.actions = []ErrorAction{
{Key: "e", Label: "Edit DNS", Description: "Update DNS server settings"},
{Key: "d", Label: "Use Default", Description: "Use default DNS (8.8.8.8, 8.8.4.4)"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// Backup/restore errors
if strings.Contains(errStr, "backup") ||
strings.Contains(errStr, "restore") {
s.friendly = "Backup Operation Failed"
s.actions = []ErrorAction{
{Key: "r", Label: "Retry", Description: "Attempt backup/restore again"},
{Key: "c", Label: "Check Space", Description: "Verify disk space is available"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
return
}
// Default: generic error
s.friendly = "An Error Occurred"
s.actions = []ErrorAction{
{Key: "r", Label: "Retry", Description: "Attempt the operation again"},
{Key: "enter", Label: "Dismiss", Description: "Return to previous screen"},
}
}

View File

@@ -1,6 +1,8 @@
package screens package screens
import ( import (
"github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/calmcacil/wg-admin/internal/tui/theme"
"github.com/charmbracelet/bubbletea" "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
@@ -37,54 +39,48 @@ func (s *HelpScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
// View renders the help screen // View renders the help screen
func (s *HelpScreen) View() string { func (s *HelpScreen) View() string {
// Styles // Breadcrumb: Help
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Help", ID: "help"}})
// Styles using theme
borderStyle := lipgloss.NewStyle(). borderStyle := lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()). BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")). BorderForeground(theme.StyleBorder).
Padding(1, 2) Padding(1, 2)
headerStyle := lipgloss.NewStyle(). keyStyle := theme.StyleHelpKey.Width(12)
Foreground(lipgloss.Color("62")).
Bold(true).
MarginBottom(1)
categoryStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true).
MarginTop(1).
MarginBottom(0)
keyStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("63")).
Bold(true).
Width(12)
descStyle := lipgloss.NewStyle(). descStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("250")) Foreground(lipgloss.Color("250"))
// Header // Header
header := headerStyle.Render("Keyboard Shortcuts") header := theme.StyleTitle.MarginBottom(1).Render("Keyboard Shortcuts")
// Shortcut groups // Shortcut groups
navigationGroup := categoryStyle.Render("Navigation") + "\n" + navigationGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Navigation") + "\n" +
keyStyle.Render("j / ↓") + descStyle.Render("Move down") + "\n" + keyStyle.Render("j / ↓") + descStyle.Render("Move down") + "\n" +
keyStyle.Render("k / ↑") + descStyle.Render("Move up") + "\n" + keyStyle.Render("k / ↑") + descStyle.Render("Move up") + "\n" +
keyStyle.Render("← →") + descStyle.Render("Move left/right") + "\n" + keyStyle.Render("← →") + descStyle.Render("Move left/right") + "\n" +
keyStyle.Render("Enter") + descStyle.Render("Select") + "\n" + keyStyle.Render("Enter") + descStyle.Render("Select") + "\n" +
keyStyle.Render("Esc") + descStyle.Render("Go back") keyStyle.Render("Esc") + descStyle.Render("Go back")
actionsGroup := categoryStyle.Render("Actions") + "\n" + actionsGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Actions") + "\n" +
keyStyle.Render("a") + descStyle.Render("Add client") + "\n" + keyStyle.Render("a") + descStyle.Render("Add client") + "\n" +
keyStyle.Render("d") + descStyle.Render("Delete client") + "\n" + keyStyle.Render("d") + descStyle.Render("Delete client") + "\n" +
keyStyle.Render("Q") + descStyle.Render("Show QR code") + "\n" + keyStyle.Render("Q") + descStyle.Render("Show QR code") + "\n" +
keyStyle.Render("r") + descStyle.Render("Refresh list") + "\n" + keyStyle.Render("r") + descStyle.Render("Refresh list") + "\n" +
keyStyle.Render("l") + descStyle.Render("List view") keyStyle.Render("l") + descStyle.Render("List view")
otherGroup := categoryStyle.Render("Other") + "\n" + otherGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Other") + "\n" +
keyStyle.Render("?") + descStyle.Render("Show this help") + "\n" + keyStyle.Render("?") + descStyle.Render("Show this help") + "\n" +
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 := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).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,
@@ -94,18 +90,17 @@ 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)
// Footer // Footer
footerStyle := lipgloss.NewStyle(). footer := theme.StyleMuted.MarginTop(1).Render("Press q or Esc to return")
Foreground(lipgloss.Color("241")).
MarginTop(1)
footer := footerStyle.Render("Press q or Esc to return")
// Combine all // Combine all
return borderStyle.Render( return breadcrumb + "\n\n" + borderStyle.Render(
lipgloss.JoinVertical(lipgloss.Left, header, content, footer), lipgloss.JoinVertical(lipgloss.Left, header, content, footer),
) )
} }

View File

@@ -4,6 +4,16 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// TransitionType defines the type of transition animation
type TransitionType int
const (
TransitionNone TransitionType = iota
TransitionFade
TransitionSlideLeft
TransitionSlideRight
)
// Screen represents a UI screen (list, add, detail, etc.) // Screen represents a UI screen (list, add, detail, etc.)
type Screen interface { type Screen interface {
Init() tea.Cmd Init() tea.Cmd

View File

@@ -1,6 +1,7 @@
package screens package screens
import ( import (
"fmt"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -27,8 +28,9 @@ type ListScreen struct {
// ClientWithStatus wraps a client with its connection status // ClientWithStatus wraps a client with its connection status
type ClientWithStatus struct { type ClientWithStatus struct {
Client wireguard.Client Client wireguard.Client
Status string Status string
Quality string
} }
// NewListScreen creates a new list screen // NewListScreen creates a new list screen
@@ -136,16 +138,60 @@ func (s *ListScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
// View renders the list screen // View renders the list screen
func (s *ListScreen) View() string { func (s *ListScreen) View() string {
// Breadcrumb: Home
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{
{Label: "Clients", ID: "list"},
})
if len(s.clients) == 0 { if len(s.clients) == 0 {
return s.search.View() + "\n" + "No clients found. Press 'r' to refresh or 'q' to quit." // Empty state with helpful guidance
return breadcrumb + "\n" + s.search.View() + "\n\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true).
Render("No clients yet") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Render("Let's get started! Here are your options:") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Press [a] to add your first client") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Press [R] to restore from backup") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Press [r] to refresh the client list") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Press [q] to quit")
} }
// Check if there are no matches // Check if there are no matches
if s.search.IsActive() && len(s.filtered) == 0 && s.search.GetQuery() != "" { if s.search.IsActive() && len(s.filtered) == 0 && s.search.GetQuery() != "" {
return s.search.View() + "\n" + lipgloss.NewStyle(). // Empty search results with helpful tips
Foreground(lipgloss.Color("241")). return breadcrumb + "\n" + s.search.View() + "\n\n" +
Italic(true). lipgloss.NewStyle().
Render("No matching clients found. Try a different search term.") Foreground(lipgloss.Color("226")).
Bold(true).
Render("No matching clients found") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Render("Search tips:") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Check your spelling") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Try a shorter search term") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Search by name, IP, or status") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Press [esc] to clear search") + "\n" +
lipgloss.NewStyle().
Foreground(lipgloss.Color("109")).
Render(" • Press [r] to refresh client list")
} }
// Calculate time since last update // Calculate time since last update
@@ -159,7 +205,13 @@ func (s *ListScreen) View() string {
Foreground(lipgloss.Color("241")). Foreground(lipgloss.Color("241")).
Render("Last updated: " + timeAgo) Render("Last updated: " + timeAgo)
return s.search.View() + "\n" + s.table.View() + "\n" + lastUpdatedText // Add keyboard shortcuts help
helpText := lipgloss.NewStyle().
Foreground(lipgloss.Color("63")).
MarginTop(1).
Render("Shortcuts: [?/h] Help • [a] Add • [R] Restore • [r] Refresh • [Q] QR Code • [/] Search • [1-4] Sort • [Enter] Details • [q] Quit")
return breadcrumb + "\n" + s.search.View() + "\n" + s.table.View() + "\n" + lastUpdatedText + "\n" + helpText
} }
// formatDuration returns a human-readable string for the duration // formatDuration returns a human-readable string for the duration
@@ -173,23 +225,40 @@ func formatDuration(d time.Duration) string {
return fmt.Sprintf("%d hr ago", int(d.Hours())) return fmt.Sprintf("%d hr ago", int(d.Hours()))
} }
// loadClients loads clients from wireguard config
// loadClients loads clients from wireguard config // loadClients loads clients from wireguard config
func (s *ListScreen) loadClients() tea.Msg { func (s *ListScreen) loadClients() tea.Msg {
clients, err := wireguard.ListClients() clients, err := wireguard.ListClients()
if err != nil { if err != nil {
return errMsg{err: err} return ErrMsg{Err: err}
} }
// Get status for each client // Get all peer statuses to retrieve quality information
peerStatuses, err := wireguard.GetAllPeers()
if err != nil {
return ErrMsg{Err: err}
}
// Match clients with their peer status
clientsWithStatus := make([]ClientWithStatus, len(clients)) clientsWithStatus := make([]ClientWithStatus, len(clients))
for i, client := range clients { for i, client := range clients {
status, err := wireguard.GetClientStatus(client.PublicKey) status := wireguard.StatusDisconnected
if err != nil { quality := ""
status = wireguard.StatusDisconnected
// Find matching peer status
for _, peerStatus := range peerStatuses {
if peerStatus.PublicKey == client.PublicKey {
status = peerStatus.Status
quality = peerStatus.Quality
break
}
} }
clientsWithStatus[i] = ClientWithStatus{ clientsWithStatus[i] = ClientWithStatus{
Client: client, Client: client,
Status: status, Status: status,
Quality: quality,
} }
} }
@@ -228,13 +297,24 @@ func (s *ListScreen) applyFilter() {
s.buildTable() s.buildTable()
} }
// formatStatusWithIcon formats the status with a colored circle icon
func (s *ListScreen) formatStatusWithIcon(status string, quality string) string {
if status == wireguard.StatusConnected {
if quality != "" {
return lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("●") + " " + status + " (" + quality + ")"
}
return lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("●") + " " + status
}
return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("●") + " " + status
}
// buildTable creates and configures the table // buildTable creates and configures the table
func (s *ListScreen) buildTable() { func (s *ListScreen) buildTable() {
columns := []table.Column{ columns := []table.Column{
{Title: "Name", Width: 20}, {Title: "Name", Width: 20},
{Title: "IPv4", Width: 15}, {Title: "IPv4", Width: 15},
{Title: "IPv6", Width: 35}, {Title: "IPv6", Width: 35},
{Title: "Status", Width: 12}, {Title: "Status", Width: 14},
} }
// Use filtered clients if search is active, otherwise use all clients // Use filtered clients if search is active, otherwise use all clients
@@ -245,11 +325,31 @@ func (s *ListScreen) buildTable() {
var rows []table.Row var rows []table.Row
for _, cws := range displayClients { for _, cws := range displayClients {
// Apply highlighting based on filter type
name := cws.Client.Name
ipv4 := cws.Client.IPv4
ipv6 := cws.Client.IPv6
status := cws.Status
if s.search.IsActive() {
switch s.search.GetFilterType() {
case components.FilterByName:
name = s.search.HighlightMatches(name)
case components.FilterByIPv4:
ipv4 = s.search.HighlightMatches(ipv4)
case components.FilterByIPv6:
ipv6 = s.search.HighlightMatches(ipv6)
case components.FilterByStatus:
status = s.search.HighlightMatches(status)
}
}
statusText := s.formatStatusWithIcon(status, cws.Quality)
row := table.Row{ row := table.Row{
cws.Client.Name, name,
cws.Client.IPv4, ipv4,
cws.Client.IPv6, ipv6,
cws.Status, statusText,
} }
rows = append(rows, row) rows = append(rows, row)
} }
@@ -356,7 +456,7 @@ type clientsLoadedMsg struct {
clients []ClientWithStatus clients []ClientWithStatus
} }
// errMsg is sent when an error occurs // ErrMsg is sent when an error occurs
type errMsg struct { type ErrMsg struct {
err error Err error
} }

View File

@@ -0,0 +1,220 @@
lastUpdatedText := lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Render("Last updated: " + timeAgo)
// Add keyboard shortcuts help
helpText := lipgloss.NewStyle().
Foreground(lipgloss.Color("63")).
MarginTop(1).
Render("Shortcuts: [?/h] Help • [a] Add • [R] Restore • [r] Refresh • [Q] QR Code • [/] Search • [1-4] Sort • [Enter] Details • [q] Quit")
return breadcrumb + "\n" + s.search.View() + "\n" + s.table.View() + "\n" + lastUpdatedText + "\n" + helpText
}
// formatDuration returns a human-readable string for the duration
func formatDuration(d time.Duration) string {
if d < time.Minute {
return "just now"
}
if d < time.Hour {
return fmt.Sprintf("%d min ago", int(d.Minutes()))
}
return fmt.Sprintf("%d hr ago", int(d.Hours()))
}
// loadClients loads clients from wireguard config
func (s *ListScreen) loadClients() tea.Msg {
clients, err := wireguard.ListClients()
if err != nil {
return ErrMsg{Err: err}
}
// Get status for each client
clientsWithStatus := make([]ClientWithStatus, len(clients))
for i, client := range clients {
status, err := wireguard.GetClientStatus(client.PublicKey)
if err != nil {
status = wireguard.StatusDisconnected
}
clientsWithStatus[i] = ClientWithStatus{
Client: client,
Status: status,
}
}
return clientsLoadedMsg{clients: clientsWithStatus}
}
// applyFilter applies the current search filter to clients
func (s *ListScreen) applyFilter() {
// Convert clients to ClientData for filtering
clientData := make([]components.ClientData, len(s.clients))
for i, cws := range s.clients {
clientData[i] = components.ClientData{
Name: cws.Client.Name,
IPv4: cws.Client.IPv4,
IPv6: cws.Client.IPv6,
Status: cws.Status,
}
}
// Filter clients
filteredData := s.search.Filter(clientData)
// Convert back to ClientWithStatus
s.filtered = make([]ClientWithStatus, len(filteredData))
for i, cd := range filteredData {
// Find the matching client
for _, cws := range s.clients {
if cws.Client.Name == cd.Name {
s.filtered[i] = cws
break
}
}
}
// Rebuild table with filtered clients
s.buildTable()
}
// formatStatusWithIcon formats the status with a colored circle icon
func (s *ListScreen) formatStatusWithIcon(status string) string {
if status == wireguard.StatusConnected {
return lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("●") + " " + status
}
return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("●") + " " + status
}
// buildTable creates and configures the table
func (s *ListScreen) buildTable() {
columns := []table.Column{
{Title: "Name", Width: 20},
{Title: "IPv4", Width: 15},
{Title: "IPv6", Width: 35},
{Title: "Status", Width: 14},
}
// Use filtered clients if search is active, otherwise use all clients
displayClients := s.filtered
if !s.search.IsActive() {
displayClients = s.clients
}
var rows []table.Row
for _, cws := range displayClients {
statusText := s.formatStatusWithIcon(cws.Status)
row := table.Row{
cws.Client.Name,
cws.Client.IPv4,
cws.Client.IPv6,
statusText,
}
rows = append(rows, row)
}
// Sort rows based on current sort settings
s.sortRows(rows)
// Determine table height
tableHeight := len(rows) + 2 // Header + rows
if tableHeight < 5 {
tableHeight = 5
}
s.table = table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(tableHeight),
)
// Apply styles
s.setTableStyles()
}
// setTableStyles applies styling to the table
func (s *ListScreen) setTableStyles() {
styles := table.DefaultStyles()
styles.Header = styles.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(true)
styles.Selected = styles.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
s.table.SetStyles(styles)
}
// sortRows sorts the rows based on the current sort settings
func (s *ListScreen) sortRows(rows []table.Row) {
colIndex := s.getColumnIndex(s.sortedBy)
sort.Slice(rows, func(i, j int) bool {
var valI, valJ string
if colIndex < len(rows[i]) {
valI = rows[i][colIndex]
}
if colIndex < len(rows[j]) {
valJ = rows[j][colIndex]
}
if s.ascending {
return strings.ToLower(valI) < strings.ToLower(valJ)
}
return strings.ToLower(valI) > strings.ToLower(valJ)
})
}
// sortByColumn changes the sort column
func (s *ListScreen) sortByColumn(col string) {
sortedBy := "Name"
switch col {
case "1":
sortedBy = "Name"
case "2":
sortedBy = "IPv4"
case "3":
sortedBy = "IPv6"
case "4":
sortedBy = "Status"
}
// Toggle direction if clicking same column
if s.sortedBy == sortedBy {
s.ascending = !s.ascending
} else {
s.sortedBy = sortedBy
s.ascending = true
}
s.buildTable()
}
// getColumnIndex returns the index of a column by name
func (s *ListScreen) getColumnIndex(name string) int {
switch name {
case "Name":
return 0
case "IPv4":
return 1
case "IPv6":
return 2
case "Status":
return 3
}
return 0
}
// Messages
// clientsLoadedMsg is sent when clients are loaded
type clientsLoadedMsg struct {
clients []ClientWithStatus
}
// ErrMsg is sent when an error occurs
type ErrMsg struct {
Err error
}

View File

@@ -4,6 +4,8 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/calmcacil/wg-admin/internal/tui/theme"
"github.com/calmcacil/wg-admin/internal/wireguard" "github.com/calmcacil/wg-admin/internal/wireguard"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -54,8 +56,8 @@ func (s *QRScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
case configLoadedMsg: case configLoadedMsg:
s.configContent = msg.content s.configContent = msg.content
s.generateQRCode() s.generateQRCode()
case errMsg: case ErrMsg:
s.errorMsg = msg.err.Error() s.errorMsg = msg.Err.Error()
} }
return s, nil return s, nil
@@ -63,20 +65,23 @@ func (s *QRScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
// View renders the QR screen // View renders the QR screen
func (s *QRScreen) View() string { func (s *QRScreen) View() string {
// Breadcrumb: Clients > Client > QR Code
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Clients", ID: "list"}, {Label: s.clientName, ID: "detail"}, {Label: "QR Code", ID: "qr"}})
if s.errorMsg != "" { if s.errorMsg != "" {
return s.renderError() return s.renderError()
} }
if s.qrCode == "" { if s.qrCode == "" {
return "Loading QR code..." return "Loading QR code..."
} }
return s.renderQR() return breadcrumb + "\n" + s.renderQR()
} }
// loadConfig loads the client configuration // loadConfig loads the client configuration
func (s *QRScreen) loadConfig() tea.Msg { func (s *QRScreen) loadConfig() tea.Msg {
content, err := wireguard.GetClientConfigContent(s.clientName) content, err := wireguard.GetClientConfigContent(s.clientName)
if err != nil { if err != nil {
return errMsg{err: err} return ErrMsg{Err: err}
} }
return configLoadedMsg{content: content} return configLoadedMsg{content: content}
} }
@@ -98,39 +103,22 @@ func (s *QRScreen) generateQRCode() {
// renderQR renders the QR code with styling // renderQR renders the QR code with styling
func (s *QRScreen) renderQR() string { func (s *QRScreen) renderQR() string {
styleTitle := lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true).
MarginBottom(1)
styleHelp := lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
styleQR := lipgloss.NewStyle(). styleQR := lipgloss.NewStyle().
MarginLeft(2) MarginLeft(2)
title := styleTitle.Render(fmt.Sprintf("QR Code: %s", s.clientName)) title := theme.StyleTitle.MarginBottom(1).Render(fmt.Sprintf("QR Code: %s", s.clientName))
help := "Press [f] to toggle fullscreen • Press [q/Escape] to return" help := "Press [f] to toggle fullscreen • Press [q/Escape] to return"
return title + "\n\n" + styleQR.Render(s.qrCode) + "\n" + styleHelp.Render(help) return title + "\n\n" + styleQR.Render(s.qrCode) + "\n" + theme.StyleMuted.MarginTop(1).Render(help)
} }
// renderError renders an error message // renderError renders an error message
func (s *QRScreen) renderError() string { func (s *QRScreen) renderError() string {
styleError := lipgloss.NewStyle(). title := theme.StyleError.Bold(true).Render("Error")
Foreground(lipgloss.Color("196")).
Bold(true)
styleHelp := lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
title := styleError.Render("Error")
message := s.errorMsg message := s.errorMsg
help := "Press [q/Escape] to return" help := "Press [q/Escape] to return"
return title + "\n\n" + message + "\n" + styleHelp.Render(help) return title + "\n\n" + message + "\n" + theme.StyleMuted.MarginTop(1).Render(help)
} }
// Messages // Messages

View File

@@ -6,6 +6,8 @@ import (
"github.com/calmcacil/wg-admin/internal/backup" "github.com/calmcacil/wg-admin/internal/backup"
"github.com/calmcacil/wg-admin/internal/tui/components" "github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/calmcacil/wg-admin/internal/tui/theme"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -22,31 +24,21 @@ type RestoreScreen struct {
restoreError error restoreError error
restoreSuccess bool restoreSuccess bool
message string message string
spinner spinner.Model
} }
// Styles // No local styles - all use theme package
var (
restoreTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true)
restoreHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("63")).
MarginTop(1)
restoreSuccessStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("46")).
Bold(true)
restoreErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true)
restoreInfoStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
)
// NewRestoreScreen creates a new restore screen // NewRestoreScreen creates a new restore screen
func NewRestoreScreen() *RestoreScreen { func NewRestoreScreen() *RestoreScreen {
// Create spinner for loading states
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
return &RestoreScreen{ return &RestoreScreen{
showConfirm: false, showConfirm: false,
spinner: s,
} }
} }
@@ -59,6 +51,12 @@ func (s *RestoreScreen) Init() tea.Cmd {
func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
// If restoring, only update spinner
if s.isRestoring && !s.showConfirm {
s.spinner, cmd = s.spinner.Update(msg)
return s, cmd
}
// 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)
@@ -69,7 +67,7 @@ func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
// User confirmed restore // User confirmed restore
s.isRestoring = true s.isRestoring = true
s.showConfirm = false s.showConfirm = false
return s, s.performRestore() return s, tea.Sequence(s.spinner.Tick, s.performRestore())
} }
// User cancelled - close modal // User cancelled - close modal
s.showConfirm = false s.showConfirm = false
@@ -82,7 +80,7 @@ func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
if msg.String() == "enter" && s.confirmModal.IsConfirmed() && s.selectedBackup != nil { if msg.String() == "enter" && s.confirmModal.IsConfirmed() && s.selectedBackup != nil {
s.isRestoring = true s.isRestoring = true
s.showConfirm = false s.showConfirm = false
return s, s.performRestore() return s, tea.Sequence(s.spinner.Tick, s.performRestore())
} }
} }
@@ -125,14 +123,14 @@ func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
} }
} }
} }
case restoreCompletedMsg: case RestoreCompletedMsg:
s.isRestoring = false s.isRestoring = false
if msg.err != nil { if msg.Err != nil {
s.restoreError = msg.err s.restoreError = msg.Err
s.message = fmt.Sprintf("Restore failed: %v", msg.err) s.message = fmt.Sprintf("Restore failed: %v", msg.Err)
} else { } else {
s.restoreSuccess = true s.restoreSuccess = true
s.message = fmt.Sprintf("Restore successful! Safety backup created at: %s", msg.safetyBackupPath) s.message = fmt.Sprintf("Restore successful! Safety backup created at: %s", msg.SafetyBackupPath)
} }
} }
@@ -164,9 +162,14 @@ func (s *RestoreScreen) View() string {
// renderContent renders the main restore screen content // renderContent renders the main restore screen content
func (s *RestoreScreen) renderContent() string { func (s *RestoreScreen) renderContent() string {
// Breadcrumb: Clients > Restore
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Clients", ID: "list"}, {Label: "Restore", ID: "restore"}})
var content strings.Builder var content strings.Builder
content.WriteString(restoreTitleStyle.Render("Restore WireGuard Configuration")) content.WriteString(breadcrumb)
content.WriteString("\n")
content.WriteString(theme.StyleTitle.Render("Restore WireGuard Configuration"))
content.WriteString("\n\n") content.WriteString("\n\n")
if len(s.backups) == 0 && !s.isRestoring && s.message == "" { if len(s.backups) == 0 && !s.isRestoring && s.message == "" {
@@ -175,23 +178,23 @@ func (s *RestoreScreen) renderContent() string {
} }
if s.isRestoring { if s.isRestoring {
content.WriteString("Restoring from backup, please wait...") content.WriteString(theme.StyleTitle.Render(s.spinner.View() + " Restoring from backup, please wait..."))
return content.String() return content.String()
} }
if s.restoreSuccess { if s.restoreSuccess {
content.WriteString(restoreSuccessStyle.Render("✓ " + s.message)) content.WriteString(theme.StyleSuccess.Render("✓ " + s.message))
content.WriteString("\n\n") content.WriteString("\n\n")
content.WriteString(restoreInfoStyle.Render("Press 'q' to return to client list.")) content.WriteString(theme.StyleMuted.Render("Press 'q' to return to client list."))
return content.String() return content.String()
} }
if s.restoreError != nil { if s.restoreError != nil {
content.WriteString(restoreErrorStyle.Render("✗ " + s.message)) content.WriteString(theme.StyleError.Render("✗ " + s.message))
content.WriteString("\n\n") content.WriteString("\n\n")
content.WriteString(s.table.View()) content.WriteString(s.table.View())
content.WriteString("\n\n") content.WriteString("\n\n")
content.WriteString(restoreHelpStyle.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back")) content.WriteString(theme.StyleHelpKey.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back"))
return content.String() return content.String()
} }
@@ -201,7 +204,7 @@ func (s *RestoreScreen) renderContent() string {
// Show selected backup details // Show selected backup details
if len(s.table.Rows()) > 0 && s.selectedBackup != nil { if len(s.table.Rows()) > 0 && s.selectedBackup != nil {
content.WriteString(restoreInfoStyle.Render( content.WriteString(theme.StyleMuted.Render(
fmt.Sprintf( fmt.Sprintf(
"Selected: %s (%s) - %s\nSize: %s", "Selected: %s (%s) - %s\nSize: %s",
s.selectedBackup.Operation, s.selectedBackup.Operation,
@@ -213,7 +216,7 @@ func (s *RestoreScreen) renderContent() string {
content.WriteString("\n") content.WriteString("\n")
} }
content.WriteString(restoreHelpStyle.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back")) content.WriteString(theme.StyleHelpKey.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back"))
return content.String() return content.String()
} }
@@ -222,7 +225,7 @@ func (s *RestoreScreen) renderContent() string {
func (s *RestoreScreen) loadBackups() tea.Msg { func (s *RestoreScreen) loadBackups() tea.Msg {
backups, err := backup.ListBackups() backups, err := backup.ListBackups()
if err != nil { if err != nil {
return errMsg{err: err} return ErrMsg{Err: err}
} }
return backupsLoadedMsg{backups: backups} return backupsLoadedMsg{backups: backups}
} }
@@ -261,15 +264,8 @@ func (s *RestoreScreen) buildTable() {
// setTableStyles applies styling to the table // setTableStyles applies styling to the table
func (s *RestoreScreen) setTableStyles() { func (s *RestoreScreen) setTableStyles() {
styles := table.DefaultStyles() styles := table.DefaultStyles()
styles.Header = styles.Header. styles.Header = theme.StyleTableHeader
BorderStyle(lipgloss.NormalBorder()). styles.Selected = theme.StyleTableSelected
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(true)
styles.Selected = styles.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
s.table.SetStyles(styles) s.table.SetStyles(styles)
} }

View File

@@ -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,8 +182,83 @@ 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")
} }
// Modal styles
var (
// ModalBaseStyle is the base style for all modals
ModalBaseStyle = func() lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("196")).
Padding(1, 2).
Background(StyleBackground)
}
// ModalTitleStyle is the style for modal titles
ModalTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true)
// ModalMessageStyle is the style for modal messages
ModalMessageStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Width(50)
// ModalHelpStyle is the style for modal help text
ModalHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
// ModalSelectedStyle is the style for selected modal options
ModalSelectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("57")).
Bold(true).
Underline(true)
// ModalUnselectedStyle is the style for unselected modal options
ModalUnselectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241"))
)
// Status icon styles
var (
// StatusConnectedStyle is the style for connected status icons
StatusConnectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("46"))
// StatusDisconnectedStyle is the style for disconnected status icons
StatusDisconnectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196"))
)
// GetThemeNames returns a list of available theme names // GetThemeNames returns a list of available theme names
func GetThemeNames() []string { func GetThemeNames() []string {
names := make([]string, 0, len(ThemeRegistry)) names := make([]string, 0, len(ThemeRegistry))

View File

@@ -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
} }