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: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ claws -p myprofile
# With specific region
claws -r us-west-2

# Start directly on a service
# Start directly on a service or view
claws -s dashboard # Start with dashboard
claws -s services # Start with service browser (default)
claws -s ec2 # EC2 instances
claws -s rds/snapshots # RDS snapshots

Expand Down
36 changes: 9 additions & 27 deletions cmd/claws/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ func printUsage() {
fmt.Println(" AWS region(s) to use (comma-separated or repeated)")
fmt.Println(" -s, --service <service>[/<resource>]")
fmt.Println(" Start directly on a service/resource (e.g., ec2, rds/snapshots, cfn)")
fmt.Println(" Special views: dashboard, services")
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)")
Expand All @@ -230,6 +231,9 @@ func printUsage() {
fmt.Println(" Show this help message")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" claws Start with service browser (default)")
fmt.Println(" claws -s dashboard Start with dashboard")
fmt.Println(" claws -s services Start with service browser")
fmt.Println(" claws -s ec2 Open EC2 instances browser")
fmt.Println(" claws -s rds/snapshots Open RDS snapshots browser")
fmt.Println(" claws -s cfn Open CloudFormation stacks (alias)")
Expand Down Expand Up @@ -270,36 +274,14 @@ func applyStartupConfig(opts cliOptions, fileCfg *config.FileConfig, cfg *config

// resolveStartupService validates and resolves a service string (e.g., "ec2", "rds/snapshots", "cfn")
// to a valid service/resourceType pair. Supports aliases and service/resource syntax.
// Special views "dashboard" and "services" are returned as-is.
func resolveStartupService(input string) (service, resourceType string, err error) {
parts := strings.SplitN(input, "/", 2)
service = parts[0]
if len(parts) > 1 {
resourceType = parts[1]
// Special views: dashboard and services
if input == "dashboard" || input == "services" {
return input, "", nil
}

if strings.Contains(resourceType, "/") {
return "", "", fmt.Errorf("invalid resource type: %s", resourceType)
}

if resolved, resolvedRes, ok := registry.Global.ResolveAlias(service); ok {
service = resolved
if resolvedRes != "" && resourceType == "" {
resourceType = resolvedRes
}
}

if resourceType == "" {
resourceType = registry.Global.DefaultResource(service)
if resourceType == "" {
return "", "", fmt.Errorf("unknown service: %s", input)
}
}

if _, ok := registry.Global.Get(service, resourceType); !ok {
return "", "", fmt.Errorf("unknown resource: %s/%s", service, resourceType)
}

return service, resourceType, nil
return registry.Global.ParseServiceResource(input)
}

// propagateAllProxy copies ALL_PROXY to HTTP_PROXY/HTTPS_PROXY if not set.
Expand Down
4 changes: 4 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,16 @@ autosave:
enabled: true # Save region/profile/theme on change (default: false)

startup: # Applied on launch if present
view: services # Startup view: "dashboard", "services", or "service/resource" (e.g., "ec2", "rds/snapshots")
profiles: # Multiple profiles supported
- production
regions:
- us-east-1
- us-west-2

navigation:
max_stack_size: 100 # Max navigation history depth (default: 100)

theme: nord # Preset: dark, light, nord, dracula, gruvbox, catppuccin

# Or use preset with custom overrides:
Expand Down
6 changes: 4 additions & 2 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ Complete reference for all keyboard shortcuts in claws.
| Key | Action |
|-----|--------|
| `:` | Command mode (e.g., `:ec2/instances`) |
| `:` + `Enter` | Go to dashboard (home) |
| `~` | Go to dashboard (from service browser) |
| `:` + `Enter` | Go to services |
| `~` | Toggle Dashboard ↔ Services |
| `:pulse` | Go to dashboard |
| `:services` | Go to service browser |
| `/` | Filter mode (fuzzy search) |
| `?` | Show help |
Expand Down Expand Up @@ -61,6 +62,7 @@ Complete reference for all keyboard shortcuts in claws.
| `:diff <n1> <n2>` | Compare two named resources |
| `:theme <name>` | Change color theme |
| `:autosave on/off` | Enable/disable config autosave |
| `:clear-history` | Clear navigation history (stack) |

## Mouse Support

Expand Down
49 changes: 43 additions & 6 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,16 @@ func (a *App) Init() tea.Cmd {
a.awsInitializing = true

if a.startupPath != nil {
a.currentView = view.NewResourceBrowserWithType(
a.ctx, a.registry,
a.startupPath.Service, a.startupPath.ResourceType,
)
// 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)
} else {
a.currentView = view.NewDashboardView(a.ctx, a.registry)
// Check config startup.view
startupView := config.File().GetStartupView()
a.currentView = a.resolveStartupView(startupView)
}

initAWSCmd := func() tea.Msg {
Expand Down Expand Up @@ -335,6 +339,11 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case view.NavigateMsg:
return a.handleNavigate(msg)

case view.ClearHistoryMsg:
log.Debug("clearing navigation history", "stackDepth", len(a.viewStack))
a.viewStack = nil
return a, nil

case view.ErrorMsg:
log.Error("application error", "error", msg.Err)
a.err = msg.Err
Expand Down Expand Up @@ -523,7 +532,7 @@ func (a *App) View() tea.View {
if contentHeight < 1 {
contentHeight = 1
}
paddedContent := lipgloss.NewStyle().Height(contentHeight).Render(content)
paddedContent := ui.NoStyle().Height(contentHeight).Render(content)
mainView := paddedContent + "\n" + status

if a.modal != nil {
Expand Down Expand Up @@ -667,11 +676,19 @@ func (a *App) navigateBack() tea.Cmd {

// pushOrClearStack either clears the view stack (for home navigation) or
// pushes the current view onto the stack (for drill-down navigation).
// Enforces max stack size from config.
func (a *App) pushOrClearStack(clearStack bool) {
if clearStack {
a.viewStack = nil
} else if a.currentView != nil {
a.viewStack = append(a.viewStack, a.currentView)

// Enforce max stack size
maxSize := config.File().MaxStackSize()
if len(a.viewStack) > maxSize {
// Remove oldest entries
a.viewStack = a.viewStack[len(a.viewStack)-maxSize:]
}
}
}

Expand Down Expand Up @@ -817,3 +834,23 @@ func (k keyMap) FullHelp() [][]key.Binding {
{k.Filter, k.Command, k.Help, k.Quit},
}
}

// resolveStartupView resolves the startup view from config.
// Returns ServiceBrowser by default if config is empty or invalid.
func (a *App) resolveStartupView(viewName string) view.View {
switch viewName {
case "dashboard":
return view.NewDashboardView(a.ctx, a.registry)
case "services", "":
// Default to ServiceBrowser
return view.NewServiceBrowser(a.ctx, a.registry)
default:
// Try to parse as AWS service/resource (e.g., "ec2", "rds/snapshots")
service, resourceType, err := a.registry.ParseServiceResource(viewName)
if err != nil {
// Fallback to ServiceBrowser on error
return view.NewServiceBrowser(a.ctx, a.registry)
}
return view.NewResourceBrowserWithType(a.ctx, a.registry, service, resourceType)
}
}
31 changes: 31 additions & 0 deletions internal/config/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const (
DefaultLogFetchTimeout = 10 * time.Second
DefaultMetricsWindow = 15 * time.Minute
DefaultMaxConcurrentFetches = 50
DefaultMaxStackSize = 100
)

func ConfigDir() (string, error) {
Expand Down Expand Up @@ -59,6 +60,7 @@ type PersistenceConfig struct {
}

type StartupConfig struct {
View string `yaml:"view,omitempty"` // "dashboard", "services", or "service/resource" (e.g., "ec2", "rds/snapshots")
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
Expand All @@ -76,6 +78,11 @@ func (s StartupConfig) GetProfiles() []string {
return nil
}

// NavigationConfig controls navigation behavior.
type NavigationConfig struct {
MaxStackSize int `yaml:"max_stack_size,omitempty"`
}

// ThemeConfig holds theme configuration.
// Can be specified as:
// - A preset name string: "dark", "light", "nord", "dracula", "gruvbox", "catppuccin"
Expand Down Expand Up @@ -126,6 +133,7 @@ type FileConfig struct {
Autosave PersistenceConfig `yaml:"autosave,omitempty"`
Startup StartupConfig `yaml:"startup,omitempty"`
Theme ThemeConfig `yaml:"theme,omitempty"`
Navigation NavigationConfig `yaml:"navigation,omitempty"`
}

// Duration wraps time.Duration for YAML marshal/unmarshal as string (e.g., "5s", "30s")
Expand Down Expand Up @@ -171,6 +179,9 @@ func DefaultFileConfig() *FileConfig {
CloudWatch: CloudWatchConfig{
Window: Duration(DefaultMetricsWindow),
},
Navigation: NavigationConfig{
MaxStackSize: DefaultMaxStackSize,
},
}
}

Expand Down Expand Up @@ -235,6 +246,9 @@ func (c *FileConfig) applyDefaults() {
if c.Concurrency.MaxFetches <= 0 {
c.Concurrency.MaxFetches = DefaultMaxConcurrentFetches
}
if c.Navigation.MaxStackSize <= 0 {
c.Navigation.MaxStackSize = DefaultMaxStackSize
}
}

func (c *FileConfig) AWSInitTimeout() time.Duration {
Expand Down Expand Up @@ -300,6 +314,16 @@ func (c *FileConfig) MetricsWindow() time.Duration {
})
}

// MaxStackSize returns the maximum navigation stack size.
func (c *FileConfig) MaxStackSize() int {
return withRLock(&c.mu, func() int {
if c.Navigation.MaxStackSize <= 0 {
return DefaultMaxStackSize
}
return c.Navigation.MaxStackSize
})
}

func (c *FileConfig) PersistenceEnabled() bool {
return withRLock(&c.mu, func() bool {
if c.persistenceOverride != nil {
Expand Down Expand Up @@ -327,6 +351,13 @@ func (c *FileConfig) GetStartup() ([]string, []string) {
return r.regions, r.profiles
}

// GetStartupView returns the configured startup view ("dashboard", "services", or service/resource).
func (c *FileConfig) GetStartupView() string {
return withRLock(&c.mu, func() string {
return c.Startup.View
})
}

func (c *FileConfig) GetTheme() ThemeConfig {
return withRLock(&c.mu, func() ThemeConfig { return c.Theme })
}
Expand Down
38 changes: 38 additions & 0 deletions internal/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,44 @@ func (r *Registry) ResolveAlias(input string) (string, string, bool) {
return input, "", false
}

// ParseServiceResource parses a service/resource string (e.g., "ec2", "rds/snapshots", "cfn")
// and resolves it to a valid service/resourceType pair.
// Returns (service, resourceType, error)
func (r *Registry) ParseServiceResource(input string) (service, resourceType string, err error) {
parts := strings.SplitN(input, "/", 2)
service = parts[0]
if len(parts) > 1 {
resourceType = parts[1]
}

if strings.Contains(resourceType, "/") {
return "", "", fmt.Errorf("invalid resource type: %s", resourceType)
}

// Try alias resolution
if resolved, resolvedRes, ok := r.ResolveAlias(service); ok {
service = resolved
if resolvedRes != "" && resourceType == "" {
resourceType = resolvedRes
}
}

// Get default resource if not specified
if resourceType == "" {
resourceType = r.DefaultResource(service)
if resourceType == "" {
return "", "", fmt.Errorf("unknown service: %s", input)
}
}

// Validate service/resource exists
if _, ok := r.Get(service, resourceType); !ok {
return "", "", fmt.Errorf("unknown resource: %s/%s", service, resourceType)
}

return service, resourceType, nil
}

// GetAliasesForService returns all aliases for a given service.
func (r *Registry) GetAliasesForService(service string) []string {
r.serviceAliasesOnce.Do(func() {
Expand Down
Loading