1 Commits

Author SHA1 Message Date
9c639206f7 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 <host>
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.
2026-02-25 20:27:22 +01:00
11 changed files with 90 additions and 11 deletions

View File

@@ -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 <host>` - **⚡ Quick Connect** - Connect to any host instantly through the TUI or the CLI with `sshm <host>`
- **🔄 Port Forwarding** - Easy setup for Local, Remote, and Dynamic (SOCKS) forwarding with history persistence - **🔄 Port Forwarding** - Easy setup for Local, Remote, and Dynamic (SOCKS) forwarding with history persistence
- **📝 Easy Management** - Add, edit, move, and manage SSH configurations seamlessly - **📝 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 - **🔍 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 - **📝 Real-time Status** - Live SSH connectivity indicators with asynchronous ping checks and color-coded status
- **🔔 Smart Updates** - Automatic version checking with update notifications - **🔔 Smart Updates** - Automatic version checking with update notifications
@@ -106,6 +106,7 @@ sshm
- `d` - Delete selected host - `d` - Delete selected host
- `m` - Move host to another config file (requires SSH Include directives) - `m` - Move host to another config file (requires SSH Include directives)
- `f` - Port forwarding setup - `f` - Port forwarding setup
- `H` - Toggle hidden hosts visibility
- `q` - Quit - `q` - Quit
- `/` - Search/filter hosts - `/` - Search/filter hosts
@@ -647,7 +648,7 @@ SSHM supports all standard SSH configuration options:
- `IdentityFile` - Path to private key file - `IdentityFile` - Path to private key file
- `ProxyJump` - Jump server for connection tunneling (e.g., `user@jumphost:port`) - `ProxyJump` - Jump server for connection tunneling (e.g., `user@jumphost:port`)
- `ProxyCommand` - Jump command for connection tunneling (e.g, `ssh -W %h:%p Jumphost`) - `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 <host>`
**Additional SSH Options:** **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. 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.

View File

@@ -75,6 +75,8 @@ Examples:
return nil, cobra.ShellCompDirectiveError return nil, cobra.ShellCompDirectiveError
} }
hosts = config.FilterVisibleHosts(hosts)
var completions []string var completions []string
toCompleteLower := strings.ToLower(toComplete) toCompleteLower := strings.ToLower(toComplete)
for _, host := range hosts { for _, host := range hosts {

View File

@@ -55,6 +55,9 @@ func runSearch(cmd *cobra.Command, args []string) {
os.Exit(1) os.Exit(1)
} }
// Filter out hidden hosts
hosts = config.FilterVisibleHosts(hosts)
// Get search query // Get search query
var query string var query string
if len(args) > 0 { if len(args) > 0 {

View File

@@ -1624,6 +1624,27 @@ func GetAllConfigFiles() ([]string, error) {
return files, nil 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 // GetAllConfigFilesFromBase returns all SSH config files starting from a specific base config file
func GetAllConfigFilesFromBase(baseConfigPath string) ([]string, error) { func GetAllConfigFilesFromBase(baseConfigPath string) ([]string, error) {
if baseConfigPath == "" { if baseConfigPath == "" {

View File

@@ -438,7 +438,12 @@ func (m *addFormModel) renderGeneralTab() string {
b.WriteString(fieldStyle.Render(field.label)) b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(m.inputs[field.index].View()) 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() return b.String()

View File

@@ -599,7 +599,12 @@ func (m *editFormModel) renderEditGeneralTab() string {
b.WriteString(fieldStyle.Render(field.label)) b.WriteString(fieldStyle.Render(field.label))
b.WriteString("\n") b.WriteString("\n")
b.WriteString(m.inputs[field.index].View()) 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() return b.String()

View File

@@ -81,6 +81,9 @@ func (m *helpModel) View() string {
lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("p "), m.styles.FocusedLabel.Render("p "),
m.styles.HelpText.Render("ping all hosts")), 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, lipgloss.JoinHorizontal(lipgloss.Left,
m.styles.FocusedLabel.Render("f "), m.styles.FocusedLabel.Render("f "),
m.styles.HelpText.Render("setup port forwarding")), m.styles.HelpText.Render("setup port forwarding")),

View File

@@ -70,8 +70,10 @@ func (p PortForwardType) String() string {
type Model struct { type Model struct {
table table.Model table table.Model
searchInput textinput.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 filteredHosts []config.SSHHost
showHidden bool // when true, hidden-tagged hosts are shown
searchMode bool searchMode bool
deleteMode bool deleteMode bool
deleteHost *config.SSHHost // Host to be deleted (with line number for precise targeting) deleteHost *config.SSHHost // Host to be deleted (with line number for precise targeting)
@@ -108,6 +110,14 @@ type Model struct {
showingError bool 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 // updateTableStyles updates the table header border color based on focus state
func (m *Model) updateTableStyles() { func (m *Model) updateTableStyles() {
s := table.DefaultStyles() s := table.DefaultStyles()

View File

@@ -48,7 +48,7 @@ func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, curren
// Create the model with default sorting by name // Create the model with default sorting by name
m := Model{ m := Model{
hosts: hosts, allHosts: hosts,
historyManager: historyManager, historyManager: historyManager,
pingManager: pingManager, pingManager: pingManager,
sortMode: SortByName, sortMode: SortByName,
@@ -63,8 +63,12 @@ func NewModel(hosts []config.SSHHost, configFile string, searchMode bool, curren
searchMode: searchMode, 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 // Sort hosts according to the default sort mode
sortedHosts := m.sortHosts(hosts) sortedHosts := m.sortHosts(visibleHosts)
// Create the search input // Create the search input
ti := textinput.New() ti := textinput.New()

View File

@@ -187,7 +187,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil { if err != nil {
return m, tea.Quit 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 // Reapply search filter if there is one active
if m.searchInput.Value() != "" { if m.searchInput.Value() != "" {
@@ -231,7 +232,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil { if err != nil {
return m, tea.Quit 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 // Reapply search filter if there is one active
if m.searchInput.Value() != "" { if m.searchInput.Value() != "" {
@@ -276,7 +278,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err != nil { if err != nil {
return m, tea.Quit 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 // Reapply search filter if there is one active
if m.searchInput.Value() != "" { if m.searchInput.Value() != "" {
@@ -535,7 +538,8 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.table.Focus() m.table.Focus()
return m, nil 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 // Reapply search filter if there is one active
if m.searchInput.Value() != "" { if m.searchInput.Value() != "" {
@@ -705,6 +709,19 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.viewMode = ViewHelp m.viewMode = ViewHelp
return m, nil 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": case "s":
if !m.searchMode && !m.deleteMode { if !m.searchMode && !m.deleteMode {
// Cycle through sort modes (only 2 modes now) // Cycle through sort modes (only 2 modes now)

View File

@@ -86,6 +86,14 @@ func (m Model) renderListView() string {
components = append(components, errorStyle.Render("❌ "+m.errorMessage)) 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 // Add the search bar with the appropriate style based on focus
searchPrompt := "Search (/ to focus): " searchPrompt := "Search (/ to focus): "
if m.searchMode { if m.searchMode {