From 6d38eae5be101fc980a9b9624ba3cc32bc40feb7 Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:36:51 +0200 Subject: [PATCH 1/7] feat: add tag support from config file --- internal/config/manager.go | 1 + internal/config/model.go | 3 + internal/config/tags.go | 112 +++++++++++++++++++++++++++++++++++ internal/config/tags_test.go | 109 ++++++++++++++++++++++++++++++++++ 4 files changed, 225 insertions(+) create mode 100644 internal/config/tags.go create mode 100644 internal/config/tags_test.go diff --git a/internal/config/manager.go b/internal/config/manager.go index 74737dc..7d23a2e 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -90,6 +90,7 @@ func (m *Manager) GetHosts() []*Host { } } + h.Tags = ExtractTagsFromNodes(astHost.Nodes) h.Name = h.Properties["HostName"] h.User = h.Properties["User"] h.Port = h.Properties["Port"] diff --git a/internal/config/model.go b/internal/config/model.go index 4041fd7..34179ea 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -29,6 +29,9 @@ type Host struct { // (e.g., "Host *") rather than a specific destination connection. IsWildcard bool + // Tags holds custom metadata tags extracted losslessly from host comments. + Tags []string + // Properties stores all configuration parameters explicitly defined under this host // block as key-value pairs (e.g., "ForwardAgent": "yes"). Properties map[string]string diff --git a/internal/config/tags.go b/internal/config/tags.go new file mode 100644 index 0000000..0c31ae6 --- /dev/null +++ b/internal/config/tags.go @@ -0,0 +1,112 @@ +package config + +import ( + "fmt" + "slices" + "strings" + + "github.com/kevinburke/ssh_config" +) + +// ExtractTagsFromComment parses tag values from a single comment line. +// Supports `# tags: tag1, tag2` and `# #hashtag` formats. +func ExtractTagsFromComment(line string) []string { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "#") { + return nil + } + trimmed = strings.TrimLeft(trimmed, "#") + trimmed = strings.TrimSpace(trimmed) + if trimmed == "" { + return nil + } + + var raw []string + lower := strings.ToLower(trimmed) + if strings.HasPrefix(lower, "tags:") || strings.HasPrefix(lower, "tag:") { + idx := strings.Index(trimmed, ":") + content := trimmed[idx+1:] + parts := strings.FieldsFunc(content, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' + }) + raw = append(raw, parts...) + } else { + fields := strings.Fields(trimmed) + for _, field := range fields { + if strings.HasPrefix(field, "#") { + raw = append(raw, strings.TrimPrefix(field, "#")) + } + } + } + + var tags []string + for _, r := range raw { + clean := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(r, "#"))) + if clean != "" && !slices.Contains(tags, clean) { + tags = append(tags, clean) + } + } + return tags +} + +// ExtractTagsFromNodes inspects AST nodes inside a Host block and collects all unique tags. +func ExtractTagsFromNodes(nodes []ssh_config.Node) []string { + var tags []string + for _, node := range nodes { + if empty, ok := node.(*ssh_config.Empty); ok { + line := empty.String() + for _, t := range ExtractTagsFromComment(line) { + if !slices.Contains(tags, t) { + tags = append(tags, t) + } + } + } + } + return tags +} + +// UpdateASTHostTags updates or inserts a tag comment node inside an AST Host block. +func UpdateASTHostTags(astHost *ssh_config.Host, tags []string) error { + if astHost == nil { + return fmt.Errorf("astHost cannot be nil") + } + + tagIdx := -1 + for i, node := range astHost.Nodes { + if empty, ok := node.(*ssh_config.Empty); ok { + parsed := ExtractTagsFromComment(empty.String()) + if len(parsed) > 0 { + tagIdx = i + break + } + } + } + + if len(tags) == 0 { + if tagIdx != -1 { + astHost.Nodes = append(astHost.Nodes[:tagIdx], astHost.Nodes[tagIdx+1:]...) + } + return nil + } + + newNode, err := createTagCommentNode(tags) + if err != nil { + return err + } + + if tagIdx != -1 { + astHost.Nodes[tagIdx] = newNode + } else { + astHost.Nodes = append([]ssh_config.Node{newNode}, astHost.Nodes...) + } + return nil +} + +func createTagCommentNode(tags []string) (ssh_config.Node, error) { + line := " # tags: " + strings.Join(tags, ", ") + "\n" + decoded, err := ssh_config.Decode(strings.NewReader(line)) + if err != nil || len(decoded.Hosts) == 0 || len(decoded.Hosts[0].Nodes) == 0 { + return nil, fmt.Errorf("failed to create tag comment AST node: %w", err) + } + return decoded.Hosts[0].Nodes[0], nil +} diff --git a/internal/config/tags_test.go b/internal/config/tags_test.go new file mode 100644 index 0000000..2c157b5 --- /dev/null +++ b/internal/config/tags_test.go @@ -0,0 +1,109 @@ +package config + +import ( + "strings" + "testing" + + "github.com/kevinburke/ssh_config" + "github.com/stretchr/testify/assert" +) + +func TestExtractTagsFromComment(t *testing.T) { + t.Run("keyed comment with commas", func(t *testing.T) { + line := "# tags: production, database, aws" + tags := ExtractTagsFromComment(line) + assert.Equal(t, []string{"production", "database", "aws"}, tags) + }) + + t.Run("keyed comment with spaces and mixed case", func(t *testing.T) { + line := " # TAGS: Prod Web AWS " + tags := ExtractTagsFromComment(line) + assert.Equal(t, []string{"prod", "web", "aws"}, tags) + }) + + t.Run("hashtag comment", func(t *testing.T) { + line := "# #prod #database #aws" + tags := ExtractTagsFromComment(line) + assert.Equal(t, []string{"prod", "database", "aws"}, tags) + }) + + t.Run("deduplicates tags", func(t *testing.T) { + line := "# tags: prod, database, PROD, aws, database" + tags := ExtractTagsFromComment(line) + assert.Equal(t, []string{"prod", "database", "aws"}, tags) + }) + + t.Run("non tag comment returns nil", func(t *testing.T) { + line := "# Standard comment describing host" + tags := ExtractTagsFromComment(line) + assert.Nil(t, tags) + }) + + t.Run("non comment line returns nil", func(t *testing.T) { + line := "HostName 10.0.0.1" + tags := ExtractTagsFromComment(line) + assert.Nil(t, tags) + }) +} + +func TestExtractTagsFromNodesAndManager(t *testing.T) { + configContent := ` +Host prod-db + # tags: production, database + HostName 10.0.0.15 + User postgres + +Host staging-web + # #staging #frontend + HostName 10.0.0.20 + User deploy +` + cfg, err := ssh_config.Decode(strings.NewReader(configContent)) + assert.NoError(t, err) + + assert.Len(t, cfg.Hosts, 2) + + tagsDB := ExtractTagsFromNodes(cfg.Hosts[0].Nodes) + assert.Equal(t, []string{"production", "database"}, tagsDB) + + tagsWeb := ExtractTagsFromNodes(cfg.Hosts[1].Nodes) + assert.Equal(t, []string{"staging", "frontend"}, tagsWeb) +} + +func TestUpdateASTHostTags(t *testing.T) { + t.Run("insert new tag comment when none exists", func(t *testing.T) { + content := "Host test-host\n HostName 127.0.0.1\n" + cfg, err := ssh_config.Decode(strings.NewReader(content)) + assert.NoError(t, err) + + err = UpdateASTHostTags(cfg.Hosts[0], []string{"web", "prod"}) + assert.NoError(t, err) + + tags := ExtractTagsFromNodes(cfg.Hosts[0].Nodes) + assert.Equal(t, []string{"web", "prod"}, tags) + }) + + t.Run("update existing tag comment in place", func(t *testing.T) { + content := "Host test-host\n # tags: old1, old2\n HostName 127.0.0.1\n" + cfg, err := ssh_config.Decode(strings.NewReader(content)) + assert.NoError(t, err) + + err = UpdateASTHostTags(cfg.Hosts[0], []string{"new1", "new2"}) + assert.NoError(t, err) + + tags := ExtractTagsFromNodes(cfg.Hosts[0].Nodes) + assert.Equal(t, []string{"new1", "new2"}, tags) + }) + + t.Run("clear tags removes tag node", func(t *testing.T) { + content := "Host test-host\n # tags: old1, old2\n HostName 127.0.0.1\n" + cfg, err := ssh_config.Decode(strings.NewReader(content)) + assert.NoError(t, err) + + err = UpdateASTHostTags(cfg.Hosts[0], nil) + assert.NoError(t, err) + + tags := ExtractTagsFromNodes(cfg.Hosts[0].Nodes) + assert.Empty(t, tags) + }) +} From 494d0991827c66c5dd7c363dac5029c624d6fae0 Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:54:32 +0200 Subject: [PATCH 2/7] feat: add tag support in TUI --- internal/config/editor.go | 6 ++++ internal/config/tags.go | 67 +++++++++++++++++++++++++----------- internal/config/tags_test.go | 18 ++++++++++ internal/tui/forms.go | 8 +++++ internal/tui/model.go | 13 ++++++- internal/tui/tui_test.go | 50 +++++++++++++++++++++++++++ internal/tui/update.go | 3 ++ 7 files changed, 143 insertions(+), 22 deletions(-) diff --git a/internal/config/editor.go b/internal/config/editor.go index 26e861c..c115980 100644 --- a/internal/config/editor.go +++ b/internal/config/editor.go @@ -57,6 +57,9 @@ func (m *Manager) AddHost(targetFile string, h *Host) error { } if newASTHost != nil { + if err := UpdateASTHostTags(newASTHost, h.Tags); err != nil { + return err + } cfg.Hosts = append(cfg.Hosts, newASTHost) } return m.SaveFile(absTarget) @@ -119,6 +122,9 @@ func (m *Manager) UpdateHost(originalAlias string, h *Host) error { newASTHost.Nodes = append(newASTHost.Nodes, node) } } + if err := UpdateASTHostTags(newASTHost, h.Tags); err != nil { + return err + } targetCfg.Hosts[targetIdx] = newASTHost } diff --git a/internal/config/tags.go b/internal/config/tags.go index 0c31ae6..c91e0bc 100644 --- a/internal/config/tags.go +++ b/internal/config/tags.go @@ -4,6 +4,7 @@ import ( "fmt" "slices" "strings" + "unicode" "github.com/kevinburke/ssh_config" ) @@ -24,24 +25,24 @@ func ExtractTagsFromComment(line string) []string { var raw []string lower := strings.ToLower(trimmed) if strings.HasPrefix(lower, "tags:") || strings.HasPrefix(lower, "tag:") { - idx := strings.Index(trimmed, ":") - content := trimmed[idx+1:] + _, after, _ := strings.Cut(trimmed, ":") + content := after parts := strings.FieldsFunc(content, func(r rune) bool { - return r == ',' || r == ' ' || r == '\t' + return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' }) raw = append(raw, parts...) } else { - fields := strings.Fields(trimmed) - for _, field := range fields { + fields := strings.FieldsSeq(trimmed) + for field := range fields { if strings.HasPrefix(field, "#") { - raw = append(raw, strings.TrimPrefix(field, "#")) + raw = append(raw, field) } } } var tags []string for _, r := range raw { - clean := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(r, "#"))) + clean := cleanTag(r) if clean != "" && !slices.Contains(tags, clean) { tags = append(tags, clean) } @@ -65,43 +66,67 @@ func ExtractTagsFromNodes(nodes []ssh_config.Node) []string { return tags } -// UpdateASTHostTags updates or inserts a tag comment node inside an AST Host block. +// UpdateASTHostTags updates or inserts a tag comment node inside an AST Host block +// and prunes any duplicate or stale tag comment lines. func UpdateASTHostTags(astHost *ssh_config.Host, tags []string) error { if astHost == nil { return fmt.Errorf("astHost cannot be nil") } - tagIdx := -1 + var sanitized []string + for _, t := range tags { + clean := cleanTag(t) + if clean != "" && !slices.Contains(sanitized, clean) { + sanitized = append(sanitized, clean) + } + } + + firstTagIdx := -1 + var filteredNodes []ssh_config.Node for i, node := range astHost.Nodes { if empty, ok := node.(*ssh_config.Empty); ok { - parsed := ExtractTagsFromComment(empty.String()) - if len(parsed) > 0 { - tagIdx = i - break + if len(ExtractTagsFromComment(empty.String())) > 0 { + if firstTagIdx == -1 { + firstTagIdx = i + } + continue } } + filteredNodes = append(filteredNodes, node) } - if len(tags) == 0 { - if tagIdx != -1 { - astHost.Nodes = append(astHost.Nodes[:tagIdx], astHost.Nodes[tagIdx+1:]...) - } + if len(sanitized) == 0 { + astHost.Nodes = filteredNodes return nil } - newNode, err := createTagCommentNode(tags) + newNode, err := createTagCommentNode(sanitized) if err != nil { return err } - if tagIdx != -1 { - astHost.Nodes[tagIdx] = newNode + if firstTagIdx != -1 && firstTagIdx <= len(filteredNodes) { + filteredNodes = slices.Insert(filteredNodes, firstTagIdx, newNode) } else { - astHost.Nodes = append([]ssh_config.Node{newNode}, astHost.Nodes...) + filteredNodes = append([]ssh_config.Node{newNode}, filteredNodes...) } + + astHost.Nodes = filteredNodes return nil } +func cleanTag(s string) string { + s = strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == '\x00' || !unicode.IsPrint(r) { + return -1 + } + return r + }, s) + s = strings.TrimSpace(s) + s = strings.TrimLeft(s, "#") + return strings.ToLower(strings.TrimSpace(s)) +} + func createTagCommentNode(tags []string) (ssh_config.Node, error) { line := " # tags: " + strings.Join(tags, ", ") + "\n" decoded, err := ssh_config.Decode(strings.NewReader(line)) diff --git a/internal/config/tags_test.go b/internal/config/tags_test.go index 2c157b5..00c572f 100644 --- a/internal/config/tags_test.go +++ b/internal/config/tags_test.go @@ -106,4 +106,22 @@ func TestUpdateASTHostTags(t *testing.T) { tags := ExtractTagsFromNodes(cfg.Hosts[0].Nodes) assert.Empty(t, tags) }) + + t.Run("sanitizes newlines and control characters", func(t *testing.T) { + line := "# tags: prod\nHost evil.com\n User root, aws" + tags := ExtractTagsFromComment(line) + assert.Equal(t, []string{"prod", "host", "evil.com", "user", "root", "aws"}, tags) + }) + + t.Run("prunes multiple tag comment lines on update", func(t *testing.T) { + content := "Host test-host\n # tags: tag1\n HostName 127.0.0.1\n # tags: tag2\n" + cfg, err := ssh_config.Decode(strings.NewReader(content)) + assert.NoError(t, err) + + err = UpdateASTHostTags(cfg.Hosts[0], []string{"merged1", "merged2"}) + assert.NoError(t, err) + + tags := ExtractTagsFromNodes(cfg.Hosts[0].Nodes) + assert.Equal(t, []string{"merged1", "merged2"}, tags) + }) } diff --git a/internal/tui/forms.go b/internal/tui/forms.go index 6583c34..ef66659 100644 --- a/internal/tui/forms.go +++ b/internal/tui/forms.go @@ -2,6 +2,7 @@ package tui import ( "path/filepath" + "strings" "tusshi/internal/config" "tusshi/internal/validation" @@ -20,6 +21,7 @@ func (m *Model) BuildHostForm(defaultFile string) *huh.Form { m.FormDestFile = defaultFile m.FormProxyJump = "" m.FormForwardAgent = "no" + m.FormTagsString = "" if m.FormAction == actionEdit && m.SelectedIndex < len(m.Filtered) { selected := m.Filtered[m.SelectedIndex] @@ -30,6 +32,7 @@ func (m *Model) BuildHostForm(defaultFile string) *huh.Form { m.FormHost.Port = selected.Port m.FormHost.IdentityFile = selected.IdentityFile m.FormDestFile = selected.SourceFile + m.FormTagsString = strings.Join(selected.Tags, ", ") m.FormProxyJump = selected.Properties["ProxyJump"] if agent, ok := selected.Properties["ForwardAgent"]; ok { @@ -79,6 +82,11 @@ func (m *Model) BuildHostForm(defaultFile string) *huh.Form { )) groups = append(groups, huh.NewGroup( + huh.NewInput(). + Title("Tags"). + Description("Metadata tags (comma, space, or # hashtag separated)"). + Placeholder("production, database, aws"). + Value(&m.FormTagsString), huh.NewInput(). Title("Identity File Path"). Description("Private key path (e.g. ~/.ssh/id_rsa)"). diff --git a/internal/tui/model.go b/internal/tui/model.go index 3713ac5..942aec6 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -51,6 +51,7 @@ type Model struct { FormDestFile string FormProxyJump string FormForwardAgent string + FormTagsString string // Alerts AlertText string @@ -174,7 +175,17 @@ func (m *Model) FilterHosts() { nameMatch := strings.Contains(strings.ToLower(h.Name), searchQ) userMatch := strings.Contains(strings.ToLower(h.User), searchQ) - if searchQ == "" || aliasMatch || nameMatch || userMatch { + tagMatch := false + cleanTagQ := strings.TrimPrefix(searchQ, "#") + cleanTagQ = strings.TrimPrefix(cleanTagQ, "tag:") + for _, tag := range h.Tags { + if strings.Contains(strings.ToLower(tag), cleanTagQ) { + tagMatch = true + break + } + } + + if searchQ == "" || aliasMatch || nameMatch || userMatch || tagMatch { filtered = append(filtered, h) } } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 0d68fda..7c5f92a 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -229,3 +229,53 @@ Host srv-1 assert.Contains(t, cell, "●") }) } + +// TestTUIHostFormTags verifies that host tags are initialized and submitted in host forms. +func TestTUIHostFormTags(t *testing.T) { + tmpDir := t.TempDir() + primaryPath := filepath.Join(tmpDir, "config") + + content := ` +Host tagged-server + # tags: production, database + HostName 10.0.0.1 + User admin +` + err := os.WriteFile(primaryPath, []byte(content), 0600) + assert.NoError(t, err) + + mgr := config.NewManager(primaryPath) + err = mgr.Load() + assert.NoError(t, err) + + m := NewModel(mgr) + + t.Run("edit form initializes tags", func(t *testing.T) { + m.FormAction = actionEdit + m.SelectedIndex = 0 + _ = m.BuildHostForm(m.ActiveTab) + assert.Equal(t, "production, database", m.FormTagsString) + }) + + t.Run("executeFormSubmit updates host tags", func(t *testing.T) { + m.FormAction = actionEdit + m.SelectedIndex = 0 + _ = m.BuildHostForm(m.ActiveTab) + m.FormTagsString = "aws, production, k8s" + m.executeFormSubmit() + + hosts := m.Manager.GetHosts() + assert.Len(t, hosts, 1) + assert.Equal(t, []string{"aws", "production", "k8s"}, hosts[0].Tags) + }) + + t.Run("filter hosts matches tags", func(t *testing.T) { + m.SearchInput.SetValue("#production") + m.FilterHosts() + assert.Len(t, m.Filtered, 1) + + m.SearchInput.SetValue("tag:nonexistent") + m.FilterHosts() + assert.Len(t, m.Filtered, 0) + }) +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 436394a..29c9c94 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -3,6 +3,8 @@ package tui import ( "fmt" + "tusshi/internal/config" + tea "github.com/charmbracelet/bubbletea" ) @@ -102,6 +104,7 @@ func (m *Model) executeFormSubmit() { var err error m.FormHost.SourceFile = m.FormDestFile + m.FormHost.Tags = config.ExtractTagsFromComment("# tags: " + m.FormTagsString) if m.FormProxyJump != "" { m.FormHost.Properties["ProxyJump"] = m.FormProxyJump } else { From 48842862fdd3b03013735207bd9d0ce9efe12330 Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:06:39 +0200 Subject: [PATCH 3/7] feat: add tags to header for categories --- internal/tui/model.go | 30 +++++++++++++++++++++++++++--- internal/tui/style/styles.go | 10 ++++++++++ internal/tui/theme/global.go | 2 +- internal/tui/tui_test.go | 15 +++++++++++++++ internal/tui/view_header.go | 14 ++++++++++++-- 5 files changed, 65 insertions(+), 6 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 942aec6..8fd1dce 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2,6 +2,7 @@ package tui import ( "path/filepath" + "slices" "strings" "tusshi/internal/config" @@ -142,6 +143,19 @@ func (m *Model) Reload() { m.Tabs = append(m.Tabs, f) } + var tagList []string + for _, h := range m.Hosts { + for _, tag := range h.Tags { + if !slices.Contains(tagList, tag) { + tagList = append(tagList, tag) + } + } + } + slices.Sort(tagList) + for _, tag := range tagList { + m.Tabs = append(m.Tabs, "#"+tag) + } + tabValid := false for _, t := range m.Tabs { if t == m.ActiveTab { @@ -162,8 +176,15 @@ func (m *Model) FilterHosts() { searchQ := strings.ToLower(m.SearchInput.Value()) for _, h := range m.Hosts { - if m.ActiveTab != "All" && h.SourceFile != m.ActiveTab { - continue + if m.ActiveTab != tabAll { + if after, ok := strings.CutPrefix(m.ActiveTab, "#"); ok { + targetTag := after + if !slices.Contains(h.Tags, targetTag) { + continue + } + } else if h.SourceFile != m.ActiveTab { + continue + } } // why: wildcard configs (e.g. Host *) are metadata, not connectable hosts @@ -200,10 +221,13 @@ func (m *Model) FilterHosts() { } } -// GetTabLabel returns a clean display label (filename) for a config tab path. +// GetTabLabel returns a clean display label (filename or tag) for a tab. func GetTabLabel(tabPath string) string { if tabPath == tabAll { return tabAll } + if strings.HasPrefix(tabPath, "#") { + return tabPath + } return filepath.Base(tabPath) } diff --git a/internal/tui/style/styles.go b/internal/tui/style/styles.go index 33d3380..6817c48 100644 --- a/internal/tui/style/styles.go +++ b/internal/tui/style/styles.go @@ -24,6 +24,16 @@ var ( Foreground(theme.Global.Muted). Padding(0, 2) + TagTabActive = lipgloss.NewStyle(). + Foreground(lipgloss.Color("255")). + Background(theme.Global.Secondary). + Bold(true). + Padding(0, 2) + + TagTabInactive = lipgloss.NewStyle(). + Foreground(theme.Global.Secondary). + Padding(0, 2) + Header = lipgloss.NewStyle(). Padding(0, 1) diff --git a/internal/tui/theme/global.go b/internal/tui/theme/global.go index 8134d93..5b9609c 100644 --- a/internal/tui/theme/global.go +++ b/internal/tui/theme/global.go @@ -17,7 +17,7 @@ var Global = Theme{ const ( primary = lipgloss.Color("#FF5500") - secondary = lipgloss.Color("#1F1F1F") + secondary = lipgloss.Color("#A15101") muted = lipgloss.Color("#757575") bg = lipgloss.Color("#121212") success = lipgloss.Color("#FF7851") diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 7c5f92a..ec5791a 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -278,4 +278,19 @@ Host tagged-server m.FilterHosts() assert.Len(t, m.Filtered, 0) }) + + t.Run("tag category tabs discovered and active tag tab filters hosts", func(t *testing.T) { + m.SearchInput.SetValue("") + assert.Contains(t, m.Tabs, "#production") + assert.Contains(t, m.Tabs, "#database") + + m.ActiveTab = "#database" + m.FilterHosts() + assert.Len(t, m.Filtered, 1) + assert.Equal(t, "tagged-server", m.Filtered[0].Alias) + + m.ActiveTab = "#nonexistent" + m.FilterHosts() + assert.Len(t, m.Filtered, 0) + }) } diff --git a/internal/tui/view_header.go b/internal/tui/view_header.go index 5f92c01..db0f3f3 100644 --- a/internal/tui/view_header.go +++ b/internal/tui/view_header.go @@ -1,6 +1,7 @@ package tui import ( + "strings" "tusshi/internal/tui/style" "github.com/charmbracelet/lipgloss" @@ -11,10 +12,19 @@ func (m *Model) renderHeader() string { var tabs []string for _, t := range m.Tabs { label := GetTabLabel(t) + isTag := strings.HasPrefix(t, "#") if t == m.ActiveTab { - tabs = append(tabs, style.TabActive.Render(label)) + if isTag { + tabs = append(tabs, style.TagTabActive.Render(label)) + } else { + tabs = append(tabs, style.TabActive.Render(label)) + } } else { - tabs = append(tabs, style.TabInactive.Render(label)) + if isTag { + tabs = append(tabs, style.TagTabInactive.Render(label)) + } else { + tabs = append(tabs, style.TabInactive.Render(label)) + } } } From 13f97fa665816c20f8a5b0839fadf27c9d88bdd9 Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:06:46 +0200 Subject: [PATCH 4/7] chore: update roadmap --- ROADMAP.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3e68286..064bee1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,9 +10,9 @@ This document outlines the planned design improvements, features, and core focus - [x] Interactive status column displaying **Online (latency in ms)** or **Offline**. ### Native Tagging via Lossless Comments -- [ ] Lossless parser support for custom hashtag metadata inside standard configuration comments (e.g., `# tags: production, database, aws`). -- [ ] Indexing tags on load to enable query filtering in the search input (e.g., `tag:production` or `#aws`). -- [ ] Categorized TUI views or tab structures based on tag groups. +- [x] Lossless parser support for custom hashtag metadata inside standard configuration comments (e.g., `# tags: production, database, aws`). +- [x] Indexing tags on load to enable query filtering in the search input (e.g., `tag:production` or `#aws`). +- [x] Categorized TUI views or tab structures based on tag groups. --- From 0bacff43e51cf273bad4398a0d33ff921ffea027 Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:09:25 +0200 Subject: [PATCH 5/7] feat: add tags to table view --- internal/tui/view_table.go | 58 +++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/internal/tui/view_table.go b/internal/tui/view_table.go index 01cd991..70240a1 100644 --- a/internal/tui/view_table.go +++ b/internal/tui/view_table.go @@ -16,53 +16,59 @@ func (m *Model) renderTable(width, maxHeight int) string { } var headerRow, dividerRow string - var wAlias, wName, wUser, wPort, wStatus, wConfig int + var wAlias, wName, wUser, wPort, wStatus, wConfig, wTags int switch { - case width >= 85: - wTotal := max(width-14, 10) + case width >= 90: + wTotal := max(width-16, 10) wAlias = int(float64(wTotal) * 0.15) - wName = int(float64(wTotal) * 0.30) - wUser = int(float64(wTotal) * 0.12) - wPort = int(float64(wTotal) * 0.08) - wConfig = int(float64(wTotal) * 0.23) - wStatus = wTotal - wAlias - wName - wUser - wPort - wConfig - - headerRow = fmt.Sprintf(" %-*s %-*s %-*s %-*s %-*s %-*s", + wName = int(float64(wTotal) * 0.25) + wUser = int(float64(wTotal) * 0.10) + wPort = int(float64(wTotal) * 0.07) + wTags = int(float64(wTotal) * 0.18) + wConfig = int(float64(wTotal) * 0.15) + wStatus = wTotal - wAlias - wName - wUser - wPort - wTags - wConfig + + headerRow = fmt.Sprintf(" %-*s %-*s %-*s %-*s %-*s %-*s %-*s", wAlias, "ALIAS", wName, "NAME / ADDRESS", wUser, "USER", wPort, "PORT", + wTags, "TAGS", wConfig, "CONFIG", wStatus, "STATUS", ) - dividerRow = fmt.Sprintf(" %s %s %s %s %s %s", + dividerRow = fmt.Sprintf(" %s %s %s %s %s %s %s", strings.Repeat("─", wAlias), strings.Repeat("─", wName), strings.Repeat("─", wUser), strings.Repeat("─", wPort), + strings.Repeat("─", wTags), strings.Repeat("─", wConfig), strings.Repeat("─", wStatus), ) - case width >= 65: - wTotal := max(width-12, 10) - wAlias = int(float64(wTotal) * 0.20) - wName = int(float64(wTotal) * 0.35) - wUser = int(float64(wTotal) * 0.15) - wConfig = int(float64(wTotal) * 0.18) - wStatus = wTotal - wAlias - wName - wUser - wConfig - - headerRow = fmt.Sprintf(" %-*s %-*s %-*s %-*s %-*s", + case width >= 70: + wTotal := max(width-14, 10) + wAlias = int(float64(wTotal) * 0.18) + wName = int(float64(wTotal) * 0.28) + wUser = int(float64(wTotal) * 0.12) + wTags = int(float64(wTotal) * 0.16) + wConfig = int(float64(wTotal) * 0.14) + wStatus = wTotal - wAlias - wName - wUser - wTags - wConfig + + headerRow = fmt.Sprintf(" %-*s %-*s %-*s %-*s %-*s %-*s", wAlias, "ALIAS", wName, "NAME / ADDRESS", wUser, "USER", + wTags, "TAGS", wConfig, "CONFIG", wStatus, "STATUS", ) - dividerRow = fmt.Sprintf(" %s %s %s %s %s", + dividerRow = fmt.Sprintf(" %s %s %s %s %s %s", strings.Repeat("─", wAlias), strings.Repeat("─", wName), strings.Repeat("─", wUser), + strings.Repeat("─", wTags), strings.Repeat("─", wConfig), strings.Repeat("─", wStatus), ) @@ -118,7 +124,7 @@ func (m *Model) renderTable(width, maxHeight int) string { for idx := startIndex; idx < len(m.Filtered) && len(rows) < maxHeight; idx++ { h := m.Filtered[idx] - rows = append(rows, m.renderRow(h, idx, wAlias, wName, wUser, wPort, wStatus, wConfig)) + rows = append(rows, m.renderRow(h, idx, wAlias, wName, wUser, wPort, wStatus, wConfig, wTags)) } return strings.Join(rows, "\n") @@ -126,7 +132,7 @@ func (m *Model) renderTable(width, maxHeight int) string { // renderRow constructs a formatted row, applying specific colors for the status column // and blending background colors correctly when the row is active/selected. -func (m *Model) renderRow(h *config.Host, idx int, wAlias, wName, wUser, wPort, wStatus, wConfig int) string { +func (m *Model) renderRow(h *config.Host, idx int, wAlias, wName, wUser, wPort, wStatus, wConfig, wTags int) string { rowActive := idx == m.SelectedIndex var cells []string @@ -151,6 +157,12 @@ func (m *Model) renderRow(h *config.Host, idx int, wAlias, wName, wUser, wPort, cells = append(cells, renderCell(port, rowCellStyle(rowActive, "242"), rowActive, wPort)) } + if wTags > 0 { + tagsStr := strings.Join(h.Tags, ", ") + tagsStr = truncate(tagsStr, wTags) + cells = append(cells, renderCell(tagsStr, rowCellStyle(rowActive, "244"), rowActive, wTags)) + } + if wConfig > 0 { cfgNickname := strings.TrimSuffix(GetTabLabel(h.SourceFile), ".conf") cfgNickname = strings.TrimSuffix(cfgNickname, "config") From 279eb7a50395698e9a21818a4a027577d79181b4 Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:16:59 +0200 Subject: [PATCH 6/7] feat: add commands to tag & untag --- internal/tui/commands.go | 19 ++++++ internal/tui/commands/tag.go | 106 ++++++++++++++++++++++++++++++ internal/tui/commands/tag_test.go | 83 +++++++++++++++++++++++ internal/tui/commands_test.go | 55 ++++++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 internal/tui/commands/tag.go create mode 100644 internal/tui/commands/tag_test.go create mode 100644 internal/tui/commands_test.go diff --git a/internal/tui/commands.go b/internal/tui/commands.go index fe5e9ca..2bf0c1f 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "tusshi/internal/config" "tusshi/internal/tui/commands" "tusshi/internal/tui/components" "tusshi/internal/tui/theme" @@ -24,6 +25,8 @@ const ( deleteConfigCmd = "rmconf, delete-config" pingCmd = "p, ping" pingAllCmd = "P, pingall" + tagCmd = "tag" + untagCmd = "untag" ) // helpOptions centralizes all interactive command shortcuts and their help text @@ -32,6 +35,8 @@ var helpOptions = []components.HelpOption{ {Shortcut: editCmd, Description: "Edit selected connection"}, {Shortcut: deleteCmd, Description: "Delete selected connection"}, {Shortcut: moveCmd, Description: "Move connection to a file/tab"}, + {Shortcut: tagCmd, Description: "Add tags to connection (:tag [alias] )"}, + {Shortcut: untagCmd, Description: "Remove tags from connection (:untag [alias] )"}, {Shortcut: pingCmd, Description: "Ping selected connection"}, {Shortcut: pingAllCmd, Description: "Ping all connections"}, {Shortcut: addConfigCmd, Description: "Add a new config file"}, @@ -177,6 +182,20 @@ func (m *Model) executeCommand(raw string) (tea.Model, tea.Cmd) { case matchesCommand(cmd, deleteConfigCmd): action = commands.DeleteConfig(m.Manager, parts) + case matchesCommand(cmd, tagCmd): + var selected *config.Host + if len(m.Filtered) > 0 { + selected = m.Filtered[m.SelectedIndex] + } + action = commands.Tag(m.Manager, selected, parts) + + case matchesCommand(cmd, untagCmd): + var selected *config.Host + if len(m.Filtered) > 0 { + selected = m.Filtered[m.SelectedIndex] + } + action = commands.Untag(m.Manager, selected, parts) + default: m.ErrorText = "Unknown command: " + cmd return m, nil diff --git a/internal/tui/commands/tag.go b/internal/tui/commands/tag.go new file mode 100644 index 0000000..534f3e1 --- /dev/null +++ b/internal/tui/commands/tag.go @@ -0,0 +1,106 @@ +package commands + +import ( + "fmt" + "slices" + "strings" + + "tusshi/internal/config" +) + +// Tag appends metadata tags to a target host or the selected host. +func Tag(mgr *config.Manager, selectedHost *config.Host, parts []string) func(Context) { + return func(ctx Context) { + if len(parts) < 2 { + ctx.SetError("Usage: :tag [alias] [tag2...]") + return + } + + targetHost, tagArgs := resolveTargetHostAndTags(mgr, selectedHost, parts[1:]) + if targetHost == nil { + ctx.SetError("No connection selected") + return + } + + if len(tagArgs) == 0 { + ctx.SetError("Usage: :tag [alias] [tag2...]") + return + } + + newTags := targetHost.Tags + for _, t := range tagArgs { + clean := config.ExtractTagsFromComment("# tags: " + t) + for _, ct := range clean { + if !slices.Contains(newTags, ct) { + newTags = append(newTags, ct) + } + } + } + + targetHost.Tags = newTags + if err := mgr.UpdateHost(targetHost.Alias, targetHost); err != nil { + ctx.SetError("Tag error: " + err.Error()) + } else { + ctx.SetAlert(fmt.Sprintf("Tagged %q with %s.", targetHost.Alias, strings.Join(tagArgs, ", "))) + } + ctx.Reload() + } +} + +// Untag removes metadata tags from a target host or the selected host. +func Untag(mgr *config.Manager, selectedHost *config.Host, parts []string) func(Context) { + return func(ctx Context) { + if len(parts) < 2 { + ctx.SetError("Usage: :untag [alias] [tag2...]") + return + } + + targetHost, tagArgs := resolveTargetHostAndTags(mgr, selectedHost, parts[1:]) + if targetHost == nil { + ctx.SetError("No connection selected") + return + } + + if len(tagArgs) == 0 { + ctx.SetError("Usage: :untag [alias] [tag2...]") + return + } + + var tagsToRemove []string + for _, t := range tagArgs { + clean := config.ExtractTagsFromComment("# tags: " + t) + tagsToRemove = append(tagsToRemove, clean...) + } + + var remaining []string + for _, t := range targetHost.Tags { + if !slices.Contains(tagsToRemove, t) { + remaining = append(remaining, t) + } + } + + targetHost.Tags = remaining + if err := mgr.UpdateHost(targetHost.Alias, targetHost); err != nil { + ctx.SetError("Untag error: " + err.Error()) + } else { + ctx.SetAlert(fmt.Sprintf("Removed tags from %q.", targetHost.Alias)) + } + ctx.Reload() + } +} + +func resolveTargetHostAndTags(mgr *config.Manager, selectedHost *config.Host, args []string) (*config.Host, []string) { + if len(args) == 0 { + return selectedHost, nil + } + + hosts := mgr.GetHosts() + firstArg := args[0] + for _, h := range hosts { + if h.Alias == firstArg { + return h, args[1:] + } + } + + return selectedHost, args +} diff --git a/internal/tui/commands/tag_test.go b/internal/tui/commands/tag_test.go new file mode 100644 index 0000000..c5bcc52 --- /dev/null +++ b/internal/tui/commands/tag_test.go @@ -0,0 +1,83 @@ +package commands_test + +import ( + "os" + "path/filepath" + "testing" + + "tusshi/internal/config" + "tusshi/internal/tui/commands" + + "github.com/stretchr/testify/assert" +) + +type mockContext struct { + alertText string + errorText string + reloaded bool +} + +func (m *mockContext) Quit() {} +func (m *mockContext) OpenHelp() {} +func (m *mockContext) OpenForm(_ string) {} +func (m *mockContext) SetAlert(text string) { + m.alertText = text +} +func (m *mockContext) SetError(text string) { + m.errorText = text +} +func (m *mockContext) Reload() { m.reloaded = true } +func (m *mockContext) GetActiveTab() string { return "All" } +func (m *mockContext) SetActiveTab(_ string) {} + +func TestTagCommand(t *testing.T) { + tmpDir := t.TempDir() + primaryPath := filepath.Join(tmpDir, "config") + + content := ` +Host web-server + # tags: production + HostName 10.0.0.1 +` + err := os.WriteFile(primaryPath, []byte(content), 0600) + assert.NoError(t, err) + + mgr := config.NewManager(primaryPath) + err = mgr.Load() + assert.NoError(t, err) + + hosts := mgr.GetHosts() + assert.Len(t, hosts, 1) + + t.Run("adds new tags to selected host", func(t *testing.T) { + ctx := &mockContext{} + action := commands.Tag(mgr, hosts[0], []string{":tag", "aws", "k8s"}) + action(ctx) + + assert.True(t, ctx.reloaded) + assert.Contains(t, ctx.alertText, "Tagged") + + updatedHosts := mgr.GetHosts() + assert.Equal(t, []string{"production", "aws", "k8s"}, updatedHosts[0].Tags) + }) + + t.Run("removes tags from selected host", func(t *testing.T) { + ctx := &mockContext{} + action := commands.Untag(mgr, hosts[0], []string{":untag", "aws"}) + action(ctx) + + assert.True(t, ctx.reloaded) + assert.Contains(t, ctx.alertText, "Removed tags") + + updatedHosts := mgr.GetHosts() + assert.Equal(t, []string{"production", "k8s"}, updatedHosts[0].Tags) + }) + + t.Run("error on missing arguments", func(t *testing.T) { + ctx := &mockContext{} + action := commands.Tag(mgr, hosts[0], []string{":tag"}) + action(ctx) + + assert.Contains(t, ctx.errorText, "Usage") + }) +} diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go new file mode 100644 index 0000000..32192ec --- /dev/null +++ b/internal/tui/commands_test.go @@ -0,0 +1,55 @@ +package tui + +import ( + "os" + "path/filepath" + "testing" + + "tusshi/internal/config" + + "github.com/stretchr/testify/assert" +) + +func TestExecuteTagCommands(t *testing.T) { + tmpDir := t.TempDir() + primaryPath := filepath.Join(tmpDir, "config") + + content := ` +Host web-server + # tags: production + HostName 10.0.0.1 +` + err := os.WriteFile(primaryPath, []byte(content), 0600) + assert.NoError(t, err) + + mgr := config.NewManager(primaryPath) + err = mgr.Load() + assert.NoError(t, err) + + m := NewModel(mgr) + + t.Run("execute :tag command", func(t *testing.T) { + m.SelectedIndex = 0 + m.executeCommand(":tag aws k8s") + hosts := m.Manager.GetHosts() + assert.Len(t, hosts, 1) + assert.Equal(t, []string{"production", "aws", "k8s"}, hosts[0].Tags) + assert.Contains(t, m.AlertText, "Tagged") + }) + + t.Run("execute :untag command", func(t *testing.T) { + m.SelectedIndex = 0 + m.executeCommand(":untag aws") + hosts := m.Manager.GetHosts() + assert.Len(t, hosts, 1) + assert.Equal(t, []string{"production", "k8s"}, hosts[0].Tags) + assert.Contains(t, m.AlertText, "Removed tags") + }) + + t.Run("execute :tag with explicit alias target", func(t *testing.T) { + m.executeCommand(":tag web-server staging") + hosts := m.Manager.GetHosts() + assert.Len(t, hosts, 1) + assert.Contains(t, hosts[0].Tags, "staging") + }) +} From 1855278fea48d1d521fdc98b4548657fc7e06f41 Mon Sep 17 00:00:00 2001 From: Sem Van Broekhoven <144097969+dotsem@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:22:08 +0200 Subject: [PATCH 7/7] test: add unit tests --- internal/config/tags_test.go | 23 +++++++++++------------ internal/tui/tui_test.go | 4 ++-- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/internal/config/tags_test.go b/internal/config/tags_test.go index 00c572f..05b2ba1 100644 --- a/internal/config/tags_test.go +++ b/internal/config/tags_test.go @@ -61,12 +61,11 @@ Host staging-web cfg, err := ssh_config.Decode(strings.NewReader(configContent)) assert.NoError(t, err) - assert.Len(t, cfg.Hosts, 2) - - tagsDB := ExtractTagsFromNodes(cfg.Hosts[0].Nodes) + // ssh_config injects an implicit default host at index 0; explicit hosts start at index 1 + tagsDB := ExtractTagsFromNodes(cfg.Hosts[1].Nodes) assert.Equal(t, []string{"production", "database"}, tagsDB) - tagsWeb := ExtractTagsFromNodes(cfg.Hosts[1].Nodes) + tagsWeb := ExtractTagsFromNodes(cfg.Hosts[2].Nodes) assert.Equal(t, []string{"staging", "frontend"}, tagsWeb) } @@ -76,10 +75,10 @@ func TestUpdateASTHostTags(t *testing.T) { cfg, err := ssh_config.Decode(strings.NewReader(content)) assert.NoError(t, err) - err = UpdateASTHostTags(cfg.Hosts[0], []string{"web", "prod"}) + err = UpdateASTHostTags(cfg.Hosts[1], []string{"web", "prod"}) assert.NoError(t, err) - tags := ExtractTagsFromNodes(cfg.Hosts[0].Nodes) + tags := ExtractTagsFromNodes(cfg.Hosts[1].Nodes) assert.Equal(t, []string{"web", "prod"}, tags) }) @@ -88,10 +87,10 @@ func TestUpdateASTHostTags(t *testing.T) { cfg, err := ssh_config.Decode(strings.NewReader(content)) assert.NoError(t, err) - err = UpdateASTHostTags(cfg.Hosts[0], []string{"new1", "new2"}) + err = UpdateASTHostTags(cfg.Hosts[1], []string{"new1", "new2"}) assert.NoError(t, err) - tags := ExtractTagsFromNodes(cfg.Hosts[0].Nodes) + tags := ExtractTagsFromNodes(cfg.Hosts[1].Nodes) assert.Equal(t, []string{"new1", "new2"}, tags) }) @@ -100,10 +99,10 @@ func TestUpdateASTHostTags(t *testing.T) { cfg, err := ssh_config.Decode(strings.NewReader(content)) assert.NoError(t, err) - err = UpdateASTHostTags(cfg.Hosts[0], nil) + err = UpdateASTHostTags(cfg.Hosts[1], nil) assert.NoError(t, err) - tags := ExtractTagsFromNodes(cfg.Hosts[0].Nodes) + tags := ExtractTagsFromNodes(cfg.Hosts[1].Nodes) assert.Empty(t, tags) }) @@ -118,10 +117,10 @@ func TestUpdateASTHostTags(t *testing.T) { cfg, err := ssh_config.Decode(strings.NewReader(content)) assert.NoError(t, err) - err = UpdateASTHostTags(cfg.Hosts[0], []string{"merged1", "merged2"}) + err = UpdateASTHostTags(cfg.Hosts[1], []string{"merged1", "merged2"}) assert.NoError(t, err) - tags := ExtractTagsFromNodes(cfg.Hosts[0].Nodes) + tags := ExtractTagsFromNodes(cfg.Hosts[1].Nodes) assert.Equal(t, []string{"merged1", "merged2"}, tags) }) } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index ec5791a..8ac5b50 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -282,9 +282,9 @@ Host tagged-server t.Run("tag category tabs discovered and active tag tab filters hosts", func(t *testing.T) { m.SearchInput.SetValue("") assert.Contains(t, m.Tabs, "#production") - assert.Contains(t, m.Tabs, "#database") + assert.Contains(t, m.Tabs, "#aws") - m.ActiveTab = "#database" + m.ActiveTab = "#production" m.FilterHosts() assert.Len(t, m.Filtered, 1) assert.Equal(t, "tagged-server", m.Filtered[0].Alias)