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 diff --git a/README.md b/README.md index de9476d..399851e 100644 --- a/README.md +++ b/README.md @@ -45,16 +45,18 @@ 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. +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 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 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..dd7a05a 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) @@ -580,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/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/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/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/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/elmapi/target_migrations.go b/internal/elmapi/target_migrations.go index 818a959..175bcd9 100644 --- a/internal/elmapi/target_migrations.go +++ b/internal/elmapi/target_migrations.go @@ -104,18 +104,118 @@ 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 { + 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 // 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 21def45..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,11 +304,62 @@ 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) }) + 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("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"}]}}`)) + })) + 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/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 f974113..671ad8e 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), + renderMigrationSummary(v.Migration, v.TargetState), + renderTargetState(v.TargetState, sourceStatus, v.Migration == nil), renderCombinedState(v.CombinedState), renderMessages(v.Messages), ) @@ -49,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{ @@ -69,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)), @@ -80,17 +88,23 @@ func renderMigrationSummary(migration *elmapi.MigrationSummary) string { return renderSection(title, lines...) } -func renderTargetState(target *elmapi.TargetState) 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") - 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))) + } + 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)) @@ -124,25 +138,33 @@ 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)), + if !combined.ReadyForCutover { + readiness.glyph = styles.Muted.Render("✗") + } + terminated := terminatedStatus(status) + var lines, renderedValues []string + normalizedStatus := normalizedValue(status) + notStarted := normalizedStatus == "created" || normalizedStatus == "queued" + cutoverStatus := cutoverRelatedStatus(status) + if cutoverStatus { + 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 { 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 != "" && - !containsEquivalentValue(renderedValues, displayMessage) { + cutoverStatus && !containsEquivalentValue(renderedValues, displayMessage) { lines = append(lines, detail(displayMessage)) renderedValues = append(renderedValues, displayMessage) } - if !completed { + if !completed && !terminated && !notStarted { for _, blocker := range combined.CutoverBlockers { blocker = strings.TrimSpace(blocker) if blocker == "" || containsEquivalentValue(renderedValues, blocker) { @@ -181,6 +203,25 @@ 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": + return true + default: + return false + } +} + func completedStatus(status string) bool { switch normalizedValue(status) { case "completed", "complete", "success", "succeeded": @@ -309,8 +350,11 @@ 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, 14), + ProgressBar(processed, added, 20), styles.Bold.Render(strconv.FormatInt(processed, 10)), strconv.FormatInt(added, 10), ) @@ -331,7 +375,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 { @@ -409,6 +455,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 edccf59..7248cc4 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) { @@ -162,6 +165,67 @@ 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 cutover readiness for created migrations", func(t *testing.T) { + status := "created" + + output := MigrationStatus(elmapi.MigrationDetail{ + CombinedState: &elmapi.CombinedState{ + Status: &status, + DisplayMessage: "Migration created - call StartMigration to begin", + CutoverBlockers: []string{"Migration not started"}, + }, + }) + + 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") + assert.Contains(t, output, "✓ Available") + assert.NotContains(t, output, "\nTarget\n") + assert.NotContains(t, output, "In progress") + }) + t.Run("preserves distinct repository phase and status", func(t *testing.T) { status := "backfilling" phase := "backfill" @@ -179,22 +243,70 @@ 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") + 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) }) } 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)) + }) +} + +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") }) } @@ -290,6 +402,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/theme/theme.go b/internal/theme/theme.go index 746af15..e2707c8 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. @@ -38,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", @@ -61,8 +70,13 @@ 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 + // 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. @@ -84,17 +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), - Placeholder: lipgloss.NewStyle().Foreground(colorPlaceholder), - Success: lipgloss.NewStyle().Foreground(colorGreen), - Active: lipgloss.NewStyle().Foreground(colorBlue), - 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 a336df3..13240a2 100644 --- a/internal/theme/theme_test.go +++ b/internal/theme/theme_test.go @@ -14,13 +14,16 @@ 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()) 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, 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()) @@ -53,6 +56,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 +67,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 58553c7..b9c0c37 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 >= 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/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/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 10d4603..c291ca1 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -8,9 +8,10 @@ import ( "errors" "fmt" "os" + "slices" "strconv" "strings" - "time" + "unicode" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" @@ -50,10 +51,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 @@ -76,6 +82,8 @@ type service interface { configurationService } +const targetListLimit = 100 + type screen int const ( @@ -89,6 +97,7 @@ const ( screenPicker screenForm screenConfirm + screenAlert screenResult ) @@ -102,11 +111,12 @@ const ( ) type formField struct { - key string label string description string + emptyValue string kind fieldKind - value string + text *string + boolean *bool options []string } @@ -114,12 +124,34 @@ type formState struct { title string description string fields []formField + actions []actionItem cursor int + actionFocus 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, present bool) formField { + field := formField{label: label, kind: fieldSecret, text: value} + if present { + field.emptyValue = "••••••••" + } + return field +} + +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 ( @@ -128,16 +160,16 @@ const ( ) type pickerState struct { - kind pickerKind - title string - parent screen - items []string - 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 + search bool + loading bool + err error + source string } type confirmState struct { @@ -148,11 +180,20 @@ type confirmState struct { focus int } +type alertState struct { + title string + body string + parent screen +} + type resultState struct { - title string - body string - parent screen - refresh bool + title string + body string + parent screen + popup bool + blankBackground bool + refresh bool + reloadSourceList bool } // Model is the Bubble Tea application model. @@ -161,27 +202,27 @@ type Model struct { service service styles theme.Styles - screen screen - width int - height int - cursor int - loading bool - err error - viewport viewport.Model - viewportReady bool - showHelp bool - - sourceMigrations []elmapi.MigrationSummary - sourceListLoaded bool - sourceListLoading bool - sourceListErr error - sourceID workflow.SourceMigrationID - sourceDetail *elmapi.MigrationDetail - sourceWatching bool - sourceSearch bool - searchInput textinput.Model - compact bool - densityUserSet 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 + sourceID workflow.SourceMigrationID + sourceDetail *elmapi.MigrationDetail + sourceSearch bool + searchInput textinput.Model + compact bool + densityUserSet bool targetMigrations []elmapi.TargetMigration targetID workflow.TargetMigrationID @@ -191,18 +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 - form formState - confirm confirmState - 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. @@ -212,23 +256,27 @@ func New(ctx context.Context, svc service) *Model { searchInput.Placeholder = "migration ID or repository" searchInput.CharLimit = 160 - return &Model{ - ctx: ctx, - service: svc, - styles: theme.New(), - screen: screenHome, - targetParent: screenTargetList, - searchInput: searchInput, + model := &Model{ + ctx: ctx, + service: svc, + styles: theme.New(), + screen: screenHome, + targetParent: screenTargetList, + searchInput: searchInput, + configurationLoading: true, } + model.syncHomeCursor() + return model } // Init implements tea.Model. func (m *Model) Init() tea.Cmd { - m.sourceListLoading = true - return tea.Batch(m.startConfigurationLoad(), m.loadSourceListCmd()) + return m.startConfigurationLoad() } // 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: @@ -255,21 +303,32 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } return m.updateKey(msg) case sourceListMsg: - m.sourceListLoading = false - m.sourceListLoaded = true - m.sourceListErr = msg.err + if msg.generation != m.sourceListGen { + return m, nil + } + if m.showConfigurationAlert(msg.err) { + return m, nil + } if m.screen == screenSourceList { m.loading = false m.err = msg.err } 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: + 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 @@ -277,15 +336,18 @@ 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() - } - if m.sourceWatching { - return m, tea.Tick(2*time.Second, func(time.Time) tea.Msg { return watchTickMsg{} }) + m.clampActionFocus() + } else if !refreshing { + m.sourceDetail = nil + m.targetID = 0 } case targetListMsg: 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 @@ -294,19 +356,29 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.cursor = 0 } case targetDetailMsg: + 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 + m.repository = "" if len(msg.migration.Repositories) > 0 { m.repository = msg.migration.Repositories[0] } - m.clampCursor() + m.clampActionFocus() + } else if !refreshing { + m.targetDetail = nil + m.repository = "" } case configMsg: if msg.generation != m.configGeneration { return m, nil } + m.configurationLoading = false m.configurationErr = msg.err if m.screen == screenConfiguration { m.loading = false @@ -327,9 +399,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 { @@ -337,6 +411,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 { @@ -344,11 +419,29 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } m.targetAuthChecked = true 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() + 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 { @@ -356,16 +449,40 @@ 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 + 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 { - m.result = resultState{title: msg.title, body: msg.body, parent: msg.parent, refresh: msg.refresh} + if msg.sourceID != "" { + m.sourceID = msg.sourceID + m.sourceDetail = nil + m.targetID = 0 + } + if msg.reloadSourceList { + m.invalidateSourceList() + } + m.result = resultState{ + title: msg.title, + body: msg.body, + parent: msg.parent, + popup: msg.popup, + blankBackground: msg.sourceID != "", + refresh: msg.refresh, + reloadSourceList: msg.reloadSourceList, + } } m.screen = screenResult m.cursor = 0 m.resetViewport() + return m, command case confirmRequestMsg: m.loading = false m.confirm = confirmState{ @@ -375,12 +492,6 @@ func (m *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { command: msg.command, } m.screen = screenConfirm - case watchTickMsg: - if m.sourceWatching && m.screen == screenSourceDetail { - m.loading = true - command := m.loadSourceDetailCmd() - return m, command - } } return m, nil } @@ -393,6 +504,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) } @@ -415,6 +528,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,22 +543,39 @@ 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.verticalActionScreen() && 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.verticalActionScreen() && m.actionFocus < m.itemCount()-1 { + m.actionFocus++ } case key.Matches(msg, keys.Up): - if !m.actionScreen() && m.cursor > 0 { + switch { + case m.screen == screenHome: + m.moveHomeCursor(-1) + case m.verticalActionScreen() && m.actionFocus > 0: + m.actionFocus-- + case m.scrollableScreen(): + return m.updateViewport(msg) + case !m.actionScreen() && m.cursor > 0: m.cursor-- } case key.Matches(msg, keys.Down): - if !m.actionScreen() && m.cursor < m.itemCount()-1 { + switch { + case m.screen == screenHome: + m.moveHomeCursor(1) + case m.verticalActionScreen() && m.actionFocus < m.itemCount()-1: + m.actionFocus++ + case m.scrollableScreen(): + return m.updateViewport(msg) + case !m.actionScreen() && m.cursor < m.itemCount()-1: 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() @@ -519,6 +651,19 @@ 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 + } + 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 := "" @@ -528,9 +673,19 @@ 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() != "" { + if m.picker.search { + m.picker.search = false m.picker.input.SetValue("") + m.picker.input.Blur() m.picker.cursor = 0 return m, nil } @@ -548,7 +703,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) } @@ -567,6 +722,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()) { @@ -575,14 +733,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) } } @@ -592,36 +750,26 @@ func (m *Model) visiblePickerItems() []string { func (m *Model) activate() (tea.Model, tea.Cmd) { switch m.screen { case screenHome: - switch m.cursor { - case 0: - 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.loadSourceListCmd() - return m, command - } - case 1: + actions := m.homeActionItems() + if m.cursor < 0 || m.cursor >= len(actions) || actions[m.cursor].disabled { + return m, nil + } + switch actions[m.cursor].id { + case "migrations": + m.screen, m.loading, m.err = screenSourceList, m.sourceMigrations == nil, nil + command := m.startSourceListLoad() + return m, command + case "create": return m.openSourceCreateForm(screenHome) - case 2: - m.screen, m.cursor = screenMannequins, 0 - case 3: + case "mannequins": + m.screen, m.actionFocus = screenMannequins, 0 + case "configuration": m.screen, m.loading, m.err = screenConfiguration, true, nil + m.actionFocus = 0 m.resetViewport() command := m.startConfigurationLoad() return m, command - case 4: - 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: @@ -630,9 +778,10 @@ 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.cursor = 0 + m.actionFocus = 0 m.resetViewport() command := m.loadSourceDetailCmd() return m, command @@ -648,9 +797,11 @@ 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.cursor = 0 + m.actionFocus = 0 m.resetViewport() command := m.loadTargetDetailCmd() return m, command @@ -667,6 +818,8 @@ 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 + var command tea.Cmd switch m.screen { case screenSourceList: m.sourceSearch = false @@ -676,24 +829,29 @@ func (m *Model) back() (tea.Model, tea.Cmd) { case screenTargetList, screenMannequins, screenConfiguration, screenHome: m.screen = screenHome case screenSourceDetail: - m.sourceWatching = false m.screen = screenSourceList + command = m.startSourceListLoad() case screenTargetDetail: m.screen = m.targetParent } - return m, nil + if m.screen == screenHome { + m.homeCursorSet = false + m.syncHomeCursor() + } + return m, command } func (m *Model) refresh() (tea.Model, tea.Cmd) { m.err = nil + m.refreshingDetail = false switch m.screen { case screenSourceList: - m.loading = true - m.sourceListLoading = true - command := m.loadSourceListCmd() + m.loading = false + command := m.startSourceListLoad() return m, command case screenSourceDetail: m.loading = true + m.refreshingDetail = true command := m.loadSourceDetailCmd() return m, command case screenTargetList: @@ -702,6 +860,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: @@ -715,7 +874,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: @@ -742,30 +901,120 @@ func (m *Model) actionScreen() bool { } } -func (m *Model) clampCursor() { - m.cursor = min(m.cursor, max(0, m.itemCount()-1)) +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)) } type actionItem struct { - id string - label string + id string + label string + shortcut string + disabled bool +} + +var homeActions = []actionItem{ + {id: "migrations", label: "Migrations"}, + {id: "create", label: "Create migration"}, + {id: "mannequins", label: "Target mannequins"}, + {id: "configuration", label: "Configuration"}, + {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) 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 { + 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 + } + } } 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": - m.sourceWatching = !m.sourceWatching - if m.sourceWatching { - m.loading = true - command := m.loadSourceDetailCmd() - return m, command + 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)) @@ -784,14 +1033,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()) @@ -801,8 +1042,10 @@ 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.cursor = 0 + m.actionFocus = 0 m.resetViewport() command := m.loadTargetDetailCmd() return m, command @@ -811,14 +1054,13 @@ 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)}, - } - - 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 && @@ -827,42 +1069,48 @@ 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"}) - } - if m.sourceDetail != nil && m.sourceDetail.CombinedState != nil { - actions = append(actions, actionItem{id: "cutover-status", label: "Show cutover status"}) + actions = append(actions, actionItem{id: "revert", label: "Revert cutover", shortcut: "v"}) } if m.targetID > 0 { - actions = append(actions, actionItem{id: "destination", label: "Open destination details"}) + actions = append(actions, actionItem{id: "destination", label: "Details", shortcut: "d"}) } 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.cursor < 0 || m.cursor >= len(actions) { + if m.actionFocus < 0 || m.actionFocus >= len(actions) { return m, nil } - switch actions[m.cursor].id { - case "refresh": - return m.refresh() + switch actions[m.actionFocus].id { case "resources": return m.openResourcesForm() case "report-request": @@ -886,11 +1134,10 @@ 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: "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,80 +1146,162 @@ 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 } -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, "_", " ") 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 { - 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 } -var configurationActions = []string{ - "Refresh configuration", - "Edit configuration", - "Reset configuration", +var configurationActions = []actionItem{ + {id: "edit", label: "Edit configuration", shortcut: "e"}, + {id: "reset", label: "Reset configuration", shortcut: "x"}, +} + +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) { - switch m.cursor { - case 0: - return m.refresh() - case 1: + if m.actionFocus < 0 || m.actionFocus >= len(configurationActions) { + return m, nil + } + switch configurationActions[m.actionFocus].id { + 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, + popup: true, + refresh: true, + reloadSourceList: true, + err: err, + } }) } 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 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.refreshingDetail = false + m.err = nil + 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() + command := m.startConfigurationLoad() + return m, command +} + func (m *Model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, keys.Left): @@ -1004,14 +1333,28 @@ 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 + 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 { + return model, tea.Batch(command, m.startSourceListLoad()) + } + return model, command + } + if reloadSourceList { + command := m.startSourceListLoad() + return m, command } } return m, nil } +func (m *Model) invalidateSourceList() { + m.sourceListGen++ + m.sourceMigrations = nil +} + func (m *Model) visibleSourceMigrations() []elmapi.MigrationSummary { query := strings.ToLower(strings.TrimSpace(m.searchInput.Value())) if query == "" { @@ -1053,10 +1396,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() { @@ -1064,7 +1415,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() { @@ -1099,10 +1450,12 @@ 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) + 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 } } @@ -1114,36 +1467,64 @@ 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.kind == fieldBool { - if field.value == "true" { - field.value = "false" + 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 { + field := &m.form.fields[m.form.cursor] + if field.boolean != nil { + *field.boolean = !*field.boolean } else { - field.value = "true" + cycleOption(field, 1) + } + } + case " ": + if !onActions { + field := &m.form.fields[m.form.cursor] + 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 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]) + } + 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++ return m, nil } - values := make(map[string]string, len(m.form.fields)) - for _, f := range m.form.fields { - values[f.key] = f.value + 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(values) + command, err := m.form.submit() if err != nil { m.form.err = err return m, nil @@ -1155,32 +1536,48 @@ 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 || 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"]) + 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 == "" { 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 @@ -1193,16 +1590,15 @@ 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 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) @@ -1214,17 +1610,16 @@ 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 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) @@ -1233,7 +1628,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 @@ -1248,27 +1642,29 @@ 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) { + actions: createMigrationActions, + 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") } @@ -1277,41 +1673,42 @@ 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"]) + actions: createMigrationActions, + 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) } @@ -1320,8 +1717,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 }, @@ -1334,26 +1731,38 @@ 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) - body := render.MigrationCreate(result.Migration) + sourceID := workflow.SourceMigrationID(result.Migration.MigrationID) + 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: title, + body: m.migrationCreatedBody(result.Migration), + parent: screenSourceDetail, + popup: true, + refresh: true, + reloadSourceList: true, + sourceID: sourceID, } - return actionMsg{title: "Migration created", body: body, parent: screenSourceDetail, refresh: true} } } 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"]) + 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 { return nil, err } m.targetID = id + m.targetDetail = nil + m.repository = "" m.targetParent = screenTargetList m.form.parent = screenTargetDetail m.resetViewport() @@ -1363,62 +1772,71 @@ 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)", + title: "Create target migration", 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) { + actions: formActions("create", "Create migration"), + 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) - 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 }, }) } 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"])) + actions: formActions("show", "Show resources"), + 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} @@ -1428,18 +1846,29 @@ 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")) + } + actionLabel := "Continue" + switch operation { + case "status": + actionLabel = "Show status" + case "url": + actionLabel = "Show URL" } 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"]} + 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 { var ( raw json.RawMessage @@ -1460,30 +1889,38 @@ 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 { title = "Export mannequins" } + actionID := "search" + actionLabel := "Search" + if export { + actionID = "export" + actionLabel = "Export mannequins" + } return m.openForm(formState{ - title: title, - parent: screenMannequins, - fields: fields, - submit: func(values map[string]string) (tea.Cmd, error) { - org := values["org"] - include := values["include"] == "true" + title: title, + parent: screenMannequins, + fields: fields, + actions: formActions(actionID, actionLabel), + 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) @@ -1495,33 +1932,41 @@ 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) { + title: "Reclaim mannequins", + parent: screenMannequins, + fields: fields, + actions: formActions("continue", "Continue"), + 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 @@ -1589,30 +2034,34 @@ func readReclaimCSV(path string) ([]ghapi.MannequinRecord, error) { } func (m *Model) openConfigurationForm() (tea.Model, tea.Cmd) { - sourceURL, targetURL := "", "" + 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{ - {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", &sourceToken, sourceTokenSet), + textFormField("Target URL", "", &targetURL), + secretFormField("Target token", &targetToken, targetTokenSet), }, - submit: func(values map[string]string) (tea.Cmd, error) { + actions: formActions("save", "Save"), + 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 configurationSavedMsg{err: err} }, nil }, }) @@ -1622,7 +2071,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} } } @@ -1630,7 +2079,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} } } @@ -1638,7 +2087,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} } } @@ -1650,12 +2099,13 @@ 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} } } type sourceListMsg struct { migrations []elmapi.MigrationSummary + generation uint64 err error } @@ -1681,6 +2131,10 @@ type configMsg struct { err error } +type configurationSavedMsg struct { + err error +} + type sourceAuthenticationMsg struct { generation uint64 err error @@ -1693,16 +2147,19 @@ type targetAuthenticationMsg struct { type pickerCatalogMsg struct { generation uint64 - items []string + items []pickerItem err error } type actionMsg struct { - title string - body string - parent screen - refresh bool - err error + title string + body string + parent screen + popup bool + refresh bool + reloadSourceList bool + sourceID workflow.SourceMigrationID + err error } type confirmRequestMsg struct { @@ -1712,12 +2169,12 @@ type confirmRequestMsg struct { command tea.Cmd } -type watchTickMsg struct{} - -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} + migrations, err := m.service.ListSourceMigrations(m.ctx, elmapi.StatusAll) + return sourceListMsg{migrations: migrations, generation: generation, err: err} } } @@ -1735,7 +2192,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} } } @@ -1759,6 +2216,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 { @@ -1787,13 +2245,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..852735d 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -2,7 +2,8 @@ package tui import ( "context" - "encoding/json" + "errors" + "fmt" "strings" "testing" @@ -17,51 +18,239 @@ import ( "github.com/github/gh-elm/internal/workflow" ) -func TestModel(t *testing.T) { - t.Run("loads configuration readiness on startup", func(t *testing.T) { +func TestModelUpdate(t *testing.T) { + t.Run("home disables configuration-dependent actions until preflight passes", func(t *testing.T) { model := New(t.Context(), &fakeService{}) - command := model.Init() + 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.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) - require.NotNil(t, command) + 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) + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) + model = updated.(*Model) + assert.Equal(t, 4, model.cursor) + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyUp}) + model = updated.(*Model) + assert.Equal(t, 3, model.cursor) + + setConfigurationReady(model) + assert.Zero(t, model.cursor) + for _, action := range model.homeActionItems() { + assert.False(t, action.disabled) + } + assert.NotContains(t, model.View(), "Checking configuration…") }) - t.Run("background prefetch errors stay off the home screen", func(t *testing.T) { + t.Run("home shows configuration checking through authentication preflight", func(t *testing.T) { model := New(t.Context(), &fakeService{}) - updated, _ := model.Update(sourceListMsg{err: assert.AnError}) + 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.Equal(t, screenHome, model.screen) - assert.NotContains(t, model.View(), assert.AnError.Error()) + assert.Contains(t, model.View(), "Checking configuration…") - updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = model.Update(sourceAuthenticationMsg{generation: 1}) model = updated.(*Model) - assert.Contains(t, model.View(), assert.AnError.Error()) + 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("uses prefetched migrations without another request", func(t *testing.T) { - model := New(t.Context(), &fakeService{}) - updated, _ := model.Update(sourceListMsg{migrations: []elmapi.MigrationSummary{{MigrationID: "source-1"}}}) + 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) + model.sourceMigrations = []elmapi.MigrationSummary{{MigrationID: "stale"}} + + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) + assert.Equal(t, screenSourceList, model.screen) + 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()) + model = updated.(*Model) + + assert.False(t, model.loading) + require.Len(t, model.sourceMigrations, 1) + 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) - assert.Nil(t, command) + 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("configuration response does not unlock a pending migration list", func(t *testing.T) { - model := New(t.Context(), &fakeService{}) - model.sourceListLoading = true - updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + t.Run("runtime configuration loss returns home through an alert", func(t *testing.T) { + service := &fakeService{ + getConfiguration: func(context.Context) (*workflow.Configuration, error) { + return &workflow.Configuration{}, nil + }, + } + model := New(t.Context(), service) + setConfigurationReady(model) + 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) - require.True(t, model.loading) - updated, _ = model.Update(configMsg{configuration: &workflow.Configuration{}}) + 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.True(t, model.loading) + 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.sourceMigrations = []elmapi.MigrationSummary{{MigrationID: "current"}} + + updated, _ := model.Update(sourceListMsg{ + migrations: []elmapi.MigrationSummary{{MigrationID: "stale"}}, + generation: 1, + }) + model = updated.(*Model) + + require.Len(t, model.sourceMigrations, 1) + assert.Equal(t, "current", model.sourceMigrations[0].MigrationID) }) t.Run("ignores stale configuration and authentication responses", func(t *testing.T) { @@ -130,55 +319,20 @@ func TestModel(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") }) - t.Run("styles the home headline", func(t *testing.T) { + t.Run("omits standalone advanced destination operations", func(t *testing.T) { model := New(t.Context(), &fakeService{}) + setConfigurationReady(model) - 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) { - close(started) - <-ctx.Done() - return nil, ctx.Err() - }, - } - model := New(t.Context(), service) - 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() - }() - <-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) { @@ -190,10 +344,14 @@ func TestModel(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) { @@ -204,11 +362,14 @@ func TestModel(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) { @@ -239,33 +400,417 @@ 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("hides warning throughout the configuration flow", 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") + + 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) { + model := New(t.Context(), &fakeService{}) + model.screen = screenSourceDetail + model.sourceID = "new-source" + model.targetID = 42 + setSourceStatus(model, elmapi.StatusCreated) + + 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("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("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.StatusInProgress) + + 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") - 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(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.StatusInProgress) + + 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("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 { + 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.sourceMigrations = []elmapi.MigrationSummary{{MigrationID: "old-source"}} + updated, _ := model.openConfigurationForm() + model = updated.(*Model) + model.form.cursor = len(model.form.fields) + + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(*Model) + require.NotNil(t, command) + updated, command = model.Update(command()) + model = updated.(*Model) + + assert.Equal(t, screenConfiguration, model.screen) + require.NotNil(t, command) + assert.Empty(t, model.sourceMigrations) + + 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.Equal(t, screenConfiguration, model.screen) + }) + + 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, + TargetURL: "https://api.staffship-01.ghe.com", + 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.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") + 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) + 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 { + return nil + }, + } + model := New(t.Context(), svc) + model.screen = screenConfiguration + model.actionFocus = len(configurationActions) - 1 + 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.Equal(t, screenResult, model.screen) + assert.Empty(t, model.sourceMigrations) + }) +} + +func TestFormActions(t *testing.T) { + t.Run("source migration ID", func(t *testing.T) { + updated, _ := New(t.Context(), &fakeService{}).openSourceIDForm() + assertFormActions(t, updated, "Show migration") + }) + + t.Run("discovered source migration", func(t *testing.T) { + updated, _ := New(t.Context(), &fakeService{}).openDiscoveredSourceCreateForm("source/repository", "target") + assertFormActions(t, updated, "Create") + }) + + 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) { 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) + setConfigurationReady(model) _, _ = model.Update(tea.WindowSizeMsg{Width: 100, Height: 60}) updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) @@ -286,9 +831,9 @@ func TestModel(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.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) @@ -298,91 +843,397 @@ func TestModel(t *testing.T) { model = updated.(*Model) updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEscape}) model = updated.(*Model) - assert.Equal(t, screenSourceDetail, model.screen) + assert.Equal(t, screenSourceDetail, model.screen) + }) + + t.Run("manual target form validates ID", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.screen = screenTargetList + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}) + model = updated.(*Model) + require.Equal(t, screenForm, model.screen) + + for _, r := range "not-a-number" { + 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) + + assert.Nil(t, cmd) + require.Error(t, model.form.err) + assert.Contains(t, model.form.err.Error(), "positive integer") + }) + + t.Run("source detail clears stale linked target ID", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.targetID = 42 + + updated, _ := model.Update(sourceDetailMsg{ + detail: &elmapi.MigrationDetail{Migration: &elmapi.MigrationSummary{MigrationID: "source-2"}}, + }) + model = updated.(*Model) + + assert.Zero(t, model.targetID) + }) + + t.Run("source actions remain visible in a standard terminal", func(t *testing.T) { + model := New(t.Context(), &fakeService{}) + model.screen = screenSourceDetail + 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 + model.height = 24 + model.actionFocus = len(model.sourceActionItems()) - 1 + + assert.Contains(t, model.View(), "Details") + 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("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: "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: "NODE_TYPE_PULL_REQUEST", Count: 79}, + {State: "eligible", Type: "NODE_TYPE_PULL_REQUEST", Count: 5}, + }, + }, + }, + { + Repository: "acme/web", + Backfill: elmapi.TargetOriginStateSummary{ + Breakdown: []elmapi.TargetStateBreakdownEntry{ + {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: "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}, + }, + }, + }, + } + 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.NotContains(t, wide, "NodeType") + 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 + 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: "tail marker"}, + }, + } + + content := model.sourceDetailView() + 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) { + model := New(t.Context(), &fakeService{}) + model.screen = screenSourceDetail + setSourceStatus(model, elmapi.StatusCreated) + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model = updated.(*Model) + assert.Equal(t, 1, model.actionFocus) + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) + model = updated.(*Model) + assert.Equal(t, 1, model.actionFocus) + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model = updated.(*Model) + assert.Zero(t, model.actionFocus) + assert.Contains(t, model.View(), "←/→ select action") }) - t.Run("manual target form validates ID", func(t *testing.T) { + t.Run("destination actions use a vertical menu", func(t *testing.T) { model := New(t.Context(), &fakeService{}) - model.screen = screenTargetList + model.screen = screenTargetDetail + model.targetDetail = &elmapi.TargetMigration{Status: elmapi.TargetMigrationStatusInProgress} - updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}) + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown}) model = updated.(*Model) - require.Equal(t, screenForm, model.screen) + assert.Equal(t, 1, model.actionFocus) - for _, r := range "not-a-number" { - updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) - model = updated.(*Model) - } - updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) model = updated.(*Model) + assert.Equal(t, 1, model.actionFocus) - assert.Nil(t, cmd) - require.Error(t, model.form.err) - assert.Contains(t, model.form.err.Error(), "positive integer") + 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("source detail clears stale linked target ID", func(t *testing.T) { + t.Run("action screens always select their first action", func(t *testing.T) { model := New(t.Context(), &fakeService{}) - model.targetID = 42 + model.cursor = 3 - updated, _ := model.Update(sourceDetailMsg{ - detail: &elmapi.MigrationDetail{Migration: &elmapi.MigrationSummary{MigrationID: "source-2"}}, + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(*Model) + updated, _ = model.Update(configMsg{ + configuration: &workflow.Configuration{}, + generation: model.configGeneration, }) model = updated.(*Model) - assert.Zero(t, model.targetID) + 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("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("source actions remain visible in a standard terminal", func(t *testing.T) { + 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" - model.sourceDetail = &elmapi.MigrationDetail{ - Migration: &elmapi.MigrationSummary{MigrationID: "source-1"}, - } - model.targetID = 42 - model.width = 80 - model.height = 24 - model.cursor = len(model.sourceActionItems()) - 1 - - assert.Contains(t, model.View(), "Open destination details") - }) + setSourceStatus(model, elmapi.StatusCreated) - t.Run("narrow detail preserves all scrollable content", func(t *testing.T) { - model := New(t.Context(), &fakeService{}) - model.width = 80 - model.height = 24 - model.screen = screenSourceDetail - 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"}, - }, - } + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + model = updated.(*Model) - content := model.sourceDetailView() - assert.Contains(t, content, "tail marker") - assert.Less(t, strings.Index(content, "Migration ID"), strings.Index(content, "Actions")) + assert.Nil(t, command) + assert.Equal(t, screenConfirm, model.screen) + assert.Contains(t, model.View(), "Cancel migration") }) - t.Run("detail actions use horizontal focus", func(t *testing.T) { + t.Run("open alias activates the focused action", func(t *testing.T) { model := New(t.Context(), &fakeService{}) - model.screen = screenSourceDetail - setSourceStatus(model, elmapi.StatusCreated) - - updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRight}) - model = updated.(*Model) - assert.Equal(t, 1, model.cursor) + 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, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown}) + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) model = updated.(*Model) - assert.Equal(t, 1, model.cursor) - updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model = updated.(*Model) - assert.Zero(t, model.cursor) - assert.Contains(t, model.View(), "←/→ select action") + 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) { @@ -412,17 +1263,129 @@ func TestModel(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 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), }, } @@ -433,6 +1396,41 @@ func TestModel(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}) @@ -449,10 +1447,18 @@ func TestModel(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 - model.cursor = 10 + model.actionFocus = 10 status := elmapi.StatusCompleted updated, _ := model.Update(sourceDetailMsg{ @@ -462,11 +1468,14 @@ 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) }) +} +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 @@ -478,9 +1487,22 @@ func TestModel(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{ - 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: migrationID, ExpiresAt: &expiresAt}, + }, nil + }, } model := New(t.Context(), svc) updated, command := model.openSourceCreateForm(screenHome) @@ -503,16 +1525,54 @@ 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) - - command, err := model.form.submit(map[string]string{ - "targetRepo": "renamed-api", - "visibility": "private", - "start": "true", - }) - require.NoError(t, err) + 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) + updated, command = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(*Model) require.NotNil(t, command) - _ = 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.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", @@ -520,34 +1580,111 @@ func TestModel(t *testing.T) { TargetRepo: "renamed-api", Visibility: "private", Start: true, - }, svc.sourceCreateInput) + }, sourceCreateInput) - updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEscape}) + updated, command = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) model = updated.(*Model) - assert.Equal(t, screenPicker, model.screen) - assert.Equal(t, "Select destination organization", model.picker.title) + 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) { + 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) { model := New(t.Context(), &fakeService{}) model.screen = screenPicker model.picker = pickerState{ - items: []string{"acme/api", "octo/web"}, + 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}}) model = updated.(*Model) } - assert.Equal(t, []string{"octo/web"}, model.visiblePickerItems()) + 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) { @@ -576,7 +1713,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) { @@ -584,14 +1721,13 @@ 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{ generation: 1, - items: []string{"stale/repo"}, + items: []pickerItem{{value: "stale/repo"}}, }) model = updated.(*Model) @@ -600,7 +1736,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) @@ -610,17 +1754,22 @@ func TestModel(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) - 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) + 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.True(t, model.result.popup) + assert.Equal(t, workflow.SourceMigrationID("created-1"), model.sourceID) assert.Equal(t, workflow.SourceCreateInput{ SourceOwner: "source-org", SourceRepo: "source-repo", @@ -628,7 +1777,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) { @@ -636,16 +1785,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) + 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 @@ -656,7 +1808,7 @@ func TestModel(t *testing.T) { } for index, action := range model.sourceActionItems() { if action.id == "cancel" { - model.cursor = index + model.actionFocus = index break } } @@ -678,7 +1830,7 @@ func TestModel(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 }, ) @@ -688,6 +1840,8 @@ func TestModel(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) @@ -701,15 +1855,25 @@ func TestModel(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", "watch", "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) { setSourceStatus(model, elmapi.StatusInProgress) - assert.ElementsMatch(t, []string{"refresh", "watch", "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) { @@ -721,12 +1885,12 @@ func TestModel(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", "messages", "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", "messages", "revert"}, actionIDs(model.sourceActionItems())) }) }) @@ -736,7 +1900,7 @@ func TestModel(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()), ) }) @@ -744,7 +1908,7 @@ func TestModel(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()), ) }) @@ -752,23 +1916,29 @@ func TestModel(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()), ) }) }) 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.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) @@ -778,13 +1948,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) { @@ -794,10 +1964,10 @@ func TestModel(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) @@ -816,10 +1986,10 @@ func TestModel(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) @@ -839,6 +2009,20 @@ 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.configurationLoading = false + model.sourceAuthChecked = true + model.targetAuthChecked = true + model.homeCursorSet = false + model.syncHomeCursor() +} + func actionIDs(actions []actionItem) []string { ids := make([]string, len(actions)) for index, action := range actions { @@ -847,135 +2031,122 @@ func actionIDs(actions []actionItem) []string { return ids } -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 -} - -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) 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 -} - -func (f *fakeService) ListTargetMigrations(ctx context.Context, _ string, _ int) ([]elmapi.TargetMigration, error) { - if f.listTargetMigrations != nil { - return f.listTargetMigrations(ctx) +func actionLabels(actions []actionItem) []string { + labels := make([]string, len(actions)) + for index, action := range actions { + labels[index] = action.label } - return nil, nil -} - -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 + return labels } -func (f *fakeService) ResumeTargetMigration(context.Context, workflow.TargetMigrationID) error { - return nil +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 } -func (f *fakeService) AbortTargetMigration(context.Context, workflow.TargetMigrationID) error { - return nil +type fakeService struct { + 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) ListResources(context.Context, workflow.ResourceInput) ([]elmapi.Node, error) { - return nil, nil +func unexpectedServiceCall(name string) { + panic("unexpected service call: " + name) } -func (f *fakeService) RequestReport(context.Context, workflow.ReportInput) (json.RawMessage, error) { - return nil, nil +func (f *fakeService) ListSourceMigrations(ctx context.Context, status string) ([]elmapi.MigrationSummary, error) { + if f.listSourceMigrations == nil { + unexpectedServiceCall("ListSourceMigrations") + } + return f.listSourceMigrations(ctx, status) } -func (f *fakeService) ReportStatus(context.Context, workflow.ReportInput) (json.RawMessage, error) { - return nil, nil +func (f *fakeService) ListSourceRepositories(ctx context.Context) ([]elmapi.Repository, error) { + if f.listSourceRepositories == nil { + unexpectedServiceCall("ListSourceRepositories") + } + return f.listSourceRepositories(ctx) } -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 066435d..bc52bd7 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -3,42 +3,56 @@ package tui import ( "fmt" "net/url" + "slices" + "strconv" "strings" "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" "github.com/github/gh-elm/internal/workflow" ) -const homeTitle = "Live migrations" +const ( + appTitle = "GHE Live Migrations" + homeTitle = "Main menu" +) // 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 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 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 && !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([]string{ - "Migrations", - "Create migration", - "Target mannequins", - "Configuration", - "Advanced destination operations", - "Quit", - }) + 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" @@ -46,84 +60,146 @@ 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.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" + 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 = m.targetDetailTitle() 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(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" 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 { + 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.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 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 • alt+del word • ←/→ choose • space toggle • enter activate • esc cancel" + } case screenResult: title = m.result.title 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 { + if m.showHelp && !confirming && !alerting && !resultPopup { title = "Keyboard help" body = m.fullHelpView() help = "? close • q quit" + topLine, bottomLine = "", "" } - if m.loading { + 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()) } - 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 || resultPopup { + help = "enter/esc close" } - rendered := m.frame(title, body, help) - if confirming { + rendered := m.frame(title, body, help, topLine, bottomLine) + if confirming || alerting || resultPopup { + rendered = strings.ReplaceAll(rendered, nativeCursorPositionMarker, "") + } + switch { + case confirming: rendered = overlayCenter( rendered, m.confirmationOverlay(), m.displayWidth(), displayHeight(m.height), ) + case alerting: + rendered = overlayCenter( + rendered, + m.alertOverlay(), + m.displayWidth(), + displayHeight(m.height), + ) + case resultPopup: + rendered = overlayCenter( + rendered, + m.resultPopupOverlay(), + m.displayWidth(), + displayHeight(m.height), + ) + case m.pickerInfoOpen: + if overlay := m.pickerInfoOverlay(); overlay != "" { + rendered = overlayCenter( + rendered, + overlay, + m.displayWidth(), + displayHeight(m.height), + ) + } } - return rendered + return nativeCursorView(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("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)) @@ -133,13 +209,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).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\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 @@ -152,7 +244,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 } @@ -160,6 +252,9 @@ func (m *Model) bodyHeight() int { } func (m *Model) configurationWarning() string { + if m.inConfigurationFlow() { + return "" + } if m.configurationErr != nil { return "Unable to load configuration: " + m.configurationErr.Error() } @@ -208,25 +303,47 @@ func (m *Model) configurationWarning() string { return strings.Join(issues, ". ") + ". Open Configuration to finish setup." } -func (m *Model) menu(items []string) string { +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 { + 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 { builder.WriteString("\n") } - builder.WriteString(m.selectorCard(item, index == m.cursor, true)) + label := item.label + if item.disabled { + label = m.styles.Disabled.Render(label) + } + if showShortcuts && item.shortcut != "" { + label += m.styles.Muted.Render(" " + item.shortcut) + } + builder.WriteString(m.selectorCard(label, index == focus, true)) } 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 { @@ -243,14 +360,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 { @@ -259,59 +387,334 @@ 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.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()) } func (m *Model) sourceDetailView() string { - var status strings.Builder if m.sourceDetail != nil { - status.WriteString(render.MigrationStatus(*m.sourceDetail)) + detail := *m.sourceDetail + detail.Messages = nil + body := render.MigrationStatus(detail) + if detail.Migration != nil { + if _, bodyWithoutTitle, found := strings.Cut(body, "\n"); found { + return bodyWithoutTitle + } + } + return body + } + 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 { + resourceType := normalizeResourceType(entry.Type) + if resourceType == "organization" { + continue + } + current := byType[resourceType] + current.resourceType = resourceTypeLabel(resourceType) + 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[resourceType] = 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 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 == ' ' + }) + 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 + 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 (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 } - if m.sourceWatching { - status.WriteString("\n") - status.WriteString(m.styles.Active.Render("● Live watch enabled (2s refresh)")) - status.WriteString("\n") + source, target, found = strings.Cut(remainder, " to ") + if !found || source == "" || target == "" { + return "", "", false } - var actions strings.Builder - actions.WriteString(m.styles.Bold.Render("Actions") + "\n\n") - actions.WriteString(m.actionButtons(m.sourceActionItems(), m.cursor, m.contentWidth())) - return m.detailLayout(status.String(), actions.String()) + return source, target, true +} + +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." + } + 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." + 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) 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] 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") } @@ -324,51 +727,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, 12), - progress.ResourcesProcessed, - progress.ResourcesAdded, - render.ProgressBar(progress.EventsProcessed, progress.EventsAdded, 12), - progress.EventsProcessed, - progress.EventsAdded, - progress.BackfillResourcesAcknowledged, - progress.LiveUpdateResourcesAcknowledged, - yesNo(progress.AllResourcesSent), - yesNo(progress.AllLiveUpdatesSent), - ) - if index < len(migration.RepositoryProgress)-1 { - detail.WriteString("\n") - } - } - } - var actions strings.Builder - actions.WriteString(m.styles.Bold.Render("Actions") + "\n\n") - actions.WriteString(m.actionButtons(m.targetActionItems(), m.cursor, m.contentWidth())) - return m.detailLayout(detail.String(), actions.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 { @@ -381,34 +743,70 @@ 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: + return m.actionMenu(m.targetActionItems(), m.actionFocus) + default: + return "" + } + return 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 { 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(actionsFromLabels(configurationActions), m.cursor, m.contentWidth())) + builder.WriteString(m.actionButtons(configurationActions, m.actionFocus, m.contentWidth())) return builder.String() } @@ -447,12 +845,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 { @@ -461,14 +859,18 @@ 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 := field.value + value := "" + placeholder := false + 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 = "[ ]" @@ -477,18 +879,46 @@ 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 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) + } } 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.focusedFormAction(), m.contentWidth())) } var suffix string @@ -505,19 +935,38 @@ 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() } +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 + } + return -1 +} + func focusedBlockRange(blocks []string, focus, height int) (start, end int) { if len(blocks) == 0 { return 0, 0 @@ -556,37 +1005,149 @@ 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") } - 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", start)) + "\n" + 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 { 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 { + 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)) + contentWidth := width - 8 + content := m.styles.Bold.Render(title) + "\n\n" + + body + "\n\n" + + 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) { @@ -610,16 +1171,14 @@ 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": 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)) @@ -637,17 +1196,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 { @@ -658,10 +1213,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", @@ -674,7 +1233,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") } diff --git a/internal/workflow/service.go b/internal/workflow/service.go index 51ad331..2be715f 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{} @@ -152,8 +158,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 +168,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. @@ -227,7 +233,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 { @@ -546,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 != "", @@ -586,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 } @@ -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 } diff --git a/internal/workflow/service_test.go b/internal/workflow/service_test.go index 2bd0c6e..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") @@ -206,7 +209,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 +232,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) @@ -282,3 +289,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) +}