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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 33 additions & 0 deletions cmd/claws/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -133,6 +148,8 @@ type cliOptions struct {
configFile string
service string
resourceID string
filter string
tag string
theme string
compactHeader *bool
}
Expand Down Expand Up @@ -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++
Expand Down Expand Up @@ -245,6 +272,10 @@ func printUsage() {
fmt.Println(" Supports aliases: cfn, sg, logs, ddb, etc.")
fmt.Println(" -i, --resource-id <id>")
fmt.Println(" Open detail view for a specific resource (requires --service)")
fmt.Println(" -f, --filter <text>")
fmt.Println(" Apply a fuzzy filter on startup (like pressing `/`, requires --service)")
fmt.Println(" --tag <key>[=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.")
Expand Down Expand Up @@ -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()
Expand Down
63 changes: 63 additions & 0 deletions cmd/claws/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
18 changes: 18 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -133,17 +135,33 @@ 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
if a.startupPath.ResourceType != "" {
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 {
Expand Down
16 changes: 16 additions & 0 deletions internal/config/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 })
}
Expand Down
17 changes: 14 additions & 3 deletions internal/view/resource_browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/view/resource_browser_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions internal/view/resource_browser_nav.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 `/<filter>` 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 <filter>` command).
func (r *ResourceBrowser) SetInitialTagFilter(tag string) {
r.tagFilterText = tag
}

func (r *ResourceBrowser) getNavigationShortcuts() string {
if r.renderer == nil || len(r.filtered) == 0 {
return ""
Expand Down
Loading