Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ favorites:
services:
- Bedrock
- ECS
contexts:
- prod-admin

ui:
boot_splash: false
Expand Down Expand Up @@ -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 |

Expand All @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
129 changes: 129 additions & 0 deletions internal/app/context_list.go
Original file line number Diff line number Diff line change
@@ -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
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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()
}
6 changes: 5 additions & 1 deletion internal/app/context_table.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 1 addition & 2 deletions internal/app/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand Down
1 change: 1 addition & 0 deletions internal/app/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
22 changes: 16 additions & 6 deletions internal/app/screen_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -323,13 +333,13 @@ func (m Model) viewContextPicker() string {
return b.String()
}
if compact {
b.WriteString(m.renderHelpBar("↑/↓: navigate • enter: switch • /: filter • a: addS: settings • q: quit"))
b.WriteString(m.renderHelpBar("↑/↓ nav • enter switch • / filter • f fava 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()
}
Expand All @@ -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]
Expand Down
107 changes: 107 additions & 0 deletions internal/app/screen_context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestContextPickerNavigationUsesTableModel(t *testing.T) {
m := New(testConfig(), "", "dev")
m.width = 80
Expand Down
Loading
Loading