init
This commit is contained in:
36
internal/pages/home.go
Normal file
36
internal/pages/home.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package pages
|
||||
|
||||
type HomePageMsg struct{}
|
||||
|
||||
type SettingsPageMsg struct{}
|
||||
|
||||
type SelectServerPageMsg struct{}
|
||||
|
||||
type VPNStatusMsg struct {
|
||||
Connected bool
|
||||
Err error
|
||||
}
|
||||
|
||||
type VPNToggleMsg struct {
|
||||
Connected bool
|
||||
Err error
|
||||
}
|
||||
|
||||
type MenuItem struct {
|
||||
Label string
|
||||
Key string
|
||||
}
|
||||
|
||||
func HomeMenuItems(connected, hasServers bool) []MenuItem {
|
||||
var items []MenuItem
|
||||
if hasServers {
|
||||
if connected {
|
||||
items = append(items, MenuItem{Label: "Turn Off", Key: "off"})
|
||||
} else {
|
||||
items = append(items, MenuItem{Label: "Turn On", Key: "on"})
|
||||
}
|
||||
items = append(items, MenuItem{Label: "Select Server", Key: "select-server"})
|
||||
}
|
||||
items = append(items, MenuItem{Label: "Settings", Key: "settings"})
|
||||
return items
|
||||
}
|
||||
10
internal/pages/select_server.go
Normal file
10
internal/pages/select_server.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package pages
|
||||
|
||||
import "tailscale-vpn/internal/services"
|
||||
|
||||
type ServerSelectedMsg struct {
|
||||
Server services.Server
|
||||
Err error
|
||||
}
|
||||
|
||||
type ServerSelectionCanceledMsg struct{}
|
||||
21
internal/pages/settings.go
Normal file
21
internal/pages/settings.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package pages
|
||||
|
||||
import "tailscale-vpn/internal/services"
|
||||
|
||||
type ServerListLoadedMsg struct {
|
||||
Servers []services.Server
|
||||
Err error
|
||||
}
|
||||
|
||||
type ServerEditMsg struct {
|
||||
Mode string
|
||||
Server services.Server
|
||||
}
|
||||
|
||||
type ServerSaveMsg struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
type ServerDeleteMsg struct {
|
||||
Err error
|
||||
}
|
||||
130
internal/services/config.go
Normal file
130
internal/services/config.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Servers []Server `json:"servers"`
|
||||
SelectedServerID string `json:"selectedServerId"`
|
||||
}
|
||||
|
||||
type ConfigService struct {
|
||||
path string
|
||||
config Config
|
||||
}
|
||||
|
||||
func NewConfigService() (*ConfigService, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get config dir: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Join(configDir, "tailscale-vpn")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
if err := os.WriteFile(path, []byte(`{"servers":[]}`), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("create config file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config file: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
return &ConfigService{path: path, config: cfg}, nil
|
||||
}
|
||||
|
||||
func (s *ConfigService) GetServers() []Server {
|
||||
return s.config.Servers
|
||||
}
|
||||
|
||||
func (s *ConfigService) GetServer(id string) (Server, bool) {
|
||||
for _, srv := range s.config.Servers {
|
||||
if srv.ID == id {
|
||||
return srv, true
|
||||
}
|
||||
}
|
||||
return Server{}, false
|
||||
}
|
||||
|
||||
func (s *ConfigService) AddServer(name, host string) error {
|
||||
srv := Server{
|
||||
ID: host,
|
||||
Name: name,
|
||||
Host: host,
|
||||
}
|
||||
s.config.Servers = append(s.config.Servers, srv)
|
||||
return s.Save()
|
||||
}
|
||||
|
||||
func (s *ConfigService) RemoveServer(id string) error {
|
||||
s.config.Servers = slices.DeleteFunc(s.config.Servers, func(srv Server) bool {
|
||||
return srv.ID == id
|
||||
})
|
||||
return s.Save()
|
||||
}
|
||||
|
||||
func (s *ConfigService) GetSelectedServer() (Server, bool) {
|
||||
for _, srv := range s.config.Servers {
|
||||
if srv.ID == s.config.SelectedServerID {
|
||||
return srv, true
|
||||
}
|
||||
}
|
||||
return Server{}, false
|
||||
}
|
||||
|
||||
func (s *ConfigService) SetSelectedServer(id string) error {
|
||||
for _, srv := range s.config.Servers {
|
||||
if srv.ID == id {
|
||||
s.config.SelectedServerID = id
|
||||
return s.Save()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("server not found")
|
||||
}
|
||||
|
||||
func (s *ConfigService) UpdateServer(id, name, host string) error {
|
||||
for i, srv := range s.config.Servers {
|
||||
if srv.ID == id {
|
||||
s.config.Servers[i].Name = name
|
||||
s.config.Servers[i].Host = host
|
||||
s.config.Servers[i].ID = host
|
||||
return s.Save()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("server not found")
|
||||
}
|
||||
|
||||
func (s *ConfigService) Save() error {
|
||||
data, err := json.MarshalIndent(s.config, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(s.path, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
11
internal/services/services_store.go
Normal file
11
internal/services/services_store.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package services
|
||||
|
||||
type ServicesStore struct {
|
||||
VPN *VPNService
|
||||
Config *ConfigService
|
||||
}
|
||||
|
||||
func NewServicesStore() (*ServicesStore, error) {
|
||||
cfg, _ := NewConfigService()
|
||||
return &ServicesStore{VPN: NewVPNService(cfg), Config: cfg}, nil
|
||||
}
|
||||
66
internal/services/vpn_service.go
Normal file
66
internal/services/vpn_service.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
||||
type VPNService struct{
|
||||
Config *ConfigService
|
||||
}
|
||||
|
||||
func NewVPNService(cfg *ConfigService) *VPNService {
|
||||
return &VPNService{
|
||||
Config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *VPNService) CheckStatus(ctx context.Context) (bool, error) {
|
||||
cmd := exec.CommandContext(ctx, "tailscale", "exit-node", "list")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "failed to check VPN status", "error", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
output := out.String()
|
||||
return strings.Contains(output, "selected"), nil
|
||||
}
|
||||
|
||||
func (v *VPNService) TurnOn(ctx context.Context) error {
|
||||
selectedServer, _ := v.Config.GetSelectedServer()
|
||||
cmd := exec.CommandContext(ctx, "sudo", "tailscale", "set", "--exit-node="+selectedServer.Host)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.ErrorContext(ctx, "failed to turn on VPN", "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *VPNService) TurnOff(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, "sudo", "tailscale", "set", "--exit-node=")
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.ErrorContext(ctx, "failed to turn off VPN", "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *VPNService) ExitNodeList(ctx context.Context) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "tailscale", "exit-node", "list")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.ErrorContext(ctx, "failed to get exit node list", "error", err)
|
||||
return "", err
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
159
internal/styles/styles.go
Normal file
159
internal/styles/styles.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package styles
|
||||
|
||||
import lipgloss "charm.land/lipgloss/v2"
|
||||
|
||||
var (
|
||||
// Card / box
|
||||
CardStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("62")).
|
||||
Width(52)
|
||||
|
||||
CardInnerStyle = lipgloss.NewStyle().
|
||||
Padding(1, 3)
|
||||
|
||||
CardTitleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("86")).
|
||||
Bold(true).
|
||||
MarginBottom(1)
|
||||
|
||||
CardSubtitleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("245")).
|
||||
MarginBottom(1)
|
||||
|
||||
// VPN Status
|
||||
VPNStatusConnectedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("86")).
|
||||
Bold(true).
|
||||
MarginBottom(1)
|
||||
|
||||
VPNStatusDisconnectedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
MarginBottom(1)
|
||||
|
||||
VPNStatusLoadingStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("245")).
|
||||
Italic(true).
|
||||
MarginBottom(1)
|
||||
|
||||
VPNErrorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("203")).
|
||||
MarginBottom(1)
|
||||
|
||||
// Buttons
|
||||
buttonBase = lipgloss.NewStyle().
|
||||
Padding(0, 2).
|
||||
MarginTop(1).
|
||||
Width(44)
|
||||
|
||||
ButtonActiveStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("15")).
|
||||
Background(lipgloss.Color("75")).
|
||||
Bold(true).
|
||||
Padding(0, 2).
|
||||
MarginTop(1).
|
||||
Width(44)
|
||||
|
||||
ButtonInactiveStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
Padding(0, 2).
|
||||
MarginTop(1).
|
||||
Width(44)
|
||||
|
||||
ButtonLoadingStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("238")).
|
||||
Padding(0, 2).
|
||||
MarginTop(1).
|
||||
Width(44)
|
||||
|
||||
// Footer
|
||||
FooterStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
BorderTop(true).
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("237")).
|
||||
Padding(0, 1).
|
||||
Width(50)
|
||||
|
||||
FooterKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("86")).Bold(true)
|
||||
FooterSepStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("237"))
|
||||
FooterDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
|
||||
|
||||
// Settings Page
|
||||
ServerListStyle = lipgloss.NewStyle().
|
||||
PaddingTop(1)
|
||||
|
||||
ServerItemActiveStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("86")).
|
||||
Bold(true)
|
||||
|
||||
ServerItemInactiveStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("245"))
|
||||
|
||||
ServerSelectedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("86")).
|
||||
Bold(true).
|
||||
MarginBottom(1)
|
||||
|
||||
ServerNotSelectedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("245")).
|
||||
Italic(true).
|
||||
MarginBottom(1)
|
||||
|
||||
EmptyListStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("245")).
|
||||
Italic(true).
|
||||
PaddingTop(1)
|
||||
|
||||
FormTitleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("86")).
|
||||
Bold(true).
|
||||
MarginBottom(1)
|
||||
|
||||
FormLabelStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("245")).
|
||||
Width(6)
|
||||
|
||||
FormInputActiveStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("15")).
|
||||
Background(lipgloss.Color("236")).
|
||||
Width(30)
|
||||
|
||||
FormInputInactiveStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
Width(30)
|
||||
|
||||
FormButtonActiveStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("232")).
|
||||
Background(lipgloss.Color("75")).
|
||||
Bold(true).
|
||||
Padding(0, 2).
|
||||
Width(14)
|
||||
|
||||
FormButtonInactiveStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
Padding(0, 2).
|
||||
Width(14)
|
||||
|
||||
ConfirmTitleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("203")).
|
||||
Bold(true).
|
||||
MarginBottom(1)
|
||||
|
||||
ConfirmStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240"))
|
||||
|
||||
SelectListStyle = lipgloss.NewStyle().
|
||||
PaddingTop(1)
|
||||
|
||||
SelectItemActiveStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("86")).
|
||||
Bold(true)
|
||||
|
||||
SelectItemInactiveStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("245"))
|
||||
|
||||
SelectedMarkerStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("42")).
|
||||
Bold(true)
|
||||
)
|
||||
100
internal/tui/commands.go
Normal file
100
internal/tui/commands.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"tailscale-vpn/internal/pages"
|
||||
"tailscale-vpn/internal/services"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
func checkVPNStatusCmd(store *services.ServicesStore) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
connected, err := store.VPN.CheckStatus(ctx)
|
||||
return pages.VPNStatusMsg{
|
||||
Connected: connected,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func turnOnVPNCmd(store *services.ServicesStore) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
if err := store.VPN.TurnOn(ctx); err != nil {
|
||||
return pages.VPNToggleMsg{
|
||||
Connected: false,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
connected, _ := store.VPN.CheckStatus(ctx)
|
||||
return pages.VPNToggleMsg{
|
||||
Connected: connected,
|
||||
Err: nil,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func turnOffVPNCmd(store *services.ServicesStore) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
if err := store.VPN.TurnOff(ctx); err != nil {
|
||||
return pages.VPNToggleMsg{
|
||||
Connected: true,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
connected, _ := store.VPN.CheckStatus(ctx)
|
||||
return pages.VPNToggleMsg{
|
||||
Connected: connected,
|
||||
Err: nil,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadServersCmd(store *services.ServicesStore) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
servers := store.Config.GetServers()
|
||||
return pages.ServerListLoadedMsg{
|
||||
Servers: servers,
|
||||
Err: nil,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addServerCmd(store *services.ServicesStore, name, host string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := store.Config.AddServer(name, host)
|
||||
return pages.ServerSaveMsg{Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func updateServerCmd(store *services.ServicesStore, id, name, host string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := store.Config.UpdateServer(id, name, host)
|
||||
return pages.ServerSaveMsg{Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteServerCmd(store *services.ServicesStore, id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := store.Config.RemoveServer(id)
|
||||
return pages.ServerDeleteMsg{Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func selectServerCmd(store *services.ServicesStore, serverID string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := store.Config.SetSelectedServer(serverID)
|
||||
if err != nil {
|
||||
return pages.ServerSelectedMsg{Err: err}
|
||||
}
|
||||
server, ok := store.Config.GetSelectedServer()
|
||||
if !ok {
|
||||
return pages.ServerSelectedMsg{Err: fmt.Errorf("failed to get selected server")}
|
||||
}
|
||||
return pages.ServerSelectedMsg{Server: server, Err: nil}
|
||||
}
|
||||
}
|
||||
15
internal/tui/init.go
Normal file
15
internal/tui/init.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"tailscale-vpn/internal/pages"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
func (m TUIInterface) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
func() tea.Msg { return pages.HomePageMsg{} },
|
||||
checkVPNStatusCmd(m.Services),
|
||||
loadServersCmd(m.Services),
|
||||
)
|
||||
}
|
||||
56
internal/tui/tui.go
Normal file
56
internal/tui/tui.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"tailscale-vpn/internal/pages"
|
||||
"tailscale-vpn/internal/services"
|
||||
)
|
||||
|
||||
type page int
|
||||
|
||||
const (
|
||||
pageHome page = iota
|
||||
pageSettings
|
||||
pageSelectServer
|
||||
)
|
||||
|
||||
type TUIInterface struct {
|
||||
Services *services.ServicesStore
|
||||
Page page
|
||||
MenuItems []pages.MenuItem
|
||||
Selected int
|
||||
Quitting bool
|
||||
WindowWidth int
|
||||
WindowHeight int
|
||||
VPNConnected bool
|
||||
VPNLoading bool
|
||||
VPNToggleLoading bool
|
||||
VPNError error
|
||||
HasServers bool
|
||||
SelectedServer services.Server
|
||||
HasSelectedServer bool
|
||||
SettingsServers []services.Server
|
||||
SettingsSelected int
|
||||
SettingsEditMode string
|
||||
SettingsEditingServer services.Server
|
||||
SettingsFormName string
|
||||
SettingsFormHost string
|
||||
SettingsFormField int
|
||||
SettingsConfirmDelete bool
|
||||
SelectServerServers []services.Server
|
||||
SelectServerSelected int
|
||||
}
|
||||
|
||||
func NewTUIInterface(store *services.ServicesStore) TUIInterface {
|
||||
servers := store.Config.GetServers()
|
||||
selectedServer, hasSelected := store.Config.GetSelectedServer()
|
||||
return TUIInterface{
|
||||
Services: store,
|
||||
Page: pageHome,
|
||||
MenuItems: pages.HomeMenuItems(false, len(servers) > 0),
|
||||
VPNConnected: false,
|
||||
VPNLoading: true,
|
||||
HasServers: len(servers) > 0,
|
||||
SelectedServer: selectedServer,
|
||||
HasSelectedServer: hasSelected,
|
||||
}
|
||||
}
|
||||
344
internal/tui/update.go
Normal file
344
internal/tui/update.go
Normal file
@@ -0,0 +1,344 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"tailscale-vpn/internal/pages"
|
||||
"tailscale-vpn/internal/services"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
func (m TUIInterface) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
|
||||
case pages.HomePageMsg:
|
||||
m.Page = pageHome
|
||||
m.MenuItems = pages.HomeMenuItems(m.VPNConnected, m.HasServers)
|
||||
return m, nil
|
||||
|
||||
case pages.SettingsPageMsg:
|
||||
m.Page = pageSettings
|
||||
m.SettingsEditMode = ""
|
||||
m.SettingsConfirmDelete = false
|
||||
m.SettingsSelected = 0
|
||||
return m, loadServersCmd(m.Services)
|
||||
|
||||
case pages.SelectServerPageMsg:
|
||||
m.Page = pageSelectServer
|
||||
m.SelectServerServers = m.SettingsServers
|
||||
m.SelectServerSelected = 0
|
||||
for i, srv := range m.SelectServerServers {
|
||||
if m.HasSelectedServer && srv.ID == m.SelectedServer.ID {
|
||||
m.SelectServerSelected = i
|
||||
break
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case pages.ServerSelectedMsg:
|
||||
m.SelectedServer = msg.Server
|
||||
m.HasSelectedServer = msg.Server.ID != ""
|
||||
if msg.Err != nil {
|
||||
m.VPNError = msg.Err
|
||||
}
|
||||
return m, func() tea.Msg { return pages.HomePageMsg{} }
|
||||
|
||||
case pages.ServerSelectionCanceledMsg:
|
||||
return m, func() tea.Msg { return pages.HomePageMsg{} }
|
||||
|
||||
case pages.ServerListLoadedMsg:
|
||||
m.SettingsServers = msg.Servers
|
||||
m.HasServers = len(msg.Servers) > 0
|
||||
if msg.Err != nil {
|
||||
m.VPNError = msg.Err
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case pages.ServerEditMsg:
|
||||
m.SettingsEditMode = msg.Mode
|
||||
m.SettingsConfirmDelete = false
|
||||
if msg.Mode == "edit" {
|
||||
m.SettingsEditingServer = msg.Server
|
||||
m.SettingsFormName = msg.Server.Name
|
||||
m.SettingsFormHost = msg.Server.Host
|
||||
} else {
|
||||
m.SettingsEditingServer = services.Server{}
|
||||
m.SettingsFormName = ""
|
||||
m.SettingsFormHost = ""
|
||||
}
|
||||
m.SettingsFormField = 0
|
||||
return m, nil
|
||||
|
||||
case pages.ServerSaveMsg:
|
||||
m.SettingsEditMode = ""
|
||||
if msg.Err != nil {
|
||||
m.VPNError = msg.Err
|
||||
}
|
||||
return m, loadServersCmd(m.Services)
|
||||
|
||||
case pages.ServerDeleteMsg:
|
||||
m.SettingsConfirmDelete = false
|
||||
if msg.Err != nil {
|
||||
m.VPNError = msg.Err
|
||||
} else {
|
||||
if m.SettingsSelected >= len(m.SettingsServers) {
|
||||
m.SettingsSelected = max(len(m.SettingsServers)-1, 0)
|
||||
}
|
||||
}
|
||||
return m, loadServersCmd(m.Services)
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.WindowWidth = msg.Width
|
||||
m.WindowHeight = msg.Height
|
||||
return m, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
if m.Page == pageHome {
|
||||
return m.updateHomePage(msg)
|
||||
}
|
||||
if m.Page == pageSettings {
|
||||
return m.updateSettingsPage(msg)
|
||||
}
|
||||
if m.Page == pageSelectServer {
|
||||
return m.updateSelectServerPage(msg)
|
||||
}
|
||||
|
||||
case tea.PasteMsg:
|
||||
if m.Page == pageSettings && m.SettingsEditMode != "" {
|
||||
if m.SettingsFormField == 0 {
|
||||
m.SettingsFormName += msg.Content
|
||||
} else if m.SettingsFormField == 1 {
|
||||
m.SettingsFormHost += msg.Content
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case pages.VPNStatusMsg:
|
||||
m.VPNConnected = msg.Connected
|
||||
m.VPNLoading = false
|
||||
if msg.Err != nil {
|
||||
m.VPNError = msg.Err
|
||||
} else {
|
||||
m.VPNError = nil
|
||||
}
|
||||
m.MenuItems = pages.HomeMenuItems(m.VPNConnected, m.HasServers)
|
||||
return m, nil
|
||||
|
||||
case pages.VPNToggleMsg:
|
||||
m.VPNConnected = msg.Connected
|
||||
m.VPNToggleLoading = false
|
||||
if msg.Err != nil {
|
||||
m.VPNError = msg.Err
|
||||
} else {
|
||||
m.VPNError = nil
|
||||
}
|
||||
m.MenuItems = pages.HomeMenuItems(m.VPNConnected, m.HasServers)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m TUIInterface) updateHomePage(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
if m.VPNLoading || m.VPNToggleLoading {
|
||||
if msg.String() == "ctrl+c" {
|
||||
m.Quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "up", "k":
|
||||
if m.Selected > 0 {
|
||||
m.Selected--
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "down", "j":
|
||||
if m.Selected < len(m.MenuItems)-1 {
|
||||
m.Selected++
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
if len(m.MenuItems) > 0 {
|
||||
switch m.MenuItems[m.Selected].Key {
|
||||
case "select-server":
|
||||
return m, func() tea.Msg { return pages.SelectServerPageMsg{} }
|
||||
case "settings":
|
||||
return m, func() tea.Msg { return pages.SettingsPageMsg{} }
|
||||
case "on":
|
||||
m.VPNToggleLoading = true
|
||||
m.VPNError = nil
|
||||
return m, turnOnVPNCmd(m.Services)
|
||||
case "off":
|
||||
m.VPNToggleLoading = true
|
||||
m.VPNError = nil
|
||||
return m, turnOffVPNCmd(m.Services)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "r":
|
||||
m.VPNLoading = true
|
||||
m.VPNError = nil
|
||||
return m, checkVPNStatusCmd(m.Services)
|
||||
|
||||
case "ctrl+c", "esc":
|
||||
m.Quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m TUIInterface) updateSelectServerPage(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "up", "k":
|
||||
if m.SelectServerSelected > 0 {
|
||||
m.SelectServerSelected--
|
||||
}
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
if m.SelectServerSelected < len(m.SelectServerServers)-1 {
|
||||
m.SelectServerSelected++
|
||||
}
|
||||
return m, nil
|
||||
case "enter":
|
||||
if m.SelectServerSelected >= 0 && m.SelectServerSelected < len(m.SelectServerServers) {
|
||||
return m, selectServerCmd(m.Services, m.SelectServerServers[m.SelectServerSelected].ID)
|
||||
}
|
||||
return m, nil
|
||||
case "esc", "ctrl+c":
|
||||
return m, func() tea.Msg { return pages.ServerSelectionCanceledMsg{} }
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m TUIInterface) updateSettingsPage(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
if m.SettingsEditMode != "" {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.SettingsEditMode = ""
|
||||
return m, nil
|
||||
case "enter":
|
||||
if m.SettingsFormField == 2 {
|
||||
if m.SettingsFormName == "" || m.SettingsFormHost == "" {
|
||||
m.VPNError = errors.New("name and host are required")
|
||||
return m, nil
|
||||
}
|
||||
if m.SettingsEditMode == "add" {
|
||||
return m, addServerCmd(m.Services, m.SettingsFormName, m.SettingsFormHost)
|
||||
}
|
||||
return m, updateServerCmd(m.Services, m.SettingsEditingServer.ID, m.SettingsFormName, m.SettingsFormHost)
|
||||
} else if m.SettingsFormField == 3 {
|
||||
m.SettingsEditMode = ""
|
||||
return m, nil
|
||||
} else {
|
||||
m.SettingsFormField = (m.SettingsFormField + 1) % 4
|
||||
return m, nil
|
||||
}
|
||||
case "up", "k":
|
||||
m.SettingsFormField = (m.SettingsFormField - 1 + 4) % 4
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
m.SettingsFormField = (m.SettingsFormField + 1) % 4
|
||||
return m, nil
|
||||
case "tab":
|
||||
m.SettingsFormField = (m.SettingsFormField + 1) % 4
|
||||
return m, nil
|
||||
case "backtab":
|
||||
m.SettingsFormField = (m.SettingsFormField - 1 + 4) % 4
|
||||
return m, nil
|
||||
case "backspace":
|
||||
if m.SettingsFormField == 0 && len(m.SettingsFormName) > 0 {
|
||||
m.SettingsFormName = m.SettingsFormName[:len(m.SettingsFormName)-1]
|
||||
} else if m.SettingsFormField == 1 && len(m.SettingsFormHost) > 0 {
|
||||
m.SettingsFormHost = m.SettingsFormHost[:len(m.SettingsFormHost)-1]
|
||||
}
|
||||
return m, nil
|
||||
case "ctrl+c":
|
||||
m.Quitting = true
|
||||
return m, tea.Quit
|
||||
default:
|
||||
if m.SettingsFormField == 0 || m.SettingsFormField == 1 {
|
||||
if msg.Text != "" {
|
||||
if m.SettingsFormField == 0 {
|
||||
m.SettingsFormName += msg.Text
|
||||
} else {
|
||||
m.SettingsFormHost += msg.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
|
||||
case "esc":
|
||||
if m.SettingsConfirmDelete {
|
||||
m.SettingsConfirmDelete = false
|
||||
return m, nil
|
||||
}
|
||||
return m, func() tea.Msg { return pages.HomePageMsg{} }
|
||||
|
||||
case "up", "k":
|
||||
if m.SettingsConfirmDelete {
|
||||
return m, nil
|
||||
}
|
||||
if m.SettingsSelected > 0 {
|
||||
m.SettingsSelected--
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "down", "j":
|
||||
if m.SettingsConfirmDelete {
|
||||
return m, nil
|
||||
}
|
||||
if m.SettingsSelected < len(m.SettingsServers)-1 {
|
||||
m.SettingsSelected++
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "a":
|
||||
if m.SettingsConfirmDelete {
|
||||
return m, nil
|
||||
}
|
||||
return m, func() tea.Msg { return pages.ServerEditMsg{Mode: "add"} }
|
||||
|
||||
case "enter":
|
||||
if m.SettingsConfirmDelete {
|
||||
if m.SettingsSelected >= 0 && m.SettingsSelected < len(m.SettingsServers) {
|
||||
return m, deleteServerCmd(m.Services, m.SettingsServers[m.SettingsSelected].ID)
|
||||
}
|
||||
m.SettingsConfirmDelete = false
|
||||
return m, nil
|
||||
}
|
||||
if m.SettingsSelected >= 0 && m.SettingsSelected < len(m.SettingsServers) {
|
||||
return m, func() tea.Msg {
|
||||
return pages.ServerEditMsg{
|
||||
Mode: "edit",
|
||||
Server: m.SettingsServers[m.SettingsSelected],
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "d":
|
||||
if m.SettingsConfirmDelete {
|
||||
return m, nil
|
||||
}
|
||||
if len(m.SettingsServers) > 0 {
|
||||
m.SettingsConfirmDelete = true
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "ctrl+c":
|
||||
m.Quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
324
internal/tui/view.go
Normal file
324
internal/tui/view.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"tailscale-vpn/internal/styles"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
lipgloss "charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func footerHint(key, desc string) string {
|
||||
return styles.FooterKeyStyle.Render(key) +
|
||||
" " +
|
||||
styles.FooterDescStyle.Render(desc)
|
||||
}
|
||||
|
||||
func footerSep() string {
|
||||
return styles.FooterSepStyle.Render(" · ")
|
||||
}
|
||||
|
||||
func (m TUIInterface) subtitle() string {
|
||||
switch m.Page {
|
||||
case pageHome:
|
||||
return "Secure vpn"
|
||||
case pageSettings:
|
||||
return "Settings"
|
||||
case pageSelectServer:
|
||||
return "Select Server"
|
||||
default:
|
||||
return "Secure vpn"
|
||||
}
|
||||
}
|
||||
|
||||
func (m TUIInterface) View() tea.View {
|
||||
if m.Quitting {
|
||||
return tea.NewView("")
|
||||
}
|
||||
|
||||
w := m.WindowWidth
|
||||
h := m.WindowHeight
|
||||
if w == 0 {
|
||||
w = 80
|
||||
}
|
||||
if h == 0 {
|
||||
h = 24
|
||||
}
|
||||
|
||||
var body string
|
||||
switch m.Page {
|
||||
case pageHome:
|
||||
body = m.viewHomePage()
|
||||
case pageSettings:
|
||||
body = m.viewSettingsPage()
|
||||
case pageSelectServer:
|
||||
body = m.viewSelectServerPage()
|
||||
default:
|
||||
body = m.viewHomePage()
|
||||
}
|
||||
|
||||
header := lipgloss.JoinVertical(lipgloss.Left,
|
||||
styles.CardTitleStyle.Render("✦ tailscale vpn"),
|
||||
styles.CardSubtitleStyle.Render(m.subtitle()),
|
||||
)
|
||||
|
||||
topContent := styles.CardInnerStyle.Render(
|
||||
lipgloss.JoinVertical(lipgloss.Left, header, body),
|
||||
)
|
||||
|
||||
var footerStr string
|
||||
switch m.Page {
|
||||
case pageHome:
|
||||
footerStr = footerHint("↑↓", "navigate") +
|
||||
footerSep() +
|
||||
footerHint("enter", "select") +
|
||||
footerSep() +
|
||||
footerHint("r", "refresh") +
|
||||
footerSep() +
|
||||
footerHint("esc", "quit")
|
||||
case pageSettings:
|
||||
if m.SettingsEditMode == "" {
|
||||
if m.SettingsConfirmDelete {
|
||||
footerStr = footerHint("enter", "confirm") +
|
||||
footerSep() +
|
||||
footerHint("esc", "cancel")
|
||||
} else {
|
||||
footerStr = footerHint("↑↓", "navigate") +
|
||||
footerSep() +
|
||||
footerHint("enter", "select") +
|
||||
footerSep() +
|
||||
footerHint("a", "add") +
|
||||
footerSep() +
|
||||
footerHint("d", "delete") +
|
||||
footerSep() +
|
||||
footerHint("esc", "back")
|
||||
}
|
||||
} else {
|
||||
footerStr = footerHint("↑↓", "navigate") +
|
||||
footerSep() +
|
||||
footerHint("enter", "select") +
|
||||
footerSep() +
|
||||
footerHint("esc", "cancel")
|
||||
}
|
||||
case pageSelectServer:
|
||||
footerStr = footerHint("↑↓", "navigate") +
|
||||
footerSep() +
|
||||
footerHint("enter", "select") +
|
||||
footerSep() +
|
||||
footerHint("esc", "cancel")
|
||||
default:
|
||||
footerStr = footerHint("↑↓", "navigate") +
|
||||
footerSep() +
|
||||
footerHint("enter", "select") +
|
||||
footerSep() +
|
||||
footerHint("esc", "quit")
|
||||
}
|
||||
footer := styles.FooterStyle.Render(footerStr)
|
||||
|
||||
card := styles.CardStyle.Render(
|
||||
lipgloss.JoinVertical(lipgloss.Left, topContent, footer),
|
||||
)
|
||||
|
||||
cardHeight := lipgloss.Height(card)
|
||||
topPad := max((h-cardHeight)/2, 0)
|
||||
|
||||
centeredCard := lipgloss.NewStyle().
|
||||
Width(w).
|
||||
Align(lipgloss.Center).
|
||||
PaddingTop(topPad).
|
||||
Render(card)
|
||||
|
||||
v := tea.NewView(centeredCard)
|
||||
v.AltScreen = true
|
||||
return v
|
||||
}
|
||||
|
||||
func (m TUIInterface) viewHomePage() string {
|
||||
var status string
|
||||
if m.VPNLoading {
|
||||
status = styles.VPNStatusLoadingStyle.Render("Checking...")
|
||||
} else if m.VPNConnected {
|
||||
status = styles.VPNStatusConnectedStyle.Render("● VPN Connected")
|
||||
} else {
|
||||
status = styles.VPNStatusDisconnectedStyle.Render("○ VPN Disconnected")
|
||||
}
|
||||
|
||||
var serverStatus string
|
||||
if m.HasSelectedServer {
|
||||
serverStatus = styles.ServerSelectedStyle.Render("Server: " + m.SelectedServer.Name)
|
||||
} else if m.HasServers {
|
||||
serverStatus = styles.ServerNotSelectedStyle.Render("No server selected")
|
||||
}
|
||||
|
||||
var menu string
|
||||
if !m.VPNLoading && !m.VPNToggleLoading {
|
||||
menu = m.renderMenu()
|
||||
} else {
|
||||
menu = styles.ButtonLoadingStyle.Render("...")
|
||||
}
|
||||
|
||||
var errorStr string
|
||||
if m.VPNError != nil {
|
||||
errorStr = styles.VPNErrorStyle.Render("✗ " + m.VPNError.Error())
|
||||
}
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, status, serverStatus, menu, errorStr)
|
||||
return content
|
||||
}
|
||||
|
||||
func (m TUIInterface) renderMenu() string {
|
||||
if len(m.MenuItems) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var rows []string
|
||||
for i, item := range m.MenuItems {
|
||||
if i == m.Selected {
|
||||
prefix := "▸ "
|
||||
rows = append(rows, styles.ButtonActiveStyle.Render(prefix+item.Label))
|
||||
} else {
|
||||
prefix := " "
|
||||
rows = append(rows, styles.ButtonInactiveStyle.Render(prefix+item.Label))
|
||||
}
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, rows...)
|
||||
}
|
||||
|
||||
func (m TUIInterface) viewSettingsPage() string {
|
||||
if m.SettingsEditMode != "" {
|
||||
return m.renderSettingsForm()
|
||||
}
|
||||
|
||||
if m.SettingsConfirmDelete {
|
||||
return m.renderSettingsDeleteConfirm()
|
||||
}
|
||||
|
||||
return m.renderSettingsList()
|
||||
}
|
||||
|
||||
func (m TUIInterface) renderSettingsList() string {
|
||||
if len(m.SettingsServers) == 0 {
|
||||
return styles.EmptyListStyle.Render("No servers configured")
|
||||
}
|
||||
|
||||
var rows []string
|
||||
for i, srv := range m.SettingsServers {
|
||||
if i == m.SettingsSelected {
|
||||
name := styles.ServerItemActiveStyle.Render(srv.Name)
|
||||
host := styles.ServerItemActiveStyle.Render(srv.Host)
|
||||
rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, "▸ ", name, styles.ServerItemInactiveStyle.Render(" · "), host))
|
||||
} else {
|
||||
name := styles.ServerItemInactiveStyle.Render(srv.Name)
|
||||
host := styles.ServerItemInactiveStyle.Render(srv.Host)
|
||||
rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, " ", name, styles.ServerItemInactiveStyle.Render(" · "), host))
|
||||
}
|
||||
}
|
||||
|
||||
return styles.ServerListStyle.Render(lipgloss.JoinVertical(lipgloss.Left, rows...))
|
||||
}
|
||||
|
||||
func (m TUIInterface) renderSettingsForm() string {
|
||||
title := styles.FormTitleStyle.Render(
|
||||
func() string {
|
||||
if m.SettingsEditMode == "add" {
|
||||
return "Add Server"
|
||||
}
|
||||
return "Edit Server"
|
||||
}(),
|
||||
)
|
||||
|
||||
nameLabel := styles.FormLabelStyle.Render("Name:")
|
||||
nameInput := func() string {
|
||||
if m.SettingsFormField == 0 {
|
||||
return styles.FormInputActiveStyle.Render(m.SettingsFormName + "_")
|
||||
}
|
||||
return styles.FormInputInactiveStyle.Render(m.SettingsFormName)
|
||||
}()
|
||||
|
||||
hostLabel := styles.FormLabelStyle.Render("Host:")
|
||||
hostInput := func() string {
|
||||
if m.SettingsFormField == 1 {
|
||||
return styles.FormInputActiveStyle.Render(m.SettingsFormHost + "_")
|
||||
}
|
||||
return styles.FormInputInactiveStyle.Render(m.SettingsFormHost)
|
||||
}()
|
||||
|
||||
saveBtn := func() string {
|
||||
if m.SettingsFormField == 2 {
|
||||
return styles.FormButtonActiveStyle.Render("Save")
|
||||
}
|
||||
return styles.FormButtonInactiveStyle.Render("Save")
|
||||
}()
|
||||
|
||||
cancelBtn := func() string {
|
||||
if m.SettingsFormField == 3 {
|
||||
return styles.FormButtonActiveStyle.Render("Cancel")
|
||||
}
|
||||
return styles.FormButtonInactiveStyle.Render("Cancel")
|
||||
}()
|
||||
|
||||
buttons := lipgloss.JoinHorizontal(lipgloss.Left, saveBtn, " ", cancelBtn)
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
title,
|
||||
"",
|
||||
lipgloss.JoinHorizontal(lipgloss.Top, nameLabel, " ", nameInput),
|
||||
lipgloss.JoinHorizontal(lipgloss.Top, hostLabel, " ", hostInput),
|
||||
"",
|
||||
buttons,
|
||||
)
|
||||
|
||||
return styles.CardInnerStyle.Render(content)
|
||||
}
|
||||
|
||||
func (m TUIInterface) renderSettingsDeleteConfirm() string {
|
||||
if m.SettingsSelected < 0 || m.SettingsSelected >= len(m.SettingsServers) {
|
||||
return ""
|
||||
}
|
||||
|
||||
srv := m.SettingsServers[m.SettingsSelected]
|
||||
title := styles.ConfirmTitleStyle.Render("Delete Server?")
|
||||
message := styles.ConfirmStyle.Render("Delete \"" + srv.Name + "\"?")
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
title,
|
||||
"",
|
||||
message,
|
||||
)
|
||||
|
||||
return styles.CardInnerStyle.Render(content)
|
||||
}
|
||||
|
||||
func (m TUIInterface) viewSelectServerPage() string {
|
||||
return m.renderSelectServerList()
|
||||
}
|
||||
|
||||
func (m TUIInterface) renderSelectServerList() string {
|
||||
if len(m.SelectServerServers) == 0 {
|
||||
return styles.EmptyListStyle.Render("No servers available")
|
||||
}
|
||||
|
||||
var rows []string
|
||||
for i, srv := range m.SelectServerServers {
|
||||
isSelected := m.HasSelectedServer && srv.ID == m.SelectedServer.ID
|
||||
if i == m.SelectServerSelected {
|
||||
prefix := "▸ "
|
||||
row := prefix + srv.Name
|
||||
if isSelected {
|
||||
row += " " + styles.SelectedMarkerStyle.Render("✓")
|
||||
}
|
||||
rows = append(rows, styles.SelectItemActiveStyle.Render(row))
|
||||
} else {
|
||||
prefix := " "
|
||||
row := prefix + srv.Name
|
||||
if isSelected {
|
||||
row += " " + styles.SelectedMarkerStyle.Render("✓")
|
||||
}
|
||||
rows = append(rows, styles.SelectItemInactiveStyle.Render(row))
|
||||
}
|
||||
}
|
||||
|
||||
return styles.SelectListStyle.Render(lipgloss.JoinVertical(lipgloss.Left, rows...))
|
||||
}
|
||||
Reference in New Issue
Block a user