diff --git a/README.md b/README.md index 4374dbc..6e184a5 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,8 @@ Selected profiles are queried in parallel; resources display with Profile and Ac | `:tag ` | Filter by tag | | `:diff ` | Compare current row with named resource | | `:diff ` | Compare two named resources | +| `:theme ` | Change color theme (saved if persistence enabled) | +| `:autosave on/off` | Enable/disable config autosave (always saved) | **Login Details:** - `:login` runs `aws login --remote` using `claws-login` profile @@ -413,19 +415,39 @@ concurrency: cloudwatch: window: 15m # Metrics data window period (default: 15m) -persistence: - enabled: true # Save region/profile on change (default: false) +autosave: + enabled: true # Save region/profile/theme on change (default: false) startup: # Applied on launch if present - profile: production + profiles: # Multiple profiles supported + - production regions: - us-east-1 - us-west-2 + +theme: nord # Preset: dark, light, nord, dracula, gruvbox, catppuccin + +# Or use preset with custom overrides: +# theme: +# preset: dracula +# primary: "#ff79c6" +# danger: "#ff5555" ``` +**Available Theme Presets:** + +| Preset | Description | +|--------|-------------| +| `dark` | Default dark theme (pink/magenta accents) | +| `light` | For light-background terminals | +| `nord` | Nordic, calm blue palette | +| `dracula` | Popular dark theme (purple/pink) | +| `gruvbox` | Retro, warm earth tones | +| `catppuccin` | Modern pastel (Mocha variant) | + The config file is **not created automatically**. Create it manually if needed. -CLI flags (`-p`, `-r`, `--persist`, `--no-persist`) override config file settings. +CLI flags (`-p`, `-r`, `-t`, `--autosave`, `--no-autosave`) override config file settings. For required IAM permissions, see [docs/iam-permissions.md](docs/iam-permissions.md). diff --git a/cmd/claws/main.go b/cmd/claws/main.go index e4874a0..c5733eb 100644 --- a/cmd/claws/main.go +++ b/cmd/claws/main.go @@ -14,6 +14,7 @@ import ( "github.com/clawscli/claws/internal/config" "github.com/clawscli/claws/internal/log" "github.com/clawscli/claws/internal/registry" + "github.com/clawscli/claws/internal/ui" ) // version is set by ldflags during build @@ -27,9 +28,8 @@ func main() { fileCfg := config.File() cfg := config.Global() - // CLI persistence flags override config file - if opts.persist != nil { - fileCfg.SetPersistenceEnabled(*opts.persist) + if opts.autosave != nil { + fileCfg.SetPersistenceEnabled(*opts.autosave) } // Check environment variables (CLI flags take precedence) @@ -53,6 +53,8 @@ func main() { applyStartupConfig(opts, fileCfg, cfg) + ui.ApplyConfigWithOverride(fileCfg.GetTheme(), opts.theme) + // Validate and resolve startup service/resource var startupPath *app.StartupPath if opts.service != "" { @@ -101,10 +103,11 @@ type cliOptions struct { region string readOnly bool envCreds bool - persist *bool // nil = use config, true = enable, false = disable + autosave *bool logFile string - service string // startup service (e.g., "ec2", "rds/snapshots", "cfn") - resourceID string // startup resource ID for direct DetailView navigation + service string + resourceID string + theme string } // parseFlags parses command line flags and returns options @@ -130,12 +133,12 @@ func parseFlags() cliOptions { opts.readOnly = true case "-e", "--env": opts.envCreds = true - case "--persist": + case "--autosave": t := true - opts.persist = &t - case "--no-persist": + opts.autosave = &t + case "--no-autosave": f := false - opts.persist = &f + opts.autosave = &f case "-l", "--log-file": if i+1 < len(args) { i++ @@ -151,6 +154,11 @@ func parseFlags() cliOptions { i++ opts.resourceID = args[i] } + case "-t", "--theme": + if i+1 < len(args) { + i++ + opts.theme = args[i] + } case "-h", "--help": showHelp = true case "-v", "--version": @@ -191,12 +199,14 @@ func printUsage() { fmt.Println(" Useful for instance profiles, ECS task roles, Lambda, etc.") fmt.Println(" -ro, --read-only") fmt.Println(" Run in read-only mode (disable dangerous actions)") - fmt.Println(" --persist") - fmt.Println(" Enable saving region/profile selection to config file") - fmt.Println(" --no-persist") - fmt.Println(" Disable saving region/profile selection to config file") + fmt.Println(" --autosave") + fmt.Println(" Enable saving region/profile/theme to config file") + fmt.Println(" --no-autosave") + fmt.Println(" Disable saving region/profile/theme to config file") fmt.Println(" -l, --log-file ") fmt.Println(" Enable debug logging to specified file") + fmt.Println(" -t, --theme ") + fmt.Println(" Color theme: dark, light, nord, dracula, gruvbox, catppuccin") fmt.Println(" -v, --version") fmt.Println(" Show version") fmt.Println(" -h, --help") @@ -213,23 +223,21 @@ func printUsage() { fmt.Println(" ALL_PROXY Propagated to HTTP_PROXY/HTTPS_PROXY if not set") } -// applyStartupConfig applies profile/region config with precedence: -// 1. CLI flags (-p, -r, -e) - highest priority -// 2. Config file startup section -// 3. AWS SDK defaults func applyStartupConfig(opts cliOptions, fileCfg *config.FileConfig, cfg *config.Config) { - startupRegions, startupProfile := fileCfg.GetStartup() + startupRegions, startupProfiles := fileCfg.GetStartup() - // Apply profile: CLI > startup config if opts.envCreds { cfg.UseEnvOnly() } else if opts.profile != "" { cfg.UseProfile(opts.profile) - } else if startupProfile != "" { - cfg.UseProfile(startupProfile) + } else if len(startupProfiles) > 0 { + sels := make([]config.ProfileSelection, len(startupProfiles)) + for i, id := range startupProfiles { + sels[i] = config.ProfileSelectionFromID(id) + } + cfg.SetSelections(sels) } - // Apply region: CLI > startup config if opts.region != "" { cfg.SetRegion(opts.region) } else if len(startupRegions) > 0 { diff --git a/custom/cloudformation/events/render.go b/custom/cloudformation/events/render.go index 379b813..2b4dc6b 100644 --- a/custom/cloudformation/events/render.go +++ b/custom/cloudformation/events/render.go @@ -149,6 +149,6 @@ func cfnResourceStatusColorer(status string) render.Style { case strings.Contains(status, "DELETE_COMPLETE") || strings.Contains(status, "SKIPPED"): return ui.DimStyle() default: - return render.DefaultStyle() + return ui.NoStyle() } } diff --git a/custom/cloudformation/resources/render.go b/custom/cloudformation/resources/render.go index 3874efa..a530528 100644 --- a/custom/cloudformation/resources/render.go +++ b/custom/cloudformation/resources/render.go @@ -157,7 +157,7 @@ func cfnResourceStatusColorer(status string) render.Style { case strings.Contains(status, "DELETE_COMPLETE") || strings.Contains(status, "SKIPPED"): return ui.DimStyle() default: - return render.DefaultStyle() + return ui.NoStyle() } } @@ -171,7 +171,7 @@ func driftColorer(status string) render.Style { case "NOT_CHECKED": return ui.DimStyle() default: - return render.DefaultStyle() + return ui.NoStyle() } } diff --git a/custom/cloudformation/stacks/render.go b/custom/cloudformation/stacks/render.go index 2305346..370665a 100644 --- a/custom/cloudformation/stacks/render.go +++ b/custom/cloudformation/stacks/render.go @@ -253,7 +253,7 @@ func cfnStateColorer(status string) render.Style { case strings.Contains(status, "DELETE_COMPLETE"): return ui.DimStyle() default: - return render.DefaultStyle() + return ui.NoStyle() } } @@ -267,7 +267,7 @@ func driftColorer(status string) render.Style { case "NOT_CHECKED": return ui.DimStyle() default: - return render.DefaultStyle() + return ui.NoStyle() } } diff --git a/docs/architecture.md b/docs/architecture.md index 550356b..4b52533 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -348,7 +348,12 @@ Some views (Help, Region Selector, Profile Selector, Action Menu) display as mod Application configuration is stored in `~/.config/claws/config.yaml`: ```yaml -profile: my-aws-profile +startup: + profiles: + - my-aws-profile + regions: + - us-east-1 +theme: nord ``` AWS credentials and config are read from standard locations: diff --git a/internal/app/app.go b/internal/app/app.go index 3de4650..6b6c457 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,6 +2,7 @@ package app import ( "context" + "fmt" "strings" "time" @@ -210,6 +211,54 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil + case view.ThemeChangedMsg: + a.styles = newAppStyles(a.width) + a.modalRenderer.ReloadStyles() + a.commandInput.ReloadStyles() + if a.currentView != nil { + a.currentView.Update(msg) + } + for _, v := range a.viewStack { + v.Update(msg) + } + return a, nil + + case view.ThemeChangeMsg: + theme := ui.GetPreset(msg.Name) + if theme == nil { + a.err = fmt.Errorf("unknown theme: %s (available: %v)", msg.Name, ui.AvailableThemes()) + return a, nil + } + ui.SetTheme(theme) + a.clipboardFlash = "Theme: " + msg.Name + a.clipboardWarning = false + if config.File().PersistenceEnabled() { + if err := config.File().SaveTheme(msg.Name); err != nil { + log.Warn("failed to persist theme", "error", err) + a.clipboardFlash = "Theme: " + msg.Name + " (save failed)" + a.clipboardWarning = true + } + } + return a, tea.Batch( + func() tea.Msg { return view.ThemeChangedMsg{} }, + tea.Tick(flashDuration, func(t time.Time) tea.Msg { return clearFlashMsg{} }), + ) + + case view.PersistenceChangeMsg: + if err := config.File().SavePersistence(msg.Enabled); err != nil { + a.err = fmt.Errorf("failed to save autosave setting: %w", err) + return a, nil + } + if msg.Enabled { + a.clipboardFlash = "Autosave enabled" + } else { + a.clipboardFlash = "Autosave disabled" + } + a.clipboardWarning = false + return a, tea.Tick(flashDuration, func(t time.Time) tea.Msg { + return clearFlashMsg{} + }) + case tea.MouseClickMsg: if msg.Button == tea.MouseBackward { if cmd := a.navigateBack(); cmd != nil { @@ -345,6 +394,11 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.region != "" { config.Global().AddRegion(msg.region) } + if config.File().PersistenceEnabled() { + if err := config.File().SaveRegions(config.Global().Regions()); err != nil { + log.Warn("failed to persist regions", "error", err) + } + } if len(msg.accountIDs) > 0 { for profileID, accountID := range msg.accountIDs { config.Global().SetAccountIDForProfile(profileID, accountID) @@ -430,41 +484,47 @@ func (a *App) View() tea.View { content = a.currentView.ViewString() } - if a.commandMode { - cmdView := a.commandInput.View() - return newAltScreenView(content + "\n" + cmdView) - } - var statusContent string - if a.err != nil { - statusContent = ui.DangerStyle().Render("Error: " + a.err.Error()) - } else if a.clipboardFlash != "" { - if a.clipboardWarning { - statusContent = ui.WarningStyle().Render("⚠ " + a.clipboardFlash) - } else { - statusContent = ui.SuccessStyle().Render("✓ " + a.clipboardFlash) + if a.commandMode { + statusContent = a.commandInput.View() + " • Esc:cancel Enter:run Tab:complete" + } else { + if a.err != nil { + statusContent = ui.DangerStyle().Render("Error: " + a.err.Error()) + } else if a.clipboardFlash != "" { + if a.clipboardWarning { + statusContent = ui.WarningStyle().Render("⚠ " + a.clipboardFlash) + } else { + statusContent = ui.SuccessStyle().Render("✓ " + a.clipboardFlash) + } + } else if a.currentView != nil { + statusContent = a.currentView.StatusLine() } - } else if a.currentView != nil { - statusContent = a.currentView.StatusLine() - } - if config.Global().ReadOnly() { - roIndicator := a.styles.readOnly.Render("READ-ONLY") - statusContent = roIndicator + " " + statusContent - } + if config.Global().ReadOnly() { + roIndicator := a.styles.readOnly.Render("READ-ONLY") + statusContent = roIndicator + " " + statusContent + } - if a.awsInitializing { - statusContent = ui.DimStyle().Render("AWS initializing...") + " • " + statusContent - } + if a.awsInitializing { + statusContent = ui.DimStyle().Render("AWS initializing...") + " • " + statusContent + } - if a.profileRefreshError != nil { - statusContent = ui.WarningStyle().Render("⚠ Profile error") + " • " + statusContent - } else if a.profileRefreshing { - statusContent = ui.DimStyle().Render("Refreshing profile...") + " • " + statusContent + if a.profileRefreshError != nil { + statusContent = ui.WarningStyle().Render("⚠ Profile error") + " • " + statusContent + } else if a.profileRefreshing { + statusContent = ui.DimStyle().Render("Refreshing profile...") + " • " + statusContent + } } status := a.styles.status.Render(statusContent) - mainView := content + "\n" + status + + // Fix content height to keep status line at bottom regardless of content size. + contentHeight := a.height - 1 + if contentHeight < 1 { + contentHeight = 1 + } + paddedContent := lipgloss.NewStyle().Height(contentHeight).Render(content) + mainView := paddedContent + "\n" + status if a.modal != nil { return newAltScreenView(a.modalRenderer.Render(a.modal, mainView, a.width, a.height)) @@ -632,10 +692,8 @@ func (a *App) fetchStartupResource() tea.Msg { func (a *App) handleRegionChanged(msg navmsg.RegionChangedMsg) (tea.Model, tea.Cmd) { log.Info("regions changed", "regions", msg.Regions) if config.File().PersistenceEnabled() { - _, existingProfile := config.File().GetStartup() - config.File().SetStartup(msg.Regions, existingProfile) - if err := config.File().Save(); err != nil { - log.Warn("failed to persist config", "error", err) + if err := config.File().SaveRegions(msg.Regions); err != nil { + log.Warn("failed to persist regions", "error", err) } } return a.refreshCurrentView() @@ -644,14 +702,12 @@ func (a *App) handleRegionChanged(msg navmsg.RegionChangedMsg) (tea.Model, tea.C func (a *App) handleProfilesChanged(msg navmsg.ProfilesChangedMsg) (tea.Model, tea.Cmd) { log.Info("profiles changed", "count", len(msg.Selections)) if config.File().PersistenceEnabled() { - profile := "" - if len(msg.Selections) > 0 && msg.Selections[0].IsNamedProfile() { - profile = msg.Selections[0].ProfileName - } - existingRegions := config.Global().Regions() - config.File().SetStartup(existingRegions, profile) - if err := config.File().Save(); err != nil { - log.Warn("failed to persist config", "error", err) + profileIDs := make([]string, len(msg.Selections)) + for i, sel := range msg.Selections { + profileIDs[i] = sel.ID() + } + if err := config.File().SaveProfiles(profileIDs); err != nil { + log.Warn("failed to persist profiles", "error", err) } } a.profileRefreshID++ diff --git a/internal/aws/init.go b/internal/aws/init.go index 4b2259f..b7cee03 100644 --- a/internal/aws/init.go +++ b/internal/aws/init.go @@ -9,26 +9,28 @@ import ( appconfig "github.com/clawscli/claws/internal/config" ) -// InitContext initializes AWS context by loading config and fetching account ID. -// Updates the global config with region (if not already set) and account ID. func InitContext(ctx context.Context) error { - sel := appconfig.Global().Selection() + selections := appconfig.Global().Selections() - cfg, err := config.LoadDefaultConfig(ctx, SelectionLoadOptions(sel)...) - if err != nil { - return err + if len(selections) == 1 { + cfg, err := config.LoadDefaultConfig(ctx, SelectionLoadOptions(selections[0])...) + if err != nil { + return err + } + if appconfig.Global().Region() == "" { + appconfig.Global().SetRegion(cfg.Region) + } + accountID := FetchAccountID(ctx, cfg) + appconfig.Global().SetAccountID(accountID) + return nil } - // Set region if not already set - if appconfig.Global().Region() == "" { - appconfig.Global().SetRegion(cfg.Region) + region, accountIDs, err := RefreshContextData(ctx) + if region != "" && appconfig.Global().Region() == "" { + appconfig.Global().SetRegion(region) } - - // Fetch and set account ID - accountID := FetchAccountID(ctx, cfg) - appconfig.Global().SetAccountID(accountID) - - return nil + appconfig.Global().SetAccountIDs(accountIDs) + return err } // RefreshContextData re-fetches region and account ID for the current profile selection(s). diff --git a/internal/config/file.go b/internal/config/file.go index 62b9876..977b320 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -1,6 +1,7 @@ package config import ( + "bytes" "errors" "fmt" "os" @@ -58,18 +59,73 @@ type PersistenceConfig struct { } type StartupConfig struct { - Regions []string `yaml:"regions,omitempty"` - Profile string `yaml:"profile,omitempty"` + 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 +} + +// GetProfiles returns profile IDs (new format preferred, fallback to old). +// Returns a copy to prevent race conditions with concurrent writes. +func (s StartupConfig) GetProfiles() []string { + if len(s.Profiles) > 0 { + return append([]string(nil), s.Profiles...) + } + if s.Profile != "" { + return []string{s.Profile} + } + return nil +} + +// ThemeConfig holds theme configuration. +// Can be specified as: +// - A preset name string: "dark", "light", "nord", "dracula", "gruvbox", "catppuccin" +// - An object with optional preset and color overrides +type ThemeConfig struct { + Preset string `yaml:"preset,omitempty"` + Primary string `yaml:"primary,omitempty"` + Secondary string `yaml:"secondary,omitempty"` + Accent string `yaml:"accent,omitempty"` + Text string `yaml:"text,omitempty"` + TextBright string `yaml:"text_bright,omitempty"` + TextDim string `yaml:"text_dim,omitempty"` + TextMuted string `yaml:"text_muted,omitempty"` + Success string `yaml:"success,omitempty"` + Warning string `yaml:"warning,omitempty"` + Danger string `yaml:"danger,omitempty"` + Info string `yaml:"info,omitempty"` + Pending string `yaml:"pending,omitempty"` + Border string `yaml:"border,omitempty"` + BorderHighlight string `yaml:"border_highlight,omitempty"` + Background string `yaml:"background,omitempty"` + BackgroundAlt string `yaml:"background_alt,omitempty"` + Selection string `yaml:"selection,omitempty"` + SelectionText string `yaml:"selection_text,omitempty"` + TableHeader string `yaml:"table_header,omitempty"` + TableHeaderText string `yaml:"table_header_text,omitempty"` + TableBorder string `yaml:"table_border,omitempty"` + BadgeForeground string `yaml:"badge_foreground,omitempty"` + BadgeBackground string `yaml:"badge_background,omitempty"` +} + +func (t *ThemeConfig) UnmarshalYAML(node *yaml.Node) error { + if node.Kind == yaml.ScalarNode { + t.Preset = node.Value + return nil + } + + type rawThemeConfig ThemeConfig + return node.Decode((*rawThemeConfig)(t)) } type FileConfig struct { mu sync.RWMutex `yaml:"-"` - persistenceOverride *bool `yaml:"-"` // CLI flag override (not persisted) + persistenceOverride *bool `yaml:"-"` Timeouts TimeoutConfig `yaml:"timeouts,omitempty"` Concurrency ConcurrencyConfig `yaml:"concurrency,omitempty"` CloudWatch CloudWatchConfig `yaml:"cloudwatch,omitempty"` - Persistence PersistenceConfig `yaml:"persistence"` + Autosave PersistenceConfig `yaml:"autosave,omitempty"` Startup StartupConfig `yaml:"startup,omitempty"` + Theme ThemeConfig `yaml:"theme,omitempty"` } // Duration wraps time.Duration for YAML marshal/unmarshal as string (e.g., "5s", "30s") @@ -115,9 +171,6 @@ func DefaultFileConfig() *FileConfig { CloudWatch: CloudWatchConfig{ Window: Duration(DefaultMetricsWindow), }, - Persistence: PersistenceConfig{ - Enabled: false, - }, } } @@ -160,60 +213,6 @@ func Load() (*FileConfig, error) { return cfg, nil } -func (c *FileConfig) Save() error { - path, err := ConfigPath() - if err != nil { - return err - } - - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("create config dir: %w", err) - } - - snapshot := withRLock(&c.mu, func() FileConfig { - return FileConfig{ - Timeouts: c.Timeouts, - Concurrency: c.Concurrency, - CloudWatch: c.CloudWatch, - Persistence: c.Persistence, - Startup: StartupConfig{ - Regions: append([]string(nil), c.Startup.Regions...), - Profile: c.Startup.Profile, - }, - } - }) - - data, err := yaml.Marshal(&snapshot) - if err != nil { - return fmt.Errorf("marshal config: %w", err) - } - - // Atomic write: write to temp file, then rename - tmpFile, err := os.CreateTemp(dir, ".config.yaml.tmp.*") - if err != nil { - return fmt.Errorf("create temp file: %w", err) - } - tmpPath := tmpFile.Name() - - if _, err := tmpFile.Write(data); err != nil { - _ = tmpFile.Close() - _ = os.Remove(tmpPath) - return fmt.Errorf("write temp file: %w", err) - } - if err := tmpFile.Close(); err != nil { - _ = os.Remove(tmpPath) - return fmt.Errorf("close temp file: %w", err) - } - - if err := os.Rename(tmpPath, path); err != nil { - _ = os.Remove(tmpPath) - return fmt.Errorf("rename config file: %w", err) - } - - return nil -} - func (c *FileConfig) applyDefaults() { if c.Timeouts.AWSInit <= 0 { c.Timeouts.AWSInit = Duration(DefaultAWSInitTimeout) @@ -306,7 +305,7 @@ func (c *FileConfig) PersistenceEnabled() bool { if c.persistenceOverride != nil { return *c.persistenceOverride } - return c.Persistence.Enabled + return c.Autosave.Enabled }) } @@ -314,20 +313,248 @@ func (c *FileConfig) SetPersistenceEnabled(enabled bool) { doWithLock(&c.mu, func() { c.persistenceOverride = &enabled }) } -func (c *FileConfig) SetStartup(regions []string, profile string) { - doWithLock(&c.mu, func() { - c.Startup.Regions = regions - c.Startup.Profile = profile +func (c *FileConfig) GetStartup() ([]string, []string) { + type result struct { + regions []string + profiles []string + } + r := withRLock(&c.mu, func() result { + return result{ + append([]string(nil), c.Startup.Regions...), + c.Startup.GetProfiles(), + } }) + return r.regions, r.profiles } -func (c *FileConfig) GetStartup() ([]string, string) { - type result struct { - regions []string - profile string +func (c *FileConfig) GetTheme() ThemeConfig { + return withRLock(&c.mu, func() ThemeConfig { return c.Theme }) +} + +func (c *FileConfig) SaveRegions(regions []string) error { + if len(regions) == 0 { + return nil } - r := withRLock(&c.mu, func() result { - return result{append([]string(nil), c.Startup.Regions...), c.Startup.Profile} + + c.mu.Lock() + defer c.mu.Unlock() + + c.Startup.Regions = append([]string(nil), regions...) + + return c.patchConfigLocked(func(mapping *yaml.Node) { + startupNode := findOrCreateMappingKey(mapping, "startup") + ensureMappingNode(startupNode) + setSequenceValue(startupNode, "regions", regions) }) - return r.regions, r.profile +} + +func (c *FileConfig) SaveProfiles(profiles []string) error { + c.mu.Lock() + defer c.mu.Unlock() + + c.Startup.Profiles = append([]string(nil), profiles...) + c.Startup.Profile = "" + + return c.patchConfigLocked(func(mapping *yaml.Node) { + startupNode := findOrCreateMappingKey(mapping, "startup") + ensureMappingNode(startupNode) + setSequenceValue(startupNode, "profiles", profiles) + removeKey(startupNode, "profile") + }) +} + +func (c *FileConfig) SaveTheme(name string) error { + c.mu.Lock() + defer c.mu.Unlock() + + c.Theme.Preset = name + + return c.patchConfigLocked(func(mapping *yaml.Node) { + setScalarValue(mapping, "theme", name) + }) +} + +func (c *FileConfig) SavePersistence(enabled bool) error { + c.mu.Lock() + defer c.mu.Unlock() + + c.Autosave.Enabled = enabled + c.persistenceOverride = nil + + return c.patchConfigLocked(func(mapping *yaml.Node) { + autosaveNode := findOrCreateMappingKey(mapping, "autosave") + ensureMappingNode(autosaveNode) + setBoolValue(autosaveNode, "enabled", enabled) + }) +} + +func (c *FileConfig) patchConfigLocked(patchFn func(mapping *yaml.Node)) error { + path, err := ConfigPath() + if err != nil { + return err + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + data = []byte("{}") + } else { + return fmt.Errorf("read config: %w", err) + } + } + + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return fmt.Errorf("parse config: %w", err) + } + + if root.Kind != yaml.DocumentNode || len(root.Content) == 0 { + root = yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.MappingNode}}} + } + mapping := root.Content[0] + if mapping.Kind != yaml.MappingNode { + mapping = &yaml.Node{Kind: yaml.MappingNode} + root.Content[0] = mapping + } + + patchFn(mapping) + + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(&root); err != nil { + return fmt.Errorf("encode config: %w", err) + } + if err := enc.Close(); err != nil { + return fmt.Errorf("close encoder: %w", err) + } + + return atomicWrite(path, buf.Bytes()) +} + +func ensureMappingNode(node *yaml.Node) { + if node.Kind != yaml.MappingNode { + node.Kind = yaml.MappingNode + node.Content = nil + } +} + +func findOrCreateMappingKey(mapping *yaml.Node, key string) *yaml.Node { + for i := 0; i < len(mapping.Content)-1; i += 2 { + if mapping.Content[i].Value == key { + return mapping.Content[i+1] + } + } + + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key} + valueNode := &yaml.Node{Kind: yaml.MappingNode} + mapping.Content = append(mapping.Content, keyNode, valueNode) + return valueNode +} + +func setSequenceValue(mapping *yaml.Node, key string, values []string) { + var seqNode *yaml.Node + for i := 0; i < len(mapping.Content)-1; i += 2 { + if mapping.Content[i].Value == key { + seqNode = mapping.Content[i+1] + break + } + } + + if len(values) == 0 { + removeKey(mapping, key) + return + } + + if seqNode == nil { + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key} + seqNode = &yaml.Node{Kind: yaml.SequenceNode} + mapping.Content = append(mapping.Content, keyNode, seqNode) + } + + seqNode.Kind = yaml.SequenceNode + seqNode.Content = make([]*yaml.Node, len(values)) + for i, v := range values { + seqNode.Content[i] = &yaml.Node{Kind: yaml.ScalarNode, Value: v} + } +} + +func setScalarValue(mapping *yaml.Node, key string, value string) { + if value == "" { + removeKey(mapping, key) + return + } + + for i := 0; i < len(mapping.Content)-1; i += 2 { + if mapping.Content[i].Value == key { + mapping.Content[i+1].Kind = yaml.ScalarNode + mapping.Content[i+1].Value = value + mapping.Content[i+1].Content = nil + return + } + } + + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key} + valueNode := &yaml.Node{Kind: yaml.ScalarNode, Value: value} + mapping.Content = append(mapping.Content, keyNode, valueNode) +} + +func setBoolValue(mapping *yaml.Node, key string, value bool) { + strVal := "false" + if value { + strVal = "true" + } + + for i := 0; i < len(mapping.Content)-1; i += 2 { + if mapping.Content[i].Value == key { + mapping.Content[i+1].Kind = yaml.ScalarNode + mapping.Content[i+1].Tag = "!!bool" + mapping.Content[i+1].Value = strVal + mapping.Content[i+1].Content = nil + return + } + } + + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key} + valueNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: strVal} + mapping.Content = append(mapping.Content, keyNode, valueNode) +} + +func removeKey(mapping *yaml.Node, key string) { + for i := 0; i < len(mapping.Content)-1; i += 2 { + if mapping.Content[i].Value == key { + mapping.Content = append(mapping.Content[:i], mapping.Content[i+2:]...) + return + } + } +} + +func atomicWrite(path string, data []byte) error { + dir := filepath.Dir(path) + tmpFile, err := os.CreateTemp(dir, ".config.yaml.tmp.*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmpFile.Name() + + if _, err := tmpFile.Write(data); err != nil { + _ = tmpFile.Close() + _ = os.Remove(tmpPath) + return fmt.Errorf("write temp file: %w", err) + } + if err := tmpFile.Close(); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("close temp file: %w", err) + } + + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("rename config file: %w", err) + } + return nil } diff --git a/internal/config/file_test.go b/internal/config/file_test.go index 8a0043d..9a28f41 100644 --- a/internal/config/file_test.go +++ b/internal/config/file_test.go @@ -82,8 +82,8 @@ func TestDefaultFileConfig(t *testing.T) { if cfg.Concurrency.MaxFetches != DefaultMaxConcurrentFetches { t.Errorf("MaxFetches = %d, want %d", cfg.Concurrency.MaxFetches, DefaultMaxConcurrentFetches) } - if cfg.Persistence.Enabled { - t.Error("Persistence.Enabled should be false by default") + if cfg.Autosave.Enabled { + t.Error("Autosave.Enabled should be false by default") } } @@ -111,68 +111,30 @@ func TestLoad_Save_Roundtrip(t *testing.T) { defer os.Setenv("HOME", origHome) os.Setenv("HOME", tmpDir) - // Create config with custom values - cfg := &FileConfig{ - Timeouts: TimeoutConfig{ - AWSInit: Duration(10 * time.Second), - MultiRegionFetch: Duration(60 * time.Second), - TagSearch: Duration(45 * time.Second), - MetricsLoad: Duration(20 * time.Second), - }, - Concurrency: ConcurrencyConfig{ - MaxFetches: 100, - }, - Persistence: PersistenceConfig{ - Enabled: true, - }, - Startup: StartupConfig{ - Regions: []string{"us-east-1", "us-west-2"}, - Profile: "production", - }, + cfg := &FileConfig{} + if err := cfg.SaveRegions([]string{"us-east-1", "us-west-2"}); err != nil { + t.Fatalf("SaveRegions failed: %v", err) } - - // Save - if err := cfg.Save(); err != nil { - t.Fatalf("Save failed: %v", err) + if err := cfg.SaveProfiles([]string{"production"}); err != nil { + t.Fatalf("SaveProfiles failed: %v", err) } - // Verify file exists configPath := filepath.Join(tmpDir, ".config", "claws", "config.yaml") if _, err := os.Stat(configPath); os.IsNotExist(err) { t.Fatal("config file was not created") } - // Load and verify loaded, err := Load() if err != nil { t.Fatalf("Load failed: %v", err) } - if loaded.AWSInitTimeout() != 10*time.Second { - t.Errorf("AWSInitTimeout() = %v, want %v", loaded.AWSInitTimeout(), 10*time.Second) - } - if loaded.MultiRegionFetchTimeout() != 60*time.Second { - t.Errorf("MultiRegionFetchTimeout() = %v, want %v", loaded.MultiRegionFetchTimeout(), 60*time.Second) - } - if loaded.TagSearchTimeout() != 45*time.Second { - t.Errorf("TagSearchTimeout() = %v, want %v", loaded.TagSearchTimeout(), 45*time.Second) - } - if loaded.MetricsLoadTimeout() != 20*time.Second { - t.Errorf("MetricsLoadTimeout() = %v, want %v", loaded.MetricsLoadTimeout(), 20*time.Second) - } - if loaded.MaxConcurrentFetches() != 100 { - t.Errorf("MaxConcurrentFetches() = %d, want %d", loaded.MaxConcurrentFetches(), 100) - } - if !loaded.PersistenceEnabled() { - t.Error("PersistenceEnabled() should be true") - } - - regions, profile := loaded.GetStartup() + regions, profiles := loaded.GetStartup() if len(regions) != 2 || regions[0] != "us-east-1" || regions[1] != "us-west-2" { t.Errorf("GetStartup() regions = %v, want [us-east-1, us-west-2]", regions) } - if profile != "production" { - t.Errorf("GetStartup() profile = %q, want %q", profile, "production") + if len(profiles) != 1 || profiles[0] != "production" { + t.Errorf("GetStartup() profiles = %v, want [production]", profiles) } } @@ -211,17 +173,26 @@ func TestFileConfig_ApplyDefaults_NegativeValues(t *testing.T) { } } -func TestFileConfig_SetStartup(t *testing.T) { - cfg := &FileConfig{} +func TestFileConfig_SaveRegionsProfiles(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + defer os.Setenv("HOME", origHome) + os.Setenv("HOME", tmpDir) - cfg.SetStartup([]string{"eu-west-1"}, "dev") + cfg := &FileConfig{} + if err := cfg.SaveRegions([]string{"eu-west-1"}); err != nil { + t.Fatalf("SaveRegions failed: %v", err) + } + if err := cfg.SaveProfiles([]string{"dev"}); err != nil { + t.Fatalf("SaveProfiles failed: %v", err) + } - regions, profile := cfg.GetStartup() + regions, profiles := cfg.GetStartup() if len(regions) != 1 || regions[0] != "eu-west-1" { t.Errorf("GetStartup() regions = %v, want [eu-west-1]", regions) } - if profile != "dev" { - t.Errorf("GetStartup() profile = %q, want %q", profile, "dev") + if len(profiles) != 1 || profiles[0] != "dev" { + t.Errorf("GetStartup() profiles = %v, want [dev]", profiles) } } @@ -252,20 +223,17 @@ func TestLoad_PartialConfig(t *testing.T) { defer os.Setenv("HOME", origHome) os.Setenv("HOME", tmpDir) - // Create config dir configDir := filepath.Join(tmpDir, ".config", "claws") if err := os.MkdirAll(configDir, 0755); err != nil { t.Fatalf("MkdirAll failed: %v", err) } - // Write partial config (only timeouts.aws_init) configPath := filepath.Join(configDir, "config.yaml") data := []byte("timeouts:\n aws_init: 15s\n") if err := os.WriteFile(configPath, data, 0644); err != nil { t.Fatalf("WriteFile failed: %v", err) } - // Load should fill in defaults for missing values cfg, err := Load() if err != nil { t.Fatalf("Load failed: %v", err) @@ -274,7 +242,6 @@ func TestLoad_PartialConfig(t *testing.T) { if cfg.AWSInitTimeout() != 15*time.Second { t.Errorf("AWSInitTimeout() = %v, want %v", cfg.AWSInitTimeout(), 15*time.Second) } - // Other values should be defaults if cfg.MultiRegionFetchTimeout() != DefaultMultiRegionFetchTimeout { t.Errorf("MultiRegionFetchTimeout() = %v, want %v", cfg.MultiRegionFetchTimeout(), DefaultMultiRegionFetchTimeout) } @@ -282,3 +249,433 @@ func TestLoad_PartialConfig(t *testing.T) { t.Errorf("MaxConcurrentFetches() = %d, want %d", cfg.MaxConcurrentFetches(), DefaultMaxConcurrentFetches) } } + +func TestThemeConfig_UnmarshalString(t *testing.T) { + var cfg ThemeConfig + if err := yaml.Unmarshal([]byte(`"nord"`), &cfg); err != nil { + t.Fatalf("Unmarshal string failed: %v", err) + } + if cfg.Preset != "nord" { + t.Errorf("Preset = %q, want %q", cfg.Preset, "nord") + } + if cfg.Primary != "" { + t.Errorf("Primary should be empty, got %q", cfg.Primary) + } +} + +func TestThemeConfig_UnmarshalObject(t *testing.T) { + yamlData := ` +preset: dracula +primary: "#ff0000" +success: "#00ff00" +` + var cfg ThemeConfig + if err := yaml.Unmarshal([]byte(yamlData), &cfg); err != nil { + t.Fatalf("Unmarshal object failed: %v", err) + } + if cfg.Preset != "dracula" { + t.Errorf("Preset = %q, want %q", cfg.Preset, "dracula") + } + if cfg.Primary != "#ff0000" { + t.Errorf("Primary = %q, want %q", cfg.Primary, "#ff0000") + } + if cfg.Success != "#00ff00" { + t.Errorf("Success = %q, want %q", cfg.Success, "#00ff00") + } +} + +func TestThemeConfig_UnmarshalEmpty(t *testing.T) { + var cfg ThemeConfig + if err := yaml.Unmarshal([]byte(`{}`), &cfg); err != nil { + t.Fatalf("Unmarshal empty failed: %v", err) + } + if cfg.Preset != "" { + t.Errorf("Preset should be empty, got %q", cfg.Preset) + } +} + +func TestSave_PreservesExistingConfig(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + defer os.Setenv("HOME", origHome) + os.Setenv("HOME", tmpDir) + + configDir := filepath.Join(tmpDir, ".config", "claws") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + + existing := `theme: nord +timeouts: + aws_init: 10s +custom_key: custom_value +` + configPath := filepath.Join(configDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(existing), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + cfg := &FileConfig{} + if err := cfg.SaveRegions([]string{"us-west-2"}); err != nil { + t.Fatalf("SaveRegions failed: %v", err) + } + if err := cfg.SaveProfiles([]string{"dev", "prod"}); err != nil { + t.Fatalf("SaveProfiles failed: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + content := string(data) + + if !contains(content, "theme: nord") { + t.Error("theme: nord was not preserved") + } + if !contains(content, "aws_init: 10s") { + t.Error("timeouts.aws_init was not preserved") + } + if !contains(content, "custom_key: custom_value") { + t.Error("custom_key was not preserved") + } + if !contains(content, "us-west-2") { + t.Error("region us-west-2 was not saved") + } + if !contains(content, "dev") || !contains(content, "prod") { + t.Error("profiles were not saved") + } +} + +func TestSave_NewFile(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + defer os.Setenv("HOME", origHome) + os.Setenv("HOME", tmpDir) + + cfg := &FileConfig{} + if err := cfg.SaveRegions([]string{"eu-west-1"}); err != nil { + t.Fatalf("SaveRegions failed: %v", err) + } + if err := cfg.SaveProfiles([]string{"production"}); err != nil { + t.Fatalf("SaveProfiles failed: %v", err) + } + + configPath := filepath.Join(tmpDir, ".config", "claws", "config.yaml") + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + content := string(data) + + if !contains(content, "eu-west-1") { + t.Error("region was not saved") + } + if !contains(content, "production") { + t.Error("profile was not saved") + } + if contains(content, "theme") { + t.Error("theme should not be in new minimal file") + } +} + +func TestSave_MultipleProfiles(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + defer os.Setenv("HOME", origHome) + os.Setenv("HOME", tmpDir) + + cfg := &FileConfig{} + profiles := []string{"dev", "prod", "__env_only__", "__sdk_default__"} + if err := cfg.SaveRegions([]string{"us-east-1", "us-west-2"}); err != nil { + t.Fatalf("SaveRegions failed: %v", err) + } + if err := cfg.SaveProfiles(profiles); err != nil { + t.Fatalf("SaveProfiles failed: %v", err) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + regions, loadedProfiles := loaded.GetStartup() + if len(regions) != 2 { + t.Errorf("regions = %v, want 2 regions", regions) + } + if len(loadedProfiles) != 4 { + t.Errorf("profiles = %v, want 4 profiles", loadedProfiles) + } + for i, want := range profiles { + if loadedProfiles[i] != want { + t.Errorf("profile[%d] = %q, want %q", i, loadedProfiles[i], want) + } + } +} + +func TestSave_BackwardCompatProfile(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + defer os.Setenv("HOME", origHome) + os.Setenv("HOME", tmpDir) + + configDir := filepath.Join(tmpDir, ".config", "claws") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + + existing := `startup: + profile: legacy-profile + regions: + - us-east-1 +` + configPath := filepath.Join(configDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(existing), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + _, profiles := loaded.GetStartup() + if len(profiles) != 1 || profiles[0] != "legacy-profile" { + t.Errorf("profiles = %v, want [legacy-profile]", profiles) + } +} + +func TestSave_PreservesComments(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + defer os.Setenv("HOME", origHome) + os.Setenv("HOME", tmpDir) + + configDir := filepath.Join(tmpDir, ".config", "claws") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + + existing := `# This is a comment on theme +theme: nord +# This is a comment on timeouts +timeouts: + aws_init: 10s +` + configPath := filepath.Join(configDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(existing), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + cfg := &FileConfig{} + if err := cfg.SaveRegions([]string{"us-west-2"}); err != nil { + t.Fatalf("SaveRegions failed: %v", err) + } + if err := cfg.SaveProfiles([]string{"dev"}); err != nil { + t.Fatalf("SaveProfiles failed: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + content := string(data) + + if !contains(content, "# This is a comment on theme") { + t.Error("comment on theme was not preserved") + } + if !contains(content, "# This is a comment on timeouts") { + t.Error("comment on timeouts was not preserved") + } +} + +func TestSaveTheme(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + defer os.Setenv("HOME", origHome) + os.Setenv("HOME", tmpDir) + + configDir := filepath.Join(tmpDir, ".config", "claws") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + + existing := `timeouts: + aws_init: 10s +startup: + regions: + - us-east-1 +` + configPath := filepath.Join(configDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(existing), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + cfg := &FileConfig{} + if err := cfg.SaveTheme("nord"); err != nil { + t.Fatalf("SaveTheme failed: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + content := string(data) + + if !contains(content, "theme: nord") { + t.Error("theme: nord was not saved") + } + if !contains(content, "aws_init: 10s") { + t.Error("timeouts.aws_init was not preserved") + } + if !contains(content, "us-east-1") { + t.Error("startup.regions was not preserved") + } + + if cfg.GetTheme().Preset != "nord" { + t.Errorf("GetTheme().Preset = %q, want %q", cfg.GetTheme().Preset, "nord") + } +} + +func TestSavePersistence(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + defer os.Setenv("HOME", origHome) + os.Setenv("HOME", tmpDir) + + configDir := filepath.Join(tmpDir, ".config", "claws") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + + existing := `theme: nord +startup: + regions: + - us-east-1 +` + configPath := filepath.Join(configDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(existing), 0644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + cfg := &FileConfig{} + if err := cfg.SavePersistence(true); err != nil { + t.Fatalf("SavePersistence(true) failed: %v", err) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + content := string(data) + + if !contains(content, "autosave:") { + t.Error("autosave: key was not created") + } + if !contains(content, "enabled: true") { + t.Error("autosave.enabled: true was not saved") + } + if !contains(content, "theme: nord") { + t.Error("theme was not preserved") + } + + if !cfg.PersistenceEnabled() { + t.Error("PersistenceEnabled() should be true") + } + + if err := cfg.SavePersistence(false); err != nil { + t.Fatalf("SavePersistence(false) failed: %v", err) + } + + data, err = os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + content = string(data) + + if !contains(content, "enabled: false") { + t.Error("autosave.enabled: false was not saved") + } + if cfg.PersistenceEnabled() { + t.Error("PersistenceEnabled() should be false") + } +} + +func TestStartupConfig_GetProfiles(t *testing.T) { + tests := []struct { + name string + config StartupConfig + want []string + }{ + {"new format", StartupConfig{Profiles: []string{"a", "b"}}, []string{"a", "b"}}, + {"old format", StartupConfig{Profile: "legacy"}, []string{"legacy"}}, + {"both prefers new", StartupConfig{Profile: "old", Profiles: []string{"new"}}, []string{"new"}}, + {"empty", StartupConfig{}, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.config.GetProfiles() + if len(got) != len(tt.want) { + t.Errorf("GetProfiles() = %v, want %v", got, tt.want) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("GetProfiles()[%d] = %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestConcurrentSaves(t *testing.T) { + tmpDir := t.TempDir() + origHome := os.Getenv("HOME") + defer os.Setenv("HOME", origHome) + os.Setenv("HOME", tmpDir) + + cfg := &FileConfig{} + + done := make(chan bool) + for i := 0; i < 5; i++ { + go func(id int) { + for j := 0; j < 20; j++ { + _ = cfg.SaveRegions([]string{"us-east-1", "us-west-2"}) + _ = cfg.SaveProfiles([]string{"profile1", "profile2"}) + _ = cfg.SaveTheme("nord") + _ = cfg.SavePersistence(true) + } + done <- true + }(i) + } + + for i := 0; i < 5; i++ { + <-done + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + regions, profiles := loaded.GetStartup() + if len(regions) != 2 { + t.Errorf("regions = %v, want 2 regions", regions) + } + if len(profiles) != 2 { + t.Errorf("profiles = %v, want 2 profiles", profiles) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/render/render.go b/internal/render/render.go index 2699536..5194ce3 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -140,7 +140,7 @@ func StateColorer() Colorer { case "pending", "starting", "creating": return ui.PendingStyle() default: - return lipgloss.NewStyle() + return ui.NoStyle() } } } @@ -202,11 +202,6 @@ func FormatDuration(d time.Duration) string { // Style is an alias for lipgloss.Style for convenience type Style = lipgloss.Style -// DefaultStyle returns a default unstyled style -func DefaultStyle() lipgloss.Style { - return lipgloss.NewStyle() -} - // FormatTags formats tags for table display // It shows the most important tags first (Name is excluded since it's usually shown separately) func FormatTags(tags map[string]string, maxLen int) string { diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 10efa83..bf5bdae 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -283,7 +283,7 @@ func TestStyleHelpers(t *testing.T) { _ = ui.WarningStyle().Render("test") _ = ui.DangerStyle().Render("test") _ = ui.DimStyle().Render("test") - _ = DefaultStyle().Render("test") + _ = ui.NoStyle().Render("test") } func TestEmptyValueConstants(t *testing.T) { diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 6e3859b..098b24e 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -1,12 +1,58 @@ package ui import ( + "fmt" "image/color" + "log/slog" + "regexp" + "strconv" + "strings" + "sync" "charm.land/bubbles/v2/spinner" "charm.land/lipgloss/v2" + + "github.com/clawscli/claws/internal/config" +) + +var ( + hex6Re = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) + hex3Re = regexp.MustCompile(`^#[0-9A-Fa-f]{3}$`) ) +// ParseColor parses a color string and returns a lipgloss color. +// Accepts hex (#RGB, #RRGGBB) or ANSI 256 numbers (0-255). +// Returns nil, nil for empty strings (caller should use default). +// Returns nil, error for invalid color strings. +func ParseColor(s string) (color.Color, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil + } + + if strings.HasPrefix(s, "#") { + if hex6Re.MatchString(s) { + return lipgloss.Color(s), nil + } + if hex3Re.MatchString(s) { + // Expand #RGB to #RRGGBB + r, g, b := s[1], s[2], s[3] + expanded := fmt.Sprintf("#%c%c%c%c%c%c", r, r, g, g, b, b) + return lipgloss.Color(expanded), nil + } + return nil, fmt.Errorf("invalid hex color %q: must be #RGB or #RRGGBB", s) + } + + n, err := strconv.Atoi(s) + if err != nil { + return nil, fmt.Errorf("invalid color %q: must be hex (#RGB/#RRGGBB) or ANSI number (0-255)", s) + } + if n < 0 || n > 255 { + return nil, fmt.Errorf("invalid ANSI color %d: must be 0-255", n) + } + return lipgloss.Color(s), nil +} + // Theme defines the color scheme for the application type Theme struct { // Primary colors @@ -39,189 +85,372 @@ type Theme struct { TableHeader color.Color // Table header background TableHeaderText color.Color // Table header text TableBorder color.Color // Table border + + // Badge colors (for READ-ONLY indicator, etc.) + BadgeForeground color.Color // Badge text color + BadgeBackground color.Color // Badge background color } -// DefaultTheme returns the default dark theme -func DefaultTheme() *Theme { +// Preset theme names +const ( + ThemeDark = "dark" + ThemeLight = "light" + ThemeNord = "nord" + ThemeDracula = "dracula" + ThemeGruvbox = "gruvbox" + ThemeCatppuccin = "catppuccin" +) + +// AvailableThemes returns a list of all available preset theme names +func AvailableThemes() []string { + return []string{ThemeDark, ThemeLight, ThemeNord, ThemeDracula, ThemeGruvbox, ThemeCatppuccin} +} + +type palette struct { + primary, secondary, accent string + text, textBright, textDim, textMuted string + success, warning, danger, info, pending string + border, borderHighlight, bg, bgAlt string + selection, selectionText string + tableHeader, tableHeaderText, tableBorder string + badgeFg, badgeBg string +} + +var presets = map[string]palette{ + ThemeDark: { + primary: "170", secondary: "33", accent: "86", + text: "252", textBright: "255", textDim: "247", textMuted: "244", + success: "42", warning: "214", danger: "196", info: "33", pending: "226", + border: "244", borderHighlight: "170", bg: "235", bgAlt: "237", + selection: "57", selectionText: "229", + tableHeader: "63", tableHeaderText: "229", tableBorder: "246", + badgeFg: "16", badgeBg: "214", + }, + ThemeLight: { + primary: "#8839ef", secondary: "#1e66f5", accent: "#179299", + text: "#4c4f69", textBright: "#1e1e2e", textDim: "#6c6f85", textMuted: "#9ca0b0", + success: "#40a02b", warning: "#df8e1d", danger: "#d20f39", info: "#1e66f5", pending: "#df8e1d", + border: "#9ca0b0", borderHighlight: "#8839ef", bg: "#eff1f5", bgAlt: "#e6e9ef", + selection: "#8839ef", selectionText: "#eff1f5", + tableHeader: "#7287fd", tableHeaderText: "#eff1f5", tableBorder: "#bcc0cc", + badgeFg: "#eff1f5", badgeBg: "#df8e1d", + }, + ThemeNord: { + primary: "#88c0d0", secondary: "#81a1c1", accent: "#8fbcbb", + text: "#d8dee9", textBright: "#eceff4", textDim: "#4c566a", textMuted: "#434c5e", + success: "#a3be8c", warning: "#ebcb8b", danger: "#bf616a", info: "#5e81ac", pending: "#ebcb8b", + border: "#4c566a", borderHighlight: "#88c0d0", bg: "#2e3440", bgAlt: "#3b4252", + selection: "#5e81ac", selectionText: "#eceff4", + tableHeader: "#434c5e", tableHeaderText: "#88c0d0", tableBorder: "#4c566a", + badgeFg: "#2e3440", badgeBg: "#ebcb8b", + }, + ThemeDracula: { + primary: "#bd93f9", secondary: "#8be9fd", accent: "#ff79c6", + text: "#f8f8f2", textBright: "#ffffff", textDim: "#6272a4", textMuted: "#44475a", + success: "#50fa7b", warning: "#ffb86c", danger: "#ff5555", info: "#8be9fd", pending: "#f1fa8c", + border: "#6272a4", borderHighlight: "#bd93f9", bg: "#282a36", bgAlt: "#44475a", + selection: "#44475a", selectionText: "#f8f8f2", + tableHeader: "#44475a", tableHeaderText: "#bd93f9", tableBorder: "#6272a4", + badgeFg: "#282a36", badgeBg: "#ffb86c", + }, + ThemeGruvbox: { + primary: "#fe8019", secondary: "#83a598", accent: "#fabd2f", + text: "#ebdbb2", textBright: "#fbf1c7", textDim: "#928374", textMuted: "#665c54", + success: "#b8bb26", warning: "#fabd2f", danger: "#fb4934", info: "#83a598", pending: "#fabd2f", + border: "#665c54", borderHighlight: "#fe8019", bg: "#282828", bgAlt: "#3c3836", + selection: "#504945", selectionText: "#fbf1c7", + tableHeader: "#3c3836", tableHeaderText: "#fe8019", tableBorder: "#665c54", + badgeFg: "#282828", badgeBg: "#fabd2f", + }, + ThemeCatppuccin: { + primary: "#cba6f7", secondary: "#89b4fa", accent: "#f5c2e7", + text: "#cdd6f4", textBright: "#ffffff", textDim: "#6c7086", textMuted: "#585b70", + success: "#a6e3a1", warning: "#f9e2af", danger: "#f38ba8", info: "#89dceb", pending: "#f9e2af", + border: "#585b70", borderHighlight: "#cba6f7", bg: "#1e1e2e", bgAlt: "#313244", + selection: "#45475a", selectionText: "#cdd6f4", + tableHeader: "#313244", tableHeaderText: "#cba6f7", tableBorder: "#585b70", + badgeFg: "#1e1e2e", badgeBg: "#f9e2af", + }, +} + +func buildTheme(p palette) *Theme { return &Theme{ - // Primary colors - Primary: lipgloss.Color("170"), // Pink/Magenta - Secondary: lipgloss.Color("33"), // Blue - Accent: lipgloss.Color("86"), // Cyan - - // Text colors - Text: lipgloss.Color("252"), // Light gray - TextBright: lipgloss.Color("255"), // White - TextDim: lipgloss.Color("247"), // Medium gray - TextMuted: lipgloss.Color("244"), // Darker gray - - // Semantic colors - Success: lipgloss.Color("42"), // Green - Warning: lipgloss.Color("214"), // Orange - Danger: lipgloss.Color("196"), // Red - Info: lipgloss.Color("33"), // Blue - Pending: lipgloss.Color("226"), // Yellow - - // UI element colors - Border: lipgloss.Color("244"), // Gray border - BorderHighlight: lipgloss.Color("170"), // Pink highlight - Background: lipgloss.Color("235"), // Dark background - BackgroundAlt: lipgloss.Color("237"), // Slightly lighter - Selection: lipgloss.Color("57"), // Purple selection - SelectionText: lipgloss.Color("229"), // Light yellow - - // Table colors - TableHeader: lipgloss.Color("63"), // Purple header - TableHeaderText: lipgloss.Color("229"), // Light yellow - TableBorder: lipgloss.Color("246"), // Gray border + Primary: lipgloss.Color(p.primary), + Secondary: lipgloss.Color(p.secondary), + Accent: lipgloss.Color(p.accent), + Text: lipgloss.Color(p.text), + TextBright: lipgloss.Color(p.textBright), + TextDim: lipgloss.Color(p.textDim), + TextMuted: lipgloss.Color(p.textMuted), + Success: lipgloss.Color(p.success), + Warning: lipgloss.Color(p.warning), + Danger: lipgloss.Color(p.danger), + Info: lipgloss.Color(p.info), + Pending: lipgloss.Color(p.pending), + Border: lipgloss.Color(p.border), + BorderHighlight: lipgloss.Color(p.borderHighlight), + Background: lipgloss.Color(p.bg), + BackgroundAlt: lipgloss.Color(p.bgAlt), + Selection: lipgloss.Color(p.selection), + SelectionText: lipgloss.Color(p.selectionText), + TableHeader: lipgloss.Color(p.tableHeader), + TableHeaderText: lipgloss.Color(p.tableHeaderText), + TableBorder: lipgloss.Color(p.tableBorder), + BadgeForeground: lipgloss.Color(p.badgeFg), + BadgeBackground: lipgloss.Color(p.badgeBg), + } +} + +// GetPreset returns a theme by name. Returns nil if the name is not recognized. +func GetPreset(name string) *Theme { + key := strings.ToLower(strings.TrimSpace(name)) + if key == "" { + key = ThemeDark + } + if p, ok := presets[key]; ok { + return buildTheme(p) } + return nil +} + +// DefaultTheme returns the default dark theme +func DefaultTheme() *Theme { + return buildTheme(presets[ThemeDark]) } // current holds the active theme -var current = DefaultTheme() +var ( + currentMu sync.RWMutex + current = DefaultTheme() +) // Current returns the current active theme func Current() *Theme { + currentMu.RLock() + defer currentMu.RUnlock() return current } +func SetTheme(t *Theme) { + if t != nil { + currentMu.Lock() + current = t + currentMu.Unlock() + } +} + +func ApplyConfig(cfg config.ThemeConfig) { + ApplyConfigWithOverride(cfg, "") +} + +func ApplyConfigWithOverride(cfg config.ThemeConfig, cliTheme string) { + presetName := cfg.Preset + if cliTheme != "" { + presetName = cliTheme + } + + theme := GetPreset(presetName) + if theme == nil { + slog.Warn("unknown theme preset, using dark", "preset", presetName) + theme = DefaultTheme() + } + + if cliTheme != "" { + SetTheme(theme) + return + } + + applyColor := func(name string, value string, target *color.Color) { + if value == "" { + return + } + c, err := ParseColor(value) + if err != nil { + slog.Warn("invalid theme color, using default", "field", name, "value", value, "error", err) + return + } + *target = c + } + + applyColor("primary", cfg.Primary, &theme.Primary) + applyColor("secondary", cfg.Secondary, &theme.Secondary) + applyColor("accent", cfg.Accent, &theme.Accent) + applyColor("text", cfg.Text, &theme.Text) + applyColor("text_bright", cfg.TextBright, &theme.TextBright) + applyColor("text_dim", cfg.TextDim, &theme.TextDim) + applyColor("text_muted", cfg.TextMuted, &theme.TextMuted) + applyColor("success", cfg.Success, &theme.Success) + applyColor("warning", cfg.Warning, &theme.Warning) + applyColor("danger", cfg.Danger, &theme.Danger) + applyColor("info", cfg.Info, &theme.Info) + applyColor("pending", cfg.Pending, &theme.Pending) + applyColor("border", cfg.Border, &theme.Border) + applyColor("border_highlight", cfg.BorderHighlight, &theme.BorderHighlight) + applyColor("background", cfg.Background, &theme.Background) + applyColor("background_alt", cfg.BackgroundAlt, &theme.BackgroundAlt) + applyColor("selection", cfg.Selection, &theme.Selection) + applyColor("selection_text", cfg.SelectionText, &theme.SelectionText) + applyColor("table_header", cfg.TableHeader, &theme.TableHeader) + applyColor("table_header_text", cfg.TableHeaderText, &theme.TableHeaderText) + applyColor("table_border", cfg.TableBorder, &theme.TableBorder) + applyColor("badge_foreground", cfg.BadgeForeground, &theme.BadgeForeground) + applyColor("badge_background", cfg.BadgeBackground, &theme.BadgeBackground) + + SetTheme(theme) +} + // Style helpers that use the current theme -// DimStyle returns a style for dimmed text +func NoStyle() lipgloss.Style { + return lipgloss.NewStyle() +} + func DimStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(current.TextDim) + return lipgloss.NewStyle().Foreground(Current().TextDim) } // SuccessStyle returns a style for success states func SuccessStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(current.Success) + return lipgloss.NewStyle().Foreground(Current().Success) } // WarningStyle returns a style for warning states func WarningStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(current.Warning) + return lipgloss.NewStyle().Foreground(Current().Warning) } // DangerStyle returns a style for danger/error states func DangerStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(current.Danger) + return lipgloss.NewStyle().Foreground(Current().Danger) } func TitleStyle() lipgloss.Style { - return lipgloss.NewStyle().Bold(true).Foreground(current.Primary) + return lipgloss.NewStyle().Bold(true).Foreground(Current().Primary) } func SelectedStyle() lipgloss.Style { - return lipgloss.NewStyle().Background(current.Selection).Foreground(current.SelectionText) + return lipgloss.NewStyle().Background(Current().Selection).Foreground(Current().SelectionText) } func TableHeaderStyle() lipgloss.Style { - return lipgloss.NewStyle().Background(current.TableHeader).Foreground(current.TableHeaderText) + return lipgloss.NewStyle().Background(Current().TableHeader).Foreground(Current().TableHeaderText) } func SectionStyle() lipgloss.Style { - return lipgloss.NewStyle().Bold(true).Foreground(current.Secondary) + return lipgloss.NewStyle().Bold(true).Foreground(Current().Secondary) } func HighlightStyle() lipgloss.Style { - return lipgloss.NewStyle().Bold(true).Foreground(current.Accent) + return lipgloss.NewStyle().Bold(true).Foreground(Current().Accent) } func BoldSuccessStyle() lipgloss.Style { - return lipgloss.NewStyle().Bold(true).Foreground(current.Success) + return lipgloss.NewStyle().Bold(true).Foreground(Current().Success) } func BoldDangerStyle() lipgloss.Style { - return lipgloss.NewStyle().Bold(true).Foreground(current.Danger) + return lipgloss.NewStyle().Bold(true).Foreground(Current().Danger) } func BoldWarningStyle() lipgloss.Style { - return lipgloss.NewStyle().Bold(true).Foreground(current.Warning) + return lipgloss.NewStyle().Bold(true).Foreground(Current().Warning) } func BoldPendingStyle() lipgloss.Style { - return lipgloss.NewStyle().Bold(true).Foreground(current.Pending) + return lipgloss.NewStyle().Bold(true).Foreground(Current().Pending) } // AccentStyle returns a style for accent-colored text (non-bold) func AccentStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(current.Accent) + return lipgloss.NewStyle().Foreground(Current().Accent) } // MutedStyle returns a style for very dim/muted text func MutedStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(current.TextMuted) + return lipgloss.NewStyle().Foreground(Current().TextMuted) } // TextStyle returns a style for normal text func TextStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(current.Text) + return lipgloss.NewStyle().Foreground(Current().Text) } // TextBrightStyle returns a style for emphasized text func TextBrightStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(current.TextBright) + return lipgloss.NewStyle().Foreground(Current().TextBright) } // SecondaryStyle returns a style for secondary-colored text func SecondaryStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(current.Secondary) + return lipgloss.NewStyle().Foreground(Current().Secondary) } // BorderStyle returns a style for border-colored text (separators) func BorderStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(current.Border) + return lipgloss.NewStyle().Foreground(Current().Border) } // PrimaryStyle returns a style for primary-colored text (non-bold) func PrimaryStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(current.Primary) + return lipgloss.NewStyle().Foreground(Current().Primary) } // InfoStyle returns a style for info states func InfoStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(current.Info) + return lipgloss.NewStyle().Foreground(Current().Info) } // PendingStyle returns a style for pending states func PendingStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(current.Pending) + return lipgloss.NewStyle().Foreground(Current().Pending) +} + +func FaintStyle() lipgloss.Style { + return lipgloss.NewStyle().Faint(true) } func BoxStyle() lipgloss.Style { return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(current.Border). + BorderForeground(Current().Border). Padding(0, 1) } func InputStyle() lipgloss.Style { return lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). - BorderForeground(current.Border). + BorderForeground(Current().Border). Padding(0, 1) } // InputFieldStyle returns a style for input fields (filter, command input) func InputFieldStyle() lipgloss.Style { return lipgloss.NewStyle(). - Background(current.Background). - Foreground(current.Text). + Background(Current().Background). + Foreground(Current().Text). Padding(0, 1) } // ReadOnlyBadgeStyle returns a style for the READ-ONLY indicator badge func ReadOnlyBadgeStyle() lipgloss.Style { return lipgloss.NewStyle(). - Background(current.Warning). - Foreground(lipgloss.Color("#000000")). // TODO: extract to theme in #96 + Background(Current().BadgeBackground). + Foreground(Current().BadgeForeground). Bold(true). Padding(0, 1) } +func CellStyle(width, height int) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(Current().Text). + Width(width). + Height(height). + Padding(0, 1) +} + func NewSpinner() spinner.Model { s := spinner.New() s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(current.Accent) + s.Style = lipgloss.NewStyle().Foreground(Current().Accent) return s } diff --git a/internal/ui/theme_test.go b/internal/ui/theme_test.go index c8f1fc4..775a8a4 100644 --- a/internal/ui/theme_test.go +++ b/internal/ui/theme_test.go @@ -3,6 +3,8 @@ package ui import ( "image/color" "testing" + + "github.com/clawscli/claws/internal/config" ) func TestDefaultTheme(t *testing.T) { @@ -285,6 +287,14 @@ func TestInputFieldStyle(t *testing.T) { } } +func TestReadOnlyBadgeStyle(t *testing.T) { + style := ReadOnlyBadgeStyle() + rendered := style.Render("READ-ONLY") + if rendered == "" { + t.Error("ReadOnlyBadgeStyle().Render() should produce output") + } +} + func TestThemeFields(t *testing.T) { theme := DefaultTheme() @@ -339,4 +349,319 @@ func TestThemeFields(t *testing.T) { t.Errorf("%s color should not be nil", tc.name) } } + + // Test badge colors + badgeColors := []struct { + name string + color color.Color + }{ + {"BadgeForeground", theme.BadgeForeground}, + {"BadgeBackground", theme.BadgeBackground}, + } + + for _, tc := range badgeColors { + if tc.color == nil { + t.Errorf("%s color should not be nil", tc.name) + } + } +} + +func TestParseColor(t *testing.T) { + tests := []struct { + name string + input string + wantNil bool + wantErr bool + }{ + {"empty string", "", true, false}, + {"whitespace only", " ", true, false}, + {"valid hex 6", "#ff5733", false, false}, + {"valid hex 6 upper", "#FF5733", false, false}, + {"valid hex 3", "#f00", false, false}, + {"valid hex 3 upper", "#F00", false, false}, + {"valid ANSI 0", "0", false, false}, + {"valid ANSI 170", "170", false, false}, + {"valid ANSI 255", "255", false, false}, + {"invalid hex short", "#ff", false, true}, + {"invalid hex long", "#ff57331", false, true}, + {"invalid hex chars", "#gggggg", false, true}, + {"invalid ANSI negative", "-1", false, true}, + {"invalid ANSI over 255", "256", false, true}, + {"invalid string", "red", false, true}, + {"invalid mixed", "ff5733", false, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, err := ParseColor(tt.input) + + if tt.wantErr { + if err == nil { + t.Errorf("ParseColor(%q) expected error, got nil", tt.input) + } + return + } + + if err != nil { + t.Errorf("ParseColor(%q) unexpected error: %v", tt.input, err) + return + } + + if tt.wantNil && c != nil { + t.Errorf("ParseColor(%q) expected nil, got %v", tt.input, c) + } + if !tt.wantNil && c == nil { + t.Errorf("ParseColor(%q) expected color, got nil", tt.input) + } + }) + } +} + +func TestParseColorHex3Expansion(t *testing.T) { + c, err := ParseColor("#f00") + if err != nil { + t.Fatalf("ParseColor(#f00) unexpected error: %v", err) + } + if c == nil { + t.Fatal("ParseColor(#f00) returned nil") + } + + r, g, b, _ := c.RGBA() + if r>>8 != 0xff || g>>8 != 0 || b>>8 != 0 { + t.Errorf("ParseColor(#f00) expected red, got R=%d G=%d B=%d", r>>8, g>>8, b>>8) + } +} + +func TestSetTheme(t *testing.T) { + original := Current() + + newTheme := DefaultTheme() + newTheme.Primary = nil + + SetTheme(newTheme) + if Current() != newTheme { + t.Error("SetTheme did not set the theme") + } + + SetTheme(nil) + if Current() != newTheme { + t.Error("SetTheme(nil) should not change theme") + } + + SetTheme(original) +} + +func TestApplyConfig(t *testing.T) { + original := Current() + defer SetTheme(original) + + cfg := config.ThemeConfig{ + Primary: "#ff0000", + Success: "42", + Danger: "#f00", + } + + ApplyConfig(cfg) + + theme := Current() + + r, g, b, _ := theme.Primary.RGBA() + if r>>8 != 0xff || g>>8 != 0 || b>>8 != 0 { + t.Errorf("Primary expected red, got R=%d G=%d B=%d", r>>8, g>>8, b>>8) + } + + if theme.Secondary == nil { + t.Error("Secondary should use default, not nil") + } +} + +func TestApplyConfigInvalidColor(t *testing.T) { + original := Current() + defer SetTheme(original) + + defaultTheme := DefaultTheme() + + cfg := config.ThemeConfig{ + Primary: "invalid", + Success: "#gggggg", + } + + ApplyConfig(cfg) + + theme := Current() + + if !colorsEqual(theme.Primary, defaultTheme.Primary) { + t.Error("Invalid primary should fallback to default") + } + if !colorsEqual(theme.Success, defaultTheme.Success) { + t.Error("Invalid success should fallback to default") + } +} + +func TestApplyConfigEmpty(t *testing.T) { + original := Current() + defer SetTheme(original) + + defaultTheme := DefaultTheme() + + ApplyConfig(config.ThemeConfig{}) + + theme := Current() + + if !colorsEqual(theme.Primary, defaultTheme.Primary) { + t.Error("Empty config should use default primary") + } + if !colorsEqual(theme.Success, defaultTheme.Success) { + t.Error("Empty config should use default success") + } +} + +func TestAvailableThemes(t *testing.T) { + themes := AvailableThemes() + if len(themes) != 6 { + t.Errorf("Expected 6 themes, got %d", len(themes)) + } + + expected := []string{"dark", "light", "nord", "dracula", "gruvbox", "catppuccin"} + for i, name := range expected { + if themes[i] != name { + t.Errorf("Expected themes[%d] = %q, got %q", i, name, themes[i]) + } + } +} + +func TestGetPreset(t *testing.T) { + tests := []struct { + name string + input string + wantNil bool + }{ + {"empty uses dark", "", false}, + {"dark", "dark", false}, + {"light", "light", false}, + {"nord", "nord", false}, + {"dracula", "dracula", false}, + {"gruvbox", "gruvbox", false}, + {"catppuccin", "catppuccin", false}, + {"case insensitive", "NORD", false}, + {"with spaces", " dark ", false}, + {"unknown", "unknown-theme", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + theme := GetPreset(tt.input) + if tt.wantNil && theme != nil { + t.Errorf("GetPreset(%q) expected nil, got theme", tt.input) + } + if !tt.wantNil && theme == nil { + t.Errorf("GetPreset(%q) expected theme, got nil", tt.input) + } + }) + } +} + +func TestGetPresetColors(t *testing.T) { + presets := AvailableThemes() + for _, name := range presets { + t.Run(name, func(t *testing.T) { + theme := GetPreset(name) + if theme == nil { + t.Fatalf("GetPreset(%q) returned nil", name) + } + + if theme.Primary == nil { + t.Error("Primary should not be nil") + } + if theme.Text == nil { + t.Error("Text should not be nil") + } + if theme.Success == nil { + t.Error("Success should not be nil") + } + if theme.Selection == nil { + t.Error("Selection should not be nil") + } + }) + } +} + +func TestApplyConfigWithPreset(t *testing.T) { + original := Current() + defer SetTheme(original) + + cfg := config.ThemeConfig{Preset: "nord"} + ApplyConfig(cfg) + + theme := Current() + nordTheme := GetPreset("nord") + + if !colorsEqual(theme.Primary, nordTheme.Primary) { + t.Error("Preset nord should apply nord primary color") + } +} + +func TestApplyConfigWithPresetAndOverride(t *testing.T) { + original := Current() + defer SetTheme(original) + + cfg := config.ThemeConfig{ + Preset: "nord", + Primary: "#ff0000", + } + ApplyConfig(cfg) + + theme := Current() + + r, g, b, _ := theme.Primary.RGBA() + if r>>8 != 0xff || g>>8 != 0 || b>>8 != 0 { + t.Errorf("Primary should be overridden to red, got R=%d G=%d B=%d", r>>8, g>>8, b>>8) + } +} + +func TestApplyConfigWithOverride(t *testing.T) { + original := Current() + defer SetTheme(original) + + cfg := config.ThemeConfig{Preset: "nord"} + ApplyConfigWithOverride(cfg, "dracula") + + theme := Current() + draculaTheme := GetPreset("dracula") + + if !colorsEqual(theme.Primary, draculaTheme.Primary) { + t.Error("CLI override should use dracula, not nord") + } +} + +func TestThemeConcurrentAccess(t *testing.T) { + original := Current() + defer SetTheme(original) + + themes := []*Theme{ + GetPreset("dark"), + GetPreset("light"), + GetPreset("nord"), + GetPreset("dracula"), + } + + done := make(chan bool) + for i := 0; i < 10; i++ { + go func(id int) { + for j := 0; j < 100; j++ { + SetTheme(themes[j%len(themes)]) + _ = Current() + } + done <- true + }(i) + } + + for i := 0; i < 10; i++ { + <-done + } + + // If we get here without race detector panic, the test passes + if Current() == nil { + t.Error("Current() should not return nil") + } } diff --git a/internal/view/action_menu.go b/internal/view/action_menu.go index 2359aa4..a8f9b0d 100644 --- a/internal/view/action_menu.go +++ b/internal/view/action_menu.go @@ -37,14 +37,14 @@ func newActionMenuStyles() actionMenuStyles { t := ui.Current() return actionMenuStyles{ title: ui.TitleStyle(), - item: lipgloss.NewStyle().PaddingLeft(2), + item: ui.TextStyle(), selected: ui.SelectedStyle().PaddingLeft(2), shortcut: ui.SecondaryStyle(), box: ui.BoxStyle().MarginTop(1), dangerBox: ui.BoxStyle().BorderForeground(t.Danger).MarginTop(1), yes: ui.BoldSuccessStyle(), no: ui.BoldDangerStyle(), - bold: lipgloss.NewStyle().Bold(true), + bold: ui.TextStyle().Bold(true), input: ui.InputStyle(), } } @@ -125,6 +125,9 @@ func (m *ActionMenu) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } return m, nil + case ThemeChangedMsg: + m.styles = newActionMenuStyles() + return m, nil case tea.MouseMotionMsg: if !m.confirming && !m.dangerous.active { @@ -299,13 +302,12 @@ func (m *ActionMenu) ViewString() string { } for i, act := range m.actions { - style := s.item + shortcutText := fmt.Sprintf("[%s]", act.Shortcut) if i == m.cursor { - style = s.selected + out += s.selected.Render(fmt.Sprintf("%s %s", shortcutText, act.Name)) + "\n" + } else { + out += fmt.Sprintf(" %s %s", s.shortcut.Render(shortcutText), s.item.Render(act.Name)) + "\n" } - - shortcut := s.shortcut.Render(fmt.Sprintf("[%s]", act.Shortcut)) - out += style.Render(fmt.Sprintf("%s %s", shortcut, act.Name)) + "\n" } if m.dangerous.active && m.confirmIdx < len(m.actions) { diff --git a/internal/view/command_input.go b/internal/view/command_input.go index f5d4bc9..6b3f472 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -90,6 +90,10 @@ func (c *CommandInput) Activate() tea.Cmd { return textinput.Blink } +func (c *CommandInput) ReloadStyles() { + c.styles = newCommandInputStyles() +} + // Deactivate deactivates command mode func (c *CommandInput) Deactivate() { c.active = false @@ -279,18 +283,38 @@ func (c *CommandInput) executeCommand() (tea.Cmd, *NavigateMsg) { if suffix, ok := strings.CutPrefix(input, "diff "); ok { parts := strings.Fields(suffix) if len(parts) == 1 { - // :diff - compare current row with named resource return func() tea.Msg { return DiffMsg{LeftName: "", RightName: parts[0]} }, nil } else if len(parts) >= 2 { - // :diff - compare two named resources return func() tea.Msg { return DiffMsg{LeftName: parts[0], RightName: parts[1]} }, nil } } + if suffix, ok := strings.CutPrefix(input, "theme "); ok { + themeName := strings.TrimSpace(suffix) + if themeName != "" { + return func() tea.Msg { + return ThemeChangeMsg{Name: themeName} + }, nil + } + } + + if suffix, ok := strings.CutPrefix(input, "autosave "); ok { + switch strings.TrimSpace(suffix) { + case "on": + return func() tea.Msg { + return PersistenceChangeMsg{Enabled: true} + }, nil + case "off": + return func() tea.Msg { + return PersistenceChangeMsg{Enabled: false} + }, nil + } + } + // Parse command: service or service/resource parts := strings.SplitN(input, "/", 2) service := parts[0] @@ -391,6 +415,14 @@ func (c *CommandInput) GetSuggestions() []string { return c.getDiffSuggestions(suffix) } + if suffix, ok := strings.CutPrefix(input, "theme "); ok { + return c.getThemeSuggestions(suffix) + } + + if suffix, ok := strings.CutPrefix(input, "autosave "); ok { + return c.getAutosaveSuggestions(suffix) + } + if strings.Contains(input, "/") { // Suggest resources parts := strings.SplitN(input, "/", 2) @@ -441,6 +473,14 @@ func (c *CommandInput) GetSuggestions() []string { suggestions = append(suggestions, "diff") } + if strings.HasPrefix("theme", input) { + suggestions = append(suggestions, "theme") + } + + if strings.HasPrefix("autosave", input) { + suggestions = append(suggestions, "autosave") + } + for _, svc := range c.registry.ListServices() { if strings.HasPrefix(svc, input) { suggestions = append(suggestions, svc) @@ -459,6 +499,32 @@ func (c *CommandInput) GetSuggestions() []string { return suggestions } +func (c *CommandInput) getThemeSuggestions(prefix string) []string { + themes := ui.AvailableThemes() + prefix = strings.ToLower(strings.TrimSpace(prefix)) + + var suggestions []string + for _, t := range themes { + if prefix == "" || strings.HasPrefix(t, prefix) { + suggestions = append(suggestions, "theme "+t) + } + } + return suggestions +} + +func (c *CommandInput) getAutosaveSuggestions(prefix string) []string { + prefix = strings.ToLower(strings.TrimSpace(prefix)) + options := []string{"on", "off"} + + var suggestions []string + for _, opt := range options { + if prefix == "" || strings.HasPrefix(opt, prefix) { + suggestions = append(suggestions, "autosave "+opt) + } + } + return suggestions +} + func (c *CommandInput) getDiffSuggestions(args string) []string { if c.diffProvider == nil { return nil diff --git a/internal/view/dashboard_view.go b/internal/view/dashboard_view.go index 208ca36..7f43f29 100644 --- a/internal/view/dashboard_view.go +++ b/internal/view/dashboard_view.go @@ -21,6 +21,7 @@ type hitArea struct { } type dashboardStyles struct { + text lipgloss.Style warning lipgloss.Style danger lipgloss.Style success lipgloss.Style @@ -30,6 +31,7 @@ type dashboardStyles struct { func newDashboardStyles() dashboardStyles { return dashboardStyles{ + text: ui.TextStyle(), warning: ui.WarningStyle(), danger: ui.DangerStyle(), success: ui.SuccessStyle(), @@ -186,6 +188,10 @@ func (d *DashboardView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case RefreshMsg: return d.handleRefresh() + case ThemeChangedMsg: + d.styles = newDashboardStyles() + d.headerPanel.ReloadStyles() + return d, nil case tea.MouseClickMsg: return d.handleMouseClick(msg) diff --git a/internal/view/dashboard_view_panels.go b/internal/view/dashboard_view_panels.go index 118862d..a27b115 100644 --- a/internal/view/dashboard_view_panels.go +++ b/internal/view/dashboard_view_panels.go @@ -47,10 +47,8 @@ func renderPanel(title, content string, width, height int, t *ui.Theme, hovered borderColor = t.Primary } - borderStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). + borderStyle := ui.BoxStyle(). BorderForeground(borderColor). - Padding(0, 1). Width(width). Height(boxHeight) @@ -78,11 +76,11 @@ func (d *DashboardView) renderCostContent(contentWidth, contentHeight int, t *ui var lines []string if d.costLoading { - lines = append(lines, d.spinner.View()+" loading...") + lines = append(lines, s.text.Render(d.spinner.View()+" loading...")) } else if d.costErr != nil { lines = append(lines, s.dim.Render("Cost: N/A")) } else { - lines = append(lines, "MTD: "+appaws.FormatMoney(d.costMTD, "")) + lines = append(lines, s.text.Render("MTD: "+appaws.FormatMoney(d.costMTD, ""))) if len(d.costTop) > 0 { maxCost := d.costTop[0].cost @@ -101,19 +99,21 @@ func (d *DashboardView) renderCostContent(contentWidth, contentHeight int, t *ui line := fmt.Sprintf("%-*s %s %8.0f", nameWidth, name, bar, c.cost) if i == focusRow { line = s.highlight.Render(line) + } else { + line = s.text.Render(line) } lines = append(lines, line) } } if d.anomalyLoading { - lines = append(lines, "Anomalies: "+d.spinner.View()) + lines = append(lines, s.text.Render("Anomalies: "+d.spinner.View())) } else if d.anomalyErr != nil { - lines = append(lines, "Anomalies: "+s.dim.Render("N/A")) + lines = append(lines, s.text.Render("Anomalies: ")+s.dim.Render("N/A")) } else if d.anomalyCount > 0 { - lines = append(lines, "Anomalies: "+s.warning.Render(fmt.Sprintf("%d", d.anomalyCount))) + lines = append(lines, s.text.Render("Anomalies: ")+s.warning.Render(fmt.Sprintf("%d", d.anomalyCount))) } else { - lines = append(lines, "Anomalies: "+s.success.Render("0")) + lines = append(lines, s.text.Render("Anomalies: ")+s.success.Render("0")) } } @@ -126,25 +126,25 @@ func (d *DashboardView) renderOpsContent(contentWidth, contentHeight int, focusR alarmCount := len(d.alarms) if d.alarmLoading { - lines = append(lines, "Alarms: "+d.spinner.View()) + lines = append(lines, s.text.Render("Alarms: "+d.spinner.View())) } else if d.alarmErr != nil { lines = append(lines, s.dim.Render("Alarms: N/A")) } else if alarmCount > 0 { lines = append(lines, s.danger.Render(fmt.Sprintf("Alarms: %d in ALARM", alarmCount))) maxShow := min(alarmCount, contentHeight-3) for i := range maxShow { - line := " " + s.danger.Render("• ") + TruncateString(d.alarms[i].name, contentWidth-bulletIndentWidth) + line := " " + s.danger.Render("• ") + s.text.Render(TruncateString(d.alarms[i].name, contentWidth-bulletIndentWidth)) if i == focusRow { line = s.highlight.Render(line) } lines = append(lines, line) } } else { - lines = append(lines, "Alarms: "+s.success.Render("0 ✓")) + lines = append(lines, s.text.Render("Alarms: ")+s.success.Render("0 ✓")) } if d.healthLoading { - lines = append(lines, "Health: "+d.spinner.View()) + lines = append(lines, s.text.Render("Health: "+d.spinner.View())) } else if d.healthErr != nil { lines = append(lines, s.dim.Render("Health: N/A")) } else if len(d.healthItems) > 0 { @@ -153,14 +153,14 @@ func (d *DashboardView) renderOpsContent(contentWidth, contentHeight int, focusR maxShow := min(len(d.healthItems), remaining) for i := range maxShow { h := d.healthItems[i] - line := " " + s.warning.Render("• ") + TruncateString(h.service+": "+h.eventType, contentWidth-bulletIndentWidth) + line := " " + s.warning.Render("• ") + s.text.Render(TruncateString(h.service+": "+h.eventType, contentWidth-bulletIndentWidth)) if alarmCount+i == focusRow { line = s.highlight.Render(line) } lines = append(lines, line) } } else { - lines = append(lines, "Health: "+s.success.Render("0 open ✓")) + lines = append(lines, s.text.Render("Health: ")+s.success.Render("0 open ✓")) } return strings.Join(lines, "\n") @@ -171,7 +171,7 @@ func (d *DashboardView) renderSecurityContent(contentWidth, contentHeight int, f var lines []string if d.secLoading { - lines = append(lines, d.spinner.View()+" loading...") + lines = append(lines, s.text.Render(d.spinner.View()+" loading...")) } else if d.secErr != nil { lines = append(lines, s.dim.Render("Security: N/A")) } else if len(d.secItems) > 0 { @@ -196,7 +196,7 @@ func (d *DashboardView) renderSecurityContent(contentWidth, contentHeight int, f if item.severity == "CRITICAL" { style = s.danger } - line := " " + style.Render("• ") + TruncateString(item.title, contentWidth-bulletIndentWidth) + line := " " + style.Render("• ") + s.text.Render(TruncateString(item.title, contentWidth-bulletIndentWidth)) if i == focusRow { line = s.highlight.Render(line) } @@ -214,7 +214,7 @@ func (d *DashboardView) renderOptimizationContent(contentWidth, contentHeight in var lines []string if d.taLoading { - lines = append(lines, d.spinner.View()+" loading...") + lines = append(lines, s.text.Render(d.spinner.View()+" loading...")) } else if d.taErr != nil { lines = append(lines, s.dim.Render("Optimization: N/A")) } else { @@ -243,7 +243,7 @@ func (d *DashboardView) renderOptimizationContent(contentWidth, contentHeight in if item.status == "error" { style = s.danger } - line := " " + style.Render("• ") + TruncateString(item.name, contentWidth-bulletIndentWidth) + line := " " + style.Render("• ") + s.text.Render(TruncateString(item.name, contentWidth-bulletIndentWidth)) if i == focusRow { line = s.highlight.Render(line) } diff --git a/internal/view/detail_view.go b/internal/view/detail_view.go index 9605e08..b769436 100644 --- a/internal/view/detail_view.go +++ b/internal/view/detail_view.go @@ -121,6 +121,13 @@ func (d *DetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return d, cmd } return d, nil + case ThemeChangedMsg: + d.styles = newDetailViewStyles() + d.headerPanel.ReloadStyles() + if d.vp.Ready { + d.vp.Model.SetContent(d.renderContent()) + } + return d, nil case tea.KeyPressMsg: // Let app handle back navigation (esc/backspace/q handled by app.go) diff --git a/internal/view/diff_view.go b/internal/view/diff_view.go index 80c8514..90abe26 100644 --- a/internal/view/diff_view.go +++ b/internal/view/diff_view.go @@ -64,6 +64,12 @@ func (d *DiffView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if IsEscKey(msg) { return d, nil } + case ThemeChangedMsg: + d.styles = newDiffViewStyles() + if d.vp.Ready { + d.vp.Model.SetContent(d.renderSideBySide()) + } + return d, nil } var cmd tea.Cmd diff --git a/internal/view/header_panel.go b/internal/view/header_panel.go index 50682a0..a33ec3d 100644 --- a/internal/view/header_panel.go +++ b/internal/view/header_panel.go @@ -33,9 +33,8 @@ type headerPanelStyles struct { } func newHeaderPanelStyles() headerPanelStyles { - t := ui.Current() return headerPanelStyles{ - panel: lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(t.Border).Padding(0, 1), + panel: ui.BoxStyle(), label: ui.DimStyle(), value: ui.TextStyle(), accent: ui.HighlightStyle(), @@ -128,6 +127,10 @@ func (h *HeaderPanel) SetWidth(width int) { h.width = width } +func (h *HeaderPanel) ReloadStyles() { + h.styles = newHeaderPanelStyles() +} + // Height returns the number of lines the rendered header will take func (h *HeaderPanel) Height(rendered string) int { return strings.Count(rendered, "\n") + 1 diff --git a/internal/view/help_view.go b/internal/view/help_view.go index 93681b8..00ac89e 100644 --- a/internal/view/help_view.go +++ b/internal/view/help_view.go @@ -43,6 +43,14 @@ func (h *HelpView) Init() tea.Cmd { } func (h *HelpView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg.(type) { + case ThemeChangedMsg: + h.styles = newHelpViewStyles() + if h.vp.Ready { + h.vp.Model.SetContent(h.renderContent()) + } + return h, nil + } var cmd tea.Cmd h.vp.Model, cmd = h.vp.Model.Update(msg) return h, cmd diff --git a/internal/view/log_view.go b/internal/view/log_view.go index e5f4bc2..f0b9488 100644 --- a/internal/view/log_view.go +++ b/internal/view/log_view.go @@ -333,6 +333,12 @@ func (v *LogView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { v.spinner, cmd = v.spinner.Update(msg) return v, cmd } + case ThemeChangedMsg: + v.styles = newLogViewStyles() + if v.vp.Ready { + v.updateViewportContent() + } + return v, nil } if v.vp.Ready { diff --git a/internal/view/modal.go b/internal/view/modal.go index f059e08..16b2796 100644 --- a/internal/view/modal.go +++ b/internal/view/modal.go @@ -43,12 +43,8 @@ type modalStyles struct { } func newModalStyles() modalStyles { - t := ui.Current() return modalStyles{ - box: lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.Border). - Padding(1, 2), + box: ui.BoxStyle().Padding(1, 2), } } @@ -62,6 +58,10 @@ func NewModalRenderer() *ModalRenderer { } } +func (r *ModalRenderer) ReloadStyles() { + r.styles = newModalStyles() +} + func (r *ModalRenderer) Render(modal *Modal, bg string, width, height int) string { if modal == nil || modal.Content == nil { return bg @@ -83,11 +83,11 @@ func (r *ModalRenderer) Render(modal *Modal, bg string, width, height int) strin } func dimBackground(bg string, width, height int) string { - dimStyle := lipgloss.NewStyle().Faint(true) + faintStyle := ui.FaintStyle() lines := strings.Split(bg, "\n") for i, line := range lines { - lines[i] = dimStyle.Render(line) + lines[i] = faintStyle.Render(line) } for len(lines) < height { diff --git a/internal/view/multi_selector.go b/internal/view/multi_selector.go index b764f5e..5cf6ca2 100644 --- a/internal/view/multi_selector.go +++ b/internal/view/multi_selector.go @@ -26,7 +26,7 @@ type selectorStyles struct { func newSelectorStyles() selectorStyles { return selectorStyles{ title: ui.TableHeaderStyle().Padding(0, 1), - item: lipgloss.NewStyle().PaddingLeft(2), + item: ui.TextStyle().PaddingLeft(2), itemSelected: ui.SelectedStyle().PaddingLeft(2), itemChecked: ui.SuccessStyle().PaddingLeft(2), filter: ui.AccentStyle(), @@ -83,6 +83,11 @@ func (m *MultiSelector[T]) SetItems(items []T) { m.updateViewport() } +func (m *MultiSelector[T]) ReloadStyles() { + m.styles = newSelectorStyles() + m.updateViewport() +} + func (m *MultiSelector[T]) SetRenderExtra(fn func(T) string) { m.renderExtra = fn } diff --git a/internal/view/profile_selector.go b/internal/view/profile_selector.go index ced66b1..4213c92 100644 --- a/internal/view/profile_selector.go +++ b/internal/view/profile_selector.go @@ -113,6 +113,9 @@ func (p *ProfileSelector) Update(msg tea.Msg) (tea.Model, tea.Cmd) { p.profileInfo = msg.infoMap p.selector.SetItems(p.profiles) return p, nil + case ThemeChangedMsg: + p.selector.ReloadStyles() + return p, nil case loginResultMsg: p.loginResult = &msg diff --git a/internal/view/region_selector.go b/internal/view/region_selector.go index 0ee0e2b..3a4497c 100644 --- a/internal/view/region_selector.go +++ b/internal/view/region_selector.go @@ -91,7 +91,9 @@ func (r *RegionSelector) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } r.selector.SetItems(r.regions) return r, nil - + case ThemeChangedMsg: + r.selector.ReloadStyles() + return r, nil } cmd, result := r.selector.HandleUpdate(msg) diff --git a/internal/view/resource_browser.go b/internal/view/resource_browser.go index 2695d57..d7d7114 100644 --- a/internal/view/resource_browser.go +++ b/internal/view/resource_browser.go @@ -9,7 +9,6 @@ import ( "time" "charm.land/bubbles/v2/spinner" - "charm.land/bubbles/v2/table" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -63,15 +62,18 @@ type ResourceBrowser struct { // Tab positions for mouse click detection tabPositions []tabPosition - table table.Model - dao dao.DAO - renderer render.Renderer - resources []dao.Resource - filtered []dao.Resource - loading bool - err error - width int - height int + + tc TableCursor + tableContent string + + dao dao.DAO + renderer render.Renderer + resources []dao.Resource + filtered []dao.Resource + loading bool + err error + width int + height int // Header panel headerPanel *HeaderPanel @@ -213,6 +215,11 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return r.handleAutoReloadTick() case RefreshMsg: return r.handleRefreshMsg() + case ThemeChangedMsg: + r.styles = newResourceBrowserStyles() + r.headerPanel.ReloadStyles() + r.buildTable() + return r, nil case SortMsg: return r.handleSortMsg(msg) case TagFilterMsg: @@ -247,16 +254,13 @@ func (r *ResourceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - var cmd tea.Cmd - r.table, cmd = r.table.Update(msg) - // Check if we should load more pages (infinite scroll) if r.shouldLoadNextPage() { r.isLoadingMore = true - return r, tea.Batch(cmd, r.loadNextPage) + return r, r.loadNextPage } - return r, cmd + return r, nil } // ViewString returns the view content as a string @@ -271,10 +275,9 @@ func (r *ResourceBrowser) ViewString() string { return header + "\n" + ui.DangerStyle().Render(fmt.Sprintf("Error: %v", r.err)) } - // Get selected resource summary fields var summaryFields []render.SummaryField - if len(r.filtered) > 0 && r.table.Cursor() < len(r.filtered) && r.renderer != nil { - selectedResource := dao.UnwrapResource(r.filtered[r.table.Cursor()]) + if len(r.filtered) > 0 && r.tc.Cursor() < len(r.filtered) && r.renderer != nil { + selectedResource := dao.UnwrapResource(r.filtered[r.tc.Cursor()]) summaryFields = r.renderer.RenderSummary(selectedResource) } @@ -314,7 +317,7 @@ func (r *ResourceBrowser) ViewString() string { ui.DimStyle().Render("No resources found") } - return headerPanel + "\n" + tabsView + "\n" + filterView + r.table.View() + return headerPanel + "\n" + tabsView + "\n" + filterView + r.tableContent } // View implements tea.Model diff --git a/internal/view/resource_browser_fetch.go b/internal/view/resource_browser_fetch.go index f3375d1..c0131f5 100644 --- a/internal/view/resource_browser_fetch.go +++ b/internal/view/resource_browser_fetch.go @@ -385,7 +385,7 @@ func (r *ResourceBrowser) shouldLoadNextPage() bool { return false } buffer := 10 - return r.table.Cursor() >= len(r.filtered)-buffer + return r.tc.Cursor() >= len(r.filtered)-buffer } func (r *ResourceBrowser) loadNextPage() tea.Msg { diff --git a/internal/view/resource_browser_input.go b/internal/view/resource_browser_input.go index de02c2f..eade01b 100644 --- a/internal/view/resource_browser_input.go +++ b/internal/view/resource_browser_input.go @@ -14,7 +14,7 @@ func (r *ResourceBrowser) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cm return r.handleFilterInput(msg) } - if len(r.filtered) > 0 && r.table.Cursor() < len(r.filtered) { + if len(r.filtered) > 0 && r.tc.Cursor() < len(r.filtered) { if nav, cmd := r.handleNavigation(msg.String()); cmd != nil { return nav, cmd } @@ -53,6 +53,36 @@ func (r *ResourceBrowser) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cm return r.handleCopyID() case "Y": return r.handleCopyARN() + case "j", "down": + r.tc.SetCursor(r.tc.Cursor()+1, len(r.filtered)) + r.tc.UpdateScrollOffset(len(r.filtered)) + r.buildTable() + return r, nil + case "k", "up": + r.tc.SetCursor(r.tc.Cursor()-1, len(r.filtered)) + r.tc.UpdateScrollOffset(len(r.filtered)) + r.buildTable() + return r, nil + case "ctrl+d", "pgdown": + r.tc.SetCursor(r.tc.Cursor()+r.tc.TableHeight()/2, len(r.filtered)) + r.tc.UpdateScrollOffset(len(r.filtered)) + r.buildTable() + return r, nil + case "ctrl+u", "pgup": + r.tc.SetCursor(r.tc.Cursor()-r.tc.TableHeight()/2, len(r.filtered)) + r.tc.UpdateScrollOffset(len(r.filtered)) + r.buildTable() + return r, nil + case "g", "home": + r.tc.SetCursor(0, len(r.filtered)) + r.tc.UpdateScrollOffset(len(r.filtered)) + r.buildTable() + return r, nil + case "G", "end": + r.tc.SetCursor(len(r.filtered)-1, len(r.filtered)) + r.tc.UpdateScrollOffset(len(r.filtered)) + r.buildTable() + return r, nil } return nil, nil @@ -113,7 +143,7 @@ func (r *ResourceBrowser) handleEsc() (tea.Model, tea.Cmd) { } func (r *ResourceBrowser) handleMark() (tea.Model, tea.Cmd) { - cursor := r.table.Cursor() + cursor := r.tc.Cursor() if len(r.filtered) > 0 && cursor >= 0 && cursor < len(r.filtered) { resource := r.filtered[cursor] if r.markedResource != nil && r.markedResource.GetID() == resource.GetID() { @@ -139,7 +169,7 @@ func (r *ResourceBrowser) handleMetricsToggle() (tea.Model, tea.Cmd) { } func (r *ResourceBrowser) handleEnter() (tea.Model, tea.Cmd) { - cursor := r.table.Cursor() + cursor := r.tc.Cursor() if len(r.filtered) > 0 && cursor >= 0 && cursor < len(r.filtered) { ctx, resource := r.contextForResource(r.filtered[cursor]) if r.markedResource != nil && r.markedResource.GetID() != resource.GetID() { @@ -157,7 +187,7 @@ func (r *ResourceBrowser) handleEnter() (tea.Model, tea.Cmd) { } func (r *ResourceBrowser) handleAction() (tea.Model, tea.Cmd) { - cursor := r.table.Cursor() + cursor := r.tc.Cursor() if len(r.filtered) > 0 && cursor >= 0 && cursor < len(r.filtered) { if actions := action.Global.Get(r.service, r.resourceType); len(actions) > 0 { ctx, resource := r.contextForResource(r.filtered[cursor]) @@ -194,14 +224,22 @@ func (r *ResourceBrowser) handleLoadNextPage() (tea.Model, tea.Cmd) { } func (r *ResourceBrowser) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - r.table, cmd = r.table.Update(msg) - return r, cmd + delta := 0 + switch msg.Button { + case tea.MouseWheelUp: + delta = -3 + case tea.MouseWheelDown: + delta = 3 + } + r.tc.AdjustScrollOffset(delta, len(r.filtered)) + r.buildTable() + return r, nil } func (r *ResourceBrowser) handleMouseMotion(msg tea.MouseMotionMsg) (tea.Model, tea.Cmd) { - if idx := r.getRowAtPosition(msg.Y); idx >= 0 && idx != r.table.Cursor() { - r.table.SetCursor(idx) + if idx := r.getRowAtPosition(msg.Y); idx >= 0 && idx != r.tc.Cursor() { + r.tc.SetCursor(idx, len(r.filtered)) + r.buildTable() } return r, nil } @@ -229,16 +267,18 @@ func (r *ResourceBrowser) getRowAtPosition(y int) int { headerHeight++ } tableHeaderRows := 1 - row := y - headerHeight - tableHeaderRows - if row >= 0 && row < len(r.filtered) { - return row + visualRow := y - headerHeight - tableHeaderRows + dataIdx := visualRow + r.tc.ScrollOffset() + if visualRow >= 0 && dataIdx >= 0 && dataIdx < len(r.filtered) { + return dataIdx } return -1 } func (r *ResourceBrowser) handleMouseClick(x, y int) (tea.Model, tea.Cmd) { if row := r.getRowAtPosition(y); row >= 0 { - r.table.SetCursor(row) + r.tc.SetCursor(row, len(r.filtered)) + r.buildTable() return r.openDetailView() } return r, nil @@ -272,7 +312,7 @@ func (r *ResourceBrowser) switchToTab(idx int) (tea.Model, tea.Cmd) { } func (r *ResourceBrowser) openDetailView() (tea.Model, tea.Cmd) { - cursor := r.table.Cursor() + cursor := r.tc.Cursor() if len(r.filtered) == 0 || cursor < 0 || cursor >= len(r.filtered) { return r, nil } @@ -284,7 +324,7 @@ func (r *ResourceBrowser) openDetailView() (tea.Model, tea.Cmd) { } func (r *ResourceBrowser) handleCopyID() (tea.Model, tea.Cmd) { - cursor := r.table.Cursor() + cursor := r.tc.Cursor() if len(r.filtered) > 0 && cursor >= 0 && cursor < len(r.filtered) { resource := dao.UnwrapResource(r.filtered[cursor]) return r, clipboard.CopyID(resource.GetID()) @@ -293,7 +333,7 @@ func (r *ResourceBrowser) handleCopyID() (tea.Model, tea.Cmd) { } func (r *ResourceBrowser) handleCopyARN() (tea.Model, tea.Cmd) { - cursor := r.table.Cursor() + cursor := r.tc.Cursor() if len(r.filtered) > 0 && cursor >= 0 && cursor < len(r.filtered) { resource := dao.UnwrapResource(r.filtered[cursor]) if arn := resource.GetARN(); arn != "" { diff --git a/internal/view/resource_browser_nav.go b/internal/view/resource_browser_nav.go index b2d2a8a..f60f63f 100644 --- a/internal/view/resource_browser_nav.go +++ b/internal/view/resource_browser_nav.go @@ -16,7 +16,7 @@ func (r *ResourceBrowser) handleNavigation(key string) (tea.Model, tea.Cmd) { return nil, nil } - ctx, resource := r.contextForResource(r.filtered[r.table.Cursor()]) + ctx, resource := r.contextForResource(r.filtered[r.tc.Cursor()]) helper := &NavigationHelper{ Ctx: ctx, @@ -54,6 +54,10 @@ func (r *ResourceBrowser) cycleResourceType(delta int) { // StatusLine implements View interface func (r *ResourceBrowser) StatusLine() string { + if r.filterActive { + return fmt.Sprintf("/%s • %d/%d items • Esc:done Enter:apply", r.filterInput.Value(), len(r.filtered), len(r.resources)) + } + total := len(r.resources) shown := len(r.filtered) hasActions := len(action.Global.Get(r.service, r.resourceType)) > 0 @@ -168,6 +172,6 @@ func (r *ResourceBrowser) getNavigationShortcuts() string { } helper := &NavigationHelper{Renderer: r.renderer} - resource := dao.UnwrapResource(r.filtered[r.table.Cursor()]) + resource := dao.UnwrapResource(r.filtered[r.tc.Cursor()]) return helper.FormatShortcuts(resource) } diff --git a/internal/view/resource_browser_table.go b/internal/view/resource_browser_table.go index 791031f..45e64e1 100644 --- a/internal/view/resource_browser_table.go +++ b/internal/view/resource_browser_table.go @@ -1,29 +1,42 @@ package view import ( - "charm.land/bubbles/v2/table" - "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" "github.com/clawscli/claws/internal/config" "github.com/clawscli/claws/internal/dao" "github.com/clawscli/claws/internal/metrics" "github.com/clawscli/claws/internal/render" - "github.com/clawscli/claws/internal/ui" ) +const ( + markColWidth = 3 + profileColWidth = 16 + accountColWidth = 14 + regionColWidth = 14 +) + +func (r *ResourceBrowser) Cursor() int { + return r.tc.Cursor() +} + +func (r *ResourceBrowser) SetCursor(n int) { + r.tc.SetCursor(n, len(r.filtered)) +} + func (r *ResourceBrowser) buildTable() { if r.renderer == nil { + r.tableContent = "" return } - currentCursor := r.table.Cursor() - cols := r.renderer.Columns() + r.tc.SetCursor(r.tc.Cursor(), len(r.filtered)) - const markColWidth = 2 - const profileColWidth = 16 - const accountColWidth = 14 - const regionColWidth = 14 - metricsColWidth := metrics.ColumnWidth + cols := r.renderer.Columns() + if len(cols) == 0 { + r.tableContent = "" + return + } effectiveMetricsEnabled := r.metricsEnabled && r.getMetricSpec() != nil isMultiProfile := config.Global().IsMultiProfile() @@ -38,59 +51,24 @@ func (r *ResourceBrowser) buildTable() { if effectiveMetricsEnabled { numCols++ } - tableCols := make([]table.Column, numCols) - tableCols[0] = table.Column{Title: " ", Width: markColWidth} - totalColWidth := markColWidth - for _, col := range cols { - totalColWidth += col.Width - } - if isMultiProfile { - totalColWidth += profileColWidth + accountColWidth + regionColWidth - } else if isMultiRegion { - totalColWidth += regionColWidth - } - if effectiveMetricsEnabled { - totalColWidth += metricsColWidth - } - - extraWidth := r.width - totalColWidth - if extraWidth < 0 { - extraWidth = 0 - } - - hasTrailingCols := isMultiProfile || isMultiRegion || effectiveMetricsEnabled + headers := make([]string, numCols) + headers[0] = "" colIdx := 1 for i, col := range cols { - title := col.Name + r.getSortIndicator(i) - width := col.Width - if i == len(cols)-1 && !hasTrailingCols { - width += extraWidth - } - tableCols[colIdx] = table.Column{ - Title: title, - Width: width, - } + headers[colIdx] = col.Name + r.getSortIndicator(i) colIdx++ } if isMultiProfile { - tableCols[colIdx] = table.Column{Title: "PROFILE", Width: profileColWidth} + headers[colIdx] = "PROFILE" colIdx++ - tableCols[colIdx] = table.Column{Title: "ACCOUNT", Width: accountColWidth} + headers[colIdx] = "ACCOUNT" colIdx++ - width := regionColWidth - if !effectiveMetricsEnabled { - width += extraWidth - } - tableCols[colIdx] = table.Column{Title: "REGION", Width: width} + headers[colIdx] = "REGION" colIdx++ } else if isMultiRegion { - width := regionColWidth - if !effectiveMetricsEnabled { - width += extraWidth - } - tableCols[colIdx] = table.Column{Title: "REGION", Width: width} + headers[colIdx] = "REGION" colIdx++ } @@ -100,21 +78,48 @@ func (r *ResourceBrowser) buildTable() { if spec != nil { header = spec.ColumnHeader } - tableCols[colIdx] = table.Column{ - Title: header, - Width: metricsColWidth + extraWidth, - } + headers[colIdx] = header + } + + var summaryFields []render.SummaryField + cursor := r.tc.Cursor() + if len(r.filtered) > 0 && cursor >= 0 && cursor < len(r.filtered) { + summaryFields = r.renderer.RenderSummary(dao.UnwrapResource(r.filtered[cursor])) } + headerStr := r.headerPanel.Render(r.service, r.resourceType, summaryFields) + headerHeight := r.headerPanel.Height(headerStr) - rows := make([]table.Row, len(r.filtered)) - for i, res := range r.filtered { + tableHeight := r.height - headerHeight - 1 + if tableHeight < 1 { + tableHeight = 1 + } + r.tc.SetTableHeight(tableHeight) + + widths := r.calculateColumnWidths(cols, isMultiProfile, isMultiRegion, effectiveMetricsEnabled, numCols) + + t := table.New(). + Headers(headers...). + Width(r.width). + Height(tableHeight). + Wrap(false). + BorderTop(false). + BorderBottom(false). + BorderLeft(false). + BorderRight(false). + BorderColumn(false). + BorderHeader(true). + BorderStyle(TableBorderStyle()). + StyleFunc(NewTableStyleFunc(widths, cursor)) + + for _, res := range r.filtered { row := r.renderer.RenderRow(dao.UnwrapResource(res), cols) - markIndicator := " " + mark := " " if r.markedResource != nil && r.markedResource.GetID() == res.GetID() { - markIndicator = "◆ " + mark = "◆" } - fullRow := make(table.Row, numCols) - fullRow[0] = markIndicator + + fullRow := make([]string, numCols) + fullRow[0] = mark copy(fullRow[1:], row) rowIdx := len(cols) + 1 @@ -139,57 +144,75 @@ func (r *ResourceBrowser) buildTable() { } else if effectiveMetricsEnabled { fullRow[rowIdx] = metrics.RenderSparkline(nil, "") } - rows[i] = fullRow + + t = t.Row(fullRow...) } - // Calculate header height dynamically - var summaryFields []render.SummaryField - if len(r.filtered) > 0 && currentCursor >= 0 && currentCursor < len(r.filtered) { - summaryFields = r.renderer.RenderSummary(dao.UnwrapResource(r.filtered[currentCursor])) + if r.tc.ScrollOffset() > 0 { + t = t.YOffset(r.tc.ScrollOffset()) } - headerStr := r.headerPanel.Render(r.service, r.resourceType, summaryFields) - headerHeight := r.headerPanel.Height(headerStr) - // height - header - tabs(1) - tableHeight := r.height - headerHeight - 1 - if tableHeight < 5 { - tableHeight = 5 - } - - t := table.New( - table.WithColumns(tableCols), - table.WithRows(rows), - table.WithFocused(true), - table.WithHeight(tableHeight), - table.WithWidth(r.width), - ) - - th := ui.Current() - s := table.DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(th.TableBorder). - BorderBottom(true). - Bold(true). - Foreground(th.TableHeaderText). - Background(th.TableHeader) - s.Selected = s.Selected. - Foreground(th.SelectionText). - Background(th.Selection). - Bold(false) - // Note: Not setting s.Cell foreground - let Selected style take precedence - t.SetStyles(s) - - // Restore cursor position (clamped to valid range) - if len(rows) > 0 { - if currentCursor >= len(rows) { - currentCursor = len(rows) - 1 + r.tableContent = t.String() +} + +func (r *ResourceBrowser) calculateColumnWidths(cols []render.Column, isMultiProfile, isMultiRegion, hasMetrics bool, numCols int) []int { + metricsColWidth := metrics.ColumnWidth + + totalColWidth := markColWidth + for _, col := range cols { + totalColWidth += col.Width + } + if isMultiProfile { + totalColWidth += profileColWidth + accountColWidth + regionColWidth + } else if isMultiRegion { + totalColWidth += regionColWidth + } + if hasMetrics { + totalColWidth += metricsColWidth + } + + extraWidth := r.width - totalColWidth + if extraWidth < 0 { + extraWidth = 0 + } + + hasTrailingCols := isMultiProfile || isMultiRegion || hasMetrics + widths := make([]int, numCols) + widths[0] = markColWidth + + colIdx := 1 + for i, col := range cols { + w := col.Width + if i == len(cols)-1 && !hasTrailingCols { + w += extraWidth } - if currentCursor < 0 { - currentCursor = 0 + widths[colIdx] = w + colIdx++ + } + + if isMultiProfile { + widths[colIdx] = profileColWidth + colIdx++ + widths[colIdx] = accountColWidth + colIdx++ + w := regionColWidth + if !hasMetrics { + w += extraWidth + } + widths[colIdx] = w + colIdx++ + } else if isMultiRegion { + w := regionColWidth + if !hasMetrics { + w += extraWidth } - t.SetCursor(currentCursor) + widths[colIdx] = w + colIdx++ + } + + if hasMetrics { + widths[colIdx] = metricsColWidth + extraWidth } - r.table = t + return widths } diff --git a/internal/view/resource_browser_test.go b/internal/view/resource_browser_test.go index dca03bc..9220982 100644 --- a/internal/view/resource_browser_test.go +++ b/internal/view/resource_browser_test.go @@ -158,13 +158,13 @@ func TestResourceBrowserMouseHover(t *testing.T) { browser.applyFilter() browser.buildTable() - initialCursor := browser.table.Cursor() + initialCursor := browser.Cursor() // Simulate mouse motion motionMsg := tea.MouseMotionMsg{X: 30, Y: 10} browser.Update(motionMsg) - t.Logf("Cursor after hover: %d (was %d)", browser.table.Cursor(), initialCursor) + t.Logf("Cursor after hover: %d (was %d)", browser.Cursor(), initialCursor) } func TestResourceBrowserMouseClick(t *testing.T) { @@ -210,7 +210,7 @@ func TestResourceBrowserMarkUnmark(t *testing.T) { } // Mark first resource - browser.table.SetCursor(0) + browser.SetCursor(0) mMsg := tea.KeyPressMsg{Code: 'm'} browser.Update(mMsg) @@ -229,9 +229,9 @@ func TestResourceBrowserMarkUnmark(t *testing.T) { } // Mark first, then mark second (should replace) - browser.table.SetCursor(0) + browser.SetCursor(0) browser.Update(mMsg) - browser.table.SetCursor(1) + browser.SetCursor(1) browser.Update(mMsg) if browser.markedResource == nil { @@ -259,7 +259,7 @@ func TestResourceBrowserMarkClearedOnResourceTypeSwitch(t *testing.T) { browser.applyFilter() browser.buildTable() - browser.table.SetCursor(0) + browser.SetCursor(0) mMsg := tea.KeyPressMsg{Code: 'm'} browser.Update(mMsg) @@ -281,7 +281,7 @@ func TestResourceBrowserMarkClearedOnResourceTypeSwitch(t *testing.T) { } browser.applyFilter() browser.buildTable() - browser.table.SetCursor(0) + browser.SetCursor(0) browser.Update(mMsg) if browser.markedResource == nil { @@ -314,7 +314,7 @@ func TestResourceBrowserMarkClearedOnFilter(t *testing.T) { browser.buildTable() // Mark the first resource - browser.table.SetCursor(0) + browser.SetCursor(0) mMsg := tea.KeyPressMsg{Code: 'm'} browser.Update(mMsg) @@ -358,7 +358,7 @@ func TestResourceBrowserDiffHintVisibility(t *testing.T) { } // Mark a resource: should show "d:diff" - browser.table.SetCursor(0) + browser.SetCursor(0) mMsg := tea.KeyPressMsg{Code: 'm'} browser.Update(mMsg) @@ -389,7 +389,7 @@ func TestResourceBrowserMarkColumnRendering(t *testing.T) { t.Error("Expected non-empty view") } - browser.table.SetCursor(0) + browser.SetCursor(0) mMsg := tea.KeyPressMsg{Code: 'm'} browser.Update(mMsg) @@ -414,7 +414,7 @@ func TestResourceBrowserEscClearsMark(t *testing.T) { browser.buildTable() // Mark a resource - browser.table.SetCursor(0) + browser.SetCursor(0) mMsg := tea.KeyPressMsg{Code: 'm'} browser.Update(mMsg) @@ -450,14 +450,14 @@ func TestResourceBrowserDiffNavigation(t *testing.T) { browser.applyFilter() browser.buildTable() - browser.table.SetCursor(0) + browser.SetCursor(0) browser.Update(tea.KeyPressMsg{Code: 'm'}) if browser.markedResource == nil { t.Fatal("Expected resource to be marked") } - browser.table.SetCursor(1) + browser.SetCursor(1) _, cmd := browser.Update(tea.KeyPressMsg{Code: 'd'}) if cmd == nil { @@ -619,7 +619,7 @@ func TestResourceBrowserCopyID(t *testing.T) { } browser.applyFilter() browser.buildTable() - browser.table.SetCursor(0) + browser.SetCursor(0) _, cmd := browser.Update(tea.KeyPressMsg{Code: 'y'}) if cmd == nil { @@ -645,7 +645,7 @@ func TestResourceBrowserCopyARN(t *testing.T) { } browser.applyFilter() browser.buildTable() - browser.table.SetCursor(0) + browser.SetCursor(0) _, cmd := browser.Update(tea.KeyPressMsg{Code: 'Y'}) if cmd == nil { @@ -666,7 +666,7 @@ func TestResourceBrowserCopyARNNoARN(t *testing.T) { } browser.applyFilter() browser.buildTable() - browser.table.SetCursor(0) + browser.SetCursor(0) _, cmd := browser.Update(tea.KeyPressMsg{Code: 'Y'}) if cmd == nil { diff --git a/internal/view/resource_browser_update.go b/internal/view/resource_browser_update.go index c55e647..2cd5a1c 100644 --- a/internal/view/resource_browser_update.go +++ b/internal/view/resource_browser_update.go @@ -129,8 +129,8 @@ func (r *ResourceBrowser) handleDiffMsg(msg DiffMsg) (tea.Model, tea.Cmd) { } if msg.LeftName == "" { - if len(r.filtered) > 0 && r.table.Cursor() < len(r.filtered) { - leftRes = r.filtered[r.table.Cursor()] + if len(r.filtered) > 0 && r.tc.Cursor() < len(r.filtered) { + leftRes = r.filtered[r.tc.Cursor()] } } else { for _, res := range r.filtered { diff --git a/internal/view/service_browser.go b/internal/view/service_browser.go index 30c44e2..8f63a2a 100644 --- a/internal/view/service_browser.go +++ b/internal/view/service_browser.go @@ -2,6 +2,7 @@ package view import ( "context" + "fmt" "strings" "charm.land/bubbles/v2/textinput" @@ -88,14 +89,8 @@ func newServiceBrowserStyles() serviceBrowserStyles { Bold(true). MarginTop(1). MarginBottom(0), - cell: lipgloss.NewStyle(). - Width(cellWidth). - Height(cellHeight). - Padding(0, 1), - cellSelected: ui.SelectedStyle(). - Width(cellWidth). - Height(cellHeight). - Padding(0, 1), + cell: ui.CellStyle(cellWidth, cellHeight), + cellSelected: ui.SelectedStyle().Width(cellWidth).Height(cellHeight).Padding(0, 1), serviceName: ui.TextStyle().Bold(true), serviceNameSe: ui.TitleStyle(), aliases: ui.DimStyle(), @@ -177,6 +172,10 @@ func (s *ServiceBrowser) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case RefreshMsg: return s, s.loadServices + case ThemeChangedMsg: + s.styles = newServiceBrowserStyles() + s.headerPanel.ReloadStyles() + return s, nil case tea.KeyPressMsg: if s.filterActive { @@ -606,12 +605,12 @@ func (s *ServiceBrowser) SetSize(width, height int) tea.Cmd { // StatusLine implements View func (s *ServiceBrowser) StatusLine() string { if s.filterActive { - return "type to filter • enter:confirm • esc:cancel" + return fmt.Sprintf("/%s • %d services • Esc:done Enter:apply", s.filterInput.Value(), len(s.flatItems)) } if s.filterText != "" { - return "~:home • c:clear • enter:select • ?:help" + return fmt.Sprintf("/%s • %d services • ~:home c:clear enter:select ?:help", s.filterText, len(s.flatItems)) } - return "~:home • /:filter • enter:select • ?:help" + return "~:home /:filter enter:select ?:help" } // HasActiveInput implements InputCapture diff --git a/internal/view/table_cursor.go b/internal/view/table_cursor.go new file mode 100644 index 0000000..7f331b9 --- /dev/null +++ b/internal/view/table_cursor.go @@ -0,0 +1,85 @@ +package view + +// TableCursor manages cursor position and scroll offset for table-based views. +// Embed this struct in views that display scrollable tables. +type TableCursor struct { + cursor int + scrollOffset int + tableHeight int +} + +// Cursor returns the current cursor position. +func (c *TableCursor) Cursor() int { + return c.cursor +} + +// SetCursor sets the cursor position, clamping to valid range [0, dataLen-1]. +func (c *TableCursor) SetCursor(n int, dataLen int) { + if dataLen == 0 { + c.cursor = 0 + return + } + if n < 0 { + n = 0 + } + if n >= dataLen { + n = dataLen - 1 + } + c.cursor = n +} + +// ScrollOffset returns the current scroll offset. +func (c *TableCursor) ScrollOffset() int { + return c.scrollOffset +} + +// TableHeight returns the current table height. +func (c *TableCursor) TableHeight() int { + return c.tableHeight +} + +// SetTableHeight sets the table height. +func (c *TableCursor) SetTableHeight(h int) { + c.tableHeight = h +} + +// UpdateScrollOffset adjusts scroll offset to keep cursor visible. +func (c *TableCursor) UpdateScrollOffset(dataLen int) { + visibleRows := c.tableHeight - 2 + if visibleRows < 1 { + visibleRows = 1 + } + + if c.cursor < c.scrollOffset { + c.scrollOffset = c.cursor + } else if c.cursor >= c.scrollOffset+visibleRows { + c.scrollOffset = c.cursor - visibleRows + 1 + } + + c.clampScrollOffset(dataLen, visibleRows) +} + +// AdjustScrollOffset adjusts scroll offset by delta (for mouse wheel). +// Returns the new scroll offset. +func (c *TableCursor) AdjustScrollOffset(delta int, dataLen int) { + visibleRows := c.tableHeight - 2 + if visibleRows < 1 { + visibleRows = 1 + } + + c.scrollOffset += delta + c.clampScrollOffset(dataLen, visibleRows) +} + +func (c *TableCursor) clampScrollOffset(dataLen int, visibleRows int) { + maxOffset := dataLen - visibleRows + if maxOffset < 0 { + maxOffset = 0 + } + if c.scrollOffset > maxOffset { + c.scrollOffset = maxOffset + } + if c.scrollOffset < 0 { + c.scrollOffset = 0 + } +} diff --git a/internal/view/table_style.go b/internal/view/table_style.go new file mode 100644 index 0000000..5ec6fdf --- /dev/null +++ b/internal/view/table_style.go @@ -0,0 +1,50 @@ +package view + +import ( + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" + + "github.com/clawscli/claws/internal/ui" +) + +// NewTableStyleFunc returns a StyleFunc for lipgloss/table that applies +// consistent styling: header row with TableHeader colors, selected row +// with Selection colors, and normal rows with Text color. +// Pre-computes styles for each column to avoid per-cell allocations. +func NewTableStyleFunc(widths []int, cursor int) func(row, col int) lipgloss.Style { + th := ui.Current() + numCols := len(widths) + + headerStyles := make([]lipgloss.Style, numCols) + selectedStyles := make([]lipgloss.Style, numCols) + normalStyles := make([]lipgloss.Style, numCols) + + for col, w := range widths { + base := lipgloss.NewStyle().Width(w) + if col == 0 { + base = base.PaddingLeft(1) + } + headerStyles[col] = base.Bold(true).Foreground(th.TableHeaderText).Background(th.TableHeader) + selectedStyles[col] = base.Foreground(th.SelectionText).Background(th.Selection) + normalStyles[col] = base.Foreground(th.Text) + } + + return func(row, col int) lipgloss.Style { + if col >= numCols { + return ui.NoStyle() + } + switch { + case row == table.HeaderRow: + return headerStyles[col] + case row == cursor: + return selectedStyles[col] + default: + return normalStyles[col] + } + } +} + +// TableBorderStyle returns a style for table borders using the current theme. +func TableBorderStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(ui.Current().TableBorder) +} diff --git a/internal/view/tag_search_view.go b/internal/view/tag_search_view.go index f50beaf..45f1540 100644 --- a/internal/view/tag_search_view.go +++ b/internal/view/tag_search_view.go @@ -9,10 +9,10 @@ import ( "sync" "charm.land/bubbles/v2/spinner" - "charm.land/bubbles/v2/table" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" tagtypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types" @@ -55,7 +55,9 @@ type TagSearchView struct { tagFilter string styles tagSearchViewStyles - table table.Model + tc TableCursor + tableContent string + resources []taggedARN filtered []taggedARN loading bool @@ -293,22 +295,35 @@ func (v *TagSearchView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return v, cmd } return v, nil + case ThemeChangedMsg: + v.styles = newTagSearchViewStyles() + v.buildTable() + return v, nil case tea.MouseWheelMsg: - var cmd tea.Cmd - v.table, cmd = v.table.Update(msg) - return v, cmd + delta := 0 + switch msg.Button { + case tea.MouseWheelUp: + delta = -3 + case tea.MouseWheelDown: + delta = 3 + } + v.tc.AdjustScrollOffset(delta, len(v.filtered)) + v.buildTable() + return v, nil case tea.MouseMotionMsg: - if idx := v.getRowAtPosition(msg.Y); idx >= 0 && idx != v.table.Cursor() { - v.table.SetCursor(idx) + if idx := v.getRowAtPosition(msg.Y); idx >= 0 && idx != v.tc.Cursor() { + v.tc.SetCursor(idx, len(v.filtered)) + v.buildTable() } return v, nil case tea.MouseClickMsg: if msg.Button == tea.MouseLeft && len(v.filtered) > 0 { if idx := v.getRowAtPosition(msg.Y); idx >= 0 { - v.table.SetCursor(idx) + v.tc.SetCursor(idx, len(v.filtered)) + v.buildTable() return v.navigateToResource() } } @@ -365,23 +380,49 @@ func (v *TagSearchView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "enter", "d": - if len(v.filtered) > 0 && v.table.Cursor() < len(v.filtered) { + if len(v.filtered) > 0 && v.tc.Cursor() < len(v.filtered) { return v.navigateToResource() } case "j", "down": - v.table.MoveDown(1) + v.tc.SetCursor(v.tc.Cursor()+1, len(v.filtered)) + v.tc.UpdateScrollOffset(len(v.filtered)) + v.buildTable() return v, nil case "k", "up": - v.table.MoveUp(1) + v.tc.SetCursor(v.tc.Cursor()-1, len(v.filtered)) + v.tc.UpdateScrollOffset(len(v.filtered)) + v.buildTable() + return v, nil + + case "ctrl+d", "pgdown": + v.tc.SetCursor(v.tc.Cursor()+v.tc.TableHeight()/2, len(v.filtered)) + v.tc.UpdateScrollOffset(len(v.filtered)) + v.buildTable() + return v, nil + + case "ctrl+u", "pgup": + v.tc.SetCursor(v.tc.Cursor()-v.tc.TableHeight()/2, len(v.filtered)) + v.tc.UpdateScrollOffset(len(v.filtered)) + v.buildTable() + return v, nil + + case "g", "home": + v.tc.SetCursor(0, len(v.filtered)) + v.tc.UpdateScrollOffset(len(v.filtered)) + v.buildTable() + return v, nil + + case "G", "end": + v.tc.SetCursor(len(v.filtered)-1, len(v.filtered)) + v.tc.UpdateScrollOffset(len(v.filtered)) + v.buildTable() return v, nil } } - var cmd tea.Cmd - v.table, cmd = v.table.Update(msg) - return v, cmd + return v, nil } func (v *TagSearchView) loadNextPage() tea.Msg { @@ -399,11 +440,12 @@ func (v *TagSearchView) loadNextPage() tea.Msg { } func (v *TagSearchView) navigateToResource() (tea.Model, tea.Cmd) { - if len(v.filtered) == 0 || v.table.Cursor() >= len(v.filtered) { + cursor := v.tc.Cursor() + if len(v.filtered) == 0 || cursor >= len(v.filtered) { return v, nil } - res := v.filtered[v.table.Cursor()] + res := v.filtered[cursor] if res.ARN == nil || !res.ARN.CanNavigate() { return v, nil } @@ -454,9 +496,6 @@ func (v *TagSearchView) getResourceIDForGet(arn *aws.ARN) string { case "states": return arn.Raw case "bedrock-agentcore": - // ARN: arn:aws:bedrock-agentcore:region:account:runtime/RUNTIME_ID/runtime-endpoint/DEFAULT - // Extract just the runtime ID (first segment) for GetAgentRuntime API - // idx > 0 (not >= 0): if "/" is at position 0, the prefix would be empty string which is invalid if idx := strings.Index(arn.ResourceID, "/"); idx > 0 { return arn.ResourceID[:idx] } @@ -503,21 +542,64 @@ func (v *TagSearchView) applyFilter() { } } +func (v *TagSearchView) Cursor() int { + return v.tc.Cursor() +} + +func (v *TagSearchView) SetCursor(n int) { + v.tc.SetCursor(n, len(v.filtered)) +} + func (v *TagSearchView) buildTable() { + v.tc.SetCursor(v.tc.Cursor(), len(v.filtered)) + isMultiRegion := config.Global().IsMultiRegion() - columns := []table.Column{ - {Title: "Service", Width: 12}, - {Title: "Type", Width: 15}, - {Title: "ID", Width: 30}, - } + headers := []string{"Service", "Type", "ID"} if isMultiRegion { - columns = append(columns, table.Column{Title: "Region", Width: 14}) + headers = append(headers, "Region") + } + headers = append(headers, "Tags") + + tableHeight := v.height - 1 + if tableHeight < 1 { + tableHeight = 1 + } + v.tc.SetTableHeight(tableHeight) + + tableWidth := v.width + if tableWidth < 80 { + tableWidth = 120 } - columns = append(columns, table.Column{Title: "Tags", Width: 50}) - rows := make([]table.Row, len(v.filtered)) - for i, res := range v.filtered { + cursor := v.tc.Cursor() + + numCols := len(headers) + widths := make([]int, numCols) + baseWidth := tableWidth / numCols + remainder := tableWidth % numCols + for i := range widths { + widths[i] = baseWidth + if i < remainder { + widths[i]++ + } + } + + t := table.New(). + Headers(headers...). + Width(tableWidth). + Height(tableHeight). + Wrap(false). + BorderTop(false). + BorderBottom(false). + BorderLeft(false). + BorderRight(false). + BorderColumn(false). + BorderHeader(true). + BorderStyle(TableBorderStyle()). + StyleFunc(NewTableStyleFunc(widths, cursor)) + + for _, res := range v.filtered { service := "" resType := "" resID := "" @@ -532,45 +614,19 @@ func (v *TagSearchView) buildTable() { tagStr := formatTags(res.Tags, 50) - row := table.Row{service, resType, resID} + row := []string{service, resType, resID} if isMultiRegion { row = append(row, res.Region) } row = append(row, tagStr) - rows[i] = row + t = t.Row(row...) } - tableHeight := v.height - 4 - if tableHeight < 10 { - tableHeight = 20 - } - tableWidth := v.width - if tableWidth < 80 { - tableWidth = 120 + if v.tc.ScrollOffset() > 0 { + t = t.YOffset(v.tc.ScrollOffset()) } - tbl := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - table.WithHeight(tableHeight), - table.WithWidth(tableWidth), - ) - - s := table.DefaultStyles() - theme := ui.Current() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(theme.TableBorder). - BorderBottom(true). - Bold(false) - s.Selected = s.Selected. - Foreground(theme.SelectionText). - Background(theme.Selection). - Bold(false) - - tbl.SetStyles(s) - v.table = tbl + v.tableContent = t.String() } func (v *TagSearchView) ViewString() string { @@ -627,7 +683,7 @@ func (v *TagSearchView) ViewString() string { return header + "\n" + status + "\n" + ui.DimStyle().Render(msg) } - return header + "\n" + status + "\n" + filterView + v.table.View() + return header + "\n" + status + "\n" + filterView + v.tableContent } func (v *TagSearchView) View() tea.View { @@ -645,6 +701,10 @@ func (v *TagSearchView) SetSize(width, height int) tea.Cmd { } func (v *TagSearchView) StatusLine() string { + if v.filterActive { + return fmt.Sprintf("/%s • %d/%d items • Esc:done Enter:apply", v.filterInput.Value(), len(v.filtered), len(v.resources)) + } + count := len(v.filtered) regions := config.Global().Regions() regionInfo := "" @@ -674,9 +734,10 @@ func (v *TagSearchView) getRowAtPosition(y int) int { headerHeight++ } - row := y - headerHeight - if row >= 0 && row < len(v.filtered) { - return row + visualRow := y - headerHeight + dataIdx := visualRow + v.tc.ScrollOffset() + if visualRow >= 0 && dataIdx >= 0 && dataIdx < len(v.filtered) { + return dataIdx } return -1 } diff --git a/internal/view/view.go b/internal/view/view.go index 96edbe0..6110524 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -64,6 +64,17 @@ type DataLoadedMsg struct { // RefreshMsg tells the view to reload its data type RefreshMsg struct{} +// ThemeChangedMsg tells views to reload their cached styles +type ThemeChangedMsg struct{} + +type ThemeChangeMsg struct { + Name string +} + +type PersistenceChangeMsg struct { + Enabled bool +} + // SortMsg tells the current view to sort by the specified column type SortMsg struct { Column string // Column name to sort by (empty to clear sort) diff --git a/internal/view/view_test.go b/internal/view/view_test.go index 8f59776..cce6ad5 100644 --- a/internal/view/view_test.go +++ b/internal/view/view_test.go @@ -29,12 +29,14 @@ type mockRenderer struct { detail string } -func (m *mockRenderer) ServiceName() string { return "test" } -func (m *mockRenderer) ResourceType() string { return "items" } -func (m *mockRenderer) Columns() []render.Column { return nil } -func (m *mockRenderer) RenderRow(r dao.Resource, cols []render.Column) []string { return nil } -func (m *mockRenderer) RenderDetail(r dao.Resource) string { return m.detail } -func (m *mockRenderer) RenderSummary(r dao.Resource) []render.SummaryField { return nil } +func (m *mockRenderer) ServiceName() string { return "test" } +func (m *mockRenderer) ResourceType() string { return "items" } +func (m *mockRenderer) Columns() []render.Column { return []render.Column{{Name: "NAME", Width: 20}} } +func (m *mockRenderer) RenderRow(r dao.Resource, cols []render.Column) []string { + return []string{r.GetName()} +} +func (m *mockRenderer) RenderDetail(r dao.Resource) string { return m.detail } +func (m *mockRenderer) RenderSummary(r dao.Resource) []render.SummaryField { return nil } // IsEscKey tests