From 0fa50dd31fccaf4dcc3a66d78289936cb63ad875 Mon Sep 17 00:00:00 2001 From: Nathan Huh Date: Thu, 21 May 2026 17:51:38 +0900 Subject: [PATCH] feat: add context filter modifier actions - Handle ctrl+y and ctrl+s while context filtering is active - Keep plain y/s as filter input and document the shortcuts - Cover filter-mode copy/setup behavior in context picker tests --- README.md | 2 +- internal/app/help.go | 6 + internal/app/screen_context.go | 21 +++ internal/app/screen_context_test.go | 201 ++++++++++++++++++++++++++++ 4 files changed, 229 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index de0304d..764c8cf 100644 --- a/README.md +++ b/README.md @@ -348,7 +348,7 @@ checks: | Inspector Mode | `i` open mode from the service list, `Enter` open the selected workflow, `l` open the checklist file picker | | 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 | -| Context Picker | `a` add context, type or `/` filter, `s` setup selected context and quit, `y` copy selected exports and quit, `u` clear shell context and quit with a final confirmation message | +| 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 | | 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 | diff --git a/internal/app/help.go b/internal/app/help.go index d7cecb0..f9d7342 100644 --- a/internal/app/help.go +++ b/internal/app/help.go @@ -89,6 +89,12 @@ func (m Model) helpModeShortcuts() []helpShortcut { {"backspace", "Delete the previous character"}, {"↑/↓", "Move through filtered results"}, } + if m.screen == screenContextPicker { + shortcuts = append(shortcuts, + helpShortcut{"ctrl+y", "Copy shell exports for the selected filtered context and quit"}, + helpShortcut{"ctrl+s", "Set up the selected filtered context for the shell and quit"}, + ) + } if m.screen == screenCWLogViewer { shortcuts = append(shortcuts, helpShortcut{"enter", "Apply the log filter and reload events"}) } else { diff --git a/internal/app/screen_context.go b/internal/app/screen_context.go index 1c44239..61601e9 100644 --- a/internal/app/screen_context.go +++ b/internal/app/screen_context.go @@ -102,6 +102,23 @@ func (m Model) updateContextPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } + if m.isFiltering(filterContexts) { + switch key { + case "ctrl+s": + selected, ok := m.selectedContextInfo() + if !ok { + return m, nil + } + return m.beginContextSetup(selected) + case "ctrl+y": + selected, ok := m.selectedContextInfo() + if !ok { + return m, nil + } + return m.beginContextExport(selected) + } + } + if cmd, handled := m.updateSharedFilter(msg, filterContexts); handled { m.syncContextTable() return m, cmd @@ -275,6 +292,10 @@ func (m Model) viewContextPicker() string { b.WriteString(m.renderListPanel(panel.String())) b.WriteString("\n\n") + if m.isFiltering(filterContexts) { + b.WriteString(m.renderHelpBar("filtering: type search • ↑/↓ choose • enter finish • esc clear • ctrl+y copy env • ctrl+s setup")) + 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 • esc: clear/back • q: quit")) } else { diff --git a/internal/app/screen_context_test.go b/internal/app/screen_context_test.go index 71497ba..7b33b65 100644 --- a/internal/app/screen_context_test.go +++ b/internal/app/screen_context_test.go @@ -141,6 +141,51 @@ func TestContextPickerFilterUpdatesTableRows(t *testing.T) { } } +func TestContextPickerFilterModePlainYAndSAppendToFilter(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 80 + m.height = 20 + + updated, _ := m.Update(contextsLoadedMsg{contexts: testContexts()}) + model := updated.(Model) + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model = updated.(Model) + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}) + model = updated.(Model) + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + model = updated.(Model) + + if !model.isFiltering(filterContexts) { + t.Fatal("expected context filter to remain active") + } + if got := model.filterValue(filterContexts); got != "ys" { + t.Fatalf("expected plain y/s to append to filter, got %q", got) + } + if model.screen != screenContextPicker { + t.Fatalf("expected to remain on context picker, got %v", model.screen) + } +} + +func TestContextPickerFilterModeHelpShowsModifierActions(t *testing.T) { + m := New(testConfig(), "", "dev") + m.width = 80 + m.height = 20 + + updated, _ := m.Update(contextsLoadedMsg{contexts: testContexts()}) + model := updated.(Model) + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model = updated.(Model) + + view := model.viewContextPicker() + for _, want := range []string{"filtering: type search", "ctrl+y copy", "env", "ctrl+s setup"} { + if !strings.Contains(view, want) { + t.Fatalf("expected filter-mode help to contain %q, got %q", want, view) + } + } +} + func TestContextPickerIncrementalFilterStartsOnTyping(t *testing.T) { m := New(testConfig(), "", "dev") m.width = 80 @@ -410,6 +455,162 @@ contexts: } } +func TestContextPickerFilterModeCtrlYCopiesSelectedFilteredContext(t *testing.T) { + path := writeContextConfig(t, ` +current: prod +defaults: + region: us-east-1 +contexts: + - name: dev + auth_type: credential + profile: dev-profile + region: us-east-1 + - name: prod + auth_type: credential + profile: prod-profile + region: us-west-2 +`) + + origBuildEnv := contextBuildEnvExportsFn + origCopy := contextCopyClipboardFn + defer func() { + contextBuildEnvExportsFn = origBuildEnv + contextCopyClipboardFn = origCopy + }() + + var copied string + contextBuildEnvExportsFn = func(_ context.Context, cfg *config.Config) (string, error) { + return "exports-for-" + cfg.ContextName, nil + } + contextCopyClipboardFn = func(text string) error { + copied = text + return nil + } + + m := New(testConfig(), path, "dev") + m.width = 80 + m.height = 20 + + updated, _ := m.Update(contextsLoadedMsg{contexts: []config.ContextInfo{ + {Name: "dev", Region: "us-east-1", AuthType: "credential"}, + {Name: "prod", Region: "us-west-2", AuthType: "credential", Current: true}, + }}) + model := updated.(Model) + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model = updated.(Model) + for _, ch := range []rune("dev") { + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}}) + model = updated.(Model) + } + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlY}) + model = updated.(Model) + if model.screen != screenLoading || cmd == nil { + t.Fatalf("expected loading command for filter-mode ctrl+y, got screen=%v cmd=%v", model.screen, cmd) + } + + for _, batchedCmd := range cmd().(tea.BatchMsg) { + msg := batchedCmd() + if msg == nil { + continue + } + updated, _ = model.Update(msg) + model = updated.(Model) + } + if model.screen != screenExitNotice { + t.Fatalf("expected copy exports to open exit notice, got %v", model.screen) + } + if copied != "exports-for-dev" { + t.Fatalf("unexpected clipboard exports: %q", copied) + } + + cfg, err := config.Load(nil, nil, path) + if err != nil { + t.Fatal(err) + } + if cfg.ContextName != "prod" { + t.Fatalf("expected current context to remain prod, got %q", cfg.ContextName) + } +} + +func TestContextPickerFilterModeCtrlSSetupsSelectedFilteredContext(t *testing.T) { + path := writeContextConfig(t, ` +current: prod +defaults: + region: us-east-1 +contexts: + - name: dev + auth_type: credential + profile: dev-profile + region: us-east-1 + - name: prod + auth_type: credential + profile: prod-profile + region: us-west-2 +`) + + origBuildEnv := contextBuildEnvExportsFn + origCopy := contextCopyClipboardFn + defer func() { + contextBuildEnvExportsFn = origBuildEnv + contextCopyClipboardFn = origCopy + }() + + var copied string + contextBuildEnvExportsFn = func(_ context.Context, cfg *config.Config) (string, error) { + return fmt.Sprintf("export %s='%s'", auth.ContextEnvVar, cfg.ContextName), nil + } + contextCopyClipboardFn = func(text string) error { + copied = text + return nil + } + + m := New(testConfig(), path, "dev") + m.width = 80 + m.height = 20 + + updated, _ := m.Update(contextsLoadedMsg{contexts: []config.ContextInfo{ + {Name: "dev", Region: "us-east-1", AuthType: "credential"}, + {Name: "prod", Region: "us-west-2", AuthType: "credential", Current: true}, + }}) + model := updated.(Model) + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + model = updated.(Model) + for _, ch := range []rune("dev") { + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}}) + model = updated.(Model) + } + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + model = updated.(Model) + if model.screen != screenLoading || cmd == nil { + t.Fatalf("expected loading command for filter-mode ctrl+s, got screen=%v cmd=%v", model.screen, cmd) + } + + for _, batchedCmd := range cmd().(tea.BatchMsg) { + msg := batchedCmd() + if msg == nil { + continue + } + updated, _ = model.Update(msg) + model = updated.(Model) + } + if model.screen != screenExitNotice { + t.Fatalf("expected setup to open exit notice, got %v", model.screen) + } + if copied != "export UNIC_CONTEXT='dev'" { + t.Fatalf("unexpected clipboard exports: %q", copied) + } + + cfg, err := config.Load(nil, nil, path) + if err != nil { + t.Fatal(err) + } + if cfg.ContextName != "dev" { + t.Fatalf("expected current context to switch to dev, got %q", cfg.ContextName) + } +} + func TestSwitchContextSSOReusesCachedSessionWithoutLoginCommand(t *testing.T) { path := writeContextConfig(t, ` current: dev-sso