From 3515fd745e3bbb7b2f98c520b7becf6a8a0425b1 Mon Sep 17 00:00:00 2001 From: Nathan Huh Date: Thu, 18 Jun 2026 15:21:58 +0900 Subject: [PATCH 1/2] feat: add favorite contexts - persist favorite context names in config alongside favorite services - group favorite contexts first in the picker without changing configured ordering - render favorite contexts with favorite styling and cover toggle behavior --- README.md | 8 +- internal/app/app.go | 4 + internal/app/context_list.go | 129 ++++++++++++++++++++++++++++ internal/app/context_table.go | 6 +- internal/app/filter.go | 3 +- internal/app/help.go | 1 + internal/app/screen_context.go | 22 +++-- internal/app/screen_context_test.go | 107 +++++++++++++++++++++++ internal/config/config.go | 69 +++++++++++++-- internal/config/config_test.go | 69 +++++++++++++++ 10 files changed, 398 insertions(+), 20 deletions(-) create mode 100644 internal/app/context_list.go diff --git a/README.md b/README.md index 86f9a88..ebb0fb0 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,8 @@ favorites: services: - Bedrock - ECS + contexts: + - prod-admin ui: boot_splash: false @@ -331,7 +333,7 @@ checks: | `C` | Open context picker | | `S` | Open settings | | `/` | Toggle filter mode on supported screens | -| `f` | Favorite/unfavorite the selected service on the service list | +| `f` | Favorite/unfavorite the selected service or context on supported lists | | `?` | Toggle context-aware shortcut help | | `Ctrl+C` | Force quit | @@ -358,11 +360,11 @@ checks: | Security Inspector | `r` run/rescan, `1`-`5` severity filter, `Enter` finding detail | | Checklist Inspector | `l` load or switch checklist files, `r` run/rerun the loaded checklist, `Enter` result detail | | Settings | `Enter`/`Space` toggle selected setting, `Esc`/`q` back | -| Context Picker | `a` add context, type or `/` filter, `s` setup selected context and quit, `y` copy selected exports and quit, filter-mode `Ctrl+S` setup selected filtered context, filter-mode `Ctrl+Y` copy selected filtered exports, `u` clear shell context and quit with a final confirmation message | +| Context Picker | `a` add context, `f` favorite/unfavorite selected context, type or `/` filter, `s` setup selected context and quit, `y` copy selected exports and quit, filter-mode `Ctrl+S` setup selected filtered context, filter-mode `Ctrl+Y` copy selected filtered exports, `u` clear shell context and quit with a final confirmation message | | ECR | `Enter` images, `d` repository detail, `/` filter, `r` refresh, image detail `c` copy digest, `t` copy tag | | Lambda | `Enter` invoke, `d` detail, `l` view CloudWatch Logs, `/` filter, `r` refresh | -The service list defaults to favorites first, then alphabetical order. Press `f` to favorite or unfavorite the selected service; favorites are saved under `favorites.services` in `config.yaml` and rendered with a distinct marker/style. The service list supports `/` filtering across service names, feature names, and feature descriptions. Shared list filters use fuzzy matching with inline match highlighting. While filter mode stays active, `↑`/`↓` continue to move through the filtered results without requiring an extra Enter first. Filtering is currently available on the service list, EC2 SSM instances, EC2 inventory instances, IAM users, VPCs, subnets, RDS instances, Route53 zones/records, CloudWatch metrics, CloudWatch log groups/streams, Secrets Manager resources, ECS clusters/services, EKS clusters/node groups/add-ons, ECR repositories/images, FIS experiment templates/history, S3 buckets/objects, Lambda functions, Bedrock API keys, and the context picker. +The service list defaults to favorites first, then alphabetical order. Press `f` to favorite or unfavorite the selected service; favorites are saved under `favorites.services` in `config.yaml` and rendered with a distinct marker/style. The context picker also supports `f`; context favorites are saved under `favorites.contexts`, displayed first in the picker, and rendered with a distinct color style while preserving the configured context order within favorite and non-favorite groups. The service list supports `/` filtering across service names, feature names, and feature descriptions. Shared list filters use fuzzy matching with inline match highlighting. While filter mode stays active, `↑`/`↓` continue to move through the filtered results without requiring an extra Enter first. Filtering is currently available on the service list, EC2 SSM instances, EC2 inventory instances, IAM users, VPCs, subnets, RDS instances, Route53 zones/records, CloudWatch metrics, CloudWatch log groups/streams, Secrets Manager resources, ECS clusters/services, EKS clusters/node groups/add-ons, ECR repositories/images, FIS experiment templates/history, S3 buckets/objects, Lambda functions, Bedrock API keys, and the context picker. The EKS Browser includes a managed add-on status view for each cluster. Add-on rows show the installed version, status, and health summary, with degraded or unhealthy add-ons highlighted so core components such as CoreDNS, kube-proxy, VPC CNI, and CSI drivers are easy to spot. diff --git a/internal/app/app.go b/internal/app/app.go index 66d95f4..e96374a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -171,6 +171,7 @@ type Model struct { filteredCtxList []config.ContextInfo ctxIdx int contextTable table.Model + favoriteContexts map[string]struct{} ctxPrevScreen screen pendingContextName string envContextName string @@ -227,8 +228,10 @@ func New(cfg *config.Config, configPath string, version string, checklistPath .. configuredChecklistPath = checklistPath[0] } var favoriteServiceNames []string + var favoriteContextNames []string if cfg != nil { favoriteServiceNames = cfg.FavoriteServices + favoriteContextNames = cfg.FavoriteContexts } model := Model{ cfg: cfg, @@ -238,6 +241,7 @@ func New(cfg *config.Config, configPath string, version string, checklistPath .. ctxPrevScreen: screenServiceList, services: services, favoriteServices: favoriteServiceSet(favoriteServiceNames), + favoriteContexts: favoriteContextSet(favoriteContextNames), bootSplash: cfg != nil && cfg.BootSplash, loadingSpinner: newLoadingSpinner(), filterTI: filterTI, diff --git a/internal/app/context_list.go b/internal/app/context_list.go new file mode 100644 index 0000000..1059f99 --- /dev/null +++ b/internal/app/context_list.go @@ -0,0 +1,129 @@ +package app + +import ( + "sort" + "strings" + + "unic/internal/config" +) + +var configSetFavoriteContextsFn = config.SetFavoriteContexts + +func favoriteContextSet(names []string) map[string]struct{} { + favorites := make(map[string]struct{}, len(names)) + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + favorites[name] = struct{}{} + } + return favorites +} + +func (m Model) isFavoriteContext(name string) bool { + _, ok := m.favoriteContexts[name] + return ok +} + +func (m *Model) contextsWithFavoriteState(contexts []config.ContextInfo) []config.ContextInfo { + updated := append([]config.ContextInfo(nil), contexts...) + if m.favoriteContexts == nil { + m.favoriteContexts = make(map[string]struct{}) + } + for i := range updated { + if updated[i].Favorite { + m.favoriteContexts[updated[i].Name] = struct{}{} + } + updated[i].Favorite = m.isFavoriteContext(updated[i].Name) + } + return updated +} + +func (m *Model) sortFavoriteContextsFirst(contexts []config.ContextInfo) { + sort.SliceStable(contexts, func(i, j int) bool { + return contexts[i].Favorite && !contexts[j].Favorite + }) +} + +func (m *Model) applyContextListFilter() { + m.filteredCtxList = applyFilter(m.ctxList, m.filterValue(filterContexts)) + m.sortFavoriteContextsFirst(m.filteredCtxList) + m.ctxIdx = clampListIndex(m.ctxIdx, len(m.filteredCtxList)) + m.syncContextTable() +} + +func (m *Model) toggleFavoriteContext(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return nil + } + if m.favoriteContexts == nil { + m.favoriteContexts = make(map[string]struct{}) + } + wasFavorite := m.isFavoriteContext(name) + neighborName := adjacentContextName(m.filteredCtxList, name) + if wasFavorite { + delete(m.favoriteContexts, name) + } else { + m.favoriteContexts[name] = struct{}{} + } + + favorites := m.favoriteContextNames() + if m.cfg != nil { + m.cfg.FavoriteContexts = favorites + } + if strings.TrimSpace(m.configPath) != "" { + if err := configSetFavoriteContextsFn(m.configPath, favorites); err != nil { + return err + } + } + + for i := range m.ctxList { + m.ctxList[i].Favorite = m.isFavoriteContext(m.ctxList[i].Name) + } + m.applyContextListFilter() + if !wasFavorite && neighborName != "" { + m.selectContextByName(neighborName) + return nil + } + m.selectContextByName(name) + return nil +} + +func adjacentContextName(contexts []config.ContextInfo, name string) string { + for i, ctx := range contexts { + if ctx.Name != name { + continue + } + if i+1 < len(contexts) { + return contexts[i+1].Name + } + if i > 0 { + return contexts[i-1].Name + } + return "" + } + return "" +} + +func (m Model) favoriteContextNames() []string { + names := make([]string, 0, len(m.favoriteContexts)) + for name := range m.favoriteContexts { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func (m *Model) selectContextByName(name string) { + for i, ctx := range m.filteredCtxList { + if ctx.Name == name { + m.ctxIdx = i + m.syncContextTable() + return + } + } + m.ctxIdx = clampListIndex(m.ctxIdx, len(m.filteredCtxList)) + m.syncContextTable() +} diff --git a/internal/app/context_table.go b/internal/app/context_table.go index 7147afe..83d6384 100644 --- a/internal/app/context_table.go +++ b/internal/app/context_table.go @@ -107,12 +107,16 @@ func contextTableRows(contexts []config.ContextInfo) []table.Row { if ctx.Current { current = "*" } + name := ctx.Name + if ctx.Favorite { + name = favoriteServiceStyle.Render(name) + } authType := ctx.AuthType if authType == "" { authType = "default" } rows = append(rows, table.Row{ - ctx.Name, + name, ctx.Region, authType, current, diff --git a/internal/app/filter.go b/internal/app/filter.go index c6db4b1..d416809 100644 --- a/internal/app/filter.go +++ b/internal/app/filter.go @@ -172,9 +172,8 @@ func (m *Model) applyFilterTarget(target filterTarget) { m.filtered = applyFilter(m.instances, m.filterValue(target)) m.instIdx = 0 case filterContexts: - m.filteredCtxList = applyFilter(m.ctxList, m.filterValue(target)) m.ctxIdx = 0 - m.syncContextTable() + m.applyContextListFilter() } } diff --git a/internal/app/help.go b/internal/app/help.go index f6c6b61..4e305d2 100644 --- a/internal/app/help.go +++ b/internal/app/help.go @@ -665,6 +665,7 @@ func (m Model) currentScreenShortcuts() []helpShortcut { {"↑/↓, j/k", "Move between contexts"}, {"/", "Start filtering contexts"}, {"enter", "Switch to the selected context"}, + {"f", "Favorite or unfavorite the selected context"}, {"s", "Set up the selected context for the shell and quit"}, {"y", "Copy shell exports for the selected context and quit"}, {"u", "Clear shell exports and current context, then quit"}, diff --git a/internal/app/screen_context.go b/internal/app/screen_context.go index 892a059..c5f5496 100644 --- a/internal/app/screen_context.go +++ b/internal/app/screen_context.go @@ -16,8 +16,9 @@ import ( func (m Model) handleContextMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { switch msg := msg.(type) { case contextsLoadedMsg: - m.ctxList = msg.contexts - m.filteredCtxList = msg.contexts + m.ctxList = m.contextsWithFavoriteState(msg.contexts) + m.filteredCtxList = append([]config.ContextInfo(nil), m.ctxList...) + m.sortFavoriteContextsFirst(m.filteredCtxList) m.ctxIdx = 0 m.contextSSOBase = config.ContextInfo{} m.contextSSOAccounts = nil @@ -186,6 +187,15 @@ func (m Model) updateContextPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } return m.beginContextExport(selected) + case "f": + selected, ok := m.selectedContextInfo() + if !ok { + return m, nil + } + if err := m.toggleFavoriteContext(selected.Name); err != nil { + m.errMsg = err.Error() + m.screen = screenError + } case "u": return m.beginContextUnset() case "a": @@ -323,13 +333,13 @@ func (m Model) viewContextPicker() string { return b.String() } if compact { - b.WriteString(m.renderHelpBar("↑/↓: navigate • enter: switch • /: filter • a: add • S: settings • q: quit")) + b.WriteString(m.renderHelpBar("↑/↓ nav • enter switch • / filter • f fav • a add • q: quit")) return b.String() } if m.cfg.ContextName != "" { - b.WriteString(m.renderHelpBar("↑/↓: navigate • type: filter • /: filter • enter: switch • s: setup • y: copy env • u: unset • a: add • S: settings • esc: clear/back • q: quit")) + b.WriteString(m.renderHelpBar("↑/↓: navigate • type: filter • /: filter • enter: switch • s: setup • y: copy env • f: favorite • u: unset • a: add • S: settings • esc: clear/back • q: quit")) } else { - b.WriteString(m.renderHelpBar("↑/↓: navigate • type: filter • /: filter • enter: switch • s: setup • y: copy env • u: unset • a: add • S: settings • q: quit")) + b.WriteString(m.renderHelpBar("↑/↓: navigate • type: filter • /: filter • enter: switch • s: setup • y: copy env • f: favorite • u: unset • a: add • S: settings • q: quit")) } return b.String() } @@ -339,7 +349,7 @@ func shouldStartContextIncrementalFilter(msg tea.KeyMsg) bool { return false } switch msg.String() { - case "/", "q", "s", "y", "u", "a", "S", "j", "k": + case "/", "q", "s", "y", "f", "u", "a", "S", "j", "k": return false } r := msg.Runes[0] diff --git a/internal/app/screen_context_test.go b/internal/app/screen_context_test.go index e72b640..8471430 100644 --- a/internal/app/screen_context_test.go +++ b/internal/app/screen_context_test.go @@ -79,6 +79,113 @@ func TestContextsLoadedSyncsContextTable(t *testing.T) { } } +func TestContextPickerFavoritesSortFirstAndRenderWithoutMarker(t *testing.T) { + cfg := testConfig() + cfg.FavoriteContexts = []string{"staging"} + m := New(cfg, "", "dev") + m.width = 80 + m.height = 20 + + updated, _ := m.Update(contextsLoadedMsg{contexts: testContexts()}) + model := updated.(Model) + + if got := model.filteredCtxList[0].Name; got != "staging" { + t.Fatalf("expected favorite context first, got %q", got) + } + if !model.filteredCtxList[0].Favorite { + t.Fatalf("expected first context to be marked favorite: %#v", model.filteredCtxList[0]) + } + rowName := model.contextTable.Rows()[0][0] + if got := stripANSI(rowName); got != "staging" { + t.Fatalf("expected favorite context name without marker, got %q", got) + } + if model.ctxIdx != 2 || model.contextTable.Cursor() != 2 { + t.Fatalf("expected current context cursor to follow prod after sorting, got idx=%d cursor=%d", model.ctxIdx, model.contextTable.Cursor()) + } +} + +func TestContextPickerFavoriteTogglePersists(t *testing.T) { + originalSetFavoriteContextsFn := configSetFavoriteContextsFn + t.Cleanup(func() { + configSetFavoriteContextsFn = originalSetFavoriteContextsFn + }) + + var gotPath string + var gotFavorites []string + configSetFavoriteContextsFn = func(path string, contexts []string) error { + gotPath = path + gotFavorites = append([]string(nil), contexts...) + return nil + } + + cfg := testConfig() + m := New(cfg, "/tmp/unic-test-config.yaml", "dev") + m.width = 80 + m.height = 20 + + updated, _ := m.Update(contextsLoadedMsg{contexts: testContexts()}) + model := updated.(Model) + for i, ctx := range model.filteredCtxList { + if ctx.Name == "staging" { + model.ctxIdx = i + model.syncContextTable() + break + } + } + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}}) + model = updated.(Model) + + if gotPath != "/tmp/unic-test-config.yaml" { + t.Fatalf("expected favorite persistence path, got %q", gotPath) + } + if len(gotFavorites) != 1 || gotFavorites[0] != "staging" { + t.Fatalf("expected persisted staging favorite, got %v", gotFavorites) + } + if got := model.filteredCtxList[0].Name; got != "staging" { + t.Fatalf("expected toggled favorite to move first, got %q", got) + } + if got := stripANSI(model.contextTable.SelectedRow()[0]); got != "prod" { + t.Fatalf("expected selection to move to adjacent context after favoriting, got %q", got) + } + if len(cfg.FavoriteContexts) != 1 || cfg.FavoriteContexts[0] != "staging" { + t.Fatalf("expected config favorite contexts to update, got %v", cfg.FavoriteContexts) + } +} + +func TestContextPickerFavoriteToggleSelectsNextContext(t *testing.T) { + originalSetFavoriteContextsFn := configSetFavoriteContextsFn + t.Cleanup(func() { + configSetFavoriteContextsFn = originalSetFavoriteContextsFn + }) + configSetFavoriteContextsFn = func(string, []string) error { return nil } + + cfg := testConfig() + m := New(cfg, "/tmp/unic-test-config.yaml", "dev") + m.width = 80 + m.height = 20 + + updated, _ := m.Update(contextsLoadedMsg{contexts: testContexts()}) + model := updated.(Model) + for i, ctx := range model.filteredCtxList { + if ctx.Name == "prod" { + model.ctxIdx = i + model.syncContextTable() + break + } + } + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}}) + model = updated.(Model) + + if got := model.filteredCtxList[0].Name; got != "prod" { + t.Fatalf("expected newly favorited prod to move first, got %q", got) + } + if got := stripANSI(model.contextTable.SelectedRow()[0]); got != "staging" { + t.Fatalf("expected selection to move to next context after favoriting, got %q", got) + } +} + func TestContextPickerNavigationUsesTableModel(t *testing.T) { m := New(testConfig(), "", "dev") m.width = 80 diff --git a/internal/config/config.go b/internal/config/config.go index 8476225..f90a0fa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,6 +36,7 @@ type fileDefaults struct { type fileFavorites struct { Services []string `yaml:"services,omitempty"` + Contexts []string `yaml:"contexts,omitempty"` } type fileUI struct { @@ -86,6 +87,7 @@ type Config struct { SSOAccountID string SSORoleName string FavoriteServices []string + FavoriteContexts []string BootSplash bool BootSplashSeen string } @@ -120,6 +122,7 @@ type ContextInfo struct { SSOAccountID string SSORoleName string Current bool + Favorite bool } // FilterText returns a lowercase string for keyword matching. @@ -205,6 +208,7 @@ func Load(cliProfile, cliRegion *string, configPath string) (*Config, error) { SSOAccountID: ssoAccountID, SSORoleName: ssoRoleName, FavoriteServices: normalizeFavoriteServices(fc.Favorites.Services), + FavoriteContexts: normalizeFavoriteContexts(fc.Favorites.Contexts), BootSplash: boolValue(fc.UI.BootSplash, false), BootSplashSeen: fc.UI.LastBootSplashVersion, }, nil @@ -251,6 +255,7 @@ func LoadNamedContext(configPath, name string) (*Config, error) { SSOAccountID: ctx.SSOAccountID, SSORoleName: ctx.SSORoleName, FavoriteServices: normalizeFavoriteServices(fc.Favorites.Services), + FavoriteContexts: normalizeFavoriteContexts(fc.Favorites.Contexts), BootSplash: boolValue(fc.UI.BootSplash, false), BootSplashSeen: fc.UI.LastBootSplashVersion, }, nil @@ -268,18 +273,26 @@ func boolValue(v *bool, fallback bool) bool { } func normalizeFavoriteServices(services []string) []string { - seen := make(map[string]struct{}, len(services)) - normalized := make([]string, 0, len(services)) - for _, service := range services { - service = strings.TrimSpace(service) - if service == "" { + return normalizeFavoriteNames(services) +} + +func normalizeFavoriteContexts(contexts []string) []string { + return normalizeFavoriteNames(contexts) +} + +func normalizeFavoriteNames(names []string) []string { + seen := make(map[string]struct{}, len(names)) + normalized := make([]string, 0, len(names)) + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { continue } - if _, ok := seen[service]; ok { + if _, ok := seen[name]; ok { continue } - seen[service] = struct{}{} - normalized = append(normalized, service) + seen[name] = struct{}{} + normalized = append(normalized, name) } sort.Strings(normalized) return normalized @@ -297,8 +310,10 @@ func Contexts(configPath string) ([]ContextInfo, error) { return nil, fmt.Errorf("failed to parse %s: %w", configPath, err) } + favoriteContexts := favoriteContextSet(fc.Favorites.Contexts) var infos []ContextInfo for _, ctx := range fc.Contexts { + _, favorite := favoriteContexts[ctx.Name] infos = append(infos, ContextInfo{ Name: ctx.Name, Order: ctx.Order, @@ -311,6 +326,7 @@ func Contexts(configPath string) ([]ContextInfo, error) { SSOAccountID: ctx.SSOAccountID, SSORoleName: ctx.SSORoleName, Current: ctx.Name == fc.Current, + Favorite: favorite, }) } sort.SliceStable(infos, func(i, j int) bool { @@ -328,6 +344,14 @@ func Contexts(configPath string) ([]ContextInfo, error) { return infos, nil } +func favoriteContextSet(names []string) map[string]struct{} { + favorites := make(map[string]struct{}, len(names)) + for _, name := range normalizeFavoriteContexts(names) { + favorites[name] = struct{}{} + } + return favorites +} + // SetCurrent updates the "current" field in the config file. func SetCurrent(configPath, name string) error { data, err := os.ReadFile(configPath) @@ -647,6 +671,35 @@ func SetFavoriteServices(configPath string, services []string) error { return nil } +// SetFavoriteContexts updates the user's preferred context ordering. +func SetFavoriteContexts(configPath string, contexts []string) error { + data, err := os.ReadFile(configPath) + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("failed to read config: %w", err) + } + data = []byte(defaultContent()) + } + + var fc fileConfig + if err := yaml.Unmarshal(data, &fc); err != nil { + return fmt.Errorf("failed to parse %s: %w", configPath, err) + } + fc.Favorites.Contexts = normalizeFavoriteContexts(contexts) + + out, err := yaml.Marshal(&fc) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + if err := os.WriteFile(configPath, out, 0644); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + return nil +} + // SetBootSplashEnabled updates whether the startup splash should run on every launch. func SetBootSplashEnabled(configPath string, enabled bool) error { fc, err := readFileConfigOrDefault(configPath) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 32fdd1c..a2536c1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -111,6 +111,30 @@ favorites: } } +func TestLoadReadsFavoriteContexts(t *testing.T) { + dir := t.TempDir() + path := writeUnicConfig(t, dir, ` +favorites: + contexts: + - prod + - staging + - prod +`) + cfg, err := Load(nil, nil, path) + if err != nil { + t.Fatal(err) + } + want := []string{"prod", "staging"} + if len(cfg.FavoriteContexts) != len(want) { + t.Fatalf("expected favorite contexts %v, got %v", want, cfg.FavoriteContexts) + } + for i := range want { + if cfg.FavoriteContexts[i] != want[i] { + t.Fatalf("expected favorite contexts %v, got %v", want, cfg.FavoriteContexts) + } + } +} + func TestCLIProfileWithConfigRegion(t *testing.T) { dir := t.TempDir() path := writeUnicConfig(t, dir, ` @@ -158,6 +182,45 @@ favorites: } } +func TestSetFavoriteContextsWritesConfig(t *testing.T) { + dir := t.TempDir() + path := writeUnicConfig(t, dir, ` +default_region: ap-northeast-2 +favorites: + services: + - EC2 + contexts: + - dev +contexts: + - name: dev + profile: dev-profile + - name: prod + profile: prod-profile +`) + if err := SetFavoriteContexts(path, []string{"prod", "staging", "prod"}); err != nil { + t.Fatal(err) + } + cfg, err := Load(nil, nil, path) + if err != nil { + t.Fatal(err) + } + want := []string{"prod", "staging"} + if len(cfg.FavoriteContexts) != len(want) { + t.Fatalf("expected favorite contexts %v, got %v", want, cfg.FavoriteContexts) + } + for i := range want { + if cfg.FavoriteContexts[i] != want[i] { + t.Fatalf("expected favorite contexts %v, got %v", want, cfg.FavoriteContexts) + } + } + if len(cfg.FavoriteServices) != 1 || cfg.FavoriteServices[0] != "EC2" { + t.Fatalf("expected favorite services to be preserved, got %v", cfg.FavoriteServices) + } + if cfg.Region != "ap-northeast-2" { + t.Fatalf("expected existing region to be preserved, got %q", cfg.Region) + } +} + func TestLoadReadsBootSplashSettings(t *testing.T) { dir := t.TempDir() path := writeUnicConfig(t, dir, ` @@ -407,6 +470,9 @@ func TestContextsRespectsExplicitOrderBeforeFallbackOrder(t *testing.T) { dir := t.TempDir() path := writeUnicConfig(t, dir, ` current: dev +favorites: + contexts: + - prod contexts: - name: prod profile: prod-profile @@ -431,6 +497,9 @@ contexts: if infos[0].Order != 10 || infos[1].Order != 20 || infos[2].Order != 0 { t.Fatalf("unexpected order values: %#v", infos) } + if !infos[2].Favorite { + t.Fatalf("expected prod favorite flag without changing context order: %#v", infos) + } } func TestSetContextOrder(t *testing.T) { From 68c69d95100fdc7fb57309958c9eb607d22c729b Mon Sep 17 00:00:00 2001 From: Nathan Huh Date: Thu, 18 Jun 2026 15:35:51 +0900 Subject: [PATCH 2/2] fix: keep favorite context toggles atomic - persist favorite context changes before mutating model state - add regression coverage for favorite persistence failures --- internal/app/context_list.go | 30 ++++++++++++++----- internal/app/screen_context_test.go | 45 +++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/internal/app/context_list.go b/internal/app/context_list.go index 1059f99..025bb61 100644 --- a/internal/app/context_list.go +++ b/internal/app/context_list.go @@ -63,22 +63,24 @@ func (m *Model) toggleFavoriteContext(name string) error { } wasFavorite := m.isFavoriteContext(name) neighborName := adjacentContextName(m.filteredCtxList, name) + nextFavorites := copyFavoriteContextSet(m.favoriteContexts) if wasFavorite { - delete(m.favoriteContexts, name) + delete(nextFavorites, name) } else { - m.favoriteContexts[name] = struct{}{} + nextFavorites[name] = struct{}{} } - favorites := m.favoriteContextNames() - if m.cfg != nil { - m.cfg.FavoriteContexts = favorites - } + favorites := favoriteContextNames(nextFavorites) if strings.TrimSpace(m.configPath) != "" { if err := configSetFavoriteContextsFn(m.configPath, favorites); err != nil { return err } } + m.favoriteContexts = nextFavorites + if m.cfg != nil { + m.cfg.FavoriteContexts = favorites + } for i := range m.ctxList { m.ctxList[i].Favorite = m.isFavoriteContext(m.ctxList[i].Name) } @@ -107,9 +109,21 @@ func adjacentContextName(contexts []config.ContextInfo, name string) string { return "" } +func copyFavoriteContextSet(favorites map[string]struct{}) map[string]struct{} { + copied := make(map[string]struct{}, len(favorites)) + for name := range favorites { + copied[name] = struct{}{} + } + return copied +} + func (m Model) favoriteContextNames() []string { - names := make([]string, 0, len(m.favoriteContexts)) - for name := range m.favoriteContexts { + return favoriteContextNames(m.favoriteContexts) +} + +func favoriteContextNames(favorites map[string]struct{}) []string { + names := make([]string, 0, len(favorites)) + for name := range favorites { names = append(names, name) } sort.Strings(names) diff --git a/internal/app/screen_context_test.go b/internal/app/screen_context_test.go index 8471430..e8e0af9 100644 --- a/internal/app/screen_context_test.go +++ b/internal/app/screen_context_test.go @@ -2,6 +2,7 @@ package app import ( "context" + "errors" "fmt" "os" "os/exec" @@ -153,6 +154,50 @@ func TestContextPickerFavoriteTogglePersists(t *testing.T) { } } +func TestContextPickerFavoriteTogglePersistenceErrorDoesNotCommitState(t *testing.T) { + originalSetFavoriteContextsFn := configSetFavoriteContextsFn + t.Cleanup(func() { + configSetFavoriteContextsFn = originalSetFavoriteContextsFn + }) + configSetFavoriteContextsFn = func(string, []string) error { + return errors.New("write failed") + } + + cfg := testConfig() + m := New(cfg, "/tmp/unic-test-config.yaml", "dev") + m.width = 80 + m.height = 20 + + updated, _ := m.Update(contextsLoadedMsg{contexts: testContexts()}) + model := updated.(Model) + for i, ctx := range model.filteredCtxList { + if ctx.Name == "staging" { + model.ctxIdx = i + model.syncContextTable() + break + } + } + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}}) + model = updated.(Model) + + if model.screen != screenError { + t.Fatalf("expected persistence error to open error screen, got %v", model.screen) + } + if !strings.Contains(model.errMsg, "write failed") { + t.Fatalf("expected error message to include write failure, got %q", model.errMsg) + } + if model.isFavoriteContext("staging") { + t.Fatal("expected failed persistence to leave favorite context state unchanged") + } + if len(cfg.FavoriteContexts) != 0 { + t.Fatalf("expected config favorite contexts to remain unchanged, got %v", cfg.FavoriteContexts) + } + if model.filteredCtxList[0].Name == "staging" || model.filteredCtxList[2].Favorite { + t.Fatalf("expected filtered contexts to remain unchanged after failed favorite write: %#v", model.filteredCtxList) + } +} + func TestContextPickerFavoriteToggleSelectsNextContext(t *testing.T) { originalSetFavoriteContextsFn := configSetFavoriteContextsFn t.Cleanup(func() {