Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
6 changes: 6 additions & 0 deletions internal/config/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down
1 change: 1 addition & 0 deletions internal/config/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
3 changes: 3 additions & 0 deletions internal/config/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
137 changes: 137 additions & 0 deletions internal/config/tags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package config

import (
"fmt"
"slices"
"strings"
"unicode"

"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:") {
_, after, _ := strings.Cut(trimmed, ":")
content := after
parts := strings.FieldsFunc(content, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
})
raw = append(raw, parts...)
} else {
fields := strings.FieldsSeq(trimmed)
for field := range fields {
if strings.HasPrefix(field, "#") {
raw = append(raw, field)
}
}
}

var tags []string
for _, r := range raw {
clean := cleanTag(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
// 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")
}

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 {
if len(ExtractTagsFromComment(empty.String())) > 0 {
if firstTagIdx == -1 {
firstTagIdx = i
}
continue
}
}
filteredNodes = append(filteredNodes, node)
}

if len(sanitized) == 0 {
astHost.Nodes = filteredNodes
return nil
}

newNode, err := createTagCommentNode(sanitized)
if err != nil {
return err
}

if firstTagIdx != -1 && firstTagIdx <= len(filteredNodes) {
filteredNodes = slices.Insert(filteredNodes, firstTagIdx, newNode)
} else {
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))
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
}
126 changes: 126 additions & 0 deletions internal/config/tags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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)

// 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[2].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[1], []string{"web", "prod"})
assert.NoError(t, err)

tags := ExtractTagsFromNodes(cfg.Hosts[1].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[1], []string{"new1", "new2"})
assert.NoError(t, err)

tags := ExtractTagsFromNodes(cfg.Hosts[1].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[1], nil)
assert.NoError(t, err)

tags := ExtractTagsFromNodes(cfg.Hosts[1].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[1], []string{"merged1", "merged2"})
assert.NoError(t, err)

tags := ExtractTagsFromNodes(cfg.Hosts[1].Nodes)
assert.Equal(t, []string{"merged1", "merged2"}, tags)
})
}
19 changes: 19 additions & 0 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"strings"

"tusshi/internal/config"
"tusshi/internal/tui/commands"
"tusshi/internal/tui/components"
"tusshi/internal/tui/theme"
Expand All @@ -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
Expand All @@ -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] <tags...>)"},
{Shortcut: untagCmd, Description: "Remove tags from connection (:untag [alias] <tags...>)"},
{Shortcut: pingCmd, Description: "Ping selected connection"},
{Shortcut: pingAllCmd, Description: "Ping all connections"},
{Shortcut: addConfigCmd, Description: "Add a new config file"},
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading