From 908d12343726a109e21f00a38a94a80364990965 Mon Sep 17 00:00:00 2001 From: Mykola Shestopal Date: Fri, 29 May 2026 16:49:39 +0300 Subject: [PATCH] feat: add --filter and --tag flags to launch into a pre-filtered list Add CLI flags (and matching startup config keys) to apply a fuzzy filter and/or tag selector at startup, mirroring the in-TUI `/` and `:tag` commands so common workflows can be wrapped in shell aliases. - -f/--filter : fuzzy filter, equivalent to pressing `/` - --tag [=value]: tag filter, equivalent to `:tag` (supports key=value, key, key~partial); single condition, matching `:tag` - Both require --service and take precedence over the new startup.filter / startup.tag config keys - Show an indicator on the resource list status line for the active fuzzy and/or tag filter, so the effect of --tag (and `:tag`) is visible - Clear the tag filter too when pressing `c`, so it clears all filters consistently - Document the flags, config keys, and updated `c` binding Closes #190 --- README.md | 4 + cmd/claws/main.go | 33 +++++ cmd/claws/main_test.go | 63 ++++++++++ docs/configuration.md | 2 + docs/keybindings.md | 2 +- internal/app/app.go | 18 +++ internal/config/file.go | 16 +++ internal/view/resource_browser.go | 17 ++- internal/view/resource_browser_input.go | 1 + internal/view/resource_browser_nav.go | 14 +++ internal/view/resource_browser_test.go | 154 ++++++++++++++++++++++++ 11 files changed, 320 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1218a7f6..d4aa2a31 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,10 @@ claws -s services # Start with service browser (default) claws -s ec2 # EC2 instances claws -s rds/snapshots # RDS snapshots +# Launch into a pre-filtered list (requires -s) +claws -s ec2 -f bastion # Fuzzy filter (like pressing `/`) +claws -s ec2 --tag Role=bastion # Tag filter (like `:tag`) + # Multiple profiles/regions (comma-separated or repeated) claws -p dev,prod -r us-east-1,ap-northeast-1 diff --git a/cmd/claws/main.go b/cmd/claws/main.go index a0d46f1d..bfdd3760 100644 --- a/cmd/claws/main.go +++ b/cmd/claws/main.go @@ -88,15 +88,30 @@ func main() { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } + // Startup filters: CLI flags take precedence over config file. + startupFilter := opts.filter + if startupFilter == "" { + startupFilter = fileCfg.GetStartupFilter() + } + startupTag := opts.tag + if startupTag == "" { + startupTag = fileCfg.GetStartupTag() + } startupPath = &app.StartupPath{ Service: service, ResourceType: resourceType, ResourceID: strings.TrimSpace(opts.resourceID), + Filter: startupFilter, + Tag: startupTag, } } else if opts.resourceID != "" { fmt.Fprintln(os.Stderr, "Error: --resource-id requires --service") fmt.Fprintln(os.Stderr, "Example: claws -s ec2 -i i-1234567890abcdef0") os.Exit(1) + } else if opts.filter != "" || opts.tag != "" { + fmt.Fprintln(os.Stderr, "Error: --filter and --tag require --service") + fmt.Fprintln(os.Stderr, "Example: claws -s ec2 --filter bastion") + os.Exit(1) } // Enable logging if log file specified @@ -133,6 +148,8 @@ type cliOptions struct { configFile string service string resourceID string + filter string + tag string theme string compactHeader *bool } @@ -198,6 +215,16 @@ func parseFlagsFromArgs(args []string) cliOptions { i++ opts.resourceID = args[i] } + case "-f", "--filter": + if i+1 < len(args) { + i++ + opts.filter = strings.TrimSpace(args[i]) + } + case "--tag": + if i+1 < len(args) { + i++ + opts.tag = strings.TrimSpace(args[i]) + } case "-t", "--theme": if i+1 < len(args) { i++ @@ -245,6 +272,10 @@ func printUsage() { fmt.Println(" Supports aliases: cfn, sg, logs, ddb, etc.") fmt.Println(" -i, --resource-id ") fmt.Println(" Open detail view for a specific resource (requires --service)") + fmt.Println(" -f, --filter ") + fmt.Println(" Apply a fuzzy filter on startup (like pressing `/`, requires --service)") + fmt.Println(" --tag [=value]") + fmt.Println(" Apply a tag filter on startup (like `:tag`, e.g. Role=bastion, requires --service)") fmt.Println(" -e, --env") fmt.Println(" Use environment credentials (ignore ~/.aws config)") fmt.Println(" Useful for instance profiles, ECS task roles, Lambda, etc.") @@ -277,6 +308,8 @@ func printUsage() { fmt.Println(" claws -s rds/snapshots Open RDS snapshots browser") fmt.Println(" claws -s cfn Open CloudFormation stacks (alias)") fmt.Println(" claws -s ec2 -i i-12345 Open detail view for instance i-12345") + fmt.Println(" claws -s ec2 -f bastion Open EC2 instances pre-filtered by 'bastion'") + fmt.Println(" claws -s ec2 --tag Role=bastion Open EC2 instances filtered by tag Role=bastion") fmt.Println(" claws -p dev,prod Query multiple profiles") fmt.Println(" claws -r us-east-1,ap-northeast-1 Query multiple regions") fmt.Println() diff --git a/cmd/claws/main_test.go b/cmd/claws/main_test.go index 7da7ad69..684bea9e 100644 --- a/cmd/claws/main_test.go +++ b/cmd/claws/main_test.go @@ -168,6 +168,69 @@ func TestParseFlags_EnvCreds(t *testing.T) { } } +func TestParseFlags_Filter(t *testing.T) { + tests := []struct { + name string + args []string + expected string + }{ + {"short flag", []string{"-f", "bastion"}, "bastion"}, + {"long flag", []string{"--filter", "bastion"}, "bastion"}, + {"with service", []string{"-s", "ec2", "-f", "bastion"}, "bastion"}, + {"whitespace trimmed", []string{"-f", " bastion "}, "bastion"}, + {"no filter", []string{"-s", "ec2"}, ""}, + {"missing value", []string{"-f"}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := parseFlagsFromArgs(tt.args) + if opts.filter != tt.expected { + t.Errorf("filter = %q, want %q", opts.filter, tt.expected) + } + }) + } +} + +func TestParseFlags_Tag(t *testing.T) { + tests := []struct { + name string + args []string + expected string + }{ + {"key=value", []string{"--tag", "Role=bastion"}, "Role=bastion"}, + {"key only", []string{"--tag", "Role"}, "Role"}, + {"partial match", []string{"--tag", "Name~web"}, "Name~web"}, + {"with service", []string{"-s", "ec2", "--tag", "Env=prod"}, "Env=prod"}, + {"whitespace trimmed", []string{"--tag", " Env=prod "}, "Env=prod"}, + {"no tag", []string{"-s", "ec2"}, ""}, + {"missing value", []string{"--tag"}, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := parseFlagsFromArgs(tt.args) + if opts.tag != tt.expected { + t.Errorf("tag = %q, want %q", opts.tag, tt.expected) + } + }) + } +} + +func TestParseFlags_FilterAndTagCombined(t *testing.T) { + opts := parseFlagsFromArgs([]string{"-s", "ec2", "-f", "bastion", "--tag", "Role=bastion"}) + + if opts.service != "ec2" { + t.Errorf("service = %q, want %q", opts.service, "ec2") + } + if opts.filter != "bastion" { + t.Errorf("filter = %q, want %q", opts.filter, "bastion") + } + if opts.tag != "Role=bastion" { + t.Errorf("tag = %q, want %q", opts.tag, "Role=bastion") + } +} + func TestApplyStartupConfig_ProfilePrecedence(t *testing.T) { tests := []struct { name string diff --git a/docs/configuration.md b/docs/configuration.md index 449537ff..ee810548 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -55,6 +55,8 @@ compact_header: false # Use single-line compact header (default: false) startup: # Applied on launch if present view: services # Startup view: "dashboard", "services", or "service/resource" (e.g., "ec2", "rds/snapshots") + filter: bastion # Fuzzy filter applied on launch (like pressing `/`); CLI -f/--filter overrides + tag: Role=bastion # Tag filter applied on launch (like `:tag`); CLI --tag overrides profiles: # Multiple profiles supported - production regions: diff --git a/docs/keybindings.md b/docs/keybindings.md index fa20c657..041bd5fd 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -35,7 +35,7 @@ Complete reference for all keyboard shortcuts in claws. | `a` | Open actions menu | | `m` | Mark resource for comparison | | `d` | Describe (or diff if marked) | -| `c` | Clear filter and mark | +| `c` | Clear filters (fuzzy + tag) and mark | | `N` | Load next page (pagination) | | `M` | Toggle inline metrics (EC2, RDS, Lambda) | | `y` | Copy resource ID to clipboard | diff --git a/internal/app/app.go b/internal/app/app.go index e7051dca..d22dcca7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -33,6 +33,8 @@ type StartupPath struct { Service string ResourceType string ResourceID string + Filter string // Fuzzy filter to apply on the startup resource list (equivalent to `/`) + Tag string // Tag filter to apply on the startup resource list (equivalent to `:tag`) } const flashDuration = 2 * time.Second @@ -133,6 +135,7 @@ func New(ctx context.Context, reg *registry.Registry, startupPath *StartupPath) func (a *App) Init() tea.Cmd { a.awsInitializing = true + var startupFilter, startupTag string if a.startupPath != nil { // CLI `-s` option takes precedence viewName := a.startupPath.Service @@ -140,10 +143,25 @@ func (a *App) Init() tea.Cmd { viewName = fmt.Sprintf("%s/%s", a.startupPath.Service, a.startupPath.ResourceType) } a.currentView = a.resolveStartupView(viewName) + startupFilter = a.startupPath.Filter + startupTag = a.startupPath.Tag } else { // Check config startup.view startupView := config.File().GetStartupView() a.currentView = a.resolveStartupView(startupView) + startupFilter = config.File().GetStartupFilter() + startupTag = config.File().GetStartupTag() + } + + // Seed startup filters so the resource list opens pre-filtered. Only applies + // to the resource browser; ignored for dashboard/service-browser views. + if rb, ok := a.currentView.(*view.ResourceBrowser); ok { + if startupFilter != "" { + rb.SetInitialFilter(startupFilter) + } + if startupTag != "" { + rb.SetInitialTagFilter(startupTag) + } } initAWSCmd := func() tea.Msg { diff --git a/internal/config/file.go b/internal/config/file.go index 22c3c3cb..4d674094 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -127,6 +127,8 @@ type StartupConfig struct { Regions []string `yaml:"regions,omitempty"` Profile string `yaml:"profile,omitempty"` // Deprecated: for backward compat (read-only) Profiles []string `yaml:"profiles,omitempty"` // New format: multiple profile IDs + Filter string `yaml:"filter,omitempty"` // Fuzzy filter applied at startup (equivalent to `/` command) + Tag string `yaml:"tag,omitempty"` // Tag filter applied at startup (equivalent to `:tag` command, e.g. "Env=prod") } // GetProfiles returns profile IDs (new format preferred, fallback to old). @@ -447,6 +449,20 @@ func (c *FileConfig) GetStartupView() string { }) } +// GetStartupFilter returns the configured startup fuzzy filter (equivalent to the `/` command). +func (c *FileConfig) GetStartupFilter() string { + return withRLock(&c.mu, func() string { + return c.Startup.Filter + }) +} + +// GetStartupTag returns the configured startup tag filter (equivalent to the `:tag` command). +func (c *FileConfig) GetStartupTag() string { + return withRLock(&c.mu, func() string { + return c.Startup.Tag + }) +} + func (c *FileConfig) GetTheme() ThemeConfig { return withRLock(&c.mu, func() ThemeConfig { return c.Theme }) } diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index 58a7ffc5..1a72e807 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -305,12 +305,23 @@ func (r *ResourceBrowser) ViewString() string { tabsView := r.renderTabs() + r.styles.count.Render(countText) - // Filter view (use cached styles) + // Filter view (use cached styles). Shows the active fuzzy filter and/or + // tag filter so the user can see why the list is narrowed (e.g. when set + // via the --filter/--tag flags or the `:tag` command). var filterView string if r.filterActive { filterView = r.styles.filterBg.Render(r.filterInput.View()) + "\n" - } else if r.filterText != "" { - filterView = r.styles.filterActive.Render(fmt.Sprintf("filter: %s", r.filterText)) + "\n" + } else { + var indicators []string + if r.filterText != "" { + indicators = append(indicators, fmt.Sprintf("filter: %s", r.filterText)) + } + if r.tagFilterText != "" { + indicators = append(indicators, fmt.Sprintf("tag: %s", r.tagFilterText)) + } + if len(indicators) > 0 { + filterView = r.styles.filterActive.Render(strings.Join(indicators, " · ")) + "\n" + } } // Handle empty states diff --git a/internal/view/resource_browser_input.go b/internal/view/resource_browser_input.go index d9a85da9..600c6e08 100644 --- a/internal/view/resource_browser_input.go +++ b/internal/view/resource_browser_input.go @@ -132,6 +132,7 @@ func (r *ResourceBrowser) handleClearFilter() (tea.Model, tea.Cmd) { r.filterInput.SetValue("") r.fieldFilter = "" r.fieldFilterValue = "" + r.tagFilterText = "" r.markedResource = nil r.loading = true r.err = nil diff --git a/internal/view/resource_browser_nav.go b/internal/view/resource_browser_nav.go index 345b9c08..55936fbc 100644 --- a/internal/view/resource_browser_nav.go +++ b/internal/view/resource_browser_nav.go @@ -187,6 +187,20 @@ func (r *ResourceBrowser) ResourceCount() int { return len(r.filtered func (r *ResourceBrowser) FilterText() string { return r.filterText } func (r *ResourceBrowser) ToggleStates() map[string]bool { return r.toggleStates } +// SetInitialFilter seeds the fuzzy filter before the first load so the list +// opens pre-filtered (equivalent to typing `/` after launch). The +// filter input is kept in sync so the user can edit or clear it normally. +func (r *ResourceBrowser) SetInitialFilter(filter string) { + r.filterText = filter + r.filterInput.SetValue(filter) +} + +// SetInitialTagFilter seeds the tag filter before the first load so the list +// opens pre-filtered (equivalent to the `:tag ` command). +func (r *ResourceBrowser) SetInitialTagFilter(tag string) { + r.tagFilterText = tag +} + func (r *ResourceBrowser) getNavigationShortcuts() string { if r.renderer == nil || len(r.filtered) == 0 { return "" diff --git a/internal/view/resource_browser_test.go b/internal/view/resource_browser_test.go index c1128c34..d2ebb981 100644 --- a/internal/view/resource_browser_test.go +++ b/internal/view/resource_browser_test.go @@ -145,6 +145,160 @@ func TestResourceBrowserTagFilter(t *testing.T) { } } +func TestResourceBrowserSetInitialFilter(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + browser := NewResourceBrowser(ctx, reg, "ec2") + browser.SetInitialFilter("bastion") + + if browser.FilterText() != "bastion" { + t.Errorf("FilterText() = %q, want %q", browser.FilterText(), "bastion") + } + if browser.filterInput.Value() != "bastion" { + t.Errorf("filterInput.Value() = %q, want %q", browser.filterInput.Value(), "bastion") + } + + browser.resources = []dao.Resource{ + &mockResource{id: "i-1", name: "prod-bastion"}, + &mockResource{id: "i-2", name: "web-server"}, + &mockResource{id: "i-3", name: "dev-bastion"}, + } + browser.applyFilter() + + if len(browser.filtered) != 2 { + t.Fatalf("got %d resources, want 2", len(browser.filtered)) + } + for _, want := range []string{"i-1", "i-3"} { + found := false + for _, res := range browser.filtered { + if res.GetID() == want { + found = true + } + } + if !found { + t.Errorf("expected %q in filtered results", want) + } + } +} + +func TestResourceBrowserSetInitialTagFilter(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + browser := NewResourceBrowser(ctx, reg, "ec2") + browser.SetInitialTagFilter("Role=bastion") + + browser.resources = []dao.Resource{ + &mockResource{id: "i-1", name: "host-1", tags: map[string]string{"Role": "bastion"}}, + &mockResource{id: "i-2", name: "host-2", tags: map[string]string{"Role": "web"}}, + &mockResource{id: "i-3", name: "host-3", tags: map[string]string{"Role": "bastion"}}, + } + browser.applyFilter() + + if len(browser.filtered) != 2 { + t.Fatalf("got %d resources, want 2", len(browser.filtered)) + } + for i, want := range []string{"i-1", "i-3"} { + if browser.filtered[i].GetID() != want { + t.Errorf("filtered[%d].GetID() = %q, want %q", i, browser.filtered[i].GetID(), want) + } + } +} + +func TestResourceBrowserFilterIndicators(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + tests := []struct { + name string + filterText string + tagFilter string + wantContain []string + wantAbsent []string + }{ + { + name: "no filters shows nothing", + wantAbsent: []string{"filter:", "tag:"}, + }, + { + name: "fuzzy filter only", + filterText: "web", + wantContain: []string{"filter: web"}, + wantAbsent: []string{"tag:"}, + }, + { + name: "tag filter only", + tagFilter: "Role=bastion", + wantContain: []string{"tag: Role=bastion"}, + wantAbsent: []string{"filter:"}, + }, + { + name: "both filters", + filterText: "web", + tagFilter: "Env=prod", + wantContain: []string{"filter: web", "tag: Env=prod", "·"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + browser := NewResourceBrowser(ctx, reg, "ec2") + browser.SetSize(100, 50) + browser.loading = false + browser.resources = []dao.Resource{ + &mockResource{id: "i-1", name: "web-prod", tags: map[string]string{"Role": "bastion", "Env": "prod"}}, + } + browser.filterText = tt.filterText + browser.tagFilterText = tt.tagFilter + browser.applyFilter() + browser.buildTable() + + out := browser.ViewString() + for _, want := range tt.wantContain { + if !strings.Contains(out, want) { + t.Errorf("view should contain %q, got:\n%s", want, out) + } + } + for _, absent := range tt.wantAbsent { + if strings.Contains(out, absent) { + t.Errorf("view should not contain %q, got:\n%s", absent, out) + } + } + }) + } +} + +func TestResourceBrowserClearFilterClearsAll(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + browser := NewResourceBrowser(ctx, reg, "ec2") + browser.filterText = "web" + browser.filterInput.SetValue("web") + browser.tagFilterText = "Role=bastion" + browser.fieldFilter = "VpcId" + browser.fieldFilterValue = "vpc-123" + + browser.handleClearFilter() + + if browser.filterText != "" { + t.Errorf("filterText = %q, want empty", browser.filterText) + } + if browser.filterInput.Value() != "" { + t.Errorf("filterInput.Value() = %q, want empty", browser.filterInput.Value()) + } + if browser.tagFilterText != "" { + t.Errorf("tagFilterText = %q, want empty", browser.tagFilterText) + } + if browser.fieldFilter != "" { + t.Errorf("fieldFilter = %q, want empty", browser.fieldFilter) + } + if browser.fieldFilterValue != "" { + t.Errorf("fieldFilterValue = %q, want empty", browser.fieldFilterValue) + } +} + func TestResourceBrowserMouseHover(t *testing.T) { ctx := context.Background() reg := registry.New()