diff --git a/menus.go b/menus.go index 5ead61d..a4af6f3 100644 --- a/menus.go +++ b/menus.go @@ -4,6 +4,7 @@ import ( "fmt" "math" "runtime" + "strings" "time" tea "github.com/charmbracelet/bubbletea" @@ -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", @@ -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 diff --git a/tsui.go b/tsui.go index f70e00c..3f30b8f 100644 --- a/tsui.go +++ b/tsui.go @@ -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. diff --git a/ui/appmenu.go b/ui/appmenu.go index f852db0..10bf294 100644 --- a/ui/appmenu.go +++ b/ui/appmenu.go @@ -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] +} diff --git a/update.go b/update.go index d5809be..067685d 100644 --- a/update.go +++ b/update.go @@ -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() } } } diff --git a/view.go b/view.go index f665393..83a63a9 100644 --- a/view.go +++ b/view.go @@ -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).