diff --git a/.claude/commands/refactor-review.md b/.claude/commands/refactor-review.md new file mode 100644 index 0000000..17c3587 --- /dev/null +++ b/.claude/commands/refactor-review.md @@ -0,0 +1,63 @@ +Refactor the codebase based on a prior `/senior-review` report. + +## Input +$ARGUMENTS — optional: specific finding or priority number to tackle (e.g., "priority 1", "#3 DRY filter logic", "all warnings"). If empty, work through findings by priority order. + +## Phase 1: Load Review + +1. Find the most recent review report in `.claude/reports/senior-review-*.md` (sort by filename descending to get the latest). +2. If no report exists, tell the user to run `/senior-review` first and stop. +3. Read the full report. Parse out: + - The **Refactoring Priorities** section (ranked list) + - All **Findings** with their severity, file paths, and suggestions + +## Phase 2: Select Scope + +If `$ARGUMENTS` is provided: +- Match it against priority numbers ("priority 1", "priority 2") or finding titles/categories +- Scope the work to just those items + +If `$ARGUMENTS` is empty: +- Present the Refactoring Priorities list to the user via AskUserQuestion +- Ask which priority (or priorities) to tackle in this session +- Recommend starting with Priority 1 + +## Phase 3: Plan + +Enter plan mode. For each selected refactoring: + +1. Read every file mentioned in the finding's `File:` references +2. Understand the current pattern and all call sites +3. Design the refactoring following the finding's `Suggestion:` as a starting point, but adapt based on what you see in the actual code +4. Ensure the refactoring does NOT change behavior — this is a pure refactor +5. Identify all files that need to change and the order of changes + +Key constraints: +- Preserve all existing tests — they must still pass after refactoring +- Do not change public API signatures unless the finding explicitly calls for it +- If a refactoring touches more than 10 files, break it into smaller commits +- Follow existing code patterns (lipgloss styling, Bubbletea conventions, etc.) + +## Phase 4: Implement + +After plan approval, implement the refactoring: + +1. Make changes file by file, following the plan order +2. After each logical group of changes, run `make test` to verify nothing broke +3. If tests fail, fix immediately before continuing + +## Phase 5: Verify & Update Report + +1. `make test` — all tests pass +2. `make build` — binary compiles +3. Re-run the LoC count (`find internal/ cmd/ -name '*.go' ! -name '*_test.go' | xargs wc -l | sort -rn`) and compare against the report's original LoC table +4. Update the review report file: mark completed findings as `[DONE]` and note what changed +5. Report a summary of what was refactored and suggest running `/ship-pr` to create the PR + +## Guidelines + +- This is a **refactor-only** skill. No new features, no behavior changes. +- Every change must be justified by a specific finding in the review report. +- If a suggestion from the report turns out to be impractical after reading the code, skip it and explain why. +- Prefer small, focused changes over large sweeping rewrites. +- If the refactoring reveals new issues not in the original report, note them but don't fix them — they belong in the next `/senior-review` cycle. diff --git a/.gitignore b/.gitignore index 371fa8e..d64e837 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /dist/ /unic +.claude/reports/ diff --git a/internal/app/app.go b/internal/app/app.go index 4b2d65b..ba04baa 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -244,299 +244,44 @@ func (m Model) loadCallerIdentity() tea.Cmd { } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Global messages switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height return m, nil - case callerIdentityMsg: m.callerIdentity = msg.identity return m, nil - case updateAvailableMsg: m.installMethod = msg.method if msg.version != "" { m.updateAvailable = msg.version } return m, nil - - case instancesLoadedMsg: - m.instances = msg.instances - m.filtered = msg.instances - m.instIdx = 0 - m.screen = screenInstanceList - return m, nil - - case vpcsLoadedMsg: - m.vpcs = msg.vpcs - m.filteredVPCs = msg.vpcs - m.vpcIdx = 0 - m.screen = screenVPCList - return m, nil - - case subnetsLoadedMsg: - m.subnets = msg.subnets - m.subnetIdx = 0 - m.screen = screenSubnetList - return m, nil - - case availableIPsLoadedMsg: - m.availableIPs = msg.ips - m.filteredIPs = msg.ips - m.ipScrollOffset = 0 - m.ipFilter = "" - m.ipFilterActive = false - m.screen = screenSubnetDetail - return m, nil - - case route53ZonesLoadedMsg: - m.route53Zones = msg.zones - m.filteredRoute53Zones = msg.zones - m.route53ZoneIdx = 0 - m.screen = screenRoute53ZoneList - return m, nil - - case route53RecordsLoadedMsg: - m.route53Records = msg.records - m.filteredRoute53Records = msg.records - m.route53RecordIdx = 0 - m.screen = screenRoute53RecordList - return m, nil - - case route53ActionDoneMsg: - if msg.err != nil { - m.errMsg = msg.err.Error() - m.screen = screenError - return m, nil - } - m.route53ChangeID = msg.changeID - m.route53ChangeStatus = "PENDING" - m.route53Polling = true - // Reload records and start polling change status - if m.selectedRoute53Zone != nil { - return m, tea.Batch( - m.loadRoute53Records(m.selectedRoute53Zone.ID), - m.pollRoute53ChangeStatus(), - ) - } - m.screen = screenRoute53RecordList - return m, nil - - case route53ChangeStatusMsg: - if msg.err != nil { - m.route53Polling = false - return m, nil - } - m.route53ChangeStatus = msg.status - if msg.status == "INSYNC" { - m.route53Polling = false - return m, nil - } - return m, m.tickRoute53Poll() - - case route53PollTickMsg: - if m.route53Polling { - return m, m.pollRoute53ChangeStatus() - } - return m, nil - - case rdsInstancesLoadedMsg: - m.rdsInstances = msg.instances - m.filteredRDS = msg.instances - m.rdsIdx = 0 - m.screen = screenRDSList - return m, nil - - case secretsLoadedMsg: - m.secrets = msg.secrets - m.filteredSecrets = msg.secrets - m.secretIdx = 0 - m.screen = screenSecretList - return m, nil - - case secretDetailLoadedMsg: - m.selectedSecret = msg.detail - m.screen = screenSecretDetail - return m, nil - - case securityGroupsLoadedMsg: - m.securityGroups = msg.securityGroups - m.filteredSecurityGroups = msg.securityGroups - m.sgIdx = 0 - m.screen = screenSecurityGroupList - return m, nil - - case sgRuleAddedMsg: - if msg.err != nil { - m.errMsg = msg.err.Error() - m.screen = screenError - return m, nil - } - return m, m.refreshSecurityGroup() - - case sgRuleDeletedMsg: - if msg.err != nil { - m.errMsg = msg.err.Error() - m.screen = screenError - return m, nil - } - return m, m.refreshSecurityGroup() - - case sgRefreshedMsg: - if msg.err != nil { - m.errMsg = msg.err.Error() - m.screen = screenError - return m, nil - } - m.selectedSecurityGroup = msg.sg - // Update the SG in the list - for i, sg := range m.securityGroups { - if sg.GroupID == msg.sg.GroupID { - m.securityGroups[i] = *msg.sg - break - } - } - m.applySecurityGroupFilter() - m.sgRuleIdx = 0 - m.screen = screenSecurityGroupDetail - return m, nil - - case iamKeysLoadedMsg: - m.iamKeys = msg.keys - m.iamKeyIdx = 0 - m.screen = screenIAMKeyList - return m, nil - - case iamKeyCreatedMsg: - if msg.err != nil { - m.errMsg = msg.err.Error() - m.screen = screenError - return m, nil - } - m.iamNewKey = msg.newKey - m.iamCopyMsg = "" - m.iamRotationStatus = "" - m.iamNewKeyVerified = false - m.iamOldKeyInactive = false - m.iamOldKeyDeleted = false - m.screen = screenIAMKeyRotateResult - return m, nil - - case iamKeyVerifiedMsg: - if msg.err != nil { - m.iamRotationStatus = fmt.Sprintf("Verification failed: %s", msg.err) - return m, nil - } - m.iamNewKeyVerified = true - if msg.identity != nil { - m.iamRotationStatus = fmt.Sprintf("Verified new key as %s", msg.identity.Arn) - } else { - m.iamRotationStatus = "Verified new key" - } - return m, nil - - case iamKeyDeactivatedMsg: - if msg.err != nil { - m.iamRotationStatus = msg.err.Error() - return m, nil - } - m.iamOldKeyInactive = true - m.iamRotationStatus = fmt.Sprintf("Old key %s marked Inactive", msg.keyID) - return m, nil - - case iamKeyDeletedMsg: - if msg.err != nil { - m.iamRotationStatus = msg.err.Error() - return m, nil - } - m.iamOldKeyDeleted = true - m.iamRotationStatus = fmt.Sprintf("Old key %s deleted", msg.keyID) - return m, nil - - case rdsActionDoneMsg: - if msg.err != nil { - m.errMsg = msg.err.Error() - m.screen = screenError - return m, nil - } - m.rdsPolling = true - m.screen = screenRDSDetail - return m, m.tickRDSPoll(msg.instanceID) - - case rdsStatusRefreshedMsg: - if msg.err != nil { - m.rdsPolling = false - return m, nil - } - m.selectedRDS = msg.instance - // Update the instance in the list - for i, inst := range m.rdsInstances { - if inst.DBInstanceID == msg.instance.DBInstanceID { - m.rdsInstances[i] = *msg.instance - break - } - } - m.applyRDSFilter() - if awsservice.IsTransitionalStatus(msg.instance.Status) { - return m, m.tickRDSPoll(msg.instance.DBInstanceID) - } - m.rdsPolling = false - return m, nil - - case rdsTickMsg: - if m.rdsPolling { - return m, m.pollRDSStatus(msg.instanceID) - } - return m, nil - case errMsg: m.errMsg = msg.err.Error() m.screen = screenError return m, nil + } - case ssmSessionDoneMsg: - if msg.err != nil { - m.errMsg = msg.err.Error() - m.screen = screenError - return m, nil - } - // Return to instance list after session ends - m.screen = screenInstanceList - return m, nil - - case contextsLoadedMsg: - m.ctxList = msg.contexts - m.filteredCtxList = msg.contexts - m.ctxIdx = 0 - m.ctxFilterInput = "" - m.ctxFilterActive = false - for i, ctx := range m.filteredCtxList { - if ctx.Current { - m.ctxIdx = i - break - } - } - m.screen = screenContextPicker - return m, nil - - case ssoLoginDoneMsg: - if msg.err != nil { - m.errMsg = fmt.Sprintf("SSO login failed: %s", msg.err) - m.screen = screenError - return m, tea.ClearScreen + // Domain message handlers + for _, h := range []func(tea.Msg) (tea.Model, tea.Cmd, bool){ + m.handleEC2VPCMsg, + m.handleRoute53Msg, + m.handleRDSMsg, + m.handleSecurityGroupMsg, + m.handleIAMMsg, + m.handleSecretMsg, + m.handleContextMsg, + } { + if newM, cmd, handled := h(msg); handled { + return newM, cmd } - // SSO login done, now finalize the context switch - return m, m.finalizeContextSwitch() - - case contextSwitchedMsg: - m.cfg = msg.cfg - m.callerIdentity = msg.identity - m.awsRepo = nil // reset so next AWS call uses new credentials - m.screen = m.ctxPrevScreen - return m, tea.ClearScreen + } - case tea.KeyMsg: + // Key messages — screen dispatch + if msg, ok := msg.(tea.KeyMsg); ok { // Global quit if msg.String() == "ctrl+c" { m.quitting = true @@ -618,6 +363,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// handleSecretMsg handles Secrets Manager messages. +func (m Model) handleSecretMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { + switch msg := msg.(type) { + case secretsLoadedMsg: + m.secrets = msg.secrets + m.filteredSecrets = msg.secrets + m.secretIdx = 0 + m.screen = screenSecretList + return m, nil, true + case secretDetailLoadedMsg: + m.selectedSecret = msg.detail + m.screen = screenSecretDetail + return m, nil, true + } + return m, nil, false +} + func (m Model) updateServiceList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q": @@ -910,21 +672,14 @@ func (m Model) updateSecretList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() if m.secretFilterActive { - switch key { - case "esc": - m.secretFilterActive = false - case "enter": + newFilter, deactivate, changed := handleFilterKey(key, m.secretFilter) + m.secretFilter = newFilter + if deactivate { m.secretFilterActive = false - case "backspace": - if len(m.secretFilter) > 0 { - m.secretFilter = m.secretFilter[:len(m.secretFilter)-1] - m.applySecretFilter() - } - default: - if len(key) == 1 { - m.secretFilter += key - m.applySecretFilter() - } + } + if changed { + m.filteredSecrets = applyFilter(m.secrets, m.secretFilter) + m.secretIdx = 0 } return m, nil } @@ -964,21 +719,6 @@ func (m Model) updateSecretDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m *Model) applySecretFilter() { - if m.secretFilter == "" { - m.filteredSecrets = m.secrets - } else { - query := strings.ToLower(m.secretFilter) - var result []awsservice.Secret - for _, s := range m.secrets { - if strings.Contains(s.FilterText(), query) { - result = append(result, s) - } - } - m.filteredSecrets = result - } - m.secretIdx = 0 -} func (m Model) viewSecretList() string { var b strings.Builder diff --git a/internal/app/filter.go b/internal/app/filter.go new file mode 100644 index 0000000..a5a02eb --- /dev/null +++ b/internal/app/filter.go @@ -0,0 +1,43 @@ +package app + +import "strings" + +// Filterable is implemented by any type that supports text-based filtering. +type Filterable interface { + FilterText() string +} + +// applyFilter returns items matching the query via case-insensitive substring on FilterText. +// Returns the full slice unchanged if query is empty. +func applyFilter[T Filterable](items []T, query string) []T { + if query == "" { + return items + } + q := strings.ToLower(query) + var result []T + for _, item := range items { + if strings.Contains(strings.ToLower(item.FilterText()), q) { + result = append(result, item) + } + } + return result +} + +// handleFilterKey processes a keystroke while filter input is active. +// Returns the updated filter string, whether to deactivate filter mode, and whether the filter changed. +func handleFilterKey(key, filter string) (newFilter string, deactivate bool, changed bool) { + switch key { + case "esc", "enter": + return filter, true, false + case "backspace": + if len(filter) > 0 { + return filter[:len(filter)-1], false, true + } + return filter, false, false + default: + if len(key) == 1 { + return filter + key, false, true + } + return filter, false, false + } +} diff --git a/internal/app/screen_context.go b/internal/app/screen_context.go index 693f6a9..7c44db8 100644 --- a/internal/app/screen_context.go +++ b/internal/app/screen_context.go @@ -13,6 +13,41 @@ import ( awsservice "unic/internal/services/aws" ) +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.ctxIdx = 0 + m.ctxFilterInput = "" + m.ctxFilterActive = false + for i, ctx := range m.filteredCtxList { + if ctx.Current { + m.ctxIdx = i + break + } + } + m.screen = screenContextPicker + return m, nil, true + + case ssoLoginDoneMsg: + if msg.err != nil { + m.errMsg = fmt.Sprintf("SSO login failed: %s", msg.err) + m.screen = screenError + return m, tea.ClearScreen, true + } + return m, m.finalizeContextSwitch(), true + + case contextSwitchedMsg: + m.cfg = msg.cfg + m.callerIdentity = msg.identity + m.awsRepo = nil + m.screen = m.ctxPrevScreen + return m, tea.ClearScreen, true + } + return m, nil, false +} + func (m Model) loadContexts() tea.Cmd { return func() tea.Msg { contexts, err := config.Contexts(m.configPath) @@ -26,23 +61,15 @@ func (m Model) loadContexts() tea.Cmd { func (m Model) updateContextPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() - // If filter is active, handle text input if m.ctxFilterActive { - switch key { - case "esc": + newFilter, deactivate, changed := handleFilterKey(key, m.ctxFilterInput) + m.ctxFilterInput = newFilter + if deactivate { m.ctxFilterActive = false - case "enter": - m.ctxFilterActive = false - case "backspace": - if len(m.ctxFilterInput) > 0 { - m.ctxFilterInput = m.ctxFilterInput[:len(m.ctxFilterInput)-1] - m.applyCtxFilter() - } - default: - if len(key) == 1 { - m.ctxFilterInput += key - m.applyCtxFilter() - } + } + if changed { + m.filteredCtxList = applyFilter(m.ctxList, m.ctxFilterInput) + m.ctxIdx = 0 } return m, nil } @@ -91,21 +118,6 @@ func (m Model) updateContextPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m *Model) applyCtxFilter() { - if m.ctxFilterInput == "" { - m.filteredCtxList = m.ctxList - } else { - query := strings.ToLower(m.ctxFilterInput) - var result []config.ContextInfo - for _, ctx := range m.ctxList { - if strings.Contains(ctx.FilterText(), query) { - result = append(result, ctx) - } - } - m.filteredCtxList = result - } - m.ctxIdx = 0 -} func (m Model) switchContext(name string) tea.Cmd { return func() tea.Msg { diff --git a/internal/app/screen_ec2.go b/internal/app/screen_ec2.go index 4116574..db4d977 100644 --- a/internal/app/screen_ec2.go +++ b/internal/app/screen_ec2.go @@ -10,26 +10,61 @@ import ( awsservice "unic/internal/services/aws" ) +func (m Model) handleEC2VPCMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { + switch msg := msg.(type) { + case instancesLoadedMsg: + m.instances = msg.instances + m.filtered = msg.instances + m.instIdx = 0 + m.screen = screenInstanceList + return m, nil, true + + case vpcsLoadedMsg: + m.vpcs = msg.vpcs + m.filteredVPCs = msg.vpcs + m.vpcIdx = 0 + m.screen = screenVPCList + return m, nil, true + + case subnetsLoadedMsg: + m.subnets = msg.subnets + m.subnetIdx = 0 + m.screen = screenSubnetList + return m, nil, true + + case availableIPsLoadedMsg: + m.availableIPs = msg.ips + m.filteredIPs = msg.ips + m.ipScrollOffset = 0 + m.ipFilter = "" + m.ipFilterActive = false + m.screen = screenSubnetDetail + return m, nil, true + + case ssmSessionDoneMsg: + if msg.err != nil { + m.errMsg = msg.err.Error() + m.screen = screenError + return m, nil, true + } + m.screen = screenInstanceList + return m, nil, true + } + return m, nil, false +} + func (m Model) updateInstanceList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() - // If filter is active, handle text input if m.filterActive { - switch key { - case "esc": - m.filterActive = false - case "enter": + newFilter, deactivate, changed := handleFilterKey(key, m.filterInput) + m.filterInput = newFilter + if deactivate { m.filterActive = false - case "backspace": - if len(m.filterInput) > 0 { - m.filterInput = m.filterInput[:len(m.filterInput)-1] - m.applyFilter() - } - default: - if len(key) == 1 { - m.filterInput += key - m.applyFilter() - } + } + if changed { + m.filtered = applyFilter(m.instances, m.filterInput) + m.instIdx = 0 } return m, nil } @@ -63,21 +98,6 @@ func (m Model) updateInstanceList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m *Model) applyFilter() { - if m.filterInput == "" { - m.filtered = m.instances - } else { - query := strings.ToLower(m.filterInput) - var result []awsservice.EC2Instance - for _, inst := range m.instances { - if strings.Contains(inst.FilterText(), query) { - result = append(result, inst) - } - } - m.filtered = result - } - m.instIdx = 0 -} func (m Model) loadInstances() tea.Cmd { return func() tea.Msg { diff --git a/internal/app/screen_iam.go b/internal/app/screen_iam.go index c2ce5b2..ab0cc65 100644 --- a/internal/app/screen_iam.go +++ b/internal/app/screen_iam.go @@ -15,6 +15,63 @@ import ( awsservice "unic/internal/services/aws" ) +func (m Model) handleIAMMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { + switch msg := msg.(type) { + case iamKeysLoadedMsg: + m.iamKeys = msg.keys + m.iamKeyIdx = 0 + m.screen = screenIAMKeyList + return m, nil, true + + case iamKeyCreatedMsg: + if msg.err != nil { + m.errMsg = msg.err.Error() + m.screen = screenError + return m, nil, true + } + m.iamNewKey = msg.newKey + m.iamCopyMsg = "" + m.iamRotationStatus = "" + m.iamNewKeyVerified = false + m.iamOldKeyInactive = false + m.iamOldKeyDeleted = false + m.screen = screenIAMKeyRotateResult + return m, nil, true + + case iamKeyVerifiedMsg: + if msg.err != nil { + m.iamRotationStatus = fmt.Sprintf("Verification failed: %s", msg.err) + return m, nil, true + } + m.iamNewKeyVerified = true + if msg.identity != nil { + m.iamRotationStatus = fmt.Sprintf("Verified new key as %s", msg.identity.Arn) + } else { + m.iamRotationStatus = "Verified new key" + } + return m, nil, true + + case iamKeyDeactivatedMsg: + if msg.err != nil { + m.iamRotationStatus = msg.err.Error() + return m, nil, true + } + m.iamOldKeyInactive = true + m.iamRotationStatus = fmt.Sprintf("Old key %s marked Inactive", msg.keyID) + return m, nil, true + + case iamKeyDeletedMsg: + if msg.err != nil { + m.iamRotationStatus = msg.err.Error() + return m, nil, true + } + m.iamOldKeyDeleted = true + m.iamRotationStatus = fmt.Sprintf("Old key %s deleted", msg.keyID) + return m, nil, true + } + return m, nil, false +} + func (m Model) loadIAMKeys() tea.Cmd { return func() tea.Msg { ctx := context.Background() diff --git a/internal/app/screen_rds.go b/internal/app/screen_rds.go index 5cf381f..62eb4e4 100644 --- a/internal/app/screen_rds.go +++ b/internal/app/screen_rds.go @@ -11,25 +11,66 @@ import ( awsservice "unic/internal/services/aws" ) +func (m Model) handleRDSMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { + switch msg := msg.(type) { + case rdsInstancesLoadedMsg: + m.rdsInstances = msg.instances + m.filteredRDS = msg.instances + m.rdsIdx = 0 + m.screen = screenRDSList + return m, nil, true + + case rdsActionDoneMsg: + if msg.err != nil { + m.errMsg = msg.err.Error() + m.screen = screenError + return m, nil, true + } + m.rdsPolling = true + m.screen = screenRDSDetail + return m, m.tickRDSPoll(msg.instanceID), true + + case rdsStatusRefreshedMsg: + if msg.err != nil { + m.rdsPolling = false + return m, nil, true + } + m.selectedRDS = msg.instance + for i, inst := range m.rdsInstances { + if inst.DBInstanceID == msg.instance.DBInstanceID { + m.rdsInstances[i] = *msg.instance + break + } + } + m.filteredRDS = applyFilter(m.rdsInstances, m.rdsFilter) + m.rdsIdx = 0 + if awsservice.IsTransitionalStatus(msg.instance.Status) { + return m, m.tickRDSPoll(msg.instance.DBInstanceID), true + } + m.rdsPolling = false + return m, nil, true + + case rdsTickMsg: + if m.rdsPolling { + return m, m.pollRDSStatus(msg.instanceID), true + } + return m, nil, true + } + return m, nil, false +} + func (m Model) updateRDSList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() if m.rdsFilterActive { - switch key { - case "esc": - m.rdsFilterActive = false - case "enter": + newFilter, deactivate, changed := handleFilterKey(key, m.rdsFilter) + m.rdsFilter = newFilter + if deactivate { m.rdsFilterActive = false - case "backspace": - if len(m.rdsFilter) > 0 { - m.rdsFilter = m.rdsFilter[:len(m.rdsFilter)-1] - m.applyRDSFilter() - } - default: - if len(key) == 1 { - m.rdsFilter += key - m.applyRDSFilter() - } + } + if changed { + m.filteredRDS = applyFilter(m.rdsInstances, m.rdsFilter) + m.rdsIdx = 0 } return m, nil } @@ -136,21 +177,6 @@ func (m Model) updateRDSConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m *Model) applyRDSFilter() { - if m.rdsFilter == "" { - m.filteredRDS = m.rdsInstances - } else { - query := strings.ToLower(m.rdsFilter) - var result []awsservice.RDSInstance - for _, inst := range m.rdsInstances { - if strings.Contains(inst.FilterText(), query) { - result = append(result, inst) - } - } - m.filteredRDS = result - } - m.rdsIdx = 0 -} func (m Model) loadRDSInstances() tea.Cmd { return func() tea.Msg { diff --git a/internal/app/screen_route53.go b/internal/app/screen_route53.go index 8efd040..32f7c7f 100644 --- a/internal/app/screen_route53.go +++ b/internal/app/screen_route53.go @@ -12,25 +12,73 @@ import ( awsservice "unic/internal/services/aws" ) +func (m Model) handleRoute53Msg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { + switch msg := msg.(type) { + case route53ZonesLoadedMsg: + m.route53Zones = msg.zones + m.filteredRoute53Zones = msg.zones + m.route53ZoneIdx = 0 + m.screen = screenRoute53ZoneList + return m, nil, true + + case route53RecordsLoadedMsg: + m.route53Records = msg.records + m.filteredRoute53Records = msg.records + m.route53RecordIdx = 0 + m.screen = screenRoute53RecordList + return m, nil, true + + case route53ActionDoneMsg: + if msg.err != nil { + m.errMsg = msg.err.Error() + m.screen = screenError + return m, nil, true + } + m.route53ChangeID = msg.changeID + m.route53ChangeStatus = "PENDING" + m.route53Polling = true + if m.selectedRoute53Zone != nil { + return m, tea.Batch( + m.loadRoute53Records(m.selectedRoute53Zone.ID), + m.pollRoute53ChangeStatus(), + ), true + } + m.screen = screenRoute53RecordList + return m, nil, true + + case route53ChangeStatusMsg: + if msg.err != nil { + m.route53Polling = false + return m, nil, true + } + m.route53ChangeStatus = msg.status + if msg.status == "INSYNC" { + m.route53Polling = false + return m, nil, true + } + return m, m.tickRoute53Poll(), true + + case route53PollTickMsg: + if m.route53Polling { + return m, m.pollRoute53ChangeStatus(), true + } + return m, nil, true + } + return m, nil, false +} + func (m Model) updateRoute53ZoneList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() if m.route53ZoneFilterActive { - switch key { - case "esc": + newFilter, deactivate, changed := handleFilterKey(key, m.route53ZoneFilter) + m.route53ZoneFilter = newFilter + if deactivate { m.route53ZoneFilterActive = false - case "enter": - m.route53ZoneFilterActive = false - case "backspace": - if len(m.route53ZoneFilter) > 0 { - m.route53ZoneFilter = m.route53ZoneFilter[:len(m.route53ZoneFilter)-1] - m.applyRoute53ZoneFilter() - } - default: - if len(key) == 1 { - m.route53ZoneFilter += key - m.applyRoute53ZoneFilter() - } + } + if changed { + m.filteredRoute53Zones = applyFilter(m.route53Zones, m.route53ZoneFilter) + m.route53ZoneIdx = 0 } return m, nil } @@ -66,21 +114,14 @@ func (m Model) updateRoute53RecordList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() if m.route53RecordFilterActive { - switch key { - case "esc": + newFilter, deactivate, changed := handleFilterKey(key, m.route53RecordFilter) + m.route53RecordFilter = newFilter + if deactivate { m.route53RecordFilterActive = false - case "enter": - m.route53RecordFilterActive = false - case "backspace": - if len(m.route53RecordFilter) > 0 { - m.route53RecordFilter = m.route53RecordFilter[:len(m.route53RecordFilter)-1] - m.applyRoute53RecordFilter() - } - default: - if len(key) == 1 { - m.route53RecordFilter += key - m.applyRoute53RecordFilter() - } + } + if changed { + m.filteredRoute53Records = applyFilter(m.route53Records, m.route53RecordFilter) + m.route53RecordIdx = 0 } return m, nil } @@ -154,37 +195,6 @@ func (m Model) updateRoute53RecordDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m *Model) applyRoute53ZoneFilter() { - if m.route53ZoneFilter == "" { - m.filteredRoute53Zones = m.route53Zones - } else { - query := strings.ToLower(m.route53ZoneFilter) - var result []awsservice.HostedZone - for _, zone := range m.route53Zones { - if strings.Contains(zone.FilterText(), query) { - result = append(result, zone) - } - } - m.filteredRoute53Zones = result - } - m.route53ZoneIdx = 0 -} - -func (m *Model) applyRoute53RecordFilter() { - if m.route53RecordFilter == "" { - m.filteredRoute53Records = m.route53Records - } else { - query := strings.ToLower(m.route53RecordFilter) - var result []awsservice.DNSRecord - for _, rec := range m.route53Records { - if strings.Contains(rec.FilterText(), query) { - result = append(result, rec) - } - } - m.filteredRoute53Records = result - } - m.route53RecordIdx = 0 -} func (m Model) loadRoute53Zones() tea.Cmd { return func() tea.Msg { diff --git a/internal/app/screen_securitygroup.go b/internal/app/screen_securitygroup.go index f870756..36ea96f 100644 --- a/internal/app/screen_securitygroup.go +++ b/internal/app/screen_securitygroup.go @@ -20,6 +20,53 @@ type sgRefreshedMsg struct { err error } +func (m Model) handleSecurityGroupMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) { + switch msg := msg.(type) { + case securityGroupsLoadedMsg: + m.securityGroups = msg.securityGroups + m.filteredSecurityGroups = msg.securityGroups + m.sgIdx = 0 + m.screen = screenSecurityGroupList + return m, nil, true + + case sgRuleAddedMsg: + if msg.err != nil { + m.errMsg = msg.err.Error() + m.screen = screenError + return m, nil, true + } + return m, m.refreshSecurityGroup(), true + + case sgRuleDeletedMsg: + if msg.err != nil { + m.errMsg = msg.err.Error() + m.screen = screenError + return m, nil, true + } + return m, m.refreshSecurityGroup(), true + + case sgRefreshedMsg: + if msg.err != nil { + m.errMsg = msg.err.Error() + m.screen = screenError + return m, nil, true + } + m.selectedSecurityGroup = msg.sg + for i, sg := range m.securityGroups { + if sg.GroupID == msg.sg.GroupID { + m.securityGroups[i] = *msg.sg + break + } + } + m.filteredSecurityGroups = applyFilter(m.securityGroups, m.sgFilter) + m.sgIdx = 0 + m.sgRuleIdx = 0 + m.screen = screenSecurityGroupDetail + return m, nil, true + } + return m, nil, false +} + // --- Commands --- func (m Model) loadSecurityGroups() tea.Cmd { @@ -119,21 +166,14 @@ func (m Model) updateSecurityGroupList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() if m.sgFilterActive { - switch key { - case "esc": + newFilter, deactivate, changed := handleFilterKey(key, m.sgFilter) + m.sgFilter = newFilter + if deactivate { m.sgFilterActive = false - case "enter": - m.sgFilterActive = false - case "backspace": - if len(m.sgFilter) > 0 { - m.sgFilter = m.sgFilter[:len(m.sgFilter)-1] - m.applySecurityGroupFilter() - } - default: - if len(key) == 1 { - m.sgFilter += key - m.applySecurityGroupFilter() - } + } + if changed { + m.filteredSecurityGroups = applyFilter(m.securityGroups, m.sgFilter) + m.sgIdx = 0 } return m, nil } @@ -171,21 +211,6 @@ func (m Model) updateSecurityGroupList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m *Model) applySecurityGroupFilter() { - if m.sgFilter == "" { - m.filteredSecurityGroups = m.securityGroups - } else { - query := strings.ToLower(m.sgFilter) - var result []awsservice.SecurityGroup - for _, sg := range m.securityGroups { - if strings.Contains(sg.FilterText(), query) { - result = append(result, sg) - } - } - m.filteredSecurityGroups = result - } - m.sgIdx = 0 -} func (m Model) viewSecurityGroupList() string { var b strings.Builder diff --git a/internal/app/screen_vpc.go b/internal/app/screen_vpc.go index 9332570..1d5b6a3 100644 --- a/internal/app/screen_vpc.go +++ b/internal/app/screen_vpc.go @@ -62,19 +62,13 @@ func (m Model) updateSubnetDetail(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() if m.ipFilterActive { - switch key { - case "esc", "enter": + newFilter, deactivate, changed := handleFilterKey(key, m.ipFilter) + m.ipFilter = newFilter + if deactivate { m.ipFilterActive = false - case "backspace": - if len(m.ipFilter) > 0 { - m.ipFilter = m.ipFilter[:len(m.ipFilter)-1] - m.applyIPFilter() - } - default: - if len(key) == 1 { - m.ipFilter += key - m.applyIPFilter() - } + } + if changed { + m.applyIPFilter() } return m, nil } diff --git a/internal/update/update.go b/internal/update/update.go index ce93903..30cb5e0 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -185,7 +185,36 @@ func IsNewer(current, latest string) bool { if c == "" || l == "" { return false } - return l > c + return compareVersions(l, c) > 0 +} + +// compareVersions compares two dot-separated version strings segment by segment. +// Returns positive if a > b, negative if a < b, zero if equal. +func compareVersions(a, b string) int { + aParts := strings.Split(a, ".") + bParts := strings.Split(b, ".") + maxLen := max(len(aParts), len(bParts)) + for i := 0; i < maxLen; i++ { + ai := parseVersionSegment(aParts, i) + bi := parseVersionSegment(bParts, i) + if ai != bi { + return ai - bi + } + } + return 0 +} + +// parseVersionSegment extracts a numeric segment from a version parts slice. +// Returns 0 for out-of-range indices or non-numeric segments. +func parseVersionSegment(parts []string, i int) int { + if i >= len(parts) { + return 0 + } + var n int + if _, err := fmt.Sscanf(parts[i], "%d", &n); err != nil { + return 0 + } + return n } // normalizeVersion strips "v" prefix for comparison. diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 43589fb..9bcc7ba 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -18,6 +18,10 @@ func TestIsNewer(t *testing.T) { {"older version", "0.2.0", "0.1.0", false}, {"with v prefix", "v0.1.0", "v0.2.0", true}, {"mixed prefix", "0.1.0", "v0.2.0", true}, + {"multi-digit minor", "0.9.0", "0.10.0", true}, + {"multi-digit patch", "0.1.9", "0.1.10", true}, + {"major double digit", "2.0.0", "10.0.0", true}, + {"non-numeric segment", "0.1.0", "0.1.0-beta", false}, {"dev version", "dev", "", false}, {"empty latest", "0.1.0", "", false}, {"empty current", "", "0.1.0", false},