Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions menus.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"math"
"runtime"
"strings"
"time"

tea "github.com/charmbracelet/bubbletea"
Expand Down Expand Up @@ -165,7 +166,20 @@ func (m *model) updateMenus() {

// Update the exit node submenu.
{
exitNodeItems := make([]ui.SubmenuItem, 2+len(m.state.ExitNodes))
// Filter exit nodes based on search text
filteredExitNodes := m.state.ExitNodes
if m.exitNodeFilter != "" {
filteredExitNodes = make([]*ipnstate.PeerStatus, 0)
filterLower := strings.ToLower(m.exitNodeFilter)
for _, exitNode := range m.state.ExitNodes {
nodeName := strings.ToLower(libts.PeerName(exitNode))
if strings.Contains(nodeName, filterLower) {
filteredExitNodes = append(filteredExitNodes, exitNode)
}
}
}

exitNodeItems := make([]ui.SubmenuItem, 2+len(filteredExitNodes))
exitNodeItems[0] = &ui.ToggleableSubmenuItem{
LabeledSubmenuItem: ui.LabeledSubmenuItem{
Label: "None",
Expand All @@ -180,7 +194,7 @@ func (m *model) updateMenus() {
IsActive: m.state.CurrentExitNode == nil,
}
exitNodeItems[1] = &ui.DividerSubmenuItem{}
for i, exitNode := range m.state.ExitNodes {
for i, exitNode := range filteredExitNodes {
// Offset for the "None" item and the divider.
i += 2

Expand Down
5 changes: 5 additions & 0 deletions tsui.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ type model struct {
// Frame counter for the loading animation. This is always running in the background,
// even if the animation is not visible.
animationT int

// Filter text for exit nodes menu.
exitNodeFilter string
// Whether the exit nodes menu is in filter mode.
exitNodeFilterMode bool
}

// Initialize the application state.
Expand Down
8 changes: 8 additions & 0 deletions ui/appmenu.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,11 @@ func (appmenu *Appmenu) CloseSubmenu() {
appmenu.isOpen = false
appmenu.items[appmenu.cursor].Submenu.ResetCursor()
}

// Get the currently selected item.
func (appmenu *Appmenu) GetSelectedItem() *AppmenuItem {
if len(appmenu.items) == 0 {
return nil
}
return appmenu.items[appmenu.cursor]
}
136 changes: 104 additions & 32 deletions update.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,58 +121,130 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
if m.menu.IsSubmenuOpen() {
// If in filter mode, exit filter mode
if m.exitNodeFilterMode {
m.exitNodeFilterMode = false
m.exitNodeFilter = ""
m.updateMenus()
} else if m.menu.IsSubmenuOpen() {
m.menu.CloseSubmenu()
} else {
return m, tea.Quit
}

case "left", "h", "a":
m.menu.CloseSubmenu()
case "up", "k", "w":
case "left", "h":
if !m.exitNodeFilterMode {
m.menu.CloseSubmenu()
}
case "a":
if m.exitNodeFilterMode {
m.exitNodeFilter += "a"
m.updateMenus()
} else {
m.menu.CloseSubmenu()
}
case "up":
m.menu.CursorUp()
case "down", "j", "s":
case "down":
m.menu.CursorDown()
case "k", "w":
if !m.exitNodeFilterMode {
m.menu.CursorUp()
} else {
m.exitNodeFilter += msg.String()
m.updateMenus()
}
case "j", "s":
if !m.exitNodeFilterMode {
m.menu.CursorDown()
} else {
m.exitNodeFilter += msg.String()
m.updateMenus()
}
case "right", "l", "d":
if !m.menu.IsSubmenuOpen() {
return m, m.menu.Activate()
if m.exitNodeFilterMode && (msg.String() == "l" || msg.String() == "d") {
m.exitNodeFilter += msg.String()
m.updateMenus()
} else if !m.exitNodeFilterMode && !m.menu.IsSubmenuOpen() {
// Show a tip when entering the exit nodes menu
cmd := m.menu.Activate()
if m.menu.GetSelectedItem() == m.exitNodes && len(m.state.ExitNodes) > 0 {
m.statusType = statusTypeTip
m.statusText = "Press / to search exit nodes"
m.statusGen++
return m, tea.Batch(
cmd,
tea.Tick(tipLifetime, func(_ time.Time) tea.Msg {
return statusExpiredMsg(m.statusGen)
}),
)
}
return m, cmd
}

case "enter", " ":
return m, m.menu.Activate()

case "backspace":
if m.exitNodeFilterMode && len(m.exitNodeFilter) > 0 {
m.exitNodeFilter = m.exitNodeFilter[:len(m.exitNodeFilter)-1]
m.updateMenus()
}

case "/":
// Enable filter mode when viewing exit nodes submenu
if m.menu.IsSubmenuOpen() && m.menu.GetSelectedItem() == m.exitNodes && !m.exitNodeFilterMode {
m.exitNodeFilterMode = true
}

// Global action hotkey.
case ".":
switch m.state.BackendState {
// If running, stop Tailscale.
case ipn.Running:
return m, func() tea.Msg {
err := libts.Down(ctx)
if err != nil {
return errorMsg(err)
if m.exitNodeFilterMode {
// Allow typing period in filter mode
m.exitNodeFilter += "."
m.updateMenus()
} else {
switch m.state.BackendState {
// If running, stop Tailscale.
case ipn.Running:
return m, func() tea.Msg {
err := libts.Down(ctx)
if err != nil {
return errorMsg(err)
}
return updateState()
}
return updateState()
}

// If stopped, start Tailscale.
case ipn.Stopped:
return m, func() tea.Msg {
err := libts.Up(ctx)
if err != nil {
return errorMsg(err)
// If stopped, start Tailscale.
case ipn.Stopped:
return m, func() tea.Msg {
err := libts.Up(ctx)
if err != nil {
return errorMsg(err)
}
return updateState()
}
return updateState()
}

// If we need to login...
case ipn.NeedsLogin:
return m, startLoginInteractive

case ipn.Starting:
// If we have an AuthURL in the Starting state, that means the user is reauthenticating
// and we want to open the browser for them (if supported).
if m.state.AuthURL != "" && libts.StartLoginInteractiveWillOpenBrowser() {
// If we need to login...
case ipn.NeedsLogin:
return m, startLoginInteractive

case ipn.Starting:
// If we have an AuthURL in the Starting state, that means the user is reauthenticating
// and we want to open the browser for them (if supported).
if m.state.AuthURL != "" && libts.StartLoginInteractiveWillOpenBrowser() {
return m, startLoginInteractive
}
}
}

default:
// Handle typing in filter mode
if m.exitNodeFilterMode {
// Only allow printable characters
if len(msg.String()) == 1 {
m.exitNodeFilter += msg.String()
m.updateMenus()
}
}
}
Expand Down
24 changes: 23 additions & 1 deletion view.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,29 @@ func renderMiddleBanner(m *model, height int, text string) string {
func renderStatusBar(m *model) string {
var text string

if m.statusText == "" && m.canWrite && m.state.BackendState == ipn.Running {
// If in filter mode, show the search input
if m.exitNodeFilterMode {
text = lipgloss.NewStyle().
Foreground(ui.Blue).
Bold(true).
Render("Filter: ")
text += lipgloss.NewStyle().
Foreground(ui.White).
Render(m.exitNodeFilter)
// Add a cursor indicator
text += lipgloss.NewStyle().
Foreground(ui.Secondary).
Render("█")
if m.exitNodeFilter == "" {
text += lipgloss.NewStyle().
Faint(true).
Render(" (type to search, esc to cancel)")
} else {
text += lipgloss.NewStyle().
Faint(true).
Render(" (esc to cancel)")
}
} else if m.statusText == "" && m.canWrite && m.state.BackendState == ipn.Running {
// If there's no other status, we're running, and we have write access, show up/down.
text = lipgloss.NewStyle().
Faint(true).
Expand Down