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 {