diff --git a/contexts/contexts.go b/contexts/contexts.go index 8359ea8..c9aa05d 100644 --- a/contexts/contexts.go +++ b/contexts/contexts.go @@ -233,7 +233,7 @@ func (m *Manager) renameEntry(store *config.Store, t *renameTarget) (RenameResul return RenameResult{}, fmt.Errorf("%w: %q", t.existsErr, t.newName) } - if err := m.renameAndRetarget(store, t.path, t.affected, t.rename, t.retarget); err != nil { + if err := m.renameAndRetarget(store, t); err != nil { return RenameResult{}, err } @@ -270,25 +270,19 @@ func validateCredential(cred *config.Credential) error { // every file that owns an affected context. Each file is written exactly once: // a file that both defines the entry and holds referencing contexts gets both // mutations applied before it is persisted. -func (m *Manager) renameAndRetarget( - store *config.Store, - entryPath string, - affected []string, - rename func(*config.Config), - retarget func(*config.Config), -) error { +func (m *Manager) renameAndRetarget(store *config.Store, t *renameTarget) error { // Collect every file that needs a write. - paths := map[string]struct{}{entryPath: {}} - for _, name := range affected { + paths := map[string]struct{}{t.path: {}} + for _, name := range t.affected { paths[store.PathForContext(name)] = struct{}{} } for path := range paths { cfg := store.FileConfig(path) - if path == entryPath { - rename(&cfg) + if path == t.path { + t.rename(&cfg) } - retarget(&cfg) + t.retarget(&cfg) if err := m.Writer.Write(path, &cfg); err != nil { return err @@ -303,17 +297,17 @@ func (m *Manager) renameAndRetarget( // updated there too. func (m *Manager) RenameContext(store *config.Store, oldName, newName string) error { if _, found := store.Config.ContextByName(oldName); !found { - return fmt.Errorf("cannot rename context %q, it does not exist", oldName) + return fmt.Errorf("%w: %q", ErrContextNotFound, oldName) } if _, found := store.Config.ContextByName(newName); found { - return fmt.Errorf("cannot rename context %q, context %q already exists", oldName, newName) + return fmt.Errorf("%w: %q", ErrContextExists, newName) } path := store.PathForContext(oldName) cfg := store.FileConfig(path) if renamed := cfg.RenameContext(oldName, newName); !renamed { - return fmt.Errorf("cannot rename context %q, it does not exist in %q", oldName, path) + return fmt.Errorf("%w: %q in %q", ErrContextNotFound, oldName, path) } if cfg.CurrentContext == oldName { @@ -361,13 +355,13 @@ type RenameResult struct { // it was the active context. func (m *Manager) DeleteContext(store *config.Store, name string) (DeleteResult, error) { if _, found := store.Config.ContextByName(name); !found { - return DeleteResult{}, fmt.Errorf("context %q not found", name) + return DeleteResult{}, fmt.Errorf("%w: %q", ErrContextNotFound, name) } path := store.PathForContext(name) cfg := store.FileConfig(path) if deleted := cfg.DeleteContext(name); !deleted { - return DeleteResult{}, fmt.Errorf("context %q not found in %q", name, path) + return DeleteResult{}, fmt.Errorf("%w: %q in %q", ErrContextNotFound, name, path) } if err := m.Writer.Write(path, &cfg); err != nil { @@ -383,12 +377,12 @@ func (m *Manager) DeleteContext(store *config.Store, name string) (DeleteResult, func (m *Manager) DeleteTenant(store *config.Store, name string) (DeleteResult, error) { _, found := store.Config.TenantByName(name) return m.deleteEntry(store, &entryTarget{ - kind: "tenant", - name: name, - found: found, - path: store.PathForTenant(name), - deleteX: func(cfg *config.Config) bool { return cfg.DeleteTenant(name) }, - orphans: store.Config.ContextsReferencingTenant(name), + notFound: ErrTenantNotFound, + name: name, + found: found, + path: store.PathForTenant(name), + remove: func(cfg *config.Config) bool { return cfg.DeleteTenant(name) }, + orphans: store.Config.ContextsReferencingTenant(name), }) } @@ -398,35 +392,35 @@ func (m *Manager) DeleteTenant(store *config.Store, name string) (DeleteResult, func (m *Manager) DeleteCredential(store *config.Store, name string) (DeleteResult, error) { _, found := store.Config.CredentialByName(name) return m.deleteEntry(store, &entryTarget{ - kind: "credential", - name: name, - found: found, - path: store.PathForCredential(name), - deleteX: func(cfg *config.Config) bool { return cfg.DeleteCredential(name) }, - orphans: store.Config.ContextsReferencingCredential(name), + notFound: ErrCredentialNotFound, + name: name, + found: found, + path: store.PathForCredential(name), + remove: func(cfg *config.Config) bool { return cfg.DeleteCredential(name) }, + orphans: store.Config.ContextsReferencingCredential(name), }) } // entryTarget describes a non-context entry to delete. type entryTarget struct { - deleteX func(cfg *config.Config) bool - kind string - name string - path string - orphans []string - found bool + notFound error + remove func(cfg *config.Config) bool + name string + path string + orphans []string + found bool } // deleteEntry removes a tenant or credential entry. Contexts use DeleteContext // directly because they additionally report whether the active context changed. func (m *Manager) deleteEntry(store *config.Store, t *entryTarget) (DeleteResult, error) { if !t.found { - return DeleteResult{}, fmt.Errorf("%s %q not found", t.kind, t.name) + return DeleteResult{}, fmt.Errorf("%w: %q", t.notFound, t.name) } cfg := store.FileConfig(t.path) - if deleted := t.deleteX(&cfg); !deleted { - return DeleteResult{}, fmt.Errorf("%s %q not found in %q", t.kind, t.name, t.path) + if removed := t.remove(&cfg); !removed { + return DeleteResult{}, fmt.Errorf("%w: %q in %q", t.notFound, t.name, t.path) } if err := m.Writer.Write(t.path, &cfg); err != nil { diff --git a/contexts/contexts_test.go b/contexts/contexts_test.go index 403c808..bc6fbea 100644 --- a/contexts/contexts_test.go +++ b/contexts/contexts_test.go @@ -205,8 +205,8 @@ func TestManager_RenameContext_UpdatesCurrent(t *testing.T) { func TestManager_RenameContext_Errors(t *testing.T) { writeConfig(t, baseConfig()) - require.Error(t, New().RenameContext(loadStore(t), "ghost", "new")) - require.Error(t, New().RenameContext(loadStore(t), devContext, prodContext)) + require.ErrorIs(t, New().RenameContext(loadStore(t), "ghost", "new"), ErrContextNotFound) + require.ErrorIs(t, New().RenameContext(loadStore(t), devContext, prodContext), ErrContextExists) } func TestManager_DeleteContext(t *testing.T) { @@ -236,7 +236,7 @@ func TestManager_DeleteContext_NotFound(t *testing.T) { store := loadStore(t) _, err := New().DeleteContext(store, "ghost") - require.Error(t, err) + require.ErrorIs(t, err, ErrContextNotFound) } func TestManager_DeleteTenant(t *testing.T) { @@ -252,14 +252,6 @@ func TestManager_DeleteTenant(t *testing.T) { assert.False(t, found) } -func TestManager_DeleteTenant_NotFound(t *testing.T) { - writeConfig(t, baseConfig()) - store := loadStore(t) - - _, err := New().DeleteTenant(store, "ghost") - require.Error(t, err) -} - func TestManager_DeleteCredential(t *testing.T) { path := writeConfig(t, baseConfig()) store := loadStore(t) @@ -273,10 +265,28 @@ func TestManager_DeleteCredential(t *testing.T) { assert.False(t, found) } -func TestManager_DeleteCredential_NotFound(t *testing.T) { - writeConfig(t, baseConfig()) - store := loadStore(t) +func TestManager_Delete_NotFound(t *testing.T) { + tests := []struct { + wantErr error + delete func(m *Manager, store *config.Store) error + name string + }{ + { + name: "tenant", + delete: func(m *Manager, s *config.Store) error { _, err := m.DeleteTenant(s, "ghost"); return err }, + wantErr: ErrTenantNotFound, + }, + { + name: "credential", + delete: func(m *Manager, s *config.Store) error { _, err := m.DeleteCredential(s, "ghost"); return err }, + wantErr: ErrCredentialNotFound, + }, + } - _, err := New().DeleteCredential(store, "ghost") - require.Error(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + writeConfig(t, baseConfig()) + require.ErrorIs(t, tt.delete(New(), loadStore(t)), tt.wantErr) + }) + } } diff --git a/tui/app.go b/tui/app.go index 1794425..817b0af 100644 --- a/tui/app.go +++ b/tui/app.go @@ -25,16 +25,11 @@ type App struct { // NewApp builds the root model wired with a production contexts.Manager. func NewApp(store *config.Store, mode state.Mode) *App { - return newApp(store, mode, contexts.New()) -} - -// newApp builds the root model with an explicit manager, for test injection. -func newApp(store *config.Store, mode state.Mode, manager tabs.Manager) *App { s := state.New(store, mode) return &App{ state: s, splash: splash.New(s), - tabs: tabs.New(s, manager), + tabs: tabs.New(s, contexts.New()), } } diff --git a/tui/form/form.go b/tui/form/form.go index c1686e7..628b8e4 100644 --- a/tui/form/form.go +++ b/tui/form/form.go @@ -5,6 +5,8 @@ package form import ( + "errors" + "fmt" "strings" "github.com/charmbracelet/bubbles/key" @@ -38,10 +40,13 @@ type Submitted struct { // Canceled is emitted when the user aborts the form. type Canceled struct{} +// ErrRequired is returned when a required field is left empty. +var ErrRequired = errors.New("field is required") + // Model is a focusable multi-field form. type Model struct { title string - err string + err error keys formKeys fields []Field inputs []textinput.Model @@ -102,6 +107,10 @@ func (m *Model) Values() map[string]string { return values } +// Err returns the current validation error, or nil when the form is valid. It +// is set when a submit is blocked and cleared on navigation. +func (m *Model) Err() error { return m.err } + // Update handles navigation, editing, submission, and cancellation. It returns // a Submitted or Canceled tea.Cmd when the form terminates. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { //nolint:gocritic // Bubble Tea value-receiver idiom; refactor tracked separately @@ -138,13 +147,13 @@ func (m Model) trySubmit() (Model, tea.Cmd) { //nolint:gocritic // Bubble Tea va for i, f := range m.fields { value := strings.TrimSpace(m.inputs[i].Value()) if f.Required && value == "" { - m.failField(i, f.Label+" is required") + m.failField(i, fmt.Errorf("%w: %s", ErrRequired, f.Label)) return m, nil } if f.Validate != nil { if err := f.Validate(value); err != nil { - m.failField(i, err.Error()) + m.failField(i, err) return m, nil } } @@ -157,15 +166,15 @@ func (m Model) trySubmit() (Model, tea.Cmd) { //nolint:gocritic // Bubble Tea va // failField records a validation error and focuses the offending field when it // is editable. -func (m *Model) failField(index int, msg string) { - m.err = msg +func (m *Model) failField(index int, err error) { + m.err = err if !m.fields[index].ReadOnly { m.focusOn(index) } } func (m *Model) focusDelta(delta int) { - m.err = "" + m.err = nil m.focusOn(m.nextEditable(m.focus, delta)) } @@ -216,8 +225,8 @@ func (m Model) View() string { //nolint:gocritic // Bubble Tea value-receiver id rows = append(rows, row) } - if m.err != "" { - rows = append(rows, "", styles.ErrorStyle.Render(m.err)) + if m.err != nil { + rows = append(rows, "", styles.ErrorStyle.Render(m.err.Error())) } rows = append(rows, "", styles.HelpStyle.Render("tab/↑↓ move · enter submit · esc cancel")) diff --git a/tui/form/form_test.go b/tui/form/form_test.go index 2c9622a..8554ad3 100644 --- a/tui/form/form_test.go +++ b/tui/form/form_test.go @@ -80,7 +80,7 @@ func TestModel_RequiredFieldBlocksSubmit(t *testing.T) { next, cmd := m.Update(keyMsg("enter")) assert.Nil(t, cmd, "submit must be blocked") - assert.NotEmpty(t, next.err, "an inline error must be shown") + require.ErrorIs(t, next.Err(), ErrRequired) assert.Equal(t, 0, next.focus, "focus returns to the invalid field") } @@ -93,7 +93,7 @@ func TestModel_ValidatorBlocksSubmit(t *testing.T) { next, cmd := m.Update(keyMsg("enter")) assert.Nil(t, cmd) - assert.Equal(t, sentinel.Error(), next.err) + require.ErrorIs(t, next.Err(), sentinel) } func TestModel_Navigation(t *testing.T) { @@ -114,10 +114,10 @@ func TestModel_NavigationClearsError(t *testing.T) { m := twoFieldForm() m, _ = m.Update(keyMsg("enter")) // triggers required error - require.NotEmpty(t, m.err) + require.Error(t, m.Err()) m, _ = m.Update(keyMsg("tab")) - assert.Empty(t, m.err, "moving focus clears the error") + assert.NoError(t, m.Err(), "moving focus clears the error") } func TestModel_ReadOnlyFieldSkipped(t *testing.T) { diff --git a/tui/tabs/browse.go b/tui/tabs/browse.go deleted file mode 100644 index a329c84..0000000 --- a/tui/tabs/browse.go +++ /dev/null @@ -1,115 +0,0 @@ -package tabs - -import ( - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/lvlcn-t/azctx/config" - "github.com/lvlcn-t/azctx/tui/details" - "github.com/lvlcn-t/azctx/tui/keys" - "github.com/lvlcn-t/azctx/tui/state" -) - -var _ Tab = (*browseTab)(nil) - -type browseTab struct { - list list.Model - state *state.UI - rebuild func(*config.Store) []list.Item - keys tabKeys -} - -// newCRUDBrowseTab builds a browse tab that also binds create, edit, rename, and -// delete keys so the tab can emit CRUD actions. -func newCRUDBrowseTab(s *state.UI, rebuild func(*config.Store) []list.Item, l listBuilder) browseTab { //nolint:gocritic // irrelevant on startup - tk := newTabKeys( - keys.New(keys.Enter).WithHelp("view").WithAliases(keys.View, keys.Describe).Bind(), - key.Binding{}, - keys.New(keys.Escape).WithHelp("close").Bind(), - ) - tk.Create = keys.New(keys.Create).WithHelp("new").Bind() - tk.Edit = keys.New(keys.Edit).WithHelp("edit").Bind() - tk.Rename = keys.New(keys.Rename).WithHelp("rename").Bind() - tk.Delete = keys.New(keys.Delete).WithHelp("delete").Bind() - return buildBrowseTab(s, rebuild, l, tk) -} - -func buildBrowseTab(s *state.UI, rebuild func(*config.Store) []list.Item, l listBuilder, tk tabKeys) browseTab { //nolint:gocritic // irrelevant on startup - return browseTab{ - list: l.WithItems(rebuild(s.Config())...). - ShowStatusBar(true). - ShowHelp(true). - EnableFiltering(true). - WithShortHelp(tk.Help()). - WithFullHelp(tk.Help()). - Build(), - keys: tk, - state: s, - rebuild: rebuild, - } -} - -func (t *browseTab) Update(msg tea.Msg) (TabAction, tea.Cmd) { - // bubbles/list needs to receive keys even after filtering has ended if a - // filter value is still applied, otherwise esc cannot clear the active filter. - if t.Filtering() || t.list.FilterValue() != "" { - var cmd tea.Cmd - t.list, cmd = t.list.Update(msg) - return NoAction(), cmd - } - - switch { - case keys.Matches(msg, t.keys.Select, t.keys.View): - if item, ok := t.list.SelectedItem().(details.Item); ok { - return ShowDetails(item), nil - } - return NoAction(), nil - - case keys.Matches(msg, t.keys.Create): - return Create(), nil - - case keys.Matches(msg, t.keys.Edit): - if item, ok := t.list.SelectedItem().(details.Item); ok { - return Edit(item), nil - } - return NoAction(), nil - - case keys.Matches(msg, t.keys.Rename): - if item, ok := t.list.SelectedItem().(details.Item); ok { - return Rename(item), nil - } - return NoAction(), nil - - case keys.Matches(msg, t.keys.Delete): - if item, ok := t.list.SelectedItem().(details.Item); ok { - return Delete(item), nil - } - return NoAction(), nil - - case keys.Matches(msg, t.keys.Close): - // Catch close events to prevent the list from exiting when the user - // spams the esc key while filtering. - return NoAction(), nil - } - - var cmd tea.Cmd - t.list, cmd = t.list.Update(msg) - return NoAction(), cmd -} - -func (t *browseTab) Filtering() bool { - return t.list.FilterState() == list.Filtering -} - -// Reload rebuilds the list items from the current store. -func (t *browseTab) Reload() { - t.list.SetItems(t.rebuild(t.state.Config())) -} - -func (t *browseTab) Resize(width, height int) { - t.list.SetSize(width, height) -} - -func (t *browseTab) View() string { - return t.list.View() -} diff --git a/tui/tabs/context_item.go b/tui/tabs/context_item.go index a209855..8ccf3a8 100644 --- a/tui/tabs/context_item.go +++ b/tui/tabs/context_item.go @@ -5,9 +5,12 @@ import ( "strings" "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" "github.com/lvlcn-t/azctx/config" + "github.com/lvlcn-t/azctx/contexts" "github.com/lvlcn-t/azctx/tui/details" "github.com/lvlcn-t/azctx/tui/form" + "github.com/lvlcn-t/azctx/tui/state" "github.com/lvlcn-t/azctx/tui/styles" ) @@ -15,6 +18,8 @@ var ( _ list.Item = (*ContextItem)(nil) _ list.DefaultItem = (*ContextItem)(nil) _ details.Item = (*ContextItem)(nil) + _ entry = (*ContextItem)(nil) + _ activatable = (*ContextItem)(nil) ) type ContextItem struct { @@ -79,18 +84,27 @@ func (i *ContextItem) Details() details.View { } } -// contextForm builds the create or edit form for a context. On edit the name is -// pre-filled and locked; tenant and credential must reference existing entries. -// The tenant and credential help text lists the available names. -func contextForm(intent formIntent, store *config.Store, item details.Item) form.Model { - var name, tenant, credential, subscription string +func (i *ContextItem) name() string { return i.Name } +func (i *ContextItem) blank() entry { return &ContextItem{} } + +// activate makes this context the current selection and quits the TUI. +func (i *ContextItem) activate(s *state.UI) tea.Cmd { + s.SelectContext(i.Name) + return s.Quit() +} + +// form builds the create or edit form for a context. On edit the name is +// pre-filled and locked; tenant and credential must reference existing entries, +// whose names are shown in the field placeholders. +func (i *ContextItem) form(intent formIntent, store *config.Store) form.Model { + name, tenant, credential, subscription := "", "", "", "" title := "New context" readonly := false - if ctx, ok := item.(*ContextItem); ok && intent == intentEdit { - name = ctx.Name - tenant = ctx.Tenant.Name - credential = ctx.Credential.Name - subscription = ctx.Subscription + if intent == intentEdit { + name = i.Name + tenant = i.Tenant.Name + credential = i.Credential.Name + subscription = i.Subscription title = "Edit context" readonly = true } @@ -100,17 +114,50 @@ func contextForm(intent formIntent, store *config.Store, item details.Item) form { Key: fieldTenant, Label: "Tenant", Value: tenant, Required: true, Placeholder: strings.Join(tenantNames(store), ", "), - Validate: existsValidator("tenant", tenantNames(store)), + Validate: existsValidator(tenantNames(store)), }, { Key: fieldCredential, Label: "Credential", Value: credential, Required: true, Placeholder: strings.Join(credentialNames(store), ", "), - Validate: existsValidator("credential", credentialNames(store)), + Validate: existsValidator(credentialNames(store)), }, {Key: fieldSubscription, Label: "Subscription", Value: subscription, Placeholder: "optional"}, }) } +func (i *ContextItem) save(m *contexts.Manager, store *config.Store, sub submission) (string, error) { + name := sub.values[fieldName] + next := config.Context{ + Name: name, + Details: config.ContextDetails{ + Tenant: sub.values[fieldTenant], + Credential: sub.values[fieldCredential], + Subscription: sub.values[fieldSubscription], + }, + } + + switch sub.intent { + case intentCreate: + return "created context " + name, m.CreateContext(store, next) + case intentEdit: + // The form always carries the subscription value, so treat it as changed. + return "updated context " + name, m.UpdateContext(store, next, true) + case intentRename: + return "renamed context " + i.Name + " to " + name, m.RenameContext(store, i.Name, name) + default: + return "", nil + } +} + +func (i *ContextItem) remove(m *contexts.Manager, store *config.Store) (string, error) { + result, err := m.DeleteContext(store, i.Name) + status := "deleted context " + i.Name + if result.WasActive { + status += " (warning: removed the active context; use a context to select a new one)" + } + return status, err +} + // tenantNames returns the configured tenant names. func tenantNames(store *config.Store) []string { names := make([]string, 0, len(store.Config.Tenants)) @@ -129,14 +176,15 @@ func credentialNames(store *config.Store) []string { return names } -// existsValidator rejects a value that is not one of the allowed names. -func existsValidator(kind string, allowed []string) func(string) error { +// existsValidator rejects a value that is not one of the allowed names, +// returning an error that wraps errReferenceUnknown. +func existsValidator(allowed []string) func(string) error { return func(value string) error { for _, name := range allowed { if name == value { return nil } } - return fmt.Errorf("%s %q does not exist", kind, value) + return fmt.Errorf("%w: %q", errReferenceUnknown, value) } } diff --git a/tui/tabs/contexts.go b/tui/tabs/contexts.go deleted file mode 100644 index c0df4f0..0000000 --- a/tui/tabs/contexts.go +++ /dev/null @@ -1,117 +0,0 @@ -package tabs - -import ( - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/lvlcn-t/azctx/tui/keys" - "github.com/lvlcn-t/azctx/tui/state" -) - -var _ Tab = (*ContextsTab)(nil) - -type ContextsTab struct { - list list.Model - state *state.UI - keys tabKeys -} - -func contextsTab(s *state.UI, l listBuilder) *ContextsTab { //nolint:gocritic // irrelevant on startup - sel := keys.New(keys.Enter).WithHelp("select").WithAliases(keys.Use).Bind() - view := keys.New(keys.View).WithHelp("view").WithAliases(keys.Describe).Bind() - if s.Mode() == state.ModeBrowse { - sel = keys.New(keys.Enter).WithHelp("view").WithAliases(keys.View, keys.Describe).Bind() - view = key.Binding{} - } - - tk := newTabKeys(sel, view, keys.New(keys.Escape).WithHelp("close").Bind()) - tk.Create = keys.New(keys.Create).WithHelp("new").Bind() - tk.Edit = keys.New(keys.Edit).WithHelp("edit").Bind() - tk.Rename = keys.New(keys.Rename).WithHelp("rename").Bind() - tk.Delete = keys.New(keys.Delete).WithHelp("delete").Bind() - items := contextItems(s.Config()) - return &ContextsTab{ - list: l.WithItems(items...). - ShowStatusBar(true). - ShowHelp(true). - EnableFiltering(true). - WithShortHelp(tk.Help()). - WithFullHelp(tk.Help()). - Build(), - state: s, - keys: tk, - } -} - -func (t *ContextsTab) Update(msg tea.Msg) (TabAction, tea.Cmd) { - // bubbles/list needs to receive keys even after filtering has ended if a - // filter value is still applied, otherwise esc cannot clear the active filter. - if t.Filtering() || t.list.FilterValue() != "" { - var cmd tea.Cmd - t.list, cmd = t.list.Update(msg) - return NoAction(), cmd - } - - switch { - case keys.Matches(msg, t.keys.Select): - item, ok := t.list.SelectedItem().(*ContextItem) - if !ok { - return NoAction(), nil - } - return Select(item), nil - - case keys.Matches(msg, t.keys.View): - item, ok := t.list.SelectedItem().(*ContextItem) - if !ok { - return NoAction(), nil - } - return ShowDetails(item), nil - - case keys.Matches(msg, t.keys.Create): - return Create(), nil - - case keys.Matches(msg, t.keys.Edit): - if item, ok := t.list.SelectedItem().(*ContextItem); ok { - return Edit(item), nil - } - return NoAction(), nil - - case keys.Matches(msg, t.keys.Rename): - if item, ok := t.list.SelectedItem().(*ContextItem); ok { - return Rename(item), nil - } - return NoAction(), nil - - case keys.Matches(msg, t.keys.Delete): - if item, ok := t.list.SelectedItem().(*ContextItem); ok { - return Delete(item), nil - } - return NoAction(), nil - - case keys.Matches(msg, t.keys.Close): - // Catch close events to prevent the list from exiting when the user - // spams the esc key while filtering. - return NoAction(), nil - } - - var cmd tea.Cmd - t.list, cmd = t.list.Update(msg) - return NoAction(), cmd -} - -func (t *ContextsTab) Filtering() bool { - return t.list.FilterState() == list.Filtering -} - -// Reload rebuilds the context items from the current store. -func (t *ContextsTab) Reload() { - t.list.SetItems(contextItems(t.state.Config())) -} - -func (t *ContextsTab) View() string { - return t.list.View() -} - -func (t *ContextsTab) Resize(width, height int) { - t.list.SetSize(width, height) -} diff --git a/tui/tabs/contexts_flow_test.go b/tui/tabs/contexts_flow_test.go index 6cbea86..56b8340 100644 --- a/tui/tabs/contexts_flow_test.go +++ b/tui/tabs/contexts_flow_test.go @@ -49,9 +49,9 @@ func TestTabs_CreateContext_RejectsUnknownTenant(t *testing.T) { cmd := tabs.Update(tea.KeyMsg{Type: tea.KeyEnter}) drain(tabs, cmd) - // Validation blocks submission; the form stays open with an inline error. + // Validation blocks submission; the form stays open with the reference error. require.True(t, tabs.state.Is(state.FormView)) - assert.Contains(t, tabs.form.View(), "does not exist") + require.ErrorIs(t, tabs.form.Err(), errReferenceUnknown) } func TestTabs_EditContext_UpdatesInPlace(t *testing.T) { diff --git a/tui/tabs/credential_items.go b/tui/tabs/credential_items.go index 170088e..5d44c4f 100644 --- a/tui/tabs/credential_items.go +++ b/tui/tabs/credential_items.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/bubbles/list" "github.com/lvlcn-t/azctx/config" + "github.com/lvlcn-t/azctx/contexts" "github.com/lvlcn-t/azctx/keyvault" "github.com/lvlcn-t/azctx/tui/details" "github.com/lvlcn-t/azctx/tui/form" @@ -16,6 +17,7 @@ var ( _ list.Item = (*CredentialItem)(nil) _ list.DefaultItem = (*CredentialItem)(nil) _ details.Item = (*CredentialItem)(nil) + _ entry = (*CredentialItem)(nil) ) type CredentialItem struct{ config.Credential } @@ -29,6 +31,9 @@ func credentialItems(s *config.Store) []list.Item { return items } +func (i *CredentialItem) name() string { return i.Name } +func (i *CredentialItem) blank() entry { return &CredentialItem{} } + func (i *CredentialItem) Title() string { return i.Name } func (i *CredentialItem) Description() string { desc := i.Credential.Details.Type.String() @@ -127,15 +132,15 @@ func (i *CredentialItem) workloadIdentityRows() []details.Row { return rows } -// credentialForm builds the create or edit form for a credential. All fields -// are shown; only the ones relevant to the chosen type are used at submit time, -// where the domain validates the result. On edit the name is locked read-only. -func credentialForm(intent formIntent, item details.Item) form.Model { - var c config.Credential +// form builds the create or edit form for a credential. All fields are shown; +// only the ones relevant to the chosen type are used at submit time, where the +// domain validates the result. On edit the name is locked read-only. +func (i *CredentialItem) form(intent formIntent, _ *config.Store) form.Model { + c := config.Credential{} title := "New credential" readonly := false - if cred, ok := item.(*CredentialItem); ok && intent == intentEdit { - c = cred.Credential + if intent == intentEdit { + c = i.Credential title = "Edit credential" readonly = true } @@ -168,6 +173,26 @@ func credentialForm(intent formIntent, item details.Item) form.Model { }) } +func (i *CredentialItem) save(m *contexts.Manager, store *config.Store, sub submission) (string, error) { + cred := credentialFromValues(sub.values) + switch sub.intent { + case intentCreate: + return "created credential " + cred.Name, m.CreateCredential(store, cred) + case intentEdit: + return "updated credential " + cred.Name, m.UpdateCredential(store, cred) + case intentRename: + result, err := m.RenameCredential(store, i.Name, cred.Name) + return renameStatus("credential", i.Name, cred.Name, result.UpdatedContexts), err + default: + return "", nil + } +} + +func (i *CredentialItem) remove(m *contexts.Manager, store *config.Store) (string, error) { + result, err := m.DeleteCredential(store, i.Name) + return deleteStatus("credential", i.Name, result.OrphanedContexts), err +} + // credentialTypeValidator rejects an unsupported credential type. func credentialTypeValidator(value string) error { _, err := config.NewCredentialType(value) diff --git a/tui/tabs/credentials.go b/tui/tabs/credentials.go deleted file mode 100644 index be78383..0000000 --- a/tui/tabs/credentials.go +++ /dev/null @@ -1,15 +0,0 @@ -package tabs - -import "github.com/lvlcn-t/azctx/tui/state" - -var _ Tab = (*CredentialsTab)(nil) - -type CredentialsTab struct { - browseTab -} - -func credentialsTab(s *state.UI, l listBuilder) *CredentialsTab { //nolint:gocritic // irrelevant on startup - return &CredentialsTab{ - browseTab: newCRUDBrowseTab(s, credentialItems, l), - } -} diff --git a/tui/tabs/credentials_flow_test.go b/tui/tabs/credentials_flow_test.go index eac8a7c..e857e38 100644 --- a/tui/tabs/credentials_flow_test.go +++ b/tui/tabs/credentials_flow_test.go @@ -85,9 +85,9 @@ func TestTabs_CreateCredential_RejectsInvalidType(t *testing.T) { cmd := tabs.Update(tea.KeyMsg{Type: tea.KeyEnter}) drain(tabs, cmd) - // Inline validation keeps the form open. + // Inline validation keeps the form open with a validation error. require.True(t, tabs.state.Is(state.FormView)) - assert.Contains(t, tabs.form.View(), "unsupported credential type") + require.Error(t, tabs.form.Err()) } func TestTabs_EditCredential_UpdatesInPlace(t *testing.T) { diff --git a/tui/tabs/entry.go b/tui/tabs/entry.go new file mode 100644 index 0000000..349be1b --- /dev/null +++ b/tui/tabs/entry.go @@ -0,0 +1,45 @@ +package tabs + +import ( + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/lvlcn-t/azctx/config" + "github.com/lvlcn-t/azctx/contexts" + "github.com/lvlcn-t/azctx/tui/details" + "github.com/lvlcn-t/azctx/tui/form" + "github.com/lvlcn-t/azctx/tui/state" +) + +// entry is a list row that maps to a config stanza and knows how to render and +// persist itself. The three item types (tenant, context, credential) implement +// it, so it is deliberately a multi-method interface shared by several +// implementations rather than a one- or two-method one. +type entry interface { + list.Item // Title, Description, FilterValue + details.Item // Details + + // name returns the entry's config name, without any display marker. + name() string + // blank returns a fresh, empty entry of the same kind, used to build the + // create form and to persist a newly created entry. + blank() entry + // form builds the create or edit form. On edit the name is locked. + form(intent formIntent, store *config.Store) form.Model + // save persists a create, edit, or rename described by sub, returning a + // human-readable status line for display. + save(m *contexts.Manager, store *config.Store, sub submission) (string, error) + // remove deletes the entry, returning a human-readable status line. + remove(m *contexts.Manager, store *config.Store) (string, error) +} + +// activatable is the optional "use" capability. Only a context implements it: +// selecting it in interactive mode activates the context and quits. +type activatable interface { + activate(s *state.UI) tea.Cmd +} + +// submission is the payload of a form submit: the intent and the field values. +type submission struct { + values map[string]string + intent formIntent +} diff --git a/tui/tabs/errors.go b/tui/tabs/errors.go new file mode 100644 index 0000000..b1e4246 --- /dev/null +++ b/tui/tabs/errors.go @@ -0,0 +1,7 @@ +package tabs + +import "errors" + +// errReferenceUnknown indicates a form value referenced a tenant or credential +// that does not exist. +var errReferenceUnknown = errors.New("does not exist") diff --git a/tui/tabs/tab.go b/tui/tabs/tab.go index a405eef..ee2961e 100644 --- a/tui/tabs/tab.go +++ b/tui/tabs/tab.go @@ -4,9 +4,11 @@ import ( "reflect" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" - "github.com/lvlcn-t/azctx/tui/details" + "github.com/lvlcn-t/azctx/config" "github.com/lvlcn-t/azctx/tui/keys" + "github.com/lvlcn-t/azctx/tui/state" ) // labelName is the shared display label for an entry's name field. @@ -32,16 +34,109 @@ const ( fieldScopes = "scopes" ) -// Tab represents a single tab in the UI, responsible for rendering its content and handling interactions. -type Tab interface { - Resize(width, height int) - Update(msg tea.Msg) (TabAction, tea.Cmd) - View() string - Filtering() bool - // Reload rebuilds the tab's items from the current store, e.g. after a write. - Reload() +// Tab is a filterable list of one entry kind with create/edit/rename/delete +// keybindings. Every tab is the same type; the entry values it lists carry all +// entity-specific behavior. +type Tab struct { + list list.Model + state *state.UI + rebuild func(*config.Store) []list.Item + title string + keys tabKeys } +// newTab builds a tab for the given entry kind. rebuild produces the rows and +// sel/view are the Enter and view keybindings (which differ for contexts). +func newTab(s *state.UI, title string, rebuild func(*config.Store) []list.Item, sel, view key.Binding, l listBuilder) *Tab { //nolint:gocritic // listBuilder is a value builder, copied intentionally + tk := newTabKeys(sel, view, keys.New(keys.Escape).WithHelp("close").Bind()) + tk.Create = keys.New(keys.Create).WithHelp("new").Bind() + tk.Edit = keys.New(keys.Edit).WithHelp("edit").Bind() + tk.Rename = keys.New(keys.Rename).WithHelp("rename").Bind() + tk.Delete = keys.New(keys.Delete).WithHelp("delete").Bind() + + return &Tab{ + list: l.WithItems(rebuild(s.Config())...). + ShowStatusBar(true). + ShowHelp(true). + EnableFiltering(true). + WithShortHelp(tk.Help()). + WithFullHelp(tk.Help()). + Build(), + keys: tk, + state: s, + rebuild: rebuild, + title: title, + } +} + +// Update handles list navigation and emits a TabAction for CRUD keys. +func (t *Tab) Update(msg tea.Msg) (TabAction, tea.Cmd) { + // bubbles/list needs to receive keys even after filtering has ended if a + // filter value is still applied, otherwise esc cannot clear the active filter. + if t.Filtering() || t.list.FilterValue() != "" { + var cmd tea.Cmd + t.list, cmd = t.list.Update(msg) + return noAction(), cmd + } + + switch { + case keys.Matches(msg, t.keys.Select): + return t.actionOn(selectAction), nil + case keys.Matches(msg, t.keys.View): + return t.actionOn(showDetails), nil + case keys.Matches(msg, t.keys.Create): + return TabAction{kind: actionCreate}, nil + case keys.Matches(msg, t.keys.Edit): + return t.actionOn(editAction), nil + case keys.Matches(msg, t.keys.Rename): + return t.actionOn(renameAction), nil + case keys.Matches(msg, t.keys.Delete): + return t.actionOn(deleteAction), nil + case keys.Matches(msg, t.keys.Close): + // Swallow esc so the list does not exit while the user clears a filter. + return noAction(), nil + } + + var cmd tea.Cmd + t.list, cmd = t.list.Update(msg) + return noAction(), cmd +} + +// actionOn builds an action of the given kind carrying the selected entry, or a +// no-op when the selection is not an entry. +func (t *Tab) actionOn(kind actionKind) TabAction { + item, ok := t.list.SelectedItem().(entry) + if !ok { + return noAction() + } + return TabAction{kind: kind, item: item} +} + +// blank returns a zero-value entry of this tab's kind, for building create +// forms and persisting new entries. +func (t *Tab) blank() entry { + for _, item := range t.list.Items() { + if e, ok := item.(entry); ok { + return e.blank() + } + } + // The list may be empty; rebuild from a throwaway to obtain the kind. + for _, item := range t.rebuild(t.state.Config()) { + if e, ok := item.(entry); ok { + return e.blank() + } + } + return nil +} + +func (t *Tab) Filtering() bool { return t.list.FilterState() == list.Filtering } +func (t *Tab) View() string { return t.list.View() } + +func (t *Tab) Resize(width, height int) { t.list.SetSize(width, height) } + +// Reload rebuilds the list items from the current store. +func (t *Tab) Reload() { t.list.SetItems(t.rebuild(t.state.Config())) } + type tabKeys struct { Next key.Binding Prev key.Binding @@ -55,7 +150,7 @@ type tabKeys struct { Quit key.Binding } -func newTabKeys(sel, view, close key.Binding) tabKeys { //nolint:gocritic // shadow is okay here +func newTabKeys(sel, view, close key.Binding) tabKeys { //nolint:gocritic // shadowing the builtin close is fine for a local field name return tabKeys{ Next: keys.New(keys.L).WithHelp("next").WithAliases(keys.Tab, keys.ArrowRight).Bind(), Prev: keys.New(keys.H).WithHelp("prev").WithAliases(keys.ShiftTab, keys.ArrowLeft).Bind(), @@ -66,71 +161,39 @@ func newTabKeys(sel, view, close key.Binding) tabKeys { //nolint:gocritic // sha } } -func (k tabKeys) Help() func() []key.Binding { //nolint:gocritic // this must be a value receiver to avoid overwriting the original bindings - // Exclude built-in keys from the help menu to avoid duplicates +func (k tabKeys) Help() func() []key.Binding { //nolint:gocritic // value receiver avoids mutating the caller's bindings + // Exclude built-in keys from the help menu to avoid duplicates. k.Quit = key.Binding{} - var kys []key.Binding + var bindings []key.Binding v := reflect.ValueOf(k) for _, field := range v.Fields() { - k := field.Interface().(key.Binding) - if !reflect.DeepEqual(k, key.Binding{}) { - kys = append(kys, k) + b := field.Interface().(key.Binding) + if !reflect.DeepEqual(b, key.Binding{}) { + bindings = append(bindings, b) } } - return func() []key.Binding { return kys } + return func() []key.Binding { return bindings } } -type TabAction struct { - Item details.Item - Kind TabActionKind -} - -type TabActionKind int +// actionKind enumerates what a tab wants Tabs to do with the selected entry. +type actionKind int const ( - TabActionNone TabActionKind = iota - TabActionShowDetails - TabActionSelect - // TabActionCreate opens a form to create a new entry. - TabActionCreate - // TabActionEdit opens a form pre-filled from Item to edit it. - TabActionEdit - // TabActionRename opens a form to rename Item. - TabActionRename - // TabActionDelete requests confirmation to delete Item. - TabActionDelete + actionNone actionKind = iota + showDetails + selectAction + actionCreate + editAction + renameAction + deleteAction ) -func NoAction() TabAction { - return TabAction{Kind: TabActionNone} -} - -func ShowDetails(item details.Item) TabAction { - return TabAction{Kind: TabActionShowDetails, Item: item} -} - -func Select(item details.Item) TabAction { - return TabAction{Kind: TabActionSelect, Item: item} -} - -// Create requests opening a create form for the active tab. -func Create() TabAction { - return TabAction{Kind: TabActionCreate} -} - -// Edit requests opening an edit form pre-filled from item. -func Edit(item details.Item) TabAction { - return TabAction{Kind: TabActionEdit, Item: item} -} - -// Rename requests opening a rename form for item. -func Rename(item details.Item) TabAction { - return TabAction{Kind: TabActionRename, Item: item} +// TabAction is a tab's request to Tabs, optionally carrying the selected entry. +type TabAction struct { + item entry + kind actionKind } -// Delete requests confirmation to delete item. -func Delete(item details.Item) TabAction { - return TabAction{Kind: TabActionDelete, Item: item} -} +func noAction() TabAction { return TabAction{kind: actionNone} } diff --git a/tui/tabs/tabs.go b/tui/tabs/tabs.go index 30dcb01..8f6ebf7 100644 --- a/tui/tabs/tabs.go +++ b/tui/tabs/tabs.go @@ -15,24 +15,6 @@ import ( "github.com/lvlcn-t/azctx/tui/styles" ) -// Manager is the subset of contexts.Manager the tabs use to persist changes. -type Manager interface { - CreateTenant(store *config.Store, name, id string) error - UpdateTenant(store *config.Store, name, id string) error - RenameTenant(store *config.Store, oldName, newName string) (contexts.RenameResult, error) - DeleteTenant(store *config.Store, name string) (contexts.DeleteResult, error) - - CreateContext(store *config.Store, next config.Context) error - UpdateContext(store *config.Store, next config.Context, subscriptionChanged bool) error - RenameContext(store *config.Store, oldName, newName string) error - DeleteContext(store *config.Store, name string) (contexts.DeleteResult, error) - - CreateCredential(store *config.Store, cred *config.Credential) error - UpdateCredential(store *config.Store, cred *config.Credential) error - RenameCredential(store *config.Store, oldName, newName string) (contexts.RenameResult, error) - DeleteCredential(store *config.Store, name string) (contexts.DeleteResult, error) -} - // formIntent records what a submitted form should do. type formIntent int @@ -42,24 +24,26 @@ const ( intentRename ) -// Tabs is the main UI component that manages the different tabs and their content. +// Tabs is the main UI component: a tab strip over uniform CRUD list tabs, plus +// the detail, form, and confirm overlays. type Tabs struct { item details.Item - manager Manager - pending details.Item + manager *contexts.Manager + pending entry state *state.UI confirm confirm status string + lastErr error keys tabKeys details details.Viewer - tabs []Tab + tabs []*Tab form form.Model intent formIntent active int } -// New creates a new Tabs component with the given state and manager. -func New(s *state.UI, manager Manager) *Tabs { +// New creates a Tabs component with the given state and manager. +func New(s *state.UI, manager *contexts.Manager) *Tabs { t := &Tabs{ state: s, manager: manager, @@ -68,22 +52,33 @@ func New(s *state.UI, manager Manager) *Tabs { } w, h := t.tabSize() l := newList(w, h) - t.tabs = []Tab{ - contextsTab(s, l), - tenantsTab(s, l), - credentialsTab(s, l), + + view := keys.New(keys.View).WithHelp("view").WithAliases(keys.Describe).Bind() + browseSel := keys.New(keys.Enter).WithHelp("view").WithAliases(keys.View, keys.Describe).Bind() + + // Contexts additionally support Enter=select (activate) in interactive mode. + ctxSel := keys.New(keys.Enter).WithHelp("select").WithAliases(keys.Use).Bind() + ctxView := view + if s.Mode() == state.ModeBrowse { + ctxSel = browseSel + ctxView = key.Binding{} + } + + t.tabs = []*Tab{ + newTab(s, "Contexts", contextItems, ctxSel, ctxView, l), + newTab(s, "Tenants", tenantItems, browseSel, key.Binding{}, l), + newTab(s, "Credentials", credentialItems, browseSel, key.Binding{}, l), } t.active = 0 return t } -// Resize resizes the active tab's content to fit the current terminal size. +// Resize resizes every tab's content to fit the current terminal size. func (t *Tabs) Resize() { w, h := t.tabSize() for _, tab := range t.tabs { tab.Resize(w, h) } - // TODO: also resize details view if it's open? } // Tab layout content sizing constants. @@ -100,9 +95,7 @@ func (t *Tabs) tabSize() (width, height int) { return width, height } -func (t *Tabs) Init() tea.Cmd { - return nil -} +func (t *Tabs) Init() tea.Cmd { return nil } func (t *Tabs) Update(msg tea.Msg) tea.Cmd { switch { @@ -110,15 +103,13 @@ func (t *Tabs) Update(msg tea.Msg) tea.Cmd { var cmd tea.Cmd t.details, cmd = t.details.Update(msg) return cmd - case t.state.Is(state.FormView): return t.updateForm(msg) - case t.state.Is(state.ConfirmView): return t.updateConfirm(msg) } - // If the active list is filtering, do not treat keys as global shortcuts. + // While filtering, keys belong to the list, not the global shortcuts. if t.tabs[t.active].Filtering() { action, cmd := t.tabs[t.active].Update(msg) return tea.Batch(cmd, t.handleAction(action)) @@ -128,11 +119,9 @@ func (t *Tabs) Update(msg tea.Msg) tea.Cmd { case keys.Matches(msg, t.keys.Next): t.next() return nil - case keys.Matches(msg, t.keys.Prev): t.prev() return nil - case keys.Matches(msg, t.keys.Quit): return t.state.Quit() } @@ -141,7 +130,7 @@ func (t *Tabs) Update(msg tea.Msg) tea.Cmd { return tea.Batch(cmd, t.handleAction(action)) } -// updateForm drives the create/edit form and applies its result on submit. +// updateForm drives the create/edit/rename form and applies its result. func (t *Tabs) updateForm(msg tea.Msg) tea.Cmd { switch msg := msg.(type) { case form.Submitted: @@ -181,266 +170,120 @@ func (t *Tabs) View() string { return t.confirm.View() } - // Title cloud := styles.SplashCloudStyle.Render("☁") bolt := styles.SplashBoltStyle.Render("⚡") name := styles.SplashNameStyle.Render("azctx") title := " " + cloud + " " + bolt + " " + name - // Tabs - tabs := t.renderTabs() content := t.tabs[t.active].View() - - view := lipgloss.JoinVertical(lipgloss.Left, title, tabs, content) + view := lipgloss.JoinVertical(lipgloss.Left, title, t.renderTabs(), content) if t.status != "" { view = lipgloss.JoinVertical(lipgloss.Left, view, styles.HelpStyle.Render(t.status)) } return view } +// handleAction reacts to a tab's request. It is entity-agnostic: every branch +// operates on the entry the action carries (or the active tab's blank entry for +// create), never on a concrete item type. func (t *Tabs) handleAction(action TabAction) tea.Cmd { - switch action.Kind { - case TabActionNone: - return nil - - case TabActionShowDetails: - if action.Item == nil { - return nil - } - - t.item = action.Item - t.state.Transition(state.DetailView) - return nil - - case TabActionSelect: - if action.Item == nil { - return nil - } + switch action.kind { + case showDetails: + return t.showDetail(action.item) + case selectAction: if t.state.Mode() == state.ModeInteractive { - return t.handleInteractiveSelect(action.Item) - } - - t.item = action.Item - t.state.Transition(state.DetailView) - return nil - - case TabActionCreate: - return t.openForm(intentCreate, nil) - - case TabActionEdit: - if action.Item == nil { + if a, ok := action.item.(activatable); ok { + return a.activate(t.state) + } return nil } - return t.openForm(intentEdit, action.Item) + return t.showDetail(action.item) - case TabActionRename: - if action.Item == nil { - return nil - } - return t.openForm(intentRename, action.Item) - - case TabActionDelete: - if action.Item == nil { - return nil - } - return t.openDelete(action.Item) + case actionCreate: + return t.openForm(intentCreate, t.tabs[t.active].blank()) + case editAction: + return t.openForm(intentEdit, action.item) + case renameAction: + return t.openRename(action.item) + case deleteAction: + return t.openDelete(action.item) default: return nil } } -// openForm opens a create, edit, or rename form for the active tab and records -// the intent and target item to apply on submit (nil item for create). -func (t *Tabs) openForm(intent formIntent, item details.Item) tea.Cmd { - f, ok := t.buildForm(intent, item) - if !ok { +func (t *Tabs) showDetail(item details.Item) tea.Cmd { + if item == nil { return nil } + t.item = item + t.state.Transition(state.DetailView) + return nil +} - t.form = f +// openForm opens a create or edit form for the given entry. +func (t *Tabs) openForm(intent formIntent, e entry) tea.Cmd { + if e == nil { + return nil + } + t.form = e.form(intent, t.state.Config()) t.intent = intent - t.pending = item + t.pending = e t.status = "" t.state.Transition(state.FormView) return nil } -// openDelete opens the confirmation prompt for deleting item. -func (t *Tabs) openDelete(item details.Item) tea.Cmd { - label, ok := deletableLabel(item) - if !ok { +// openRename opens the single-field rename form for the given entry. +func (t *Tabs) openRename(e entry) tea.Cmd { + if e == nil { return nil } - - t.confirm = newConfirm("Delete " + label + "?") - t.pending = item + t.form = renameForm(e) + t.intent = intentRename + t.pending = e t.status = "" - t.state.Transition(state.ConfirmView) + t.state.Transition(state.FormView) return nil } -// buildForm returns the form for the active tab and intent, pre-filled from item -// for edit and rename. The second return is false when the active tab does not -// support the requested form. -func (t *Tabs) buildForm(intent formIntent, item details.Item) (form.Model, bool) { - switch t.tabs[t.active].(type) { - case *TenantsTab: - if intent == intentRename { - return renameForm("tenant", item), true - } - return tenantForm(intent, item), true - - case *ContextsTab: - if intent == intentRename { - return renameForm("context", item), true - } - return contextForm(intent, t.state.Config(), item), true - - case *CredentialsTab: - if intent == intentRename { - return renameForm("credential", item), true - } - return credentialForm(intent, item), true - - default: - return form.Model{}, false +// openDelete opens the confirmation prompt for deleting the given entry. +func (t *Tabs) openDelete(e entry) tea.Cmd { + if e == nil { + return nil } + t.confirm = newConfirm("Delete " + e.name() + "?") + t.pending = e + t.status = "" + t.state.Transition(state.ConfirmView) + return nil } -// applyForm persists the submitted form values according to the active tab and -// recorded intent. +// applyForm persists the submitted form via the pending entry. func (t *Tabs) applyForm(values map[string]string) tea.Cmd { t.state.Transition(state.Tabs) - - switch t.tabs[t.active].(type) { - case *TenantsTab: - return t.applyTenantForm(values) - case *ContextsTab: - return t.applyContextForm(values) - case *CredentialsTab: - return t.applyCredentialForm(values) - default: - return nil - } -} - -// applyCredentialForm maps a submitted credential form to the matching intent -// method. -func (t *Tabs) applyCredentialForm(values map[string]string) tea.Cmd { - store := t.state.Config() - cred := credentialFromValues(values) - - switch t.intent { - case intentCreate: - err := t.manager.CreateCredential(store, cred) - return t.finish(err, "created credential "+cred.Name) - - case intentEdit: - err := t.manager.UpdateCredential(store, cred) - return t.finish(err, "updated credential "+cred.Name) - - case intentRename: - item, ok := t.pending.(*CredentialItem) - if !ok { - return nil - } - result, err := t.manager.RenameCredential(store, item.Name, values[fieldName]) - return t.finish(err, renameStatus("credential", item.Name, values[fieldName], result.UpdatedContexts)) - - default: + if t.pending == nil { return nil } -} - -// applyContextForm maps a submitted context form to the matching intent method. -func (t *Tabs) applyContextForm(values map[string]string) tea.Cmd { - store := t.state.Config() - next := config.Context{ - Name: values[fieldName], - Details: config.ContextDetails{ - Tenant: values[fieldTenant], - Credential: values[fieldCredential], - Subscription: values[fieldSubscription], - }, - } - - switch t.intent { - case intentCreate: - err := t.manager.CreateContext(store, next) - return t.finish(err, "created context "+next.Name) - - case intentEdit: - // The form always carries the subscription value, so treat it as changed. - err := t.manager.UpdateContext(store, next, true) - return t.finish(err, "updated context "+next.Name) - - case intentRename: - item, ok := t.pending.(*ContextItem) - if !ok { - return nil - } - err := t.manager.RenameContext(store, item.Name, values[fieldName]) - return t.finish(err, "renamed context "+item.Name+" to "+values[fieldName]) - default: - return nil - } + status, err := t.pending.save(t.manager, t.state.Config(), submission{intent: t.intent, values: values}) + return t.finish(status, err) } -// applyTenantForm maps a submitted tenant form to the matching intent method. -func (t *Tabs) applyTenantForm(values map[string]string) tea.Cmd { - store := t.state.Config() - - switch t.intent { - case intentCreate: - err := t.manager.CreateTenant(store, values[fieldName], values[fieldID]) - return t.finish(err, "created tenant "+values[fieldName]) - - case intentEdit: - err := t.manager.UpdateTenant(store, values[fieldName], values[fieldID]) - return t.finish(err, "updated tenant "+values[fieldName]) - - case intentRename: - item, ok := t.pending.(*TenantItem) - if !ok { - return nil - } - result, err := t.manager.RenameTenant(store, item.Name, values[fieldName]) - return t.finish(err, renameStatus("tenant", item.Name, values[fieldName], result.UpdatedContexts)) - - default: - return nil - } -} - -// applyDelete performs the pending delete. +// applyDelete performs the pending delete via the pending entry. func (t *Tabs) applyDelete() tea.Cmd { - switch item := t.pending.(type) { - case *TenantItem: - result, err := t.manager.DeleteTenant(t.state.Config(), item.Name) - return t.finish(err, deleteStatus("tenant", item.Name, result.OrphanedContexts)) - - case *ContextItem: - result, err := t.manager.DeleteContext(t.state.Config(), item.Name) - status := "deleted context " + item.Name - if result.WasActive { - status += " (warning: removed the active context; use a context to select a new one)" - } - return t.finish(err, status) - - case *CredentialItem: - result, err := t.manager.DeleteCredential(t.state.Config(), item.Name) - return t.finish(err, deleteStatus("credential", item.Name, result.OrphanedContexts)) - - default: + if t.pending == nil { return nil } + status, err := t.pending.remove(t.manager, t.state.Config()) + return t.finish(status, err) } -// finish reloads the tabs after a write and records a status message. -func (t *Tabs) finish(writeErr error, okStatus string) tea.Cmd { +// finish reloads the tabs after a successful write and records a status line. +func (t *Tabs) finish(status string, writeErr error) tea.Cmd { + t.lastErr = writeErr if writeErr != nil { t.status = "error: " + writeErr.Error() return nil @@ -451,7 +294,7 @@ func (t *Tabs) finish(writeErr error, okStatus string) tea.Cmd { return nil } - t.status = okStatus + t.status = status return nil } @@ -470,18 +313,11 @@ func (t *Tabs) reload() error { return nil } -// deletableLabel returns a human label for a deletable item. -func deletableLabel(item details.Item) (string, bool) { - switch it := item.(type) { - case *TenantItem: - return "tenant " + it.Name, true - case *ContextItem: - return "context " + it.Name, true - case *CredentialItem: - return "credential " + it.Name, true - default: - return "", false - } +// renameForm builds the single-field new-name form for an entry. +func renameForm(e entry) form.Model { + return form.New("Rename "+e.name(), []form.Field{ + {Key: fieldName, Label: "New name", Placeholder: e.name(), Required: true}, + }) } // deleteStatus builds the status line for a delete, warning about orphans. @@ -502,64 +338,18 @@ func renameStatus(kind, oldName, newName string, updated []string) string { return msg } -// renameForm builds a single-field form asking for the entry's new name. -func renameForm(kind string, item details.Item) form.Model { - current := entryName(item) - return form.New("Rename "+kind+" "+current, []form.Field{ - {Key: fieldName, Label: "New name", Placeholder: current, Required: true}, - }) -} +func (t *Tabs) next() { t.active = (t.active + 1) % len(t.tabs) } +func (t *Tabs) prev() { t.active = (t.active - 1 + len(t.tabs)) % len(t.tabs) } -// entryName returns the config name of a list item, without any display marker. -func entryName(item details.Item) string { - switch it := item.(type) { - case *TenantItem: - return it.Name - case *ContextItem: - return it.Name - case *CredentialItem: - return it.Name - default: - return "" - } -} - -func (t *Tabs) handleInteractiveSelect(item details.Item) tea.Cmd { - ctx, ok := item.(*ContextItem) - if !ok { - // In interactive mode, only context selection is meaningful right now. - // Other tabs can either no-op or fall back to details, depending on taste. - return nil - } - - t.state.SelectContext(ctx.Name) - return t.state.Quit() -} - -func (t *Tabs) next() { - t.active = (t.active + 1) % len(t.tabs) -} - -func (t *Tabs) prev() { - t.active = (t.active - 1 + len(t.tabs)) % len(t.tabs) -} - -var tabLabels = []string{ - "Contexts", - "Tenants", - "Credentials", -} - -// renderTabs renders the tab bar with the given active index. +// renderTabs renders the tab bar with the active tab highlighted. func (t *Tabs) renderTabs() string { - var rendered []string - for i, label := range tabLabels { + rendered := make([]string, 0, len(t.tabs)) + for i, tab := range t.tabs { if i == t.active { - rendered = append(rendered, styles.ActiveTabStyle.Render(label)) + rendered = append(rendered, styles.ActiveTabStyle.Render(tab.title)) continue } - - rendered = append(rendered, styles.InactiveTabStyle.Render(label)) + rendered = append(rendered, styles.InactiveTabStyle.Render(tab.title)) } row := lipgloss.JoinHorizontal(lipgloss.Top, rendered...) diff --git a/tui/tabs/tabs_test.go b/tui/tabs/tabs_test.go index c4df8a1..006d620 100644 --- a/tui/tabs/tabs_test.go +++ b/tui/tabs/tabs_test.go @@ -165,8 +165,8 @@ func TestTabs_CreateTenant_RejectsDuplicate(t *testing.T) { cmd := tabs.Update(tea.KeyMsg{Type: tea.KeyEnter}) drain(tabs, cmd) - // The write is rejected and surfaced in the status line. - assert.Contains(t, tabs.status, "already exists") + // The write is rejected with the create-conflict sentinel. + require.ErrorIs(t, tabs.lastErr, contexts.ErrTenantExists) } func TestTabs_DeleteTenant(t *testing.T) { @@ -207,6 +207,3 @@ func drain(tabs *Tabs, cmd tea.Cmd) { tabs.Update(msg) } } - -// compile-time guard: the real manager satisfies the tabs.Manager interface. -var _ Manager = (*contexts.Manager)(nil) diff --git a/tui/tabs/tenant_items.go b/tui/tabs/tenant_items.go index 6911c9b..a2bdaeb 100644 --- a/tui/tabs/tenant_items.go +++ b/tui/tabs/tenant_items.go @@ -3,6 +3,7 @@ package tabs import ( "github.com/charmbracelet/bubbles/list" "github.com/lvlcn-t/azctx/config" + "github.com/lvlcn-t/azctx/contexts" "github.com/lvlcn-t/azctx/tui/details" "github.com/lvlcn-t/azctx/tui/form" ) @@ -11,6 +12,7 @@ var ( _ list.Item = (*TenantItem)(nil) _ list.DefaultItem = (*TenantItem)(nil) _ details.Item = (*TenantItem)(nil) + _ entry = (*TenantItem)(nil) ) type TenantItem struct{ config.Tenant } @@ -26,9 +28,9 @@ func tenantItems(s *config.Store) []list.Item { func (i *TenantItem) Title() string { return i.Name } func (i *TenantItem) Description() string { return i.Tenant.Details.ID } -func (i *TenantItem) FilterValue() string { - return i.Name + " " + i.Tenant.Details.ID -} +func (i *TenantItem) name() string { return i.Name } +func (i *TenantItem) blank() entry { return &TenantItem{} } +func (i *TenantItem) FilterValue() string { return i.Name + " " + i.Tenant.Details.ID } func (i *TenantItem) Details() details.View { return details.View{ @@ -40,16 +42,15 @@ func (i *TenantItem) Details() details.View { } } -// tenantForm builds the create or edit form for a tenant. On edit the name is +// form builds the create or edit form for a tenant. On edit the name is // pre-filled and locked (read-only): the name is the entry's identity and can // only be changed through the rename flow, never an update. -func tenantForm(intent formIntent, item details.Item) form.Model { - var name, id string +func (i *TenantItem) form(intent formIntent, _ *config.Store) form.Model { + name, id := "", "" title := "New tenant" readonly := false - if tenant, ok := item.(*TenantItem); ok && intent == intentEdit { - name = tenant.Name - id = tenant.Tenant.Details.ID + if intent == intentEdit { + name, id = i.Name, i.Tenant.Details.ID title = "Edit tenant" readonly = true } @@ -59,3 +60,23 @@ func tenantForm(intent formIntent, item details.Item) form.Model { {Key: fieldID, Label: "ID", Placeholder: "00000000-0000-0000-0000-000000000000", Value: id, Required: true}, }) } + +func (i *TenantItem) save(m *contexts.Manager, store *config.Store, sub submission) (string, error) { + name, id := sub.values[fieldName], sub.values[fieldID] + switch sub.intent { + case intentCreate: + return "created tenant " + name, m.CreateTenant(store, name, id) + case intentEdit: + return "updated tenant " + name, m.UpdateTenant(store, name, id) + case intentRename: + result, err := m.RenameTenant(store, i.Name, name) + return renameStatus("tenant", i.Name, name, result.UpdatedContexts), err + default: + return "", nil + } +} + +func (i *TenantItem) remove(m *contexts.Manager, store *config.Store) (string, error) { + result, err := m.DeleteTenant(store, i.Name) + return deleteStatus("tenant", i.Name, result.OrphanedContexts), err +} diff --git a/tui/tabs/tenants.go b/tui/tabs/tenants.go deleted file mode 100644 index 31c0812..0000000 --- a/tui/tabs/tenants.go +++ /dev/null @@ -1,15 +0,0 @@ -package tabs - -import "github.com/lvlcn-t/azctx/tui/state" - -var _ Tab = (*TenantsTab)(nil) - -type TenantsTab struct { - browseTab -} - -func tenantsTab(s *state.UI, l listBuilder) *TenantsTab { //nolint:gocritic // irrelevant on startup - return &TenantsTab{ - browseTab: newCRUDBrowseTab(s, tenantItems, l), - } -} diff --git a/tui/tabs/validators_test.go b/tui/tabs/validators_test.go new file mode 100644 index 0000000..50338ae --- /dev/null +++ b/tui/tabs/validators_test.go @@ -0,0 +1,39 @@ +package tabs + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExistsValidator(t *testing.T) { + validate := existsValidator([]string{"corp", "platform"}) + + require.NoError(t, validate("corp")) + require.ErrorIs(t, validate("ghost"), errReferenceUnknown) +} + +func TestCredentialTypeValidator(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "user", value: "user", wantErr: false}, + {name: "service-principal", value: "service-principal", wantErr: false}, + {name: "workload-identity", value: "workload-identity", wantErr: false}, + {name: "unknown", value: "bogus", wantErr: true}, + {name: "empty", value: "", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := credentialTypeValidator(tt.value) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +}