package screens import ( "sort" "strings" "github.com/calmcacil/wg-admin/internal/tui/components" "github.com/calmcacil/wg-admin/internal/wireguard" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) const statusRefreshInterval = 10 // seconds // ListScreen displays a table of WireGuard clients type ListScreen struct { table table.Model search *components.SearchModel clients []ClientWithStatus filtered []ClientWithStatus sortedBy string // Column name being sorted by ascending bool // Sort direction } // ClientWithStatus wraps a client with its connection status type ClientWithStatus struct { Client wireguard.Client Status string } // NewListScreen creates a new list screen func NewListScreen() *ListScreen { return &ListScreen{ search: components.NewSearch(), sortedBy: "Name", ascending: true, } } // Init initializes the list screen func (s *ListScreen) Init() tea.Cmd { return tea.Batch( s.loadClients, wireguard.Tick(statusRefreshInterval), ) } // Update handles messages for the list screen func (s *ListScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: // Handle search activation if msg.String() == "/" && !s.search.IsActive() { s.search.Activate() return s, nil } // If search is active, pass input to search if s.search.IsActive() { s.search, cmd = s.search.Update(msg) // Apply filter to clients s.applyFilter() return s, cmd } // Normal key handling when search is not active switch msg.String() { case "q", "ctrl+c": // Handle quit in parent model return s, nil case "r": // Refresh clients return s, s.loadClients case "R": // Show restore screen return NewRestoreScreen(), nil case "Q": // Show QR code for selected client if len(s.table.Rows()) > 0 { selected := s.table.SelectedRow() clientName := selected[0] // First column is Name return NewQRScreen(clientName), nil } case "enter": // Open detail view for selected client if len(s.table.Rows()) > 0 { selectedRow := s.table.SelectedRow() selectedName := selectedRow[0] // First column is Name // Find the client with this name for _, cws := range s.clients { if cws.Client.Name == selectedName { return s, func() tea.Msg { return ClientSelectedMsg{Client: cws} } } } } case "1", "2", "3", "4": // Sort by column number (Name, IPv4, IPv6, Status) s.sortByColumn(msg.String()) } case clientsLoadedMsg: s.clients = msg.clients s.search.SetTotalCount(len(s.clients)) s.applyFilter() case wireguard.StatusTickMsg: // Refresh status on periodic tick return s, s.loadClients case wireguard.RefreshStatusMsg: // Refresh status on manual refresh return s, s.loadClients } s.table, cmd = s.table.Update(msg) return s, cmd } // View renders the list screen func (s *ListScreen) View() string { if len(s.clients) == 0 { return s.search.View() + "\n" + "No clients found. Press 'r' to refresh or 'q' to quit." } // Check if there are no matches if s.search.IsActive() && len(s.filtered) == 0 && s.search.GetQuery() != "" { return s.search.View() + "\n" + lipgloss.NewStyle(). Foreground(lipgloss.Color("241")). Italic(true). Render("No matching clients found. Try a different search term.") } return s.search.View() + "\n" + s.table.View() } // 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() } // 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: 12}, } // 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 { row := table.Row{ cws.Client.Name, cws.Client.IPv4, cws.Client.IPv6, cws.Status, } 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 }