From f9cd10b42b87000df625572a4f6edbaa0ca09166 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Tue, 25 Aug 2026 16:31:09 +0200 Subject: [PATCH 01/32] Fix migration credential reference Use the API-required system-pat reference for source-driven migration creation in both CLI and TUI workflows, with request-level regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/cmd/migration/migration.go | 8 ++--- internal/cmd/migration/migration_test.go | 3 +- internal/elmapi/migrations.go | 8 +++-- internal/workflow/service.go | 2 +- internal/workflow/service_test.go | 45 ++++++++++++++++++++++++ 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/internal/cmd/migration/migration.go b/internal/cmd/migration/migration.go index 1212ef0..b880561 100644 --- a/internal/cmd/migration/migration.go +++ b/internal/cmd/migration/migration.go @@ -179,11 +179,9 @@ func newCreateCmd() *cobra.Command { TargetOrganizationLogin: repositories.target.organization, TargetRepositoryName: repositories.target.repository, TargetAPIEndpoint: targetAPI, - // WORKAROUND (API defect): the create endpoint requires a - // non-empty pat_name, but migration credentials are supplied by - // the system rather than this CLI, so there is nothing meaningful - // to send. Stub it with a sentinel until the API stops requiring it. - PATName: "BOGON", + // The API resolves the source and target tokens server-side from + // this required static credential reference. + PATName: elmapi.SystemPATName, TargetVisibility: visibility, } diff --git a/internal/cmd/migration/migration_test.go b/internal/cmd/migration/migration_test.go index 690b26a..8c22c4f 100644 --- a/internal/cmd/migration/migration_test.go +++ b/internal/cmd/migration/migration_test.go @@ -40,8 +40,7 @@ func TestCreate(t *testing.T) { assert.True(t, strings.HasSuffix(gotPath, "/enterprise/live-migrations"), "path suffix: %q", gotPath) // The target endpoint is derived from GH_TARGET_HOST (API-defect workaround). assert.Equal(t, "https://api.example.ghe.com", gotBody.TargetAPIEndpoint) - // pat_name is stubbed with a sentinel (API-defect workaround). - assert.Equal(t, "BOGON", gotBody.PATName) + assert.Equal(t, elmapi.SystemPATName, gotBody.PATName) assert.Equal(t, "acme", gotBody.SourceOrganizationLogin) assert.Equal(t, "web", gotBody.SourceRepositoryName) assert.Equal(t, "acme-cloud", gotBody.TargetOrganizationLogin) diff --git a/internal/elmapi/migrations.go b/internal/elmapi/migrations.go index 51db8f3..7a8a695 100644 --- a/internal/elmapi/migrations.go +++ b/internal/elmapi/migrations.go @@ -32,12 +32,16 @@ const ( StatusFailed = "failed" StatusTerminated = "terminated" StatusAll = "all" + + // SystemPATName is the credential reference required by the live-migration + // create API. The source and target token values remain stored server-side. + SystemPATName = "system-pat" ) // CreateMigrationRequest is the body of a create-migration call. TargetVisibility // is optional and defaults to internal server-side when omitted. TargetAPIEndpoint -// and PATName are required by the API; the migration commands derive/stub them -// (see newCreateCmd) rather than exposing dedicated flags. +// and PATName are required by the API; callers derive the endpoint and use the +// static SystemPATName credential reference rather than exposing dedicated flags. type CreateMigrationRequest struct { SourceOrganizationLogin string `json:"source_organization_login"` SourceRepositoryName string `json:"source_repository_name"` diff --git a/internal/workflow/service.go b/internal/workflow/service.go index 51ad331..8ad50ad 100644 --- a/internal/workflow/service.go +++ b/internal/workflow/service.go @@ -227,7 +227,7 @@ func (s *Service) CreateSourceMigration(ctx context.Context, in SourceCreateInpu TargetOrganizationLogin: in.TargetOwner, TargetRepositoryName: in.TargetRepo, TargetAPIEndpoint: target.URL, - PATName: "BOGON", + PATName: elmapi.SystemPATName, TargetVisibility: visibility, } if err := ensureUniqueSourceMigration(ctx, sourceClient, req); err != nil { diff --git a/internal/workflow/service_test.go b/internal/workflow/service_test.go index 2bd0c6e..530a03f 100644 --- a/internal/workflow/service_test.go +++ b/internal/workflow/service_test.go @@ -282,3 +282,48 @@ func assertWorkflowCustomerTransition(t *testing.T, body map[string]any) string require.NoError(t, uuid.Validate(operationID)) return operationID } + +func TestCreateSourceMigrationUsesSystemPATReference(t *testing.T) { + t.Setenv("GH_ELM_CONFIG_DIR", t.TempDir()) + t.Setenv("GH_ELM_CREDENTIAL_STORE", "file") + + var request elmapi.CreateMigrationRequest + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v3/enterprise/live-migrations", r.URL.Path) + if r.Method == http.MethodGet { + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "migrations": []any{}, + "total_count": 0, + })) + return + } + require.Equal(t, http.MethodPost, r.Method) + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"migration_id":"migration-1","expires_at":null}`)) + })) + t.Cleanup(source.Close) + target := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(target.Close) + + service := New() + require.NoError(t, service.SaveConfiguration(t.Context(), ConfigurationInput{ + SourceURL: source.URL, + SourceToken: "source-token", + TargetURL: target.URL, + TargetToken: "target-token", + })) + + result, err := service.CreateSourceMigration(t.Context(), SourceCreateInput{ + SourceOwner: "octo-source", + SourceRepo: "repository", + TargetOwner: "octo-target", + TargetRepo: "repository", + Visibility: "internal", + }) + + require.NoError(t, err) + assert.Equal(t, "migration-1", result.Migration.MigrationID) + assert.Equal(t, elmapi.SystemPATName, request.PATName) + assert.Equal(t, target.URL, request.TargetAPIEndpoint) +} From 34fd2cf20cd5d56623c1e709462f5dd06d49e438 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Wed, 26 Aug 2026 14:20:04 +0200 Subject: [PATCH 02/32] Preserve source repository metadata Repository records now retain API metadata through the workflow catalog for TUI use. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/elmapi/catalog.go | 12 ++++++++++-- internal/elmapi/catalog_test.go | 16 ++++++++++++++-- internal/workflow/service.go | 12 ++++++------ internal/workflow/service_test.go | 8 ++++++-- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/internal/elmapi/catalog.go b/internal/elmapi/catalog.go index dc4f9a8..98f81a9 100644 --- a/internal/elmapi/catalog.go +++ b/internal/elmapi/catalog.go @@ -13,8 +13,16 @@ const catalogPageSize = 100 // Repository is a repository visible to the authenticated user. type Repository struct { - FullName string `json:"full_name"` - Owner struct { + FullName string `json:"full_name"` + Description string `json:"description"` + Language string `json:"language"` + Visibility string `json:"visibility"` + Private bool `json:"private"` + Archived bool `json:"archived"` + Fork bool `json:"fork"` + Stargazers int `json:"stargazers_count"` + OpenIssueCount int `json:"open_issues_count"` + Owner struct { Type string `json:"type"` } `json:"owner"` } diff --git a/internal/elmapi/catalog_test.go b/internal/elmapi/catalog_test.go index e362a66..1174688 100644 --- a/internal/elmapi/catalog_test.go +++ b/internal/elmapi/catalog_test.go @@ -15,7 +15,7 @@ func TestListRepositories(t *testing.T) { assert.Equal(t, "100", r.URL.Query().Get("per_page")) assert.Equal(t, "owner,collaborator,organization_member", r.URL.Query().Get("affiliation")) _, _ = w.Write([]byte(`[ - {"full_name":"zeta/repo","owner":{"type":"Organization"}}, + {"full_name":"zeta/repo","description":"API service","language":"Go","visibility":"private","private":true,"archived":true,"fork":false,"stargazers_count":12,"open_issues_count":4,"owner":{"type":"Organization"}}, {"full_name":"Acme/api","owner":{"type":"Organization"}} ]`)) })) @@ -26,7 +26,19 @@ func TestListRepositories(t *testing.T) { require.NoError(t, err) assert.Equal(t, []Repository{ repository("Acme/api", "Organization"), - repository("zeta/repo", "Organization"), + { + FullName: "zeta/repo", + Description: "API service", + Language: "Go", + Visibility: "private", + Private: true, + Archived: true, + Stargazers: 12, + OpenIssueCount: 4, + Owner: struct { + Type string `json:"type"` + }{Type: "Organization"}, + }, }, repositories) } diff --git a/internal/workflow/service.go b/internal/workflow/service.go index 8ad50ad..58130bf 100644 --- a/internal/workflow/service.go +++ b/internal/workflow/service.go @@ -152,8 +152,8 @@ func (s *Service) GetSourceMigration(ctx context.Context, id SourceMigrationID) return client.GetMigrationDetail(ctx, string(id)) } -// ListSourceRepositories lists repositories visible through the source credentials. -func (s *Service) ListSourceRepositories(ctx context.Context) ([]string, error) { +// ListSourceRepositories lists organization-owned repositories visible through the source credentials. +func (s *Service) ListSourceRepositories(ctx context.Context) ([]elmapi.Repository, error) { client, err := s.sourceClient() if err != nil { return nil, err @@ -162,16 +162,16 @@ func (s *Service) ListSourceRepositories(ctx context.Context) ([]string, error) if err != nil { return nil, err } - names := make([]string, 0, len(repositories)) + organizationRepositories := make([]elmapi.Repository, 0, len(repositories)) for _, repository := range repositories { if !strings.EqualFold(repository.Owner.Type, "Organization") { continue } - if name := strings.TrimSpace(repository.FullName); name != "" { - names = append(names, name) + if repository.FullName = strings.TrimSpace(repository.FullName); repository.FullName != "" { + organizationRepositories = append(organizationRepositories, repository) } } - return names, nil + return organizationRepositories, nil } // ListTargetOrganizations lists organizations visible through the target credentials. diff --git a/internal/workflow/service_test.go b/internal/workflow/service_test.go index 530a03f..a270033 100644 --- a/internal/workflow/service_test.go +++ b/internal/workflow/service_test.go @@ -206,7 +206,7 @@ func TestRepositoryCatalog(t *testing.T) { source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/api/v3/user/repos", r.URL.Path) _, _ = w.Write([]byte(`[ - {"full_name":"octo/source","owner":{"type":"Organization"}}, + {"full_name":"octo/source","description":"Source repository","language":"Go","stargazers_count":7,"owner":{"type":"Organization"}}, {"full_name":"personal/source","owner":{"type":"User"}} ]`)) })) @@ -229,7 +229,11 @@ func TestRepositoryCatalog(t *testing.T) { repositories, err := service.ListSourceRepositories(t.Context()) require.NoError(t, err) - assert.Equal(t, []string{"octo/source"}, repositories) + require.Len(t, repositories, 1) + assert.Equal(t, "octo/source", repositories[0].FullName) + assert.Equal(t, "Source repository", repositories[0].Description) + assert.Equal(t, "Go", repositories[0].Language) + assert.Equal(t, 7, repositories[0].Stargazers) organizations, err := service.ListTargetOrganizations(t.Context()) require.NoError(t, err) From a3012af5a30c268c0fe79c046564f440594e9852 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Wed, 26 Aug 2026 14:20:04 +0200 Subject: [PATCH 03/32] Align TUI visuals and action focus Improve responsive repository details, richer card and button decoration, functional shortcuts, and independent always-valid action focus. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/theme/theme.go | 2 +- internal/theme/theme_test.go | 2 +- internal/tui/components.go | 36 ++++++- internal/tui/model.go | 190 +++++++++++++++++++++++------------ internal/tui/model_test.go | 158 +++++++++++++++++++++++++---- internal/tui/view.go | 142 ++++++++++++++++++++++---- 6 files changed, 423 insertions(+), 107 deletions(-) diff --git a/internal/theme/theme.go b/internal/theme/theme.go index 746af15..263495c 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -91,7 +91,7 @@ func New() Styles { Muted: lipgloss.NewStyle().Foreground(colorMuted), Placeholder: lipgloss.NewStyle().Foreground(colorPlaceholder), Success: lipgloss.NewStyle().Foreground(colorGreen), - Active: lipgloss.NewStyle().Foreground(colorBlue), + Active: lipgloss.NewStyle().Foreground(colorGreen), Warning: lipgloss.NewStyle().Foreground(warningColor), Paused: lipgloss.NewStyle().Foreground(warningColor), Failure: lipgloss.NewStyle().Foreground(githubRed), diff --git a/internal/theme/theme_test.go b/internal/theme/theme_test.go index a336df3..5b45ebb 100644 --- a/internal/theme/theme_test.go +++ b/internal/theme/theme_test.go @@ -14,7 +14,7 @@ func TestNew(t *testing.T) { t.Run("semantic colours come from the theme palette", func(t *testing.T) { assert.Equal(t, lipgloss.NoColor{}, s.Primary.GetForeground()) assert.Equal(t, colorGreen, s.Success.GetForeground()) - assert.Equal(t, colorBlue, s.Active.GetForeground()) + assert.Equal(t, colorGreen, s.Active.GetForeground()) assert.Equal(t, warningColor, s.Warning.GetForeground()) assert.Equal(t, warningColor, s.Paused.GetForeground()) assert.Equal(t, githubRed, s.Failure.GetForeground()) diff --git a/internal/tui/components.go b/internal/tui/components.go index 58553c7..b01fe50 100644 --- a/internal/tui/components.go +++ b/internal/tui/components.go @@ -24,20 +24,54 @@ func (m *Model) selectorCard(content string, selected, compact bool) string { return style.Render(content) } +func (m *Model) repositoryChip(value string) string { + return lipgloss.NewStyle(). + Background(lipgloss.AdaptiveColor{Light: "#f6f8fa", Dark: "#262c33"}). + Bold(true). + Padding(0, 1). + Render(value) +} + +func (m *Model) metadataBadge(value string) string { + return lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#57606a", Dark: "#b1bac4"}). + Background(lipgloss.AdaptiveColor{Light: "#eaeef2", Dark: "#21262d"}). + Padding(0, 1). + Render(value) +} + func (m *Model) actionButtons(items []actionItem, focus, width int) string { if len(items) == 0 { return "" } + if focus < 0 || focus >= len(items) { + focus = 0 + } width = max(1, width) rows := make([]string, 0, len(items)) row := "" for index, item := range items { style := m.styles.BlurredButton + shortcutForeground := lipgloss.TerminalColor( + lipgloss.AdaptiveColor{Light: "#8c959f", Dark: "#6e7681"}, + ) if index == focus { style = m.styles.FocusedButton + shortcutForeground = lipgloss.Color("#bcd0f5") + } + inner := style.Render(item.label) + if item.shortcut != "" { + inner += lipgloss.NewStyle(). + Foreground(shortcutForeground). + Background(style.GetBackground()). + Bold(index == focus). + Render(" " + item.shortcut) } - button := style.Padding(0, 2).Render(item.label) + button := lipgloss.NewStyle(). + Background(style.GetBackground()). + Padding(0, 2). + Render(inner) candidate := button if row != "" { candidate = row + buttonGap + button diff --git a/internal/tui/model.go b/internal/tui/model.go index 10d4603..9375824 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -50,10 +50,15 @@ type targetService interface { } type catalogService interface { - ListSourceRepositories(context.Context) ([]string, error) + ListSourceRepositories(context.Context) ([]elmapi.Repository, error) ListTargetOrganizations(context.Context) ([]string, error) } +type pickerItem struct { + value string + repository *elmapi.Repository +} + type mannequinService interface { ListMannequins(context.Context, string, bool) ([]ghapi.MannequinRecord, error) ExportMannequins(context.Context, string, string, bool) error @@ -131,7 +136,7 @@ type pickerState struct { kind pickerKind title string parent screen - items []string + items []pickerItem cursor int input textinput.Model loading bool @@ -165,6 +170,7 @@ type Model struct { width int height int cursor int + actionFocus int loading bool err error viewport viewport.Model @@ -200,6 +206,7 @@ type Model struct { targetAuthErr error picker pickerState pickerGeneration uint64 + pickerInfoOpen bool form formState confirm confirmState result resultState @@ -277,7 +284,7 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if msg.detail.Migration != nil && msg.detail.Migration.TargetMigrationID > 0 { m.targetID = workflow.TargetMigrationID(msg.detail.Migration.TargetMigrationID) } - m.clampCursor() + m.clampActionFocus() } if m.sourceWatching { return m, tea.Tick(2*time.Second, func(time.Time) tea.Msg { return watchTickMsg{} }) @@ -301,7 +308,7 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if len(msg.migration.Repositories) > 0 { m.repository = msg.migration.Repositories[0] } - m.clampCursor() + m.clampActionFocus() } case configMsg: if msg.generation != m.configGeneration { @@ -415,6 +422,8 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } switch { + case m.actionScreen() && m.activateActionShortcut(msg.String()): + return m.activate() case key.Matches(msg, keys.Help): m.showHelp = !m.showHelp return m, nil @@ -428,12 +437,12 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.back() } case key.Matches(msg, keys.Left): - if m.actionScreen() && m.cursor > 0 { - m.cursor-- + if m.actionScreen() && m.actionFocus > 0 { + m.actionFocus-- } case key.Matches(msg, keys.Right): - if m.actionScreen() && m.cursor < m.itemCount()-1 { - m.cursor++ + if m.actionScreen() && m.actionFocus < m.itemCount()-1 { + m.actionFocus++ } case key.Matches(msg, keys.Up): if !m.actionScreen() && m.cursor > 0 { @@ -519,6 +528,13 @@ func (m *Model) updateSourceSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m *Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.pickerInfoOpen { + switch msg.String() { + case "esc", "enter", "?", "q": + m.pickerInfoOpen = false + } + return m, nil + } switch msg.String() { case "ctrl+e": source := "" @@ -528,6 +544,14 @@ func (m *Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.openManualSourceCreateForm(m.picker.parent, source) case "ctrl+r": return m.reloadPicker() + case "?": + items := m.visiblePickerItems() + if m.picker.kind == pickerSourceRepository && + m.picker.cursor >= 0 && m.picker.cursor < len(items) && + items[m.picker.cursor].repository != nil { + m.pickerInfoOpen = true + } + return m, nil case "esc": if m.picker.input.Value() != "" { m.picker.input.SetValue("") @@ -548,7 +572,7 @@ func (m *Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(items) == 0 { return m, nil } - selected := items[m.picker.cursor] + selected := items[m.picker.cursor].value if m.picker.kind == pickerSourceRepository { return m.openTargetOrganizationPicker(m.picker.parent, selected) } @@ -575,14 +599,14 @@ func (m *Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, command } -func (m *Model) visiblePickerItems() []string { +func (m *Model) visiblePickerItems() []pickerItem { query := strings.ToLower(strings.TrimSpace(m.picker.input.Value())) if query == "" { return m.picker.items } - items := make([]string, 0, len(m.picker.items)) + items := make([]pickerItem, 0, len(m.picker.items)) for _, item := range m.picker.items { - if strings.Contains(strings.ToLower(item), query) { + if strings.Contains(strings.ToLower(item.value), query) { items = append(items, item) } } @@ -611,9 +635,10 @@ func (m *Model) activate() (tea.Model, tea.Cmd) { case 1: return m.openSourceCreateForm(screenHome) case 2: - m.screen, m.cursor = screenMannequins, 0 + m.screen, m.actionFocus = screenMannequins, 0 case 3: m.screen, m.loading, m.err = screenConfiguration, true, nil + m.actionFocus = 0 m.resetViewport() command := m.startConfigurationLoad() return m, command @@ -632,7 +657,7 @@ func (m *Model) activate() (tea.Model, tea.Cmd) { m.sourceID = workflow.SourceMigrationID(migrations[m.cursor].MigrationID) m.targetID = 0 m.screen, m.loading, m.err = screenSourceDetail, true, nil - m.cursor = 0 + m.actionFocus = 0 m.resetViewport() command := m.loadSourceDetailCmd() return m, command @@ -650,7 +675,7 @@ func (m *Model) activate() (tea.Model, tea.Cmd) { m.targetID = id m.targetParent = screenTargetList m.screen, m.loading, m.err = screenTargetDetail, true, nil - m.cursor = 0 + m.actionFocus = 0 m.resetViewport() command := m.loadTargetDetailCmd() return m, command @@ -667,6 +692,7 @@ func (m *Model) activate() (tea.Model, tea.Cmd) { func (m *Model) back() (tea.Model, tea.Cmd) { m.err = nil m.cursor = 0 + m.actionFocus = 0 switch m.screen { case screenSourceList: m.sourceSearch = false @@ -742,21 +768,22 @@ func (m *Model) actionScreen() bool { } } -func (m *Model) clampCursor() { - m.cursor = min(m.cursor, max(0, m.itemCount()-1)) +func (m *Model) clampActionFocus() { + m.actionFocus = min(max(0, m.actionFocus), max(0, m.itemCount()-1)) } type actionItem struct { - id string - label string + id string + label string + shortcut string } func (m *Model) activateSourceAction() (tea.Model, tea.Cmd) { actions := m.sourceActionItems() - if m.cursor < 0 || m.cursor >= len(actions) { + if m.actionFocus < 0 || m.actionFocus >= len(actions) { return m, nil } - switch actions[m.cursor].id { + switch actions[m.actionFocus].id { case "refresh": return m.refresh() case "watch": @@ -802,7 +829,7 @@ func (m *Model) activateSourceAction() (tea.Model, tea.Cmd) { } m.screen, m.loading, m.err = screenTargetDetail, true, nil m.targetParent = screenSourceDetail - m.cursor = 0 + m.actionFocus = 0 m.resetViewport() command := m.loadTargetDetailCmd() return m, command @@ -812,8 +839,8 @@ func (m *Model) activateSourceAction() (tea.Model, tea.Cmd) { func (m *Model) sourceActionItems() []actionItem { actions := []actionItem{ - {id: "refresh", label: "Refresh status"}, - {id: "watch", label: watchLabel(m.sourceWatching)}, + {id: "refresh", label: "Refresh status", shortcut: "r"}, + {id: "watch", label: watchLabel(m.sourceWatching), shortcut: "w"}, } status := "" @@ -827,40 +854,40 @@ func (m *Model) sourceActionItems() []actionItem { switch status { case "created": actions = append(actions, - actionItem{id: "start", label: "Start migration"}, - actionItem{id: "cancel", label: "Cancel migration"}, + actionItem{id: "start", label: "Start migration", shortcut: "s"}, + actionItem{id: "cancel", label: "Cancel migration", shortcut: "x"}, ) case "queued", "in progress": - actions = append(actions, actionItem{id: "pause", label: "Pause migration"}) + actions = append(actions, actionItem{id: "pause", label: "Pause migration", shortcut: "p"}) if readyForCutover { - actions = append(actions, actionItem{id: "cutover", label: "Initiate cutover"}) + actions = append(actions, actionItem{id: "cutover", label: "Initiate cutover", shortcut: "c"}) } else { - actions = append(actions, actionItem{id: "force-cutover", label: "Force cutover"}) + actions = append(actions, actionItem{id: "force-cutover", label: "Force cutover", shortcut: "c"}) } - actions = append(actions, actionItem{id: "cancel", label: "Cancel migration"}) + actions = append(actions, actionItem{id: "cancel", label: "Cancel migration", shortcut: "x"}) case "paused": actions = append(actions, - actionItem{id: "resume", label: "Resume migration"}, - actionItem{id: "cancel", label: "Cancel migration"}, + actionItem{id: "resume", label: "Resume migration", shortcut: "u"}, + actionItem{id: "cancel", label: "Cancel migration", shortcut: "x"}, ) case "completed": - actions = append(actions, actionItem{id: "revert", label: "Revert cutover"}) + actions = append(actions, actionItem{id: "revert", label: "Revert cutover", shortcut: "v"}) } if m.sourceDetail != nil && m.sourceDetail.CombinedState != nil { - actions = append(actions, actionItem{id: "cutover-status", label: "Show cutover status"}) + actions = append(actions, actionItem{id: "cutover-status", label: "Show cutover status", shortcut: "i"}) } if m.targetID > 0 { - actions = append(actions, actionItem{id: "destination", label: "Open destination details"}) + actions = append(actions, actionItem{id: "destination", label: "Open destination details", shortcut: "d"}) } return actions } func (m *Model) activateTargetAction() (tea.Model, tea.Cmd) { actions := m.targetActionItems() - if m.cursor < 0 || m.cursor >= len(actions) { + if m.actionFocus < 0 || m.actionFocus >= len(actions) { return m, nil } - switch actions[m.cursor].id { + switch actions[m.actionFocus].id { case "refresh": return m.refresh() case "resources": @@ -886,11 +913,11 @@ func (m *Model) activateTargetAction() (tea.Model, tea.Cmd) { func (m *Model) targetActionItems() []actionItem { actions := []actionItem{ - {id: "refresh", label: "Refresh status"}, - {id: "resources", label: "List repository resources"}, - {id: "report-request", label: "Request node report"}, - {id: "report-status", label: "Check report status"}, - {id: "report-url", label: "Get report download URL"}, + {id: "refresh", label: "Refresh status", shortcut: "r"}, + {id: "resources", label: "List repository resources", shortcut: "o"}, + {id: "report-request", label: "Request node report", shortcut: "n"}, + {id: "report-status", label: "Check report status", shortcut: "s"}, + {id: "report-url", label: "Get report download URL", shortcut: "u"}, } status := "" if m.targetDetail != nil { @@ -899,13 +926,13 @@ func (m *Model) targetActionItems() []actionItem { switch status { case "in progress": actions = append(actions, - actionItem{id: "pause", label: "Pause destination migration"}, - actionItem{id: "abort", label: "Abort destination migration"}, + actionItem{id: "pause", label: "Pause destination migration", shortcut: "p"}, + actionItem{id: "abort", label: "Abort destination migration", shortcut: "x"}, ) case "paused": actions = append(actions, - actionItem{id: "resume", label: "Resume destination migration"}, - actionItem{id: "abort", label: "Abort destination migration"}, + actionItem{id: "resume", label: "Resume destination migration", shortcut: "m"}, + actionItem{id: "abort", label: "Abort destination migration", shortcut: "x"}, ) } return actions @@ -924,15 +951,15 @@ func normalizedStatus(status string) string { return strings.ToLower(strings.TrimSpace(status)) } -var mannequinActions = []string{ - "List mannequins", - "Export mannequins to CSV", - "Reclaim a mannequin", - "Reclaim mannequins from CSV", +var mannequinActions = []actionItem{ + {id: "list", label: "List mannequins", shortcut: "a"}, + {id: "export", label: "Export mannequins to CSV", shortcut: "e"}, + {id: "reclaim", label: "Reclaim a mannequin", shortcut: "r"}, + {id: "reclaim-csv", label: "Reclaim mannequins from CSV", shortcut: "c"}, } func (m *Model) activateMannequinAction() (tea.Model, tea.Cmd) { - switch m.cursor { + switch m.actionFocus { case 0: return m.openMannequinListForm(false) case 1: @@ -945,14 +972,14 @@ func (m *Model) activateMannequinAction() (tea.Model, tea.Cmd) { return m, nil } -var configurationActions = []string{ - "Refresh configuration", - "Edit configuration", - "Reset configuration", +var configurationActions = []actionItem{ + {id: "refresh", label: "Refresh configuration", shortcut: "r"}, + {id: "edit", label: "Edit configuration", shortcut: "e"}, + {id: "reset", label: "Reset configuration", shortcut: "x"}, } func (m *Model) activateConfigurationAction() (tea.Model, tea.Cmd) { - switch m.cursor { + switch m.actionFocus { case 0: return m.refresh() case 1: @@ -967,6 +994,30 @@ func (m *Model) activateConfigurationAction() (tea.Model, tea.Cmd) { return m, nil } +func (m *Model) activateActionShortcut(value string) bool { + if len(value) != 1 { + return false + } + var actions []actionItem + switch m.screen { + case screenSourceDetail: + actions = m.sourceActionItems() + case screenTargetDetail: + actions = m.targetActionItems() + case screenMannequins: + actions = mannequinActions + case screenConfiguration: + actions = configurationActions + } + for index, action := range actions { + if strings.EqualFold(value, action.shortcut) { + m.actionFocus = index + return true + } + } + return false +} + func (m *Model) confirmAction(title, body string, parent screen, command tea.Cmd) (tea.Model, tea.Cmd) { m.confirm = confirmState{title: title, body: body, parent: parent, command: command, focus: 0} m.screen = screenConfirm @@ -1004,7 +1055,7 @@ func (m *Model) updateResult(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.Open), key.Matches(msg, keys.Back), key.Matches(msg, keys.Quit): parent := m.result.parent refresh := m.result.refresh - m.screen, m.cursor, m.err = parent, 0, nil + m.screen, m.cursor, m.actionFocus, m.err = parent, 0, 0, nil if refresh { return m.refresh() } @@ -1196,6 +1247,7 @@ func (m *Model) openSourceCreateForm(parent screen) (tea.Model, tea.Cmd) { input.Focus() m.pickerGeneration++ + m.pickerInfoOpen = false m.picker = pickerState{ kind: pickerSourceRepository, title: "Select source repository", @@ -1217,6 +1269,7 @@ func (m *Model) openTargetOrganizationPicker(parent screen, source string) (tea. input.Focus() m.pickerGeneration++ + m.pickerInfoOpen = false m.picker = pickerState{ kind: pickerTargetOrganization, title: "Select destination organization", @@ -1693,7 +1746,7 @@ type targetAuthenticationMsg struct { type pickerCatalogMsg struct { generation uint64 - items []string + items []pickerItem err error } @@ -1787,13 +1840,26 @@ func (m *Model) checkTargetAuthenticationCmd(generation uint64) tea.Cmd { func (m *Model) loadPickerCatalogCmd(kind pickerKind, generation uint64) tea.Cmd { return func() tea.Msg { - var items []string + var items []pickerItem var err error switch kind { case pickerSourceRepository: - items, err = m.service.ListSourceRepositories(m.ctx) + var repositories []elmapi.Repository + repositories, err = m.service.ListSourceRepositories(m.ctx) + items = make([]pickerItem, 0, len(repositories)) + for index := range repositories { + items = append(items, pickerItem{ + value: repositories[index].FullName, + repository: &repositories[index], + }) + } case pickerTargetOrganization: - items, err = m.service.ListTargetOrganizations(m.ctx) + var organizations []string + organizations, err = m.service.ListTargetOrganizations(m.ctx) + items = make([]pickerItem, 0, len(organizations)) + for _, organization := range organizations { + items = append(items, pickerItem{value: organization}) + } } return pickerCatalogMsg{generation: generation, items: items, err: err} } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index cdf27dd..18c8b8b 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -3,6 +3,7 @@ package tui import ( "context" "encoding/json" + "fmt" "strings" "testing" @@ -288,7 +289,7 @@ func TestModel(t *testing.T) { assert.Equal(t, workflow.TargetMigrationID(42), model.targetID) assert.Contains(t, model.View(), "Open destination details") - model.cursor = len(model.sourceActionItems()) - 1 + model.actionFocus = len(model.sourceActionItems()) - 1 updated, cmd = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) require.Equal(t, screenTargetDetail, model.screen) @@ -343,7 +344,7 @@ func TestModel(t *testing.T) { model.targetID = 42 model.width = 80 model.height = 24 - model.cursor = len(model.sourceActionItems()) - 1 + model.actionFocus = len(model.sourceActionItems()) - 1 assert.Contains(t, model.View(), "Open destination details") }) @@ -373,18 +374,69 @@ func TestModel(t *testing.T) { updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRight}) model = updated.(*Model) - assert.Equal(t, 1, model.cursor) + assert.Equal(t, 1, model.actionFocus) updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) model = updated.(*Model) - assert.Equal(t, 1, model.cursor) + assert.Equal(t, 1, model.actionFocus) updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) model = updated.(*Model) - assert.Zero(t, model.cursor) + assert.Zero(t, model.actionFocus) assert.Contains(t, model.View(), "←/→ select action") }) + t.Run("action screens always select their first action", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.cursor = 3 + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(*Model) + updated, _ = model.Update(configMsg{configuration: &workflow.Configuration{}}) + model = updated.(*Model) + + assert.Equal(t, screenConfiguration, model.screen) + assert.Zero(t, model.actionFocus) + assert.Contains( + t, + model.actionButtons(configurationActions, model.actionFocus, model.contentWidth()), + model.styles.FocusedButton.Padding(0, 2).Render("Refresh configuration r"), + ) + }) + + t.Run("action shortcuts focus and activate the matching button", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.screen = screenSourceDetail + model.sourceID = "source-1" + setSourceStatus(model, elmapi.StatusCreated) + + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + model = updated.(*Model) + + assert.Nil(t, command) + assert.Equal(t, screenConfirm, model.screen) + assert.Contains(t, model.View(), "Cancel migration") + }) + + t.Run("open alias activates the focused action", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.screen = screenTargetDetail + model.targetDetail = &elmapi.TargetMigration{Status: elmapi.TargetMigrationStatusInProgress} + for index, action := range model.targetActionItems() { + if action.id == "pause" { + model.actionFocus = index + break + } + } + + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + model = updated.(*Model) + + assert.Nil(t, command) + assert.Equal(t, screenConfirm, model.screen) + assert.Contains(t, model.View(), "Pause target migration") + }) + t.Run("action buttons wrap without changing focus order", func(t *testing.T) { model := New(t.Context(), &fakeService{}) actions := []actionItem{ @@ -452,7 +504,7 @@ func TestModel(t *testing.T) { t.Run("state changes clamp detail action focus", func(t *testing.T) { model := New(t.Context(), &fakeService{}) model.screen = screenSourceDetail - model.cursor = 10 + model.actionFocus = 10 status := elmapi.StatusCompleted updated, _ := model.Update(sourceDetailMsg{ @@ -462,7 +514,7 @@ func TestModel(t *testing.T) { }) model = updated.(*Model) - assert.Equal(t, len(model.sourceActionItems())-1, model.cursor) + assert.Equal(t, len(model.sourceActionItems())-1, model.actionFocus) }) t.Run("home exposes migration creation", func(t *testing.T) { @@ -510,6 +562,60 @@ func TestModel(t *testing.T) { "visibility": "private", "start": "true", }) + + t.Run("repository picker presents real metadata", func(t *testing.T) { + repository := elmapi.Repository{ + FullName: "acme/api", + Description: "Public API for Acme products.", + Language: "Go", + Visibility: "private", + Stargazers: 42, + OpenIssueCount: 7, + } + repository.Owner.Type = "Organization" + model := New(t.Context(), &fakeService{sourceRepositoryDetails: []elmapi.Repository{repository}}) + model.width = 120 + model.height = 40 + + updated, command := model.openSourceCreateForm(screenHome) + model = updated.(*Model) + require.NotNil(t, command) + updated, _ = model.Update(command()) + model = updated.(*Model) + + view := model.pickerView() + assert.Contains(t, view, "★ 42") + assert.Contains(t, view, "≡ 7") + assert.Contains(t, view, "◆ Go") + assert.Contains(t, view, "Public API for Acme products.") + + model.width = 80 + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) + model = updated.(*Model) + assert.True(t, model.pickerInfoOpen) + assert.Contains(t, model.View(), "Open issues") + }) + + t.Run("repository details remain visible after the picker scrolls", func(t *testing.T) { + repositories := make([]elmapi.Repository, 20) + for index := range repositories { + repositories[index].FullName = fmt.Sprintf("acme/repo-%02d", index) + repositories[index].Description = fmt.Sprintf("Repository %02d", index) + } + model := New(t.Context(), &fakeService{sourceRepositoryDetails: repositories}) + model.width = 120 + model.height = 16 + updated, command := model.openSourceCreateForm(screenHome) + model = updated.(*Model) + updated, _ = model.Update(command()) + model = updated.(*Model) + model.picker.cursor = len(repositories) - 1 + + view := model.pickerView() + + assert.Contains(t, view, "↑") + assert.Contains(t, view, "Repository 19") + }) require.NoError(t, err) require.NotNil(t, command) _ = command() @@ -537,7 +643,7 @@ func TestModel(t *testing.T) { model := New(t.Context(), &fakeService{}) model.screen = screenPicker model.picker = pickerState{ - items: []string{"acme/api", "octo/web"}, + items: []pickerItem{{value: "acme/api"}, {value: "octo/web"}}, input: textinput.New(), } model.picker.input.Focus() @@ -547,7 +653,7 @@ func TestModel(t *testing.T) { model = updated.(*Model) } - assert.Equal(t, []string{"octo/web"}, model.visiblePickerItems()) + assert.Equal(t, []pickerItem{{value: "octo/web"}}, model.visiblePickerItems()) }) t.Run("repository picker offers manual fallback", func(t *testing.T) { @@ -591,7 +697,7 @@ func TestModel(t *testing.T) { updated, _ := model.Update(pickerCatalogMsg{ generation: 1, - items: []string{"stale/repo"}, + items: []pickerItem{{value: "stale/repo"}}, }) model = updated.(*Model) @@ -656,7 +762,7 @@ func TestModel(t *testing.T) { } for index, action := range model.sourceActionItems() { if action.id == "cancel" { - model.cursor = index + model.actionFocus = index break } } @@ -848,23 +954,31 @@ func actionIDs(actions []actionItem) []string { } type fakeService struct { - sourceMigrations []elmapi.MigrationSummary - sourceRepositories []string - sourceDetail *elmapi.MigrationDetail - sourceCreateInput workflow.SourceCreateInput - targetOrganizations []string - listTargetMigrations func(context.Context) ([]elmapi.TargetMigration, error) - reclaimCalls int - sourceAuthErr error - targetAuthErr error + sourceMigrations []elmapi.MigrationSummary + sourceRepositories []string + sourceRepositoryDetails []elmapi.Repository + sourceDetail *elmapi.MigrationDetail + sourceCreateInput workflow.SourceCreateInput + targetOrganizations []string + listTargetMigrations func(context.Context) ([]elmapi.TargetMigration, error) + reclaimCalls int + sourceAuthErr error + targetAuthErr error } func (f *fakeService) ListSourceMigrations(context.Context, string) ([]elmapi.MigrationSummary, error) { return f.sourceMigrations, nil } -func (f *fakeService) ListSourceRepositories(context.Context) ([]string, error) { - return f.sourceRepositories, nil +func (f *fakeService) ListSourceRepositories(context.Context) ([]elmapi.Repository, error) { + if f.sourceRepositoryDetails != nil { + return f.sourceRepositoryDetails, nil + } + repositories := make([]elmapi.Repository, len(f.sourceRepositories)) + for index, name := range f.sourceRepositories { + repositories[index].FullName = name + } + return repositories, nil } func (f *fakeService) GetSourceMigration(context.Context, workflow.SourceMigrationID) (*elmapi.MigrationDetail, error) { diff --git a/internal/tui/view.go b/internal/tui/view.go index 066435d..52b731b 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -65,7 +65,7 @@ func (m *Model) View() string { help = helpLine(keys.Left, keys.Open, keys.PageUp, keys.PageDown, keys.Refresh, keys.Back) case screenMannequins: title = "Target mannequins" - body = m.actionButtons(actionsFromLabels(mannequinActions), m.cursor, m.contentWidth()) + body = m.actionButtons(mannequinActions, m.actionFocus, m.contentWidth()) help = helpLine(keys.Left, keys.Open, keys.Back) case screenConfiguration: title = "Configuration" @@ -75,6 +75,9 @@ func (m *Model) View() string { title = m.picker.title body = m.pickerView() help = "type to search • ↑/↓ select • enter continue • ctrl+e manual entry • esc cancel" + if m.picker.kind == pickerSourceRepository { + help = "type to search • ↑/↓ select • enter continue • ? repository details • ctrl+e manual entry • esc cancel" + } case screenForm: title = m.form.title body = m.formView() @@ -113,6 +116,15 @@ func (m *Model) View() string { m.displayWidth(), displayHeight(m.height), ) + } else if m.pickerInfoOpen { + if overlay := m.pickerInfoOverlay(); overlay != "" { + rendered = overlayCenter( + rendered, + overlay, + m.displayWidth(), + displayHeight(m.height), + ) + } } return rendered } @@ -219,14 +231,6 @@ func (m *Model) menu(items []string) string { return builder.String() } -func actionsFromLabels(labels []string) []actionItem { - actions := make([]actionItem, len(labels)) - for index, label := range labels { - actions[index] = actionItem{label: label} - } - return actions -} - func (m *Model) sourceListView() string { migrations := m.visibleSourceMigrations() if len(migrations) == 0 { @@ -263,17 +267,20 @@ func (m *Model) sourceMigrationCard(migration elmapi.MigrationSummary, selected target := migration.TargetOrganizationLogin + "/" + migration.TargetRepositoryName var card strings.Builder - fmt.Fprintf(&card, "%s %s %s\n", glyph, statusText, m.styles.Bold.Render(source+" → "+target)) + fmt.Fprintf(&card, "%s %s %s\n", glyph, statusText, m.repositoryChip(source+" → "+target)) if m.compactSourceList() { - fmt.Fprintf(&card, " %s", m.styles.Muted.Render(migration.MigrationID)) + fmt.Fprintf(&card, " %s %s", m.styles.Muted.Render("id:"), m.styles.Muted.Render(migration.MigrationID)) } else { - fmt.Fprintf(&card, " %s %s", m.styles.Muted.Render("ID"), m.styles.Muted.Render(migration.MigrationID)) + fmt.Fprintf(&card, " %s %s", m.styles.Muted.Render("id:"), m.styles.Muted.Render(migration.MigrationID)) if migration.TargetMigrationID > 0 { fmt.Fprintf(&card, "%s%s", m.styles.Muted.Render(" · destination "), m.styles.Bold.Render(fmt.Sprintf("%d", migration.TargetMigrationID))) } if migration.CreatedAt != nil && *migration.CreatedAt != "" { fmt.Fprintf(&card, "%s%s", m.styles.Muted.Render(" · created "), m.styles.Muted.Render(*migration.CreatedAt)) } + if migration.TargetVisibility != nil && *migration.TargetVisibility != "" { + fmt.Fprintf(&card, " %s", m.metadataBadge(*migration.TargetVisibility)) + } } return m.selectorCard(card.String(), selected, m.compactSourceList()) @@ -291,7 +298,7 @@ func (m *Model) sourceDetailView() string { } var actions strings.Builder actions.WriteString(m.styles.Bold.Render("Actions") + "\n\n") - actions.WriteString(m.actionButtons(m.sourceActionItems(), m.cursor, m.contentWidth())) + actions.WriteString(m.actionButtons(m.sourceActionItems(), m.actionFocus, m.contentWidth())) return m.detailLayout(status.String(), actions.String()) } @@ -310,8 +317,8 @@ func (m *Model) targetListView() string { repositories := strings.Join(migration.Repositories, ", ") var card strings.Builder glyph, status := m.statusDisplay(migration.Status) - fmt.Fprintf(&card, "%s %s %s\n", glyph, status, m.styles.Bold.Render(repositories)) - fmt.Fprintf(&card, "%s %s", m.styles.Muted.Render("ID"), m.styles.Muted.Render(migration.MigrationID)) + fmt.Fprintf(&card, "%s %s %s\n", glyph, status, m.repositoryChip(repositories)) + fmt.Fprintf(&card, "%s %s", m.styles.Muted.Render("id:"), m.styles.Muted.Render(migration.MigrationID)) if index > start { builder.WriteString("\n") } @@ -360,7 +367,7 @@ func (m *Model) targetDetailView() string { } var actions strings.Builder actions.WriteString(m.styles.Bold.Render("Actions") + "\n\n") - actions.WriteString(m.actionButtons(m.targetActionItems(), m.cursor, m.contentWidth())) + actions.WriteString(m.actionButtons(m.targetActionItems(), m.actionFocus, m.contentWidth())) return m.detailLayout(detail.String(), actions.String()) } @@ -408,7 +415,7 @@ func (m *Model) configurationView() string { fmt.Fprintf(&builder, "Credentials: %s\n", configuration.CredentialStore) } builder.WriteString("\n" + m.styles.Bold.Render("Actions") + "\n\n") - builder.WriteString(m.actionButtons(actionsFromLabels(configurationActions), m.cursor, m.contentWidth())) + builder.WriteString(m.actionButtons(configurationActions, m.actionFocus, m.contentWidth())) return builder.String() } @@ -566,15 +573,110 @@ func (m *Model) pickerView() string { if index > start { builder.WriteString("\n") } - builder.WriteString(m.selectorCard(items[index], index == m.picker.cursor, true)) + builder.WriteString(m.pickerItemView(items[index], index == m.picker.cursor)) } if end < len(items) { fmt.Fprintf(&builder, "\n%s", m.styles.Muted.Render(fmt.Sprintf("↓ %d more", len(items)-end))) } + list := builder.String() if start > 0 { - return m.styles.Muted.Render(fmt.Sprintf("↑ %d more\n", start)) + builder.String() + list = m.styles.Muted.Render(fmt.Sprintf("↑ %d more\n", start)) + list + } + if m.picker.kind != pickerSourceRepository || m.contentWidth() < 92 { + return list + } + selected := items[m.picker.cursor] + if selected.repository == nil { + return list + } + info := m.repositoryInfoPanel(*selected.repository, false) + listWidth := max(30, m.contentWidth()-lipgloss.Width(info)-4) + return lipgloss.JoinHorizontal( + lipgloss.Top, + lipgloss.NewStyle().Width(listWidth).Render(list), + " ", + info, + ) +} + +func (m *Model) pickerItemView(item pickerItem, selected bool) string { + if item.repository == nil { + return m.selectorCard(item.value, selected, true) } - return builder.String() + repository := item.repository + var metadata []string + if repository.Stargazers > 0 { + metadata = append(metadata, fmt.Sprintf("★ %d", repository.Stargazers)) + } + if repository.OpenIssueCount > 0 { + metadata = append(metadata, fmt.Sprintf("≡ %d", repository.OpenIssueCount)) + } + if repository.Language != "" { + metadata = append(metadata, "◆ "+repository.Language) + } + switch { + case repository.Archived: + metadata = append(metadata, "archived") + case repository.Visibility != "": + metadata = append(metadata, repository.Visibility) + case repository.Private: + metadata = append(metadata, "private") + } + if repository.Fork { + metadata = append(metadata, "fork") + } + content := m.styles.Bold.Render(repository.FullName) + if len(metadata) > 0 { + content += " " + m.styles.Muted.Render(strings.Join(metadata, " · ")) + } + return m.selectorCard(content, selected, true) +} + +func (m *Model) pickerInfoOverlay() string { + items := m.visiblePickerItems() + if m.picker.cursor < 0 || m.picker.cursor >= len(items) || items[m.picker.cursor].repository == nil { + return "" + } + return m.repositoryInfoPanel(*items[m.picker.cursor].repository, true) +} + +func (m *Model) repositoryInfoPanel(repository elmapi.Repository, closeButton bool) string { + const width = 36 + row := func(label, value string) string { + return m.styles.Muted.Render(fmt.Sprintf("%-14s", label)) + value + } + visibility := repository.Visibility + if visibility == "" && repository.Private { + visibility = "private" + } + if visibility == "" { + visibility = "unknown" + } + description := repository.Description + if description == "" { + description = "No description." + } + lines := []string{ + m.styles.Bold.Render(repository.FullName), + "", + m.styles.Muted.Render(description), + "", + row("★ Stars", fmt.Sprintf("%d", repository.Stargazers)), + row("≡ Open issues", fmt.Sprintf("%d", repository.OpenIssueCount)), + row("◆ Language", orUnset(repository.Language)), + row("Visibility", visibility), + } + if repository.Archived { + lines = append(lines, row("State", m.styles.Warning.Render("archived"))) + } + if repository.Fork { + lines = append(lines, row("Type", "fork")) + } + if closeButton { + lines = append(lines, "", m.actionButtons([]actionItem{{id: "close", label: "Close", shortcut: "esc"}}, 0, width)) + } + content := lipgloss.NewStyle().Width(width).Render(strings.Join(lines, "\n")) + return m.panel(content) } func (m *Model) confirmationOverlay() string { From 355c696326be5a0f1275a497a74632943a8e57fa Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Wed, 26 Aug 2026 14:20:04 +0200 Subject: [PATCH 04/32] Document TUI metadata and action shortcuts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index de9476d..fc3d427 100644 --- a/README.md +++ b/README.md @@ -49,12 +49,15 @@ and reports without repeatedly copying IDs. Lower-level destination migration co remain available under **Advanced destination operations**. Use arrow keys or `j`/`k` to move, Enter to select, Escape to go back, `/` to search migrations, `Ctrl+V` to toggle list density, `?` for contextual help, and `q` to quit. Long detail and result -views support Page Up and Page Down. +views support Page Up and Page Down. Action screens always focus their first button; +use Left/Right and Enter or the shortcut shown inside a button to activate it. Migration creation lazily loads searchable source repositories and destination organizations from the configured APIs. The destination repository name defaults to the selected source name and remains editable. Press `Ctrl+E` from either picker to -fall back to manual `org/repo` entry. +fall back to manual `org/repo` entry. Source repository rows include metadata returned +by the source appliance; press `?` to inspect the selected repository when its detail +panel does not fit beside the list. In scripts, redirected output, and other non-interactive environments, bare `gh elm` continues to print help and exit successfully. Explicit commands and machine-readable From b058d195ba6f66feb4c32a3418173958ba5bd96b Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Wed, 26 Aug 2026 17:04:20 +0200 Subject: [PATCH 05/32] Fix TUI state management and tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/tui/model.go | 516 +++++++++++++++++++----------- internal/tui/model_test.go | 624 ++++++++++++++++++++++--------------- internal/tui/view.go | 20 +- 3 files changed, 709 insertions(+), 451 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 9375824..a2750e0 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -107,11 +107,11 @@ const ( ) type formField struct { - key string label string description string kind fieldKind - value string + text *string + boolean *bool options []string } @@ -121,10 +121,26 @@ type formState struct { fields []formField cursor int parent screen - submit func(map[string]string) (tea.Cmd, error) + submit func() (tea.Cmd, error) err error } +func textFormField(label, description string, value *string) formField { + return formField{label: label, description: description, kind: fieldText, text: value} +} + +func secretFormField(label string, value *string) formField { + return formField{label: label, kind: fieldSecret, text: value} +} + +func boolFormField(label string, value *bool) formField { + return formField{label: label, kind: fieldBool, boolean: value} +} + +func selectFormField(label string, value *string, options ...string) formField { + return formField{label: label, kind: fieldSelect, text: value, options: options} +} + type pickerKind int const ( @@ -133,16 +149,15 @@ const ( ) type pickerState struct { - kind pickerKind - title string - parent screen - items []pickerItem - cursor int - input textinput.Model - loading bool - err error - source string - generation uint64 + kind pickerKind + title string + parent screen + items []pickerItem + cursor int + input textinput.Model + loading bool + err error + source string } type confirmState struct { @@ -154,10 +169,11 @@ type confirmState struct { } type resultState struct { - title string - body string - parent screen - refresh bool + title string + body string + parent screen + refresh bool + reloadSourceList bool } // Model is the Bubble Tea application model. @@ -181,9 +197,11 @@ type Model struct { sourceListLoaded bool sourceListLoading bool sourceListErr error + sourceListGen uint64 sourceID workflow.SourceMigrationID sourceDetail *elmapi.MigrationDetail sourceWatching bool + sourceWatchGen uint64 sourceSearch bool searchInput textinput.Model compact bool @@ -232,7 +250,7 @@ func New(ctx context.Context, svc service) *Model { // Init implements tea.Model. func (m *Model) Init() tea.Cmd { m.sourceListLoading = true - return tea.Batch(m.startConfigurationLoad(), m.loadSourceListCmd()) + return tea.Batch(m.startConfigurationLoad(), m.startSourceListLoad()) } // Update implements tea.Model. @@ -262,6 +280,9 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } return m.updateKey(msg) case sourceListMsg: + if msg.generation != m.sourceListGen { + return m, nil + } m.sourceListLoading = false m.sourceListLoaded = true m.sourceListErr = msg.err @@ -285,9 +306,12 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.targetID = workflow.TargetMigrationID(msg.detail.Migration.TargetMigrationID) } m.clampActionFocus() + } else { + m.sourceDetail = nil + m.targetID = 0 } if m.sourceWatching { - return m, tea.Tick(2*time.Second, func(time.Time) tea.Msg { return watchTickMsg{} }) + return m, m.scheduleWatchTick() } case targetListMsg: if msg.generation != m.targetListGen { @@ -305,10 +329,14 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.err = msg.err if msg.err == nil { m.targetDetail = msg.migration + m.repository = "" if len(msg.migration.Repositories) > 0 { m.repository = msg.migration.Repositories[0] } m.clampActionFocus() + } else { + m.targetDetail = nil + m.repository = "" } case configMsg: if msg.generation != m.configGeneration { @@ -368,7 +396,19 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if msg.err != nil { m.result = resultState{title: "Action failed", body: msg.err.Error(), parent: msg.parent} } else { - m.result = resultState{title: msg.title, body: msg.body, parent: msg.parent, refresh: msg.refresh} + if msg.sourceID != "" { + m.sourceID = msg.sourceID + } + if msg.reloadSourceList { + m.invalidateSourceList() + } + m.result = resultState{ + title: msg.title, + body: msg.body, + parent: msg.parent, + refresh: msg.refresh, + reloadSourceList: msg.reloadSourceList, + } } m.screen = screenResult m.cursor = 0 @@ -383,7 +423,11 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } m.screen = screenConfirm case watchTickMsg: - if m.sourceWatching && m.screen == screenSourceDetail { + if msg.generation == m.sourceWatchGen && + m.sourceWatching && + m.screen == screenSourceDetail && + !m.loading { + m.sourceWatchGen++ m.loading = true command := m.loadSourceDetailCmd() return m, command @@ -616,8 +660,11 @@ func (m *Model) visiblePickerItems() []pickerItem { func (m *Model) activate() (tea.Model, tea.Cmd) { switch m.screen { case screenHome: - switch m.cursor { - case 0: + if m.cursor < 0 || m.cursor >= len(homeActions) { + return m, nil + } + switch homeActions[m.cursor].id { + case "migrations": m.screen, m.err = screenSourceList, m.sourceListErr switch { case m.sourceListLoading: @@ -629,24 +676,24 @@ func (m *Model) activate() (tea.Model, tea.Cmd) { default: m.loading = true m.sourceListLoading = true - command := m.loadSourceListCmd() + command := m.startSourceListLoad() return m, command } - case 1: + case "create": return m.openSourceCreateForm(screenHome) - case 2: + case "mannequins": m.screen, m.actionFocus = screenMannequins, 0 - case 3: + case "configuration": m.screen, m.loading, m.err = screenConfiguration, true, nil m.actionFocus = 0 m.resetViewport() command := m.startConfigurationLoad() return m, command - case 4: + case "target": m.screen, m.loading, m.err = screenTargetList, true, nil command := m.startTargetListLoad() return m, command - case 5: + case "quit": return m, tea.Quit } case screenSourceList: @@ -655,6 +702,7 @@ func (m *Model) activate() (tea.Model, tea.Cmd) { return m.openSourceCreateForm(screenSourceList) } m.sourceID = workflow.SourceMigrationID(migrations[m.cursor].MigrationID) + m.sourceDetail = nil m.targetID = 0 m.screen, m.loading, m.err = screenSourceDetail, true, nil m.actionFocus = 0 @@ -673,6 +721,8 @@ func (m *Model) activate() (tea.Model, tea.Cmd) { return m, nil } m.targetID = id + m.targetDetail = nil + m.repository = "" m.targetParent = screenTargetList m.screen, m.loading, m.err = screenTargetDetail, true, nil m.actionFocus = 0 @@ -703,6 +753,7 @@ func (m *Model) back() (tea.Model, tea.Cmd) { m.screen = screenHome case screenSourceDetail: m.sourceWatching = false + m.sourceWatchGen++ m.screen = screenSourceList case screenTargetDetail: m.screen = m.targetParent @@ -716,9 +767,12 @@ func (m *Model) refresh() (tea.Model, tea.Cmd) { case screenSourceList: m.loading = true m.sourceListLoading = true - command := m.loadSourceListCmd() + command := m.startSourceListLoad() return m, command case screenSourceDetail: + if m.sourceWatching { + m.sourceWatchGen++ + } m.loading = true command := m.loadSourceDetailCmd() return m, command @@ -741,7 +795,7 @@ func (m *Model) refresh() (tea.Model, tea.Cmd) { func (m *Model) itemCount() int { switch m.screen { case screenHome: - return 6 + return len(homeActions) case screenSourceList: return len(m.visibleSourceMigrations()) case screenSourceDetail: @@ -778,6 +832,15 @@ type actionItem struct { shortcut string } +var homeActions = []actionItem{ + {id: "migrations", label: "Migrations"}, + {id: "create", label: "Create migration"}, + {id: "mannequins", label: "Target mannequins"}, + {id: "configuration", label: "Configuration"}, + {id: "target", label: "Advanced destination operations"}, + {id: "quit", label: "Quit"}, +} + func (m *Model) activateSourceAction() (tea.Model, tea.Cmd) { actions := m.sourceActionItems() if m.actionFocus < 0 || m.actionFocus >= len(actions) { @@ -788,6 +851,7 @@ func (m *Model) activateSourceAction() (tea.Model, tea.Cmd) { return m.refresh() case "watch": m.sourceWatching = !m.sourceWatching + m.sourceWatchGen++ if m.sourceWatching { m.loading = true command := m.loadSourceDetailCmd() @@ -828,6 +892,8 @@ func (m *Model) activateSourceAction() (tea.Model, tea.Cmd) { return m, nil } m.screen, m.loading, m.err = screenTargetDetail, true, nil + m.targetDetail = nil + m.repository = "" m.targetParent = screenSourceDetail m.actionFocus = 0 m.resetViewport() @@ -959,14 +1025,17 @@ var mannequinActions = []actionItem{ } func (m *Model) activateMannequinAction() (tea.Model, tea.Cmd) { - switch m.actionFocus { - case 0: + if m.actionFocus < 0 || m.actionFocus >= len(mannequinActions) { + return m, nil + } + switch mannequinActions[m.actionFocus].id { + case "list": return m.openMannequinListForm(false) - case 1: + case "export": return m.openMannequinListForm(true) - case 2: + case "reclaim": return m.openMannequinReclaimForm(false) - case 3: + case "reclaim-csv": return m.openMannequinReclaimForm(true) } return m, nil @@ -979,16 +1048,26 @@ var configurationActions = []actionItem{ } func (m *Model) activateConfigurationAction() (tea.Model, tea.Cmd) { - switch m.actionFocus { - case 0: + if m.actionFocus < 0 || m.actionFocus >= len(configurationActions) { + return m, nil + } + switch configurationActions[m.actionFocus].id { + case "refresh": return m.refresh() - case 1: + case "edit": return m.openConfigurationForm() - case 2: + case "reset": return m.confirmAction("Reset configuration", "Remove all stored endpoint URLs and credentials?", screenConfiguration, func() tea.Msg { err := m.service.ResetConfiguration(m.ctx) - return actionMsg{title: "Configuration reset", body: "Stored configuration and credentials were cleared.", parent: screenConfiguration, refresh: true, err: err} + return actionMsg{ + title: "Configuration reset", + body: "Stored configuration and credentials were cleared.", + parent: screenConfiguration, + refresh: true, + reloadSourceList: true, + err: err, + } }) } return m, nil @@ -1055,14 +1134,32 @@ func (m *Model) updateResult(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.Open), key.Matches(msg, keys.Back), key.Matches(msg, keys.Quit): parent := m.result.parent refresh := m.result.refresh + reloadSourceList := m.result.reloadSourceList m.screen, m.cursor, m.actionFocus, m.err = parent, 0, 0, nil if refresh { - return m.refresh() + model, command := m.refresh() + if reloadSourceList { + m.sourceListLoading = true + return model, tea.Batch(command, m.startSourceListLoad()) + } + return model, command + } + if reloadSourceList { + m.sourceListLoading = true + return m, m.startSourceListLoad() } } return m, nil } +func (m *Model) invalidateSourceList() { + m.sourceListGen++ + m.sourceMigrations = nil + m.sourceListLoaded = false + m.sourceListLoading = false + m.sourceListErr = nil +} + func (m *Model) visibleSourceMigrations() []elmapi.MigrationSummary { query := strings.ToLower(strings.TrimSpace(m.searchInput.Value())) if query == "" { @@ -1152,8 +1249,8 @@ func (m *Model) openForm(form formState) (tea.Model, tea.Cmd) { func (m *Model) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { field := &m.form.fields[m.form.cursor] if msg.Type == tea.KeyRunes { - if field.kind == fieldText || field.kind == fieldSecret { - field.value += string(msg.Runes) + if field.text != nil && (field.kind == fieldText || field.kind == fieldSecret) { + *field.text += string(msg.Runes) return m, nil } } @@ -1171,30 +1268,22 @@ func (m *Model) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "left": cycleOption(field, -1) case "right", " ": - if field.kind == fieldBool { - if field.value == "true" { - field.value = "false" - } else { - field.value = "true" - } + if field.boolean != nil { + *field.boolean = !*field.boolean } else { cycleOption(field, 1) } case "backspace": - if (field.kind == fieldText || field.kind == fieldSecret) && field.value != "" { - runes := []rune(field.value) - field.value = string(runes[:len(runes)-1]) + if field.text != nil && (field.kind == fieldText || field.kind == fieldSecret) && *field.text != "" { + runes := []rune(*field.text) + *field.text = string(runes[:len(runes)-1]) } case "enter": if m.form.cursor < len(m.form.fields)-1 { m.form.cursor++ return m, nil } - values := make(map[string]string, len(m.form.fields)) - for _, f := range m.form.fields { - values[f.key] = f.value - } - command, err := m.form.submit(values) + command, err := m.form.submit() if err != nil { m.form.err = err return m, nil @@ -1207,31 +1296,34 @@ func (m *Model) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func cycleOption(field *formField, delta int) { - if field.kind != fieldSelect || len(field.options) == 0 { + if field.kind != fieldSelect || field.text == nil || len(field.options) == 0 { return } index := 0 for i, option := range field.options { - if option == field.value { + if option == *field.text { index = i break } } index = (index + delta + len(field.options)) % len(field.options) - field.value = field.options[index] + *field.text = field.options[index] } func (m *Model) openSourceIDForm() (tea.Model, tea.Cmd) { + id := "" return m.openForm(formState{ title: "Open source migration", parent: screenSourceList, - fields: []formField{{key: "id", label: "Source migration UUID", kind: fieldText}}, - submit: func(values map[string]string) (tea.Cmd, error) { - id := strings.TrimSpace(values["id"]) + fields: []formField{textFormField("Source migration UUID", "", &id)}, + submit: func() (tea.Cmd, error) { + id = strings.TrimSpace(id) if id == "" { return nil, errors.New("source migration UUID is required") } m.sourceID = workflow.SourceMigrationID(id) + m.sourceDetail = nil + m.targetID = 0 m.form.parent = screenSourceDetail m.resetViewport() return m.loadSourceDetailCmd(), nil @@ -1249,12 +1341,11 @@ func (m *Model) openSourceCreateForm(parent screen) (tea.Model, tea.Cmd) { m.pickerGeneration++ m.pickerInfoOpen = false m.picker = pickerState{ - kind: pickerSourceRepository, - title: "Select source repository", - parent: parent, - input: input, - loading: true, - generation: m.pickerGeneration, + kind: pickerSourceRepository, + title: "Select source repository", + parent: parent, + input: input, + loading: true, } m.screen = screenPicker command := m.loadPickerCatalogCmd(pickerSourceRepository, m.pickerGeneration) @@ -1271,13 +1362,12 @@ func (m *Model) openTargetOrganizationPicker(parent screen, source string) (tea. m.pickerGeneration++ m.pickerInfoOpen = false m.picker = pickerState{ - kind: pickerTargetOrganization, - title: "Select destination organization", - parent: parent, - input: input, - loading: true, - source: source, - generation: m.pickerGeneration, + kind: pickerTargetOrganization, + title: "Select destination organization", + parent: parent, + input: input, + loading: true, + source: source, } m.screen = screenPicker command := m.loadPickerCatalogCmd(pickerTargetOrganization, m.pickerGeneration) @@ -1286,7 +1376,6 @@ func (m *Model) openTargetOrganizationPicker(parent screen, source string) (tea. func (m *Model) reloadPicker() (tea.Model, tea.Cmd) { m.pickerGeneration++ - m.picker.generation = m.pickerGeneration m.picker.loading = true m.picker.err = nil m.picker.items = nil @@ -1301,27 +1390,28 @@ func (m *Model) openDiscoveredSourceCreateForm(source, targetOrganization string m.picker.err = err return m, nil } + targetRepository := sourceRepository + visibility := "internal" + start := false return m.openForm(formState{ title: "Create migration", description: fmt.Sprintf("Source: %s\nDestination organization: %s", source, targetOrganization), parent: screenPicker, fields: []formField{ - { - key: "targetRepo", - label: "Destination repository name", - description: "Defaults to the source repository name; edit it if the destination should differ.", - kind: fieldText, - value: sourceRepository, - }, - {key: "visibility", label: "Target visibility", kind: fieldSelect, value: "internal", options: []string{"internal", "private"}}, - {key: "start", label: "Start after creation", kind: fieldBool, value: "false"}, + textFormField( + "Destination repository name", + "Defaults to the source repository name; edit it if the destination should differ.", + &targetRepository, + ), + selectFormField("Target visibility", &visibility, "internal", "private"), + boolFormField("Start after creation", &start), }, - submit: func(values map[string]string) (tea.Cmd, error) { + submit: func() (tea.Cmd, error) { sourceOwner, sourceRepo, err := workflow.ParseRepositoryCoordinate(source) if err != nil { return nil, fmt.Errorf("invalid source repository: %w", err) } - targetRepo := strings.TrimSpace(values["targetRepo"]) + targetRepo := strings.TrimSpace(targetRepository) if targetRepo == "" || strings.Contains(targetRepo, "/") { return nil, errors.New("destination repository name must be non-empty and must not contain a slash") } @@ -1330,41 +1420,41 @@ func (m *Model) openDiscoveredSourceCreateForm(source, targetOrganization string SourceRepo: sourceRepo, TargetOwner: targetOrganization, TargetRepo: targetRepo, - Visibility: values["visibility"], - Start: values["start"] == "true", + Visibility: visibility, + Start: start, }), nil }, }) } func (m *Model) openManualSourceCreateForm(parent screen, source string) (tea.Model, tea.Cmd) { + target := "" + visibility := "internal" + start := false return m.openForm(formState{ title: "Create migration manually", description: "Enter repositories directly when API discovery is unavailable.", parent: parent, fields: []formField{ - { - key: "source", - label: "Source repository", - description: "Format: org/repo (for example, source-org/source-repo)", - kind: fieldText, - value: source, - }, - { - key: "target", - label: "Target repository", - description: "Format: org/repo (for example, target-org/target-repo)", - kind: fieldText, - }, - {key: "visibility", label: "Target visibility", kind: fieldSelect, value: "internal", options: []string{"internal", "private"}}, - {key: "start", label: "Start after creation", kind: fieldBool, value: "false"}, + textFormField( + "Source repository", + "Format: org/repo (for example, source-org/source-repo)", + &source, + ), + textFormField( + "Target repository", + "Format: org/repo (for example, target-org/target-repo)", + &target, + ), + selectFormField("Target visibility", &visibility, "internal", "private"), + boolFormField("Start after creation", &start), }, - submit: func(values map[string]string) (tea.Cmd, error) { - sourceOwner, sourceRepository, err := workflow.ParseRepositoryCoordinate(values["source"]) + submit: func() (tea.Cmd, error) { + sourceOwner, sourceRepository, err := workflow.ParseRepositoryCoordinate(source) if err != nil { return nil, fmt.Errorf("invalid source repository: %w", err) } - targetOwner, targetRepository, err := workflow.ParseRepositoryCoordinate(values["target"]) + targetOwner, targetRepository, err := workflow.ParseRepositoryCoordinate(target) if err != nil { return nil, fmt.Errorf("invalid target repository: %w", err) } @@ -1373,8 +1463,8 @@ func (m *Model) openManualSourceCreateForm(parent screen, source string) (tea.Mo SourceRepo: sourceRepository, TargetOwner: targetOwner, TargetRepo: targetRepository, - Visibility: values["visibility"], - Start: values["start"] == "true", + Visibility: visibility, + Start: start, } return m.createSourceMigrationCmd(input), nil }, @@ -1387,26 +1477,35 @@ func (m *Model) createSourceMigrationCmd(input workflow.SourceCreateInput) tea.C if err != nil { return actionMsg{parent: screenSourceList, err: err} } - m.sourceID = workflow.SourceMigrationID(result.Migration.MigrationID) + sourceID := workflow.SourceMigrationID(result.Migration.MigrationID) body := render.MigrationCreate(result.Migration) if result.Started { body = fmt.Sprintf("Migration %s created and started.", result.Migration.MigrationID) } - return actionMsg{title: "Migration created", body: body, parent: screenSourceDetail, refresh: true} + return actionMsg{ + title: "Migration created", + body: body, + parent: screenSourceDetail, + refresh: true, + sourceID: sourceID, + } } } func (m *Model) openTargetIDForm() (tea.Model, tea.Cmd) { + value := "" return m.openForm(formState{ title: "Open target migration", parent: screenTargetList, - fields: []formField{{key: "id", label: "Numeric target migration ID", kind: fieldText}}, - submit: func(values map[string]string) (tea.Cmd, error) { - id, err := workflow.ParseTargetMigrationID(values["id"]) + fields: []formField{textFormField("Numeric target migration ID", "", &value)}, + submit: func() (tea.Cmd, error) { + id, err := workflow.ParseTargetMigrationID(value) if err != nil { return nil, err } m.targetID = id + m.targetDetail = nil + m.repository = "" m.targetParent = screenTargetList m.form.parent = screenTargetDetail m.resetViewport() @@ -1416,21 +1515,25 @@ func (m *Model) openTargetIDForm() (tea.Model, tea.Cmd) { } func (m *Model) openTargetCreateForm() (tea.Model, tea.Cmd) { + sourceURL := "" + repository := "" + description := "" + guid := "" return m.openForm(formState{ title: "Create target migration (advanced)", parent: screenTargetList, fields: []formField{ - {key: "sourceURL", label: "Source repository URL", kind: fieldText}, - {key: "repository", label: "Target owner/repository", kind: fieldText}, - {key: "description", label: "Description", kind: fieldText}, - {key: "guid", label: "Exporter migration GUID", kind: fieldText}, + textFormField("Source repository URL", "", &sourceURL), + textFormField("Target owner/repository", "", &repository), + textFormField("Description", "", &description), + textFormField("Exporter migration GUID", "", &guid), }, - submit: func(values map[string]string) (tea.Cmd, error) { + submit: func() (tea.Cmd, error) { input := workflow.TargetCreateInput{ - SourceRepositoryURL: values["sourceURL"], - Repository: values["repository"], - Description: values["description"], - ExporterGUID: values["guid"], + SourceRepositoryURL: sourceURL, + Repository: repository, + Description: description, + ExporterGUID: guid, } return func() tea.Msg { raw, err := m.service.CreateTargetMigration(m.ctx, input) @@ -1441,37 +1544,40 @@ func (m *Model) openTargetCreateForm() (tea.Model, tea.Cmd) { } func (m *Model) openResourcesForm() (tea.Model, tea.Cmd) { - defaultRepo := m.repository + repository := m.repository + origin := "all" + state := "all" + maximum := "100" return m.openForm(formState{ title: "List target resources", parent: screenTargetDetail, fields: []formField{ - {key: "repository", label: "Repository owner/name", kind: fieldText, value: defaultRepo}, - {key: "origin", label: "Origin", kind: fieldSelect, value: "all", options: []string{"all", "backfill", "live-update"}}, - {key: "state", label: "State", kind: fieldSelect, value: "all", options: []string{"all", "pending", "processed", "failed", "eligible"}}, - {key: "max", label: "Maximum results (0 = all)", kind: fieldText, value: "100"}, + textFormField("Repository owner/name", "", &repository), + selectFormField("Origin", &origin, "all", "backfill", "live-update"), + selectFormField("State", &state, "all", "pending", "processed", "failed", "eligible"), + textFormField("Maximum results (0 = all)", "", &maximum), }, - submit: func(values map[string]string) (tea.Cmd, error) { - maxResults, err := strconv.Atoi(strings.TrimSpace(values["max"])) + submit: func() (tea.Cmd, error) { + maxResults, err := strconv.Atoi(strings.TrimSpace(maximum)) if err != nil || maxResults < 0 { return nil, errors.New("maximum results must be zero or a positive integer") } - origin := values["origin"] - if origin == "all" { - origin = "" + resourceOrigin := origin + if resourceOrigin == "all" { + resourceOrigin = "" } - state := values["state"] - if state == "all" { - state = "" + resourceState := state + if resourceState == "all" { + resourceState = "" } input := workflow.ResourceInput{ MigrationID: m.targetID, - Repository: values["repository"], - Origin: origin, - State: state, + Repository: repository, + Origin: resourceOrigin, + State: resourceState, MaxResults: maxResults, } - m.repository = strings.TrimSpace(values["repository"]) + m.repository = strings.TrimSpace(repository) return func() tea.Msg { nodes, err := m.service.ListResources(m.ctx, input) return actionMsg{title: "Target resources", body: renderNodes(nodes), parent: screenTargetDetail, err: err} @@ -1481,18 +1587,21 @@ func (m *Model) openResourcesForm() (tea.Model, tea.Cmd) { } func (m *Model) openReportForm(title, operation string) (tea.Model, tea.Cmd) { + stage := "backfill" + state := "" fields := []formField{ - {key: "stage", label: "Stage", kind: fieldSelect, value: "backfill", options: []string{"backfill", "live-update"}}, + selectFormField("Stage", &stage, "backfill", "live-update"), } if operation == "request" { - fields = append(fields, formField{key: "state", label: "Node state", kind: fieldSelect, value: "all", options: []string{"all", "migrated", "unmigrated"}}) + state = "all" + fields = append(fields, selectFormField("Node state", &state, "all", "migrated", "unmigrated")) } return m.openForm(formState{ title: title, parent: screenTargetDetail, fields: fields, - submit: func(values map[string]string) (tea.Cmd, error) { - input := workflow.ReportInput{MigrationID: m.targetID, Stage: values["stage"], State: values["state"]} + submit: func() (tea.Cmd, error) { + input := workflow.ReportInput{MigrationID: m.targetID, Stage: stage, State: state} return func() tea.Msg { var ( raw json.RawMessage @@ -1513,12 +1622,15 @@ func (m *Model) openReportForm(title, operation string) (tea.Model, tea.Cmd) { } func (m *Model) openMannequinListForm(export bool) (tea.Model, tea.Cmd) { + organization := "" + includeReclaimed := false + path := "mannequins.csv" fields := []formField{ - {key: "org", label: "Target organization", kind: fieldText}, - {key: "include", label: "Include reclaimed mannequins", kind: fieldBool, value: "false"}, + textFormField("Target organization", "", &organization), + boolFormField("Include reclaimed mannequins", &includeReclaimed), } if export { - fields = append(fields, formField{key: "path", label: "CSV output path", kind: fieldText, value: "mannequins.csv"}) + fields = append(fields, textFormField("CSV output path", "", &path)) } title := "List mannequins" if export { @@ -1528,15 +1640,13 @@ func (m *Model) openMannequinListForm(export bool) (tea.Model, tea.Cmd) { title: title, parent: screenMannequins, fields: fields, - submit: func(values map[string]string) (tea.Cmd, error) { - org := values["org"] - include := values["include"] == "true" + submit: func() (tea.Cmd, error) { return func() tea.Msg { if export { - err := m.service.ExportMannequins(m.ctx, org, values["path"], include) - return actionMsg{title: "Mannequins exported", body: fmt.Sprintf("Wrote mannequin CSV to %s.", values["path"]), parent: screenMannequins, err: err} + err := m.service.ExportMannequins(m.ctx, organization, path, includeReclaimed) + return actionMsg{title: "Mannequins exported", body: fmt.Sprintf("Wrote mannequin CSV to %s.", path), parent: screenMannequins, err: err} } - records, err := m.service.ListMannequins(m.ctx, org, include) + records, err := m.service.ListMannequins(m.ctx, organization, includeReclaimed) var buffer bytes.Buffer if err == nil { err = workflow.WriteMannequinCSV(&buffer, records) @@ -1548,33 +1658,40 @@ func (m *Model) openMannequinListForm(export bool) (tea.Model, tea.Cmd) { } func (m *Model) openMannequinReclaimForm(csvMode bool) (tea.Model, tea.Cmd) { - fields := []formField{{key: "org", label: "Target organization", kind: fieldText}} + organization := "" + csvPath := "" + mannequin := "" + mannequinID := "" + targetUser := "" + force := false + skipInvitation := false + fields := []formField{textFormField("Target organization", "", &organization)} if csvMode { - fields = append(fields, formField{key: "csv", label: "Mannequin CSV path", kind: fieldText}) + fields = append(fields, textFormField("Mannequin CSV path", "", &csvPath)) } else { fields = append(fields, - formField{key: "mannequin", label: "Mannequin login", kind: fieldText}, - formField{key: "mannequinID", label: "Mannequin ID (optional)", kind: fieldText}, - formField{key: "target", label: "Target user or app[bot]", kind: fieldText}, + textFormField("Mannequin login", "", &mannequin), + textFormField("Mannequin ID (optional)", "", &mannequinID), + textFormField("Target user or app[bot]", "", &targetUser), ) } fields = append(fields, - formField{key: "force", label: "Force already-reclaimed mannequins", kind: fieldBool, value: "false"}, - formField{key: "skip", label: "Immediate reattribution (EMU)", kind: fieldBool, value: "false"}, + boolFormField("Force already-reclaimed mannequins", &force), + boolFormField("Immediate reattribution (EMU)", &skipInvitation), ) return m.openForm(formState{ title: "Reclaim mannequins", parent: screenMannequins, fields: fields, - submit: func(values map[string]string) (tea.Cmd, error) { + submit: func() (tea.Cmd, error) { input := workflow.MannequinReclaimInput{ - Organization: values["org"], - CSVPath: values["csv"], - Mannequin: values["mannequin"], - MannequinID: values["mannequinID"], - TargetUser: values["target"], - Force: values["force"] == "true", - SkipInvitation: values["skip"] == "true", + Organization: organization, + CSVPath: csvPath, + Mannequin: mannequin, + MannequinID: mannequinID, + TargetUser: targetUser, + Force: force, + SkipInvitation: skipInvitation, } // Match workflow.Service, which trims the target before deciding the // reclaim path, so a bot login with stray whitespace still selects the @@ -1642,7 +1759,7 @@ func readReclaimCSV(path string) ([]ghapi.MannequinRecord, error) { } func (m *Model) openConfigurationForm() (tea.Model, tea.Cmd) { - sourceURL, targetURL := "", "" + sourceURL, sourceToken, targetURL, targetToken := "", "", "", "" if m.configuration != nil { sourceURL = m.configuration.SourceURL targetURL = m.configuration.TargetURL @@ -1651,21 +1768,28 @@ func (m *Model) openConfigurationForm() (tea.Model, tea.Cmd) { title: "Edit configuration", parent: screenConfiguration, fields: []formField{ - {key: "sourceURL", label: "Source URL", kind: fieldText, value: sourceURL}, - {key: "sourceToken", label: "Source token (blank preserves current)", kind: fieldSecret}, - {key: "targetURL", label: "Target URL", kind: fieldText, value: targetURL}, - {key: "targetToken", label: "Target token (blank preserves current)", kind: fieldSecret}, + textFormField("Source URL", "", &sourceURL), + secretFormField("Source token (blank preserves current)", &sourceToken), + textFormField("Target URL", "", &targetURL), + secretFormField("Target token (blank preserves current)", &targetToken), }, - submit: func(values map[string]string) (tea.Cmd, error) { + submit: func() (tea.Cmd, error) { input := workflow.ConfigurationInput{ - SourceURL: values["sourceURL"], - SourceToken: values["sourceToken"], - TargetURL: values["targetURL"], - TargetToken: values["targetToken"], + SourceURL: sourceURL, + SourceToken: sourceToken, + TargetURL: targetURL, + TargetToken: targetToken, } return func() tea.Msg { err := m.service.SaveConfiguration(m.ctx, input) - return actionMsg{title: "Configuration saved", body: "Stored gh elm configuration.", parent: screenConfiguration, refresh: true, err: err} + return actionMsg{ + title: "Configuration saved", + body: "Stored gh elm configuration.", + parent: screenConfiguration, + refresh: true, + reloadSourceList: true, + err: err, + } }, nil }, }) @@ -1709,6 +1833,7 @@ func (m *Model) revertCutoverCmd() tea.Cmd { type sourceListMsg struct { migrations []elmapi.MigrationSummary + generation uint64 err error } @@ -1751,11 +1876,13 @@ type pickerCatalogMsg struct { } type actionMsg struct { - title string - body string - parent screen - refresh bool - err error + title string + body string + parent screen + refresh bool + reloadSourceList bool + sourceID workflow.SourceMigrationID + err error } type confirmRequestMsg struct { @@ -1765,15 +1892,26 @@ type confirmRequestMsg struct { command tea.Cmd } -type watchTickMsg struct{} +type watchTickMsg struct { + generation uint64 +} -func (m *Model) loadSourceListCmd() tea.Cmd { +func (m *Model) startSourceListLoad() tea.Cmd { + m.sourceListGen++ + generation := m.sourceListGen return func() tea.Msg { migrations, err := m.service.ListSourceMigrations(m.ctx, "") - return sourceListMsg{migrations: migrations, err: err} + return sourceListMsg{migrations: migrations, generation: generation, err: err} } } +func (m *Model) scheduleWatchTick() tea.Cmd { + generation := m.sourceWatchGen + return tea.Tick(2*time.Second, func(time.Time) tea.Msg { + return watchTickMsg{generation: generation} + }) +} + func (m *Model) loadSourceDetailCmd() tea.Cmd { id := m.sourceID return func() tea.Msg { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 18c8b8b..b94a69e 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -2,7 +2,6 @@ package tui import ( "context" - "encoding/json" "fmt" "strings" "testing" @@ -18,15 +17,7 @@ import ( "github.com/github/gh-elm/internal/workflow" ) -func TestModel(t *testing.T) { - t.Run("loads configuration readiness on startup", func(t *testing.T) { - model := New(t.Context(), &fakeService{}) - - command := model.Init() - - require.NotNil(t, command) - }) - +func TestModelUpdate(t *testing.T) { t.Run("background prefetch errors stay off the home screen", func(t *testing.T) { model := New(t.Context(), &fakeService{}) updated, _ := model.Update(sourceListMsg{err: assert.AnError}) @@ -52,6 +43,23 @@ func TestModel(t *testing.T) { assert.Nil(t, command) }) + t.Run("ignores stale source migration responses", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.sourceListGen = 2 + model.sourceListLoading = true + model.sourceMigrations = []elmapi.MigrationSummary{{MigrationID: "current"}} + + updated, _ := model.Update(sourceListMsg{ + migrations: []elmapi.MigrationSummary{{MigrationID: "stale"}}, + generation: 1, + }) + model = updated.(*Model) + + assert.True(t, model.sourceListLoading) + require.Len(t, model.sourceMigrations, 1) + assert.Equal(t, "current", model.sourceMigrations[0].MigrationID) + }) + t.Run("configuration response does not unlock a pending migration list", func(t *testing.T) { model := New(t.Context(), &fakeService{}) model.sourceListLoading = true @@ -138,19 +146,10 @@ func TestModel(t *testing.T) { assert.Contains(t, view, "destination URL, destination token") }) - t.Run("styles the home headline", func(t *testing.T) { - model := New(t.Context(), &fakeService{}) - - view := model.View() - - assert.Contains(t, view, model.styles.Primary.Bold(true).Render("GitHub Enterprise")) - assert.Contains(t, view, model.styles.Success.Render("Live migrations")) - }) - t.Run("cancels a destination migration load and returns home", func(t *testing.T) { started := make(chan struct{}) service := &fakeService{ - listTargetMigrations: func(ctx context.Context) ([]elmapi.TargetMigration, error) { + listTargetMigrations: func(ctx context.Context, _ string, _ int) ([]elmapi.TargetMigration, error) { close(started) <-ctx.Done() return nil, ctx.Err() @@ -240,30 +239,163 @@ func TestModel(t *testing.T) { assert.Contains(t, model.View(), "Failed source authentication") }) - t.Run("colors preflight status marks", func(t *testing.T) { + t.Run("failed source detail load clears previous migration state", func(t *testing.T) { model := New(t.Context(), &fakeService{}) + model.screen = screenSourceDetail + model.sourceID = "new-source" + model.targetID = 42 + setSourceStatus(model, elmapi.StatusCreated) - assert.Equal(t, model.styles.Success.Render("✓"), model.checkMark(true)) - assert.Equal(t, model.styles.Failure.Render("✗"), model.checkMark(false)) - assert.Equal(t, model.styles.Muted.Render("…"), model.authenticationMark(false, nil)) + updated, _ := model.Update(sourceDetailMsg{err: assert.AnError}) + model = updated.(*Model) + + assert.Nil(t, model.sourceDetail) + assert.Zero(t, model.targetID) + assert.NotContains(t, actionIDs(model.sourceActionItems()), "start") + assert.NotContains(t, actionIDs(model.sourceActionItems()), "cancel") }) + t.Run("watch ticks do not overlap source detail loads", func(t *testing.T) { + svc := &fakeService{ + getSourceMigration: func(context.Context, workflow.SourceMigrationID) (*elmapi.MigrationDetail, error) { + return &elmapi.MigrationDetail{}, nil + }, + } + model := New(t.Context(), svc) + model.screen = screenSourceDetail + model.sourceWatching = true + model.sourceWatchGen = 1 + + updated, command := model.Update(watchTickMsg{generation: 1}) + model = updated.(*Model) + require.NotNil(t, command) + assert.True(t, model.loading) + assert.Equal(t, uint64(2), model.sourceWatchGen) + + updated, duplicate := model.Update(watchTickMsg{generation: 1}) + model = updated.(*Model) + assert.Nil(t, duplicate) + + updated, nextTick := model.Update(command()) + model = updated.(*Model) + assert.False(t, model.loading) + require.NotNil(t, nextTick) + }) + + t.Run("target detail load clears previous repository", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.repository = "old/repository" + model.targetDetail = &elmapi.TargetMigration{Repositories: []string{"old/repository"}} + + updated, _ := model.Update(targetDetailMsg{migration: &elmapi.TargetMigration{}}) + model = updated.(*Model) + assert.Empty(t, model.repository) + + model.repository = "another/old-repository" + updated, _ = model.Update(targetDetailMsg{err: assert.AnError}) + model = updated.(*Model) + assert.Nil(t, model.targetDetail) + assert.Empty(t, model.repository) + }) + + t.Run("configuration save reloads source migrations", func(t *testing.T) { + svc := &fakeService{ + saveConfiguration: func(context.Context, workflow.ConfigurationInput) error { + return nil + }, + getConfiguration: func(context.Context) (*workflow.Configuration, error) { + return &workflow.Configuration{}, nil + }, + listSourceMigrations: func(context.Context, string) ([]elmapi.MigrationSummary, error) { + return []elmapi.MigrationSummary{{MigrationID: "new-source"}}, nil + }, + } + model := New(t.Context(), svc) + model.screen = screenConfiguration + model.sourceListLoaded = true + model.sourceListErr = assert.AnError + model.sourceMigrations = []elmapi.MigrationSummary{{MigrationID: "old-source"}} + updated, _ := model.openConfigurationForm() + model = updated.(*Model) + model.form.cursor = len(model.form.fields) - 1 + + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(*Model) + require.NotNil(t, command) + updated, _ = model.Update(command()) + model = updated.(*Model) + + assert.Equal(t, screenResult, model.screen) + assert.False(t, model.sourceListLoaded) + assert.Empty(t, model.sourceMigrations) + assert.NoError(t, model.sourceListErr) + + updated, command = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(*Model) + require.NotNil(t, command) + batch, ok := command().(tea.BatchMsg) + require.True(t, ok) + for _, batchCommand := range batch { + updated, _ = model.Update(batchCommand()) + model = updated.(*Model) + } + + require.Len(t, model.sourceMigrations, 1) + assert.Equal(t, "new-source", model.sourceMigrations[0].MigrationID) + assert.True(t, model.sourceListLoaded) + }) + + t.Run("configuration reset invalidates source migrations", func(t *testing.T) { + svc := &fakeService{ + resetConfiguration: func(context.Context) error { + return nil + }, + } + model := New(t.Context(), svc) + model.screen = screenConfiguration + model.actionFocus = len(configurationActions) - 1 + model.sourceListLoaded = true + model.sourceMigrations = []elmapi.MigrationSummary{{MigrationID: "old-source"}} + + updated, _ := model.activateConfigurationAction() + model = updated.(*Model) + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}) + model = updated.(*Model) + require.NotNil(t, command) + updated, _ = model.Update(command()) + model = updated.(*Model) + + assert.Equal(t, screenResult, model.screen) + assert.False(t, model.sourceListLoaded) + assert.Empty(t, model.sourceMigrations) + }) +} + +func TestModelNavigationAndLayout(t *testing.T) { t.Run("opens source migration from list", func(t *testing.T) { status := "in_progress" + sourceDetail := &elmapi.MigrationDetail{ + Migration: &elmapi.MigrationSummary{ + MigrationID: "source-1", + TargetMigrationID: 42, + }, + } svc := &fakeService{ - sourceMigrations: []elmapi.MigrationSummary{{ - MigrationID: "source-1", - Status: &status, - SourceOrganizationLogin: "source", - SourceRepositoryName: "repo", - TargetOrganizationLogin: "target", - TargetRepositoryName: "repo", - }}, - sourceDetail: &elmapi.MigrationDetail{ - Migration: &elmapi.MigrationSummary{ - MigrationID: "source-1", - TargetMigrationID: 42, - }, + listSourceMigrations: func(context.Context, string) ([]elmapi.MigrationSummary, error) { + return []elmapi.MigrationSummary{{ + MigrationID: "source-1", + Status: &status, + SourceOrganizationLogin: "source", + SourceRepositoryName: "repo", + TargetOrganizationLogin: "target", + TargetRepositoryName: "repo", + }}, nil + }, + getSourceMigration: func(context.Context, workflow.SourceMigrationID) (*elmapi.MigrationDetail, error) { + return sourceDetail, nil + }, + getTargetMigration: func(context.Context, workflow.TargetMigrationID) (*elmapi.TargetMigration, error) { + return &elmapi.TargetMigration{}, nil }, } model := New(t.Context(), svc) @@ -468,13 +600,14 @@ func TestModel(t *testing.T) { model := New(t.Context(), &fakeService{}) model.width = 80 model.height = 16 + first, second, third, fourth := "one", "two", "three", "four" model.form = formState{ cursor: 3, fields: []formField{ - {label: "First", value: "one"}, - {label: "Second", value: "two"}, - {label: "Third", value: "three"}, - {label: "Fourth", value: "four"}, + textFormField("First", "", &first), + textFormField("Second", "", &second), + textFormField("Third", "", &third), + textFormField("Fourth", "", &fourth), }, } @@ -516,7 +649,9 @@ func TestModel(t *testing.T) { assert.Equal(t, len(model.sourceActionItems())-1, model.actionFocus) }) +} +func TestMigrationCreation(t *testing.T) { t.Run("home exposes migration creation", func(t *testing.T) { model := New(t.Context(), &fakeService{}) assert.Contains(t, model.View(), "Create migration") @@ -530,9 +665,20 @@ func TestModel(t *testing.T) { }) t.Run("migration creation discovers repositories and organizations", func(t *testing.T) { + var sourceCreateInput workflow.SourceCreateInput svc := &fakeService{ - sourceRepositories: []string{"acme/api", "octo/web"}, - targetOrganizations: []string{"acme-cloud", "octo-cloud"}, + listSourceRepositories: func(context.Context) ([]elmapi.Repository, error) { + return []elmapi.Repository{{FullName: "acme/api"}, {FullName: "octo/web"}}, nil + }, + listTargetOrganizations: func(context.Context) ([]string, error) { + return []string{"acme-cloud", "octo-cloud"}, nil + }, + createSourceMigration: func(_ context.Context, input workflow.SourceCreateInput) (*workflow.SourceCreateResult, error) { + sourceCreateInput = input + return &workflow.SourceCreateResult{ + Migration: elmapi.CreateMigrationResponse{MigrationID: "created-1"}, + }, nil + }, } model := New(t.Context(), svc) updated, command := model.openSourceCreateForm(screenHome) @@ -555,70 +701,22 @@ func TestModel(t *testing.T) { require.Equal(t, screenForm, model.screen) assert.Contains(t, model.form.description, "Source: acme/api") assert.Contains(t, model.form.description, "Destination organization: acme-cloud") - assert.Equal(t, "api", model.form.fields[0].value) + assert.Equal(t, "api", *model.form.fields[0].text) - command, err := model.form.submit(map[string]string{ - "targetRepo": "renamed-api", - "visibility": "private", - "start": "true", - }) - - t.Run("repository picker presents real metadata", func(t *testing.T) { - repository := elmapi.Repository{ - FullName: "acme/api", - Description: "Public API for Acme products.", - Language: "Go", - Visibility: "private", - Stargazers: 42, - OpenIssueCount: 7, - } - repository.Owner.Type = "Organization" - model := New(t.Context(), &fakeService{sourceRepositoryDetails: []elmapi.Repository{repository}}) - model.width = 120 - model.height = 40 - - updated, command := model.openSourceCreateForm(screenHome) - model = updated.(*Model) - require.NotNil(t, command) - updated, _ = model.Update(command()) - model = updated.(*Model) - - view := model.pickerView() - assert.Contains(t, view, "★ 42") - assert.Contains(t, view, "≡ 7") - assert.Contains(t, view, "◆ Go") - assert.Contains(t, view, "Public API for Acme products.") - - model.width = 80 - updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) - model = updated.(*Model) - assert.True(t, model.pickerInfoOpen) - assert.Contains(t, model.View(), "Open issues") - }) - - t.Run("repository details remain visible after the picker scrolls", func(t *testing.T) { - repositories := make([]elmapi.Repository, 20) - for index := range repositories { - repositories[index].FullName = fmt.Sprintf("acme/repo-%02d", index) - repositories[index].Description = fmt.Sprintf("Repository %02d", index) - } - model := New(t.Context(), &fakeService{sourceRepositoryDetails: repositories}) - model.width = 120 - model.height = 16 - updated, command := model.openSourceCreateForm(screenHome) - model = updated.(*Model) - updated, _ = model.Update(command()) - model = updated.(*Model) - model.picker.cursor = len(repositories) - 1 - - view := model.pickerView() - - assert.Contains(t, view, "↑") - assert.Contains(t, view, "Repository 19") - }) - require.NoError(t, err) + *model.form.fields[0].text = "renamed-api" + *model.form.fields[1].text = "private" + *model.form.fields[2].boolean = true + model.form.cursor = len(model.form.fields) - 1 + updated, command = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(*Model) require.NotNil(t, command) - _ = command() + message := command() + assert.Empty(t, model.sourceID) + updated, _ = model.Update(message) + model = updated.(*Model) + + assert.Equal(t, screenResult, model.screen) + assert.Equal(t, workflow.SourceMigrationID("created-1"), model.sourceID) assert.Equal(t, workflow.SourceCreateInput{ SourceOwner: "acme", SourceRepo: "api", @@ -626,17 +724,69 @@ func TestModel(t *testing.T) { TargetRepo: "renamed-api", Visibility: "private", Start: true, - }, svc.sourceCreateInput) + }, sourceCreateInput) + }) - updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEscape}) - model = updated.(*Model) - assert.Equal(t, screenPicker, model.screen) - assert.Equal(t, "Select destination organization", model.picker.title) + t.Run("repository picker presents real metadata", func(t *testing.T) { + repository := elmapi.Repository{ + FullName: "acme/api", + Description: "Public API for Acme products.", + Language: "Go", + Visibility: "private", + Stargazers: 42, + OpenIssueCount: 7, + } + repository.Owner.Type = "Organization" + model := New(t.Context(), &fakeService{ + listSourceRepositories: func(context.Context) ([]elmapi.Repository, error) { + return []elmapi.Repository{repository}, nil + }, + }) + model.width = 120 + model.height = 40 - updated, command = model.Update(tea.KeyMsg{Type: tea.KeyEscape}) + updated, command := model.openSourceCreateForm(screenHome) model = updated.(*Model) require.NotNil(t, command) - assert.Equal(t, "Select source repository", model.picker.title) + updated, _ = model.Update(command()) + model = updated.(*Model) + + view := model.pickerView() + assert.Contains(t, view, "★ 42") + assert.Contains(t, view, "≡ 7") + assert.Contains(t, view, "◆ Go") + assert.Contains(t, view, "Public API for Acme products.") + + model.width = 80 + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}) + model = updated.(*Model) + assert.True(t, model.pickerInfoOpen) + assert.Contains(t, model.View(), "Open issues") + }) + + t.Run("repository details remain visible after the picker scrolls", func(t *testing.T) { + repositories := make([]elmapi.Repository, 20) + for index := range repositories { + repositories[index].FullName = fmt.Sprintf("acme/repo-%02d", index) + repositories[index].Description = fmt.Sprintf("Repository %02d", index) + } + model := New(t.Context(), &fakeService{ + listSourceRepositories: func(context.Context) ([]elmapi.Repository, error) { + return repositories, nil + }, + }) + model.width = 120 + model.height = 16 + updated, command := model.openSourceCreateForm(screenHome) + model = updated.(*Model) + updated, _ = model.Update(command()) + model = updated.(*Model) + model.picker.cursor = len(repositories) - 1 + + view := model.pickerView() + + assert.Contains(t, view, "↑") + assert.Contains(t, view, "Repository 19") }) t.Run("repository picker filters options", func(t *testing.T) { @@ -682,7 +832,7 @@ func TestModel(t *testing.T) { model = updated.(*Model) require.Equal(t, screenForm, model.screen) - assert.Equal(t, "acme/api", model.form.fields[0].value) + assert.Equal(t, "acme/api", *model.form.fields[0].text) }) t.Run("repository picker ignores stale catalog responses", func(t *testing.T) { @@ -690,9 +840,8 @@ func TestModel(t *testing.T) { model.screen = screenPicker model.pickerGeneration = 2 model.picker = pickerState{ - generation: 2, - loading: true, - input: textinput.New(), + loading: true, + input: textinput.New(), } updated, _ := model.Update(pickerCatalogMsg{ @@ -706,7 +855,15 @@ func TestModel(t *testing.T) { }) t.Run("manual migration creation uses source and target coordinates", func(t *testing.T) { - svc := &fakeService{} + var sourceCreateInput workflow.SourceCreateInput + svc := &fakeService{ + createSourceMigration: func(_ context.Context, input workflow.SourceCreateInput) (*workflow.SourceCreateResult, error) { + sourceCreateInput = input + return &workflow.SourceCreateResult{ + Migration: elmapi.CreateMigrationResponse{MigrationID: "created-1"}, + }, nil + }, + } model := New(t.Context(), svc) updated, _ := model.openManualSourceCreateForm(screenHome, "") model = updated.(*Model) @@ -717,16 +874,19 @@ func TestModel(t *testing.T) { assert.Contains(t, model.formView(), "source-org/source-repo") assert.Contains(t, model.formView(), "target-org/target-repo") - command, err := model.form.submit(map[string]string{ - "source": "source-org/source-repo", - "target": "target-org/target-repo", - "visibility": "internal", - "start": "true", - }) - require.NoError(t, err) + *model.form.fields[0].text = "source-org/source-repo" + *model.form.fields[1].text = "target-org/target-repo" + *model.form.fields[2].text = "internal" + *model.form.fields[3].boolean = true + model.form.cursor = len(model.form.fields) - 1 + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(*Model) require.NotNil(t, command) - _ = command() + updated, _ = model.Update(command()) + model = updated.(*Model) + assert.Equal(t, screenResult, model.screen) + assert.Equal(t, workflow.SourceMigrationID("created-1"), model.sourceID) assert.Equal(t, workflow.SourceCreateInput{ SourceOwner: "source-org", SourceRepo: "source-repo", @@ -734,7 +894,7 @@ func TestModel(t *testing.T) { TargetRepo: "target-repo", Visibility: "internal", Start: true, - }, svc.sourceCreateInput) + }, sourceCreateInput) }) t.Run("manual migration creation rejects malformed coordinates", func(t *testing.T) { @@ -742,16 +902,19 @@ func TestModel(t *testing.T) { updated, _ := model.openManualSourceCreateForm(screenHome, "") model = updated.(*Model) - command, err := model.form.submit(map[string]string{ - "source": "source-org/source-repo/extra", - "target": "target-org/target-repo", - }) + *model.form.fields[0].text = "source-org/source-repo/extra" + *model.form.fields[1].text = "target-org/target-repo" + model.form.cursor = len(model.form.fields) - 1 + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(*Model) assert.Nil(t, command) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid source repository") + require.Error(t, model.form.err) + assert.Contains(t, model.form.err.Error(), "invalid source repository") }) +} +func TestModelActions(t *testing.T) { t.Run("destructive source action requires confirmation", func(t *testing.T) { model := New(t.Context(), &fakeService{}) model.screen = screenSourceDetail @@ -865,15 +1028,21 @@ func TestModel(t *testing.T) { }) t.Run("immediate mannequin reclaim requires confirmation", func(t *testing.T) { - svc := &fakeService{} + reclaimCalls := 0 + svc := &fakeService{ + reclaimMannequins: func(context.Context, workflow.MannequinReclaimInput, ghapi.Logger) error { + reclaimCalls++ + return nil + }, + } model := New(t.Context(), svc) model.screen = screenMannequins updated, _ := model.openMannequinReclaimForm(false) model = updated.(*Model) - model.form.fields[0].value = "octo-org" - model.form.fields[1].value = "mannequin" - model.form.fields[3].value = "app[bot]" + *model.form.fields[0].text = "octo-org" + *model.form.fields[1].text = "mannequin" + *model.form.fields[3].text = "app[bot]" model.form.cursor = len(model.form.fields) - 1 updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) @@ -884,13 +1053,13 @@ func TestModel(t *testing.T) { model = updated.(*Model) require.Equal(t, screenConfirm, model.screen) assert.Contains(t, model.View(), "cannot be undone") - assert.Equal(t, 0, svc.reclaimCalls) + assert.Zero(t, reclaimCalls) updated, cmd = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}) model = updated.(*Model) require.NotNil(t, cmd) _, _ = model.Update(cmd()) - assert.Equal(t, 1, svc.reclaimCalls) + assert.Equal(t, 1, reclaimCalls) }) t.Run("uppercase [BOT] target still requires the irreversible confirmation", func(t *testing.T) { @@ -954,142 +1123,97 @@ func actionIDs(actions []actionItem) []string { } type fakeService struct { - sourceMigrations []elmapi.MigrationSummary - sourceRepositories []string - sourceRepositoryDetails []elmapi.Repository - sourceDetail *elmapi.MigrationDetail - sourceCreateInput workflow.SourceCreateInput - targetOrganizations []string - listTargetMigrations func(context.Context) ([]elmapi.TargetMigration, error) - reclaimCalls int - sourceAuthErr error - targetAuthErr error + service + listSourceMigrations func(context.Context, string) ([]elmapi.MigrationSummary, error) + getSourceMigration func(context.Context, workflow.SourceMigrationID) (*elmapi.MigrationDetail, error) + createSourceMigration func(context.Context, workflow.SourceCreateInput) (*workflow.SourceCreateResult, error) + listSourceRepositories func(context.Context) ([]elmapi.Repository, error) + listTargetOrganizations func(context.Context) ([]string, error) + listTargetMigrations func(context.Context, string, int) ([]elmapi.TargetMigration, error) + getTargetMigration func(context.Context, workflow.TargetMigrationID) (*elmapi.TargetMigration, error) + reclaimMannequins func(context.Context, workflow.MannequinReclaimInput, ghapi.Logger) error + getConfiguration func(context.Context) (*workflow.Configuration, error) + saveConfiguration func(context.Context, workflow.ConfigurationInput) error + resetConfiguration func(context.Context) error } -func (f *fakeService) ListSourceMigrations(context.Context, string) ([]elmapi.MigrationSummary, error) { - return f.sourceMigrations, nil +func unexpectedServiceCall(name string) { + panic("unexpected service call: " + name) } -func (f *fakeService) ListSourceRepositories(context.Context) ([]elmapi.Repository, error) { - if f.sourceRepositoryDetails != nil { - return f.sourceRepositoryDetails, nil - } - repositories := make([]elmapi.Repository, len(f.sourceRepositories)) - for index, name := range f.sourceRepositories { - repositories[index].FullName = name +func (f *fakeService) ListSourceMigrations(ctx context.Context, status string) ([]elmapi.MigrationSummary, error) { + if f.listSourceMigrations == nil { + unexpectedServiceCall("ListSourceMigrations") } - return repositories, nil -} - -func (f *fakeService) GetSourceMigration(context.Context, workflow.SourceMigrationID) (*elmapi.MigrationDetail, error) { - return f.sourceDetail, nil -} - -func (f *fakeService) CreateSourceMigration(_ context.Context, input workflow.SourceCreateInput) (*workflow.SourceCreateResult, error) { - f.sourceCreateInput = input - return &workflow.SourceCreateResult{}, nil -} - -func (f *fakeService) StartSourceMigration(context.Context, workflow.SourceMigrationID) error { - return nil -} - -func (f *fakeService) PauseSourceMigration(context.Context, workflow.SourceMigrationID) error { - return nil -} - -func (f *fakeService) ResumeSourceMigration(context.Context, workflow.SourceMigrationID) error { - return nil -} - -func (f *fakeService) CancelSourceMigration(context.Context, workflow.SourceMigrationID) error { - return nil -} - -func (f *fakeService) CutoverSourceMigration(context.Context, workflow.SourceMigrationID, bool) error { - return nil -} - -func (f *fakeService) RevertSourceCutover(context.Context, workflow.SourceMigrationID) (*elmapi.RevertCutoverResponse, error) { - return &elmapi.RevertCutoverResponse{}, nil + return f.listSourceMigrations(ctx, status) } -func (f *fakeService) ListTargetMigrations(ctx context.Context, _ string, _ int) ([]elmapi.TargetMigration, error) { - if f.listTargetMigrations != nil { - return f.listTargetMigrations(ctx) +func (f *fakeService) ListSourceRepositories(ctx context.Context) ([]elmapi.Repository, error) { + if f.listSourceRepositories == nil { + unexpectedServiceCall("ListSourceRepositories") } - return nil, nil + return f.listSourceRepositories(ctx) } -func (f *fakeService) ListTargetOrganizations(context.Context) ([]string, error) { - return f.targetOrganizations, nil -} - -func (f *fakeService) CreateTargetMigration(context.Context, workflow.TargetCreateInput) (json.RawMessage, error) { - return nil, nil -} - -func (f *fakeService) GetTargetMigration(context.Context, workflow.TargetMigrationID) (*elmapi.TargetMigration, error) { - return &elmapi.TargetMigration{}, nil -} - -func (f *fakeService) PauseTargetMigration(context.Context, workflow.TargetMigrationID) error { - return nil -} - -func (f *fakeService) ResumeTargetMigration(context.Context, workflow.TargetMigrationID) error { - return nil -} - -func (f *fakeService) AbortTargetMigration(context.Context, workflow.TargetMigrationID) error { - return nil -} - -func (f *fakeService) ListResources(context.Context, workflow.ResourceInput) ([]elmapi.Node, error) { - return nil, nil -} - -func (f *fakeService) RequestReport(context.Context, workflow.ReportInput) (json.RawMessage, error) { - return nil, nil -} - -func (f *fakeService) ReportStatus(context.Context, workflow.ReportInput) (json.RawMessage, error) { - return nil, nil -} - -func (f *fakeService) ReportURL(context.Context, workflow.ReportInput) (json.RawMessage, error) { - return nil, nil +func (f *fakeService) GetSourceMigration(ctx context.Context, id workflow.SourceMigrationID) (*elmapi.MigrationDetail, error) { + if f.getSourceMigration == nil { + unexpectedServiceCall("GetSourceMigration") + } + return f.getSourceMigration(ctx, id) } -func (f *fakeService) ListMannequins(context.Context, string, bool) ([]ghapi.MannequinRecord, error) { - return nil, nil +func (f *fakeService) CreateSourceMigration(ctx context.Context, input workflow.SourceCreateInput) (*workflow.SourceCreateResult, error) { + if f.createSourceMigration == nil { + unexpectedServiceCall("CreateSourceMigration") + } + return f.createSourceMigration(ctx, input) } -func (f *fakeService) ExportMannequins(context.Context, string, string, bool) error { - return nil +func (f *fakeService) ListTargetMigrations(ctx context.Context, status string, maxResults int) ([]elmapi.TargetMigration, error) { + if f.listTargetMigrations == nil { + unexpectedServiceCall("ListTargetMigrations") + } + return f.listTargetMigrations(ctx, status, maxResults) } -func (f *fakeService) ReclaimMannequins(context.Context, workflow.MannequinReclaimInput, ghapi.Logger) error { - f.reclaimCalls++ - return nil +func (f *fakeService) ListTargetOrganizations(ctx context.Context) ([]string, error) { + if f.listTargetOrganizations == nil { + unexpectedServiceCall("ListTargetOrganizations") + } + return f.listTargetOrganizations(ctx) } -func (f *fakeService) GetConfiguration(context.Context) (*workflow.Configuration, error) { - return &workflow.Configuration{}, nil +func (f *fakeService) GetTargetMigration(ctx context.Context, id workflow.TargetMigrationID) (*elmapi.TargetMigration, error) { + if f.getTargetMigration == nil { + unexpectedServiceCall("GetTargetMigration") + } + return f.getTargetMigration(ctx, id) } -func (f *fakeService) CheckSourceAuthentication(context.Context) error { - return f.sourceAuthErr +func (f *fakeService) ReclaimMannequins(ctx context.Context, input workflow.MannequinReclaimInput, logger ghapi.Logger) error { + if f.reclaimMannequins == nil { + unexpectedServiceCall("ReclaimMannequins") + } + return f.reclaimMannequins(ctx, input, logger) } -func (f *fakeService) CheckTargetAuthentication(context.Context) error { - return f.targetAuthErr +func (f *fakeService) GetConfiguration(ctx context.Context) (*workflow.Configuration, error) { + if f.getConfiguration == nil { + unexpectedServiceCall("GetConfiguration") + } + return f.getConfiguration(ctx) } -func (f *fakeService) SaveConfiguration(context.Context, workflow.ConfigurationInput) error { - return nil +func (f *fakeService) SaveConfiguration(ctx context.Context, input workflow.ConfigurationInput) error { + if f.saveConfiguration == nil { + unexpectedServiceCall("SaveConfiguration") + } + return f.saveConfiguration(ctx, input) } -func (f *fakeService) ResetConfiguration(context.Context) error { - return nil +func (f *fakeService) ResetConfiguration(ctx context.Context) error { + if f.resetConfiguration == nil { + unexpectedServiceCall("ResetConfiguration") + } + return f.resetConfiguration(ctx) } diff --git a/internal/tui/view.go b/internal/tui/view.go index 52b731b..107833e 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -31,14 +31,7 @@ func (m *Model) View() string { switch current { case screenHome: title = homeTitle - body = m.menu([]string{ - "Migrations", - "Create migration", - "Target mannequins", - "Configuration", - "Advanced destination operations", - "Quit", - }) + body = m.menu(homeActions) help = helpLine(keys.Up, keys.Down, keys.Open, keys.Help, keys.Quit) case screenSourceList: title = "Migrations" @@ -220,13 +213,13 @@ func (m *Model) configurationWarning() string { return strings.Join(issues, ". ") + ". Open Configuration to finish setup." } -func (m *Model) menu(items []string) string { +func (m *Model) menu(items []actionItem) string { var builder strings.Builder for index, item := range items { if index > 0 { builder.WriteString("\n") } - builder.WriteString(m.selectorCard(item, index == m.cursor, true)) + builder.WriteString(m.selectorCard(item.label, index == m.cursor, true)) } return builder.String() } @@ -470,12 +463,15 @@ func (m *Model) formView() string { } blocks := make([]string, 0, len(m.form.fields)) for index, field := range m.form.fields { - value := field.value + value := "" + if field.text != nil { + value = *field.text + } switch field.kind { case fieldSecret: value = strings.Repeat("•", len([]rune(value))) case fieldBool: - if value == "true" { + if field.boolean != nil && *field.boolean { value = "[x]" } else { value = "[ ]" From aa28773604b610444366573cc71e71544f727684 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Fri, 28 Aug 2026 14:42:34 +0200 Subject: [PATCH 06/32] Add configuration form actions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/tui/model.go | 52 ++++++++++++++++++++++++++++++++------ internal/tui/model_test.go | 27 +++++++++++++++++++- internal/tui/view.go | 16 +++++++++--- 3 files changed, 83 insertions(+), 12 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index a2750e0..31a0167 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -119,7 +119,9 @@ type formState struct { title string description string fields []formField + actions []actionItem cursor int + actionFocus int parent screen submit func() (tea.Cmd, error) err error @@ -1247,8 +1249,10 @@ func (m *Model) openForm(form formState) (tea.Model, tea.Cmd) { } func (m *Model) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - field := &m.form.fields[m.form.cursor] - if msg.Type == tea.KeyRunes { + actionRow := len(m.form.fields) + onActions := len(m.form.actions) > 0 && m.form.cursor == actionRow + if msg.Type == tea.KeyRunes && !onActions { + field := &m.form.fields[m.form.cursor] if field.text != nil && (field.kind == fieldText || field.kind == fieldSecret) { *field.text += string(msg.Runes) return m, nil @@ -1262,18 +1266,38 @@ func (m *Model) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.form.cursor-- } case "down", "tab": - if m.form.cursor < len(m.form.fields)-1 { + if m.form.cursor < len(m.form.fields)-1 || len(m.form.actions) > 0 && m.form.cursor < actionRow { m.form.cursor++ } case "left": - cycleOption(field, -1) - case "right", " ": - if field.boolean != nil { - *field.boolean = !*field.boolean + if onActions { + m.form.actionFocus = max(0, m.form.actionFocus-1) + } else { + cycleOption(&m.form.fields[m.form.cursor], -1) + } + case "right": + if onActions { + m.form.actionFocus = min(len(m.form.actions)-1, m.form.actionFocus+1) } else { - cycleOption(field, 1) + field := &m.form.fields[m.form.cursor] + if field.boolean != nil { + *field.boolean = !*field.boolean + } else { + cycleOption(field, 1) + } + } + case " ": + if !onActions { + field := &m.form.fields[m.form.cursor] + if field.boolean != nil { + *field.boolean = !*field.boolean + } } case "backspace": + if onActions { + return m, nil + } + field := &m.form.fields[m.form.cursor] if field.text != nil && (field.kind == fieldText || field.kind == fieldSecret) && *field.text != "" { runes := []rune(*field.text) *field.text = string(runes[:len(runes)-1]) @@ -1283,6 +1307,14 @@ func (m *Model) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.form.cursor++ return m, nil } + if len(m.form.actions) > 0 && !onActions { + m.form.cursor = actionRow + return m, nil + } + if onActions && m.form.actions[m.form.actionFocus].id == "cancel" { + m.screen = m.form.parent + return m, nil + } command, err := m.form.submit() if err != nil { m.form.err = err @@ -1773,6 +1805,10 @@ func (m *Model) openConfigurationForm() (tea.Model, tea.Cmd) { textFormField("Target URL", "", &targetURL), secretFormField("Target token (blank preserves current)", &targetToken), }, + actions: []actionItem{ + {id: "save", label: "Save"}, + {id: "cancel", label: "Cancel"}, + }, submit: func() (tea.Cmd, error) { input := workflow.ConfigurationInput{ SourceURL: sourceURL, diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index b94a69e..cd473d8 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -317,7 +317,7 @@ func TestModelUpdate(t *testing.T) { model.sourceMigrations = []elmapi.MigrationSummary{{MigrationID: "old-source"}} updated, _ := model.openConfigurationForm() model = updated.(*Model) - model.form.cursor = len(model.form.fields) - 1 + model.form.cursor = len(model.form.fields) updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) @@ -345,6 +345,31 @@ func TestModelUpdate(t *testing.T) { assert.True(t, model.sourceListLoaded) }) + t.Run("configuration form offers save and cancel buttons", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.screen = screenConfiguration + updated, _ := model.openConfigurationForm() + model = updated.(*Model) + + view := model.formView() + assert.Contains(t, view, "Save") + assert.Contains(t, view, "Cancel") + + model.form.cursor = len(model.form.fields) - 1 + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(*Model) + assert.Nil(t, command) + assert.Equal(t, len(model.form.fields), model.form.cursor) + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model = updated.(*Model) + updated, command = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(*Model) + + assert.Nil(t, command) + assert.Equal(t, screenConfiguration, model.screen) + }) + t.Run("configuration reset invalidates source migrations", func(t *testing.T) { svc := &fakeService{ resetConfiguration: func(context.Context) error { diff --git a/internal/tui/view.go b/internal/tui/view.go index 107833e..a841d04 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -75,6 +75,9 @@ func (m *Model) View() string { title = m.form.title body = m.formView() help = "tab/↑/↓ fields • type edit • ←/→ choose • space toggle • enter continue • esc cancel" + if len(m.form.actions) > 0 { + help = "tab/↑/↓ fields/actions • type edit • ←/→ choose • space toggle • enter activate • esc cancel" + } case screenResult: title = m.result.title body = m.result.body @@ -461,7 +464,7 @@ func (m *Model) formView() string { prefix.WriteString(m.styles.Muted.Render(m.form.description)) prefix.WriteString("\n\n") } - blocks := make([]string, 0, len(m.form.fields)) + blocks := make([]string, 0, len(m.form.fields)+1) for index, field := range m.form.fields { value := "" if field.text != nil { @@ -493,6 +496,9 @@ func (m *Model) formView() string { } blocks = append(blocks, m.selectorCard(content.String(), index == m.form.cursor, true)) } + if len(m.form.actions) > 0 { + blocks = append(blocks, m.actionButtons(m.form.actions, m.form.actionFocus, m.contentWidth())) + } var suffix string if m.form.err != nil { @@ -508,14 +514,18 @@ func (m *Model) formView() string { } available := m.bodyHeight() - reserved start, end := focusedBlockRange(blocks, m.form.cursor, max(1, available-2)) + moreLabel := "field(s)" + if len(m.form.actions) > 0 { + moreLabel = "item(s)" + } var builder strings.Builder builder.WriteString(prefix.String()) if start > 0 { - builder.WriteString(m.styles.Muted.Render(fmt.Sprintf("↑ %d more field(s)", start)) + "\n") + builder.WriteString(m.styles.Muted.Render(fmt.Sprintf("↑ %d more %s", start, moreLabel)) + "\n") } builder.WriteString(strings.Join(blocks[start:end], "\n")) if end < len(blocks) { - builder.WriteString("\n" + m.styles.Muted.Render(fmt.Sprintf("↓ %d more field(s)", len(blocks)-end))) + builder.WriteString("\n" + m.styles.Muted.Render(fmt.Sprintf("↓ %d more %s", len(blocks)-end, moreLabel))) } builder.WriteString(suffix) return builder.String() From 6c663a614975c8b53e3001ee33fb8860c90e7e0f Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Fri, 28 Aug 2026 14:50:44 +0200 Subject: [PATCH 07/32] Disable TUI actions until configured Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/tui/model.go | 56 +++++++++++++++++++++++++++++++++++--- internal/tui/model_test.go | 51 ++++++++++++++++++++++++++++++++++ internal/tui/view.go | 8 ++++-- 3 files changed, 109 insertions(+), 6 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 31a0167..3c6971c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "os" + "slices" "strconv" "strings" "time" @@ -491,11 +492,15 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.actionFocus++ } case key.Matches(msg, keys.Up): - if !m.actionScreen() && m.cursor > 0 { + if m.screen == screenHome { + m.moveHomeCursor(-1) + } else if !m.actionScreen() && m.cursor > 0 { m.cursor-- } case key.Matches(msg, keys.Down): - if !m.actionScreen() && m.cursor < m.itemCount()-1 { + if m.screen == screenHome { + m.moveHomeCursor(1) + } else if !m.actionScreen() && m.cursor < m.itemCount()-1 { m.cursor++ } case key.Matches(msg, keys.Refresh): @@ -662,10 +667,11 @@ func (m *Model) visiblePickerItems() []pickerItem { func (m *Model) activate() (tea.Model, tea.Cmd) { switch m.screen { case screenHome: - if m.cursor < 0 || m.cursor >= len(homeActions) { + actions := m.homeActionItems() + if m.cursor < 0 || m.cursor >= len(actions) || actions[m.cursor].disabled { return m, nil } - switch homeActions[m.cursor].id { + switch actions[m.cursor].id { case "migrations": m.screen, m.err = screenSourceList, m.sourceListErr switch { @@ -832,6 +838,7 @@ type actionItem struct { id string label string shortcut string + disabled bool } var homeActions = []actionItem{ @@ -843,6 +850,47 @@ var homeActions = []actionItem{ {id: "quit", label: "Quit"}, } +func (m *Model) homeActionItems() []actionItem { + actions := slices.Clone(homeActions) + if m.configurationReady() { + return actions + } + for index := range actions { + switch actions[index].id { + case "configuration", "quit": + default: + actions[index].disabled = true + } + } + return actions +} + +func (m *Model) configurationReady() bool { + if m.configuration == nil || m.configurationErr != nil { + return false + } + sourceURL, sourceTokenSet := effectiveSourceConfiguration(m.configuration) + targetURL, targetTokenSet := effectiveTargetConfiguration(m.configuration) + return validHTTPURL(sourceURL) && + sourceTokenSet && + validHTTPURL(targetURL) && + targetTokenSet && + m.sourceAuthChecked && + m.sourceAuthErr == nil && + m.targetAuthChecked && + m.targetAuthErr == nil +} + +func (m *Model) moveHomeCursor(delta int) { + actions := m.homeActionItems() + for index := m.cursor + delta; index >= 0 && index < len(actions); index += delta { + if !actions[index].disabled { + m.cursor = index + return + } + } +} + func (m *Model) activateSourceAction() (tea.Model, tea.Cmd) { actions := m.sourceActionItems() if m.actionFocus < 0 || m.actionFocus >= len(actions) { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index cd473d8..5f9178d 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -18,8 +18,43 @@ import ( ) func TestModelUpdate(t *testing.T) { + t.Run("home disables configuration-dependent actions until preflight passes", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + + actions := model.homeActionItems() + for _, action := range actions { + if action.id == "configuration" || action.id == "quit" { + assert.False(t, action.disabled) + } else { + assert.True(t, action.disabled) + } + } + assert.Equal(t, 4, strings.Count(model.View(), "(disabled)")) + + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(*Model) + assert.Nil(t, command) + assert.Equal(t, screenHome, model.screen) + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) + model = updated.(*Model) + assert.Equal(t, 3, model.cursor) + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) + model = updated.(*Model) + assert.Equal(t, 5, model.cursor) + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyUp}) + model = updated.(*Model) + assert.Equal(t, 3, model.cursor) + + setConfigurationReady(model) + for _, action := range model.homeActionItems() { + assert.False(t, action.disabled) + } + }) + t.Run("background prefetch errors stay off the home screen", func(t *testing.T) { model := New(t.Context(), &fakeService{}) + setConfigurationReady(model) updated, _ := model.Update(sourceListMsg{err: assert.AnError}) model = updated.(*Model) @@ -33,6 +68,7 @@ func TestModelUpdate(t *testing.T) { t.Run("uses prefetched migrations without another request", func(t *testing.T) { model := New(t.Context(), &fakeService{}) + setConfigurationReady(model) updated, _ := model.Update(sourceListMsg{migrations: []elmapi.MigrationSummary{{MigrationID: "source-1"}}}) model = updated.(*Model) @@ -62,6 +98,7 @@ func TestModelUpdate(t *testing.T) { t.Run("configuration response does not unlock a pending migration list", func(t *testing.T) { model := New(t.Context(), &fakeService{}) + setConfigurationReady(model) model.sourceListLoading = true updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) @@ -156,6 +193,7 @@ func TestModelUpdate(t *testing.T) { }, } model := New(t.Context(), service) + setConfigurationReady(model) model.cursor = 4 updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) @@ -424,6 +462,7 @@ func TestModelNavigationAndLayout(t *testing.T) { }, } model := New(t.Context(), svc) + setConfigurationReady(model) _, _ = model.Update(tea.WindowSizeMsg{Width: 100, Height: 60}) updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) @@ -679,6 +718,7 @@ func TestModelNavigationAndLayout(t *testing.T) { func TestMigrationCreation(t *testing.T) { t.Run("home exposes migration creation", func(t *testing.T) { model := New(t.Context(), &fakeService{}) + setConfigurationReady(model) assert.Contains(t, model.View(), "Create migration") model.cursor = 1 @@ -1139,6 +1179,17 @@ func setSourceStatus(model *Model, status string) { } } +func setConfigurationReady(model *Model) { + model.configuration = &workflow.Configuration{ + SourceURL: "https://source.example", + SourceTokenSet: true, + TargetURL: "https://target.example", + TargetTokenSet: true, + } + model.sourceAuthChecked = true + model.targetAuthChecked = true +} + func actionIDs(actions []actionItem) []string { ids := make([]string, len(actions)) for index, action := range actions { diff --git a/internal/tui/view.go b/internal/tui/view.go index a841d04..daef330 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -31,7 +31,7 @@ func (m *Model) View() string { switch current { case screenHome: title = homeTitle - body = m.menu(homeActions) + body = m.menu(m.homeActionItems()) help = helpLine(keys.Up, keys.Down, keys.Open, keys.Help, keys.Quit) case screenSourceList: title = "Migrations" @@ -222,7 +222,11 @@ func (m *Model) menu(items []actionItem) string { if index > 0 { builder.WriteString("\n") } - builder.WriteString(m.selectorCard(item.label, index == m.cursor, true)) + label := item.label + if item.disabled { + label = m.styles.Muted.Render(label + " (disabled)") + } + builder.WriteString(m.selectorCard(label, index == m.cursor, true)) } return builder.String() } From 564928a44635f320bd6eb400975256cde19896e8 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Fri, 28 Aug 2026 16:22:07 +0200 Subject: [PATCH 08/32] Refine configuration TUI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/theme/theme.go | 4 ++ internal/theme/theme_test.go | 7 ++- internal/tui/components.go | 2 +- internal/tui/model.go | 52 +++++++++++++++++---- internal/tui/model_test.go | 73 ++++++++++++++++++++++++++---- internal/tui/view.go | 88 ++++++++++++++++++++++++++---------- 6 files changed, 182 insertions(+), 44 deletions(-) diff --git a/internal/theme/theme.go b/internal/theme/theme.go index 263495c..125b928 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -14,6 +14,7 @@ const ( colorBlue = lipgloss.Color("4") colorPlaceholder = lipgloss.Color("238") colorMuted = lipgloss.Color("242") + colorDisabled = lipgloss.Color("244") colorSecondary = lipgloss.Color("245") // githubBlue is GitHub's accent blue. @@ -61,6 +62,8 @@ type Styles struct { Secondary lipgloss.Style // Muted de-emphasises secondary text: timestamps, hints, pending items. Muted lipgloss.Style + // Disabled marks unavailable controls. + Disabled lipgloss.Style // Placeholder de-emphasises example input beneath surrounding help text. Placeholder lipgloss.Style // Success marks a completed or passing item. @@ -89,6 +92,7 @@ func New() Styles { Info: lipgloss.NewStyle().Foreground(colorBlue), Secondary: lipgloss.NewStyle().Foreground(colorSecondary), Muted: lipgloss.NewStyle().Foreground(colorMuted), + Disabled: lipgloss.NewStyle().Foreground(colorDisabled), Placeholder: lipgloss.NewStyle().Foreground(colorPlaceholder), Success: lipgloss.NewStyle().Foreground(colorGreen), Active: lipgloss.NewStyle().Foreground(colorGreen), diff --git a/internal/theme/theme_test.go b/internal/theme/theme_test.go index 5b45ebb..ffb2a84 100644 --- a/internal/theme/theme_test.go +++ b/internal/theme/theme_test.go @@ -20,6 +20,7 @@ func TestNew(t *testing.T) { assert.Equal(t, githubRed, s.Failure.GetForeground()) assert.Equal(t, colorBlue, s.Info.GetForeground()) assert.Equal(t, colorSecondary, s.Secondary.GetForeground()) + assert.Equal(t, colorDisabled, s.Disabled.GetForeground()) assert.Equal(t, colorPlaceholder, s.Placeholder.GetForeground()) assert.Equal(t, colorBlue, s.FocusedButton.GetBackground()) assert.Equal(t, colorButtonText, s.FocusedButton.GetForeground()) @@ -53,6 +54,10 @@ func TestNew(t *testing.T) { assert.Equal(t, lipgloss.Color("242"), s.Muted.GetForeground()) }) + t.Run("disabled is a lighter grey than muted", func(t *testing.T) { + assert.Equal(t, lipgloss.Color("244"), s.Disabled.GetForeground()) + }) + t.Run("emphasis carries no colour of its own", func(t *testing.T) { assert.True(t, s.Bold.GetBold()) assert.Equal(t, lipgloss.NoColor{}, s.Bold.GetForeground()) @@ -60,7 +65,7 @@ func TestNew(t *testing.T) { t.Run("other styles use fixed colours", func(t *testing.T) { for name, style := range map[string]lipgloss.Style{ - "Info": s.Info, "Secondary": s.Secondary, "Muted": s.Muted, + "Info": s.Info, "Secondary": s.Secondary, "Muted": s.Muted, "Disabled": s.Disabled, "Placeholder": s.Placeholder, "Success": s.Success, "Active": s.Active, "Failure": s.Failure, } { diff --git a/internal/tui/components.go b/internal/tui/components.go index b01fe50..b9c0c37 100644 --- a/internal/tui/components.go +++ b/internal/tui/components.go @@ -44,7 +44,7 @@ func (m *Model) actionButtons(items []actionItem, focus, width int) string { if len(items) == 0 { return "" } - if focus < 0 || focus >= len(items) { + if focus >= len(items) { focus = 0 } diff --git a/internal/tui/model.go b/internal/tui/model.go index 3c6971c..084a9c5 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -110,6 +110,7 @@ const ( type formField struct { label string description string + emptyValue string kind fieldKind text *string boolean *bool @@ -132,8 +133,12 @@ func textFormField(label, description string, value *string) formField { return formField{label: label, description: description, kind: fieldText, text: value} } -func secretFormField(label string, value *string) formField { - return formField{label: label, kind: fieldSecret, text: value} +func secretFormField(label string, value *string, present bool) formField { + field := formField{label: label, kind: fieldSecret, text: value} + if present { + field.emptyValue = "••••••••" + } + return field } func boolFormField(label string, value *bool) formField { @@ -189,6 +194,7 @@ type Model struct { width int height int cursor int + homeCursorSet bool actionFocus int loading bool err error @@ -240,7 +246,7 @@ func New(ctx context.Context, svc service) *Model { searchInput.Placeholder = "migration ID or repository" searchInput.CharLimit = 160 - return &Model{ + model := &Model{ ctx: ctx, service: svc, styles: theme.New(), @@ -248,6 +254,8 @@ func New(ctx context.Context, svc service) *Model { targetParent: screenTargetList, searchInput: searchInput, } + model.syncHomeCursor() + return model } // Init implements tea.Model. @@ -365,9 +373,11 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if validHTTPURL(targetURL) && targetTokenSet { commands = append(commands, m.checkTargetAuthenticationCmd(msg.generation)) } + m.syncHomeCursor() m.syncViewportSize() return m, tea.Batch(commands...) } + m.syncHomeCursor() m.syncViewportSize() case sourceAuthenticationMsg: if msg.generation != m.configGeneration { @@ -375,6 +385,7 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } m.sourceAuthChecked = true m.sourceAuthErr = msg.err + m.syncHomeCursor() m.syncViewportSize() case targetAuthenticationMsg: if msg.generation != m.configGeneration { @@ -382,6 +393,7 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } m.targetAuthChecked = true m.targetAuthErr = msg.err + m.syncHomeCursor() m.syncViewportSize() case pickerCatalogMsg: if msg.generation != m.pickerGeneration { @@ -766,6 +778,10 @@ func (m *Model) back() (tea.Model, tea.Cmd) { case screenTargetDetail: m.screen = m.targetParent } + if m.screen == screenHome { + m.homeCursorSet = false + m.syncHomeCursor() + } return m, nil } @@ -885,6 +901,26 @@ func (m *Model) moveHomeCursor(delta int) { actions := m.homeActionItems() for index := m.cursor + delta; index >= 0 && index < len(actions); index += delta { if !actions[index].disabled { + m.cursor = index + m.homeCursorSet = true + return + } + } +} + +func (m *Model) syncHomeCursor() { + if m.screen != screenHome { + return + } + actions := m.homeActionItems() + if m.homeCursorSet && + m.cursor >= 0 && + m.cursor < len(actions) && + !actions[m.cursor].disabled { + return + } + for index, action := range actions { + if !action.disabled { m.cursor = index return } @@ -1092,7 +1128,6 @@ func (m *Model) activateMannequinAction() (tea.Model, tea.Cmd) { } var configurationActions = []actionItem{ - {id: "refresh", label: "Refresh configuration", shortcut: "r"}, {id: "edit", label: "Edit configuration", shortcut: "e"}, {id: "reset", label: "Reset configuration", shortcut: "x"}, } @@ -1102,8 +1137,6 @@ func (m *Model) activateConfigurationAction() (tea.Model, tea.Cmd) { return m, nil } switch configurationActions[m.actionFocus].id { - case "refresh": - return m.refresh() case "edit": return m.openConfigurationForm() case "reset": @@ -1840,18 +1873,21 @@ func readReclaimCSV(path string) ([]ghapi.MannequinRecord, error) { func (m *Model) openConfigurationForm() (tea.Model, tea.Cmd) { sourceURL, sourceToken, targetURL, targetToken := "", "", "", "" + sourceTokenSet, targetTokenSet := false, false if m.configuration != nil { sourceURL = m.configuration.SourceURL + sourceTokenSet = m.configuration.SourceTokenSet targetURL = m.configuration.TargetURL + targetTokenSet = m.configuration.TargetTokenSet } return m.openForm(formState{ title: "Edit configuration", parent: screenConfiguration, fields: []formField{ textFormField("Source URL", "", &sourceURL), - secretFormField("Source token (blank preserves current)", &sourceToken), + secretFormField("Source token", &sourceToken, sourceTokenSet), textFormField("Target URL", "", &targetURL), - secretFormField("Target token (blank preserves current)", &targetToken), + secretFormField("Target token", &targetToken, targetTokenSet), }, actions: []actionItem{ {id: "save", label: "Save"}, diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 5f9178d..d57fd98 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -29,13 +29,17 @@ func TestModelUpdate(t *testing.T) { assert.True(t, action.disabled) } } - assert.Equal(t, 4, strings.Count(model.View(), "(disabled)")) + assert.NotContains(t, model.View(), "(disabled)") + assert.Contains(t, model.View(), model.styles.Disabled.Render("Migrations")) + assert.Equal(t, 3, model.cursor) + model.cursor = 0 updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) assert.Nil(t, command) assert.Equal(t, screenHome, model.screen) + model.cursor = 0 updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) model = updated.(*Model) assert.Equal(t, 3, model.cursor) @@ -47,6 +51,7 @@ func TestModelUpdate(t *testing.T) { assert.Equal(t, 3, model.cursor) setConfigurationReady(model) + assert.Zero(t, model.cursor) for _, action := range model.homeActionItems() { assert.False(t, action.disabled) } @@ -104,7 +109,10 @@ func TestModelUpdate(t *testing.T) { model = updated.(*Model) require.True(t, model.loading) - updated, _ = model.Update(configMsg{configuration: &workflow.Configuration{}}) + updated, _ = model.Update(configMsg{ + configuration: &workflow.Configuration{}, + generation: model.configGeneration, + }) model = updated.(*Model) assert.True(t, model.loading) @@ -228,10 +236,14 @@ func TestModelUpdate(t *testing.T) { view := model.configurationView() - assert.NotContains(t, view, "Source authentication") - assert.NotContains(t, view, "Destination authentication") - assert.Contains(t, view, "Source token") - assert.Contains(t, view, "Destination token") + assert.NotContains(t, view, "Source auth") + assert.NotContains(t, view, "Destination auth") + assert.NotContains(t, view, "Preflight") + assert.NotContains(t, view, "Stored configuration") + assert.Contains(t, view, "Source URL: https://source.example") + assert.Contains(t, view, "Source token: not set") + assert.Contains(t, view, "Destination URL: https://target.example") + assert.Contains(t, view, "Destination token: not set") }) t.Run("shows authentication rows when prerequisites are configured", func(t *testing.T) { @@ -242,11 +254,14 @@ func TestModelUpdate(t *testing.T) { TargetURL: "https://target.example", TargetTokenSet: true, } + model.sourceAuthChecked = true + model.targetAuthChecked = true view := model.configurationView() - assert.Contains(t, view, "Source authentication") - assert.Contains(t, view, "Destination authentication") + assert.Contains(t, view, "Source auth") + assert.Contains(t, view, "Destination auth") + assert.Equal(t, 2, strings.Count(view, "successful")) }) t.Run("hides warning when configuration is ready", func(t *testing.T) { @@ -277,6 +292,23 @@ func TestModelUpdate(t *testing.T) { assert.Contains(t, model.View(), "Failed source authentication") }) + t.Run("hides warning on configuration screen", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.screen = screenConfiguration + model.configuration = &workflow.Configuration{ + SourceURL: "https://source.example", + SourceTokenSet: true, + } + model.sourceAuthChecked = true + model.sourceAuthErr = assert.AnError + + view := model.View() + + assert.NotContains(t, view, "Configuration not ready") + assert.NotContains(t, view, "Open Configuration to finish setup") + assert.Contains(t, view, "Source auth") + }) + t.Run("failed source detail load clears previous migration state", func(t *testing.T) { model := New(t.Context(), &fakeService{}) model.screen = screenSourceDetail @@ -386,18 +418,28 @@ func TestModelUpdate(t *testing.T) { t.Run("configuration form offers save and cancel buttons", func(t *testing.T) { model := New(t.Context(), &fakeService{}) model.screen = screenConfiguration + model.configuration = &workflow.Configuration{ + SourceTokenSet: true, + TargetTokenSet: true, + } updated, _ := model.openConfigurationForm() model = updated.(*Model) view := model.formView() + assert.NotContains(t, view, "blank preserves current") + assert.Equal(t, 2, strings.Count(view, "••••••••")) + assert.Empty(t, *model.form.fields[1].text) + assert.Empty(t, *model.form.fields[3].text) assert.Contains(t, view, "Save") assert.Contains(t, view, "Cancel") + assert.Equal(t, -1, model.focusedFormAction()) model.form.cursor = len(model.form.fields) - 1 updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) assert.Nil(t, command) assert.Equal(t, len(model.form.fields), model.form.cursor) + assert.Zero(t, model.focusedFormAction()) updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) model = updated.(*Model) @@ -588,7 +630,10 @@ func TestModelNavigationAndLayout(t *testing.T) { updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) - updated, _ = model.Update(configMsg{configuration: &workflow.Configuration{}}) + updated, _ = model.Update(configMsg{ + configuration: &workflow.Configuration{}, + generation: model.configGeneration, + }) model = updated.(*Model) assert.Equal(t, screenConfiguration, model.screen) @@ -596,8 +641,14 @@ func TestModelNavigationAndLayout(t *testing.T) { assert.Contains( t, model.actionButtons(configurationActions, model.actionFocus, model.contentWidth()), - model.styles.FocusedButton.Padding(0, 2).Render("Refresh configuration r"), + model.styles.FocusedButton.Padding(0, 2).Render("Edit configuration e"), ) + assert.NotContains(t, actionIDs(configurationActions), "refresh") + + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + model = updated.(*Model) + assert.NotNil(t, command) + assert.True(t, model.loading) }) t.Run("action shortcuts focus and activate the matching button", func(t *testing.T) { @@ -1188,6 +1239,8 @@ func setConfigurationReady(model *Model) { } model.sourceAuthChecked = true model.targetAuthChecked = true + model.homeCursorSet = false + model.syncHomeCursor() } func actionIDs(actions []actionItem) []string { diff --git a/internal/tui/view.go b/internal/tui/view.go index daef330..0e0f1e7 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -168,6 +168,13 @@ func (m *Model) bodyHeight() int { } func (m *Model) configurationWarning() string { + current := m.screen + if current == screenConfirm { + current = m.confirm.parent + } + if current == screenConfiguration { + return "" + } if m.configurationErr != nil { return "Unable to load configuration: " + m.configurationErr.Error() } @@ -224,7 +231,7 @@ func (m *Model) menu(items []actionItem) string { } label := item.label if item.disabled { - label = m.styles.Muted.Render(label + " (disabled)") + label = m.styles.Disabled.Render(label) } builder.WriteString(m.selectorCard(label, index == m.cursor, true)) } @@ -393,26 +400,38 @@ func (m *Model) configurationView() string { if configuration := m.configuration; configuration != nil { sourceURL, sourceTokenSet := effectiveSourceConfiguration(configuration) targetURL, targetTokenSet := effectiveTargetConfiguration(configuration) - builder.WriteString(m.styles.Bold.Render("Preflight") + "\n") - fmt.Fprintf(&builder, " %s Source URL\n", m.checkMark(sourceURL != "" && validHTTPURL(sourceURL))) - fmt.Fprintf(&builder, " %s Source token\n", m.checkMark(sourceTokenSet)) + row := func(mark, label, value string) { + fmt.Fprintf(&builder, " %s %-18s %s\n", mark, label+":", value) + } + builder.WriteString(m.styles.Bold.Render("Configuration") + "\n") + row( + m.checkMark(sourceURL != "" && validHTTPURL(sourceURL)), + "Source URL", + orUnset(sourceURL), + ) + row(m.checkMark(sourceTokenSet), "Source token", setStatus(sourceTokenSet)) if validHTTPURL(sourceURL) && sourceTokenSet { - fmt.Fprintf(&builder, " %s Source authentication%s\n", m.authenticationMark(m.sourceAuthChecked, m.sourceAuthErr), m.authenticationDetail(m.sourceAuthChecked, m.sourceAuthErr)) + row( + m.authenticationMark(m.sourceAuthChecked, m.sourceAuthErr), + "Source auth", + m.authenticationDetail(m.sourceAuthChecked, m.sourceAuthErr), + ) } - fmt.Fprintf(&builder, " %s Destination URL\n", m.checkMark(targetURL != "" && validHTTPURL(targetURL))) - fmt.Fprintf(&builder, " %s Destination token\n", m.checkMark(targetTokenSet)) + row( + m.checkMark(targetURL != "" && validHTTPURL(targetURL)), + "Destination URL", + orUnset(targetURL), + ) + row(m.checkMark(targetTokenSet), "Destination token", setStatus(targetTokenSet)) if validHTTPURL(targetURL) && targetTokenSet { - fmt.Fprintf(&builder, " %s Destination authentication%s\n", m.authenticationMark(m.targetAuthChecked, m.targetAuthErr), m.authenticationDetail(m.targetAuthChecked, m.targetAuthErr)) + row( + m.authenticationMark(m.targetAuthChecked, m.targetAuthErr), + "Destination auth", + m.authenticationDetail(m.targetAuthChecked, m.targetAuthErr), + ) } - builder.WriteString("\n") - - builder.WriteString("Stored configuration\n") - fmt.Fprintf(&builder, "Source URL: %s\n", orUnset(configuration.SourceURL)) - fmt.Fprintf(&builder, "Source token: %s\n", setStatus(configuration.SourceTokenSet)) - fmt.Fprintf(&builder, "Target URL: %s\n", orUnset(configuration.TargetURL)) - fmt.Fprintf(&builder, "Target token: %s\n", setStatus(configuration.TargetTokenSet)) - fmt.Fprintf(&builder, "Config: %s\n", configuration.ConfigPath) - fmt.Fprintf(&builder, "Credentials: %s\n", configuration.CredentialStore) + row(" ", "Config", configuration.ConfigPath) + row(" ", "Credentials", configuration.CredentialStore) } builder.WriteString("\n" + m.styles.Bold.Render("Actions") + "\n\n") builder.WriteString(m.actionButtons(configurationActions, m.actionFocus, m.contentWidth())) @@ -454,12 +473,12 @@ func (m *Model) authenticationMark(checked bool, err error) string { func (m *Model) authenticationDetail(checked bool, err error) string { if !checked { - return m.styles.Muted.Render(" (checking)") + return m.styles.Muted.Render("checking") } if err != nil { - return m.styles.Failure.Render(" (" + err.Error() + ")") + return m.styles.Failure.Render(err.Error()) } - return "" + return m.styles.Success.Render("successful") } func (m *Model) formView() string { @@ -471,6 +490,7 @@ func (m *Model) formView() string { blocks := make([]string, 0, len(m.form.fields)+1) for index, field := range m.form.fields { value := "" + placeholder := false if field.text != nil { value = *field.text } @@ -487,21 +507,34 @@ func (m *Model) formView() string { value = "‹ " + value + " ›" } if value == "" { - value = m.styles.Placeholder.Render("(empty)") + if field.emptyValue != "" { + value = field.emptyValue + } else { + value = "(empty)" + placeholder = true + } } label := field.label - if index == m.form.cursor { + focused := index == m.form.cursor + if focused { label = m.styles.Info.Bold(true).Render(label) + if placeholder { + value = m.styles.Placeholder.Render(value) + } } var content strings.Builder fmt.Fprintf(&content, "%s\n %s", label, value) if field.description != "" { fmt.Fprintf(&content, "\n %s", m.styles.Muted.Render(field.description)) } - blocks = append(blocks, m.selectorCard(content.String(), index == m.form.cursor, true)) + rendered := content.String() + if !focused { + rendered = m.styles.Muted.Render(rendered) + } + blocks = append(blocks, m.selectorCard(rendered, focused, true)) } if len(m.form.actions) > 0 { - blocks = append(blocks, m.actionButtons(m.form.actions, m.form.actionFocus, m.contentWidth())) + blocks = append(blocks, m.actionButtons(m.form.actions, m.focusedFormAction(), m.contentWidth())) } var suffix string @@ -535,6 +568,13 @@ func (m *Model) formView() string { return builder.String() } +func (m *Model) focusedFormAction() int { + if m.form.cursor == len(m.form.fields) { + return m.form.actionFocus + } + return -1 +} + func focusedBlockRange(blocks []string, focus, height int) (start, end int) { if len(blocks) == 0 { return 0, 0 From 754f30cc8c0f5f5c57172bea1b76551eecbeceb8 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Fri, 28 Aug 2026 17:23:39 +0200 Subject: [PATCH 09/32] Show all migrations with refined progress Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/render/migration.go | 6 +++-- internal/render/migration_test.go | 13 +++++++---- internal/theme/theme.go | 37 +++++++++++++++++++++---------- internal/theme/theme_test.go | 2 ++ internal/tui/model.go | 2 +- internal/tui/model_test.go | 14 ++++++++++++ internal/tui/view.go | 6 ++--- 7 files changed, 58 insertions(+), 22 deletions(-) diff --git a/internal/render/migration.go b/internal/render/migration.go index f974113..3c09556 100644 --- a/internal/render/migration.go +++ b/internal/render/migration.go @@ -310,7 +310,7 @@ func MigrationRevertCutover(v elmapi.RevertCutoverResponse) string { func progressLine(label string, processed, added, failed int64) string { styles := theme.New() counts := fmt.Sprintf("%s %s / %s processed", - ProgressBar(processed, added, 14), + ProgressBar(processed, added, 20), styles.Bold.Render(strconv.FormatInt(processed, 10)), strconv.FormatInt(added, 10), ) @@ -331,7 +331,9 @@ func ProgressBar(processed, total int64, width int) string { if total > 0 { filled = int(min(max(processed, 0), total) * int64(width) / total) } - return strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + styles := theme.New() + return styles.ProgressBarFill.Render(strings.Repeat("━", filled)) + + styles.ProgressBarTrack.Render(strings.Repeat("━", width-filled)) } type state struct { diff --git a/internal/render/migration_test.go b/internal/render/migration_test.go index edccf59..0e7de02 100644 --- a/internal/render/migration_test.go +++ b/internal/render/migration_test.go @@ -185,16 +185,21 @@ Repository states func TestProgressBar(t *testing.T) { t.Run("renders proportional progress", func(t *testing.T) { - assert.Equal(t, "████████░░", ProgressBar(8, 10, 10)) + styles := theme.New() + assert.Equal(t, + styles.ProgressBarFill.Render("━━━━━━━━")+styles.ProgressBarTrack.Render("━━"), + ProgressBar(8, 10, 10), + ) }) t.Run("clamps values to the bar bounds", func(t *testing.T) { - assert.Equal(t, "░░░░", ProgressBar(-1, 10, 4)) - assert.Equal(t, "████", ProgressBar(12, 10, 4)) + styles := theme.New() + assert.Equal(t, styles.ProgressBarTrack.Render("━━━━"), ProgressBar(-1, 10, 4)) + assert.Equal(t, styles.ProgressBarFill.Render("━━━━"), ProgressBar(12, 10, 4)) }) t.Run("renders an empty bar without a total", func(t *testing.T) { - assert.Equal(t, "░░░░", ProgressBar(4, 0, 4)) + assert.Equal(t, theme.New().ProgressBarTrack.Render("━━━━"), ProgressBar(4, 0, 4)) }) } diff --git a/internal/theme/theme.go b/internal/theme/theme.go index 125b928..e2707c8 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -39,6 +39,14 @@ var warningColor = lipgloss.AdaptiveColor{ } var ( + progressBarFill = lipgloss.AdaptiveColor{ + Light: "#1a7f37", + Dark: "#3fb950", + } + progressBarTrack = lipgloss.AdaptiveColor{ + Light: "#9be4ab", + Dark: "#2f4a37", + } buttonIdleBackground = lipgloss.AdaptiveColor{ Light: "#eaeef2", Dark: "#141b22", @@ -66,6 +74,9 @@ type Styles struct { Disabled lipgloss.Style // Placeholder de-emphasises example input beneath surrounding help text. Placeholder lipgloss.Style + // ProgressBarFill and ProgressBarTrack distinguish completed and remaining work. + ProgressBarFill lipgloss.Style + ProgressBarTrack lipgloss.Style // Success marks a completed or passing item. Success lipgloss.Style // Active marks work currently in progress. @@ -87,18 +98,20 @@ type Styles struct { // New returns the `gh elm` styles. func New() Styles { return Styles{ - Primary: lipgloss.NewStyle(), - Bold: lipgloss.NewStyle().Bold(true), - Info: lipgloss.NewStyle().Foreground(colorBlue), - Secondary: lipgloss.NewStyle().Foreground(colorSecondary), - Muted: lipgloss.NewStyle().Foreground(colorMuted), - Disabled: lipgloss.NewStyle().Foreground(colorDisabled), - Placeholder: lipgloss.NewStyle().Foreground(colorPlaceholder), - Success: lipgloss.NewStyle().Foreground(colorGreen), - Active: lipgloss.NewStyle().Foreground(colorGreen), - Warning: lipgloss.NewStyle().Foreground(warningColor), - Paused: lipgloss.NewStyle().Foreground(warningColor), - Failure: lipgloss.NewStyle().Foreground(githubRed), + Primary: lipgloss.NewStyle(), + Bold: lipgloss.NewStyle().Bold(true), + Info: lipgloss.NewStyle().Foreground(colorBlue), + Secondary: lipgloss.NewStyle().Foreground(colorSecondary), + Muted: lipgloss.NewStyle().Foreground(colorMuted), + Disabled: lipgloss.NewStyle().Foreground(colorDisabled), + Placeholder: lipgloss.NewStyle().Foreground(colorPlaceholder), + ProgressBarFill: lipgloss.NewStyle().Foreground(progressBarFill), + ProgressBarTrack: lipgloss.NewStyle().Foreground(progressBarTrack), + Success: lipgloss.NewStyle().Foreground(colorGreen), + Active: lipgloss.NewStyle().Foreground(colorGreen), + Warning: lipgloss.NewStyle().Foreground(warningColor), + Paused: lipgloss.NewStyle().Foreground(warningColor), + Failure: lipgloss.NewStyle().Foreground(githubRed), FocusedButton: lipgloss.NewStyle(). Foreground(colorButtonText). Background(colorBlue). diff --git a/internal/theme/theme_test.go b/internal/theme/theme_test.go index ffb2a84..13240a2 100644 --- a/internal/theme/theme_test.go +++ b/internal/theme/theme_test.go @@ -22,6 +22,8 @@ func TestNew(t *testing.T) { assert.Equal(t, colorSecondary, s.Secondary.GetForeground()) assert.Equal(t, colorDisabled, s.Disabled.GetForeground()) assert.Equal(t, colorPlaceholder, s.Placeholder.GetForeground()) + assert.Equal(t, progressBarFill, s.ProgressBarFill.GetForeground()) + assert.Equal(t, progressBarTrack, s.ProgressBarTrack.GetForeground()) assert.Equal(t, colorBlue, s.FocusedButton.GetBackground()) assert.Equal(t, colorButtonText, s.FocusedButton.GetForeground()) assert.Equal(t, buttonIdleBackground, s.BlurredButton.GetBackground()) diff --git a/internal/tui/model.go b/internal/tui/model.go index 084a9c5..99fc63f 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2020,7 +2020,7 @@ func (m *Model) startSourceListLoad() tea.Cmd { m.sourceListGen++ generation := m.sourceListGen return func() tea.Msg { - migrations, err := m.service.ListSourceMigrations(m.ctx, "") + migrations, err := m.service.ListSourceMigrations(m.ctx, elmapi.StatusAll) return sourceListMsg{migrations: migrations, generation: generation, err: err} } } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index d57fd98..a08ed44 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -71,6 +71,20 @@ func TestModelUpdate(t *testing.T) { assert.Contains(t, model.View(), assert.AnError.Error()) }) + t.Run("migration list requests every status", func(t *testing.T) { + service := &fakeService{ + listSourceMigrations: func(_ context.Context, status string) ([]elmapi.MigrationSummary, error) { + assert.Equal(t, elmapi.StatusAll, status) + return nil, nil + }, + } + model := New(t.Context(), service) + + message := model.startSourceListLoad()() + + require.IsType(t, sourceListMsg{}, message) + }) + t.Run("uses prefetched migrations without another request", func(t *testing.T) { model := New(t.Context(), &fakeService{}) setConfigurationReady(model) diff --git a/internal/tui/view.go b/internal/tui/view.go index 0e0f1e7..620ff71 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -356,10 +356,10 @@ func (m *Model) targetDetailView() string { &detail, "%s\n Resources %s %d/%d\n Events %s %d/%d\n Acknowledged: %d backfill · %d live updates\n Sent: %s resources · %s live updates\n", progress.RepositoryNWO, - render.ProgressBar(progress.ResourcesProcessed, progress.ResourcesAdded, 12), + render.ProgressBar(progress.ResourcesProcessed, progress.ResourcesAdded, 20), progress.ResourcesProcessed, progress.ResourcesAdded, - render.ProgressBar(progress.EventsProcessed, progress.EventsAdded, 12), + render.ProgressBar(progress.EventsProcessed, progress.EventsAdded, 20), progress.EventsProcessed, progress.EventsAdded, progress.BackfillResourcesAcknowledged, @@ -762,7 +762,7 @@ func (m *Model) statusDisplay(status string) (glyph, label string) { case "in progress", "processing": return m.styles.Active.Render("●"), m.styles.Active.Bold(true).Render("In progress") case "created": - return m.styles.Info.Render("●"), m.styles.Bold.Render("Created") + return m.styles.Muted.Render("○"), m.styles.Muted.Render("Created") case "queued": return m.styles.Muted.Render("○"), m.styles.Muted.Render("Queued") case "paused": From 6c0a77740b5f5226a37d29e84a5c62b70fc89023 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Mon, 31 Aug 2026 18:13:56 +0200 Subject: [PATCH 10/32] Fix migration details and configuration flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/render/migration.go | 39 ++++++++++++++++++++-------- internal/render/migration_test.go | 43 +++++++++++++++++++++++++++++++ internal/tui/model.go | 28 ++++++++++++++------ internal/tui/model_test.go | 23 ++++++++++++----- internal/tui/view.go | 21 +++++++++++---- 5 files changed, 124 insertions(+), 30 deletions(-) diff --git a/internal/render/migration.go b/internal/render/migration.go index 3c09556..c39109f 100644 --- a/internal/render/migration.go +++ b/internal/render/migration.go @@ -32,10 +32,14 @@ func MigrationStatus(v elmapi.MigrationDetail) string { if v.Migration == nil && v.TargetState == nil && v.CombinedState == nil && len(v.Messages) == 0 { return "No migration status data returned.\n" } + sourceStatus := "" + if v.Migration != nil { + sourceStatus = pointerString(v.Migration.Status) + } return joinSections( renderMigrationSummary(v.Migration), - renderTargetState(v.TargetState), + renderTargetState(v.TargetState, sourceStatus), renderCombinedState(v.CombinedState), renderMessages(v.Messages), ) @@ -80,17 +84,19 @@ func renderMigrationSummary(migration *elmapi.MigrationSummary) string { return renderSection(title, lines...) } -func renderTargetState(target *elmapi.TargetState) string { +func renderTargetState(target *elmapi.TargetState, sourceStatus string) string { if target == nil { return "" } var sections []string availability := failureState(!target.TargetUnavailable, "Target available", "Target unavailable") - sections = append(sections, renderSection("Target", - bullet(statusGlyph(pointerString(target.Status)), statusText(pointerString(target.Status))), - bullet(availability.glyph, availability.text), - )) + lines := make([]string, 0, 2) + if status := pointerString(target.Status); normalizedValue(sourceStatus) != "created" && !terminatedStatus(status) { + lines = append(lines, bullet(statusGlyph(status), statusText(status))) + } + lines = append(lines, bullet(availability.glyph, availability.text)) + sections = append(sections, renderSection("Target", lines...)) for _, progress := range target.RepositoryProgress { sections = append(sections, renderRepositoryProgress(progress)) @@ -124,10 +130,12 @@ func renderCombinedState(combined *elmapi.CombinedState) string { styles := theme.New() status := pointerString(combined.Status) readiness := positiveState(combined.ReadyForCutover, "Ready for cutover", "Not ready for cutover") - lines := []string{ - bullet(statusGlyph(status), statusText(status)), + terminated := terminatedStatus(status) + var lines, renderedValues []string + if !terminated { + lines = append(lines, bullet(statusGlyph(status), statusText(status))) + renderedValues = append(renderedValues, status) } - renderedValues := []string{status} completed := completedStatus(status) readinessText := "Not ready for cutover" if combined.ReadyForCutover { @@ -138,11 +146,11 @@ func renderCombinedState(combined *elmapi.CombinedState) string { renderedValues = append(renderedValues, readinessText) } if displayMessage := strings.TrimSpace(combined.DisplayMessage); displayMessage != "" && - !containsEquivalentValue(renderedValues, displayMessage) { + !terminated && !containsEquivalentValue(renderedValues, displayMessage) { lines = append(lines, detail(displayMessage)) renderedValues = append(renderedValues, displayMessage) } - if !completed { + if !completed && !terminated { for _, blocker := range combined.CutoverBlockers { blocker = strings.TrimSpace(blocker) if blocker == "" || containsEquivalentValue(renderedValues, blocker) { @@ -181,6 +189,15 @@ func renderCombinedState(combined *elmapi.CombinedState) string { return joinSections(sections...) } +func terminatedStatus(status string) bool { + switch normalizedValue(status) { + case "terminated", "cancelled", "canceled", "aborted": + return true + default: + return false + } +} + func completedStatus(status string) bool { switch normalizedValue(status) { case "completed", "complete", "success", "succeeded": diff --git a/internal/render/migration_test.go b/internal/render/migration_test.go index 0e7de02..e9fb772 100644 --- a/internal/render/migration_test.go +++ b/internal/render/migration_test.go @@ -162,6 +162,49 @@ Repository states `, output) }) + t.Run("shows only cutover readiness for terminated migrations", func(t *testing.T) { + status := "terminated" + + output := MigrationStatus(elmapi.MigrationDetail{ + CombinedState: &elmapi.CombinedState{ + Status: &status, + DisplayMessage: "Migration terminated", + ReadyForCutover: false, + CutoverBlockers: []string{"Migration terminated"}, + }, + }) + + assert.Equal(t, `Cutover + ○ Not ready for cutover +`, output) + }) + + t.Run("shows only target availability for aborted targets", func(t *testing.T) { + status := "aborted" + + output := MigrationStatus(elmapi.MigrationDetail{ + TargetState: &elmapi.TargetState{Status: &status}, + }) + + assert.Equal(t, `Target + ✓ Target available +`, output) + }) + + t.Run("hides target progress before a migration starts", func(t *testing.T) { + created := "created" + inProgress := "in_progress" + + output := MigrationStatus(elmapi.MigrationDetail{ + Migration: &elmapi.MigrationSummary{Status: &created}, + TargetState: &elmapi.TargetState{Status: &inProgress}, + }) + + assert.Contains(t, output, "○ Created") + assert.Contains(t, output, "✓ Target available") + assert.NotContains(t, output, "In progress") + }) + t.Run("preserves distinct repository phase and status", func(t *testing.T) { status := "backfilling" phase := "backfill" diff --git a/internal/tui/model.go b/internal/tui/model.go index 99fc63f..b021406 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -395,6 +395,21 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.targetAuthErr = msg.err m.syncHomeCursor() m.syncViewportSize() + case configurationSavedMsg: + m.loading = false + m.err = nil + if msg.err != nil { + m.result = resultState{title: "Action failed", body: msg.err.Error(), parent: screenConfiguration} + m.screen = screenResult + m.resetViewport() + break + } + m.screen = screenConfiguration + m.actionFocus = 0 + m.invalidateSourceList() + m.sourceListLoading = true + model, command := m.refresh() + return model, tea.Batch(command, m.startSourceListLoad()) case pickerCatalogMsg: if msg.generation != m.pickerGeneration { return m, nil @@ -1902,14 +1917,7 @@ func (m *Model) openConfigurationForm() (tea.Model, tea.Cmd) { } return func() tea.Msg { err := m.service.SaveConfiguration(m.ctx, input) - return actionMsg{ - title: "Configuration saved", - body: "Stored gh elm configuration.", - parent: screenConfiguration, - refresh: true, - reloadSourceList: true, - err: err, - } + return configurationSavedMsg{err: err} }, nil }, }) @@ -1979,6 +1987,10 @@ type configMsg struct { err error } +type configurationSavedMsg struct { + err error +} + type sourceAuthenticationMsg struct { generation uint64 err error diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index a08ed44..574997a 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -306,7 +306,7 @@ func TestModelUpdate(t *testing.T) { assert.Contains(t, model.View(), "Failed source authentication") }) - t.Run("hides warning on configuration screen", func(t *testing.T) { + t.Run("hides warning throughout the configuration flow", func(t *testing.T) { model := New(t.Context(), &fakeService{}) model.screen = screenConfiguration model.configuration = &workflow.Configuration{ @@ -321,6 +321,18 @@ func TestModelUpdate(t *testing.T) { assert.NotContains(t, view, "Configuration not ready") assert.NotContains(t, view, "Open Configuration to finish setup") assert.Contains(t, view, "Source auth") + + model.screen = screenForm + model.form.parent = screenConfiguration + assert.Empty(t, model.configurationWarning()) + + model.screen = screenConfirm + model.confirm.parent = screenConfiguration + assert.Empty(t, model.configurationWarning()) + + model.screen = screenResult + model.result.parent = screenConfiguration + assert.Empty(t, model.configurationWarning()) }) t.Run("failed source detail load clears previous migration state", func(t *testing.T) { @@ -406,17 +418,15 @@ func TestModelUpdate(t *testing.T) { updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) require.NotNil(t, command) - updated, _ = model.Update(command()) + updated, command = model.Update(command()) model = updated.(*Model) - assert.Equal(t, screenResult, model.screen) + assert.Equal(t, screenConfiguration, model.screen) + require.NotNil(t, command) assert.False(t, model.sourceListLoaded) assert.Empty(t, model.sourceMigrations) assert.NoError(t, model.sourceListErr) - updated, command = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) - model = updated.(*Model) - require.NotNil(t, command) batch, ok := command().(tea.BatchMsg) require.True(t, ok) for _, batchCommand := range batch { @@ -427,6 +437,7 @@ func TestModelUpdate(t *testing.T) { require.Len(t, model.sourceMigrations, 1) assert.Equal(t, "new-source", model.sourceMigrations[0].MigrationID) assert.True(t, model.sourceListLoaded) + assert.Equal(t, screenConfiguration, model.screen) }) t.Run("configuration form offers save and cancel buttons", func(t *testing.T) { diff --git a/internal/tui/view.go b/internal/tui/view.go index 620ff71..245d4b1 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -168,11 +168,7 @@ func (m *Model) bodyHeight() int { } func (m *Model) configurationWarning() string { - current := m.screen - if current == screenConfirm { - current = m.confirm.parent - } - if current == screenConfiguration { + if m.inConfigurationFlow() { return "" } if m.configurationErr != nil { @@ -223,6 +219,21 @@ func (m *Model) configurationWarning() string { return strings.Join(issues, ". ") + ". Open Configuration to finish setup." } +func (m *Model) inConfigurationFlow() bool { + switch m.screen { + case screenConfiguration: + return true + case screenForm: + return m.form.parent == screenConfiguration + case screenConfirm: + return m.confirm.parent == screenConfiguration + case screenResult: + return m.result.parent == screenConfiguration + default: + return false + } +} + func (m *Model) menu(items []actionItem) string { var builder strings.Builder for index, item := range items { From 200fce99469165ac4861db9c40f1cb75c076042b Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Mon, 31 Aug 2026 18:41:11 +0200 Subject: [PATCH 11/32] Refine TUI migration workflows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/tui/keys.go | 2 +- internal/tui/model.go | 150 +++++++++++++++++++++++++---------- internal/tui/model_test.go | 134 ++++++++++++++++++------------- internal/tui/view.go | 86 ++++++++++++++++---- internal/workflow/service.go | 12 ++- 5 files changed, 268 insertions(+), 116 deletions(-) diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 2e9171b..bd6767c 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -64,7 +64,7 @@ var keys = keyMap{ ), Search: key.NewBinding( key.WithKeys("/", "f"), - key.WithHelp("/", "search"), + key.WithHelp("f", "search"), ), Density: key.NewBinding( key.WithKeys("ctrl+v"), diff --git a/internal/tui/model.go b/internal/tui/model.go index b021406..c32a16e 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -95,6 +95,7 @@ const ( screenPicker screenForm screenConfirm + screenAlert screenResult ) @@ -163,6 +164,7 @@ type pickerState struct { items []pickerItem cursor int input textinput.Model + search bool loading bool err error source string @@ -176,6 +178,12 @@ type confirmState struct { focus int } +type alertState struct { + title string + body string + parent screen +} + type resultState struct { title string body string @@ -202,19 +210,16 @@ type Model struct { viewportReady bool showHelp bool - sourceMigrations []elmapi.MigrationSummary - sourceListLoaded bool - sourceListLoading bool - sourceListErr error - sourceListGen uint64 - sourceID workflow.SourceMigrationID - sourceDetail *elmapi.MigrationDetail - sourceWatching bool - sourceWatchGen uint64 - sourceSearch bool - searchInput textinput.Model - compact bool - densityUserSet bool + sourceMigrations []elmapi.MigrationSummary + sourceListGen uint64 + sourceID workflow.SourceMigrationID + sourceDetail *elmapi.MigrationDetail + sourceWatching bool + sourceWatchGen uint64 + sourceSearch bool + searchInput textinput.Model + compact bool + densityUserSet bool targetMigrations []elmapi.TargetMigration targetID workflow.TargetMigrationID @@ -236,6 +241,7 @@ type Model struct { pickerInfoOpen bool form formState confirm confirmState + alert alertState result resultState } @@ -260,8 +266,7 @@ func New(ctx context.Context, svc service) *Model { // Init implements tea.Model. func (m *Model) Init() tea.Cmd { - m.sourceListLoading = true - return tea.Batch(m.startConfigurationLoad(), m.startSourceListLoad()) + return m.startConfigurationLoad() } // Update implements tea.Model. @@ -294,9 +299,9 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if msg.generation != m.sourceListGen { return m, nil } - m.sourceListLoading = false - m.sourceListLoaded = true - m.sourceListErr = msg.err + if m.showConfigurationAlert(msg.err) { + return m, nil + } if m.screen == screenSourceList { m.loading = false m.err = msg.err @@ -308,6 +313,9 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } } case sourceDetailMsg: + if m.showConfigurationAlert(msg.err) { + return m, nil + } m.loading = false m.err = msg.err if msg.err == nil { @@ -328,6 +336,9 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if msg.generation != m.targetListGen { return m, nil } + if m.showConfigurationAlert(msg.err) { + return m, nil + } m.targetListCancel = nil m.loading = false m.err = msg.err @@ -336,6 +347,9 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.cursor = 0 } case targetDetailMsg: + if m.showConfigurationAlert(msg.err) { + return m, nil + } m.loading = false m.err = msg.err if msg.err == nil { @@ -407,13 +421,15 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.screen = screenConfiguration m.actionFocus = 0 m.invalidateSourceList() - m.sourceListLoading = true model, command := m.refresh() return model, tea.Batch(command, m.startSourceListLoad()) case pickerCatalogMsg: if msg.generation != m.pickerGeneration { return m, nil } + if m.showConfigurationAlert(msg.err) { + return m, nil + } m.picker.loading = false m.picker.err = msg.err if msg.err == nil { @@ -421,6 +437,9 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.picker.cursor = 0 } case actionMsg: + if m.showConfigurationAlert(msg.err) { + return m, nil + } m.loading = false m.err = nil if msg.err != nil { @@ -474,6 +493,8 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.updateForm(msg) case screenConfirm: return m.updateConfirm(msg) + case screenAlert: + return m.updateAlert(msg) case screenResult: return m.updateResult(msg) } @@ -613,6 +634,12 @@ func (m *Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + if !m.picker.search && key.Matches(msg, keys.Search) { + m.picker.search = true + m.picker.cursor = 0 + m.picker.input.Focus() + return m, textinput.Blink + } switch msg.String() { case "ctrl+e": source := "" @@ -631,8 +658,10 @@ func (m *Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil case "esc": - if m.picker.input.Value() != "" { + if m.picker.search { + m.picker.search = false m.picker.input.SetValue("") + m.picker.input.Blur() m.picker.cursor = 0 return m, nil } @@ -669,6 +698,9 @@ func (m *Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.picker.loading || m.picker.err != nil { return m, nil } + if !m.picker.search { + return m, nil + } var command tea.Cmd m.picker.input, command = m.picker.input.Update(msg) if m.picker.cursor >= len(m.visiblePickerItems()) { @@ -700,20 +732,8 @@ func (m *Model) activate() (tea.Model, tea.Cmd) { } switch actions[m.cursor].id { case "migrations": - m.screen, m.err = screenSourceList, m.sourceListErr - switch { - case m.sourceListLoading: - m.loading = true - return m, nil - case m.sourceListLoaded: - m.loading = false - return m, nil - default: - m.loading = true - m.sourceListLoading = true - command := m.startSourceListLoad() - return m, command - } + m.screen, m.loading, m.err = screenSourceList, true, nil + return m, m.startSourceListLoad() case "create": return m.openSourceCreateForm(screenHome) case "mannequins": @@ -805,7 +825,6 @@ func (m *Model) refresh() (tea.Model, tea.Cmd) { switch m.screen { case screenSourceList: m.loading = true - m.sourceListLoading = true command := m.startSourceListLoad() return m, command case screenSourceDetail: @@ -1147,6 +1166,11 @@ var configurationActions = []actionItem{ {id: "reset", label: "Reset configuration", shortcut: "x"}, } +var createMigrationActions = []actionItem{ + {id: "create", label: "Create"}, + {id: "cancel", label: "Cancel"}, +} + func (m *Model) activateConfigurationAction() (tea.Model, tea.Cmd) { if m.actionFocus < 0 || m.actionFocus >= len(configurationActions) { return m, nil @@ -1201,6 +1225,51 @@ func (m *Model) confirmAction(title, body string, parent screen, command tea.Cmd return m, nil } +func (m *Model) showConfigurationAlert(err error) bool { + if err == nil || m.screen == screenHome || m.inConfigurationFlow() { + return false + } + var body string + switch { + case errors.Is(err, workflow.ErrSourceConfigurationMissing): + body = "The source URL or token is no longer configured." + case errors.Is(err, workflow.ErrTargetConfigurationMissing): + body = "The destination URL or token is no longer configured." + default: + return false + } + m.alert = alertState{ + title: "Configuration unavailable", + body: body, + parent: m.screen, + } + m.loading = false + m.err = nil + m.sourceWatching = false + m.sourceWatchGen++ + m.pickerInfoOpen = false + m.screen = screenAlert + return true +} + +func (m *Model) updateAlert(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if !key.Matches(msg, keys.Open) && !key.Matches(msg, keys.Back) && !key.Matches(msg, keys.Quit) { + return m, nil + } + m.configuration = nil + m.configurationErr = nil + m.sourceAuthChecked = false + m.sourceAuthErr = nil + m.targetAuthChecked = false + m.targetAuthErr = nil + m.invalidateSourceList() + m.screen = screenHome + m.cursor = 0 + m.homeCursorSet = false + m.syncHomeCursor() + return m, m.startConfigurationLoad() +} + func (m *Model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, keys.Left): @@ -1237,13 +1306,11 @@ func (m *Model) updateResult(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if refresh { model, command := m.refresh() if reloadSourceList { - m.sourceListLoading = true return model, tea.Batch(command, m.startSourceListLoad()) } return model, command } if reloadSourceList { - m.sourceListLoading = true return m, m.startSourceListLoad() } } @@ -1253,9 +1320,6 @@ func (m *Model) updateResult(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *Model) invalidateSourceList() { m.sourceListGen++ m.sourceMigrations = nil - m.sourceListLoaded = false - m.sourceListLoading = false - m.sourceListErr = nil } func (m *Model) visibleSourceMigrations() []elmapi.MigrationSummary { @@ -1310,7 +1374,7 @@ func (m *Model) syncViewportSize() { return } m.viewport.Width = m.contentWidth() - m.viewport.Height = m.bodyHeight() + m.viewport.Height = m.viewportBodyHeight(m.screen) } func (m *Model) resetViewport() { @@ -1464,7 +1528,6 @@ func (m *Model) openSourceCreateForm(parent screen) (tea.Model, tea.Cmd) { input.Prompt = "" input.Placeholder = "filter source repositories" input.CharLimit = 160 - input.Focus() m.pickerGeneration++ m.pickerInfoOpen = false @@ -1485,7 +1548,6 @@ func (m *Model) openTargetOrganizationPicker(parent screen, source string) (tea. input.Prompt = "" input.Placeholder = "filter destination organizations" input.CharLimit = 160 - input.Focus() m.pickerGeneration++ m.pickerInfoOpen = false @@ -1534,6 +1596,7 @@ func (m *Model) openDiscoveredSourceCreateForm(source, targetOrganization string selectFormField("Target visibility", &visibility, "internal", "private"), boolFormField("Start after creation", &start), }, + actions: createMigrationActions, submit: func() (tea.Cmd, error) { sourceOwner, sourceRepo, err := workflow.ParseRepositoryCoordinate(source) if err != nil { @@ -1577,6 +1640,7 @@ func (m *Model) openManualSourceCreateForm(parent screen, source string) (tea.Mo selectFormField("Target visibility", &visibility, "internal", "private"), boolFormField("Start after creation", &start), }, + actions: createMigrationActions, submit: func() (tea.Cmd, error) { sourceOwner, sourceRepository, err := workflow.ParseRepositoryCoordinate(source) if err != nil { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 574997a..15b015a 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -57,51 +57,77 @@ func TestModelUpdate(t *testing.T) { } }) - t.Run("background prefetch errors stay off the home screen", func(t *testing.T) { - model := New(t.Context(), &fakeService{}) + t.Run("opening migrations fetches a fresh list with every status", func(t *testing.T) { + service := &fakeService{ + listSourceMigrations: func(_ context.Context, status string) ([]elmapi.MigrationSummary, error) { + assert.Equal(t, elmapi.StatusAll, status) + return []elmapi.MigrationSummary{{MigrationID: "fresh"}}, nil + }, + } + model := New(t.Context(), service) setConfigurationReady(model) - updated, _ := model.Update(sourceListMsg{err: assert.AnError}) + model.sourceMigrations = []elmapi.MigrationSummary{{MigrationID: "stale"}} + + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) - assert.Equal(t, screenHome, model.screen) - assert.NotContains(t, model.View(), assert.AnError.Error()) + assert.Equal(t, screenSourceList, model.screen) + assert.True(t, model.loading) + require.NotNil(t, command) - updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = model.Update(command()) model = updated.(*Model) - assert.Contains(t, model.View(), assert.AnError.Error()) + + assert.False(t, model.loading) + require.Len(t, model.sourceMigrations, 1) + assert.Equal(t, "fresh", model.sourceMigrations[0].MigrationID) }) - t.Run("migration list requests every status", func(t *testing.T) { + t.Run("runtime configuration loss returns home through an alert", func(t *testing.T) { service := &fakeService{ - listSourceMigrations: func(_ context.Context, status string) ([]elmapi.MigrationSummary, error) { - assert.Equal(t, elmapi.StatusAll, status) - return nil, nil + getConfiguration: func(context.Context) (*workflow.Configuration, error) { + return &workflow.Configuration{}, nil }, } model := New(t.Context(), service) - - message := model.startSourceListLoad()() - - require.IsType(t, sourceListMsg{}, message) - }) - - t.Run("uses prefetched migrations without another request", func(t *testing.T) { - model := New(t.Context(), &fakeService{}) setConfigurationReady(model) - updated, _ := model.Update(sourceListMsg{migrations: []elmapi.MigrationSummary{{MigrationID: "source-1"}}}) + model.screen = screenSourceList + model.loading = true + model.width = 100 + model.height = 40 + + updated, _ := model.Update(sourceListMsg{ + generation: model.sourceListGen, + err: fmt.Errorf("loading migrations: %w", workflow.ErrSourceConfigurationMissing), + }) model = updated.(*Model) + assert.Equal(t, screenAlert, model.screen) + assert.Equal(t, screenSourceList, model.alert.parent) + assert.Contains(t, model.View(), "Configuration unavailable") + assert.Contains(t, model.View(), "source URL or token is no longer configured") + assert.Contains(t, model.View(), "Close") + assert.NotContains(t, model.View(), "Error:") + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) - assert.Equal(t, screenSourceList, model.screen) - assert.Nil(t, command) + assert.Equal(t, screenHome, model.screen) + assert.Equal(t, 3, model.cursor) + assert.Nil(t, model.configuration) + require.NotNil(t, command) + for _, action := range model.homeActionItems() { + if action.id == "configuration" || action.id == "quit" { + assert.False(t, action.disabled) + } else { + assert.True(t, action.disabled) + } + } }) t.Run("ignores stale source migration responses", func(t *testing.T) { model := New(t.Context(), &fakeService{}) model.sourceListGen = 2 - model.sourceListLoading = true model.sourceMigrations = []elmapi.MigrationSummary{{MigrationID: "current"}} updated, _ := model.Update(sourceListMsg{ @@ -110,28 +136,10 @@ func TestModelUpdate(t *testing.T) { }) model = updated.(*Model) - assert.True(t, model.sourceListLoading) require.Len(t, model.sourceMigrations, 1) assert.Equal(t, "current", model.sourceMigrations[0].MigrationID) }) - t.Run("configuration response does not unlock a pending migration list", func(t *testing.T) { - model := New(t.Context(), &fakeService{}) - setConfigurationReady(model) - model.sourceListLoading = true - updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) - model = updated.(*Model) - require.True(t, model.loading) - - updated, _ = model.Update(configMsg{ - configuration: &workflow.Configuration{}, - generation: model.configGeneration, - }) - model = updated.(*Model) - - assert.True(t, model.loading) - }) - t.Run("ignores stale configuration and authentication responses", func(t *testing.T) { model := New(t.Context(), &fakeService{}) model.screen = screenConfiguration @@ -408,8 +416,6 @@ func TestModelUpdate(t *testing.T) { } model := New(t.Context(), svc) model.screen = screenConfiguration - model.sourceListLoaded = true - model.sourceListErr = assert.AnError model.sourceMigrations = []elmapi.MigrationSummary{{MigrationID: "old-source"}} updated, _ := model.openConfigurationForm() model = updated.(*Model) @@ -423,9 +429,7 @@ func TestModelUpdate(t *testing.T) { assert.Equal(t, screenConfiguration, model.screen) require.NotNil(t, command) - assert.False(t, model.sourceListLoaded) assert.Empty(t, model.sourceMigrations) - assert.NoError(t, model.sourceListErr) batch, ok := command().(tea.BatchMsg) require.True(t, ok) @@ -436,7 +440,6 @@ func TestModelUpdate(t *testing.T) { require.Len(t, model.sourceMigrations, 1) assert.Equal(t, "new-source", model.sourceMigrations[0].MigrationID) - assert.True(t, model.sourceListLoaded) assert.Equal(t, screenConfiguration, model.screen) }) @@ -484,7 +487,6 @@ func TestModelUpdate(t *testing.T) { model := New(t.Context(), svc) model.screen = screenConfiguration model.actionFocus = len(configurationActions) - 1 - model.sourceListLoaded = true model.sourceMigrations = []elmapi.MigrationSummary{{MigrationID: "old-source"}} updated, _ := model.activateConfigurationAction() @@ -494,9 +496,8 @@ func TestModelUpdate(t *testing.T) { require.NotNil(t, command) updated, _ = model.Update(command()) model = updated.(*Model) - assert.Equal(t, screenResult, model.screen) - assert.False(t, model.sourceListLoaded) + assert.Equal(t, screenResult, model.screen) assert.Empty(t, model.sourceMigrations) }) } @@ -603,6 +604,9 @@ func TestModelNavigationAndLayout(t *testing.T) { model.sourceID = "source-1" model.sourceDetail = &elmapi.MigrationDetail{ Migration: &elmapi.MigrationSummary{MigrationID: "source-1"}, + Messages: []elmapi.MigrationMessage{ + {Message: strings.Repeat("long detail ", 80)}, + }, } model.targetID = 42 model.width = 80 @@ -627,7 +631,7 @@ func TestModelNavigationAndLayout(t *testing.T) { content := model.sourceDetailView() assert.Contains(t, content, "tail marker") - assert.Less(t, strings.Index(content, "Migration ID"), strings.Index(content, "Actions")) + assert.NotContains(t, content, "Actions") }) t.Run("detail actions use horizontal focus", func(t *testing.T) { @@ -843,11 +847,14 @@ func TestMigrationCreation(t *testing.T) { assert.Contains(t, model.form.description, "Source: acme/api") assert.Contains(t, model.form.description, "Destination organization: acme-cloud") assert.Equal(t, "api", *model.form.fields[0].text) + assert.Equal(t, createMigrationActions, model.form.actions) + assert.Contains(t, model.formView(), "Create") + assert.Contains(t, model.formView(), "Cancel") *model.form.fields[0].text = "renamed-api" *model.form.fields[1].text = "private" *model.form.fields[2].boolean = true - model.form.cursor = len(model.form.fields) - 1 + model.form.cursor = len(model.form.fields) updated, command = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) require.NotNil(t, command) @@ -934,10 +941,20 @@ func TestMigrationCreation(t *testing.T) { model := New(t.Context(), &fakeService{}) model.screen = screenPicker model.picker = pickerState{ + title: "Select source repository", items: []pickerItem{{value: "acme/api"}, {value: "octo/web"}}, input: textinput.New(), } - model.picker.input.Focus() + + assert.NotContains(t, model.View(), "filter source repositories") + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + model = updated.(*Model) + assert.Empty(t, model.picker.input.Value()) + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}}) + model = updated.(*Model) + assert.True(t, model.picker.search) + assert.True(t, model.picker.input.Focused()) for _, character := range "octo" { updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{character}}) @@ -945,6 +962,14 @@ func TestMigrationCreation(t *testing.T) { } assert.Equal(t, []pickerItem{{value: "octo/web"}}, model.visiblePickerItems()) + assert.Contains(t, model.View(), "Select source repository · filter") + assert.NotContains(t, model.pickerView(), "Search:") + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + model = updated.(*Model) + assert.False(t, model.picker.search) + assert.False(t, model.picker.input.Focused()) + assert.Len(t, model.visiblePickerItems(), 2) }) t.Run("repository picker offers manual fallback", func(t *testing.T) { @@ -1014,12 +1039,13 @@ func TestMigrationCreation(t *testing.T) { assert.Equal(t, "Target repository", model.form.fields[1].label) assert.Contains(t, model.formView(), "source-org/source-repo") assert.Contains(t, model.formView(), "target-org/target-repo") + assert.Equal(t, createMigrationActions, model.form.actions) *model.form.fields[0].text = "source-org/source-repo" *model.form.fields[1].text = "target-org/target-repo" *model.form.fields[2].text = "internal" *model.form.fields[3].boolean = true - model.form.cursor = len(model.form.fields) - 1 + model.form.cursor = len(model.form.fields) updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) require.NotNil(t, command) @@ -1045,7 +1071,7 @@ func TestMigrationCreation(t *testing.T) { *model.form.fields[0].text = "source-org/source-repo/extra" *model.form.fields[1].text = "target-org/target-repo" - model.form.cursor = len(model.form.fields) - 1 + model.form.cursor = len(model.form.fields) updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) diff --git a/internal/tui/view.go b/internal/tui/view.go index 245d4b1..6b052a0 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -23,9 +23,13 @@ func (m *Model) View() string { current := m.screen confirming := current == screenConfirm + alerting := current == screenAlert if confirming { current = m.confirm.parent } + if alerting { + current = m.alert.parent + } var title, body, help string switch current { @@ -66,10 +70,20 @@ func (m *Model) View() string { help = helpLine(keys.Left, keys.Open, keys.PageUp, keys.PageDown, keys.Refresh, keys.Back) case screenPicker: title = m.picker.title + if m.picker.search { + title += " · filter " + m.picker.input.View() + } body = m.pickerView() - help = "type to search • ↑/↓ select • enter continue • ctrl+e manual entry • esc cancel" - if m.picker.kind == pickerSourceRepository { - help = "type to search • ↑/↓ select • enter continue • ? repository details • ctrl+e manual entry • esc cancel" + if m.picker.search { + help = "type to filter • ↑/↓ select • enter continue • esc close search" + if m.picker.kind == pickerSourceRepository { + help = "type to filter • ↑/↓ select • enter continue • ? repository details • esc close search" + } + } else { + help = "↑/↓ select • enter continue • f search • ctrl+e manual entry • esc cancel" + if m.picker.kind == pickerSourceRepository { + help = "↑/↓ select • enter continue • f search • ? repository details • ctrl+e manual entry • esc cancel" + } } case screenForm: title = m.form.title @@ -84,7 +98,7 @@ func (m *Model) View() string { help = helpLine(keys.Up, keys.Down, keys.PageUp, keys.PageDown, keys.Back) } - if m.showHelp && !confirming { + if m.showHelp && !confirming && !alerting { title = "Keyboard help" body = m.fullHelpView() help = "? close • q quit" @@ -95,14 +109,23 @@ func (m *Model) View() string { if m.err != nil { body += "\n\n" + m.styles.Failure.Render("Error: "+m.err.Error()) } - bodyHeight := m.bodyHeight() + detailActions := "" + if !m.showHelp { + detailActions = m.detailActionView(current) + } + bodyHeight := m.viewportBodyHeight(current) if isScrollableScreen(current) || m.showHelp { body = m.viewportView(body, bodyHeight) } else { body = clip(body, bodyHeight) } + if detailActions != "" { + body = m.detailLayout(body, detailActions) + } if confirming { help = "←/→ select action • enter activate • y confirm • n/esc cancel" + } else if alerting { + help = "enter/esc close" } rendered := m.frame(title, body, help) if confirming { @@ -112,6 +135,13 @@ func (m *Model) View() string { m.displayWidth(), displayHeight(m.height), ) + } else if alerting { + rendered = overlayCenter( + rendered, + m.alertOverlay(), + m.displayWidth(), + displayHeight(m.height), + ) } else if m.pickerInfoOpen { if overlay := m.pickerInfoOverlay(); overlay != "" { rendered = overlayCenter( @@ -314,10 +344,7 @@ func (m *Model) sourceDetailView() string { status.WriteString(m.styles.Active.Render("● Live watch enabled (2s refresh)")) status.WriteString("\n") } - var actions strings.Builder - actions.WriteString(m.styles.Bold.Render("Actions") + "\n\n") - actions.WriteString(m.actionButtons(m.sourceActionItems(), m.actionFocus, m.contentWidth())) - return m.detailLayout(status.String(), actions.String()) + return status.String() } func (m *Model) targetListView() string { @@ -383,10 +410,7 @@ func (m *Model) targetDetailView() string { } } } - var actions strings.Builder - actions.WriteString(m.styles.Bold.Render("Actions") + "\n\n") - actions.WriteString(m.actionButtons(m.targetActionItems(), m.actionFocus, m.contentWidth())) - return m.detailLayout(detail.String(), actions.String()) + return detail.String() } func yesNo(value bool) string { @@ -406,6 +430,31 @@ func (m *Model) detailLayout(detail, actions string) string { return detail + "\n\n" + actions } +func (m *Model) detailActionView(current screen) string { + var actions []actionItem + switch current { + case screenSourceDetail: + actions = m.sourceActionItems() + case screenTargetDetail: + actions = m.targetActionItems() + default: + return "" + } + return m.styles.Bold.Render("Actions") + "\n\n" + + m.actionButtons(actions, m.actionFocus, m.contentWidth()) +} + +func (m *Model) viewportBodyHeight(current screen) int { + height := m.bodyHeight() + if m.showHelp { + return height + } + if actions := m.detailActionView(current); actions != "" { + height -= lipgloss.Height(actions) + 2 + } + return max(3, height) +} + func (m *Model) configurationView() string { var builder strings.Builder if configuration := m.configuration; configuration != nil { @@ -624,12 +673,11 @@ func (m *Model) pickerView() string { items := m.visiblePickerItems() var builder strings.Builder - fmt.Fprintf(&builder, "Search: %s\n\n", m.picker.input.View()) if len(items) == 0 { fmt.Fprintf(&builder, "%s\n", m.styles.Muted.Render("No matching options.")) return builder.String() } - start, end := pickerBounds(m.picker.cursor, len(items), max(3, m.bodyHeight()-3)) + start, end := pickerBounds(m.picker.cursor, len(items), max(3, m.bodyHeight()-1)) for index := start; index < end; index++ { if index > start { builder.WriteString("\n") @@ -752,6 +800,14 @@ func (m *Model) confirmationOverlay() string { return lipgloss.NewStyle().Width(width).Render(m.panel(content)) } +func (m *Model) alertOverlay() string { + width := min(60, max(24, m.contentWidth()-8)) + content := m.styles.Bold.Render(m.alert.title) + "\n\n" + + m.alert.body + "\n\n" + + m.actionButtons([]actionItem{{id: "close", label: "Close"}}, 0, width-6) + return lipgloss.NewStyle().Width(width).Render(m.panel(content)) +} + func pickerBounds(cursor, total, capacity int) (start, end int) { if total == 0 { return 0, 0 diff --git a/internal/workflow/service.go b/internal/workflow/service.go index 58130bf..34464a1 100644 --- a/internal/workflow/service.go +++ b/internal/workflow/service.go @@ -25,6 +25,12 @@ type SourceMigrationID string // TargetMigrationID is a target-side numeric migration ID. type TargetMigrationID int64 +// ErrSourceConfigurationMissing means the source endpoint cannot be used. +var ErrSourceConfigurationMissing = errors.New("source URL and token are not configured") + +// ErrTargetConfigurationMissing means the target endpoint cannot be used. +var ErrTargetConfigurationMissing = errors.New("target URL and token are not configured") + // Service executes ELM workflows using configured endpoints. type Service struct{} @@ -625,7 +631,7 @@ func (s *Service) sourceClient() (*elmapi.Client, error) { return nil, err } if ep.URL == "" || ep.Token == "" { - return nil, errors.New("source URL and token are not configured") + return nil, ErrSourceConfigurationMissing } return elmapi.NewClient(ep.URL, ep.Token), nil } @@ -640,7 +646,7 @@ func (s *Service) targetClient() (*elmapi.Client, error) { return nil, err } if ep.URL == "" || ep.Token == "" { - return nil, errors.New("target URL and token are not configured") + return nil, ErrTargetConfigurationMissing } return elmapi.NewClient(ep.URL, ep.Token), nil } @@ -655,7 +661,7 @@ func (s *Service) mannequinClient() (*ghapi.Client, error) { return nil, err } if ep.URL == "" || ep.Token == "" { - return nil, errors.New("target URL and token are not configured") + return nil, ErrTargetConfigurationMissing } return ghapi.NewClient(ep.URL, ep.Token), nil } From a5c705dc9db98dff986eaa04ead268ab8c86d173 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Mon, 31 Aug 2026 19:06:13 +0200 Subject: [PATCH 12/32] Refine TUI migration interactions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/elmapi/target_migrations.go | 46 +++++++++++++++ internal/elmapi/target_migrations_test.go | 31 ++++++++++ internal/tui/model.go | 69 ++++------------------- internal/tui/model_test.go | 45 +++++---------- internal/tui/view.go | 40 ++++++++----- 5 files changed, 128 insertions(+), 103 deletions(-) diff --git a/internal/elmapi/target_migrations.go b/internal/elmapi/target_migrations.go index 818a959..24c726b 100644 --- a/internal/elmapi/target_migrations.go +++ b/internal/elmapi/target_migrations.go @@ -104,6 +104,52 @@ type TargetRepositoryProgress struct { LiveUpdateResourcesAcknowledged int64 `json:"liveUpdateResourcesAcknowledged"` } +// UnmarshalJSON accepts the quoted int64 values returned by the status endpoint +// while remaining compatible with the numeric values documented by its schema. +func (p *TargetRepositoryProgress) UnmarshalJSON(data []byte) error { + type progressFields TargetRepositoryProgress + var fields struct { + progressFields + ResourcesAdded wireInt64 `json:"resourcesAdded"` + ResourcesProcessed wireInt64 `json:"resourcesProcessed"` + EventsAdded wireInt64 `json:"eventsAdded"` + EventsProcessed wireInt64 `json:"eventsProcessed"` + BackfillResourcesAcknowledged wireInt64 `json:"backfillResourcesAcknowledged"` + LiveUpdateResourcesAcknowledged wireInt64 `json:"liveUpdateResourcesAcknowledged"` + } + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + *p = TargetRepositoryProgress(fields.progressFields) + p.ResourcesAdded = int64(fields.ResourcesAdded) + p.ResourcesProcessed = int64(fields.ResourcesProcessed) + p.EventsAdded = int64(fields.EventsAdded) + p.EventsProcessed = int64(fields.EventsProcessed) + p.BackfillResourcesAcknowledged = int64(fields.BackfillResourcesAcknowledged) + p.LiveUpdateResourcesAcknowledged = int64(fields.LiveUpdateResourcesAcknowledged) + return nil +} + +type wireInt64 int64 + +func (v *wireInt64) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + value := string(data) + if len(data) > 0 && data[0] == '"' { + if err := json.Unmarshal(data, &value); err != nil { + return err + } + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return fmt.Errorf("invalid integer %q: %w", value, err) + } + *v = wireInt64(parsed) + return nil +} + // TargetMigration is a target-side migration record, as returned by the list // and status endpoints. Raw holds the exact JSON object the API returned for // this migration, so callers rendering JSON can echo the API's response diff --git a/internal/elmapi/target_migrations_test.go b/internal/elmapi/target_migrations_test.go index 21def45..7dfa025 100644 --- a/internal/elmapi/target_migrations_test.go +++ b/internal/elmapi/target_migrations_test.go @@ -309,6 +309,37 @@ func TestGetTargetMigrationStatus(t *testing.T) { assert.Equal(t, int64(10), resp.Migration.RepositoryProgress[0].ResourcesAdded) }) + t.Run("decodes string-encoded progress counts", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"migration":{"migrationId":"42","status":"STATUS_TYPE_IN_PROGRESS","expiresAt":"2024-01-01T00:00:00Z","repositoryProgress":[{"repositoryNwo":"octo/repo","resourcesAdded":"10","resourcesProcessed":"5","eventsAdded":"4","eventsProcessed":"3","backfillResourcesAcknowledged":"2","liveUpdateResourcesAcknowledged":"1"}]}}`)) + })) + defer srv.Close() + + resp, err := NewClient(srv.URL, "tok").GetTargetMigrationStatus(t.Context(), 42) + + require.NoError(t, err) + require.Len(t, resp.Migration.RepositoryProgress, 1) + progress := resp.Migration.RepositoryProgress[0] + assert.Equal(t, int64(10), progress.ResourcesAdded) + assert.Equal(t, int64(5), progress.ResourcesProcessed) + assert.Equal(t, int64(4), progress.EventsAdded) + assert.Equal(t, int64(3), progress.EventsProcessed) + assert.Equal(t, int64(2), progress.BackfillResourcesAcknowledged) + assert.Equal(t, int64(1), progress.LiveUpdateResourcesAcknowledged) + }) + + t.Run("rejects malformed progress counts", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"migration":{"migrationId":"42","status":"STATUS_TYPE_IN_PROGRESS","expiresAt":"2024-01-01T00:00:00Z","repositoryProgress":[{"resourcesAdded":"many"}]}}`)) + })) + defer srv.Close() + + _, err := NewClient(srv.URL, "tok").GetTargetMigrationStatus(t.Context(), 42) + + require.Error(t, err) + assert.Contains(t, err.Error(), `invalid integer "many"`) + }) + t.Run("returns HTTPError on non-200", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "not found", http.StatusNotFound) diff --git a/internal/tui/model.go b/internal/tui/model.go index c32a16e..ab8ab04 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -11,7 +11,6 @@ import ( "slices" "strconv" "strings" - "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" @@ -188,6 +187,7 @@ type resultState struct { title string body string parent screen + popup bool refresh bool reloadSourceList bool } @@ -214,8 +214,6 @@ type Model struct { sourceListGen uint64 sourceID workflow.SourceMigrationID sourceDetail *elmapi.MigrationDetail - sourceWatching bool - sourceWatchGen uint64 sourceSearch bool searchInput textinput.Model compact bool @@ -329,9 +327,6 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.sourceDetail = nil m.targetID = 0 } - if m.sourceWatching { - return m, m.scheduleWatchTick() - } case targetListMsg: if msg.generation != m.targetListGen { return m, nil @@ -455,6 +450,7 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { title: msg.title, body: msg.body, parent: msg.parent, + popup: msg.popup, refresh: msg.refresh, reloadSourceList: msg.reloadSourceList, } @@ -471,16 +467,6 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { command: msg.command, } m.screen = screenConfirm - case watchTickMsg: - if msg.generation == m.sourceWatchGen && - m.sourceWatching && - m.screen == screenSourceDetail && - !m.loading { - m.sourceWatchGen++ - m.loading = true - command := m.loadSourceDetailCmd() - return m, command - } } return m, nil } @@ -807,8 +793,6 @@ func (m *Model) back() (tea.Model, tea.Cmd) { case screenTargetList, screenMannequins, screenConfiguration, screenHome: m.screen = screenHome case screenSourceDetail: - m.sourceWatching = false - m.sourceWatchGen++ m.screen = screenSourceList case screenTargetDetail: m.screen = m.targetParent @@ -828,9 +812,6 @@ func (m *Model) refresh() (tea.Model, tea.Cmd) { command := m.startSourceListLoad() return m, command case screenSourceDetail: - if m.sourceWatching { - m.sourceWatchGen++ - } m.loading = true command := m.loadSourceDetailCmd() return m, command @@ -969,14 +950,6 @@ func (m *Model) activateSourceAction() (tea.Model, tea.Cmd) { switch actions[m.actionFocus].id { case "refresh": return m.refresh() - case "watch": - m.sourceWatching = !m.sourceWatching - m.sourceWatchGen++ - if m.sourceWatching { - m.loading = true - command := m.loadSourceDetailCmd() - return m, command - } case "start": return m.confirmAction("Start migration", "Start this migration?", screenSourceDetail, m.sourceMutationCmd("Migration started", m.service.StartSourceMigration)) @@ -1024,10 +997,7 @@ func (m *Model) activateSourceAction() (tea.Model, tea.Cmd) { } func (m *Model) sourceActionItems() []actionItem { - actions := []actionItem{ - {id: "refresh", label: "Refresh status", shortcut: "r"}, - {id: "watch", label: watchLabel(m.sourceWatching), shortcut: "w"}, - } + actions := []actionItem{{id: "refresh", label: "Refresh status", shortcut: "r"}} status := "" if m.sourceDetail != nil && m.sourceDetail.Migration != nil && m.sourceDetail.Migration.Status != nil { @@ -1124,13 +1094,6 @@ func (m *Model) targetActionItems() []actionItem { return actions } -func watchLabel(watching bool) string { - if watching { - return "Stop live watch" - } - return "Start live watch" -} - func normalizedStatus(status string) string { status = strings.TrimPrefix(status, "STATUS_TYPE_") status = strings.ReplaceAll(status, "_", " ") @@ -1186,6 +1149,7 @@ func (m *Model) activateConfigurationAction() (tea.Model, tea.Cmd) { title: "Configuration reset", body: "Stored configuration and credentials were cleared.", parent: screenConfiguration, + popup: true, refresh: true, reloadSourceList: true, err: err, @@ -1245,8 +1209,6 @@ func (m *Model) showConfigurationAlert(err error) bool { } m.loading = false m.err = nil - m.sourceWatching = false - m.sourceWatchGen++ m.pickerInfoOpen = false m.screen = screenAlert return true @@ -1678,6 +1640,7 @@ func (m *Model) createSourceMigrationCmd(input workflow.SourceCreateInput) tea.C title: "Migration created", body: body, parent: screenSourceDetail, + popup: true, refresh: true, sourceID: sourceID, } @@ -1729,7 +1692,7 @@ func (m *Model) openTargetCreateForm() (tea.Model, tea.Cmd) { } return func() tea.Msg { raw, err := m.service.CreateTargetMigration(m.ctx, input) - return actionMsg{title: "Target migration created", body: prettyJSON(raw), parent: screenTargetList, refresh: true, err: err} + return actionMsg{title: "Target migration created", body: prettyJSON(raw), parent: screenTargetList, popup: true, refresh: true, err: err} }, nil }, }) @@ -1991,7 +1954,7 @@ func (m *Model) sourceMutationCmd(title string, action func(context.Context, wor id := m.sourceID return func() tea.Msg { err := action(m.ctx, id) - return actionMsg{title: title, body: fmt.Sprintf("%s (%s).", title, id), parent: screenSourceDetail, refresh: true, err: err} + return actionMsg{title: title, body: fmt.Sprintf("%s (%s).", title, id), parent: screenSourceDetail, popup: true, refresh: true, err: err} } } @@ -1999,7 +1962,7 @@ func (m *Model) targetMutationCmd(title string, action func(context.Context, wor id := m.targetID return func() tea.Msg { err := action(m.ctx, id) - return actionMsg{title: title, body: fmt.Sprintf("%s (%d).", title, id), parent: screenTargetDetail, refresh: true, err: err} + return actionMsg{title: title, body: fmt.Sprintf("%s (%d).", title, id), parent: screenTargetDetail, popup: true, refresh: true, err: err} } } @@ -2007,7 +1970,7 @@ func (m *Model) cutoverCmd(force bool) tea.Cmd { id := m.sourceID return func() tea.Msg { err := m.service.CutoverSourceMigration(m.ctx, id, force) - return actionMsg{title: "Cutover initiated", body: fmt.Sprintf("Cutover initiated for migration %s.", id), parent: screenSourceDetail, refresh: true, err: err} + return actionMsg{title: "Cutover initiated", body: fmt.Sprintf("Cutover initiated for migration %s.", id), parent: screenSourceDetail, popup: true, refresh: true, err: err} } } @@ -2019,7 +1982,7 @@ func (m *Model) revertCutoverCmd() tea.Cmd { if result != nil { body = render.MigrationRevertCutover(*result) } - return actionMsg{title: "Cutover reverted", body: body, parent: screenSourceDetail, refresh: true, err: err} + return actionMsg{title: "Cutover reverted", body: body, parent: screenSourceDetail, popup: true, refresh: true, err: err} } } @@ -2075,6 +2038,7 @@ type actionMsg struct { title string body string parent screen + popup bool refresh bool reloadSourceList bool sourceID workflow.SourceMigrationID @@ -2088,10 +2052,6 @@ type confirmRequestMsg struct { command tea.Cmd } -type watchTickMsg struct { - generation uint64 -} - func (m *Model) startSourceListLoad() tea.Cmd { m.sourceListGen++ generation := m.sourceListGen @@ -2101,13 +2061,6 @@ func (m *Model) startSourceListLoad() tea.Cmd { } } -func (m *Model) scheduleWatchTick() tea.Cmd { - generation := m.sourceWatchGen - return tea.Tick(2*time.Second, func(time.Time) tea.Msg { - return watchTickMsg{generation: generation} - }) -} - func (m *Model) loadSourceDetailCmd() tea.Cmd { id := m.sourceID return func() tea.Msg { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 15b015a..6fd4534 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -359,33 +359,6 @@ func TestModelUpdate(t *testing.T) { assert.NotContains(t, actionIDs(model.sourceActionItems()), "cancel") }) - t.Run("watch ticks do not overlap source detail loads", func(t *testing.T) { - svc := &fakeService{ - getSourceMigration: func(context.Context, workflow.SourceMigrationID) (*elmapi.MigrationDetail, error) { - return &elmapi.MigrationDetail{}, nil - }, - } - model := New(t.Context(), svc) - model.screen = screenSourceDetail - model.sourceWatching = true - model.sourceWatchGen = 1 - - updated, command := model.Update(watchTickMsg{generation: 1}) - model = updated.(*Model) - require.NotNil(t, command) - assert.True(t, model.loading) - assert.Equal(t, uint64(2), model.sourceWatchGen) - - updated, duplicate := model.Update(watchTickMsg{generation: 1}) - model = updated.(*Model) - assert.Nil(t, duplicate) - - updated, nextTick := model.Update(command()) - model = updated.(*Model) - assert.False(t, model.loading) - require.NotNil(t, nextTick) - }) - t.Run("target detail load clears previous repository", func(t *testing.T) { model := New(t.Context(), &fakeService{}) model.repository = "old/repository" @@ -614,6 +587,7 @@ func TestModelNavigationAndLayout(t *testing.T) { model.actionFocus = len(model.sourceActionItems()) - 1 assert.Contains(t, model.View(), "Open destination details") + assert.NotContains(t, model.View(), "Actions") }) t.Run("narrow detail preserves all scrollable content", func(t *testing.T) { @@ -864,6 +838,9 @@ func TestMigrationCreation(t *testing.T) { model = updated.(*Model) assert.Equal(t, screenResult, model.screen) + assert.True(t, model.result.popup) + assert.Contains(t, model.View(), "Migration created") + assert.Contains(t, model.View(), "Close") assert.Equal(t, workflow.SourceMigrationID("created-1"), model.sourceID) assert.Equal(t, workflow.SourceCreateInput{ SourceOwner: "acme", @@ -873,6 +850,11 @@ func TestMigrationCreation(t *testing.T) { Visibility: "private", Start: true, }, sourceCreateInput) + + updated, command = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(*Model) + assert.Equal(t, screenSourceDetail, model.screen) + assert.NotNil(t, command) }) t.Run("repository picker presents real metadata", func(t *testing.T) { @@ -1053,6 +1035,7 @@ func TestMigrationCreation(t *testing.T) { model = updated.(*Model) assert.Equal(t, screenResult, model.screen) + assert.True(t, model.result.popup) assert.Equal(t, workflow.SourceMigrationID("created-1"), model.sourceID) assert.Equal(t, workflow.SourceCreateInput{ SourceOwner: "source-org", @@ -1140,12 +1123,12 @@ func TestModelActions(t *testing.T) { t.Run("created can start or cancel", func(t *testing.T) { setSourceStatus(model, elmapi.StatusCreated) - assert.ElementsMatch(t, []string{"refresh", "watch", "start", "cancel"}, actionIDs(model.sourceActionItems())) + assert.ElementsMatch(t, []string{"refresh", "start", "cancel"}, actionIDs(model.sourceActionItems())) }) t.Run("in progress can pause force cutover or cancel", func(t *testing.T) { setSourceStatus(model, elmapi.StatusInProgress) - assert.ElementsMatch(t, []string{"refresh", "watch", "pause", "force-cutover", "cancel"}, actionIDs(model.sourceActionItems())) + assert.ElementsMatch(t, []string{"refresh", "pause", "force-cutover", "cancel"}, actionIDs(model.sourceActionItems())) }) t.Run("ready migration offers normal cutover", func(t *testing.T) { @@ -1157,12 +1140,12 @@ func TestModelActions(t *testing.T) { t.Run("paused can resume or cancel", func(t *testing.T) { setSourceStatus(model, elmapi.StatusPaused) - assert.ElementsMatch(t, []string{"refresh", "watch", "resume", "cancel"}, actionIDs(model.sourceActionItems())) + assert.ElementsMatch(t, []string{"refresh", "resume", "cancel"}, actionIDs(model.sourceActionItems())) }) t.Run("completed can revert", func(t *testing.T) { setSourceStatus(model, elmapi.StatusCompleted) - assert.ElementsMatch(t, []string{"refresh", "watch", "revert"}, actionIDs(model.sourceActionItems())) + assert.ElementsMatch(t, []string{"refresh", "revert"}, actionIDs(model.sourceActionItems())) }) }) diff --git a/internal/tui/view.go b/internal/tui/view.go index 6b052a0..f789b00 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -24,12 +24,16 @@ func (m *Model) View() string { current := m.screen confirming := current == screenConfirm alerting := current == screenAlert + resultPopup := current == screenResult && m.result.popup if confirming { current = m.confirm.parent } if alerting { current = m.alert.parent } + if resultPopup { + current = m.result.parent + } var title, body, help string switch current { @@ -98,7 +102,7 @@ func (m *Model) View() string { help = helpLine(keys.Up, keys.Down, keys.PageUp, keys.PageDown, keys.Back) } - if m.showHelp && !confirming && !alerting { + if m.showHelp && !confirming && !alerting && !resultPopup { title = "Keyboard help" body = m.fullHelpView() help = "? close • q quit" @@ -124,7 +128,7 @@ func (m *Model) View() string { } if confirming { help = "←/→ select action • enter activate • y confirm • n/esc cancel" - } else if alerting { + } else if alerting || resultPopup { help = "enter/esc close" } rendered := m.frame(title, body, help) @@ -142,6 +146,13 @@ func (m *Model) View() string { m.displayWidth(), displayHeight(m.height), ) + } else if resultPopup { + rendered = overlayCenter( + rendered, + m.resultPopupOverlay(), + m.displayWidth(), + displayHeight(m.height), + ) } else if m.pickerInfoOpen { if overlay := m.pickerInfoOverlay(); overlay != "" { rendered = overlayCenter( @@ -335,16 +346,10 @@ func (m *Model) sourceMigrationCard(migration elmapi.MigrationSummary, selected } func (m *Model) sourceDetailView() string { - var status strings.Builder if m.sourceDetail != nil { - status.WriteString(render.MigrationStatus(*m.sourceDetail)) - } - if m.sourceWatching { - status.WriteString("\n") - status.WriteString(m.styles.Active.Render("● Live watch enabled (2s refresh)")) - status.WriteString("\n") + return render.MigrationStatus(*m.sourceDetail) } - return status.String() + return "" } func (m *Model) targetListView() string { @@ -440,8 +445,7 @@ func (m *Model) detailActionView(current screen) string { default: return "" } - return m.styles.Bold.Render("Actions") + "\n\n" + - m.actionButtons(actions, m.actionFocus, m.contentWidth()) + return m.actionButtons(actions, m.actionFocus, m.contentWidth()) } func (m *Model) viewportBodyHeight(current screen) int { @@ -801,9 +805,17 @@ func (m *Model) confirmationOverlay() string { } func (m *Model) alertOverlay() string { + return m.messageOverlay(m.alert.title, m.alert.body) +} + +func (m *Model) resultPopupOverlay() string { + return m.messageOverlay(m.result.title, m.result.body) +} + +func (m *Model) messageOverlay(title, body string) string { width := min(60, max(24, m.contentWidth()-8)) - content := m.styles.Bold.Render(m.alert.title) + "\n\n" + - m.alert.body + "\n\n" + + content := m.styles.Bold.Render(title) + "\n\n" + + body + "\n\n" + m.actionButtons([]actionItem{{id: "close", label: "Close"}}, 0, width-6) return lipgloss.NewStyle().Width(width).Render(m.panel(content)) } From 8bda361ffe6421b0cc6387220712bc94f3d04743 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Mon, 31 Aug 2026 19:21:29 +0200 Subject: [PATCH 13/32] Polish TUI detail layouts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/render/migration.go | 9 ++++++++- internal/render/migration_test.go | 28 +++++++++++++++++++++++++--- internal/tui/model.go | 29 +++++++++++++---------------- internal/tui/model_test.go | 31 +++++++++++++++++++++++++++++-- internal/tui/view.go | 19 +++++++++++++++---- 5 files changed, 90 insertions(+), 26 deletions(-) diff --git a/internal/render/migration.go b/internal/render/migration.go index c39109f..e075629 100644 --- a/internal/render/migration.go +++ b/internal/render/migration.go @@ -130,9 +130,13 @@ func renderCombinedState(combined *elmapi.CombinedState) string { styles := theme.New() status := pointerString(combined.Status) readiness := positiveState(combined.ReadyForCutover, "Ready for cutover", "Not ready for cutover") + if !combined.ReadyForCutover { + readiness.glyph = styles.Muted.Render("✗") + } terminated := terminatedStatus(status) var lines, renderedValues []string - if !terminated { + normalizedStatus := normalizedValue(status) + if !terminated && normalizedStatus != "" && normalizedStatus != "created" && normalizedStatus != "queued" { lines = append(lines, bullet(statusGlyph(status), statusText(status))) renderedValues = append(renderedValues, status) } @@ -326,6 +330,9 @@ func MigrationRevertCutover(v elmapi.RevertCutoverResponse) string { func progressLine(label string, processed, added, failed int64) string { styles := theme.New() + if processed == 0 && added == 0 && failed == 0 { + return field(label, styles.Muted.Render("○ Not started")) + } counts := fmt.Sprintf("%s %s / %s processed", ProgressBar(processed, added, 20), styles.Bold.Render(strconv.FormatInt(processed, 10)), diff --git a/internal/render/migration_test.go b/internal/render/migration_test.go index e9fb772..53c490d 100644 --- a/internal/render/migration_test.go +++ b/internal/render/migration_test.go @@ -108,7 +108,7 @@ func TestMigrationStatus(t *testing.T) { for _, want := range []string{ "Migration", "mig-1", "Progress · octo/repo", - "✓ Target available", "○ Not ready for cutover", "backfill incomplete", + "✓ Target available", "✗ Not ready for cutover", "backfill incomplete", "Repository states", "Messages", "Migration is running", } { assert.Contains(t, output, want) @@ -175,7 +175,19 @@ Repository states }) assert.Equal(t, `Cutover - ○ Not ready for cutover + ✗ Not ready for cutover +`, output) + }) + + t.Run("shows only cutover readiness for created migrations", func(t *testing.T) { + status := "created" + + output := MigrationStatus(elmapi.MigrationDetail{ + CombinedState: &elmapi.CombinedState{Status: &status}, + }) + + assert.Equal(t, `Cutover + ✗ Not ready for cutover `, output) }) @@ -222,7 +234,7 @@ Repository states }) assert.Contains(t, output, "acme/web · Backfill · In progress") - assert.Contains(t, output, "○ Not ready for cutover") + assert.Contains(t, output, "✗ Not ready for cutover") }) } @@ -246,6 +258,16 @@ func TestProgressBar(t *testing.T) { }) } +func TestProgressLine(t *testing.T) { + t.Run("renders zero work as not started", func(t *testing.T) { + output := progressLine("Backfill", 0, 0, 0) + + assert.Contains(t, output, "○ Not started") + assert.NotContains(t, output, "0 / 0") + assert.NotContains(t, output, "no failures") + }) +} + func TestMigrationList(t *testing.T) { t.Run("renders rows and pagination", func(t *testing.T) { status := "completed" diff --git a/internal/tui/model.go b/internal/tui/model.go index ab8ab04..4c6fc63 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -518,22 +518,26 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.back() } case key.Matches(msg, keys.Left): - if m.actionScreen() && m.actionFocus > 0 { + if m.actionScreen() && !m.verticalActionScreen() && m.actionFocus > 0 { m.actionFocus-- } case key.Matches(msg, keys.Right): - if m.actionScreen() && m.actionFocus < m.itemCount()-1 { + if m.actionScreen() && !m.verticalActionScreen() && m.actionFocus < m.itemCount()-1 { m.actionFocus++ } case key.Matches(msg, keys.Up): if m.screen == screenHome { m.moveHomeCursor(-1) + } else if m.verticalActionScreen() && m.actionFocus > 0 { + m.actionFocus-- } else if !m.actionScreen() && m.cursor > 0 { m.cursor-- } case key.Matches(msg, keys.Down): if m.screen == screenHome { m.moveHomeCursor(1) + } else if m.verticalActionScreen() && m.actionFocus < m.itemCount()-1 { + m.actionFocus++ } else if !m.actionScreen() && m.cursor < m.itemCount()-1 { m.cursor++ } @@ -861,6 +865,10 @@ func (m *Model) actionScreen() bool { } } +func (m *Model) verticalActionScreen() bool { + return m.screen == screenTargetDetail +} + func (m *Model) clampActionFocus() { m.actionFocus = min(max(0, m.actionFocus), max(0, m.itemCount()-1)) } @@ -968,14 +976,6 @@ func (m *Model) activateSourceAction() (tea.Model, tea.Cmd) { case "force-cutover": return m.confirmAction("Force cutover", "Bypass readiness checks and force cutover?", screenSourceDetail, m.cutoverCmd(true)) - case "cutover-status": - body := "No combined cutover state is available." - if m.sourceDetail != nil { - body = render.CutoverStatus(*m.sourceDetail) - } - m.result = resultState{title: "Cutover status", body: body, parent: screenSourceDetail} - m.screen = screenResult - m.resetViewport() case "revert": return m.confirmAction("Revert cutover", "Revert cutover effects and terminate work still in progress?", screenSourceDetail, m.revertCutoverCmd()) @@ -997,7 +997,7 @@ func (m *Model) activateSourceAction() (tea.Model, tea.Cmd) { } func (m *Model) sourceActionItems() []actionItem { - actions := []actionItem{{id: "refresh", label: "Refresh status", shortcut: "r"}} + actions := []actionItem{{id: "refresh", label: "Refresh", shortcut: "r"}} status := "" if m.sourceDetail != nil && m.sourceDetail.Migration != nil && m.sourceDetail.Migration.Status != nil { @@ -1029,11 +1029,8 @@ func (m *Model) sourceActionItems() []actionItem { case "completed": actions = append(actions, actionItem{id: "revert", label: "Revert cutover", shortcut: "v"}) } - if m.sourceDetail != nil && m.sourceDetail.CombinedState != nil { - actions = append(actions, actionItem{id: "cutover-status", label: "Show cutover status", shortcut: "i"}) - } if m.targetID > 0 { - actions = append(actions, actionItem{id: "destination", label: "Open destination details", shortcut: "d"}) + actions = append(actions, actionItem{id: "destination", label: "Details", shortcut: "d"}) } return actions } @@ -1069,7 +1066,7 @@ func (m *Model) activateTargetAction() (tea.Model, tea.Cmd) { func (m *Model) targetActionItems() []actionItem { actions := []actionItem{ - {id: "refresh", label: "Refresh status", shortcut: "r"}, + {id: "refresh", label: "Refresh", shortcut: "r"}, {id: "resources", label: "List repository resources", shortcut: "o"}, {id: "report-request", label: "Request node report", shortcut: "n"}, {id: "report-status", label: "Check report status", shortcut: "s"}, diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 6fd4534..2b2e0a0 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -524,7 +524,7 @@ func TestModelNavigationAndLayout(t *testing.T) { model = updated.(*Model) assert.Equal(t, workflow.SourceMigrationID("source-1"), model.sourceID) assert.Equal(t, workflow.TargetMigrationID(42), model.targetID) - assert.Contains(t, model.View(), "Open destination details") + assert.Contains(t, model.View(), "Details") model.actionFocus = len(model.sourceActionItems()) - 1 updated, cmd = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) @@ -586,7 +586,7 @@ func TestModelNavigationAndLayout(t *testing.T) { model.height = 24 model.actionFocus = len(model.sourceActionItems()) - 1 - assert.Contains(t, model.View(), "Open destination details") + assert.Contains(t, model.View(), "Details") assert.NotContains(t, model.View(), "Actions") }) @@ -627,6 +627,25 @@ func TestModelNavigationAndLayout(t *testing.T) { assert.Contains(t, model.View(), "←/→ select action") }) + t.Run("advanced destination actions use a vertical menu", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.screen = screenTargetDetail + model.targetDetail = &elmapi.TargetMigration{Status: elmapi.TargetMigrationStatusInProgress} + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown}) + model = updated.(*Model) + assert.Equal(t, 1, model.actionFocus) + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model = updated.(*Model) + assert.Equal(t, 1, model.actionFocus) + + view := model.detailActionView(screenTargetDetail) + assert.GreaterOrEqual(t, strings.Count(view, "\n"), len(model.targetActionItems())-1) + assert.Contains(t, view, "List repository resources") + assert.Contains(t, model.View(), "↑/k up") + }) + t.Run("action screens always select their first action", func(t *testing.T) { model := New(t.Context(), &fakeService{}) model.cursor = 3 @@ -752,6 +771,14 @@ func TestModelNavigationAndLayout(t *testing.T) { assert.Equal(t, model.bodyHeight(), model.viewport.Height) }) + t.Run("footer stays on the final terminal row", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.width = 80 + model.height = 24 + + assert.Equal(t, model.height, lipgloss.Height(model.View())) + }) + t.Run("state changes clamp detail action focus", func(t *testing.T) { model := New(t.Context(), &fakeService{}) model.screen = screenSourceDetail diff --git a/internal/tui/view.go b/internal/tui/view.go index f789b00..a03bafc 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -63,7 +63,7 @@ func (m *Model) View() string { case screenTargetDetail: title = fmt.Sprintf("Destination migration %d (advanced)", m.targetID) body = m.targetDetailView() - help = helpLine(keys.Left, keys.Open, keys.PageUp, keys.PageDown, keys.Refresh, keys.Back) + help = helpLine(keys.Up, keys.Down, keys.Open, keys.PageUp, keys.PageDown, keys.Refresh, keys.Back) case screenMannequins: title = "Target mannequins" body = m.actionButtons(mannequinActions, m.actionFocus, m.contentWidth()) @@ -182,7 +182,7 @@ func (m *Model) frame(title, body, help string) string { warningBlock = "\n" + m.styles.Warning.Bold(true).Render("⚠ Configuration not ready") + "\n" + warning } - content := lipgloss.NewStyle().Width(contentWidth).Render(body) + content := lipgloss.NewStyle().Width(contentWidth).Height(m.bodyHeight()).Render(body) footer := m.styles.Muted.Render(help) return lipgloss.NewStyle().Padding(0, 1).Render( header + warningBlock + "\n\n" + content + "\n\n" + footer, @@ -276,6 +276,14 @@ func (m *Model) inConfigurationFlow() bool { } func (m *Model) menu(items []actionItem) string { + return m.verticalMenu(items, m.cursor, false) +} + +func (m *Model) actionMenu(items []actionItem, focus int) string { + return m.verticalMenu(items, focus, true) +} + +func (m *Model) verticalMenu(items []actionItem, focus int, showShortcuts bool) string { var builder strings.Builder for index, item := range items { if index > 0 { @@ -285,7 +293,10 @@ func (m *Model) menu(items []actionItem) string { if item.disabled { label = m.styles.Disabled.Render(label) } - builder.WriteString(m.selectorCard(label, index == m.cursor, true)) + if showShortcuts && item.shortcut != "" { + label += m.styles.Muted.Render(" " + item.shortcut) + } + builder.WriteString(m.selectorCard(label, index == focus, true)) } return builder.String() } @@ -441,7 +452,7 @@ func (m *Model) detailActionView(current screen) string { case screenSourceDetail: actions = m.sourceActionItems() case screenTargetDetail: - actions = m.targetActionItems() + return m.actionMenu(m.targetActionItems(), m.actionFocus) default: return "" } From d5276127e645348ebe7ae41deed64f4be45a663d Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Mon, 31 Aug 2026 19:31:50 +0200 Subject: [PATCH 14/32] Refine target configuration and status Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/endpoints/endpoints.go | 2 +- internal/endpoints/endpoints_test.go | 4 ++++ internal/render/migration.go | 30 ++++++++++++++++++---------- internal/render/migration_test.go | 9 +++++++-- internal/tui/model_test.go | 2 ++ internal/workflow/service.go | 4 ++-- internal/workflow/service_test.go | 5 ++++- 7 files changed, 39 insertions(+), 17 deletions(-) diff --git a/internal/endpoints/endpoints.go b/internal/endpoints/endpoints.go index 267244a..aa3667c 100644 --- a/internal/endpoints/endpoints.go +++ b/internal/endpoints/endpoints.go @@ -137,7 +137,7 @@ func ensureGHESRESTBase(raw string) string { // NormalizeTargetAPIURL turns a target web hostname into its API hostname. // Existing API hostnames and local development endpoints are left unchanged. func NormalizeTargetAPIURL(raw string) string { - raw = strings.TrimSpace(raw) + raw = normalizeBaseURL(raw) u, err := url.Parse(raw) if err != nil || u.Host == "" { return raw diff --git a/internal/endpoints/endpoints_test.go b/internal/endpoints/endpoints_test.go index 1332a55..982df43 100644 --- a/internal/endpoints/endpoints_test.go +++ b/internal/endpoints/endpoints_test.go @@ -121,6 +121,10 @@ func TestNormalizeTargetAPIURL(t *testing.T) { assert.Equal(t, "https://API.staffship.blabla.com", NormalizeTargetAPIURL("https://API.staffship.blabla.com")) }) + t.Run("normalizes a bare web hostname", func(t *testing.T) { + assert.Equal(t, "https://api.staffship.blabla.com", NormalizeTargetAPIURL("staffship.blabla.com")) + }) + t.Run("prefixes a hostname that only contains api elsewhere", func(t *testing.T) { assert.Equal(t, "https://api.staffship-api.blabla.com", NormalizeTargetAPIURL("https://staffship-api.blabla.com")) }) diff --git a/internal/render/migration.go b/internal/render/migration.go index e075629..91a093a 100644 --- a/internal/render/migration.go +++ b/internal/render/migration.go @@ -38,8 +38,8 @@ func MigrationStatus(v elmapi.MigrationDetail) string { } return joinSections( - renderMigrationSummary(v.Migration), - renderTargetState(v.TargetState, sourceStatus), + renderMigrationSummary(v.Migration, v.TargetState), + renderTargetState(v.TargetState, sourceStatus, v.Migration == nil), renderCombinedState(v.CombinedState), renderMessages(v.Messages), ) @@ -53,17 +53,17 @@ func CutoverStatus(v elmapi.MigrationDetail) string { return renderCombinedState(v.CombinedState) } -func renderMigrationSummary(migration *elmapi.MigrationSummary) string { +func renderMigrationSummary(migration *elmapi.MigrationSummary, target *elmapi.TargetState) string { if migration == nil { return "" } styles := theme.New() source := repositoryName(migration.SourceOrganizationLogin, migration.SourceRepositoryName) - target := repositoryName(migration.TargetOrganizationLogin, migration.TargetRepositoryName) + targetRepository := repositoryName(migration.TargetOrganizationLogin, migration.TargetRepositoryName) title := "Migration" - if source != emptyValue || target != emptyValue { - title = source + " → " + target + if source != emptyValue || targetRepository != emptyValue { + title = source + " → " + targetRepository } lines := []string{ @@ -73,8 +73,12 @@ func renderMigrationSummary(migration *elmapi.MigrationSummary) string { if migration.TargetMigrationID != 0 { lines = append(lines, field("Target migration ID", styles.Bold.Render(strconv.FormatInt(migration.TargetMigrationID, 10)))) } + lines = append(lines, field("Visibility", pointerValue(migration.TargetVisibility))) + if target != nil { + availability := failureState(!target.TargetUnavailable, "Available", "Unavailable") + lines = append(lines, field("Target", availability.glyph+" "+availability.text)) + } lines = append(lines, - field("Visibility", pointerValue(migration.TargetVisibility)), field("Created", pointerValue(migration.CreatedAt)), field("Started", pointerValue(migration.StartedAt)), field("Completed", completedValue(migration.CompletedAt)), @@ -84,19 +88,23 @@ func renderMigrationSummary(migration *elmapi.MigrationSummary) string { return renderSection(title, lines...) } -func renderTargetState(target *elmapi.TargetState, sourceStatus string) string { +func renderTargetState(target *elmapi.TargetState, sourceStatus string, showAvailability bool) string { if target == nil { return "" } var sections []string - availability := failureState(!target.TargetUnavailable, "Target available", "Target unavailable") lines := make([]string, 0, 2) if status := pointerString(target.Status); normalizedValue(sourceStatus) != "created" && !terminatedStatus(status) { lines = append(lines, bullet(statusGlyph(status), statusText(status))) } - lines = append(lines, bullet(availability.glyph, availability.text)) - sections = append(sections, renderSection("Target", lines...)) + if showAvailability { + availability := failureState(!target.TargetUnavailable, "Target available", "Target unavailable") + lines = append(lines, bullet(availability.glyph, availability.text)) + } + if len(lines) > 0 { + sections = append(sections, renderSection("Target", lines...)) + } for _, progress := range target.RepositoryProgress { sections = append(sections, renderRepositoryProgress(progress)) diff --git a/internal/render/migration_test.go b/internal/render/migration_test.go index 53c490d..36b3ec1 100644 --- a/internal/render/migration_test.go +++ b/internal/render/migration_test.go @@ -3,6 +3,7 @@ package render import ( "bytes" "errors" + "strings" "testing" "github.com/charmbracelet/lipgloss" @@ -108,11 +109,13 @@ func TestMigrationStatus(t *testing.T) { for _, want := range []string{ "Migration", "mig-1", "Progress · octo/repo", - "✓ Target available", "✗ Not ready for cutover", "backfill incomplete", + "Target", "✓ Available", "✗ Not ready for cutover", "backfill incomplete", "Repository states", "Messages", "Migration is running", } { assert.Contains(t, output, want) } + assert.Less(t, strings.Index(output, "Visibility"), strings.Index(output, "Target")) + assert.Less(t, strings.Index(output, "Target"), strings.Index(output, "Created")) }) t.Run("renders empty response explicitly", func(t *testing.T) { @@ -213,7 +216,9 @@ Repository states }) assert.Contains(t, output, "○ Created") - assert.Contains(t, output, "✓ Target available") + assert.Contains(t, output, "Target") + assert.Contains(t, output, "✓ Available") + assert.NotContains(t, output, "\nTarget\n") assert.NotContains(t, output, "In progress") }) diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 2b2e0a0..4f41a63 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -421,6 +421,7 @@ func TestModelUpdate(t *testing.T) { model.screen = screenConfiguration model.configuration = &workflow.Configuration{ SourceTokenSet: true, + TargetURL: "https://api.staffship-01.ghe.com", TargetTokenSet: true, } updated, _ := model.openConfigurationForm() @@ -430,6 +431,7 @@ func TestModelUpdate(t *testing.T) { assert.NotContains(t, view, "blank preserves current") assert.Equal(t, 2, strings.Count(view, "••••••••")) assert.Empty(t, *model.form.fields[1].text) + assert.Equal(t, "https://api.staffship-01.ghe.com", *model.form.fields[2].text) assert.Empty(t, *model.form.fields[3].text) assert.Contains(t, view, "Save") assert.Contains(t, view, "Cancel") diff --git a/internal/workflow/service.go b/internal/workflow/service.go index 34464a1..2be715f 100644 --- a/internal/workflow/service.go +++ b/internal/workflow/service.go @@ -552,7 +552,7 @@ func (s *Service) GetConfiguration(context.Context) (*Configuration, error) { return &Configuration{ SourceURL: cfg.SourceURL, SourceTokenSet: sourceToken != "", - TargetURL: cfg.TargetURL, + TargetURL: endpoints.NormalizeTargetAPIURL(cfg.TargetURL), TargetTokenSet: targetToken != "", ResolvedSourceURL: source.URL, ResolvedSourceTokenSet: source.Token != "", @@ -592,7 +592,7 @@ func (s *Service) SaveConfiguration(ctx context.Context, in ConfigurationInput) return err } cfg.SourceURL = strings.TrimSpace(in.SourceURL) - cfg.TargetURL = strings.TrimSpace(in.TargetURL) + cfg.TargetURL = endpoints.NormalizeTargetAPIURL(in.TargetURL) if err := cfg.Save(); err != nil { return err } diff --git a/internal/workflow/service_test.go b/internal/workflow/service_test.go index a270033..d51563a 100644 --- a/internal/workflow/service_test.go +++ b/internal/workflow/service_test.go @@ -84,12 +84,15 @@ func TestConfiguration(t *testing.T) { require.NoError(t, err) assert.Equal(t, "https://source.example", configuration.SourceURL) assert.True(t, configuration.SourceTokenSet) - assert.Equal(t, "https://target.example", configuration.TargetURL) + assert.Equal(t, "https://api.target.example", configuration.TargetURL) assert.True(t, configuration.TargetTokenSet) assert.Equal(t, "https://source.example/api/v3", configuration.ResolvedSourceURL) assert.True(t, configuration.ResolvedSourceTokenSet) assert.Equal(t, "https://api.target.example", configuration.ResolvedTargetURL) assert.True(t, configuration.ResolvedTargetTokenSet) + stored, err := config.Load() + require.NoError(t, err) + assert.Equal(t, "https://api.target.example", stored.TargetURL) t.Setenv(config.EnvSourceURL, "source-env.example") t.Setenv(config.EnvSourceToken, "source-env-token") From 15433ee5c89ce1ee7848a10ff7cb5032a9904153 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Mon, 31 Aug 2026 19:35:20 +0200 Subject: [PATCH 15/32] Fix TUI popover layout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/tui/model_test.go | 4 +++- internal/tui/view.go | 12 +++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 4f41a63..f116add 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1126,7 +1126,7 @@ func TestModelActions(t *testing.T) { setSourceStatus(model, elmapi.StatusCreated) updated, _ := model.confirmAction( "Cancel migration", - "This cannot be undone.", + "This permanently terminates the source migration and cannot be undone.", screenSourceDetail, func() tea.Msg { return nil }, ) @@ -1136,6 +1136,8 @@ func TestModelActions(t *testing.T) { assert.Contains(t, view, "Migration source-1") assert.Contains(t, view, "Cancel migration") assert.Contains(t, view, "Confirm") + assert.Equal(t, 60, lipgloss.Width(model.confirmationOverlay())) + assert.LessOrEqual(t, lipgloss.Height(model.confirmationOverlay()), 12) updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) model = updated.(*Model) diff --git a/internal/tui/view.go b/internal/tui/view.go index a03bafc..47fa5fc 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -805,14 +805,15 @@ func (m *Model) repositoryInfoPanel(repository elmapi.Repository, closeButton bo func (m *Model) confirmationOverlay() string { width := min(60, max(24, m.contentWidth()-8)) + contentWidth := width - 8 content := m.styles.Bold.Render(m.confirm.title) + "\n\n" + - m.styles.Warning.Render(m.confirm.body) + "\n\n" + + m.styles.Warning.Width(contentWidth).Render(m.confirm.body) + "\n\n" + m.actionButtons( []actionItem{{id: "confirm", label: "Confirm"}, {id: "cancel", label: "Cancel"}}, m.confirm.focus, - width-6, + contentWidth, ) - return lipgloss.NewStyle().Width(width).Render(m.panel(content)) + return m.panel(lipgloss.NewStyle().Width(contentWidth).Render(content)) } func (m *Model) alertOverlay() string { @@ -825,10 +826,11 @@ func (m *Model) resultPopupOverlay() string { func (m *Model) messageOverlay(title, body string) string { width := min(60, max(24, m.contentWidth()-8)) + contentWidth := width - 8 content := m.styles.Bold.Render(title) + "\n\n" + body + "\n\n" + - m.actionButtons([]actionItem{{id: "close", label: "Close"}}, 0, width-6) - return lipgloss.NewStyle().Width(width).Render(m.panel(content)) + m.actionButtons([]actionItem{{id: "close", label: "Close"}}, 0, contentWidth) + return m.panel(lipgloss.NewStyle().Width(contentWidth).Render(content)) } func pickerBounds(cursor, total, capacity int) (start, end int) { From 4307d8dd00248ab8f3ff177bac19ee831aff23f5 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Mon, 31 Aug 2026 19:38:01 +0200 Subject: [PATCH 16/32] Simplify unstarted cutover status Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/render/migration.go | 7 ++++--- internal/render/migration_test.go | 6 +++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/internal/render/migration.go b/internal/render/migration.go index 91a093a..96d051e 100644 --- a/internal/render/migration.go +++ b/internal/render/migration.go @@ -144,7 +144,8 @@ func renderCombinedState(combined *elmapi.CombinedState) string { terminated := terminatedStatus(status) var lines, renderedValues []string normalizedStatus := normalizedValue(status) - if !terminated && normalizedStatus != "" && normalizedStatus != "created" && normalizedStatus != "queued" { + notStarted := normalizedStatus == "created" || normalizedStatus == "queued" + if !terminated && !notStarted && normalizedStatus != "" { lines = append(lines, bullet(statusGlyph(status), statusText(status))) renderedValues = append(renderedValues, status) } @@ -158,11 +159,11 @@ func renderCombinedState(combined *elmapi.CombinedState) string { renderedValues = append(renderedValues, readinessText) } if displayMessage := strings.TrimSpace(combined.DisplayMessage); displayMessage != "" && - !terminated && !containsEquivalentValue(renderedValues, displayMessage) { + !terminated && !notStarted && !containsEquivalentValue(renderedValues, displayMessage) { lines = append(lines, detail(displayMessage)) renderedValues = append(renderedValues, displayMessage) } - if !completed && !terminated { + if !completed && !terminated && !notStarted { for _, blocker := range combined.CutoverBlockers { blocker = strings.TrimSpace(blocker) if blocker == "" || containsEquivalentValue(renderedValues, blocker) { diff --git a/internal/render/migration_test.go b/internal/render/migration_test.go index 36b3ec1..c6f572c 100644 --- a/internal/render/migration_test.go +++ b/internal/render/migration_test.go @@ -186,7 +186,11 @@ Repository states status := "created" output := MigrationStatus(elmapi.MigrationDetail{ - CombinedState: &elmapi.CombinedState{Status: &status}, + CombinedState: &elmapi.CombinedState{ + Status: &status, + DisplayMessage: "Migration created - call StartMigration to begin", + CutoverBlockers: []string{"Migration not started"}, + }, }) assert.Equal(t, `Cutover From cf2e909d542a9291a4b3bfd594cb3c35c26ed791 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Mon, 31 Aug 2026 20:20:38 +0200 Subject: [PATCH 17/32] Improve TUI detail navigation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/tui/model.go | 33 +++++++++++-- internal/tui/model_test.go | 96 +++++++++++++++++++++++++++++++++++--- internal/tui/view.go | 27 ++++++++--- 3 files changed, 137 insertions(+), 19 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 4c6fc63..61671f1 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -530,6 +530,8 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.moveHomeCursor(-1) } else if m.verticalActionScreen() && m.actionFocus > 0 { m.actionFocus-- + } else if m.scrollableScreen() { + return m.updateViewport(msg) } else if !m.actionScreen() && m.cursor > 0 { m.cursor-- } @@ -538,6 +540,8 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.moveHomeCursor(1) } else if m.verticalActionScreen() && m.actionFocus < m.itemCount()-1 { m.actionFocus++ + } else if m.scrollableScreen() { + return m.updateViewport(msg) } else if !m.actionScreen() && m.cursor < m.itemCount()-1 { m.cursor++ } @@ -958,6 +962,14 @@ func (m *Model) activateSourceAction() (tea.Model, tea.Cmd) { switch actions[m.actionFocus].id { case "refresh": return m.refresh() + case "messages": + m.result = resultState{ + title: fmt.Sprintf("Migration %s", m.sourceID), + body: m.sourceMessagesView(), + parent: screenSourceDetail, + } + m.screen = screenResult + m.resetViewport() case "start": return m.confirmAction("Start migration", "Start this migration?", screenSourceDetail, m.sourceMutationCmd("Migration started", m.service.StartSourceMigration)) @@ -997,7 +1009,10 @@ func (m *Model) activateSourceAction() (tea.Model, tea.Cmd) { } func (m *Model) sourceActionItems() []actionItem { - actions := []actionItem{{id: "refresh", label: "Refresh", shortcut: "r"}} + actions := []actionItem{ + {id: "refresh", label: "Refresh", shortcut: "r"}, + {id: "messages", label: "Messages", shortcut: "m"}, + } status := "" if m.sourceDetail != nil && m.sourceDetail.Migration != nil && m.sourceDetail.Migration.Status != nil { @@ -1322,10 +1337,18 @@ func (m *Model) updateViewport(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.viewportReady = true } m.syncViewportSize() - m.viewport.SetContent(m.scrollableContent()) - var command tea.Cmd - m.viewport, command = m.viewport.Update(msg) - return m, command + m.viewport.SetContent(m.wrapViewportContent(m.scrollableContent())) + switch { + case key.Matches(msg, keys.PageUp): + m.viewport.PageUp() + case key.Matches(msg, keys.PageDown): + m.viewport.PageDown() + default: + var command tea.Cmd + m.viewport, command = m.viewport.Update(msg) + return m, command + } + return m, nil } func (m *Model) syncViewportSize() { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index f116add..91b83e0 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -592,22 +592,104 @@ func TestModelNavigationAndLayout(t *testing.T) { assert.NotContains(t, model.View(), "Actions") }) - t.Run("narrow detail preserves all scrollable content", func(t *testing.T) { + t.Run("messages render only on their dedicated page", func(t *testing.T) { model := New(t.Context(), &fakeService{}) model.width = 80 model.height = 24 model.screen = screenSourceDetail + model.sourceID = "source-1" status := elmapi.StatusInProgress model.sourceDetail = &elmapi.MigrationDetail{ Migration: &elmapi.MigrationSummary{MigrationID: "source-1", Status: &status}, Messages: []elmapi.MigrationMessage{ - {Message: strings.Repeat("detail ", 30) + "tail marker"}, + {Message: "tail marker"}, }, } content := model.sourceDetailView() - assert.Contains(t, content, "tail marker") + assert.NotContains(t, content, "tail marker") assert.NotContains(t, content, "Actions") + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}) + model = updated.(*Model) + assert.Equal(t, screenResult, model.screen) + assert.Contains(t, model.View(), "tail marker") + }) + + t.Run("arrow and page keys scroll message content", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.screen = screenSourceDetail + model.sourceID = "source-1" + messages := make([]elmapi.MigrationMessage, 0, 30) + for index := range 30 { + messages = append(messages, elmapi.MigrationMessage{Message: fmt.Sprintf("message-%02d", index)}) + } + model.sourceDetail = &elmapi.MigrationDetail{Messages: messages} + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}) + model = updated.(*Model) + require.Equal(t, screenResult, model.screen) + updated, _ = model.Update(tea.WindowSizeMsg{Width: 80, Height: 16}) + model = updated.(*Model) + + assert.Contains(t, model.View(), "message-00") + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) + model = updated.(*Model) + assert.Equal(t, 1, model.viewport.YOffset) + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyUp}) + model = updated.(*Model) + assert.Zero(t, model.viewport.YOffset) + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{']'}}) + model = updated.(*Model) + + assert.Positive(t, model.viewport.YOffset) + assert.NotContains(t, model.View(), "message-00") + }) + + t.Run("scrollable errors and messages wrap without truncation", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.width = 48 + model.height = 24 + model.screen = screenResult + model.result = resultState{ + title: "Action failed", + body: "The migration could not be started. Check the migration status for complete error details.", + } + + assert.Contains(t, model.View(), "complete error details.") + + model.screen = screenSourceDetail + model.sourceDetail = &elmapi.MigrationDetail{ + Messages: []elmapi.MigrationMessage{{ + Message: "The migration message remains visible through its final wrapped words.", + }}, + } + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}) + model = updated.(*Model) + + assert.Contains(t, model.View(), "final wrapped words.") + }) + + t.Run("arrow keys scroll source migration details", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.screen = screenSourceDetail + repositories := make([]elmapi.CombinedRepositoryState, 30) + for index := range repositories { + repositories[index].RepositoryNWO = fmt.Sprintf("acme/repo-%02d", index) + } + model.sourceDetail = &elmapi.MigrationDetail{ + Migration: &elmapi.MigrationSummary{MigrationID: "source-1"}, + CombinedState: &elmapi.CombinedState{Repositories: repositories}, + } + updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 16}) + model = updated.(*Model) + + assert.Zero(t, model.viewport.YOffset) + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) + model = updated.(*Model) + + assert.Equal(t, 1, model.viewport.YOffset) }) t.Run("detail actions use horizontal focus", func(t *testing.T) { @@ -1154,12 +1236,12 @@ func TestModelActions(t *testing.T) { t.Run("created can start or cancel", func(t *testing.T) { setSourceStatus(model, elmapi.StatusCreated) - assert.ElementsMatch(t, []string{"refresh", "start", "cancel"}, actionIDs(model.sourceActionItems())) + assert.ElementsMatch(t, []string{"refresh", "messages", "start", "cancel"}, actionIDs(model.sourceActionItems())) }) t.Run("in progress can pause force cutover or cancel", func(t *testing.T) { setSourceStatus(model, elmapi.StatusInProgress) - assert.ElementsMatch(t, []string{"refresh", "pause", "force-cutover", "cancel"}, actionIDs(model.sourceActionItems())) + assert.ElementsMatch(t, []string{"refresh", "messages", "pause", "force-cutover", "cancel"}, actionIDs(model.sourceActionItems())) }) t.Run("ready migration offers normal cutover", func(t *testing.T) { @@ -1171,12 +1253,12 @@ func TestModelActions(t *testing.T) { t.Run("paused can resume or cancel", func(t *testing.T) { setSourceStatus(model, elmapi.StatusPaused) - assert.ElementsMatch(t, []string{"refresh", "resume", "cancel"}, actionIDs(model.sourceActionItems())) + assert.ElementsMatch(t, []string{"refresh", "messages", "resume", "cancel"}, actionIDs(model.sourceActionItems())) }) t.Run("completed can revert", func(t *testing.T) { setSourceStatus(model, elmapi.StatusCompleted) - assert.ElementsMatch(t, []string{"refresh", "revert"}, actionIDs(model.sourceActionItems())) + assert.ElementsMatch(t, []string{"refresh", "messages", "revert"}, actionIDs(model.sourceActionItems())) }) }) diff --git a/internal/tui/view.go b/internal/tui/view.go index 47fa5fc..21873b9 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -55,7 +55,7 @@ func (m *Model) View() string { case screenSourceDetail: title = fmt.Sprintf("Migration %s", m.sourceID) body = m.sourceDetailView() - help = helpLine(keys.Left, keys.Open, keys.PageUp, keys.PageDown, keys.Refresh, keys.Back) + help = helpLine(keys.Left, keys.Up, keys.Down, keys.Open, keys.PageUp, keys.PageDown, keys.Refresh, keys.Back) case screenTargetList: title = "Advanced destination migrations" body = m.targetListView() @@ -71,7 +71,7 @@ func (m *Model) View() string { case screenConfiguration: title = "Configuration" body = m.configurationView() - help = helpLine(keys.Left, keys.Open, keys.PageUp, keys.PageDown, keys.Refresh, keys.Back) + help = helpLine(keys.Left, keys.Up, keys.Down, keys.Open, keys.PageUp, keys.PageDown, keys.Refresh, keys.Back) case screenPicker: title = m.picker.title if m.picker.search { @@ -185,7 +185,7 @@ func (m *Model) frame(title, body, help string) string { content := lipgloss.NewStyle().Width(contentWidth).Height(m.bodyHeight()).Render(body) footer := m.styles.Muted.Render(help) return lipgloss.NewStyle().Padding(0, 1).Render( - header + warningBlock + "\n\n" + content + "\n\n" + footer, + header + warningBlock + "\n\n" + content + "\n" + footer, ) } @@ -201,7 +201,7 @@ func (m *Model) contentWidth() int { } func (m *Model) bodyHeight() int { - height := displayHeight(m.height) - 6 + height := displayHeight(m.height) - 5 if warning := m.configurationWarning(); warning != "" { height -= lipgloss.Height(lipgloss.NewStyle().Width(m.contentWidth()).Render(warning)) + 1 } @@ -358,11 +358,20 @@ func (m *Model) sourceMigrationCard(migration elmapi.MigrationSummary, selected func (m *Model) sourceDetailView() string { if m.sourceDetail != nil { - return render.MigrationStatus(*m.sourceDetail) + detail := *m.sourceDetail + detail.Messages = nil + return render.MigrationStatus(detail) } return "" } +func (m *Model) sourceMessagesView() string { + if m.sourceDetail == nil || len(m.sourceDetail.Messages) == 0 { + return "No messages reported." + } + return render.MigrationStatus(elmapi.MigrationDetail{Messages: m.sourceDetail.Messages}) +} + func (m *Model) targetListView() string { if len(m.targetMigrations) == 0 { return "No target migrations found.\n\nPress n for advanced direct creation or m to open a numeric target ID." @@ -902,10 +911,14 @@ func (m *Model) viewportView(body string, height int) string { } view.Width = width view.Height = height - view.SetContent(body) + view.SetContent(m.wrapViewportContent(body)) return view.View() } +func (m *Model) wrapViewportContent(body string) string { + return lipgloss.NewStyle().Width(m.contentWidth()).Render(body) +} + func (m *Model) fullHelpView() string { return strings.Join([]string{ "Global", @@ -918,7 +931,7 @@ func (m *Model) fullHelpView() string { " " + helpLine(keys.New, keys.Manual, keys.Search, keys.Density, keys.Refresh), "", "Scrollable views", - " " + helpLine(keys.PageUp, keys.PageDown), + " " + helpLine(keys.Up, keys.Down, keys.PageUp, keys.PageDown), }, "\n") } From f51d284f91a0dbe339bb545e3e71472745e490e0 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Mon, 31 Aug 2026 21:05:49 +0200 Subject: [PATCH 18/32] Bound advanced migration list loading Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/tui/model.go | 4 +++- internal/tui/model_test.go | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 61671f1..e3dab5d 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -81,6 +81,8 @@ type service interface { configurationService } +const targetListLimit = 100 + type screen int const ( @@ -2095,7 +2097,7 @@ func (m *Model) startTargetListLoad() tea.Cmd { m.targetListCancel = cancel generation := m.targetListGen return func() tea.Msg { - migrations, err := m.service.ListTargetMigrations(ctx, "", 0) + migrations, err := m.service.ListTargetMigrations(ctx, "", targetListLimit) return targetListMsg{migrations: migrations, generation: generation, err: err} } } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 91b83e0..15e128f 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -214,10 +214,10 @@ func TestModelUpdate(t *testing.T) { }) t.Run("cancels a destination migration load and returns home", func(t *testing.T) { - started := make(chan struct{}) + started := make(chan int) service := &fakeService{ - listTargetMigrations: func(ctx context.Context, _ string, _ int) ([]elmapi.TargetMigration, error) { - close(started) + listTargetMigrations: func(ctx context.Context, _ string, maxResults int) ([]elmapi.TargetMigration, error) { + started <- maxResults <-ctx.Done() return nil, ctx.Err() }, @@ -236,7 +236,7 @@ func TestModelUpdate(t *testing.T) { go func() { response <- command() }() - <-started + assert.Equal(t, targetListLimit, <-started) updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEscape}) model = updated.(*Model) From e708d0674d82c59e6783cacf8c6feb8af4a80388 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Mon, 31 Aug 2026 21:09:48 +0200 Subject: [PATCH 19/32] Polish TUI page headers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/tui/model_test.go | 3 ++- internal/tui/view.go | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 15e128f..640eef7 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -206,10 +206,11 @@ func TestModelUpdate(t *testing.T) { view := model.View() warningIndex := strings.Index(view, "Configuration not ready") - titleIndex := strings.Index(view, "GitHub Enterprise") + titleIndex := strings.Index(view, appTitle) require.NotEqual(t, -1, warningIndex) require.NotEqual(t, -1, titleIndex) assert.Less(t, titleIndex, warningIndex) + assert.Contains(t, view, homeTitle) assert.Contains(t, view, "destination URL, destination token") }) diff --git a/internal/tui/view.go b/internal/tui/view.go index 21873b9..4d893d8 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -13,7 +13,10 @@ import ( "github.com/github/gh-elm/internal/workflow" ) -const homeTitle = "Live migrations" +const ( + appTitle = "GHE Live Migrations" + homeTitle = "Main menu" +) // View implements tea.Model. func (m *Model) View() string { @@ -168,11 +171,8 @@ func (m *Model) View() string { func (m *Model) frame(title, body, help string) string { contentWidth := m.contentWidth() - brand := m.styles.Primary.Bold(true).Render("GitHub Enterprise") - subtitle := m.styles.Info.Bold(true).Render(title) - if title == homeTitle { - subtitle = m.styles.Success.Render(title) - } + brand := m.styles.Primary.Bold(true).Render(appTitle) + subtitle := m.styles.Muted.Bold(false).Render(title) header := brand + "\n" + subtitle + "\n" + m.styles.Muted.Render(strings.Repeat("─", contentWidth)) From 03b44f30457cbe5db421adf1dc4e7a0e22481747 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Tue, 1 Sep 2026 12:35:25 +0200 Subject: [PATCH 20/32] Avoid loading flash during migration refresh Keep existing migration details visible until refreshed values arrive, and preserve them when a refresh fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/tui/model.go | 35 +++++++++------ internal/tui/model_test.go | 91 ++++++++++++++++++++++++++++++++++++++ internal/tui/view.go | 2 +- 3 files changed, 114 insertions(+), 14 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index e3dab5d..7385761 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -200,17 +200,18 @@ type Model struct { service service styles theme.Styles - screen screen - width int - height int - cursor int - homeCursorSet bool - actionFocus int - loading bool - err error - viewport viewport.Model - viewportReady bool - showHelp bool + screen screen + width int + height int + cursor int + homeCursorSet bool + actionFocus int + loading bool + refreshingDetail bool + err error + viewport viewport.Model + viewportReady bool + showHelp bool sourceMigrations []elmapi.MigrationSummary sourceListGen uint64 @@ -316,7 +317,9 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if m.showConfigurationAlert(msg.err) { return m, nil } + refreshing := m.refreshingDetail m.loading = false + m.refreshingDetail = false m.err = msg.err if msg.err == nil { m.sourceDetail = msg.detail @@ -325,7 +328,7 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.targetID = workflow.TargetMigrationID(msg.detail.Migration.TargetMigrationID) } m.clampActionFocus() - } else { + } else if !refreshing { m.sourceDetail = nil m.targetID = 0 } @@ -347,7 +350,9 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if m.showConfigurationAlert(msg.err) { return m, nil } + refreshing := m.refreshingDetail m.loading = false + m.refreshingDetail = false m.err = msg.err if msg.err == nil { m.targetDetail = msg.migration @@ -356,7 +361,7 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.repository = msg.migration.Repositories[0] } m.clampActionFocus() - } else { + } else if !refreshing { m.targetDetail = nil m.repository = "" } @@ -816,6 +821,7 @@ func (m *Model) back() (tea.Model, tea.Cmd) { func (m *Model) refresh() (tea.Model, tea.Cmd) { m.err = nil + m.refreshingDetail = false switch m.screen { case screenSourceList: m.loading = true @@ -823,6 +829,7 @@ func (m *Model) refresh() (tea.Model, tea.Cmd) { return m, command case screenSourceDetail: m.loading = true + m.refreshingDetail = true command := m.loadSourceDetailCmd() return m, command case screenTargetList: @@ -831,6 +838,7 @@ func (m *Model) refresh() (tea.Model, tea.Cmd) { return m, command case screenTargetDetail: m.loading = true + m.refreshingDetail = true command := m.loadTargetDetailCmd() return m, command case screenConfiguration: @@ -1222,6 +1230,7 @@ func (m *Model) showConfigurationAlert(err error) bool { parent: m.screen, } m.loading = false + m.refreshingDetail = false m.err = nil m.pickerInfoOpen = false m.screen = screenAlert diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 640eef7..1fbea13 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -376,6 +376,97 @@ func TestModelUpdate(t *testing.T) { assert.Empty(t, model.repository) }) + t.Run("detail refresh keeps current values visible until replacements arrive", func(t *testing.T) { + t.Run("source migration", func(t *testing.T) { + completed := elmapi.StatusCompleted + service := &fakeService{ + getSourceMigration: func(context.Context, workflow.SourceMigrationID) (*elmapi.MigrationDetail, error) { + return &elmapi.MigrationDetail{ + Migration: &elmapi.MigrationSummary{MigrationID: "source-1", Status: &completed}, + }, nil + }, + } + model := New(t.Context(), service) + model.screen = screenSourceDetail + model.sourceID = "source-1" + setSourceStatus(model, elmapi.StatusCreated) + + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + model = updated.(*Model) + + require.NotNil(t, command) + assert.True(t, model.loading) + assert.True(t, model.refreshingDetail) + assert.NotContains(t, model.View(), "Loading…") + assert.Contains(t, model.View(), "source-1") + + updated, _ = model.Update(command()) + model = updated.(*Model) + + assert.False(t, model.loading) + assert.False(t, model.refreshingDetail) + require.NotNil(t, model.sourceDetail.Migration.Status) + assert.Equal(t, elmapi.StatusCompleted, *model.sourceDetail.Migration.Status) + }) + + t.Run("destination migration", func(t *testing.T) { + service := &fakeService{ + getTargetMigration: func(context.Context, workflow.TargetMigrationID) (*elmapi.TargetMigration, error) { + return &elmapi.TargetMigration{ + Status: elmapi.TargetMigrationStatusComplete, + Repositories: []string{"new/repository"}, + }, nil + }, + } + model := New(t.Context(), service) + model.screen = screenTargetDetail + model.targetID = 42 + model.targetDetail = &elmapi.TargetMigration{ + Status: elmapi.TargetMigrationStatusInProgress, + Repositories: []string{"old/repository"}, + } + + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + model = updated.(*Model) + + require.NotNil(t, command) + assert.True(t, model.loading) + assert.True(t, model.refreshingDetail) + assert.NotContains(t, model.View(), "Loading…") + assert.Contains(t, model.View(), "old/repository") + + updated, _ = model.Update(command()) + model = updated.(*Model) + + assert.False(t, model.loading) + assert.False(t, model.refreshingDetail) + assert.Equal(t, elmapi.TargetMigrationStatusComplete, model.targetDetail.Status) + assert.Equal(t, []string{"new/repository"}, model.targetDetail.Repositories) + }) + }) + + t.Run("failed detail refresh preserves current values", func(t *testing.T) { + model := New(t.Context(), &fakeService{ + getSourceMigration: func(context.Context, workflow.SourceMigrationID) (*elmapi.MigrationDetail, error) { + return nil, assert.AnError + }, + }) + model.screen = screenSourceDetail + model.sourceID = "source-1" + setSourceStatus(model, elmapi.StatusCreated) + + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + model = updated.(*Model) + require.NotNil(t, command) + + updated, _ = model.Update(command()) + model = updated.(*Model) + + require.NotNil(t, model.sourceDetail) + assert.Contains(t, model.View(), "source-1") + assert.Contains(t, model.View(), assert.AnError.Error()) + }) + t.Run("configuration save reloads source migrations", func(t *testing.T) { svc := &fakeService{ saveConfiguration: func(context.Context, workflow.ConfigurationInput) error { diff --git a/internal/tui/view.go b/internal/tui/view.go index 4d893d8..a3d8db2 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -110,7 +110,7 @@ func (m *Model) View() string { body = m.fullHelpView() help = "? close • q quit" } - if m.loading { + if m.loading && !m.refreshingDetail { body = m.styles.Active.Render("Loading…") } if m.err != nil { From b311346b8045ca24aa25ccd48ba4064d364a635b Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Tue, 1 Sep 2026 15:47:08 +0200 Subject: [PATCH 21/32] Refine migration actions and form editing Hide refresh and messages until source migrations start, and support terminal-style Alt+Delete word removal in editable form fields. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/tui/model.go | 50 +++++++++++++++++++++++++++++++------ internal/tui/model_test.go | 51 +++++++++++++++++++++++++++++++++++--- internal/tui/view.go | 9 ++++--- 3 files changed, 96 insertions(+), 14 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 7385761..47f7949 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -11,6 +11,7 @@ import ( "slices" "strconv" "strings" + "unicode" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" @@ -553,6 +554,9 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.cursor++ } case key.Matches(msg, keys.Refresh): + if m.screen == screenSourceDetail && !m.sourceMigrationStarted() { + return m, nil + } return m.refresh() case key.Matches(msg, keys.Open): return m.activate() @@ -1019,14 +1023,13 @@ func (m *Model) activateSourceAction() (tea.Model, tea.Cmd) { } func (m *Model) sourceActionItems() []actionItem { - actions := []actionItem{ - {id: "refresh", label: "Refresh", shortcut: "r"}, - {id: "messages", label: "Messages", shortcut: "m"}, - } - - status := "" - if m.sourceDetail != nil && m.sourceDetail.Migration != nil && m.sourceDetail.Migration.Status != nil { - status = normalizedStatus(*m.sourceDetail.Migration.Status) + status := m.sourceMigrationStatus() + var actions []actionItem + if m.sourceMigrationStarted() { + actions = append(actions, + actionItem{id: "refresh", label: "Refresh", shortcut: "r"}, + actionItem{id: "messages", label: "Messages", shortcut: "m"}, + ) } readyForCutover := m.sourceDetail != nil && m.sourceDetail.CombinedState != nil && @@ -1060,6 +1063,17 @@ func (m *Model) sourceActionItems() []actionItem { return actions } +func (m *Model) sourceMigrationStatus() string { + if m.sourceDetail == nil || m.sourceDetail.Migration == nil || m.sourceDetail.Migration.Status == nil { + return "" + } + return normalizedStatus(*m.sourceDetail.Migration.Status) +} + +func (m *Model) sourceMigrationStarted() bool { + return m.sourceMigrationStatus() != "created" +} + func (m *Model) activateTargetAction() (tea.Model, tea.Cmd) { actions := m.targetActionItems() if m.actionFocus < 0 || m.actionFocus >= len(actions) { @@ -1455,6 +1469,14 @@ func (m *Model) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { runes := []rune(*field.text) *field.text = string(runes[:len(runes)-1]) } + case "alt+backspace", "alt+delete": + if onActions { + return m, nil + } + field := &m.form.fields[m.form.cursor] + if field.text != nil && (field.kind == fieldText || field.kind == fieldSecret) { + *field.text = deletePreviousChunk(*field.text) + } case "enter": if m.form.cursor < len(m.form.fields)-1 { m.form.cursor++ @@ -1480,6 +1502,18 @@ func (m *Model) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func deletePreviousChunk(value string) string { + runes := []rune(value) + end := len(runes) + for end > 0 && unicode.IsSpace(runes[end-1]) { + end-- + } + for end > 0 && !unicode.IsSpace(runes[end-1]) { + end-- + } + return string(runes[:end]) +} + func cycleOption(field *formField, delta int) { if field.kind != fieldSelect || field.text == nil || len(field.options) == 0 { return diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 1fbea13..bfc42bd 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -389,7 +389,7 @@ func TestModelUpdate(t *testing.T) { model := New(t.Context(), service) model.screen = screenSourceDetail model.sourceID = "source-1" - setSourceStatus(model, elmapi.StatusCreated) + setSourceStatus(model, elmapi.StatusInProgress) updated, command := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) model = updated.(*Model) @@ -453,7 +453,7 @@ func TestModelUpdate(t *testing.T) { }) model.screen = screenSourceDetail model.sourceID = "source-1" - setSourceStatus(model, elmapi.StatusCreated) + setSourceStatus(model, elmapi.StatusInProgress) updated, command := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) model = updated.(*Model) @@ -931,6 +931,41 @@ func TestModelNavigationAndLayout(t *testing.T) { assert.LessOrEqual(t, lipgloss.Height(view), model.bodyHeight()) }) + t.Run("form alt delete removes the previous chunk", func(t *testing.T) { + t.Run("backspace event", func(t *testing.T) { + value := "owner/repository migration " + model := New(t.Context(), &fakeService{}) + model.screen = screenForm + model.form = formState{fields: []formField{textFormField("Value", "", &value)}} + + _, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}) + + assert.Equal(t, "owner/repository ", value) + }) + + t.Run("delete event", func(t *testing.T) { + value := "owner/repository migration" + model := New(t.Context(), &fakeService{}) + model.screen = screenForm + model.form = formState{fields: []formField{textFormField("Value", "", &value)}} + + _, _ = model.Update(tea.KeyMsg{Type: tea.KeyDelete, Alt: true}) + + assert.Equal(t, "owner/repository ", value) + }) + }) + + t.Run("form alt delete does not change a selection", func(t *testing.T) { + value := "internal" + model := New(t.Context(), &fakeService{}) + model.screen = screenForm + model.form = formState{fields: []formField{selectFormField("Visibility", &value, "internal", "private")}} + + _, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}) + + assert.Equal(t, "internal", value) + }) + t.Run("dynamic warnings synchronize viewport height", func(t *testing.T) { model := New(t.Context(), &fakeService{}) updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) @@ -1325,10 +1360,20 @@ func TestModelActions(t *testing.T) { t.Run("source actions follow migration state", func(t *testing.T) { model := New(t.Context(), &fakeService{}) + model.screen = screenSourceDetail + model.sourceID = "source-1" t.Run("created can start or cancel", func(t *testing.T) { setSourceStatus(model, elmapi.StatusCreated) - assert.ElementsMatch(t, []string{"refresh", "messages", "start", "cancel"}, actionIDs(model.sourceActionItems())) + assert.ElementsMatch(t, []string{"start", "cancel"}, actionIDs(model.sourceActionItems())) + assert.NotContains(t, model.View(), "Refresh") + assert.NotContains(t, model.View(), "Messages") + assert.NotContains(t, model.View(), "r refresh") + + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + model = updated.(*Model) + assert.Nil(t, command) + assert.False(t, model.loading) }) t.Run("in progress can pause force cutover or cancel", func(t *testing.T) { diff --git a/internal/tui/view.go b/internal/tui/view.go index a3d8db2..d7807e3 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -58,7 +58,10 @@ func (m *Model) View() string { case screenSourceDetail: title = fmt.Sprintf("Migration %s", m.sourceID) body = m.sourceDetailView() - help = helpLine(keys.Left, keys.Up, keys.Down, keys.Open, keys.PageUp, keys.PageDown, keys.Refresh, keys.Back) + help = helpLine(keys.Left, keys.Up, keys.Down, keys.Open, keys.PageUp, keys.PageDown, keys.Back) + if m.sourceMigrationStarted() { + help = helpLine(keys.Left, keys.Up, keys.Down, keys.Open, keys.PageUp, keys.PageDown, keys.Refresh, keys.Back) + } case screenTargetList: title = "Advanced destination migrations" body = m.targetListView() @@ -95,9 +98,9 @@ func (m *Model) View() string { case screenForm: title = m.form.title body = m.formView() - help = "tab/↑/↓ fields • type edit • ←/→ choose • space toggle • enter continue • esc cancel" + help = "tab/↑/↓ fields • type edit • alt+del word • ←/→ choose • space toggle • enter continue • esc cancel" if len(m.form.actions) > 0 { - help = "tab/↑/↓ fields/actions • type edit • ←/→ choose • space toggle • enter activate • esc cancel" + help = "tab/↑/↓ fields/actions • type edit • alt+del word • ←/→ choose • space toggle • enter activate • esc cancel" } case screenResult: title = m.result.title From 1556d43978891010854bf944511c3cf9728005e2 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Tue, 1 Sep 2026 18:29:18 +0200 Subject: [PATCH 22/32] Polish migration state and list rendering Keep migration details and lists current without loading flashes, improve migration headers and creation results, and refine cancellation labels and list overflow layout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/cmd/migration/watch/phases.go | 2 +- internal/cmd/migration/watch/view.go | 2 +- internal/cmd/migration/watch/watch_test.go | 13 + internal/render/migration.go | 3 + internal/render/migration_test.go | 1 + internal/tui/model.go | 97 ++++-- internal/tui/model_test.go | 332 ++++++++++++++++++++- internal/tui/view.go | 145 +++++++-- 8 files changed, 526 insertions(+), 69 deletions(-) diff --git a/internal/cmd/migration/watch/phases.go b/internal/cmd/migration/watch/phases.go index cd1d025..8b09756 100644 --- a/internal/cmd/migration/watch/phases.go +++ b/internal/cmd/migration/watch/phases.go @@ -88,7 +88,7 @@ func (o Overlay) String() string { case OverlayFailed: return "Failed" case OverlayTerminated: - return "Terminated" + return "Cancelled" case OverlayPaused: return "Paused" case OverlayDegraded: diff --git a/internal/cmd/migration/watch/view.go b/internal/cmd/migration/watch/view.go index 8aec740..56cab33 100644 --- a/internal/cmd/migration/watch/view.go +++ b/internal/cmd/migration/watch/view.go @@ -235,7 +235,7 @@ func (m Model) phaseDetail(p Phase) string { case p == PhaseCompleted && m.basePhase == PhaseCompleted && m.overlay == OverlayNone: return m.completedDetail() case p == PhaseCompleted && m.overlay == OverlayTerminated: - return m.styles.Failure.Render("Migration terminated") + return m.styles.Failure.Render("Migration cancelled") case p == PhaseCompleted && m.overlay == OverlayFailed: return m.failedDetail() } diff --git a/internal/cmd/migration/watch/watch_test.go b/internal/cmd/migration/watch/watch_test.go index 709e148..44baad9 100644 --- a/internal/cmd/migration/watch/watch_test.go +++ b/internal/cmd/migration/watch/watch_test.go @@ -138,4 +138,17 @@ func TestView(t *testing.T) { m := New("id", time.Second, nil) assert.Contains(t, m.View(), "Loading migration status") }) + + t.Run("terminated migration displays as cancelled", func(t *testing.T) { + m := New("id", time.Second, nil) + m.detail = combined(combinedTerminated) + m.basePhase, m.overlay = DerivePhase(m.detail) + + output := m.View() + + assert.Equal(t, "Cancelled", OverlayTerminated.String()) + assert.Contains(t, output, "Migration cancelled") + assert.NotContains(t, output, "Terminated") + assert.NotContains(t, output, "Migration terminated") + }) } diff --git a/internal/render/migration.go b/internal/render/migration.go index 96d051e..219745a 100644 --- a/internal/render/migration.go +++ b/internal/render/migration.go @@ -444,6 +444,9 @@ func statusGlyph(status string) string { func statusText(status string) string { styles := theme.New() text := friendlyValue(status) + if normalizedValue(status) == "terminated" { + text = "Cancelled" + } switch strings.ToLower(status) { case "completed", "complete", "success", "succeeded", "ready_for_cutover", "ready for cutover": return styles.Success.Bold(true).Render(text) diff --git a/internal/render/migration_test.go b/internal/render/migration_test.go index c6f572c..6ab7733 100644 --- a/internal/render/migration_test.go +++ b/internal/render/migration_test.go @@ -369,6 +369,7 @@ func TestStatusPresentation(t *testing.T) { assert.Contains(t, failureState(false, "available", "unavailable").glyph, styles.Failure.Render("✗")) assert.Equal(t, styles.Success.Bold(true).Render("Completed"), statusText("completed")) assert.Equal(t, styles.Failure.Bold(true).Render("Failed"), statusText("failed")) + assert.Equal(t, styles.Failure.Bold(true).Render("Cancelled"), statusText("terminated")) }) } diff --git a/internal/tui/model.go b/internal/tui/model.go index 47f7949..b0209a3 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -191,6 +191,7 @@ type resultState struct { body string parent screen popup bool + blankBackground bool refresh bool reloadSourceList bool } @@ -231,20 +232,21 @@ type Model struct { targetListCancel context.CancelFunc targetListGen uint64 - configuration *workflow.Configuration - configurationErr error - configGeneration uint64 - sourceAuthChecked bool - sourceAuthErr error - targetAuthChecked bool - targetAuthErr error - picker pickerState - pickerGeneration uint64 - pickerInfoOpen bool - form formState - confirm confirmState - alert alertState - result resultState + configuration *workflow.Configuration + configurationErr error + configurationLoading bool + configGeneration uint64 + sourceAuthChecked bool + sourceAuthErr error + targetAuthChecked bool + targetAuthErr error + picker pickerState + pickerGeneration uint64 + pickerInfoOpen bool + form formState + confirm confirmState + alert alertState + result resultState } // New creates the main TUI model. @@ -255,12 +257,13 @@ func New(ctx context.Context, svc service) *Model { searchInput.CharLimit = 160 model := &Model{ - ctx: ctx, - service: svc, - styles: theme.New(), - screen: screenHome, - targetParent: screenTargetList, - searchInput: searchInput, + ctx: ctx, + service: svc, + styles: theme.New(), + screen: screenHome, + targetParent: screenTargetList, + searchInput: searchInput, + configurationLoading: true, } model.syncHomeCursor() return model @@ -310,8 +313,11 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } if msg.err == nil { m.sourceMigrations = msg.migrations + if m.sourceMigrations == nil { + m.sourceMigrations = []elmapi.MigrationSummary{} + } if m.screen == screenSourceList { - m.cursor = 0 + m.cursor = min(max(m.cursor, 0), max(len(m.visibleSourceMigrations())-1, 0)) } } case sourceDetailMsg: @@ -370,6 +376,7 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if msg.generation != m.configGeneration { return m, nil } + m.configurationLoading = false m.configurationErr = msg.err if m.screen == screenConfiguration { m.loading = false @@ -445,11 +452,17 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } m.loading = false m.err = nil + var command tea.Cmd if msg.err != nil { m.result = resultState{title: "Action failed", body: msg.err.Error(), parent: msg.parent} + if msg.refresh && msg.parent == screenSourceDetail { + command = tea.Batch(m.loadSourceDetailCmd(), m.startSourceListLoad()) + } } else { if msg.sourceID != "" { m.sourceID = msg.sourceID + m.sourceDetail = nil + m.targetID = 0 } if msg.reloadSourceList { m.invalidateSourceList() @@ -459,6 +472,7 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { body: msg.body, parent: msg.parent, popup: msg.popup, + blankBackground: msg.sourceID != "", refresh: msg.refresh, reloadSourceList: msg.reloadSourceList, } @@ -466,6 +480,7 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.screen = screenResult m.cursor = 0 m.resetViewport() + return m, command case confirmRequestMsg: m.loading = false m.confirm = confirmState{ @@ -737,7 +752,7 @@ func (m *Model) activate() (tea.Model, tea.Cmd) { } switch actions[m.cursor].id { case "migrations": - m.screen, m.loading, m.err = screenSourceList, true, nil + m.screen, m.loading, m.err = screenSourceList, m.sourceMigrations == nil, nil return m, m.startSourceListLoad() case "create": return m.openSourceCreateForm(screenHome) @@ -803,6 +818,7 @@ func (m *Model) back() (tea.Model, tea.Cmd) { m.err = nil m.cursor = 0 m.actionFocus = 0 + var command tea.Cmd switch m.screen { case screenSourceList: m.sourceSearch = false @@ -813,6 +829,7 @@ func (m *Model) back() (tea.Model, tea.Cmd) { m.screen = screenHome case screenSourceDetail: m.screen = screenSourceList + command = m.startSourceListLoad() case screenTargetDetail: m.screen = m.targetParent } @@ -820,7 +837,7 @@ func (m *Model) back() (tea.Model, tea.Cmd) { m.homeCursorSet = false m.syncHomeCursor() } - return m, nil + return m, command } func (m *Model) refresh() (tea.Model, tea.Cmd) { @@ -828,7 +845,7 @@ func (m *Model) refresh() (tea.Model, tea.Cmd) { m.refreshingDetail = false switch m.screen { case screenSourceList: - m.loading = true + m.loading = false command := m.startSourceListLoad() return m, command case screenSourceDetail: @@ -938,6 +955,20 @@ func (m *Model) configurationReady() bool { m.targetAuthErr == nil } +func (m *Model) configurationCheckPending() bool { + if m.configurationLoading { + return true + } + if m.configuration == nil || m.configurationErr != nil { + return false + } + sourceURL, sourceTokenSet := effectiveSourceConfiguration(m.configuration) + targetURL, targetTokenSet := effectiveTargetConfiguration(m.configuration) + sourcePending := validHTTPURL(sourceURL) && sourceTokenSet && !m.sourceAuthChecked + targetPending := validHTTPURL(targetURL) && targetTokenSet && !m.targetAuthChecked + return sourcePending || targetPending +} + func (m *Model) moveHomeCursor(delta int) { actions := m.homeActionItems() for index := m.cursor + delta; index >= 0 && index < len(actions); index += delta { @@ -1697,17 +1728,18 @@ func (m *Model) createSourceMigrationCmd(input workflow.SourceCreateInput) tea.C return actionMsg{parent: screenSourceList, err: err} } sourceID := workflow.SourceMigrationID(result.Migration.MigrationID) - body := render.MigrationCreate(result.Migration) + title := "Migration created" if result.Started { - body = fmt.Sprintf("Migration %s created and started.", result.Migration.MigrationID) + title = "Migration created and started" } return actionMsg{ - title: "Migration created", - body: body, - parent: screenSourceDetail, - popup: true, - refresh: true, - sourceID: sourceID, + title: title, + body: m.migrationCreatedBody(result.Migration), + parent: screenSourceDetail, + popup: true, + refresh: true, + reloadSourceList: true, + sourceID: sourceID, } } } @@ -2164,6 +2196,7 @@ func (m *Model) loadTargetDetailCmd() tea.Cmd { } func (m *Model) startConfigurationLoad() tea.Cmd { + m.configurationLoading = true m.configGeneration++ generation := m.configGeneration return func() tea.Msg { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index bfc42bd..acbb04e 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -2,6 +2,7 @@ package tui import ( "context" + "errors" "fmt" "strings" "testing" @@ -31,6 +32,7 @@ func TestModelUpdate(t *testing.T) { } assert.NotContains(t, model.View(), "(disabled)") assert.Contains(t, model.View(), model.styles.Disabled.Render("Migrations")) + assert.Contains(t, model.View(), "Checking configuration…") assert.Equal(t, 3, model.cursor) model.cursor = 0 @@ -55,6 +57,33 @@ func TestModelUpdate(t *testing.T) { for _, action := range model.homeActionItems() { assert.False(t, action.disabled) } + assert.NotContains(t, model.View(), "Checking configuration…") + }) + + t.Run("home shows configuration checking through authentication preflight", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.configGeneration = 1 + + updated, _ := model.Update(configMsg{ + configuration: &workflow.Configuration{ + SourceURL: "https://source.example", + SourceTokenSet: true, + TargetURL: "https://target.example", + TargetTokenSet: true, + }, + generation: 1, + }) + model = updated.(*Model) + + assert.Contains(t, model.View(), "Checking configuration…") + + updated, _ = model.Update(sourceAuthenticationMsg{generation: 1}) + model = updated.(*Model) + assert.Contains(t, model.View(), "Checking configuration…") + + updated, _ = model.Update(targetAuthenticationMsg{generation: 1}) + model = updated.(*Model) + assert.NotContains(t, model.View(), "Checking configuration…") }) t.Run("opening migrations fetches a fresh list with every status", func(t *testing.T) { @@ -72,7 +101,9 @@ func TestModelUpdate(t *testing.T) { model = updated.(*Model) assert.Equal(t, screenSourceList, model.screen) - assert.True(t, model.loading) + assert.False(t, model.loading) + assert.Contains(t, model.View(), "stale") + assert.NotContains(t, model.View(), "Loading…") require.NotNil(t, command) updated, _ = model.Update(command()) @@ -83,6 +114,88 @@ func TestModelUpdate(t *testing.T) { assert.Equal(t, "fresh", model.sourceMigrations[0].MigrationID) }) + t.Run("first migration list load shows loading", func(t *testing.T) { + service := &fakeService{ + listSourceMigrations: func(context.Context, string) ([]elmapi.MigrationSummary, error) { + return nil, nil + }, + } + model := New(t.Context(), service) + setConfigurationReady(model) + + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(*Model) + + assert.True(t, model.loading) + assert.Contains(t, model.View(), "Loading…") + require.NotNil(t, command) + }) + + t.Run("refreshing migrations keeps the current list visible", func(t *testing.T) { + service := &fakeService{ + listSourceMigrations: func(context.Context, string) ([]elmapi.MigrationSummary, error) { + return []elmapi.MigrationSummary{ + {MigrationID: "fresh-1"}, + {MigrationID: "fresh-2"}, + }, nil + }, + } + model := New(t.Context(), service) + model.screen = screenSourceList + model.sourceMigrations = []elmapi.MigrationSummary{ + {MigrationID: "stale-1"}, + {MigrationID: "stale-2"}, + } + model.cursor = 1 + + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + model = updated.(*Model) + + require.NotNil(t, command) + assert.False(t, model.loading) + assert.Contains(t, model.View(), "stale-2") + assert.NotContains(t, model.View(), "Loading…") + + updated, _ = model.Update(command()) + model = updated.(*Model) + + assert.Contains(t, model.View(), "fresh-2") + assert.NotContains(t, model.View(), "stale-2") + assert.Equal(t, 1, model.cursor) + }) + + t.Run("returning to migrations refreshes the list in place", func(t *testing.T) { + failed := elmapi.StatusFailed + service := &fakeService{ + listSourceMigrations: func(_ context.Context, status string) ([]elmapi.MigrationSummary, error) { + assert.Equal(t, elmapi.StatusAll, status) + return []elmapi.MigrationSummary{{MigrationID: "source-1", Status: &failed}}, nil + }, + } + model := New(t.Context(), service) + model.screen = screenSourceDetail + model.sourceID = "source-1" + setSourceStatus(model, elmapi.StatusCreated) + model.sourceMigrations = []elmapi.MigrationSummary{*model.sourceDetail.Migration} + + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEscape}) + model = updated.(*Model) + + assert.Equal(t, screenSourceList, model.screen) + require.NotNil(t, command) + assert.False(t, model.loading) + assert.Contains(t, model.View(), "Created") + assert.NotContains(t, model.View(), "Loading…") + + updated, _ = model.Update(command()) + model = updated.(*Model) + + require.Len(t, model.sourceMigrations, 1) + require.NotNil(t, model.sourceMigrations[0].Status) + assert.Equal(t, elmapi.StatusFailed, *model.sourceMigrations[0].Status) + assert.Contains(t, model.View(), "Failed") + }) + t.Run("runtime configuration loss returns home through an alert", func(t *testing.T) { service := &fakeService{ getConfiguration: func(context.Context) (*workflow.Configuration, error) { @@ -467,6 +580,52 @@ func TestModelUpdate(t *testing.T) { assert.Contains(t, model.View(), assert.AnError.Error()) }) + t.Run("failed source action refreshes detail and migration list in the background", func(t *testing.T) { + failed := elmapi.StatusFailed + service := &fakeService{ + getSourceMigration: func(_ context.Context, id workflow.SourceMigrationID) (*elmapi.MigrationDetail, error) { + assert.Equal(t, workflow.SourceMigrationID("source-1"), id) + return &elmapi.MigrationDetail{ + Migration: &elmapi.MigrationSummary{MigrationID: "source-1", Status: &failed}, + }, nil + }, + listSourceMigrations: func(_ context.Context, status string) ([]elmapi.MigrationSummary, error) { + assert.Equal(t, elmapi.StatusAll, status) + return []elmapi.MigrationSummary{{MigrationID: "source-1", Status: &failed}}, nil + }, + } + model := New(t.Context(), service) + model.screen = screenSourceDetail + model.sourceID = "source-1" + setSourceStatus(model, elmapi.StatusCreated) + model.sourceMigrations = []elmapi.MigrationSummary{*model.sourceDetail.Migration} + + updated, command := model.Update(actionMsg{ + parent: screenSourceDetail, + refresh: true, + err: errors.New("starting migration: HTTP 422"), + }) + model = updated.(*Model) + + assert.Equal(t, screenResult, model.screen) + assert.Contains(t, model.View(), "HTTP 422") + require.NotNil(t, command) + + batch, ok := command().(tea.BatchMsg) + require.True(t, ok) + for _, batchCommand := range batch { + updated, _ = model.Update(batchCommand()) + model = updated.(*Model) + } + + assert.Equal(t, screenResult, model.screen) + require.NotNil(t, model.sourceDetail.Migration.Status) + assert.Equal(t, elmapi.StatusFailed, *model.sourceDetail.Migration.Status) + require.Len(t, model.sourceMigrations, 1) + require.NotNil(t, model.sourceMigrations[0].Status) + assert.Equal(t, elmapi.StatusFailed, *model.sourceMigrations[0].Status) + }) + t.Run("configuration save reloads source migrations", func(t *testing.T) { svc := &fakeService{ saveConfiguration: func(context.Context, workflow.ConfigurationInput) error { @@ -684,6 +843,31 @@ func TestModelNavigationAndLayout(t *testing.T) { assert.NotContains(t, model.View(), "Actions") }) + t.Run("source detail moves repository names into the header", func(t *testing.T) { + status := elmapi.StatusInProgress + model := New(t.Context(), &fakeService{}) + model.width = 100 + model.height = 30 + model.screen = screenSourceDetail + model.sourceID = "d2430eb8-eb8f-4ffd-8907-cfa23f662302" + model.sourceDetail = &elmapi.MigrationDetail{ + Migration: &elmapi.MigrationSummary{ + MigrationID: string(model.sourceID), + Status: &status, + SourceOrganizationLogin: "acme-corp", + SourceRepositoryName: "api-gateway", + TargetOrganizationLogin: "acme-cloud", + TargetRepositoryName: "api-gateway", + }, + } + + view := model.View() + + assert.Contains(t, view, "Migration · acme-corp/api-gateway → acme-cloud/api-gateway") + assert.NotContains(t, model.sourceDetailView(), "acme-corp/api-gateway → acme-cloud/api-gateway") + assert.Contains(t, model.sourceDetailView(), "In progress") + }) + t.Run("messages render only on their dedicated page", func(t *testing.T) { model := New(t.Context(), &fakeService{}) model.width = 80 @@ -909,6 +1093,117 @@ func TestModelNavigationAndLayout(t *testing.T) { assert.Equal(t, lipgloss.Height(unselected), lipgloss.Height(selected)) }) + t.Run("migration list fills the available body height", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.width = 160 + model.height = 32 + model.screen = screenSourceList + for index := range 8 { + model.sourceMigrations = append(model.sourceMigrations, elmapi.MigrationSummary{ + MigrationID: fmt.Sprintf("migration-%d", index), + }) + } + + start, end := model.sourceListBounds(len(model.sourceMigrations)) + view := model.sourceListView() + + assert.Zero(t, start) + assert.Equal(t, len(model.sourceMigrations), end) + assert.NotContains(t, view, "more") + assert.LessOrEqual(t, lipgloss.Height(view), model.bodyHeight()) + }) + + t.Run("migration list displays both overflow indicators outside the list", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.width = 160 + model.height = 20 + model.screen = screenSourceList + model.densityUserSet = true + model.compact = false + model.cursor = 10 + for index := range 20 { + model.sourceMigrations = append(model.sourceMigrations, elmapi.MigrationSummary{ + MigrationID: fmt.Sprintf("migration-%d", index), + }) + } + + start, end := model.sourceListBounds(len(model.sourceMigrations)) + view := model.sourceListView() + topLine := model.sourceListTopLine() + bottomLine := model.sourceListBottomLine() + + assert.Positive(t, start) + assert.Less(t, end, len(model.sourceMigrations)) + assert.Contains(t, topLine, "↑") + assert.Contains(t, bottomLine, "↓") + assert.NotContains(t, view, "more") + assert.LessOrEqual(t, lipgloss.Height(view), model.bodyHeight()) + firstCardLine := strings.Split(model.sourceMigrationCard(model.sourceMigrations[start], false), "\n")[0] + assert.Equal(t, firstCardLine, strings.Split(view, "\n")[0]) + + renderedLines := strings.Split(model.View(), "\n") + indicatorLine := -1 + firstCard := -1 + lowerIndicatorLine := -1 + footerLine := -1 + for index, line := range renderedLines { + if indicatorLine == -1 && strings.Contains(line, "↑") && strings.Contains(line, "more") { + indicatorLine = index + } + if strings.Contains(line, "↓") && strings.Contains(line, "more") { + lowerIndicatorLine = index + } + if strings.Contains(line, "enter open") { + footerLine = index + } + if firstCard == -1 && strings.Contains(line, firstCardLine) { + firstCard = index + } + } + require.NotEqual(t, -1, indicatorLine) + require.NotEqual(t, -1, firstCard) + require.NotEqual(t, -1, lowerIndicatorLine) + require.NotEqual(t, -1, footerLine) + assert.Equal(t, indicatorLine+1, firstCard) + assert.Equal(t, lowerIndicatorLine, footerLine) + }) + + t.Run("lower overflow indicator does not reduce list capacity", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.width = 160 + model.height = 32 + model.screen = screenSourceList + model.densityUserSet = true + model.compact = false + for index := range 10 { + model.sourceMigrations = append(model.sourceMigrations, elmapi.MigrationSummary{ + MigrationID: fmt.Sprintf("migration-%d", index), + }) + } + + start, end := model.sourceListBounds(len(model.sourceMigrations)) + + assert.Zero(t, start) + assert.Equal(t, 9, end) + assert.Contains(t, model.sourceListBottomLine(), "↓ 1 more") + + model.cursor = len(model.sourceMigrations) - 1 + start, end = model.sourceListBounds(len(model.sourceMigrations)) + + assert.Equal(t, 1, start) + assert.Equal(t, len(model.sourceMigrations), end) + assert.Empty(t, model.sourceListBottomLine()) + }) + + t.Run("terminated migrations display as cancelled", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + + _, status := model.statusDisplay(elmapi.StatusTerminated) + + assert.Contains(t, status, "Cancelled") + assert.NotContains(t, status, "Terminated") + }) + t.Run("forms keep the focused field visible in short terminals", func(t *testing.T) { model := New(t.Context(), &fakeService{}) model.width = 80 @@ -1023,6 +1318,8 @@ func TestMigrationCreation(t *testing.T) { t.Run("migration creation discovers repositories and organizations", func(t *testing.T) { var sourceCreateInput workflow.SourceCreateInput + const migrationID = "c2856799-c6b5-4b00-aa16-7f9fe698c51f" + expiresAt := "2026-09-22T16:08:28Z" svc := &fakeService{ listSourceRepositories: func(context.Context) ([]elmapi.Repository, error) { return []elmapi.Repository{{FullName: "acme/api"}, {FullName: "octo/web"}}, nil @@ -1033,7 +1330,7 @@ func TestMigrationCreation(t *testing.T) { createSourceMigration: func(_ context.Context, input workflow.SourceCreateInput) (*workflow.SourceCreateResult, error) { sourceCreateInput = input return &workflow.SourceCreateResult{ - Migration: elmapi.CreateMigrationResponse{MigrationID: "created-1"}, + Migration: elmapi.CreateMigrationResponse{MigrationID: migrationID, ExpiresAt: &expiresAt}, }, nil }, } @@ -1072,14 +1369,40 @@ func TestMigrationCreation(t *testing.T) { require.NotNil(t, command) message := command() assert.Empty(t, model.sourceID) + staleStatus := elmapi.StatusCompleted + model.sourceDetail = &elmapi.MigrationDetail{ + Migration: &elmapi.MigrationSummary{ + MigrationID: "stale-migration", + Status: &staleStatus, + SourceOrganizationLogin: "wrong", + SourceRepositoryName: "repository", + TargetOrganizationLogin: "random", + TargetRepositoryName: "repository", + }, + } + model.targetID = 42 updated, _ = model.Update(message) model = updated.(*Model) assert.Equal(t, screenResult, model.screen) assert.True(t, model.result.popup) + assert.True(t, model.result.blankBackground) assert.Contains(t, model.View(), "Migration created") assert.Contains(t, model.View(), "Close") - assert.Equal(t, workflow.SourceMigrationID("created-1"), model.sourceID) + assert.Contains(t, model.View(), expiresAt) + assert.NotContains(t, model.View(), "Migration successfully created") + assert.NotContains(t, model.View(), "wrong/repository") + assert.Nil(t, model.sourceDetail) + assert.Zero(t, model.targetID) + assert.Equal(t, workflow.SourceMigrationID(migrationID), model.sourceID) + idLineFound := false + for line := range strings.SplitSeq(model.resultPopupOverlay(), "\n") { + if strings.Contains(line, "Migration ID") { + idLineFound = true + assert.Contains(t, line, migrationID) + } + } + assert.True(t, idLineFound) assert.Equal(t, workflow.SourceCreateInput{ SourceOwner: "acme", SourceRepo: "api", @@ -1093,6 +1416,8 @@ func TestMigrationCreation(t *testing.T) { model = updated.(*Model) assert.Equal(t, screenSourceDetail, model.screen) assert.NotNil(t, command) + assert.Contains(t, model.View(), "Loading…") + assert.NotContains(t, model.View(), "wrong/repository") }) t.Run("repository picker presents real metadata", func(t *testing.T) { @@ -1521,6 +1846,7 @@ func setConfigurationReady(model *Model) { TargetURL: "https://target.example", TargetTokenSet: true, } + model.configurationLoading = false model.sourceAuthChecked = true model.targetAuthChecked = true model.homeCursorSet = false diff --git a/internal/tui/view.go b/internal/tui/view.go index d7807e3..7b8d301 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "github.com/github/gh-elm/internal/elmapi" "github.com/github/gh-elm/internal/render" @@ -21,7 +22,7 @@ const ( // View implements tea.Model. func (m *Model) View() string { if m.width > 0 && (m.width < 48 || m.height < 12) { - return m.frame("Terminal too small", "Resize to at least 48 columns by 12 rows.", "ctrl+c quit") + return m.frame("Terminal too small", "Resize to at least 48 columns by 12 rows.", "ctrl+c quit", "", "") } current := m.screen @@ -34,15 +35,18 @@ func (m *Model) View() string { if alerting { current = m.alert.parent } - if resultPopup { + if resultPopup && !m.result.blankBackground { current = m.result.parent } - var title, body, help string + var title, body, help, topLine, bottomLine string switch current { case screenHome: title = homeTitle body = m.menu(m.homeActionItems()) + if m.configurationCheckPending() { + body += "\n\n" + m.styles.Muted.Render("… Checking configuration…") + } help = helpLine(keys.Up, keys.Down, keys.Open, keys.Help, keys.Quit) case screenSourceList: title = "Migrations" @@ -50,13 +54,15 @@ func (m *Model) View() string { title += " · search: " + m.searchInput.View() } body = m.sourceListView() + topLine = m.sourceListTopLine() + bottomLine = m.sourceListBottomLine() if m.sourceSearch { help = "type to filter • " + helpLine(keys.Up, keys.Down, keys.Open, keys.Back) } else { help = helpLine(keys.Up, keys.Down, keys.Open, keys.New, keys.Search, keys.Density, keys.Refresh, keys.Back) } case screenSourceDetail: - title = fmt.Sprintf("Migration %s", m.sourceID) + title = m.sourceDetailTitle() body = m.sourceDetailView() help = helpLine(keys.Left, keys.Up, keys.Down, keys.Open, keys.PageUp, keys.PageDown, keys.Back) if m.sourceMigrationStarted() { @@ -107,14 +113,22 @@ func (m *Model) View() string { body = m.result.body help = helpLine(keys.Up, keys.Down, keys.PageUp, keys.PageDown, keys.Back) } + if resultPopup && m.result.blankBackground { + title, body, help = "", "", "" + } if m.showHelp && !confirming && !alerting && !resultPopup { title = "Keyboard help" body = m.fullHelpView() help = "? close • q quit" + topLine, bottomLine = "", "" } - if m.loading && !m.refreshingDetail { + showingRefreshedDetail := m.refreshingDetail && + (current == screenSourceDetail && m.sourceDetail != nil || + current == screenTargetDetail && m.targetDetail != nil) + if m.loading && !showingRefreshedDetail { body = m.styles.Active.Render("Loading…") + topLine, bottomLine = "", "" } if m.err != nil { body += "\n\n" + m.styles.Failure.Render("Error: "+m.err.Error()) @@ -137,7 +151,7 @@ func (m *Model) View() string { } else if alerting || resultPopup { help = "enter/esc close" } - rendered := m.frame(title, body, help) + rendered := m.frame(title, body, help, topLine, bottomLine) if confirming { rendered = overlayCenter( rendered, @@ -172,7 +186,7 @@ func (m *Model) View() string { return rendered } -func (m *Model) frame(title, body, help string) string { +func (m *Model) frame(title, body, help, topLine, bottomLine string) string { contentWidth := m.contentWidth() brand := m.styles.Primary.Bold(true).Render(appTitle) subtitle := m.styles.Muted.Bold(false).Render(title) @@ -185,13 +199,29 @@ func (m *Model) frame(title, body, help string) string { warningBlock = "\n" + m.styles.Warning.Bold(true).Render("⚠ Configuration not ready") + "\n" + warning } - content := lipgloss.NewStyle().Width(contentWidth).Height(m.bodyHeight()).Render(body) - footer := m.styles.Muted.Render(help) + contentHeight := m.bodyHeight() + content := lipgloss.NewStyle().Width(contentWidth).Height(contentHeight).Render(body) + footer := m.footer(help, bottomLine, contentWidth) + bodySeparator := "\n\n" + if topLine != "" { + bodySeparator = "\n" + topLine + "\n" + } return lipgloss.NewStyle().Padding(0, 1).Render( - header + warningBlock + "\n\n" + content + "\n" + footer, + header + warningBlock + bodySeparator + content + "\n" + footer, ) } +func (m *Model) footer(help, status string, width int) string { + if status == "" { + return m.styles.Muted.Render(help) + } + + statusWidth := lipgloss.Width(status) + help = ansi.Truncate(help, max(0, width-statusWidth-2), "") + gap := max(1, width-lipgloss.Width(help)-statusWidth) + return m.styles.Muted.Render(help) + strings.Repeat(" ", gap) + status +} + func (m *Model) displayWidth() int { if m.width > 0 { return m.width @@ -320,14 +350,25 @@ func (m *Model) sourceListView() string { } builder.WriteString(m.sourceMigrationCard(migrations[index], index == m.cursor)) } - if end < len(migrations) { - builder.WriteString("\n") - builder.WriteString(m.styles.Muted.Render(fmt.Sprintf("↓ %d more", len(migrations)-end))) + return builder.String() +} + +func (m *Model) sourceListTopLine() string { + migrations := m.visibleSourceMigrations() + start, _ := m.sourceListBounds(len(migrations)) + if start == 0 { + return "" } - if start > 0 { - return m.styles.Muted.Render(fmt.Sprintf("↑ %d more\n", start)) + builder.String() + return m.styles.Muted.Render(fmt.Sprintf("↑ %d more", start)) +} + +func (m *Model) sourceListBottomLine() string { + migrations := m.visibleSourceMigrations() + _, end := m.sourceListBounds(len(migrations)) + if end == len(migrations) { + return "" } - return builder.String() + return m.styles.Muted.Render(fmt.Sprintf("↓ %d more", len(migrations)-end)) } func (m *Model) sourceMigrationCard(migration elmapi.MigrationSummary, selected bool) string { @@ -336,8 +377,8 @@ func (m *Model) sourceMigrationCard(migration elmapi.MigrationSummary, selected status = *migration.Status } glyph, statusText := m.statusDisplay(status) - source := migration.SourceOrganizationLogin + "/" + migration.SourceRepositoryName - target := migration.TargetOrganizationLogin + "/" + migration.TargetRepositoryName + source := repositoryCoordinate(migration.SourceOrganizationLogin, migration.SourceRepositoryName) + target := repositoryCoordinate(migration.TargetOrganizationLogin, migration.TargetRepositoryName) var card strings.Builder fmt.Fprintf(&card, "%s %s %s\n", glyph, statusText, m.repositoryChip(source+" → "+target)) @@ -363,11 +404,40 @@ func (m *Model) sourceDetailView() string { if m.sourceDetail != nil { detail := *m.sourceDetail detail.Messages = nil - return render.MigrationStatus(detail) + body := render.MigrationStatus(detail) + if detail.Migration != nil { + if _, bodyWithoutTitle, found := strings.Cut(body, "\n"); found { + return bodyWithoutTitle + } + } + return body } return "" } +func (m *Model) sourceDetailTitle() string { + if m.sourceDetail != nil && m.sourceDetail.Migration != nil { + migration := m.sourceDetail.Migration + source := repositoryCoordinate(migration.SourceOrganizationLogin, migration.SourceRepositoryName) + target := repositoryCoordinate(migration.TargetOrganizationLogin, migration.TargetRepositoryName) + if source != "" && target != "" { + return "Migration · " + source + " → " + target + } + } + return fmt.Sprintf("Migration %s", m.sourceID) +} + +func repositoryCoordinate(owner, name string) string { + switch { + case owner == "": + return name + case name == "": + return owner + default: + return owner + "/" + name + } +} + func (m *Model) sourceMessagesView() string { if m.sourceDetail == nil || len(m.sourceDetail.Messages) == 0 { return "No messages reported." @@ -375,6 +445,22 @@ func (m *Model) sourceMessagesView() string { return render.MigrationStatus(elmapi.MigrationDetail{Messages: m.sourceDetail.Messages}) } +func (m *Model) migrationCreatedBody(migration elmapi.CreateMigrationResponse) string { + id := migration.MigrationID + if id == "" { + id = "—" + } + expires := "—" + if migration.ExpiresAt != nil && *migration.ExpiresAt != "" { + expires = *migration.ExpiresAt + } + row := func(label, value string) string { + return m.styles.Muted.Render(fmt.Sprintf("%-12s", label)) + " " + value + } + return row("Migration ID", m.styles.Bold.Render(id)) + "\n" + + row("Expires", expires) +} + func (m *Model) targetListView() string { if len(m.targetMigrations) == 0 { return "No target migrations found.\n\nPress n for advanced direct creation or m to open a numeric target ID." @@ -383,7 +469,8 @@ func (m *Model) targetListView() string { capacity := max(1, (m.bodyHeight()-2)/4) start, end := pickerBounds(m.cursor, len(m.targetMigrations), capacity) if start > 0 { - builder.WriteString(m.styles.Muted.Render(fmt.Sprintf("↑ %d more\n", start))) + builder.WriteString(m.styles.Muted.Render(fmt.Sprintf("↑ %d more", start))) + _ = builder.WriteByte('\n') } for index := start; index < end; index++ { migration := m.targetMigrations[index] @@ -716,7 +803,7 @@ func (m *Model) pickerView() string { } list := builder.String() if start > 0 { - list = m.styles.Muted.Render(fmt.Sprintf("↑ %d more\n", start)) + list + list = m.styles.Muted.Render(fmt.Sprintf("↑ %d more", start)) + "\n" + list } if m.picker.kind != pickerSourceRepository || m.contentWidth() < 92 { return list @@ -873,9 +960,7 @@ func (m *Model) statusDisplay(status string) (glyph, label string) { return m.styles.Warning.Render("●"), m.styles.Warning.Render("Paused") case "failed": return m.styles.Failure.Render("✗"), m.styles.Failure.Render("Failed") - case "terminated": - return m.styles.Failure.Render("⊘"), m.styles.Failure.Render("Terminated") - case "cancelled": + case "terminated", "cancelled", "canceled": return m.styles.Failure.Render("⊘"), m.styles.Failure.Render("Cancelled") default: return m.styles.Muted.Render("●"), m.styles.Muted.Render(friendly(status)) @@ -893,17 +978,13 @@ func (m *Model) sourceListBounds(total int) (start, end int) { if total == 0 { return 0, 0 } - linesPerCard := 4 + linesPerCard := 3 if m.compactSourceList() { linesPerCard = 2 } - capacity := max(1, (m.bodyHeight()-1)/linesPerCard) - start = max(0, m.cursor-capacity+1) - end = min(total, start+capacity) - if end-start < capacity { - start = max(0, end-capacity) - } - return start, end + height := m.bodyHeight() + capacity := max(1, height/linesPerCard) + return pickerBounds(m.cursor, total, capacity) } func (m *Model) viewportView(body string, height int) string { From e0071807f016783d5db87f63968aac5dbafebc00 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Thu, 10 Sep 2026 19:24:46 +0200 Subject: [PATCH 23/32] Fix cutover status rendering Hide migration-only progress states from cutover output while retaining readiness, blockers, repository details, and cutover lifecycle statuses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/cmd/migration/migration_test.go | 4 ++- internal/render/migration.go | 17 +++++++++--- internal/render/migration_test.go | 33 ++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/internal/cmd/migration/migration_test.go b/internal/cmd/migration/migration_test.go index 8c22c4f..dd7a05a 100644 --- a/internal/cmd/migration/migration_test.go +++ b/internal/cmd/migration/migration_test.go @@ -579,9 +579,11 @@ func TestCutoverStatus(t *testing.T) { out := run(t, "cutover", "status", "m", "--source-url", srv.URL, "--source-token", "tok") - for _, want := range []string{"○ Not ready for cutover", "Backfill in progress", "backfill incomplete", "acme/web · Backfill · In progress"} { + for _, want := range []string{"✗ Not ready for cutover", "backfill incomplete", "acme/web · Backfill · In progress"} { assert.Contains(t, out, want) } + assert.NotContains(t, out, "Backfilling") + assert.NotContains(t, out, "Backfill in progress") assert.NotContains(t, out, "Ready for cutover: false") }) diff --git a/internal/render/migration.go b/internal/render/migration.go index 219745a..671ad8e 100644 --- a/internal/render/migration.go +++ b/internal/render/migration.go @@ -145,7 +145,8 @@ func renderCombinedState(combined *elmapi.CombinedState) string { var lines, renderedValues []string normalizedStatus := normalizedValue(status) notStarted := normalizedStatus == "created" || normalizedStatus == "queued" - if !terminated && !notStarted && normalizedStatus != "" { + cutoverStatus := cutoverRelatedStatus(status) + if cutoverStatus { lines = append(lines, bullet(statusGlyph(status), statusText(status))) renderedValues = append(renderedValues, status) } @@ -154,12 +155,12 @@ func renderCombinedState(combined *elmapi.CombinedState) string { if combined.ReadyForCutover { readinessText = "Ready for cutover" } - if !completed && !equivalentValue(status, readinessText) { + if !completed && !cutoverStatus { lines = append(lines, bullet(readiness.glyph, readiness.text)) renderedValues = append(renderedValues, readinessText) } if displayMessage := strings.TrimSpace(combined.DisplayMessage); displayMessage != "" && - !terminated && !notStarted && !containsEquivalentValue(renderedValues, displayMessage) { + cutoverStatus && !containsEquivalentValue(renderedValues, displayMessage) { lines = append(lines, detail(displayMessage)) renderedValues = append(renderedValues, displayMessage) } @@ -202,6 +203,16 @@ func renderCombinedState(combined *elmapi.CombinedState) string { return joinSections(sections...) } +func cutoverRelatedStatus(status string) bool { + switch normalizedValue(status) { + case "ready for cutover", "cutting over", "cutover pending", "cutover finalizing", + "completed", "complete", "success", "succeeded": + return true + default: + return false + } +} + func terminatedStatus(status string) bool { switch normalizedValue(status) { case "terminated", "cancelled", "canceled", "aborted": diff --git a/internal/render/migration_test.go b/internal/render/migration_test.go index 6ab7733..7248cc4 100644 --- a/internal/render/migration_test.go +++ b/internal/render/migration_test.go @@ -244,6 +244,39 @@ Repository states assert.Contains(t, output, "acme/web · Backfill · In progress") assert.Contains(t, output, "✗ Not ready for cutover") + assert.NotContains(t, output, "Backfilling") + }) + + t.Run("suppresses running migration status from cutover", func(t *testing.T) { + status := "exporting" + + output := CutoverStatus(elmapi.MigrationDetail{ + CombinedState: &elmapi.CombinedState{ + Status: &status, + DisplayMessage: "Exporting data from source", + ReadyForCutover: false, + }, + }) + + assert.Equal(t, `Cutover + ✗ Not ready for cutover +`, output) + }) + + t.Run("shows active cutover status without obsolete readiness", func(t *testing.T) { + status := "cutting_over" + + output := CutoverStatus(elmapi.MigrationDetail{ + CombinedState: &elmapi.CombinedState{ + Status: &status, + DisplayMessage: "Cutover in progress", + }, + }) + + assert.Equal(t, `Cutover + ● Cutting over + Cutover in progress +`, output) }) } From cf9782be606c7a6ee8aafefb3fa324062195c14b Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Thu, 10 Sep 2026 19:30:11 +0200 Subject: [PATCH 24/32] Add actions to all TUI forms Give every form a contextual primary button and a Cancel button, matching the configuration form interaction pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/tui/model.go | 65 +++++++++++++++++++++------------ internal/tui/model_test.go | 74 +++++++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 23 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index b0209a3..1a3a60f 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1196,9 +1196,13 @@ var configurationActions = []actionItem{ {id: "reset", label: "Reset configuration", shortcut: "x"}, } -var createMigrationActions = []actionItem{ - {id: "create", label: "Create"}, - {id: "cancel", label: "Cancel"}, +var createMigrationActions = formActions("create", "Create") + +func formActions(id, label string) []actionItem { + return []actionItem{ + {id: id, label: label}, + {id: "cancel", label: "Cancel"}, + } } func (m *Model) activateConfigurationAction() (tea.Model, tea.Cmd) { @@ -1563,9 +1567,10 @@ func cycleOption(field *formField, delta int) { func (m *Model) openSourceIDForm() (tea.Model, tea.Cmd) { id := "" return m.openForm(formState{ - title: "Open source migration", - parent: screenSourceList, - fields: []formField{textFormField("Source migration UUID", "", &id)}, + title: "Open source migration", + parent: screenSourceList, + fields: []formField{textFormField("Source migration UUID", "", &id)}, + actions: formActions("show", "Show migration"), submit: func() (tea.Cmd, error) { id = strings.TrimSpace(id) if id == "" { @@ -1747,9 +1752,10 @@ func (m *Model) createSourceMigrationCmd(input workflow.SourceCreateInput) tea.C func (m *Model) openTargetIDForm() (tea.Model, tea.Cmd) { value := "" return m.openForm(formState{ - title: "Open target migration", - parent: screenTargetList, - fields: []formField{textFormField("Numeric target migration ID", "", &value)}, + title: "Open target migration", + parent: screenTargetList, + fields: []formField{textFormField("Numeric target migration ID", "", &value)}, + actions: formActions("show", "Show migration"), submit: func() (tea.Cmd, error) { id, err := workflow.ParseTargetMigrationID(value) if err != nil { @@ -1780,6 +1786,7 @@ func (m *Model) openTargetCreateForm() (tea.Model, tea.Cmd) { textFormField("Description", "", &description), textFormField("Exporter migration GUID", "", &guid), }, + actions: formActions("create", "Create migration"), submit: func() (tea.Cmd, error) { input := workflow.TargetCreateInput{ SourceRepositoryURL: sourceURL, @@ -1809,6 +1816,7 @@ func (m *Model) openResourcesForm() (tea.Model, tea.Cmd) { selectFormField("State", &state, "all", "pending", "processed", "failed", "eligible"), textFormField("Maximum results (0 = all)", "", &maximum), }, + actions: formActions("show", "Show resources"), submit: func() (tea.Cmd, error) { maxResults, err := strconv.Atoi(strings.TrimSpace(maximum)) if err != nil || maxResults < 0 { @@ -1848,10 +1856,18 @@ func (m *Model) openReportForm(title, operation string) (tea.Model, tea.Cmd) { state = "all" fields = append(fields, selectFormField("Node state", &state, "all", "migrated", "unmigrated")) } + actionLabel := "Continue" + switch operation { + case "status": + actionLabel = "Show status" + case "url": + actionLabel = "Show URL" + } return m.openForm(formState{ - title: title, - parent: screenTargetDetail, - fields: fields, + title: title, + parent: screenTargetDetail, + fields: fields, + actions: formActions(operation, actionLabel), submit: func() (tea.Cmd, error) { input := workflow.ReportInput{MigrationID: m.targetID, Stage: stage, State: state} return func() tea.Msg { @@ -1888,10 +1904,17 @@ func (m *Model) openMannequinListForm(export bool) (tea.Model, tea.Cmd) { if export { title = "Export mannequins" } + actionID := "search" + actionLabel := "Search" + if export { + actionID = "export" + actionLabel = "Export mannequins" + } return m.openForm(formState{ - title: title, - parent: screenMannequins, - fields: fields, + title: title, + parent: screenMannequins, + fields: fields, + actions: formActions(actionID, actionLabel), submit: func() (tea.Cmd, error) { return func() tea.Msg { if export { @@ -1932,9 +1955,10 @@ func (m *Model) openMannequinReclaimForm(csvMode bool) (tea.Model, tea.Cmd) { boolFormField("Immediate reattribution (EMU)", &skipInvitation), ) return m.openForm(formState{ - title: "Reclaim mannequins", - parent: screenMannequins, - fields: fields, + title: "Reclaim mannequins", + parent: screenMannequins, + fields: fields, + actions: formActions("continue", "Continue"), submit: func() (tea.Cmd, error) { input := workflow.MannequinReclaimInput{ Organization: organization, @@ -2028,10 +2052,7 @@ func (m *Model) openConfigurationForm() (tea.Model, tea.Cmd) { textFormField("Target URL", "", &targetURL), secretFormField("Target token", &targetToken, targetTokenSet), }, - actions: []actionItem{ - {id: "save", label: "Save"}, - {id: "cancel", label: "Cancel"}, - }, + actions: formActions("save", "Save"), submit: func() (tea.Cmd, error) { input := workflow.ConfigurationInput{ SourceURL: sourceURL, diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index acbb04e..27cc539 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -728,6 +728,69 @@ func TestModelUpdate(t *testing.T) { }) } +func TestFormActions(t *testing.T) { + tests := []struct { + name string + primary string + open func(*Model) (tea.Model, tea.Cmd) + }{ + {"source migration ID", "Show migration", func(model *Model) (tea.Model, tea.Cmd) { + return model.openSourceIDForm() + }}, + {"discovered source migration", "Create", func(model *Model) (tea.Model, tea.Cmd) { + return model.openDiscoveredSourceCreateForm("source/repository", "target") + }}, + {"manual source migration", "Create", func(model *Model) (tea.Model, tea.Cmd) { + return model.openManualSourceCreateForm(screenHome, "") + }}, + {"target migration ID", "Show migration", func(model *Model) (tea.Model, tea.Cmd) { + return model.openTargetIDForm() + }}, + {"target migration creation", "Create migration", func(model *Model) (tea.Model, tea.Cmd) { + return model.openTargetCreateForm() + }}, + {"resources", "Show resources", func(model *Model) (tea.Model, tea.Cmd) { + return model.openResourcesForm() + }}, + {"report request", "Continue", func(model *Model) (tea.Model, tea.Cmd) { + return model.openReportForm("Request report", "request") + }}, + {"report status", "Show status", func(model *Model) (tea.Model, tea.Cmd) { + return model.openReportForm("Report status", "status") + }}, + {"report URL", "Show URL", func(model *Model) (tea.Model, tea.Cmd) { + return model.openReportForm("Report URL", "url") + }}, + {"mannequin search", "Search", func(model *Model) (tea.Model, tea.Cmd) { + return model.openMannequinListForm(false) + }}, + {"mannequin export", "Export mannequins", func(model *Model) (tea.Model, tea.Cmd) { + return model.openMannequinListForm(true) + }}, + {"mannequin reclaim", "Continue", func(model *Model) (tea.Model, tea.Cmd) { + return model.openMannequinReclaimForm(false) + }}, + {"mannequin CSV reclaim", "Continue", func(model *Model) (tea.Model, tea.Cmd) { + return model.openMannequinReclaimForm(true) + }}, + {"configuration", "Save", func(model *Model) (tea.Model, tea.Cmd) { + return model.openConfigurationForm() + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + updated, _ := test.open(New(t.Context(), &fakeService{})) + model := updated.(*Model) + + assert.Equal(t, []string{test.primary, "Cancel"}, actionLabels(model.form.actions)) + model.form.cursor = len(model.form.fields) + assert.Contains(t, model.formView(), test.primary) + assert.Contains(t, model.formView(), "Cancel") + }) + } +} + func TestModelNavigationAndLayout(t *testing.T) { t.Run("opens source migration from list", func(t *testing.T) { status := "in_progress" @@ -804,6 +867,7 @@ func TestModelNavigationAndLayout(t *testing.T) { updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) model = updated.(*Model) } + model.form.cursor = len(model.form.fields) updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) @@ -1768,7 +1832,7 @@ func TestModelActions(t *testing.T) { *model.form.fields[0].text = "octo-org" *model.form.fields[1].text = "mannequin" *model.form.fields[3].text = "app[bot]" - model.form.cursor = len(model.form.fields) - 1 + model.form.cursor = len(model.form.fields) updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) @@ -1861,6 +1925,14 @@ func actionIDs(actions []actionItem) []string { return ids } +func actionLabels(actions []actionItem) []string { + labels := make([]string, len(actions)) + for index, action := range actions { + labels[index] = action.label + } + return labels +} + type fakeService struct { service listSourceMigrations func(context.Context, string) ([]elmapi.MigrationSummary, error) From 6eff6a50c524c46312e0bf798eec4d96bd2d25c6 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Thu, 10 Sep 2026 19:38:47 +0200 Subject: [PATCH 25/32] Remove advanced destination entry Keep destination operations accessible through source migration details and remove redundant advanced navigation and branding from the TUI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 11 +++++------ internal/tui/model.go | 7 +------ internal/tui/model_test.go | 40 ++++++-------------------------------- internal/tui/view.go | 6 +++--- 4 files changed, 15 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index fc3d427..399851e 100644 --- a/README.md +++ b/README.md @@ -45,12 +45,11 @@ gh elm config set-target-pat ORG # set an organization's TARGET_PAT secret When standard input and output are interactive terminals, invoking `gh elm` with no arguments opens a full-screen TUI. Its main migration workflow supports creating, monitoring, and controlling migrations, then links to destination details, resources, -and reports without repeatedly copying IDs. Lower-level destination migration controls -remain available under **Advanced destination operations**. Use arrow keys or `j`/`k` -to move, Enter to select, Escape to go back, `/` to search migrations, `Ctrl+V` to -toggle list density, `?` for contextual help, and `q` to quit. Long detail and result -views support Page Up and Page Down. Action screens always focus their first button; -use Left/Right and Enter or the shortcut shown inside a button to activate it. +and reports without repeatedly copying IDs. Use arrow keys or `j`/`k` to move, Enter +to select, Escape to go back, `/` to search migrations, `Ctrl+V` to toggle list density, +`?` for contextual help, and `q` to quit. Long detail and result views support Page Up +and Page Down. Action screens always focus their first button; use Left/Right and Enter +or the shortcut shown inside a button to activate it. Migration creation lazily loads searchable source repositories and destination organizations from the configured APIs. The destination repository name defaults to diff --git a/internal/tui/model.go b/internal/tui/model.go index 1a3a60f..e29faee 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -764,10 +764,6 @@ func (m *Model) activate() (tea.Model, tea.Cmd) { m.resetViewport() command := m.startConfigurationLoad() return m, command - case "target": - m.screen, m.loading, m.err = screenTargetList, true, nil - command := m.startTargetListLoad() - return m, command case "quit": return m, tea.Quit } @@ -920,7 +916,6 @@ var homeActions = []actionItem{ {id: "create", label: "Create migration"}, {id: "mannequins", label: "Target mannequins"}, {id: "configuration", label: "Configuration"}, - {id: "target", label: "Advanced destination operations"}, {id: "quit", label: "Quit"}, } @@ -1778,7 +1773,7 @@ func (m *Model) openTargetCreateForm() (tea.Model, tea.Cmd) { description := "" guid := "" return m.openForm(formState{ - title: "Create target migration (advanced)", + title: "Create target migration", parent: screenTargetList, fields: []formField{ textFormField("Source repository URL", "", &sourceURL), diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 27cc539..62b51d4 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -47,7 +47,7 @@ func TestModelUpdate(t *testing.T) { assert.Equal(t, 3, model.cursor) updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) model = updated.(*Model) - assert.Equal(t, 5, model.cursor) + assert.Equal(t, 4, model.cursor) updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyUp}) model = updated.(*Model) assert.Equal(t, 3, model.cursor) @@ -327,40 +327,12 @@ func TestModelUpdate(t *testing.T) { assert.Contains(t, view, "destination URL, destination token") }) - t.Run("cancels a destination migration load and returns home", func(t *testing.T) { - started := make(chan int) - service := &fakeService{ - listTargetMigrations: func(ctx context.Context, _ string, maxResults int) ([]elmapi.TargetMigration, error) { - started <- maxResults - <-ctx.Done() - return nil, ctx.Err() - }, - } - model := New(t.Context(), service) + t.Run("omits standalone advanced destination operations", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) setConfigurationReady(model) - model.cursor = 4 - - updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) - model = updated.(*Model) - require.NotNil(t, command) - require.Equal(t, screenTargetList, model.screen) - require.True(t, model.loading) - response := make(chan tea.Msg) - go func() { - response <- command() - }() - assert.Equal(t, targetListLimit, <-started) - - updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEscape}) - model = updated.(*Model) - assert.Equal(t, screenHome, model.screen) - assert.False(t, model.loading) - - updated, _ = model.Update(<-response) - model = updated.(*Model) - assert.Equal(t, screenHome, model.screen) - assert.NoError(t, model.err) + assert.NotContains(t, actionIDs(model.homeActionItems()), "target") + assert.NotContains(t, model.View(), "Advanced destination operations") }) t.Run("hides authentication rows until prerequisites are configured", func(t *testing.T) { @@ -1051,7 +1023,7 @@ func TestModelNavigationAndLayout(t *testing.T) { assert.Contains(t, model.View(), "←/→ select action") }) - t.Run("advanced destination actions use a vertical menu", func(t *testing.T) { + t.Run("destination actions use a vertical menu", func(t *testing.T) { model := New(t.Context(), &fakeService{}) model.screen = screenTargetDetail model.targetDetail = &elmapi.TargetMigration{Status: elmapi.TargetMigrationStatusInProgress} diff --git a/internal/tui/view.go b/internal/tui/view.go index 7b8d301..228be14 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -69,11 +69,11 @@ func (m *Model) View() string { help = helpLine(keys.Left, keys.Up, keys.Down, keys.Open, keys.PageUp, keys.PageDown, keys.Refresh, keys.Back) } case screenTargetList: - title = "Advanced destination migrations" + title = "Destination migrations" body = m.targetListView() help = helpLine(keys.Up, keys.Down, keys.Open, keys.New, keys.Manual, keys.Refresh, keys.Back) case screenTargetDetail: - title = fmt.Sprintf("Destination migration %d (advanced)", m.targetID) + title = fmt.Sprintf("Destination migration %d", m.targetID) body = m.targetDetailView() help = helpLine(keys.Up, keys.Down, keys.Open, keys.PageUp, keys.PageDown, keys.Refresh, keys.Back) case screenMannequins: @@ -463,7 +463,7 @@ func (m *Model) migrationCreatedBody(migration elmapi.CreateMigrationResponse) s func (m *Model) targetListView() string { if len(m.targetMigrations) == 0 { - return "No target migrations found.\n\nPress n for advanced direct creation or m to open a numeric target ID." + return "No target migrations found.\n\nPress n for direct creation or m to open a numeric target ID." } var builder strings.Builder capacity := max(1, (m.bodyHeight()-2)/4) From 322b9fed07d6d2d83b75452b9b02c0da6556227d Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Thu, 10 Sep 2026 20:13:21 +0200 Subject: [PATCH 26/32] Refine destination migration details Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/elmapi/target_migrations.go | 66 +++++- internal/elmapi/target_migrations_test.go | 22 +- internal/tui/model.go | 3 - internal/tui/model_test.go | 136 ++++++++++- internal/tui/view.go | 269 ++++++++++++++++++---- 5 files changed, 441 insertions(+), 55 deletions(-) diff --git a/internal/elmapi/target_migrations.go b/internal/elmapi/target_migrations.go index 24c726b..175bcd9 100644 --- a/internal/elmapi/target_migrations.go +++ b/internal/elmapi/target_migrations.go @@ -104,6 +104,58 @@ type TargetRepositoryProgress struct { LiveUpdateResourcesAcknowledged int64 `json:"liveUpdateResourcesAcknowledged"` } +// TargetRepositoryStateSummary contains per-type node counts for a repository. +type TargetRepositoryStateSummary struct { + Repository string `json:"repository"` + Backfill TargetOriginStateSummary `json:"backfill"` + LiveUpdate TargetOriginStateSummary `json:"liveUpdate"` +} + +// TargetOriginStateSummary contains node counts for one migration origin. +type TargetOriginStateSummary struct { + Breakdown []TargetStateBreakdownEntry `json:"breakdown"` + Total int64 `json:"total"` +} + +// UnmarshalJSON accepts the quoted int64 values emitted by protobuf JSON. +func (s *TargetOriginStateSummary) UnmarshalJSON(data []byte) error { + type summaryFields TargetOriginStateSummary + var fields struct { + summaryFields + Total wireInt64 `json:"total"` + } + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + *s = TargetOriginStateSummary(fields.summaryFields) + s.Total = int64(fields.Total) + return nil +} + +// TargetStateBreakdownEntry is one state, kind, and resource-type bucket. +type TargetStateBreakdownEntry struct { + State string `json:"state"` + Kind string `json:"kind"` + Type string `json:"type"` + Count int64 `json:"count"` + Origin string `json:"origin"` +} + +// UnmarshalJSON accepts the quoted int64 values emitted by protobuf JSON. +func (e *TargetStateBreakdownEntry) UnmarshalJSON(data []byte) error { + type entryFields TargetStateBreakdownEntry + var fields struct { + entryFields + Count wireInt64 `json:"count"` + } + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + *e = TargetStateBreakdownEntry(fields.entryFields) + e.Count = int64(fields.Count) + return nil +} + // UnmarshalJSON accepts the quoted int64 values returned by the status endpoint // while remaining compatible with the numeric values documented by its schema. func (p *TargetRepositoryProgress) UnmarshalJSON(data []byte) error { @@ -156,12 +208,14 @@ func (v *wireInt64) UnmarshalJSON(data []byte) error { // verbatim — preserving fields this struct does not model and avoiding // zero-valued fields that re-marshaling would inject. type TargetMigration struct { - MigrationID string `json:"migrationId"` - Status string `json:"status"` - ExpiresAt time.Time `json:"expiresAt"` - Description string `json:"description,omitempty"` - Repositories []string `json:"repositories,omitempty"` - RepositoryProgress []TargetRepositoryProgress `json:"repositoryProgress,omitempty"` + MigrationID string `json:"migrationId"` + Status string `json:"status"` + ExpiresAt time.Time `json:"expiresAt"` + Description string `json:"description,omitempty"` + Repositories []string `json:"repositories,omitempty"` + RepositoryProgress []TargetRepositoryProgress `json:"repositoryProgress,omitempty"` + RepositoryStateSummaries []TargetRepositoryStateSummary `json:"repositoryStateSummaries,omitempty"` + ExporterMigrationGUID string `json:"exporterMigrationGuid,omitempty"` // Raw is the original JSON object for this migration. It is populated on // decode and excluded from (re-)marshaling. diff --git a/internal/elmapi/target_migrations_test.go b/internal/elmapi/target_migrations_test.go index 7dfa025..82d154e 100644 --- a/internal/elmapi/target_migrations_test.go +++ b/internal/elmapi/target_migrations_test.go @@ -294,7 +294,7 @@ func TestGetTargetMigrationStatus(t *testing.T) { var gotPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path - _, _ = w.Write([]byte(`{"migration":{"migrationId":"42","status":"STATUS_TYPE_IN_PROGRESS","expiresAt":"2024-01-01T00:00:00Z","repositoryProgress":[{"repositoryNwo":"octo/repo","resourcesAdded":10,"resourcesProcessed":5}]}}`)) + _, _ = w.Write([]byte(`{"migration":{"migrationId":"42","status":"STATUS_TYPE_IN_PROGRESS","expiresAt":"2024-01-01T00:00:00Z","exporterMigrationGuid":"source-guid","repositoryProgress":[{"repositoryNwo":"octo/repo","resourcesAdded":10,"resourcesProcessed":5}]}}`)) })) defer srv.Close() @@ -304,6 +304,7 @@ func TestGetTargetMigrationStatus(t *testing.T) { assert.Equal(t, "/enterprise/migration/42/status", gotPath) assert.Equal(t, "42", resp.Migration.MigrationID) + assert.Equal(t, "source-guid", resp.Migration.ExporterMigrationGUID) require.Len(t, resp.Migration.RepositoryProgress, 1) assert.Equal(t, "octo/repo", resp.Migration.RepositoryProgress[0].RepositoryNWO) assert.Equal(t, int64(10), resp.Migration.RepositoryProgress[0].ResourcesAdded) @@ -328,6 +329,25 @@ func TestGetTargetMigrationStatus(t *testing.T) { assert.Equal(t, int64(1), progress.LiveUpdateResourcesAcknowledged) }) + t.Run("decodes repository state summaries", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"migration":{"migrationId":"42","repositoryStateSummaries":[{"repository":"octo/repo","backfill":{"total":"12","breakdown":[{"state":"processed","kind":"resource","type":"issue_comment","count":"10","origin":"backfill"},{"state":"failed","kind":"resource","type":"issue_comment","count":2,"origin":"backfill"}]},"liveUpdate":{"total":"3","breakdown":[{"state":"pending","kind":"event","type":"pull_request","count":"3","origin":"live_update"}]}}]}}`)) + })) + defer srv.Close() + + resp, err := NewClient(srv.URL, "tok").GetTargetMigrationStatus(t.Context(), 42) + + require.NoError(t, err) + require.Len(t, resp.Migration.RepositoryStateSummaries, 1) + summary := resp.Migration.RepositoryStateSummaries[0] + assert.Equal(t, "octo/repo", summary.Repository) + assert.Equal(t, int64(12), summary.Backfill.Total) + require.Len(t, summary.Backfill.Breakdown, 2) + assert.Equal(t, "issue_comment", summary.Backfill.Breakdown[0].Type) + assert.Equal(t, int64(10), summary.Backfill.Breakdown[0].Count) + assert.Equal(t, int64(3), summary.LiveUpdate.Total) + }) + t.Run("rejects malformed progress counts", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"migration":{"migrationId":"42","status":"STATUS_TYPE_IN_PROGRESS","expiresAt":"2024-01-01T00:00:00Z","repositoryProgress":[{"resourcesAdded":"many"}]}}`)) diff --git a/internal/tui/model.go b/internal/tui/model.go index e29faee..e919f03 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1106,8 +1106,6 @@ func (m *Model) activateTargetAction() (tea.Model, tea.Cmd) { return m, nil } switch actions[m.actionFocus].id { - case "refresh": - return m.refresh() case "resources": return m.openResourcesForm() case "report-request": @@ -1131,7 +1129,6 @@ func (m *Model) activateTargetAction() (tea.Model, tea.Cmd) { func (m *Model) targetActionItems() []actionItem { actions := []actionItem{ - {id: "refresh", label: "Refresh", shortcut: "r"}, {id: "resources", label: "List repository resources", shortcut: "o"}, {id: "report-request", label: "Request node report", shortcut: "n"}, {id: "report-status", label: "Check report status", shortcut: "s"}, diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 62b51d4..e777ccd 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -904,6 +904,120 @@ func TestModelNavigationAndLayout(t *testing.T) { assert.Contains(t, model.sourceDetailView(), "In progress") }) + t.Run("destination detail renders responsive progress tables", func(t *testing.T) { + summaries := []elmapi.TargetRepositoryStateSummary{ + { + Repository: "acme/api", + Backfill: elmapi.TargetOriginStateSummary{ + Breakdown: []elmapi.TargetStateBreakdownEntry{ + {State: "processed", Type: "issue", Count: 1100}, + {State: "pending", Type: "issue", Count: 100}, + {State: "failed", Type: "issue", Count: 50}, + {State: "processed", Type: "issue_comment", Count: 75}, + {State: "processed", Type: "organization", Count: 1}, + }, + }, + LiveUpdate: elmapi.TargetOriginStateSummary{ + Breakdown: []elmapi.TargetStateBreakdownEntry{ + {State: "processed", Type: "pull_request", Count: 79}, + {State: "eligible", Type: "pull_request", Count: 5}, + }, + }, + }, + { + Repository: "acme/web", + Backfill: elmapi.TargetOriginStateSummary{ + Breakdown: []elmapi.TargetStateBreakdownEntry{ + {State: "processed", Type: "issue", Count: 90}, + {State: "acknowledged", Type: "issue", Count: 10}, + }, + }, + LiveUpdate: elmapi.TargetOriginStateSummary{ + Breakdown: []elmapi.TargetStateBreakdownEntry{ + {State: "processed", Type: "pull_request", Count: 12}, + {State: "failed", Type: "pull_request", Count: 1}, + {State: "pending", Type: "pull_request_review", Count: 3}, + }, + }, + }, + } + model := New(t.Context(), &fakeService{}) + model.screen = screenTargetDetail + model.targetID = 42 + model.sourceID = "403166a1-05b8-479f-b483-086496070084" + model.sourceDetail = &elmapi.MigrationDetail{ + Migration: &elmapi.MigrationSummary{ + MigrationID: string(model.sourceID), + SourceOrganizationLogin: "acme-corp", + SourceRepositoryName: "android-app", + TargetOrganizationLogin: "acme-cloud", + TargetRepositoryName: "android-app", + TargetMigrationID: int64(model.targetID), + }, + } + model.targetDetail = &elmapi.TargetMigration{ + MigrationID: "42", + Status: elmapi.TargetMigrationStatusInProgress, + Repositories: []string{"acme/api", "acme/web"}, + Description: "Migration of acme/api to acme-cloud/api", + RepositoryStateSummaries: summaries, + } + + model.width = 120 + model.height = 30 + wide := model.targetDetailView() + view := model.View() + + assert.Contains(t, view, "Migration · acme-corp/android-app → acme-cloud/android-app · 403166a1-05b8-479f-b483-086496070084") + assert.NotContains(t, wide, "Status:") + assert.NotContains(t, wide, "Repositories:") + assert.NotContains(t, wide, "Description:") + assert.NotContains(t, wide, "Expires:") + assert.Contains(t, wide, "Backfill Breakdown (1,425 total)") + assert.Contains(t, wide, "Live Update Breakdown (100 total)") + assert.Contains(t, wide, "RESOURCE TYPE") + assert.Contains(t, wide, "PROCESSED") + assert.Contains(t, wide, "FAILED") + assert.Contains(t, wide, "IN PROGRESS") + assert.Contains(t, wide, "IssueComment") + assert.Contains(t, wide, "PullRequest") + assert.Contains(t, wide, "PullRequestReview") + assert.NotContains(t, wide, "Organization") + assert.Contains(t, wide, "1,265") + assert.Contains(t, wide, "50") + assert.Contains(t, wide, "110") + assert.Contains(t, wide, "91") + assert.Contains(t, wide, "1") + assert.Contains(t, wide, "8") + assert.Contains(t, wide, "9") + assert.True(t, lineContainsAll(wide, "Backfill Breakdown", "Live Update Breakdown")) + assert.NotContains(t, wide, "Resources ━") + + model.width = 80 + stacked := model.targetDetailView() + + assert.False(t, lineContainsAll(stacked, "Backfill Breakdown", "Live Update Breakdown")) + assert.Less(t, strings.Index(stacked, "Backfill Breakdown"), strings.Index(stacked, "Live Update Breakdown")) + assert.LessOrEqual(t, lipgloss.Width(stacked), model.contentWidth()) + }) + + t.Run("destination detail title uses target metadata when opened directly", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.targetID = 42 + model.targetDetail = &elmapi.TargetMigration{ + MigrationID: "42", + Repositories: []string{"github/migrations-vnext"}, + Description: "Migration of github/migrations-vnext to elm-test/migrations-vnext-zz", + ExporterMigrationGUID: "403166a1-05b8-479f-b483-086496070084", + } + + assert.Equal( + t, + "Migration · github/migrations-vnext → elm-test/migrations-vnext-zz · 403166a1-05b8-479f-b483-086496070084", + model.targetDetailTitle(), + ) + }) + t.Run("messages render only on their dedicated page", func(t *testing.T) { model := New(t.Context(), &fakeService{}) model.width = 80 @@ -1766,7 +1880,7 @@ func TestModelActions(t *testing.T) { t.Run("in progress can pause or abort", func(t *testing.T) { model.targetDetail = &elmapi.TargetMigration{Status: elmapi.TargetMigrationStatusInProgress} assert.ElementsMatch(t, - []string{"refresh", "resources", "report-request", "report-status", "report-url", "pause", "abort"}, + []string{"resources", "report-request", "report-status", "report-url", "pause", "abort"}, actionIDs(model.targetActionItems()), ) }) @@ -1774,7 +1888,7 @@ func TestModelActions(t *testing.T) { t.Run("paused can resume or abort", func(t *testing.T) { model.targetDetail = &elmapi.TargetMigration{Status: elmapi.TargetMigrationStatusPaused} assert.ElementsMatch(t, - []string{"refresh", "resources", "report-request", "report-status", "report-url", "resume", "abort"}, + []string{"resources", "report-request", "report-status", "report-url", "resume", "abort"}, actionIDs(model.targetActionItems()), ) }) @@ -1782,7 +1896,7 @@ func TestModelActions(t *testing.T) { t.Run("completed has no lifecycle mutation", func(t *testing.T) { model.targetDetail = &elmapi.TargetMigration{Status: elmapi.TargetMigrationStatusComplete} assert.ElementsMatch(t, - []string{"refresh", "resources", "report-request", "report-status", "report-url"}, + []string{"resources", "report-request", "report-status", "report-url"}, actionIDs(model.targetActionItems()), ) }) @@ -1905,6 +2019,22 @@ func actionLabels(actions []actionItem) []string { return labels } +func lineContainsAll(value string, fragments ...string) bool { + for line := range strings.SplitSeq(value, "\n") { + containsAll := true + for _, fragment := range fragments { + if !strings.Contains(line, fragment) { + containsAll = false + break + } + } + if containsAll { + return true + } + } + return false +} + type fakeService struct { service listSourceMigrations func(context.Context, string) ([]elmapi.MigrationSummary, error) diff --git a/internal/tui/view.go b/internal/tui/view.go index 228be14..875a1c2 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -3,6 +3,8 @@ package tui import ( "fmt" "net/url" + "slices" + "strconv" "strings" "github.com/charmbracelet/bubbles/viewport" @@ -73,7 +75,7 @@ func (m *Model) View() string { body = m.targetListView() help = helpLine(keys.Up, keys.Down, keys.Open, keys.New, keys.Manual, keys.Refresh, keys.Back) case screenTargetDetail: - title = fmt.Sprintf("Destination migration %d", m.targetID) + title = m.targetDetailTitle() body = m.targetDetailView() help = helpLine(keys.Up, keys.Down, keys.Open, keys.PageUp, keys.PageDown, keys.Refresh, keys.Back) case screenMannequins: @@ -415,6 +417,159 @@ func (m *Model) sourceDetailView() string { return "" } +const ( + progressTableGap = 3 + minProgressTableWidth = 46 +) + +type progressStage int + +const ( + progressBackfill progressStage = iota + progressLiveUpdate +) + +func (m *Model) targetProgressTables(summaries []elmapi.TargetRepositoryStateSummary) string { + if len(summaries) == 0 { + return "" + } + + width := m.contentWidth() + if width >= 2*minProgressTableWidth+progressTableGap { + leftWidth := (width - progressTableGap) / 2 + rightWidth := width - progressTableGap - leftWidth + return lipgloss.JoinHorizontal( + lipgloss.Top, + m.progressTable("Backfill Breakdown", summaries, progressBackfill, leftWidth), + strings.Repeat(" ", progressTableGap), + m.progressTable("Live Update Breakdown", summaries, progressLiveUpdate, rightWidth), + ) + } + + return m.progressTable("Backfill Breakdown", summaries, progressBackfill, width) + + "\n\n" + + m.progressTable("Live Update Breakdown", summaries, progressLiveUpdate, width) +} + +func (m *Model) progressTable(title string, summaries []elmapi.TargetRepositoryStateSummary, stage progressStage, width int) string { + type row struct { + resourceType string + processed int64 + failed int64 + total int64 + } + + byType := make(map[string]row) + for _, repository := range summaries { + var entries []elmapi.TargetStateBreakdownEntry + switch stage { + case progressBackfill: + entries = repository.Backfill.Breakdown + case progressLiveUpdate: + entries = repository.LiveUpdate.Breakdown + } + for _, entry := range entries { + if strings.EqualFold(entry.Type, "organization") { + continue + } + current := byType[entry.Type] + current.resourceType = resourceTypeLabel(entry.Type) + current.total += entry.Count + switch strings.ToLower(strings.TrimPrefix(entry.State, "NODE_STATE_")) { + case "processed": + current.processed += entry.Count + case "failed": + current.failed += entry.Count + } + byType[entry.Type] = current + } + } + + rows := make([]row, 0, len(byType)) + for _, current := range byType { + rows = append(rows, current) + } + slices.SortFunc(rows, func(left, right row) int { + return strings.Compare(left.resourceType, right.resourceType) + }) + + var total, totalProcessed, totalFailed int64 + innerWidth := max(1, width-2) + resourceTypeWidth := max(13, innerWidth-30) + tableRow := func(resourceType, processed, failed, inProgress string) string { + resourceType = ansi.Truncate(resourceType, resourceTypeWidth, "…") + left := lipgloss.NewStyle().Width(resourceTypeWidth).Render(resourceType) + right := func(value string, cellWidth int) string { + return lipgloss.NewStyle().Width(cellWidth).Align(lipgloss.Right).Render(value) + } + failedCell := right(failed, 7) + if failed != "FAILED" && failed != "0" { + failedCell = m.styles.Failure.Render(failedCell) + } + return left + " " + right(processed, 9) + " " + failedCell + " " + right(inProgress, 11) + } + count := formatCount + + lines := []string{ + m.styles.Muted.Bold(true).Render(tableRow("RESOURCE TYPE", "PROCESSED", "FAILED", "IN PROGRESS")), + m.styles.Muted.Render(strings.Repeat("─", innerWidth)), + } + for _, current := range rows { + inProgress := pendingResources(current.total, current.processed+current.failed) + lines = append(lines, tableRow( + current.resourceType, + count(current.processed), + count(current.failed), + count(inProgress), + )) + total += current.total + totalProcessed += current.processed + totalFailed += current.failed + } + lines = append(lines, m.styles.Bold.Render(tableRow( + "TOTAL", + count(totalProcessed), + count(totalFailed), + count(pendingResources(total, totalProcessed+totalFailed)), + ))) + + heading := m.styles.Bold.Render(fmt.Sprintf("%s (%s total)", title, count(total))) + table := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(m.styles.Muted.GetForeground()). + Render(strings.Join(lines, "\n")) + return heading + "\n" + table +} + +func pendingResources(added, processed int64) int64 { + return max(0, added-processed) +} + +func resourceTypeLabel(value string) string { + parts := strings.FieldsFunc(value, func(r rune) bool { + return r == '_' || r == '-' || r == ' ' + }) + if len(parts) == 0 { + return "Unknown" + } + for index := range parts { + parts[index] = strings.ToUpper(parts[index][:1]) + strings.ToLower(parts[index][1:]) + } + return strings.Join(parts, "") +} + +func formatCount(value int64) string { + digits := strconv.FormatInt(value, 10) + start := 0 + if strings.HasPrefix(digits, "-") { + start = 1 + } + for index := len(digits) - 3; index > start; index -= 3 { + digits = digits[:index] + "," + digits[index:] + } + return digits +} + func (m *Model) sourceDetailTitle() string { if m.sourceDetail != nil && m.sourceDetail.Migration != nil { migration := m.sourceDetail.Migration @@ -427,6 +582,74 @@ func (m *Model) sourceDetailTitle() string { return fmt.Sprintf("Migration %s", m.sourceID) } +func (m *Model) targetDetailTitle() string { + var source, target, id string + if migration := m.sourceMigrationForTarget(); migration != nil { + source = repositoryCoordinate(migration.SourceOrganizationLogin, migration.SourceRepositoryName) + target = repositoryCoordinate(migration.TargetOrganizationLogin, migration.TargetRepositoryName) + id = migration.MigrationID + } + if migration := m.targetDetail; migration != nil { + if source == "" && len(migration.Repositories) > 0 { + source = migration.Repositories[0] + } + if parsedSource, parsedTarget, ok := parseMigrationDescription(migration.Description); ok { + if source == "" { + source = parsedSource + } + if target == "" { + target = parsedTarget + } + } + if id == "" { + id = migration.ExporterMigrationGUID + } + if id == "" { + id = migration.MigrationID + } + } + if id == "" && m.targetID > 0 { + id = strconv.FormatInt(int64(m.targetID), 10) + } + + parts := []string{"Migration"} + if source != "" && target != "" { + parts = append(parts, source+" → "+target) + } else if source != "" { + parts = append(parts, source) + } + if id != "" { + parts = append(parts, id) + } + return strings.Join(parts, " · ") +} + +func (m *Model) sourceMigrationForTarget() *elmapi.MigrationSummary { + if m.sourceDetail != nil && m.sourceDetail.Migration != nil && + workflow.TargetMigrationID(m.sourceDetail.Migration.TargetMigrationID) == m.targetID { + return m.sourceDetail.Migration + } + for index := range m.sourceMigrations { + if workflow.TargetMigrationID(m.sourceMigrations[index].TargetMigrationID) == m.targetID { + return &m.sourceMigrations[index] + } + } + return nil +} + +func parseMigrationDescription(description string) (source, target string, ok bool) { + const prefix = "Migration of " + remainder, found := strings.CutPrefix(description, prefix) + if !found { + return "", "", false + } + source, target, found = strings.Cut(remainder, " to ") + if !found || source == "" || target == "" { + return "", "", false + } + return source, target, true +} + func repositoryCoordinate(owner, name string) string { switch { case owner == "": @@ -491,48 +714,10 @@ func (m *Model) targetListView() string { } func (m *Model) targetDetailView() string { - var detail strings.Builder - if migration := m.targetDetail; migration != nil { - fmt.Fprintf(&detail, "Status: %s\n", friendly(migration.Status)) - fmt.Fprintf(&detail, "Repositories: %s\n", strings.Join(migration.Repositories, ", ")) - if migration.Description != "" { - fmt.Fprintf(&detail, "Description: %s\n", migration.Description) - } - if !migration.ExpiresAt.IsZero() { - fmt.Fprintf(&detail, "Expires: %s\n", migration.ExpiresAt.Format("2006-01-02 15:04:05Z07:00")) - } - if len(migration.RepositoryProgress) > 0 { - detail.WriteString("\nRepository progress\n") - } - for index, progress := range migration.RepositoryProgress { - fmt.Fprintf( - &detail, - "%s\n Resources %s %d/%d\n Events %s %d/%d\n Acknowledged: %d backfill · %d live updates\n Sent: %s resources · %s live updates\n", - progress.RepositoryNWO, - render.ProgressBar(progress.ResourcesProcessed, progress.ResourcesAdded, 20), - progress.ResourcesProcessed, - progress.ResourcesAdded, - render.ProgressBar(progress.EventsProcessed, progress.EventsAdded, 20), - progress.EventsProcessed, - progress.EventsAdded, - progress.BackfillResourcesAcknowledged, - progress.LiveUpdateResourcesAcknowledged, - yesNo(progress.AllResourcesSent), - yesNo(progress.AllLiveUpdatesSent), - ) - if index < len(migration.RepositoryProgress)-1 { - detail.WriteString("\n") - } - } - } - return detail.String() -} - -func yesNo(value bool) string { - if value { - return "yes" + if m.targetDetail == nil { + return "" } - return "no" + return m.targetProgressTables(m.targetDetail.RepositoryStateSummaries) } func (m *Model) detailLayout(detail, actions string) string { From f9a3b59a963143a84c9daaa8f4c693ab6a244f4b Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Mon, 14 Sep 2026 19:36:24 +0200 Subject: [PATCH 27/32] Show native cursor in form fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/cmd/tui.go | 2 +- internal/tui/cursor.go | 151 +++++++++++++++++++++++++++++ internal/tui/cursor_test.go | 188 ++++++++++++++++++++++++++++++++++++ internal/tui/view.go | 29 +++++- 4 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 internal/tui/cursor.go create mode 100644 internal/tui/cursor_test.go diff --git a/internal/cmd/tui.go b/internal/cmd/tui.go index 5bc6332..2da7617 100644 --- a/internal/cmd/tui.go +++ b/internal/cmd/tui.go @@ -24,7 +24,7 @@ func runRoot(cmd *cobra.Command) error { tea.WithContext(cmd.Context()), tea.WithAltScreen(), tea.WithInput(cmd.InOrStdin()), - tea.WithOutput(cmd.OutOrStdout()), + tea.WithOutput(elmtui.NativeCursorOutput(cmd.OutOrStdout())), ) if _, err := program.Run(); err != nil { if errors.Is(err, tea.ErrProgramKilled) && cmd.Context().Err() != nil { diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go new file mode 100644 index 0000000..6fc2dca --- /dev/null +++ b/internal/tui/cursor.go @@ -0,0 +1,151 @@ +package tui + +import ( + "bytes" + "fmt" + "io" + "strconv" + "strings" + "sync" + + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/term" +) + +const ( + nativeCursorPositionMarker = "\x1b_gh-elm-cursor-position\x1b\\" + nativeCursorCommandPrefix = "\x1b_gh-elm-native-cursor:" + nativeCursorCommandSuffix = "\x1b\\" +) + +type nativeCursorWriter struct { + mu sync.Mutex + writer io.Writer +} + +type nativeCursorTerminalWriter struct { + *nativeCursorWriter + term.File +} + +func (w *nativeCursorTerminalWriter) Write(p []byte) (int, error) { + return w.nativeCursorWriter.Write(p) +} + +// NativeCursorOutput adapts Bubble Tea output so forms can use the terminal's +// native cursor without changing the user's configured cursor shape or blink mode. +func NativeCursorOutput(writer io.Writer) io.Writer { + cursorWriter := &nativeCursorWriter{writer: writer} + if file, ok := writer.(term.File); ok { + return &nativeCursorTerminalWriter{nativeCursorWriter: cursorWriter, File: file} + } + return cursorWriter +} + +func (w *nativeCursorWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + + output, command := stripNativeCursorCommands(p) + if err := writeAll(w.writer, output); err != nil { + return 0, err + } + if command != "" { + if err := writeAll(w.writer, []byte(command)); err != nil { + return len(p), err + } + } + return len(p), nil +} + +func nativeCursorView(view string) string { + index := strings.Index(view, nativeCursorPositionMarker) + action := "hide" + column, row := 0, 0 + if index < 0 { + return addNativeCursorCommand(view, nativeCursorCommand(action, column, row)) + } + + before := view[:index] + lastLine := before[strings.LastIndex(before, "\n")+1:] + action = "show" + column = ansi.StringWidth(lastLine) + 1 + row = strings.Count(before, "\n") + 1 + view = strings.Replace(view, nativeCursorPositionMarker, "", 1) + return addNativeCursorCommand(view, nativeCursorCommand(action, column, row)) +} + +func addNativeCursorCommand(view, command string) string { + lines := strings.Split(view, "\n") + for index := range lines { + lines[index] = command + lines[index] + } + return strings.Join(lines, "\n") +} + +func nativeCursorCommand(action string, column, row int) string { + return fmt.Sprintf("%s%s;%d;%d%s", nativeCursorCommandPrefix, action, column, row, nativeCursorCommandSuffix) +} + +func stripNativeCursorCommands(p []byte) ([]byte, string) { + if !bytes.Contains(p, []byte(nativeCursorCommandPrefix)) { + return p, "" + } + + output := make([]byte, 0, len(p)) + command := "" + for len(p) > 0 { + start := bytes.Index(p, []byte(nativeCursorCommandPrefix)) + if start < 0 { + output = append(output, p...) + break + } + output = append(output, p[:start]...) + commandStart := start + len(nativeCursorCommandPrefix) + endOffset := bytes.Index(p[commandStart:], []byte(nativeCursorCommandSuffix)) + if endOffset < 0 { + output = append(output, p[start:]...) + break + } + end := commandStart + endOffset + if parsed := parseNativeCursorCommand(p[commandStart:end]); parsed != "" { + command = parsed + } + p = p[end+len(nativeCursorCommandSuffix):] + } + return output, command +} + +func parseNativeCursorCommand(command []byte) string { + fields := strings.Split(string(command), ";") + if len(fields) != 3 { + return "" + } + switch fields[0] { + case "hide": + return ansi.HideCursor + case "show": + column, columnErr := strconv.Atoi(fields[1]) + row, rowErr := strconv.Atoi(fields[2]) + if columnErr != nil || rowErr != nil || column < 1 || row < 1 { + return "" + } + return ansi.CursorPosition(column, row) + ansi.ShowCursor + default: + return "" + } +} + +func writeAll(writer io.Writer, p []byte) error { + for len(p) > 0 { + written, err := writer.Write(p) + if err != nil { + return err + } + if written == 0 { + return io.ErrShortWrite + } + p = p[written:] + } + return nil +} diff --git a/internal/tui/cursor_test.go b/internal/tui/cursor_test.go new file mode 100644 index 0000000..4aa72dc --- /dev/null +++ b/internal/tui/cursor_test.go @@ -0,0 +1,188 @@ +package tui + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/term" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type terminalBuffer struct { + bytes.Buffer +} + +func (*terminalBuffer) Close() error { + return nil +} + +func (*terminalBuffer) Fd() uintptr { + return 1 +} + +func TestNativeCursorOutput(t *testing.T) { + t.Run("shows the native cursor at the requested position", func(t *testing.T) { + var output bytes.Buffer + writer := NativeCursorOutput(&output) + render := "rendered output" + nativeCursorCommand("show", 12, 7) + + written, err := writer.Write([]byte(render)) + + require.NoError(t, err) + assert.Equal(t, len(render), written) + assert.Equal(t, "rendered output"+ansi.CursorPosition(12, 7)+ansi.ShowCursor, output.String()) + }) + + t.Run("hides the native cursor when no field is active", func(t *testing.T) { + var output bytes.Buffer + writer := NativeCursorOutput(&output) + render := "rendered output" + nativeCursorCommand("hide", 0, 0) + + written, err := writer.Write([]byte(render)) + + require.NoError(t, err) + assert.Equal(t, len(render), written) + assert.Equal(t, "rendered output"+ansi.HideCursor, output.String()) + }) + + t.Run("does not alter Bubble Tea terminal setup or cleanup writes", func(t *testing.T) { + var output bytes.Buffer + writer := NativeCursorOutput(&output) + + written, err := writer.Write([]byte(ansi.ShowCursor)) + + require.NoError(t, err) + assert.Equal(t, len(ansi.ShowCursor), written) + assert.Equal(t, ansi.ShowCursor, output.String()) + }) + + t.Run("preserves terminal file capabilities", func(t *testing.T) { + output := &terminalBuffer{} + + writer := NativeCursorOutput(output) + + require.Implements(t, (*term.File)(nil), writer) + assert.Equal(t, output.Fd(), writer.(term.File).Fd()) + }) +} + +func TestNativeCursorView(t *testing.T) { + t.Run("positions the cursor at the form marker", func(t *testing.T) { + view := "first line\n value" + nativeCursorPositionMarker + "\nlast line" + + render := nativeCursorView(view) + output, command := stripNativeCursorCommands([]byte(render)) + + assert.Equal(t, "first line\n value\nlast line", string(output)) + assert.Equal(t, ansi.CursorPosition(8, 2)+ansi.ShowCursor, command) + }) + + t.Run("requests a hidden cursor without a form marker", func(t *testing.T) { + render := nativeCursorView("plain view") + + output, command := stripNativeCursorCommands([]byte(render)) + + assert.Equal(t, "plain view", string(output)) + assert.Equal(t, ansi.HideCursor, command) + }) + + t.Run("includes the cursor command on every rendered line", func(t *testing.T) { + render := nativeCursorView("first line\nsecond line") + + assert.Equal(t, 2, strings.Count(render, nativeCursorCommandPrefix)) + }) +} + +func TestFormNativeCursor(t *testing.T) { + value := "https://example.com" + model := New(t.Context(), &fakeService{}) + model.width = 80 + model.height = 24 + model.screen = screenForm + model.form = formState{ + title: "Configuration", + fields: []formField{textFormField("Source URL", "", &value)}, + actions: formActions("save", "Save"), + } + + t.Run("shows the native cursor on the focused text field", func(t *testing.T) { + output, command := stripNativeCursorCommands([]byte(model.View())) + + assert.NotContains(t, string(output), nativeCursorPositionMarker) + assert.Contains(t, command, ansi.ShowCursor) + }) + + t.Run("hides the native cursor on the action row", func(t *testing.T) { + model.form.cursor = len(model.form.fields) + + _, command := stripNativeCursorCommands([]byte(model.View())) + + assert.Equal(t, ansi.HideCursor, command) + }) + + t.Run("hides the native cursor behind an overlay", func(t *testing.T) { + model.form.cursor = 0 + model.alert = alertState{title: "Alert", body: "Something happened.", parent: screenForm} + model.screen = screenAlert + + _, command := stripNativeCursorCommands([]byte(model.View())) + + assert.Equal(t, ansi.HideCursor, command) + }) + + t.Run("keeps the native cursor visible for long values", func(t *testing.T) { + value = strings.Repeat("x", model.width*2) + "tail" + model.screen = screenForm + + output, command := stripNativeCursorCommands([]byte(model.View())) + position := strings.TrimSuffix(command, ansi.ShowCursor) + var row, column int + _, err := fmt.Sscanf(position, "\x1b[%d;%dH", &row, &column) + + require.NoError(t, err) + assert.LessOrEqual(t, column, model.width) + assert.Contains(t, string(output), "tail") + }) +} + +func TestFocusedSecretField(t *testing.T) { + value := "" + model := New(t.Context(), &fakeService{}) + model.width = 80 + model.height = 24 + model.screen = screenForm + model.form = formState{ + title: "Configuration", + fields: []formField{secretFormField("Source token", &value, false)}, + actions: formActions("save", "Save"), + } + + t.Run("does not show empty placeholder", func(t *testing.T) { + output, command := stripNativeCursorCommands([]byte(model.View())) + + assert.NotContains(t, string(output), "(empty)") + assert.Contains(t, command, ansi.ShowCursor) + }) + + t.Run("shows empty placeholder when unfocused", func(t *testing.T) { + model.form.cursor = len(model.form.fields) + + output, _ := stripNativeCursorCommands([]byte(model.View())) + + assert.Contains(t, string(output), "(empty)") + }) + + t.Run("preserves already-set indicator", func(t *testing.T) { + model.form.fields[0] = secretFormField("Source token", &value, true) + model.form.cursor = 0 + + output, command := stripNativeCursorCommands([]byte(model.View())) + + assert.Contains(t, string(output), "••••••••") + assert.Contains(t, command, ansi.ShowCursor) + }) +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 875a1c2..4f9d7a0 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -24,7 +24,9 @@ const ( // View implements tea.Model. func (m *Model) View() string { if m.width > 0 && (m.width < 48 || m.height < 12) { - return m.frame("Terminal too small", "Resize to at least 48 columns by 12 rows.", "ctrl+c quit", "", "") + return nativeCursorView( + m.frame("Terminal too small", "Resize to at least 48 columns by 12 rows.", "ctrl+c quit", "", ""), + ) } current := m.screen @@ -154,6 +156,9 @@ func (m *Model) View() string { help = "enter/esc close" } rendered := m.frame(title, body, help, topLine, bottomLine) + if confirming || alerting || resultPopup { + rendered = strings.ReplaceAll(rendered, nativeCursorPositionMarker, "") + } if confirming { rendered = overlayCenter( rendered, @@ -185,7 +190,7 @@ func (m *Model) View() string { ) } } - return rendered + return nativeCursorView(rendered) } func (m *Model) frame(title, body, help, topLine, bottomLine string) string { @@ -877,6 +882,18 @@ func (m *Model) formView() string { focused := index == m.form.cursor if focused { label = m.styles.Info.Bold(true).Render(label) + if field.kind == fieldSecret && field.text != nil && *field.text == "" && placeholder { + value = "" + placeholder = false + } + if field.text != nil && (field.kind == fieldText || field.kind == fieldSecret) { + if *field.text == "" { + value = nativeCursorPositionMarker + value + } else { + value = truncateFormValue(value, max(1, m.contentWidth()-4)) + value += nativeCursorPositionMarker + } + } if placeholder { value = m.styles.Placeholder.Render(value) } @@ -927,6 +944,14 @@ func (m *Model) formView() string { return builder.String() } +func truncateFormValue(value string, width int) string { + excess := ansi.StringWidth(value) - width + if excess <= 0 { + return value + } + return ansi.TruncateLeft(value, excess, "") +} + func (m *Model) focusedFormAction() int { if m.form.cursor == len(m.form.fields) { return m.form.actionFocus From 3441ee8569d988a93829baa3346d9202f7a9d113 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Fri, 18 Sep 2026 16:38:12 +0200 Subject: [PATCH 28/32] Adapt mannequin form tests to TUI fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/tui/model_test.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index e777ccd..f3c6b9e 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1944,10 +1944,10 @@ func TestModelActions(t *testing.T) { updated, _ := model.openMannequinReclaimForm(false) model = updated.(*Model) - model.form.fields[0].value = "octo-org" - model.form.fields[1].value = "mannequin" - model.form.fields[3].value = "app[BOT]" - model.form.cursor = len(model.form.fields) - 1 + *model.form.fields[0].text = "octo-org" + *model.form.fields[1].text = "mannequin" + *model.form.fields[3].text = "app[BOT]" + model.form.cursor = len(model.form.fields) updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) @@ -1966,10 +1966,10 @@ func TestModelActions(t *testing.T) { updated, _ := model.openMannequinReclaimForm(false) model = updated.(*Model) - model.form.fields[0].value = "octo-org" - model.form.fields[1].value = "human-mannequin" - model.form.fields[3].value = "app[bot] " - model.form.cursor = len(model.form.fields) - 1 + *model.form.fields[0].text = "octo-org" + *model.form.fields[1].text = "human-mannequin" + *model.form.fields[3].text = "app[bot] " + model.form.cursor = len(model.form.fields) updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) From fbc03625b521924cea3b085d48abe54be9509766 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Fri, 18 Sep 2026 17:06:32 +0200 Subject: [PATCH 29/32] Use explicit subtests for TUI forms Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/tui/model_test.go | 135 +++++++++++++++++++++---------------- 1 file changed, 77 insertions(+), 58 deletions(-) diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index f3c6b9e..9184621 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -701,66 +701,85 @@ func TestModelUpdate(t *testing.T) { } func TestFormActions(t *testing.T) { - tests := []struct { - name string - primary string - open func(*Model) (tea.Model, tea.Cmd) - }{ - {"source migration ID", "Show migration", func(model *Model) (tea.Model, tea.Cmd) { - return model.openSourceIDForm() - }}, - {"discovered source migration", "Create", func(model *Model) (tea.Model, tea.Cmd) { - return model.openDiscoveredSourceCreateForm("source/repository", "target") - }}, - {"manual source migration", "Create", func(model *Model) (tea.Model, tea.Cmd) { - return model.openManualSourceCreateForm(screenHome, "") - }}, - {"target migration ID", "Show migration", func(model *Model) (tea.Model, tea.Cmd) { - return model.openTargetIDForm() - }}, - {"target migration creation", "Create migration", func(model *Model) (tea.Model, tea.Cmd) { - return model.openTargetCreateForm() - }}, - {"resources", "Show resources", func(model *Model) (tea.Model, tea.Cmd) { - return model.openResourcesForm() - }}, - {"report request", "Continue", func(model *Model) (tea.Model, tea.Cmd) { - return model.openReportForm("Request report", "request") - }}, - {"report status", "Show status", func(model *Model) (tea.Model, tea.Cmd) { - return model.openReportForm("Report status", "status") - }}, - {"report URL", "Show URL", func(model *Model) (tea.Model, tea.Cmd) { - return model.openReportForm("Report URL", "url") - }}, - {"mannequin search", "Search", func(model *Model) (tea.Model, tea.Cmd) { - return model.openMannequinListForm(false) - }}, - {"mannequin export", "Export mannequins", func(model *Model) (tea.Model, tea.Cmd) { - return model.openMannequinListForm(true) - }}, - {"mannequin reclaim", "Continue", func(model *Model) (tea.Model, tea.Cmd) { - return model.openMannequinReclaimForm(false) - }}, - {"mannequin CSV reclaim", "Continue", func(model *Model) (tea.Model, tea.Cmd) { - return model.openMannequinReclaimForm(true) - }}, - {"configuration", "Save", func(model *Model) (tea.Model, tea.Cmd) { - return model.openConfigurationForm() - }}, - } + t.Run("source migration ID", func(t *testing.T) { + updated, _ := New(t.Context(), &fakeService{}).openSourceIDForm() + assertFormActions(t, updated, "Show migration") + }) - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - updated, _ := test.open(New(t.Context(), &fakeService{})) - model := updated.(*Model) + t.Run("discovered source migration", func(t *testing.T) { + updated, _ := New(t.Context(), &fakeService{}).openDiscoveredSourceCreateForm("source/repository", "target") + assertFormActions(t, updated, "Create") + }) - assert.Equal(t, []string{test.primary, "Cancel"}, actionLabels(model.form.actions)) - model.form.cursor = len(model.form.fields) - assert.Contains(t, model.formView(), test.primary) - assert.Contains(t, model.formView(), "Cancel") - }) - } + t.Run("manual source migration", func(t *testing.T) { + updated, _ := New(t.Context(), &fakeService{}).openManualSourceCreateForm(screenHome, "") + assertFormActions(t, updated, "Create") + }) + + t.Run("target migration ID", func(t *testing.T) { + updated, _ := New(t.Context(), &fakeService{}).openTargetIDForm() + assertFormActions(t, updated, "Show migration") + }) + + t.Run("target migration creation", func(t *testing.T) { + updated, _ := New(t.Context(), &fakeService{}).openTargetCreateForm() + assertFormActions(t, updated, "Create migration") + }) + + t.Run("resources", func(t *testing.T) { + updated, _ := New(t.Context(), &fakeService{}).openResourcesForm() + assertFormActions(t, updated, "Show resources") + }) + + t.Run("report request", func(t *testing.T) { + updated, _ := New(t.Context(), &fakeService{}).openReportForm("Request report", "request") + assertFormActions(t, updated, "Continue") + }) + + t.Run("report status", func(t *testing.T) { + updated, _ := New(t.Context(), &fakeService{}).openReportForm("Report status", "status") + assertFormActions(t, updated, "Show status") + }) + + t.Run("report URL", func(t *testing.T) { + updated, _ := New(t.Context(), &fakeService{}).openReportForm("Report URL", "url") + assertFormActions(t, updated, "Show URL") + }) + + t.Run("mannequin search", func(t *testing.T) { + updated, _ := New(t.Context(), &fakeService{}).openMannequinListForm(false) + assertFormActions(t, updated, "Search") + }) + + t.Run("mannequin export", func(t *testing.T) { + updated, _ := New(t.Context(), &fakeService{}).openMannequinListForm(true) + assertFormActions(t, updated, "Export mannequins") + }) + + t.Run("mannequin reclaim", func(t *testing.T) { + updated, _ := New(t.Context(), &fakeService{}).openMannequinReclaimForm(false) + assertFormActions(t, updated, "Continue") + }) + + t.Run("mannequin CSV reclaim", func(t *testing.T) { + updated, _ := New(t.Context(), &fakeService{}).openMannequinReclaimForm(true) + assertFormActions(t, updated, "Continue") + }) + + t.Run("configuration", func(t *testing.T) { + updated, _ := New(t.Context(), &fakeService{}).openConfigurationForm() + assertFormActions(t, updated, "Save") + }) +} + +func assertFormActions(t *testing.T, updated tea.Model, primary string) { + t.Helper() + + model := updated.(*Model) + assert.Equal(t, []string{primary, "Cancel"}, actionLabels(model.form.actions)) + model.form.cursor = len(model.form.fields) + assert.Contains(t, model.formView(), primary) + assert.Contains(t, model.formView(), "Cancel") } func TestModelNavigationAndLayout(t *testing.T) { From 0a7e3aa126c1d5a1d3927820ebb1bfffa302980d Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Fri, 18 Sep 2026 17:07:59 +0200 Subject: [PATCH 30/32] Normalize target resource type enums Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/tui/model_test.go | 25 +++++++++++++------------ internal/tui/view.go | 13 +++++++++---- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 9184621..852735d 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -929,17 +929,17 @@ func TestModelNavigationAndLayout(t *testing.T) { Repository: "acme/api", Backfill: elmapi.TargetOriginStateSummary{ Breakdown: []elmapi.TargetStateBreakdownEntry{ - {State: "processed", Type: "issue", Count: 1100}, - {State: "pending", Type: "issue", Count: 100}, - {State: "failed", Type: "issue", Count: 50}, - {State: "processed", Type: "issue_comment", Count: 75}, - {State: "processed", Type: "organization", Count: 1}, + {State: "processed", Type: "NODE_TYPE_ISSUE", Count: 1100}, + {State: "pending", Type: "NODE_TYPE_ISSUE", Count: 100}, + {State: "failed", Type: "NODE_TYPE_ISSUE", Count: 50}, + {State: "processed", Type: "NODE_TYPE_ISSUE_COMMENT", Count: 75}, + {State: "processed", Type: "NODE_TYPE_ORGANIZATION", Count: 1}, }, }, LiveUpdate: elmapi.TargetOriginStateSummary{ Breakdown: []elmapi.TargetStateBreakdownEntry{ - {State: "processed", Type: "pull_request", Count: 79}, - {State: "eligible", Type: "pull_request", Count: 5}, + {State: "processed", Type: "NODE_TYPE_PULL_REQUEST", Count: 79}, + {State: "eligible", Type: "NODE_TYPE_PULL_REQUEST", Count: 5}, }, }, }, @@ -947,15 +947,15 @@ func TestModelNavigationAndLayout(t *testing.T) { Repository: "acme/web", Backfill: elmapi.TargetOriginStateSummary{ Breakdown: []elmapi.TargetStateBreakdownEntry{ - {State: "processed", Type: "issue", Count: 90}, - {State: "acknowledged", Type: "issue", Count: 10}, + {State: "processed", Type: "NODE_TYPE_ISSUE", Count: 90}, + {State: "acknowledged", Type: "NODE_TYPE_ISSUE", Count: 10}, }, }, LiveUpdate: elmapi.TargetOriginStateSummary{ Breakdown: []elmapi.TargetStateBreakdownEntry{ - {State: "processed", Type: "pull_request", Count: 12}, - {State: "failed", Type: "pull_request", Count: 1}, - {State: "pending", Type: "pull_request_review", Count: 3}, + {State: "processed", Type: "NODE_TYPE_PULL_REQUEST", Count: 12}, + {State: "failed", Type: "NODE_TYPE_PULL_REQUEST", Count: 1}, + {State: "pending", Type: "NODE_TYPE_PULL_REQUEST_REVIEW", Count: 3}, }, }, }, @@ -1002,6 +1002,7 @@ func TestModelNavigationAndLayout(t *testing.T) { assert.Contains(t, wide, "PullRequest") assert.Contains(t, wide, "PullRequestReview") assert.NotContains(t, wide, "Organization") + assert.NotContains(t, wide, "NodeType") assert.Contains(t, wide, "1,265") assert.Contains(t, wide, "50") assert.Contains(t, wide, "110") diff --git a/internal/tui/view.go b/internal/tui/view.go index 4f9d7a0..367fad6 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -474,11 +474,12 @@ func (m *Model) progressTable(title string, summaries []elmapi.TargetRepositoryS entries = repository.LiveUpdate.Breakdown } for _, entry := range entries { - if strings.EqualFold(entry.Type, "organization") { + resourceType := normalizeResourceType(entry.Type) + if resourceType == "organization" { continue } - current := byType[entry.Type] - current.resourceType = resourceTypeLabel(entry.Type) + current := byType[resourceType] + current.resourceType = resourceTypeLabel(resourceType) current.total += entry.Count switch strings.ToLower(strings.TrimPrefix(entry.State, "NODE_STATE_")) { case "processed": @@ -486,7 +487,7 @@ func (m *Model) progressTable(title string, summaries []elmapi.TargetRepositoryS case "failed": current.failed += entry.Count } - byType[entry.Type] = current + byType[resourceType] = current } } @@ -550,6 +551,10 @@ func pendingResources(added, processed int64) int64 { return max(0, added-processed) } +func normalizeResourceType(value string) string { + return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(value), "NODE_TYPE_")) +} + func resourceTypeLabel(value string) string { parts := strings.FieldsFunc(value, func(r rune) bool { return r == '_' || r == '-' || r == ' ' From d6ec5d6e76084c21700ad639160f120addf1fa48 Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Fri, 18 Sep 2026 17:22:56 +0200 Subject: [PATCH 31/32] Remove private Go proxy action Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/lint.yaml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 287e05f..4269893 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -10,7 +10,6 @@ on: merge_group: permissions: - id-token: write contents: read jobs: @@ -26,12 +25,6 @@ jobs: - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: go.mod - # setup-go caches by default; disable it here because this workflow - # also runs on tags and has id-token: write. - cache: false - - - name: OIDC Setup for goproxy - uses: github/setup-goproxy@5e60e1074d42316dfe2949ebf9a92bf77b24645b # v1.1.0 - name: Run Go linter run: make lint From 2634b88add10093f77ceeaf84e875d3b9b96ddaa Mon Sep 17 00:00:00 2001 From: Colin Stark Date: Fri, 18 Sep 2026 17:33:38 +0200 Subject: [PATCH 32/32] Address TUI lint findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/tui/model.go | 29 ++++++++++++++++++----------- internal/tui/view.go | 11 +++++++---- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index e919f03..c291ca1 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -275,6 +275,8 @@ func (m *Model) Init() tea.Cmd { } // Update implements tea.Model. +// +//nolint:maintidx // Bubble Tea centralizes model message dispatch in this method. func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { switch msg := message.(type) { case tea.WindowSizeMsg: @@ -549,23 +551,25 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.actionFocus++ } case key.Matches(msg, keys.Up): - if m.screen == screenHome { + switch { + case m.screen == screenHome: m.moveHomeCursor(-1) - } else if m.verticalActionScreen() && m.actionFocus > 0 { + case m.verticalActionScreen() && m.actionFocus > 0: m.actionFocus-- - } else if m.scrollableScreen() { + case m.scrollableScreen(): return m.updateViewport(msg) - } else if !m.actionScreen() && m.cursor > 0 { + case !m.actionScreen() && m.cursor > 0: m.cursor-- } case key.Matches(msg, keys.Down): - if m.screen == screenHome { + switch { + case m.screen == screenHome: m.moveHomeCursor(1) - } else if m.verticalActionScreen() && m.actionFocus < m.itemCount()-1 { + case m.verticalActionScreen() && m.actionFocus < m.itemCount()-1: m.actionFocus++ - } else if m.scrollableScreen() { + case m.scrollableScreen(): return m.updateViewport(msg) - } else if !m.actionScreen() && m.cursor < m.itemCount()-1 { + case !m.actionScreen() && m.cursor < m.itemCount()-1: m.cursor++ } case key.Matches(msg, keys.Refresh): @@ -753,7 +757,8 @@ func (m *Model) activate() (tea.Model, tea.Cmd) { switch actions[m.cursor].id { case "migrations": m.screen, m.loading, m.err = screenSourceList, m.sourceMigrations == nil, nil - return m, m.startSourceListLoad() + command := m.startSourceListLoad() + return m, command case "create": return m.openSourceCreateForm(screenHome) case "mannequins": @@ -1293,7 +1298,8 @@ func (m *Model) updateAlert(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.cursor = 0 m.homeCursorSet = false m.syncHomeCursor() - return m, m.startConfigurationLoad() + command := m.startConfigurationLoad() + return m, command } func (m *Model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { @@ -1337,7 +1343,8 @@ func (m *Model) updateResult(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return model, command } if reloadSourceList { - return m, m.startSourceListLoad() + command := m.startSourceListLoad() + return m, command } } return m, nil diff --git a/internal/tui/view.go b/internal/tui/view.go index 367fad6..bc52bd7 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -22,6 +22,8 @@ const ( ) // View implements tea.Model. +// +//nolint:maintidx // Bubble Tea centralizes screen rendering in this method. func (m *Model) View() string { if m.width > 0 && (m.width < 48 || m.height < 12) { return nativeCursorView( @@ -159,28 +161,29 @@ func (m *Model) View() string { if confirming || alerting || resultPopup { rendered = strings.ReplaceAll(rendered, nativeCursorPositionMarker, "") } - if confirming { + switch { + case confirming: rendered = overlayCenter( rendered, m.confirmationOverlay(), m.displayWidth(), displayHeight(m.height), ) - } else if alerting { + case alerting: rendered = overlayCenter( rendered, m.alertOverlay(), m.displayWidth(), displayHeight(m.height), ) - } else if resultPopup { + case resultPopup: rendered = overlayCenter( rendered, m.resultPopupOverlay(), m.displayWidth(), displayHeight(m.height), ) - } else if m.pickerInfoOpen { + case m.pickerInfoOpen: if overlay := m.pickerInfoOverlay(); overlay != "" { rendered = overlayCenter( rendered,