From 9c639206f766b20cd50cfedfea7403d88ae9cfba Mon Sep 17 00:00:00 2001 From: Gu1llaum-3 Date: Wed, 25 Feb 2026 20:27:22 +0100 Subject: [PATCH] feat: add hidden tag to hide hosts from TUI and search Hosts tagged with "hidden" are excluded from the TUI list, shell completions, and sshm search. Direct connections via sshm still work regardless of the tag. A toggle key (H) shows or hides hidden hosts in the TUI, with a yellow banner indicating the active state. The key is documented in the help panel (h). A contextual hint on the Tags field in the add and edit forms reminds the user that "hidden" hides the host from the list. --- README.md | 5 +++-- cmd/root.go | 2 ++ cmd/search.go | 3 +++ internal/config/ssh.go | 21 +++++++++++++++++++++ internal/ui/add_form.go | 7 ++++++- internal/ui/edit_form.go | 7 ++++++- internal/ui/help_form.go | 3 +++ internal/ui/model.go | 12 +++++++++++- internal/ui/tui.go | 8 ++++++-- internal/ui/update.go | 25 +++++++++++++++++++++---- internal/ui/view.go | 8 ++++++++ 11 files changed, 90 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ef8ed5e..254bd73 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect - **⚡ Quick Connect** - Connect to any host instantly through the TUI or the CLI with `sshm ` - **🔄 Port Forwarding** - Easy setup for Local, Remote, and Dynamic (SOCKS) forwarding with history persistence - **📝 Easy Management** - Add, edit, move, and manage SSH configurations seamlessly -- **🏷️ Tag Support** - Organize your hosts with custom tags for better categorization +- **🏷️ Tag Support** - Organize your hosts with custom tags for better categorization; use the special `hidden` tag to exclude hosts from the list while keeping them connectable - **🔍 Smart Search** - Find hosts quickly with built-in filtering and search - **📝 Real-time Status** - Live SSH connectivity indicators with asynchronous ping checks and color-coded status - **🔔 Smart Updates** - Automatic version checking with update notifications @@ -106,6 +106,7 @@ sshm - `d` - Delete selected host - `m` - Move host to another config file (requires SSH Include directives) - `f` - Port forwarding setup +- `H` - Toggle hidden hosts visibility - `q` - Quit - `/` - Search/filter hosts @@ -647,7 +648,7 @@ SSHM supports all standard SSH configuration options: - `IdentityFile` - Path to private key file - `ProxyJump` - Jump server for connection tunneling (e.g., `user@jumphost:port`) - `ProxyCommand` - Jump command for connection tunneling (e.g, `ssh -W %h:%p Jumphost`) -- `Tags` - Custom tags (SSHM extension) +- `Tags` - Custom tags (SSHM extension); the special tag `hidden` hides the host from the TUI and `sshm search` while keeping it connectable via `sshm ` **Additional SSH Options:** You can add any valid SSH option using the "SSH Options" field in the interactive forms. Enter them in command-line format (e.g., `-o Compression=yes -o ServerAliveInterval=60`) and SSHM will automatically convert them to the proper SSH config format. diff --git a/cmd/root.go b/cmd/root.go index 1dbf66a..25fa3d1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -75,6 +75,8 @@ Examples: return nil, cobra.ShellCompDirectiveError } + hosts = config.FilterVisibleHosts(hosts) + var completions []string toCompleteLower := strings.ToLower(toComplete) for _, host := range hosts { diff --git a/cmd/search.go b/cmd/search.go index d9afc10..520e476 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -55,6 +55,9 @@ func runSearch(cmd *cobra.Command, args []string) { os.Exit(1) } + // Filter out hidden hosts + hosts = config.FilterVisibleHosts(hosts) + // Get search query var query string if len(args) > 0 { diff --git a/internal/config/ssh.go b/internal/config/ssh.go index b7c9112..3e1c671 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -1624,6 +1624,27 @@ func GetAllConfigFiles() ([]string, error) { return files, nil } +// FilterVisibleHosts returns only hosts that do not have the "hidden" tag. +func FilterVisibleHosts(hosts []SSHHost) []SSHHost { + var visible []SSHHost + for _, h := range hosts { + if !hostHasTag(h.Tags, "hidden") { + visible = append(visible, h) + } + } + return visible +} + +// hostHasTag reports whether the given tag list contains the target tag (case-insensitive). +func hostHasTag(tags []string, target string) bool { + for _, t := range tags { + if strings.EqualFold(t, target) { + return true + } + } + return false +} + // GetAllConfigFilesFromBase returns all SSH config files starting from a specific base config file func GetAllConfigFilesFromBase(baseConfigPath string) ([]string, error) { if baseConfigPath == "" { diff --git a/internal/ui/add_form.go b/internal/ui/add_form.go index 12c3242..4b0cba7 100644 --- a/internal/ui/add_form.go +++ b/internal/ui/add_form.go @@ -438,7 +438,12 @@ func (m *addFormModel) renderGeneralTab() string { b.WriteString(fieldStyle.Render(field.label)) b.WriteString("\n") b.WriteString(m.inputs[field.index].View()) - b.WriteString("\n\n") + b.WriteString("\n") + if field.index == tagsInput && m.focused == tagsInput { + b.WriteString(m.styles.FormHelp.Render(` tip: use "hidden" to hide this host from the list`)) + b.WriteString("\n") + } + b.WriteString("\n") } return b.String() diff --git a/internal/ui/edit_form.go b/internal/ui/edit_form.go index ab34b5c..fd867bc 100644 --- a/internal/ui/edit_form.go +++ b/internal/ui/edit_form.go @@ -599,7 +599,12 @@ func (m *editFormModel) renderEditGeneralTab() string { b.WriteString(fieldStyle.Render(field.label)) b.WriteString("\n") b.WriteString(m.inputs[field.index].View()) - b.WriteString("\n\n") + b.WriteString("\n") + if field.index == 7 && m.focusArea == focusAreaProperties && m.focused == 7 { + b.WriteString(m.styles.FormHelp.Render(` tip: use "hidden" to hide this host from the list`)) + b.WriteString("\n") + } + b.WriteString("\n") } return b.String() diff --git a/internal/ui/help_form.go b/internal/ui/help_form.go index 3b21744..a904ffa 100644 --- a/internal/ui/help_form.go +++ b/internal/ui/help_form.go @@ -81,6 +81,9 @@ func (m *helpModel) View() string { lipgloss.JoinHorizontal(lipgloss.Left, m.styles.FocusedLabel.Render("p "), m.styles.HelpText.Render("ping all hosts")), + lipgloss.JoinHorizontal(lipgloss.Left, + m.styles.FocusedLabel.Render("H "), + m.styles.HelpText.Render("toggle hidden hosts visibility")), lipgloss.JoinHorizontal(lipgloss.Left, m.styles.FocusedLabel.Render("f "), m.styles.HelpText.Render("setup port forwarding")), diff --git a/internal/ui/model.go b/internal/ui/model.go index bb809a0..4786fbc 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -70,8 +70,10 @@ func (p PortForwardType) String() string { type Model struct { table table.Model searchInput textinput.Model - hosts []config.SSHHost + allHosts []config.SSHHost // all parsed hosts, including hidden ones + hosts []config.SSHHost // visible hosts (filtered by showHidden) filteredHosts []config.SSHHost + showHidden bool // when true, hidden-tagged hosts are shown searchMode bool deleteMode bool deleteHost *config.SSHHost // Host to be deleted (with line number for precise targeting) @@ -108,6 +110,14 @@ type Model struct { showingError bool } +// applyVisibilityFilter returns hosts filtered according to the showHidden flag. +func (m Model) applyVisibilityFilter(hosts []config.SSHHost) []config.SSHHost { + if m.showHidden { + return hosts + } + return config.FilterVisibleHosts(hosts) +} + // updateTableStyles updates the table header border color based on focus state func (m *Model) updateTableStyles() { s := table.DefaultStyles() diff --git a/internal/ui/tui.go b/internal/ui/tui.go index e621555..ed2b22d 100644 --- a/internal/ui/tui.go +++ b/internal/ui/tui.go @@ -48,7 +48,7 @@ func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, curren // Create the model with default sorting by name m := Model{ - hosts: hosts, + allHosts: hosts, historyManager: historyManager, pingManager: pingManager, sortMode: SortByName, @@ -63,8 +63,12 @@ func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, curren searchMode: searchMode, } + // Apply visibility filter (showHidden is false by default) + visibleHosts := m.applyVisibilityFilter(hosts) + m.hosts = visibleHosts + // Sort hosts according to the default sort mode - sortedHosts := m.sortHosts(hosts) + sortedHosts := m.sortHosts(visibleHosts) // Create the search input ti := textinput.New() diff --git a/internal/ui/update.go b/internal/ui/update.go index f59482a..1cc4cc2 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -187,7 +187,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err != nil { return m, tea.Quit } - m.hosts = m.sortHosts(hosts) + m.allHosts = hosts + m.hosts = m.sortHosts(m.applyVisibilityFilter(hosts)) // Reapply search filter if there is one active if m.searchInput.Value() != "" { @@ -231,7 +232,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err != nil { return m, tea.Quit } - m.hosts = m.sortHosts(hosts) + m.allHosts = hosts + m.hosts = m.sortHosts(m.applyVisibilityFilter(hosts)) // Reapply search filter if there is one active if m.searchInput.Value() != "" { @@ -276,7 +278,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err != nil { return m, tea.Quit } - m.hosts = m.sortHosts(hosts) + m.allHosts = hosts + m.hosts = m.sortHosts(m.applyVisibilityFilter(hosts)) // Reapply search filter if there is one active if m.searchInput.Value() != "" { @@ -535,7 +538,8 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.table.Focus() return m, nil } - m.hosts = m.sortHosts(hosts) + m.allHosts = hosts + m.hosts = m.sortHosts(m.applyVisibilityFilter(hosts)) // Reapply search filter if there is one active if m.searchInput.Value() != "" { @@ -705,6 +709,19 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.viewMode = ViewHelp return m, nil } + case "H": + if !m.searchMode && !m.deleteMode { + // Toggle visibility of hidden hosts + m.showHidden = !m.showHidden + m.hosts = m.sortHosts(m.applyVisibilityFilter(m.allHosts)) + if m.searchInput.Value() != "" { + m.filteredHosts = m.filterHosts(m.searchInput.Value()) + } else { + m.filteredHosts = m.hosts + } + m.updateTableRows() + return m, nil + } case "s": if !m.searchMode && !m.deleteMode { // Cycle through sort modes (only 2 modes now) diff --git a/internal/ui/view.go b/internal/ui/view.go index 0d6e8a6..ed9aa3c 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -86,6 +86,14 @@ func (m Model) renderListView() string { components = append(components, errorStyle.Render("❌ "+m.errorMessage)) } + // Add indicator when hidden hosts are shown + if m.showHidden { + hiddenBannerStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("11")). + Bold(true) + components = append(components, hiddenBannerStyle.Render(" [showing hidden hosts — press H to hide]")) + } + // Add the search bar with the appropriate style based on focus searchPrompt := "Search (/ to focus): " if m.searchMode {