From 33651c4a852e717cdc25c0d2932037d2d0e39b2f Mon Sep 17 00:00:00 2001 From: Sebastian Florek Date: Mon, 27 Jul 2026 14:29:59 +0200 Subject: [PATCH 01/11] feat: initialize Plural CLI authentication and access modules - Add `assets.go` to embed terminal cell logo for CLI - Implement core authentication types and interfaces in `bridge/types.go` - Develop CLI state reading logic in `bridge/welcome/local.go` - Add unit tests for `welcome` package functionality - Create access management infrastructure in `bridge/access/local.go` - Include diagnostics view tests in `tui/screens/diagnostics` - Establish access management and tests in `bridge/access/access.go` - Add TUI models for diagnostics and access screens --- .gitignore | 2 +- cmd/command/plural/plural.go | 2 + cmd/command/tui/tui.go | 34 ++ cmd/command/tui/tui_test.go | 30 ++ go.mod | 17 + go.sum | 40 ++ pkg/api/client.go | 8 +- pkg/bridge/access/access.go | 287 ++++++++++++++ pkg/bridge/access/access_test.go | 158 ++++++++ pkg/bridge/access/local.go | 194 +++++++++ pkg/bridge/access/local_test.go | 54 +++ pkg/bridge/access/plural_service_accounts.go | 75 ++++ .../access/plural_service_accounts_test.go | 35 ++ pkg/bridge/auth.go | 91 +++++ pkg/bridge/auth_test.go | 98 +++++ pkg/bridge/credentials.go | 130 ++++++ pkg/bridge/errors.go | 21 + pkg/bridge/errors_test.go | 18 + pkg/bridge/identity.go | 28 ++ pkg/bridge/identity_test.go | 27 ++ pkg/bridge/legacy_profile.go | 26 ++ pkg/bridge/legacy_profile_test.go | 26 ++ pkg/bridge/plural_auth.go | 46 +++ pkg/bridge/types.go | 172 ++++++++ pkg/bridge/welcome/local.go | 96 +++++ pkg/bridge/welcome/local_test.go | 75 ++++ pkg/bridge/welcome/welcome.go | 60 +++ pkg/bridge/welcome/welcome_test.go | 22 ++ pkg/common/common.go | 94 +++-- pkg/common/login_test.go | 100 +++++ pkg/config/config.go | 15 +- pkg/console/config.go | 5 +- tui/app/model.go | 93 +++++ tui/app/model_test.go | 86 ++++ tui/app/run.go | 35 ++ tui/app/view.go | 25 ++ tui/assets/assets.go | 9 + tui/assets/logo.txt | 5 + tui/components/commandbar/commandbar.go | 236 +++++++++++ tui/components/commandbar/commandbar_test.go | 97 +++++ tui/components/page/page.go | 103 +++++ tui/components/page/page_test.go | 37 ++ tui/components/spinner/spinner.go | 23 ++ tui/components/spinner/spinner_test.go | 29 ++ tui/navigation/navigation.go | 21 + tui/screens/access/golden_test.go | 74 ++++ tui/screens/access/model.go | 371 ++++++++++++++++++ tui/screens/access/model_test.go | 137 +++++++ tui/screens/access/testdata/access-120.golden | 30 ++ tui/screens/access/testdata/access-80.golden | 24 ++ tui/screens/access/view.go | 153 ++++++++ tui/screens/diagnostics/golden_test.go | 67 ++++ tui/screens/diagnostics/model.go | 78 ++++ tui/screens/diagnostics/model_test.go | 33 ++ .../testdata/diagnostics-120.golden | 30 ++ .../testdata/diagnostics-80.golden | 24 ++ tui/screens/diagnostics/view.go | 65 +++ tui/screens/welcome/model.go | 90 +++++ tui/screens/welcome/model_test.go | 323 +++++++++++++++ .../welcome/testdata/welcome-120.golden | 30 ++ .../welcome/testdata/welcome-80.golden | 24 ++ .../welcome/testdata/welcome-popup-80.golden | 24 ++ tui/screens/welcome/view.go | 273 +++++++++++++ tui/theme/testdata/ansi16.golden | 1 + tui/theme/testdata/ansi256.golden | 1 + tui/theme/testdata/no-color.golden | 1 + tui/theme/testdata/truecolor.golden | 1 + tui/theme/theme.go | 98 +++++ tui/theme/theme_test.go | 37 ++ 69 files changed, 4820 insertions(+), 54 deletions(-) create mode 100644 cmd/command/tui/tui.go create mode 100644 cmd/command/tui/tui_test.go create mode 100644 pkg/bridge/access/access.go create mode 100644 pkg/bridge/access/access_test.go create mode 100644 pkg/bridge/access/local.go create mode 100644 pkg/bridge/access/local_test.go create mode 100644 pkg/bridge/access/plural_service_accounts.go create mode 100644 pkg/bridge/access/plural_service_accounts_test.go create mode 100644 pkg/bridge/auth.go create mode 100644 pkg/bridge/auth_test.go create mode 100644 pkg/bridge/credentials.go create mode 100644 pkg/bridge/errors.go create mode 100644 pkg/bridge/errors_test.go create mode 100644 pkg/bridge/identity.go create mode 100644 pkg/bridge/identity_test.go create mode 100644 pkg/bridge/legacy_profile.go create mode 100644 pkg/bridge/legacy_profile_test.go create mode 100644 pkg/bridge/plural_auth.go create mode 100644 pkg/bridge/types.go create mode 100644 pkg/bridge/welcome/local.go create mode 100644 pkg/bridge/welcome/local_test.go create mode 100644 pkg/bridge/welcome/welcome.go create mode 100644 pkg/bridge/welcome/welcome_test.go create mode 100644 pkg/common/login_test.go create mode 100644 tui/app/model.go create mode 100644 tui/app/model_test.go create mode 100644 tui/app/run.go create mode 100644 tui/app/view.go create mode 100644 tui/assets/assets.go create mode 100644 tui/assets/logo.txt create mode 100644 tui/components/commandbar/commandbar.go create mode 100644 tui/components/commandbar/commandbar_test.go create mode 100644 tui/components/page/page.go create mode 100644 tui/components/page/page_test.go create mode 100644 tui/components/spinner/spinner.go create mode 100644 tui/components/spinner/spinner_test.go create mode 100644 tui/navigation/navigation.go create mode 100644 tui/screens/access/golden_test.go create mode 100644 tui/screens/access/model.go create mode 100644 tui/screens/access/model_test.go create mode 100644 tui/screens/access/testdata/access-120.golden create mode 100644 tui/screens/access/testdata/access-80.golden create mode 100644 tui/screens/access/view.go create mode 100644 tui/screens/diagnostics/golden_test.go create mode 100644 tui/screens/diagnostics/model.go create mode 100644 tui/screens/diagnostics/model_test.go create mode 100644 tui/screens/diagnostics/testdata/diagnostics-120.golden create mode 100644 tui/screens/diagnostics/testdata/diagnostics-80.golden create mode 100644 tui/screens/diagnostics/view.go create mode 100644 tui/screens/welcome/model.go create mode 100644 tui/screens/welcome/model_test.go create mode 100644 tui/screens/welcome/testdata/welcome-120.golden create mode 100644 tui/screens/welcome/testdata/welcome-80.golden create mode 100644 tui/screens/welcome/testdata/welcome-popup-80.golden create mode 100644 tui/screens/welcome/view.go create mode 100644 tui/theme/testdata/ansi16.golden create mode 100644 tui/theme/testdata/ansi256.golden create mode 100644 tui/theme/testdata/no-color.golden create mode 100644 tui/theme/testdata/truecolor.golden create mode 100644 tui/theme/theme.go create mode 100644 tui/theme/theme_test.go diff --git a/.gitignore b/.gitignore index cd0d11399..0b42017d6 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,4 @@ vendor/ # Agents .codex/ -AGENTS.md \ No newline at end of file +AGENTS.md diff --git a/cmd/command/plural/plural.go b/cmd/command/plural/plural.go index 15df2fa2d..e184e8a30 100644 --- a/cmd/command/plural/plural.go +++ b/cmd/command/plural/plural.go @@ -15,6 +15,7 @@ import ( "github.com/pluralsh/plural-cli/cmd/command/pr" "github.com/pluralsh/plural-cli/cmd/command/profile" "github.com/pluralsh/plural-cli/cmd/command/stacks" + tuicmd "github.com/pluralsh/plural-cli/cmd/command/tui" "github.com/pluralsh/plural-cli/cmd/command/up" "github.com/pluralsh/plural-cli/cmd/command/version" "github.com/pluralsh/plural-cli/cmd/command/workbenches" @@ -119,6 +120,7 @@ func CreateNewApp(plural *Plural) *cli.App { profile.Command(), stacks.Command(plural.Plural), pr.Command(plural.Plural), + tuicmd.Command(), cmdinit.Command(plural.Plural), up.Command(plural.Plural), version.Command(), diff --git a/cmd/command/tui/tui.go b/cmd/command/tui/tui.go new file mode 100644 index 000000000..7aeb871ca --- /dev/null +++ b/cmd/command/tui/tui.go @@ -0,0 +1,34 @@ +package tui + +import ( + "context" + "os" + + "github.com/urfave/cli" + + "github.com/pluralsh/plural-cli/pkg/bridge" + accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + "github.com/pluralsh/plural-cli/pkg/common" + tuiapp "github.com/pluralsh/plural-cli/tui/app" +) + +// Command is the only route that starts the full-screen terminal application. +func Command() cli.Command { + return command(func(ctx context.Context) error { + welcome := welcomebridge.NewService(welcomebridge.NewLocalSource(common.Version)) + auth := bridge.NewAuthService(bridge.PluralAuthFactory{}, 0) + access := accessbridge.NewLocalManager("", auth, nil) + return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{Welcome: welcome, Access: access}) + }) +} + +func command(run func(context.Context) error) cli.Command { + return cli.Command{ + Name: "tui", + Usage: "opens the interactive terminal application", + Action: func(*cli.Context) error { + return run(context.Background()) + }, + } +} diff --git a/cmd/command/tui/tui_test.go b/cmd/command/tui/tui_test.go new file mode 100644 index 000000000..ff292a562 --- /dev/null +++ b/cmd/command/tui/tui_test.go @@ -0,0 +1,30 @@ +package tui + +import ( + "context" + "testing" + + "github.com/urfave/cli" +) + +func TestCommandIsExplicitLaunchPath(t *testing.T) { + called := false + app := cli.NewApp() + app.Commands = []cli.Command{command(func(context.Context) error { + called = true + return nil + })} + + if err := app.Run([]string{"plural"}); err != nil { + t.Fatalf("bare app: %v", err) + } + if called { + t.Fatal("bare plural launched the TUI") + } + if err := app.Run([]string{"plural", "tui"}); err != nil { + t.Fatalf("plural tui: %v", err) + } + if !called { + t.Fatal("plural tui did not launch the TUI") + } +} diff --git a/go.mod b/go.mod index a578baee8..07bc82563 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/pluralsh/plural-cli go 1.26.5 require ( + charm.land/bubbles/v2 v2.1.1 + charm.land/bubbletea/v2 v2.0.8 + charm.land/lipgloss/v2 v2.0.5 cloud.google.com/go/compute v1.64.0 cloud.google.com/go/container v1.53.0 cloud.google.com/go/resourcemanager v1.15.0 @@ -27,6 +30,9 @@ require ( github.com/aws/aws-sdk-go-v2/service/route53 v1.64.0 github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 github.com/briandowns/spinner v1.23.2 + github.com/charmbracelet/colorprofile v0.4.3 + github.com/charmbracelet/x/ansi v0.11.7 + github.com/charmbracelet/x/term v0.2.2 github.com/chartmuseum/helm-push v0.11.1 github.com/fatih/color v1.19.0 github.com/go-git/go-git/v5 v5.19.1 @@ -50,6 +56,7 @@ require ( github.com/samber/lo v1.53.0 github.com/urfave/cli v1.22.17 github.com/yuin/gopher-lua v1.1.2 + github.com/zalando/go-keyring v0.2.6 gitlab.com/gitlab-org/api/client-go v1.46.0 golang.org/x/crypto v0.53.0 golang.org/x/oauth2 v0.36.0 @@ -67,6 +74,7 @@ require ( ) require ( + al.essio.dev/pkg/shellescape v1.6.0 // indirect cel.dev/expr v0.25.2 // indirect cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect @@ -100,6 +108,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect github.com/andybalholm/brotli v1.2.2 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.27 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect @@ -118,6 +127,9 @@ require ( github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect @@ -126,6 +138,7 @@ require ( github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/docker/cli v29.6.1+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.8 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -152,6 +165,7 @@ require ( github.com/go-openapi/swag/typeutils v0.27.0 // indirect github.com/go-openapi/swag/yamlutils v0.27.0 // indirect github.com/goccy/go-json v0.10.6 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/s2a-go v0.1.9 // indirect @@ -166,9 +180,11 @@ require ( github.com/kylelemons/godebug v1.1.0 // indirect github.com/likexian/gokit v0.25.16 // indirect github.com/linkdata/deadlock v0.5.5 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect github.com/minio/simdjson-go v0.4.5 // indirect github.com/mitchellh/pointerstructure v1.2.1 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.3.0 // indirect @@ -199,6 +215,7 @@ require ( github.com/trailofbits/go-mutexasserts v0.0.0-20250514102930-c1f3d2e37561 // indirect github.com/vektah/gqlparser/v2 v2.5.36 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/collector/component v1.62.0 // indirect diff --git a/go.sum b/go.sum index 2c393e7c8..9abdfd114 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,15 @@ +al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= +al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60= +charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo= +charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= +charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY= +charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= @@ -172,6 +180,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= @@ -214,6 +224,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 h1:bLZ0PolJ8J+HkJHztcXORUpHXBye github.com/aws/aws-sdk-go-v2/service/sts v1.44.0/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q= github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -231,6 +243,20 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/gettext-go v1.0.3 h1:9liNh8t+u26xl5ddmWLmsOsdNLwkdRTg5AG+JnTiM80= github.com/chai2010/gettext-go v1.0.3/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/chartmuseum/helm-push v0.11.1 h1:H/coyIQ120kuHKGNpjVcmsillr2+rxXiiWmVCuI9DQ0= github.com/chartmuseum/helm-push v0.11.1/go.mod h1:wKQbUrVv41bnzjfrmYg30sq31w/MY0G8L/kow40FDHQ= github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= @@ -267,6 +293,8 @@ github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -396,6 +424,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= @@ -430,6 +460,8 @@ github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/v github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -515,6 +547,8 @@ github.com/likexian/gokit v0.25.16 h1:wwBeUIN/OdoPp6t00xTnZE8Di/+s969Bl5N2Kw6bzP github.com/likexian/gokit v0.25.16/go.mod h1:Wqd4f+iifV0qxA1N3MqePJTUsmRy/lpst9/yXriDx/4= github.com/linkdata/deadlock v0.5.5 h1:d6O+rzEqasSfamGDA8u7bjtaq7hOX8Ha4Zn36Wxrkvo= github.com/linkdata/deadlock v0.5.5/go.mod h1:tXb28stzAD3trzEEK0UJWC+rZKuobCoPktPYzebb1u0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 h1:YkjVPl/YH5XlJ+/NiwzJtPYXXKRcyjmEUhsDci6YK3c= github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= @@ -564,6 +598,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oleiade/reflections v1.1.0 h1:D+I/UsXQB4esMathlt0kkZRJZdUDmhv5zGi/HOwYTWo= @@ -739,6 +775,8 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= @@ -747,6 +785,8 @@ github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24= gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/pkg/api/client.go b/pkg/api/client.go index dea61a7bd..401d53b0a 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -71,6 +71,12 @@ func NewClient() Client { } func FromConfig(conf *config.Config) Client { + return FromConfigWithContext(context.Background(), conf) +} + +// FromConfigWithContext constructs a client whose requests honor caller-owned +// cancellation. FromConfig remains as the compatibility entrypoint. +func FromConfigWithContext(ctx context.Context, conf *config.Config) Client { httpClient := http.Client{ Transport: &authedTransport{ key: conf.Token, @@ -81,7 +87,7 @@ func FromConfig(conf *config.Config) Client { return &client{ pluralClient: gqlclient.NewClient(&httpClient, conf.Url(), nil), config: *conf, - ctx: context.Background(), + ctx: ctx, httpClient: &httpClient, } } diff --git a/pkg/bridge/access/access.go b/pkg/bridge/access/access.go new file mode 100644 index 000000000..afd939f40 --- /dev/null +++ b/pkg/bridge/access/access.go @@ -0,0 +1,287 @@ +package access + +import ( + "context" + "errors" + "fmt" + "net/url" + "sort" + "strings" + "sync" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type Profile = bridge.Profile +type Identity = bridge.Identity +type ConsoleProfile = bridge.ConsoleProfile +type AuthContext = bridge.AuthContext +type DeviceAuthorization = bridge.DeviceAuthorization + +// ServiceAccount is a selectable acting identity. Its credential is never +// persisted as profile metadata. +type ServiceAccount struct { + ID string `yaml:"id"` + Email string `yaml:"email"` +} + +// State is the non-secret, independently switchable access registry. +type State struct { + Profiles []Profile `yaml:"profiles"` + ConsoleProfiles []ConsoleProfile `yaml:"consoleProfiles"` + ActiveProfileID string `yaml:"activeProfile"` + ActiveConsoleID string `yaml:"activeConsole"` +} + +type Snapshot struct { + State State + Context AuthContext + ServiceAccounts []ServiceAccount +} + +type Repository interface { + Load(context.Context) (State, error) + Save(context.Context, State) error +} +type ServiceAccountSource interface { + ListServiceAccounts(context.Context, Profile, string) ([]ServiceAccount, error) +} + +// Manager is the narrow contract consumed by the Access screen. +type Manager interface { + Load(context.Context) (Snapshot, error) + BeginDeviceLogin(context.Context, string) (DeviceAuthorization, error) + CompleteDeviceLogin(context.Context, string, DeviceAuthorization, string) (Profile, error) + AddConsoleProfile(context.Context, string, string, string) (ConsoleProfile, error) + ActivateProfile(context.Context, string) error + ActivateConsole(context.Context, string) error + SearchServiceAccounts(context.Context, string) ([]ServiceAccount, error) + Impersonate(context.Context, string) error + StopImpersonating() +} + +// Service coordinates registries, secure credentials, and ephemeral sessions. +type Service struct { + repository Repository + credentials bridge.CredentialStore + auth *bridge.AuthService + serviceAccounts ServiceAccountSource + mu sync.RWMutex + acting *Identity +} + +func NewService(repository Repository, credentials bridge.CredentialStore, auth *bridge.AuthService, serviceAccounts ServiceAccountSource) *Service { + return &Service{repository: repository, credentials: credentials, auth: auth, serviceAccounts: serviceAccounts} +} + +func (s *Service) Load(ctx context.Context) (Snapshot, error) { + state, err := s.repository.Load(ctx) + if err != nil { + return Snapshot{}, err + } + result := Snapshot{State: state} + if profile, ok := findProfile(state.Profiles, state.ActiveProfileID); ok { + result.Context.Base = &profile + } + if profile, ok := findConsole(state.ConsoleProfiles, state.ActiveConsoleID); ok { + result.Context.Console = &profile + } + s.mu.RLock() + if s.acting != nil { + copy := *s.acting + result.Context.Acting = © + } + s.mu.RUnlock() + return result, result.Context.Validate() +} + +func (s *Service) BeginDeviceLogin(ctx context.Context, endpoint string) (DeviceAuthorization, error) { + if s.auth == nil { + return DeviceAuthorization{}, errors.New("device login is unavailable") + } + return s.auth.BeginDeviceLogin(ctx, normalizeAppEndpoint(endpoint)) +} + +func (s *Service) CompleteDeviceLogin(ctx context.Context, name string, authorization DeviceAuthorization, endpoint string) (Profile, error) { + if s.auth == nil { + return Profile{}, errors.New("device login is unavailable") + } + endpoint = normalizeAppEndpoint(endpoint) + credential, err := s.auth.AwaitDeviceLogin(ctx, endpoint, authorization.DeviceToken) + if err != nil { + return Profile{}, err + } + session, err := s.auth.EstablishSession(ctx, endpoint, credential, "", true, nil) + if err != nil { + return Profile{}, err + } + profile := Profile{ID: stableID("app", name, session.BaseEmail, endpoint), Name: strings.TrimSpace(name), Email: session.BaseEmail, Endpoint: endpoint} + if profile.Name == "" { + profile.Name = "default" + } + if err := s.credentials.Set(ctx, profile.ID, session.Credential); err != nil { + return Profile{}, err + } + state, err := s.repository.Load(ctx) + if err != nil { + return Profile{}, err + } + state.Profiles = upsertProfile(state.Profiles, profile) + state.ActiveProfileID = profile.ID + if err := s.repository.Save(ctx, state); err != nil { + _ = s.credentials.Delete(ctx, profile.ID) + return Profile{}, err + } + s.StopImpersonating() + return profile, nil +} + +func (s *Service) AddConsoleProfile(ctx context.Context, name, rawURL, token string) (ConsoleProfile, error) { + normalized, err := normalizeConsoleURL(rawURL) + if err != nil { + return ConsoleProfile{}, err + } + profile := ConsoleProfile{ID: stableID("console", name, normalized), Name: strings.TrimSpace(name), URL: normalized} + if profile.Name == "" { + profile.Name = "default" + } + if strings.TrimSpace(token) == "" { + return ConsoleProfile{}, errors.New("Console token is required") + } + if err := s.credentials.Set(ctx, profile.ID, token); err != nil { + return ConsoleProfile{}, err + } + state, err := s.repository.Load(ctx) + if err != nil { + return ConsoleProfile{}, err + } + state.ConsoleProfiles = upsertConsole(state.ConsoleProfiles, profile) + state.ActiveConsoleID = profile.ID + if err := s.repository.Save(ctx, state); err != nil { + _ = s.credentials.Delete(ctx, profile.ID) + return ConsoleProfile{}, err + } + return profile, nil +} + +func (s *Service) ActivateProfile(ctx context.Context, id string) error { + state, err := s.repository.Load(ctx) + if err != nil { + return err + } + if _, ok := findProfile(state.Profiles, id); !ok { + return fmt.Errorf("Plural App profile %q not found", id) + } + state.ActiveProfileID = id + s.StopImpersonating() + return s.repository.Save(ctx, state) +} + +func (s *Service) ActivateConsole(ctx context.Context, id string) error { + state, err := s.repository.Load(ctx) + if err != nil { + return err + } + if _, ok := findConsole(state.ConsoleProfiles, id); !ok { + return fmt.Errorf("Console profile %q not found", id) + } + state.ActiveConsoleID = id + return s.repository.Save(ctx, state) +} + +func (s *Service) SearchServiceAccounts(ctx context.Context, query string) ([]ServiceAccount, error) { + snapshot, err := s.Load(ctx) + if err != nil || snapshot.Context.Base == nil { + return nil, err + } + if s.serviceAccounts == nil { + return nil, nil + } + return s.serviceAccounts.ListServiceAccounts(ctx, *snapshot.Context.Base, query) +} + +func (s *Service) Impersonate(ctx context.Context, email string) error { + if s.auth == nil { + return errors.New("impersonation is unavailable") + } + snapshot, err := s.Load(ctx) + if err != nil { + return err + } + if snapshot.Context.Base == nil { + return errors.New("connect a Plural App profile before impersonating") + } + credential, err := s.credentials.Get(ctx, snapshot.Context.Base.ID) + if err != nil { + return err + } + session, err := s.auth.EstablishSession(ctx, snapshot.Context.Base.Endpoint, credential, email, false, nil) + if err != nil { + return err + } + s.mu.Lock() + s.acting = &Identity{Email: session.EffectiveEmail, ServiceAccount: true} + s.mu.Unlock() + return nil +} + +func (s *Service) StopImpersonating() { s.mu.Lock(); s.acting = nil; s.mu.Unlock() } + +func findProfile(values []Profile, id string) (Profile, bool) { + for _, value := range values { + if value.ID == id { + return value, true + } + } + return Profile{}, false +} +func findConsole(values []ConsoleProfile, id string) (ConsoleProfile, bool) { + for _, value := range values { + if value.ID == id { + return value, true + } + } + return ConsoleProfile{}, false +} +func upsertProfile(values []Profile, value Profile) []Profile { + for i := range values { + if values[i].ID == value.ID { + values[i] = value + return values + } + } + values = append(values, value) + sort.Slice(values, func(i, j int) bool { return values[i].Name < values[j].Name }) + return values +} +func upsertConsole(values []ConsoleProfile, value ConsoleProfile) []ConsoleProfile { + for i := range values { + if values[i].ID == value.ID { + values[i] = value + return values + } + } + values = append(values, value) + sort.Slice(values, func(i, j int) bool { return values[i].Name < values[j].Name }) + return values +} +func normalizeAppEndpoint(endpoint string) string { + return strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(endpoint), "https://"), "/") +} +func normalizeConsoleURL(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return "", errors.New("Console URL must be an absolute https URL") + } + parsed.Path = strings.TrimSuffix(parsed.Path, "/") + return parsed.String(), nil +} +func stableID(parts ...string) string { + joined := strings.ToLower(strings.Join(parts, "\x00")) + var hash uint64 = 1469598103934665603 + for i := range joined { + hash ^= uint64(joined[i]) + hash *= 1099511628211 + } + return fmt.Sprintf("%s-%x", parts[0], hash) +} diff --git a/pkg/bridge/access/access_test.go b/pkg/bridge/access/access_test.go new file mode 100644 index 000000000..7415fc4c4 --- /dev/null +++ b/pkg/bridge/access/access_test.go @@ -0,0 +1,158 @@ +package access + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeAuthFactory struct{ client *fakeAuthClient } + +func (f fakeAuthFactory) New(context.Context, string, string) bridge.AuthClient { return f.client } + +type fakeAuthClient struct{} + +func (*fakeAuthClient) DeviceLogin(context.Context) (bridge.DeviceAuthorization, error) { + return bridge.DeviceAuthorization{LoginURL: "https://example.com/login", DeviceToken: "device"}, nil +} +func (*fakeAuthClient) PollLoginToken(context.Context, string) (string, error) { + return "jwt", nil +} +func (*fakeAuthClient) CurrentIdentity(context.Context) (string, error) { + return "dev@example.com", nil +} +func (*fakeAuthClient) ImpersonateServiceAccount(context.Context, string) (string, string, error) { + return "session-jwt", "deploy@example.com", nil +} +func (*fakeAuthClient) GrabAccessToken(context.Context) (string, error) { + return "access-token", nil +} + +type memoryAccessRepository struct{ state State } + +func (r *memoryAccessRepository) Load(context.Context) (State, error) { return r.state, nil } +func (r *memoryAccessRepository) Save(_ context.Context, state State) error { + r.state = state + return nil +} + +type memoryCredentials struct { + values map[string]string + unavailable bool +} + +func (s *memoryCredentials) Get(_ context.Context, id string) (string, error) { + if s.unavailable { + return "", errors.New("unavailable") + } + value, ok := s.values[id] + if !ok { + return "", os.ErrNotExist + } + return value, nil +} +func (s *memoryCredentials) Set(_ context.Context, id, value string) error { + if s.unavailable { + return errors.New("unavailable") + } + if s.values == nil { + s.values = map[string]string{} + } + s.values[id] = value + return nil +} +func (s *memoryCredentials) Delete(_ context.Context, id string) error { + if s.unavailable { + return errors.New("unavailable") + } + delete(s.values, id) + return nil +} + +func TestAccessProfilesSwitchIndependentlyAndClearActingIdentity(t *testing.T) { + repository := &memoryAccessRepository{state: State{ + Profiles: []Profile{{ID: "app-a", Email: "a@example.com"}, {ID: "app-b", Email: "b@example.com"}}, ActiveProfileID: "app-a", + ConsoleProfiles: []ConsoleProfile{{ID: "console-a"}, {ID: "console-b"}}, ActiveConsoleID: "console-a", + }} + credentials := &memoryCredentials{values: map[string]string{"app-a": "base-token"}} + service := NewService(repository, credentials, bridge.NewAuthService(fakeAuthFactory{&fakeAuthClient{}}, time.Millisecond), nil) + if err := service.Impersonate(t.Context(), "deploy@example.com"); err != nil { + t.Fatalf("Impersonate() error = %v", err) + } + if err := service.ActivateConsole(t.Context(), "console-b"); err != nil { + t.Fatalf("ActivateConsole() error = %v", err) + } + snapshot, _ := service.Load(t.Context()) + if snapshot.State.ActiveProfileID != "app-a" || snapshot.State.ActiveConsoleID != "console-b" || snapshot.Context.Acting == nil { + t.Fatalf("independent Console switch lost state: %#v", snapshot) + } + if err := service.ActivateProfile(t.Context(), "app-b"); err != nil { + t.Fatalf("ActivateProfile() error = %v", err) + } + snapshot, _ = service.Load(t.Context()) + if snapshot.State.ActiveConsoleID != "console-b" || snapshot.Context.Acting != nil { + t.Fatalf("base switch did not preserve Console/clear acting: %#v", snapshot) + } + if credentials.values["app-a"] != "base-token" { + t.Fatal("impersonation overwrote the base credential") + } +} + +func TestCompleteDeviceLoginStoresBaseCredentialOutsideMetadata(t *testing.T) { + repository := &memoryAccessRepository{} + credentials := &memoryCredentials{} + service := NewService(repository, credentials, bridge.NewAuthService(fakeAuthFactory{&fakeAuthClient{}}, time.Millisecond), nil) + profile, err := service.CompleteDeviceLogin(t.Context(), "personal", DeviceAuthorization{DeviceToken: "device"}, "app.plural.sh") + if err != nil { + t.Fatalf("CompleteDeviceLogin() error = %v", err) + } + if repository.state.ActiveProfileID != profile.ID || profile.Email != "dev@example.com" { + t.Fatalf("profile = %#v state = %#v", profile, repository.state) + } + if credentials.values[profile.ID] != "access-token" { + t.Fatalf("stored credential = %q", credentials.values[profile.ID]) + } +} + +func TestFileCredentialStoreUsesOwnerOnlyPermissions(t *testing.T) { + store := bridge.FileCredentialStore{Dir: filepath.Join(t.TempDir(), "credentials")} + if err := store.Set(t.Context(), "../../unsafe", "secret"); err != nil { + t.Fatalf("Set() error = %v", err) + } + entries, err := os.ReadDir(store.Dir) + if err != nil || len(entries) != 1 { + t.Fatalf("entries = %v, error = %v", entries, err) + } + info, _ := entries[0].Info() + if info.Mode().Perm() != 0600 { + t.Fatalf("credential mode = %o", info.Mode().Perm()) + } + dirInfo, _ := os.Stat(store.Dir) + if dirInfo.Mode().Perm() != 0700 { + t.Fatalf("directory mode = %o", dirInfo.Mode().Perm()) + } +} + +func TestResilientCredentialStoreMigratesFallback(t *testing.T) { + primary := &memoryCredentials{unavailable: true} + fallback := &memoryCredentials{values: map[string]string{"profile": "secret"}} + store := bridge.ResilientCredentialStore{Primary: primary, Fallback: fallback} + if value, err := store.Get(t.Context(), "profile"); err != nil || value != "secret" { + t.Fatalf("fallback Get() = %q, %v", value, err) + } + primary.unavailable = false + if value, err := store.Get(t.Context(), "profile"); err != nil || value != "secret" { + t.Fatalf("migrating Get() = %q, %v", value, err) + } + if primary.values["profile"] != "secret" { + t.Fatal("fallback credential was not migrated") + } + if _, ok := fallback.values["profile"]; ok { + t.Fatal("fallback credential remained after migration") + } +} diff --git a/pkg/bridge/access/local.go b/pkg/bridge/access/local.go new file mode 100644 index 000000000..29cb2569a --- /dev/null +++ b/pkg/bridge/access/local.go @@ -0,0 +1,194 @@ +package access + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + + "gopkg.in/yaml.v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +const accessRegistryName = "access.yml" + +// LocalRepository persists only non-secret registry metadata and imports +// legacy config.yml/console.yml records the first time it is loaded. +type LocalRepository struct { + Dir string + Credentials bridge.CredentialStore + mu sync.Mutex +} + +func NewLocalRepository(home string, credentials bridge.CredentialStore) *LocalRepository { + if home == "" { + home, _ = os.UserHomeDir() + } + return &LocalRepository{Dir: filepath.Join(home, ".plural"), Credentials: credentials} +} + +func (r *LocalRepository) Load(ctx context.Context) (State, error) { + if err := ctx.Err(); err != nil { + return State{}, err + } + r.mu.Lock() + defer r.mu.Unlock() + contents, err := os.ReadFile(filepath.Join(r.Dir, accessRegistryName)) + if err == nil { + var state State + if err := yaml.Unmarshal(contents, &state); err != nil { + return State{}, err + } + return state, nil + } + if !errors.Is(err, os.ErrNotExist) { + return State{}, err + } + state, err := r.importLegacy(ctx) + if err != nil { + return State{}, err + } + if err := r.save(state); err != nil { + return State{}, err + } + return state, nil +} + +func (r *LocalRepository) Save(ctx context.Context, state State) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + return r.save(state) +} + +func (r *LocalRepository) save(state State) error { + contents, err := yaml.Marshal(state) + if err != nil { + return err + } + if err := os.MkdirAll(r.Dir, 0700); err != nil { + return err + } + if err := os.Chmod(r.Dir, 0700); err != nil { + return err + } + temporary, err := os.CreateTemp(r.Dir, ".access-*.tmp") + if err != nil { + return err + } + name := temporary.Name() + defer os.Remove(name) + if err := temporary.Chmod(0600); err != nil { + temporary.Close() + return err + } + if _, err := temporary.Write(contents); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return os.Rename(name, filepath.Join(r.Dir, accessRegistryName)) +} + +type legacyAppConfig struct { + Kind string `yaml:"kind"` + Metadata struct { + Name string `yaml:"name"` + } `yaml:"metadata"` + Spec struct { + Email string `yaml:"email"` + Token string `yaml:"token"` + Endpoint string `yaml:"endpoint"` + } `yaml:"spec"` +} +type legacyConsoleConfig struct { + Kind string `yaml:"kind"` + Spec struct { + URL string `yaml:"url"` + Token string `yaml:"token"` + } `yaml:"spec"` +} + +func (r *LocalRepository) importLegacy(ctx context.Context) (State, error) { + state := State{} + entries, err := os.ReadDir(r.Dir) + if errors.Is(err, os.ErrNotExist) { + return state, nil + } + if err != nil { + return state, err + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yml") || entry.Name() == accessRegistryName { + continue + } + contents, err := os.ReadFile(filepath.Join(r.Dir, entry.Name())) + if err != nil { + return state, err + } + if entry.Name() == "console.yml" { + var legacy legacyConsoleConfig + if yaml.Unmarshal(contents, &legacy) != nil || legacy.Spec.URL == "" { + continue + } + profile := ConsoleProfile{ID: stableID("console", "default", legacy.Spec.URL), Name: "default", URL: legacy.Spec.URL} + state.ConsoleProfiles = upsertConsole(state.ConsoleProfiles, profile) + state.ActiveConsoleID = profile.ID + if legacy.Spec.Token != "" && r.Credentials != nil { + if err := r.Credentials.Set(ctx, profile.ID, legacy.Spec.Token); err != nil { + return state, err + } + } + continue + } + var legacy legacyAppConfig + if yaml.Unmarshal(contents, &legacy) != nil || legacy.Kind != "Config" || legacy.Spec.Email == "" { + continue + } + name := strings.TrimSuffix(entry.Name(), ".yml") + if entry.Name() == "config.yml" { + name = "default" + } + if legacy.Metadata.Name != "" { + name = legacy.Metadata.Name + } + profile := Profile{ID: stableID("app", name, legacy.Spec.Email, legacy.Spec.Endpoint), Name: name, Email: legacy.Spec.Email, Endpoint: legacy.Spec.Endpoint} + state.Profiles = upsertProfile(state.Profiles, profile) + if entry.Name() == "config.yml" { + state.ActiveProfileID = profile.ID + } + if legacy.Spec.Token != "" && r.Credentials != nil { + if err := r.Credentials.Set(ctx, profile.ID, legacy.Spec.Token); err != nil { + return state, err + } + } + } + if state.ActiveProfileID == "" && len(state.Profiles) > 0 { + state.ActiveProfileID = state.Profiles[0].ID + } + return state, nil +} + +// NewLocalManager builds the production persistence stack. Callers can +// still inject every boundary separately in tests or alternate frontends. +func NewLocalManager(home string, auth *bridge.AuthService, serviceAccounts ServiceAccountSource) *Service { + if home == "" { + home, _ = os.UserHomeDir() + } + credentials := bridge.ResilientCredentialStore{ + Primary: bridge.KeyringCredentialStore{}, + Fallback: bridge.FileCredentialStore{Dir: filepath.Join(home, ".plural", "credentials")}, + } + if serviceAccounts == nil { + serviceAccounts = PluralServiceAccountSource{Credentials: credentials} + } + repository := NewLocalRepository(home, credentials) + return NewService(repository, credentials, auth, serviceAccounts) +} diff --git a/pkg/bridge/access/local_test.go b/pkg/bridge/access/local_test.go new file mode 100644 index 000000000..145349433 --- /dev/null +++ b/pkg/bridge/access/local_test.go @@ -0,0 +1,54 @@ +package access + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLocalAccessRepositoryImportsLegacyProfilesOnce(t *testing.T) { + home := t.TempDir() + dir := filepath.Join(home, ".plural") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + legacy := "apiVersion: platform.plural.sh/v1alpha1\nkind: Config\nmetadata:\n name: personal\nspec:\n email: dev@example.com\n endpoint: app.plural.sh\n token: legacy-secret\n" + if err := os.WriteFile(filepath.Join(dir, "config.yml"), []byte(legacy), 0600); err != nil { + t.Fatal(err) + } + credentials := &memoryCredentials{} + repository := NewLocalRepository(home, credentials) + state, err := repository.Load(t.Context()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if len(state.Profiles) != 1 || state.ActiveProfileID == "" { + t.Fatalf("state = %#v", state) + } + if credentials.values[state.ActiveProfileID] != "legacy-secret" { + t.Fatal("legacy secret was not moved to credential storage") + } + contents, err := os.ReadFile(filepath.Join(dir, accessRegistryName)) + if err != nil { + t.Fatal(err) + } + if string(contents) == "" || containsSecret(string(contents), "legacy-secret") { + t.Fatalf("registry contains secret:\n%s", contents) + } + if err := os.Remove(filepath.Join(dir, "config.yml")); err != nil { + t.Fatal(err) + } + state, err = repository.Load(t.Context()) + if err != nil || len(state.Profiles) != 1 { + t.Fatalf("second Load() = %#v, %v", state, err) + } +} + +func containsSecret(value, secret string) bool { + for i := 0; i+len(secret) <= len(value); i++ { + if value[i:i+len(secret)] == secret { + return true + } + } + return false +} diff --git a/pkg/bridge/access/plural_service_accounts.go b/pkg/bridge/access/plural_service_accounts.go new file mode 100644 index 000000000..03637e5e5 --- /dev/null +++ b/pkg/bridge/access/plural_service_accounts.go @@ -0,0 +1,75 @@ +package access + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/config" +) + +// PluralServiceAccountSource queries the Plural App API without exposing its +// GraphQL transport to the Access screen. +type PluralServiceAccountSource struct { + Credentials bridge.CredentialStore + Client *http.Client +} + +func (s PluralServiceAccountSource) ListServiceAccounts(ctx context.Context, profile Profile, query string) ([]ServiceAccount, error) { + credential, err := s.Credentials.Get(ctx, profile.ID) + if err != nil { + return nil, err + } + payload, err := json.Marshal(map[string]any{ + "query": `query TUIServiceAccounts($q: String, $serviceAccount: Boolean!) { users(q: $q, serviceAccount: $serviceAccount, first: 100) { edges { node { id email } } } }`, + "variables": map[string]any{"q": query, "serviceAccount": true}, + }) + if err != nil { + return nil, err + } + conf := config.Config{Endpoint: profile.Endpoint} + request, err := http.NewRequestWithContext(ctx, http.MethodPost, conf.Url(), bytes.NewReader(payload)) + if err != nil { + return nil, err + } + request.Header.Set("Authorization", "Bearer "+credential) + request.Header.Set("Content-Type", "application/json") + client := s.Client + if client == nil { + client = http.DefaultClient + } + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("service-account query returned %s", response.Status) + } + var result struct { + Data struct { + Users struct { + Edges []struct { + Node struct{ ID, Email string } `json:"node"` + } `json:"edges"` + } `json:"users"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + return nil, err + } + if len(result.Errors) > 0 { + return nil, fmt.Errorf("service-account query failed: %s", result.Errors[0].Message) + } + accounts := make([]ServiceAccount, 0, len(result.Data.Users.Edges)) + for _, edge := range result.Data.Users.Edges { + accounts = append(accounts, ServiceAccount{ID: edge.Node.ID, Email: edge.Node.Email}) + } + return accounts, nil +} diff --git a/pkg/bridge/access/plural_service_accounts_test.go b/pkg/bridge/access/plural_service_accounts_test.go new file mode 100644 index 000000000..2f35ff3d8 --- /dev/null +++ b/pkg/bridge/access/plural_service_accounts_test.go @@ -0,0 +1,35 @@ +package access + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } + +func TestPluralServiceAccountSourceUsesActiveBaseCredential(t *testing.T) { + credentials := &memoryCredentials{values: map[string]string{"app": "base-secret"}} + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + if got := request.Header.Get("Authorization"); got != "Bearer base-secret" { + t.Fatalf("Authorization = %q", got) + } + body, _ := io.ReadAll(request.Body) + if !strings.Contains(string(body), `"serviceAccount":true`) || !strings.Contains(string(body), `"q":"deploy"`) { + t.Fatalf("request body = %s", body) + } + return &http.Response{StatusCode: 200, Status: "200 OK", Body: io.NopCloser(strings.NewReader(`{"data":{"users":{"edges":[{"node":{"id":"sa-1","email":"deploy@example.com"}}]}}}`)), Header: make(http.Header)}, nil + })} + source := PluralServiceAccountSource{Credentials: credentials, Client: client} + accounts, err := source.ListServiceAccounts(context.Background(), Profile{ID: "app", Endpoint: "app.plural.sh"}, "deploy") + if err != nil { + t.Fatalf("ListServiceAccounts() error = %v", err) + } + if len(accounts) != 1 || accounts[0].Email != "deploy@example.com" { + t.Fatalf("accounts = %#v", accounts) + } +} diff --git a/pkg/bridge/auth.go b/pkg/bridge/auth.go new file mode 100644 index 000000000..14077a5fb --- /dev/null +++ b/pkg/bridge/auth.go @@ -0,0 +1,91 @@ +package bridge + +import ( + "context" + "errors" + "time" +) + +func NewAuthService(clients AuthClientFactory, pollInterval time.Duration) *AuthService { + if pollInterval <= 0 { + pollInterval = 2 * time.Second + } + return &AuthService{clients: clients, pollInterval: pollInterval} +} + +func (s *AuthService) BeginDeviceLogin(ctx context.Context, endpoint string) (DeviceAuthorization, error) { + authorization, err := s.clients.New(ctx, endpoint, "").DeviceLogin(ctx) + if err != nil { + return DeviceAuthorization{}, s.operationError(OperationDeviceLogin, err) + } + return authorization, nil +} + +func (s *AuthService) AwaitDeviceLogin(ctx context.Context, endpoint, deviceToken string) (string, error) { + client := s.clients.New(ctx, endpoint, "") + for { + credential, err := client.PollLoginToken(ctx, deviceToken) + if err == nil { + return credential, nil + } + + timer := time.NewTimer(s.pollInterval) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return "", &Error{Code: ErrorCancelled, Operation: OperationPollLoginToken, Err: ctx.Err()} + case <-timer.C: + } + } +} + +func (s *AuthService) EstablishSession( + ctx context.Context, + endpoint, credential, serviceAccount string, + persist bool, + notify func(AuthEvent), +) (AuthSession, error) { + client := s.clients.New(ctx, endpoint, credential) + baseEmail, err := client.CurrentIdentity(ctx) + if err != nil { + return AuthSession{}, s.operationError(OperationCurrentIdentity, err) + } + if notify != nil { + notify(AuthEvent{Kind: AuthEventIdentified, Email: baseEmail, Credential: credential}) + } + + result := AuthSession{BaseEmail: baseEmail, EffectiveEmail: baseEmail, Credential: credential} + if serviceAccount != "" { + impersonatedCredential, effectiveEmail, err := client.ImpersonateServiceAccount(ctx, serviceAccount) + if err != nil { + return AuthSession{}, s.operationError(OperationImpersonateServiceAccount, err) + } + result.EffectiveEmail = effectiveEmail + result.Credential = impersonatedCredential + result.Impersonated = true + if notify != nil { + notify(AuthEvent{Kind: AuthEventImpersonated, Email: effectiveEmail, Credential: impersonatedCredential}) + } + client = s.clients.New(ctx, endpoint, impersonatedCredential) + if !persist { + return result, nil + } + } + + accessToken, err := client.GrabAccessToken(ctx) + if err != nil { + return AuthSession{}, s.operationError(OperationGrabAccessToken, err) + } + result.Credential = accessToken + return result, nil +} + +func (s *AuthService) operationError(operation Operation, err error) error { + code := ErrorUnavailable + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + code = ErrorCancelled + } + return &Error{Code: code, Operation: operation, Err: err} +} diff --git a/pkg/bridge/auth_test.go b/pkg/bridge/auth_test.go new file mode 100644 index 000000000..5f2f58626 --- /dev/null +++ b/pkg/bridge/auth_test.go @@ -0,0 +1,98 @@ +package bridge + +import ( + "context" + "errors" + "testing" + "time" +) + +type fakeAuthFactory struct{ client *fakeAuthClient } + +func (f fakeAuthFactory) New(context.Context, string, string) AuthClient { return f.client } + +type fakeAuthClient struct { + polls int + accessCalls int +} + +func (*fakeAuthClient) DeviceLogin(context.Context) (DeviceAuthorization, error) { + return DeviceAuthorization{LoginURL: "https://example.com/login", DeviceToken: "device"}, nil +} + +func (f *fakeAuthClient) PollLoginToken(context.Context, string) (string, error) { + f.polls++ + if f.polls < 2 { + return "", errors.New("pending") + } + return "jwt", nil +} + +func (*fakeAuthClient) CurrentIdentity(context.Context) (string, error) { + return "dev@example.com", nil +} + +func (*fakeAuthClient) ImpersonateServiceAccount(context.Context, string) (string, string, error) { + return "session-jwt", "deploy@example.com", nil +} + +func (f *fakeAuthClient) GrabAccessToken(context.Context) (string, error) { + f.accessCalls++ + return "access-token", nil +} + +func TestAwaitDeviceLoginRetriesAndCanComplete(t *testing.T) { + client := &fakeAuthClient{} + service := NewAuthService(fakeAuthFactory{client}, time.Millisecond) + credential, err := service.AwaitDeviceLogin(t.Context(), "", "device") + if err != nil { + t.Fatalf("AwaitDeviceLogin() error = %v", err) + } + if credential != "jwt" || client.polls != 2 { + t.Fatalf("credential = %q, polls = %d", credential, client.polls) + } +} + +func TestAwaitDeviceLoginHonorsCancellation(t *testing.T) { + client := &fakeAuthClient{polls: -100} + service := NewAuthService(fakeAuthFactory{client}, time.Hour) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err := service.AwaitDeviceLogin(ctx, "", "device") + if !IsCode(err, ErrorCancelled) { + t.Fatalf("AwaitDeviceLogin() error = %v", err) + } +} + +func TestSessionOnlyImpersonationDoesNotExchangeOrPersistBaseIdentity(t *testing.T) { + client := &fakeAuthClient{} + service := NewAuthService(fakeAuthFactory{client}, time.Millisecond) + var events []AuthEvent + session, err := service.EstablishSession(t.Context(), "", "base-jwt", "deploy@example.com", false, func(event AuthEvent) { + events = append(events, event) + }) + if err != nil { + t.Fatalf("EstablishSession() error = %v", err) + } + if session.BaseEmail != "dev@example.com" || session.EffectiveEmail != "deploy@example.com" { + t.Fatalf("session = %#v", session) + } + if session.Credential != "session-jwt" || client.accessCalls != 0 { + t.Fatalf("credential = %q, access calls = %d", session.Credential, client.accessCalls) + } + if len(events) != 2 || events[0].Kind != AuthEventIdentified || events[1].Kind != AuthEventImpersonated { + t.Fatalf("events = %#v", events) + } +} + +func TestPersistedImpersonationExchangesAccessToken(t *testing.T) { + client := &fakeAuthClient{} + service := NewAuthService(fakeAuthFactory{client}, time.Millisecond) + session, err := service.EstablishSession(t.Context(), "", "base-jwt", "deploy@example.com", true, nil) + if err != nil { + t.Fatalf("EstablishSession() error = %v", err) + } + if session.Credential != "access-token" || client.accessCalls != 1 { + t.Fatalf("session = %#v, access calls = %d", session, client.accessCalls) + } +} diff --git a/pkg/bridge/credentials.go b/pkg/bridge/credentials.go new file mode 100644 index 000000000..1c1b8dccc --- /dev/null +++ b/pkg/bridge/credentials.go @@ -0,0 +1,130 @@ +package bridge + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/zalando/go-keyring" +) + +const credentialService = "plural-cli" + +// KeyringCredentialStore stores secrets in the operating-system credential +// store. It contains no filesystem policy and is independently replaceable. +type KeyringCredentialStore struct{ Service string } + +func (s KeyringCredentialStore) service() string { + if s.Service != "" { + return s.Service + } + return credentialService +} +func (s KeyringCredentialStore) Get(_ context.Context, id string) (string, error) { + return keyring.Get(s.service(), id) +} +func (s KeyringCredentialStore) Set(_ context.Context, id, secret string) error { + return keyring.Set(s.service(), id, secret) +} +func (s KeyringCredentialStore) Delete(_ context.Context, id string) error { + return keyring.Delete(s.service(), id) +} + +// FileCredentialStore is the owner-only fallback for hosts without a usable +// keyring. IDs are hashed so untrusted profile names cannot escape its root. +type FileCredentialStore struct{ Dir string } + +func (s FileCredentialStore) path(id string) string { + hash := sha256.Sum256([]byte(id)) + return filepath.Join(s.Dir, fmt.Sprintf("%x", hash[:])+".credential") +} +func (s FileCredentialStore) Get(ctx context.Context, id string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + value, err := os.ReadFile(s.path(id)) + if err != nil { + return "", err + } + return strings.TrimSpace(string(value)), nil +} +func (s FileCredentialStore) Set(ctx context.Context, id, secret string) error { + if err := ctx.Err(); err != nil { + return err + } + if err := os.MkdirAll(s.Dir, 0700); err != nil { + return err + } + if err := os.Chmod(s.Dir, 0700); err != nil { + return err + } + target := s.path(id) + if err := os.WriteFile(target, []byte(secret+"\n"), 0600); err != nil { + return err + } + return os.Chmod(target, 0600) +} +func (s FileCredentialStore) Delete(ctx context.Context, id string) error { + if err := ctx.Err(); err != nil { + return err + } + err := os.Remove(s.path(id)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +// ResilientCredentialStore prefers a keyring, falls back to owner-only files, +// and lazily migrates fallback secrets when the keyring becomes available. +type ResilientCredentialStore struct{ Primary, Fallback CredentialStore } + +func (s ResilientCredentialStore) Get(ctx context.Context, id string) (string, error) { + if s.Primary != nil { + if value, err := s.Primary.Get(ctx, id); err == nil { + return value, nil + } + } + if s.Fallback == nil { + return "", os.ErrNotExist + } + value, err := s.Fallback.Get(ctx, id) + if err != nil { + return "", err + } + if s.Primary != nil && s.Primary.Set(ctx, id, value) == nil { + _ = s.Fallback.Delete(ctx, id) + } + return value, nil +} +func (s ResilientCredentialStore) Set(ctx context.Context, id, secret string) error { + if s.Primary != nil && s.Primary.Set(ctx, id, secret) == nil { + if s.Fallback != nil { + _ = s.Fallback.Delete(ctx, id) + } + return nil + } + if s.Fallback == nil { + return errors.New("no credential store is available") + } + return s.Fallback.Set(ctx, id, secret) +} +func (s ResilientCredentialStore) Delete(ctx context.Context, id string) error { + var primaryErr error + if s.Primary != nil { + primaryErr = s.Primary.Delete(ctx, id) + } + if s.Fallback != nil { + if err := s.Fallback.Delete(ctx, id); err != nil { + return err + } + } + if errors.Is(primaryErr, keyring.ErrNotFound) { + return nil + } + return primaryErr +} diff --git a/pkg/bridge/errors.go b/pkg/bridge/errors.go new file mode 100644 index 000000000..7f7898149 --- /dev/null +++ b/pkg/bridge/errors.go @@ -0,0 +1,21 @@ +package bridge + +import ( + "errors" + "fmt" +) + +func (e *Error) Error() string { + if e.Operation == "" { + return fmt.Sprintf("%s: %v", e.Code, e.Err) + } + return fmt.Sprintf("%s: %s: %v", e.Operation, e.Code, e.Err) +} + +func (e *Error) Unwrap() error { return e.Err } + +// IsCode reports whether err contains a bridge error with code. +func IsCode(err error, code ErrorCode) bool { + var bridgeErr *Error + return errors.As(err, &bridgeErr) && bridgeErr.Code == code +} diff --git a/pkg/bridge/errors_test.go b/pkg/bridge/errors_test.go new file mode 100644 index 000000000..4dbb9e0ea --- /dev/null +++ b/pkg/bridge/errors_test.go @@ -0,0 +1,18 @@ +package bridge + +import ( + "errors" + "testing" +) + +func TestBridgeErrorSupportsTypedRecoveryAndUnwrap(t *testing.T) { + cause := errors.New("connection refused") + err := &Error{Code: ErrorUnavailable, Operation: "load profile", Err: cause} + + if !IsCode(err, ErrorUnavailable) { + t.Fatal("IsCode() = false") + } + if !errors.Is(err, cause) { + t.Fatal("bridge error does not unwrap its cause") + } +} diff --git a/pkg/bridge/identity.go b/pkg/bridge/identity.go new file mode 100644 index 000000000..e0d337a3e --- /dev/null +++ b/pkg/bridge/identity.go @@ -0,0 +1,28 @@ +package bridge + +import ( + "fmt" +) + +func (a AuthContext) Validate() error { + if a.Acting != nil && a.Base == nil { + return fmt.Errorf("acting identity requires a base profile") + } + if a.Base != nil && a.Base.ID == "" { + return fmt.Errorf("base profile ID is required") + } + if a.Console != nil && a.Console.ID == "" { + return fmt.Errorf("console profile ID is required") + } + return nil +} + +func (a AuthContext) EffectiveEmail() string { + if a.Acting != nil { + return a.Acting.Email + } + if a.Base != nil { + return a.Base.Email + } + return "" +} diff --git a/pkg/bridge/identity_test.go b/pkg/bridge/identity_test.go new file mode 100644 index 000000000..f0084a577 --- /dev/null +++ b/pkg/bridge/identity_test.go @@ -0,0 +1,27 @@ +package bridge + +import "testing" + +func TestAuthContextKeepsBaseAndActingIdentitySeparate(t *testing.T) { + ctx := AuthContext{ + Base: &Profile{ID: "personal", Email: "dev@example.com"}, + Acting: &Identity{Email: "deploy@example.com", ServiceAccount: true}, + } + + if err := ctx.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + if got := ctx.EffectiveEmail(); got != "deploy@example.com" { + t.Fatalf("EffectiveEmail() = %q", got) + } + if got := ctx.Base.Email; got != "dev@example.com" { + t.Fatalf("base profile was changed: %q", got) + } +} + +func TestAuthContextRejectsActingIdentityWithoutBase(t *testing.T) { + ctx := AuthContext{Acting: &Identity{Email: "deploy@example.com"}} + if err := ctx.Validate(); err == nil { + t.Fatal("Validate() expected an error") + } +} diff --git a/pkg/bridge/legacy_profile.go b/pkg/bridge/legacy_profile.go new file mode 100644 index 000000000..4d144a16d --- /dev/null +++ b/pkg/bridge/legacy_profile.go @@ -0,0 +1,26 @@ +package bridge + +import ( + "context" + + "github.com/pluralsh/plural-cli/pkg/config" +) + +// LegacyProfileStore preserves config.yml persistence while keeping it out of +// presentation handlers. +type LegacyProfileStore struct{} + +func (LegacyProfileStore) Persist(ctx context.Context, conf *config.Config) error { + if err := ctx.Err(); err != nil { + return err + } + return conf.Flush() +} + +func (LegacyProfileStore) Activate(ctx context.Context, conf *config.Config) error { + if err := ctx.Err(); err != nil { + return err + } + config.SetConfig(conf) + return nil +} diff --git a/pkg/bridge/legacy_profile_test.go b/pkg/bridge/legacy_profile_test.go new file mode 100644 index 000000000..a0a8f1fd9 --- /dev/null +++ b/pkg/bridge/legacy_profile_test.go @@ -0,0 +1,26 @@ +package bridge + +import ( + "os" + "path/filepath" + "testing" + + "github.com/pluralsh/plural-cli/pkg/config" +) + +func TestLegacyProfileStorePersistsCredentialsWithOwnerOnlyPermissions(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + conf := &config.Config{Email: "dev@example.com", Token: "secret"} + if err := (LegacyProfileStore{}).Persist(t.Context(), conf); err != nil { + t.Fatalf("Persist() error = %v", err) + } + info, err := os.Stat(filepath.Join(home, ".plural", config.ConfigName)) + if err != nil { + t.Fatalf("stat config: %v", err) + } + if got := info.Mode().Perm(); got != 0600 { + t.Fatalf("config permissions = %04o", got) + } +} diff --git a/pkg/bridge/plural_auth.go b/pkg/bridge/plural_auth.go new file mode 100644 index 000000000..d92f192e8 --- /dev/null +++ b/pkg/bridge/plural_auth.go @@ -0,0 +1,46 @@ +package bridge + +import ( + "context" + + "github.com/pluralsh/plural-cli/pkg/api" + "github.com/pluralsh/plural-cli/pkg/config" +) + +// PluralAuthFactory adapts the legacy GraphQL client to AuthClientFactory. +type PluralAuthFactory struct{} + +func (PluralAuthFactory) New(ctx context.Context, endpoint, credential string) AuthClient { + conf := &config.Config{Endpoint: endpoint, Token: credential} + return pluralAuthClient{client: api.FromConfigWithContext(ctx, conf)} +} + +type pluralAuthClient struct{ client api.Client } + +func (c pluralAuthClient) DeviceLogin(context.Context) (DeviceAuthorization, error) { + device, err := c.client.DeviceLogin() + if err != nil { + return DeviceAuthorization{}, err + } + return DeviceAuthorization{LoginURL: device.LoginUrl, DeviceToken: device.DeviceToken}, nil +} + +func (c pluralAuthClient) PollLoginToken(_ context.Context, deviceToken string) (string, error) { + return c.client.PollLoginToken(deviceToken) +} + +func (c pluralAuthClient) CurrentIdentity(context.Context) (string, error) { + me, err := c.client.Me() + if err != nil { + return "", err + } + return me.Email, nil +} + +func (c pluralAuthClient) ImpersonateServiceAccount(_ context.Context, email string) (string, string, error) { + return c.client.ImpersonateServiceAccount(email) +} + +func (c pluralAuthClient) GrabAccessToken(context.Context) (string, error) { + return c.client.GrabAccessToken() +} diff --git a/pkg/bridge/types.go b/pkg/bridge/types.go new file mode 100644 index 000000000..a033b6f7b --- /dev/null +++ b/pkg/bridge/types.go @@ -0,0 +1,172 @@ +// Package bridge defines the frontend-independent boundary between Plural CLI +// presentation layers and authentication infrastructure. +// +// Authentication starts with an AuthClientFactory, which supplies transport +// implementations to AuthService. AuthService produces an AuthSession while +// reporting optional AuthEvents. Persisted Profile metadata is deliberately +// separated from secrets, which are resolved through CredentialStore. +// AuthContext then combines the selected base profile, an optional ephemeral +// acting identity, and an independently selected Console profile. +// +// View-oriented aggregation lives in the bridge/access and bridge/welcome +// subpackages. This package contains the shared vocabulary and infrastructure +// contracts used by those services and by legacy CLI commands. +package bridge + +import ( + "context" + "time" +) + +// Operation identifies an authentication operation that can be attached to an +// Error for diagnostics and recovery decisions. +type Operation string + +const ( + // OperationDeviceLogin starts device authorization. + OperationDeviceLogin Operation = "DeviceLogin" + // OperationPollLoginToken waits for device authorization to complete. + OperationPollLoginToken Operation = "PollLoginToken" + // OperationCurrentIdentity resolves the identity associated with a credential. + OperationCurrentIdentity Operation = "Me" + // OperationImpersonateServiceAccount exchanges a base credential for an acting identity. + OperationImpersonateServiceAccount Operation = "ImpersonateServiceAccount" + // OperationGrabAccessToken exchanges an authenticated session for a durable access token. + OperationGrabAccessToken Operation = "GrabAccessToken" +) + +// ErrorCode is a stable category that presentation layers can map to recovery +// actions without matching backend error strings. +type ErrorCode string + +const ( + // ErrorUnauthenticated indicates that valid authentication is required. + ErrorUnauthenticated ErrorCode = "unauthenticated" + // ErrorUnauthorized indicates that the identity lacks permission. + ErrorUnauthorized ErrorCode = "unauthorized" + // ErrorInvalid indicates invalid caller input or persisted state. + ErrorInvalid ErrorCode = "invalid" + // ErrorUnavailable indicates a dependency or operation is unavailable. + ErrorUnavailable ErrorCode = "unavailable" + // ErrorCancelled indicates cancellation or deadline expiry. + ErrorCancelled ErrorCode = "cancelled" +) + +// Error adds operation and recovery semantics while preserving its cause. +type Error struct { + Code ErrorCode + Operation Operation + Err error +} + +// DeviceAuthorization contains the user-facing URL and opaque token for a +// device-login flow. +type DeviceAuthorization struct { + LoginURL string + DeviceToken string +} + +// AuthClient is the context-aware authentication transport required by the +// bridge. Implementations adapt concrete APIs without leaking them to callers. +type AuthClient interface { + DeviceLogin(ctx context.Context) (DeviceAuthorization, error) + PollLoginToken(ctx context.Context, deviceToken string) (string, error) + CurrentIdentity(ctx context.Context) (string, error) + ImpersonateServiceAccount(ctx context.Context, email string) (credential, effectiveEmail string, err error) + GrabAccessToken(ctx context.Context) (string, error) +} + +// AuthClientFactory creates an authentication client for an endpoint and an +// optional existing credential. +type AuthClientFactory interface { + New(ctx context.Context, endpoint, credential string) AuthClient +} + +// AuthService coordinates authentication clients, device-login polling, token +// exchange, and optional service-account impersonation. +type AuthService struct { + clients AuthClientFactory + pollInterval time.Duration +} + +// AuthEventKind identifies a meaningful transition during session creation. +type AuthEventKind string + +const ( + // AuthEventIdentified reports the base identity resolved from a credential. + AuthEventIdentified AuthEventKind = "identified" + // AuthEventImpersonated reports a successful service-account exchange. + AuthEventImpersonated AuthEventKind = "impersonated" +) + +// AuthEvent reports an identity or credential transition to interested callers. +type AuthEvent struct { + Kind AuthEventKind + Email string + Credential string +} + +// AuthSession is the result of authentication and optional impersonation. +type AuthSession struct { + BaseEmail string + EffectiveEmail string + Credential string + Impersonated bool +} + +// Profile identifies a Plural App login. Its credential is stored separately +// and resolved through CredentialStore by profile ID. +type Profile struct { + ID string + Name string + Email string + Endpoint string +} + +// Identity is the effective actor for the current session. +type Identity struct { + Email string + ServiceAccount bool +} + +// ConsoleProfile identifies a Console connection selected independently from +// the Plural App profile. +type ConsoleProfile struct { + ID string + Name string + URL string + Actor string +} + +// AuthContext keeps persisted base identity separate from the effective +// in-memory actor and the independently selected Console connection. +type AuthContext struct { + Base *Profile + Acting *Identity + Console *ConsoleProfile +} + +// ProfileRepository stores non-secret identity metadata. +type ProfileRepository interface { + List(ctx context.Context) ([]Profile, error) + Get(ctx context.Context, id string) (Profile, error) + Save(ctx context.Context, profile Profile) error +} + +// CredentialStore stores secret material independently from profile metadata. +type CredentialStore interface { + Get(ctx context.Context, profileID string) (string, error) + Set(ctx context.Context, profileID, credential string) error + Delete(ctx context.Context, profileID string) error +} + +// SessionExchanger creates an ephemeral acting identity without replacing the +// persisted credential of its base profile. +type SessionExchanger interface { + Impersonate(ctx context.Context, base Profile, serviceAccount string) (Identity, string, error) +} + +// AuthContextLoader resolves active identity state for a caller-owned context. +type AuthContextLoader interface { + Load(ctx context.Context) (AuthContext, error) +} diff --git a/pkg/bridge/welcome/local.go b/pkg/bridge/welcome/local.go new file mode 100644 index 000000000..ff8bdb426 --- /dev/null +++ b/pkg/bridge/welcome/local.go @@ -0,0 +1,96 @@ +package welcome + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/samber/lo" + "k8s.io/client-go/tools/clientcmd" + + "github.com/pluralsh/plural-cli/pkg/config" + "github.com/pluralsh/plural-cli/pkg/console" + "github.com/pluralsh/plural-cli/pkg/manifest" + "github.com/pluralsh/plural-cli/pkg/utils" +) + +// LocalSource reads existing CLI state without returning credentials or +// contacting remote APIs. +type LocalSource struct{ version string } + +func NewLocalSource(version string) LocalSource { + return LocalSource{version: version} +} + +func (s LocalSource) Read(ctx context.Context) (Snapshot, error) { + if err := ctx.Err(); err != nil { + return Snapshot{}, err + } + + snapshot := Snapshot{Version: s.version} + snapshot.App = s.readAppProfile() + snapshot.Console = s.readConsole() + s.readWorkspace(&snapshot) + s.readKubeContext(&snapshot) + return snapshot, nil +} + +func (s LocalSource) readAppProfile() AppProfile { + if !config.Exists() { + return AppProfile{} + } + + conf := config.Read() + profile := AppProfile{ + Configured: conf.Email != "" || conf.Token != "", + Name: lo.CoalesceOrEmpty(conf.ProfileName(), "active"), + Email: conf.Email, + Endpoint: conf.BaseUrl(), + } + if profiles, err := config.Profiles(); err == nil { + profile.SavedProfiles = len(profiles) + } + return profile +} + +func (s LocalSource) readConsole() ConsoleConnection { + conf := console.ReadConfig() + return ConsoleConnection{ + Configured: conf.Url != "" || conf.Token != "", + URL: conf.Url, + } +} + +func (s LocalSource) readWorkspace(snapshot *Snapshot) { + root, found := utils.ProjectRoot() + if !found { + return + } + + project, err := manifest.ReadProject(filepath.Join(root, "workspace.yaml")) + if err != nil { + snapshot.Diagnostics = append(snapshot.Diagnostics, fmt.Sprintf("workspace: %v", err)) + return + } + snapshot.Workspace = Workspace{ + Configured: true, + Path: root, + Name: project.Cluster, + Project: project.Project, + Provider: project.Provider, + Region: project.Region, + } + if project.Owner != nil { + snapshot.Workspace.Owner = project.Owner.Email + } +} + +func (s LocalSource) readKubeContext(snapshot *Snapshot) { + rules := clientcmd.NewDefaultClientConfigLoadingRules() + raw, err := rules.Load() + if err != nil { + snapshot.Diagnostics = append(snapshot.Diagnostics, fmt.Sprintf("kubeconfig: %v", err)) + return + } + snapshot.KubeContext = raw.CurrentContext +} diff --git a/pkg/bridge/welcome/local_test.go b/pkg/bridge/welcome/local_test.go new file mode 100644 index 000000000..3e14a997c --- /dev/null +++ b/pkg/bridge/welcome/local_test.go @@ -0,0 +1,75 @@ +package welcome + +import ( + "os" + "path/filepath" + "testing" + + "github.com/pluralsh/plural-cli/pkg/config" + "github.com/pluralsh/plural-cli/pkg/console" + "github.com/pluralsh/plural-cli/pkg/manifest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +func TestLocalContextSourceReadsStateWithoutCredentials(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + config.SetConfig(nil) + t.Cleanup(func() { config.SetConfig(nil) }) + + appConfig := &config.Config{Email: "dev@example.com", Token: "app-secret"} + if err := appConfig.Flush(); err != nil { + t.Fatalf("save app config: %v", err) + } + consoleConfig := &console.Config{Url: "https://console.example.com", Token: "console-secret"} + if err := consoleConfig.Save(); err != nil { + t.Fatalf("save console config: %v", err) + } + + root := filepath.Join(t.TempDir(), "workspace") + nested := filepath.Join(root, "services", "api") + if err := os.MkdirAll(nested, 0755); err != nil { + t.Fatalf("mkdir workspace: %v", err) + } + project := &manifest.ProjectManifest{ + Cluster: "platform-prod", Project: "acme", Provider: "aws", Region: "eu-west-1", + Owner: &manifest.Owner{Email: "deploy@example.com"}, + } + if err := project.Write(filepath.Join(root, "workspace.yaml")); err != nil { + t.Fatalf("write workspace: %v", err) + } + oldWorkingDirectory, err := os.Getwd() + if err != nil { + t.Fatalf("get working directory: %v", err) + } + if err := os.Chdir(nested); err != nil { + t.Fatalf("change working directory: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(oldWorkingDirectory) }) + + kubeconfig := filepath.Join(home, "kubeconfig") + kube := clientcmdapi.NewConfig() + kube.CurrentContext = "plural-platform-prod" + if err := clientcmd.WriteToFile(*kube, kubeconfig); err != nil { + t.Fatalf("write kubeconfig: %v", err) + } + t.Setenv("KUBECONFIG", kubeconfig) + + snapshot, err := NewLocalSource("v1").Read(t.Context()) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + if snapshot.Version != "v1" || snapshot.App.Email != "dev@example.com" || snapshot.App.Endpoint != "https://app.plural.sh" { + t.Fatalf("app snapshot = %#v", snapshot.App) + } + if snapshot.Console.URL != "https://console.example.com" { + t.Fatalf("console snapshot = %#v", snapshot.Console) + } + if snapshot.Workspace.Name != "platform-prod" || snapshot.Workspace.Owner != "deploy@example.com" { + t.Fatalf("workspace snapshot = %#v", snapshot.Workspace) + } + if snapshot.KubeContext != "plural-platform-prod" { + t.Fatalf("kube context = %q", snapshot.KubeContext) + } +} diff --git a/pkg/bridge/welcome/welcome.go b/pkg/bridge/welcome/welcome.go new file mode 100644 index 000000000..769253f66 --- /dev/null +++ b/pkg/bridge/welcome/welcome.go @@ -0,0 +1,60 @@ +package welcome + +import "context" + +type AppProfile struct { + Configured bool + Name string + Email string + Endpoint string + SavedProfiles int +} + +type ConsoleConnection struct { + Configured bool + URL string +} + +type Workspace struct { + Configured bool + Path string + Name string + Project string + Provider string + Region string + Owner string +} + +// Snapshot is the credential-free local state shown by the welcome +// screen. +type Snapshot struct { + Version string + App AppProfile + Console ConsoleConnection + Workspace Workspace + KubeContext string + Diagnostics []string +} + +// Source reads local state without presentation concerns. +type Source interface { + Read(ctx context.Context) (Snapshot, error) +} + +// Loader is the narrow dependency consumed by the TUI. +type Loader interface { + Load(ctx context.Context) (Snapshot, error) +} + +type Service struct{ source Source } + +func NewService(source Source) *Service { + return &Service{source: source} +} + +func (s *Service) Load(ctx context.Context) (Snapshot, error) { + if err := ctx.Err(); err != nil { + return Snapshot{}, err + } + return s.source.Read(ctx) +} diff --git a/pkg/bridge/welcome/welcome_test.go b/pkg/bridge/welcome/welcome_test.go new file mode 100644 index 000000000..9fc67d493 --- /dev/null +++ b/pkg/bridge/welcome/welcome_test.go @@ -0,0 +1,22 @@ +package welcome + +import ( + "context" + "testing" +) + +type welcomeSourceFunc func(context.Context) (Snapshot, error) + +func (f welcomeSourceFunc) Read(ctx context.Context) (Snapshot, error) { return f(ctx) } + +func TestWelcomeServiceReturnsReadOnlySnapshot(t *testing.T) { + want := Snapshot{Version: "v1", App: AppProfile{Configured: true, Email: "dev@example.com"}} + service := NewService(welcomeSourceFunc(func(context.Context) (Snapshot, error) { return want, nil })) + got, err := service.Load(t.Context()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.App.Email != want.App.Email { + t.Fatalf("Load() = %#v", got) + } +} diff --git a/pkg/common/common.go b/pkg/common/common.go index a2ed5c2b6..d9e65597e 100644 --- a/pkg/common/common.go +++ b/pkg/common/common.go @@ -1,19 +1,21 @@ package common import ( + "context" + "errors" "fmt" "net/url" "os" "os/exec" "path/filepath" "strings" - "time" "github.com/google/uuid" "github.com/pkg/browser" "github.com/urfave/cli" "github.com/pluralsh/plural-cli/pkg/api" + "github.com/pluralsh/plural-cli/pkg/bridge" "github.com/pluralsh/plural-cli/pkg/config" "github.com/pluralsh/plural-cli/pkg/crypto" "github.com/pluralsh/plural-cli/pkg/provider" @@ -25,7 +27,9 @@ import ( ) var ( - loggedIn = false + loggedIn = false + newAuthService = func() *bridge.AuthService { return bridge.NewAuthService(bridge.PluralAuthFactory{}, 0) } + openLoginURL = browser.OpenURL ) func HandleLogin(c *cli.Context) error { @@ -39,77 +43,68 @@ func HandleLogin(c *cli.Context) error { conf := &config.Config{} conf.Token = "" conf.Endpoint = c.String("endpoint") - client := api.FromConfig(conf) + auth := newAuthService() + ctx := context.Background() persist := c.Command.Name == "login" if config.Exists() { conf := config.Read() if Affirm(fmt.Sprintf("It looks like your current Plural user is %s, use this profile?", conf.Email), "PLURAL_LOGIN_AFFIRM_CURRENT_USER") { - client = api.FromConfig(&conf) - return postLogin(&conf, client, c, persist) + return establishLogin(ctx, auth, &conf, c.String("service-account"), persist) } } - device, err := client.DeviceLogin() + device, err := auth.BeginDeviceLogin(ctx, conf.Endpoint) if err != nil { - return api.GetErrorResponse(err, "DeviceLogin") + return authError(err) } - fmt.Printf("logging into Plural at %s\n", device.LoginUrl) - if err := browser.OpenURL(device.LoginUrl); err != nil { - fmt.Printf("Open %s in your browser to proceed\n", device.LoginUrl) + fmt.Printf("logging into Plural at %s\n", device.LoginURL) + if err := openLoginURL(device.LoginURL); err != nil { + fmt.Printf("Open %s in your browser to proceed\n", device.LoginURL) } - var jwt string - for { - result, err := client.PollLoginToken(device.DeviceToken) - if err == nil { - jwt = result - break - } - - time.Sleep(2 * time.Second) + jwt, err := auth.AwaitDeviceLogin(ctx, conf.Endpoint, device.DeviceToken) + if err != nil { + return authError(err) } conf.Token = jwt conf.ReportErrors = Affirm("Would you be willing to report any errors to Plural to help with debugging?", "PLURAL_LOGIN_AFFIRM_REPORT_ERRORS") - client = api.FromConfig(conf) - return postLogin(conf, client, c, persist) + return establishLogin(ctx, auth, conf, c.String("service-account"), persist) } -func postLogin(conf *config.Config, client api.Client, c *cli.Context, persist bool) error { - me, err := client.Me() +func establishLogin(ctx context.Context, auth *bridge.AuthService, conf *config.Config, serviceAccount string, persist bool) error { + profiles := bridge.LegacyProfileStore{} + session, err := auth.EstablishSession(ctx, conf.Endpoint, conf.Token, serviceAccount, persist, func(event bridge.AuthEvent) { + switch event.Kind { + case bridge.AuthEventIdentified: + conf.Email = event.Email + fmt.Printf("\nLogged in as %s!\n", event.Email) + case bridge.AuthEventImpersonated: + conf.Email = event.Email + conf.Token = event.Credential + fmt.Printf("Assumed service account %s\n", serviceAccount) + _ = profiles.Activate(ctx, conf) + } + }) if err != nil { - return api.GetErrorResponse(err, "Me") + return authError(err) } - conf.Email = me.Email - fmt.Printf("\nLogged in as %s!\n", me.Email) - - saEmail := c.String("service-account") - if saEmail != "" { - jwt, email, err := client.ImpersonateServiceAccount(saEmail) - if err != nil { - return api.GetErrorResponse(err, "ImpersonateServiceAccount") - } - - conf.Email = email - conf.Token = jwt - fmt.Printf("Assumed service account %s\n", saEmail) - config.SetConfig(conf) - client = api.FromConfig(conf) - if !persist { - return nil - } + conf.Email = session.EffectiveEmail + conf.Token = session.Credential + if session.Impersonated && !persist { + return profiles.Activate(ctx, conf) } + return profiles.Persist(ctx, conf) +} - accessToken, err := client.GrabAccessToken() - if err != nil { - return api.GetErrorResponse(err, "GrabAccessToken") +func authError(err error) error { + if appErr, ok := errors.AsType[*bridge.Error](err); ok { + return api.GetErrorResponse(appErr.Err, string(appErr.Operation)) } - - conf.Token = accessToken - return conf.Flush() + return err } func Preflights(c *cli.Context) error { _, err := RunPreflights(c) @@ -201,8 +196,7 @@ func IsUUIDv4(input string) bool { func GetIdAndName(input string) (id, name *string) { switch { case strings.HasPrefix(input, "@"): - h := strings.Trim(input, "@") - name = &h + name = new(strings.Trim(input, "@")) case IsUUIDv4(input): id = &input default: diff --git a/pkg/common/login_test.go b/pkg/common/login_test.go new file mode 100644 index 000000000..d6edcf874 --- /dev/null +++ b/pkg/common/login_test.go @@ -0,0 +1,100 @@ +package common + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/config" + "github.com/urfave/cli" +) + +type loginFactory struct{ client *loginClient } + +func (f loginFactory) New(context.Context, string, string) bridge.AuthClient { return f.client } + +type loginClient struct{} + +func (*loginClient) DeviceLogin(context.Context) (bridge.DeviceAuthorization, error) { + return bridge.DeviceAuthorization{LoginURL: "https://example.com/device", DeviceToken: "device"}, nil +} +func (*loginClient) PollLoginToken(context.Context, string) (string, error) { + return "device-jwt", nil +} +func (*loginClient) CurrentIdentity(context.Context) (string, error) { + return "dev@example.com", nil +} +func (*loginClient) ImpersonateServiceAccount(context.Context, string) (string, string, error) { + return "", "", nil +} +func (*loginClient) GrabAccessToken(context.Context) (string, error) { + return "access-token", nil +} + +func TestHandleLoginKeepsLegacyPresentationAndPersistsResult(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("PLURAL_LOGIN_AFFIRM_REPORT_ERRORS", "true") + + oldService, oldOpen, oldLoggedIn := newAuthService, openLoginURL, loggedIn + newAuthService = func() *bridge.AuthService { + return bridge.NewAuthService(loginFactory{client: &loginClient{}}, time.Millisecond) + } + openLoginURL = func(string) error { return nil } + loggedIn = false + t.Cleanup(func() { + newAuthService, openLoginURL, loggedIn = oldService, oldOpen, oldLoggedIn + config.SetConfig(nil) + }) + + app := cli.NewApp() + app.Commands = []cli.Command{{ + Name: "login", + Action: HandleLogin, + Flags: []cli.Flag{ + cli.StringFlag{Name: "endpoint"}, + cli.StringFlag{Name: "service-account"}, + }, + }} + + output, err := captureLoginOutput(func() error { + return app.Run([]string{"plural", "login", "--endpoint", "example.com"}) + }) + if err != nil { + t.Fatalf("login error = %v", err) + } + want := "logging into Plural at https://example.com/device\n\nLogged in as dev@example.com!\n" + if output != want { + t.Fatalf("output changed\nwant: %q\n got: %q", want, output) + } + + stored := config.Import(filepath.Join(home, ".plural", config.ConfigName)) + if stored.Email != "dev@example.com" || stored.Token != "access-token" || stored.Endpoint != "example.com" || !stored.ReportErrors { + t.Fatalf("stored config = %#v", stored) + } +} + +func captureLoginOutput(run func() error) (string, error) { + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + return "", err + } + os.Stdout = w + defer func() { os.Stdout = old }() + + runErr := run() + _ = w.Close() + var output bytes.Buffer + _, copyErr := io.Copy(&output, r) + _ = r.Close() + if runErr != nil { + return "", runErr + } + return output.String(), copyErr +} diff --git a/pkg/config/config.go b/pkg/config/config.go index aceb18b8d..3c4730050 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -48,6 +48,14 @@ type VersionedConfig struct { Spec *Config `yaml:"spec"` } +// ProfileName exposes non-secret profile metadata to application services. +func (c *Config) ProfileName() string { + if c.metadata == nil { + return "" + } + return c.metadata.Name +} + func SetConfig(conf *Config) { config = conf } @@ -199,7 +207,12 @@ func (c *Config) Save(filename string) error { return err } - return os.WriteFile(f, io, 0644) + if err := os.WriteFile(f, io, 0600); err != nil { + return err + } + // WriteFile preserves permissions on an existing file. Enforce the + // credential fallback contract when upgrading legacy 0644 profiles. + return os.Chmod(f, 0600) } func (c *Config) Flush() error { diff --git a/pkg/console/config.go b/pkg/console/config.go index c620892c1..4a2d5d20d 100644 --- a/pkg/console/config.go +++ b/pkg/console/config.go @@ -76,5 +76,8 @@ func (conf *Config) Save() error { return err } - return os.WriteFile(f, io, 0644) + if err := os.WriteFile(f, io, 0600); err != nil { + return err + } + return os.Chmod(f, 0600) } diff --git a/tui/app/model.go b/tui/app/model.go new file mode 100644 index 000000000..2ec377f55 --- /dev/null +++ b/tui/app/model.go @@ -0,0 +1,93 @@ +// Package app composes the root Bubble Tea model and runs the TUI process. +package app + +import ( + "context" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + "github.com/pluralsh/plural-cli/tui/navigation" + accessscreen "github.com/pluralsh/plural-cli/tui/screens/access" + diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics" + welcomescreen "github.com/pluralsh/plural-cli/tui/screens/welcome" + "github.com/pluralsh/plural-cli/tui/theme" +) + +// Dependencies contains the services required by TUI screens. +type Dependencies struct { + Welcome welcomebridge.Loader + Access accessbridge.Manager +} + +// Model is the root TUI model. It owns global input and delegates screen state +// to the active screen model. +type Model struct { + width int + height int + + theme theme.Theme + quit key.Binding + + welcome welcomescreen.Model + access accessscreen.Model + diagnostics diagnosticsscreen.Model + route navigation.Route +} + +// New composes the root model with caller-provided dependencies. +func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { + return Model{ + theme: t, + welcome: welcomescreen.New(ctx, dependencies.Welcome, t), + access: accessscreen.New(ctx, dependencies.Access, t), + diagnostics: diagnosticsscreen.New(ctx, dependencies.Welcome, t), + route: navigation.Welcome, + quit: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), + ), + } +} + +func (m Model) Init() tea.Cmd { return m.welcome.Init() } + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case navigation.NavigateMsg: + m.route = msg.Route + switch m.route { + case navigation.Access: + return m, m.access.Init() + case navigation.Diagnostics: + return m, m.diagnostics.Init() + default: + return m, m.welcome.Init() + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case tea.KeyPressMsg: + if key.Matches(msg, m.quit) { + if m.route == navigation.Access && m.access.HasCancellableOperation() { + var cmd tea.Cmd + m.access, cmd = m.access.Update(msg) + return m, cmd + } + return m, tea.Quit + } + } + + var cmd tea.Cmd + switch m.route { + case navigation.Access: + m.access, cmd = m.access.Update(msg) + case navigation.Diagnostics: + m.diagnostics, cmd = m.diagnostics.Update(msg) + default: + m.welcome, cmd = m.welcome.Update(msg) + } + return m, cmd +} diff --git a/tui/app/model_test.go b/tui/app/model_test.go new file mode 100644 index 000000000..365edcf26 --- /dev/null +++ b/tui/app/model_test.go @@ -0,0 +1,86 @@ +package app + +import ( + "context" + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + + welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type welcomeLoaderFunc func(context.Context) (welcomebridge.Snapshot, error) + +func (f welcomeLoaderFunc) Load(ctx context.Context) (welcomebridge.Snapshot, error) { return f(ctx) } + +func TestModelHandlesResizeAndQuit(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), Dependencies{}) + updated, cmd := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + if cmd != nil { + t.Fatal("resize returned a command") + } + resized := updated.(Model) + if resized.width != 100 || resized.height != 30 { + t.Fatalf("size = %dx%d", resized.width, resized.height) + } + + _, cmd = resized.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if cmd == nil { + t.Fatal("quit key did not return a command") + } + if msg := cmd(); msg == nil { + t.Fatal("quit command returned nil") + } +} + +func TestModelRoutesScreensWithoutRebuildingShell(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), Dependencies{}) + updated, _ := model.Update(navigation.NavigateMsg{Route: navigation.Diagnostics}) + routed := updated.(Model) + if routed.route != navigation.Diagnostics || !strings.Contains(routed.View().Content, "Diagnostics") { + t.Fatalf("route/view = %q\n%s", routed.route, routed.View().Content) + } + updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Welcome}) + if got := updated.(Model).route; got != navigation.Welcome { + t.Fatalf("route = %q", got) + } +} + +func TestRunRejectsMissingTerminal(t *testing.T) { + if err := Run(t.Context(), nil, nil, Dependencies{}); !errors.Is(err, ErrNoTerminal) { + t.Fatalf("Run() error = %v", err) + } +} + +func TestModelLoadsWelcomeSnapshot(t *testing.T) { + loader := welcomeLoaderFunc(func(context.Context) (welcomebridge.Snapshot, error) { + return welcomebridge.Snapshot{ + Version: "v1.0.0", + App: welcomebridge.AppProfile{Configured: true, Email: "dev@example.com"}, + }, nil + }) + model := New(t.Context(), theme.New(colorprofile.ASCII), Dependencies{Welcome: loader}) + cmd := model.Init() + if cmd == nil { + t.Fatal("Init() did not load the welcome snapshot") + } + updated := tea.Model(model) + msg := cmd() + if batch, ok := msg.(tea.BatchMsg); ok { + for _, batchCmd := range batch { + updated, _ = updated.Update(batchCmd()) + } + } else { + updated, _ = updated.Update(msg) + } + loaded := updated.(Model) + loaded.width = 80 + if got := loaded.View().Content; !strings.Contains(got, "dev@example.com") { + t.Fatalf("welcome view does not contain loaded identity:\n%s", got) + } +} diff --git a/tui/app/run.go b/tui/app/run.go new file mode 100644 index 000000000..e9b56c92a --- /dev/null +++ b/tui/app/run.go @@ -0,0 +1,35 @@ +package app + +import ( + "context" + "errors" + "os" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/term" + + "github.com/pluralsh/plural-cli/tui/theme" +) + +// ErrNoTerminal is returned when the explicit TUI entrypoint has no usable +// interactive terminal. +var ErrNoTerminal = errors.New("plural tui requires an interactive terminal") + +// Run validates the terminal contract and starts the root model with +// caller-owned cancellation. +func Run(ctx context.Context, input, output *os.File, dependencies Dependencies) error { + if input == nil || output == nil || !term.IsTerminal(input.Fd()) || !term.IsTerminal(output.Fd()) { + return ErrNoTerminal + } + + profile := colorprofile.Detect(output, os.Environ()) + program := tea.NewProgram( + New(ctx, theme.New(profile), dependencies), + tea.WithContext(ctx), + tea.WithInput(input), + tea.WithOutput(output), + ) + _, err := program.Run() + return err +} diff --git a/tui/app/view.go b/tui/app/view.go new file mode 100644 index 000000000..ec87c4c8f --- /dev/null +++ b/tui/app/view.go @@ -0,0 +1,25 @@ +package app + +import ( + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/tui/navigation" +) + +const windowTitle = "Plural" + +func (m Model) View() tea.View { + content := m.welcome.View(m.width, m.height) + switch m.route { + case navigation.Access: + content = m.access.View(m.width, m.height) + case navigation.Diagnostics: + content = m.diagnostics.View(m.width, m.height) + } + view := tea.NewView(content) + view.AltScreen = true + view.WindowTitle = windowTitle + view.BackgroundColor = m.theme.Colors.Background + view.ForegroundColor = m.theme.Colors.Text + return view +} diff --git a/tui/assets/assets.go b/tui/assets/assets.go new file mode 100644 index 000000000..a595b64a4 --- /dev/null +++ b/tui/assets/assets.go @@ -0,0 +1,9 @@ +package assets + +import _ "embed" + +// Logo is the terminal-cell interpretation of plural-logo.png. It is embedded +// so installed binaries do not depend on their working directory. +// +//go:embed logo.txt +var Logo string diff --git a/tui/assets/logo.txt b/tui/assets/logo.txt new file mode 100644 index 000000000..e5cd8a500 --- /dev/null +++ b/tui/assets/logo.txt @@ -0,0 +1,5 @@ +███████ ██ +██ ██ +██ ██ ██ +██ ██ +██ ███████ \ No newline at end of file diff --git a/tui/components/commandbar/commandbar.go b/tui/components/commandbar/commandbar.go new file mode 100644 index 000000000..5cd48c570 --- /dev/null +++ b/tui/components/commandbar/commandbar.go @@ -0,0 +1,236 @@ +// Package commandbar provides the reusable command input shown at the bottom +// of TUI screens. +package commandbar + +import ( + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/tui/theme" +) + +const ( + minimumWidth = 12 + title = "Command" + popupTitle = "Available commands" + maximumPopupRows = 6 +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionNextSuggestion + keyActionPreviousSuggestion + keyActionSubmit + keyActionDismiss +) + +var keyActionKeystrokes = map[keyAction]string{ + keyActionNextSuggestion: "down", + keyActionPreviousSuggestion: "up", + keyActionSubmit: "enter", + keyActionDismiss: "esc", +} + +func actionForKeystroke(keystroke string) keyAction { + for action, candidate := range keyActionKeystrokes { + if keystroke == candidate { + return action + } + } + + return keyActionNone +} + +// Model owns command entry, completion, selection, and rendering. +type Model struct { + theme theme.Theme + input textinput.Model + selected string + suggestions []string + popupOpen bool + popupCursor int +} + +// SubmittedMsg is emitted when the user submits a command. The shell or +// screen decides what the command means; the input component only owns entry. +type SubmittedMsg struct{ Command string } + +// New creates a focused command bar with the provided completion candidates. +func New(t theme.Theme, suggestions []string) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "Search commands…" + input.CharLimit = 80 + input.ShowSuggestions = true + input.SetSuggestions(suggestions) + input.SetVirtualCursor(true) + + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Focused.Suggestion = t.Muted + styles.Blurred = styles.Focused + styles.Cursor.Color = t.Colors.Primary + styles.Cursor.Shape = tea.CursorBar + styles.Cursor.Blink = false + input.SetStyles(styles) + input.Focus() + + return Model{theme: t, input: input, suggestions: suggestions} +} + +// Update handles completion, selection, clearing, and text entry. +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + if key, ok := msg.(tea.KeyPressMsg); ok { + switch actionForKeystroke(key.Keystroke()) { + case keyActionNextSuggestion: + matches := m.filteredSuggestions() + if len(matches) == 0 { + return m, nil + } + if m.popupOpen { + m.popupCursor = (m.popupCursor + 1) % len(matches) + } else { + m.popupOpen = true + m.popupCursor = 0 + } + return m, nil + case keyActionPreviousSuggestion: + matches := m.filteredSuggestions() + if len(matches) == 0 { + return m, nil + } + if m.popupOpen { + m.popupCursor = (m.popupCursor - 1 + len(matches)) % len(matches) + } else { + m.popupOpen = true + m.popupCursor = len(matches) - 1 + } + return m, nil + case keyActionSubmit: + if m.popupOpen { + matches := m.filteredSuggestions() + if len(matches) > 0 { + m.popupCursor = min(m.popupCursor, len(matches)-1) + m.selected = matches[m.popupCursor] + m.input.SetValue(m.selected) + } + m.popupOpen = false + } else { + m.selected = lo.CoalesceOrEmpty(strings.TrimSpace(m.input.Value()), m.input.CurrentSuggestion()) + } + if m.selected == "" { + return m, nil + } + selected := m.selected + return m, func() tea.Msg { return SubmittedMsg{Command: selected} } + case keyActionDismiss: + if m.popupOpen { + m.popupOpen = false + return m, nil + } + m.input.Reset() + m.selected = "" + return m, nil + } + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + if matches := m.filteredSuggestions(); len(matches) == 0 { + m.popupCursor = 0 + } else { + m.popupCursor = min(m.popupCursor, len(matches)-1) + } + return m, cmd +} + +// Selected returns the most recently selected command. +func (m Model) Selected() string { return m.selected } + +// Value returns the current command input value. +func (m Model) Value() string { return m.input.Value() } + +// CurrentSuggestion returns the active completion candidate. +func (m Model) CurrentSuggestion() string { return m.input.CurrentSuggestion() } + +// View renders the framed input and its contextual key help. +func (m Model) View(width int) string { + width = max(width, minimumWidth) + input := m.input + input.SetWidth(max(1, width-7)) + + help := "tab complete · ↑/↓ suggestions · enter select · esc clear · ctrl+c quit" + if m.popupOpen { + help = "↑/↓ choose · enter open · esc close · type to filter · ctrl+c quit" + } else if m.selected != "" { + help = "Opening “" + m.selected + "”…" + } + help = m.theme.Muted.Render(ansi.Truncate(help, max(1, width-2), "…")) + + command := renderBox(input.View(), width) + "\n " + help + if !m.popupOpen { + return command + } + return m.renderPopup(width) + "\n" + command +} + +func (m Model) filteredSuggestions() []string { + query := strings.ToLower(strings.TrimSpace(m.input.Value())) + if query == "" { + return m.suggestions + } + result := make([]string, 0, len(m.suggestions)) + for _, suggestion := range m.suggestions { + if strings.Contains(strings.ToLower(suggestion), query) { + result = append(result, suggestion) + } + } + return result +} + +func (m Model) renderPopup(width int) string { + matches := m.filteredSuggestions() + rows := min(maximumPopupRows, len(matches)) + popupWidth := min(width, 38) + innerWidth := popupWidth - 4 + title := ansi.Truncate(popupTitle, max(1, popupWidth-5), "…") + rule := strings.Repeat("─", max(0, popupWidth-5-lipgloss.Width(title))) + lines := []string{"╭─ " + title + " " + rule + "╮"} + start := 0 + if m.popupCursor >= rows { + start = m.popupCursor - rows + 1 + } + for i := 0; i < rows; i++ { + index := start + i + line := " " + matches[index] + if index == m.popupCursor { + line = m.theme.Title.Render("› " + matches[index]) + } + line = ansi.Truncate(line, innerWidth, "…") + lines = append(lines, "│ "+line+strings.Repeat(" ", max(0, innerWidth-lipgloss.Width(line)))+" │") + } + lines = append(lines, "╰"+strings.Repeat("─", popupWidth-2)+"╯") + return strings.Join(lines, "\n") +} + +// renderBox draws the frame directly so text input escape sequences remain on +// one line and its width stays predictable. +func renderBox(line string, width int) string { + innerWidth := width - 4 + topRule := strings.Repeat("─", max(0, width-5-lipgloss.Width(title))) + top := "╭─ " + title + " " + topRule + "╮" + + line = ansi.Truncate(line, innerWidth, "…") + body := "│ " + line + strings.Repeat(" ", max(0, innerWidth-lipgloss.Width(line))) + " │" + bottom := "╰" + strings.Repeat("─", width-2) + "╯" + return strings.Join([]string{top, body, bottom}, "\n") +} diff --git a/tui/components/commandbar/commandbar_test.go b/tui/components/commandbar/commandbar_test.go new file mode 100644 index 000000000..ad87a0731 --- /dev/null +++ b/tui/components/commandbar/commandbar_test.go @@ -0,0 +1,97 @@ +package commandbar + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/theme" +) + +func TestCompletesAndSubmitsSelection(t *testing.T) { + model := New(theme.New(colorprofile.ASCII), []string{"access", "diagnostics"}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + if got := model.CurrentSuggestion(); got != "diagnostics" { + t.Fatalf("suggestion = %q, want diagnostics", got) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + if got := model.Value(); got != "diagnostics" { + t.Fatalf("completed value = %q, want diagnostics", got) + } + + var cmd tea.Cmd + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("selecting a command did not emit submission") + } + if got := cmd().(SubmittedMsg).Command; got != "diagnostics" { + t.Fatalf("submitted command = %q, want diagnostics", got) + } + if got := model.Selected(); got != "diagnostics" { + t.Fatalf("selected command = %q, want diagnostics", got) + } +} + +func TestViewIsStandaloneAndWidthBounded(t *testing.T) { + model := New(theme.New(colorprofile.ASCII), []string{"access"}) + view := model.View(76) + lines := strings.Split(ansi.Strip(view), "\n") + if len(lines) != 4 { + t.Fatalf("view height = %d, want 4", len(lines)) + } + if !strings.Contains(lines[0], "Command") || !strings.Contains(lines[3], "ctrl+c quit") { + t.Fatalf("command bar is incomplete:\n%s", view) + } + for _, line := range lines { + if got := lipgloss.Width(line); got > 76 { + t.Fatalf("line width %d exceeds 76: %q", got, line) + } + } +} + +func TestArrowKeysOpenAndSelectCommandPopup(t *testing.T) { + model := New(theme.New(colorprofile.ASCII), []string{"access", "diagnostics", "profiles"}) + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + if cmd != nil || !model.popupOpen || model.popupCursor != 0 { + t.Fatalf("first down did not open popup: %#v", model) + } + view := ansi.Strip(model.View(76)) + if !strings.Contains(view, "Available commands") || !strings.Contains(view, "› access") || !strings.Contains(view, " diagnostics") { + t.Fatalf("command popup is incomplete:\n%s", view) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("popup selection did not submit") + } + if got := cmd().(SubmittedMsg).Command; got != "diagnostics" { + t.Fatalf("submitted command = %q, want diagnostics", got) + } + if model.popupOpen { + t.Fatal("popup remained open after submit") + } +} + +func TestCommandPopupFiltersAndEscClosesBeforeClearing(t *testing.T) { + model := New(theme.New(colorprofile.ASCII), []string{"access", "diagnostics", "profiles"}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'p', Text: "p"}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + view := ansi.Strip(model.View(76)) + if !strings.Contains(view, "› profiles") || strings.Contains(view, "diagnostics") { + t.Fatalf("filtered popup is incorrect:\n%s", view) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if model.popupOpen || model.Value() != "p" { + t.Fatalf("first esc should only close popup: open=%v value=%q", model.popupOpen, model.Value()) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if model.Value() != "" { + t.Fatalf("second esc did not clear input: %q", model.Value()) + } +} diff --git a/tui/components/page/page.go b/tui/components/page/page.go new file mode 100644 index 000000000..ccc8fdf5f --- /dev/null +++ b/tui/components/page/page.go @@ -0,0 +1,103 @@ +// Package page provides shared routed-screen chrome: the same two-cell gutter, +// semantic rule, framed surfaces, responsive minimum, and bottom-anchored key +// help used throughout the TUI. +package page + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/theme" +) + +const ( + DefaultWidth = 100 + DefaultHeight = 30 + MinimumWidth = 80 + MinimumHeight = 24 + SideMargin = 2 +) + +// Size applies deterministic defaults for model tests and initial renders. +func Size(width, height int) (int, int) { + if width <= 0 { + width = DefaultWidth + } + if height <= 0 { + height = DefaultHeight + } + return width, height +} + +// ContentWidth returns the width available inside the shared side gutters. +func ContentWidth(width int) int { return max(1, width-2*SideMargin) } + +// Render composes routed-screen content and anchors help to the final row. +func Render(t theme.Theme, width, height int, title, status, body, help string) string { + width, height = Size(width, height) + if width < MinimumWidth || height < MinimumHeight { + return Unsupported(t, width, height) + } + contentWidth := ContentWidth(width) + header := renderHeader(t, contentWidth, title, status) + occupied := lipgloss.Height(header) + 1 + lipgloss.Height(body) + separation := max(2, height-occupied) + help = t.Muted.Render(ansi.Truncate(help, contentWidth, "…")) + content := header + "\n\n" + body + strings.Repeat("\n", separation) + help + return indent(content, SideMargin) +} + +// Panel renders a fixed-height semantic surface. Content is truncated rather +// than allowed to push key help off-screen. +func Panel(t theme.Theme, title string, lines []string, width, height int, focused bool) string { + width = max(8, width) + height = max(3, height) + innerWidth := width - 4 + border := lipgloss.NewStyle().Foreground(t.Colors.Border) + if focused { + border = lipgloss.NewStyle().Foreground(t.Colors.Primary) + } + styledTitle := t.Body.Render(title) + if focused { + styledTitle = t.Title.Render("› " + title) + } + ruleWidth := max(1, width-lipgloss.Width(styledTitle)-5) + result := []string{border.Render("╭─ ") + styledTitle + border.Render(" "+strings.Repeat("─", ruleWidth)+"╮")} + visible := height - 2 + for i := 0; i < visible; i++ { + line := "" + if i < len(lines) { + line = lines[i] + } + if i == visible-1 && len(lines) > visible { + line = t.Muted.Render("…") + } + line = ansi.Truncate(line, innerWidth, "…") + result = append(result, border.Render("│")+" "+line+strings.Repeat(" ", max(0, innerWidth-lipgloss.Width(line)))+" "+border.Render("│")) + } + result = append(result, border.Render("╰"+strings.Repeat("─", width-2)+"╯")) + return strings.Join(result, "\n") +} + +func Unsupported(t theme.Theme, width, height int) string { + message := "Unsupported terminal size: " + dimensions(width, height) + " · minimum " + dimensions(MinimumWidth, MinimumHeight) + message = t.Body.Render(ansi.Truncate(message, max(1, width), "…")) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, message) +} + +func renderHeader(t theme.Theme, width int, title, status string) string { + left := t.Title.Render("Plural") + " " + t.Body.Render(title) + status = ansi.Truncate(status, max(0, width-lipgloss.Width(left)-2), "…") + gap := strings.Repeat(" ", max(1, width-lipgloss.Width(left)-lipgloss.Width(status))) + line := ansi.Truncate(left+gap+status, width, "…") + return line + "\n" + lipgloss.NewStyle().Foreground(t.Colors.Primary).Render(strings.Repeat("─", width)) +} + +func indent(content string, width int) string { + padding := strings.Repeat(" ", width) + return padding + strings.ReplaceAll(content, "\n", "\n"+padding) +} +func dimensions(width, height int) string { return fmt.Sprintf("%d×%d", width, height) } diff --git a/tui/components/page/page_test.go b/tui/components/page/page_test.go new file mode 100644 index 000000000..aa16a0939 --- /dev/null +++ b/tui/components/page/page_test.go @@ -0,0 +1,37 @@ +package page + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/theme" +) + +func TestRenderAnchorsSharedChromeAndHelp(t *testing.T) { + theme := theme.New(colorprofile.ASCII) + body := Panel(theme, "Content", []string{"one", "two"}, ContentWidth(80), 6, true) + view := ansi.Strip(Render(theme, 80, 24, "Screen", "✓ ready", body, "esc back")) + lines := strings.Split(view, "\n") + if len(lines) != 24 { + t.Fatalf("height = %d, want 24", len(lines)) + } + if !strings.HasPrefix(lines[0], " Plural Screen") || !strings.Contains(lines[len(lines)-1], "esc back") { + t.Fatalf("shared chrome is incomplete:\n%s", view) + } + for _, line := range lines { + if got := lipgloss.Width(line); got > 80 { + t.Fatalf("line width %d exceeds 80: %q", got, line) + } + } +} + +func TestUnsupportedUsesWelcomeMinimum(t *testing.T) { + view := ansi.Strip(Render(theme.New(colorprofile.ASCII), 79, 23, "Screen", "", "", "")) + if !strings.Contains(view, "79×23") || !strings.Contains(view, "80×24") { + t.Fatalf("unsupported dimensions missing:\n%s", view) + } +} diff --git a/tui/components/spinner/spinner.go b/tui/components/spinner/spinner.go new file mode 100644 index 000000000..59efc11c9 --- /dev/null +++ b/tui/components/spinner/spinner.go @@ -0,0 +1,23 @@ +package spinner + +import ( + "time" + + "charm.land/bubbles/v2/spinner" + + "github.com/pluralsh/plural-cli/tui/theme" +) + +// Mark follows the logo's top-left bracket, center dot, and bottom-right +// bracket. Every frame occupies one terminal cell, so nearby text stays put. +var Mark = spinner.Spinner{ + Frames: []string{"▛", "●", "▟", "●"}, + FPS: 120 * time.Millisecond, +} + +func New(t theme.Theme) spinner.Model { + return spinner.New( + spinner.WithSpinner(Mark), + spinner.WithStyle(t.Title), + ) +} diff --git a/tui/components/spinner/spinner_test.go b/tui/components/spinner/spinner_test.go new file mode 100644 index 000000000..025faacd6 --- /dev/null +++ b/tui/components/spinner/spinner_test.go @@ -0,0 +1,29 @@ +package spinner + +import ( + "reflect" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + + "github.com/pluralsh/plural-cli/tui/theme" +) + +func TestMarkFramesHaveStableCompactWidth(t *testing.T) { + if len(Mark.Frames) == 0 { + t.Fatal("spinner has no frames") + } + for _, frame := range Mark.Frames { + if width := lipgloss.Width(frame); width != 1 { + t.Fatalf("frame %q has width %d, want 1", frame, width) + } + } +} + +func TestNewUsesPluralMark(t *testing.T) { + model := New(theme.New(colorprofile.ASCII)) + if got, want := model.Spinner.Frames, Mark.Frames; !reflect.DeepEqual(got, want) { + t.Fatalf("got frames %q, want %q", got, want) + } +} diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go new file mode 100644 index 000000000..186ed074f --- /dev/null +++ b/tui/navigation/navigation.go @@ -0,0 +1,21 @@ +// Package navigation defines route messages shared by otherwise independent +// screens. Keeping route ownership out of individual screens lets new features +// be developed without importing the root application package. +package navigation + +import tea "charm.land/bubbletea/v2" + +// Route identifies a top-level TUI screen. +type Route string + +const ( + Welcome Route = "welcome" + Access Route = "access" + Diagnostics Route = "diagnostics" +) + +// NavigateMsg requests a top-level route change. +type NavigateMsg struct{ Route Route } + +// Navigate returns a typed route command. +func Navigate(route Route) tea.Cmd { return func() tea.Msg { return NavigateMsg{Route: route} } } diff --git a/tui/screens/access/golden_test.go b/tui/screens/access/golden_test.go new file mode 100644 index 000000000..075789dcb --- /dev/null +++ b/tui/screens/access/golden_test.go @@ -0,0 +1,74 @@ +package access + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + "github.com/pluralsh/plural-cli/tui/theme" +) + +func TestAccessGoldens(t *testing.T) { + personal := accessbridge.Profile{ID: "app-personal", Name: "personal", Email: "alex@acme.io", Endpoint: "app.plural.sh"} + consulting := accessbridge.Profile{ID: "app-consulting", Name: "consulting", Email: "alex@consulting.dev", Endpoint: "cloud.plural.example"} + production := accessbridge.ConsoleProfile{ID: "console-production", Name: "production", URL: "https://console.acme.io"} + staging := accessbridge.ConsoleProfile{ID: "console-staging", Name: "staging", URL: "https://console.staging.acme.io"} + snapshot := accessbridge.Snapshot{ + State: accessbridge.State{ + Profiles: []accessbridge.Profile{personal, consulting}, ActiveProfileID: personal.ID, + ConsoleProfiles: []accessbridge.ConsoleProfile{production, staging}, ActiveConsoleID: production.ID, + }, + Context: accessbridge.AuthContext{Base: &personal, Acting: &accessbridge.Identity{Email: "deploy@acme.io", ServiceAccount: true}, Console: &production}, + } + + for _, width := range []int{80, 120} { + t.Run(strconv.Itoa(width), func(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model.snapshot = snapshot + height := 24 + if width == 120 { + height = 30 + } + got := normalizeGoldenView(model.View(width, height)) + golden := filepath.Join("testdata", "access-"+strconv.Itoa(width)+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + assertGoldenDimensions(t, got, width, height) + if strings.Contains(got, "super-secret") { + t.Fatal("Access golden exposed a credential") + } + }) + } +} + +func normalizeGoldenView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} +func assertGoldenDimensions(t *testing.T, view string, width, height int) { + t.Helper() + lines := strings.Split(view, "\n") + if len(lines) != height { + t.Fatalf("view height = %d, want %d", len(lines), height) + } + for _, line := range lines { + if got := lipgloss.Width(line); got > width { + t.Fatalf("line width %d exceeds %d: %q", got, width, line) + } + } +} diff --git a/tui/screens/access/model.go b/tui/screens/access/model.go new file mode 100644 index 000000000..5c0505c2e --- /dev/null +++ b/tui/screens/access/model.go @@ -0,0 +1,371 @@ +// Package access implements the Phase 1 identity and connection screen. It +// depends only on access.Manager and can be developed independently of +// the root shell. +package access + +import ( + "context" + "errors" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type loadedMsg struct { + snapshot accessbridge.Snapshot + err error +} +type changedMsg struct{ err error } +type accountsMsg struct { + accounts []accessbridge.ServiceAccount + err error +} +type authorizedMsg struct { + authorization accessbridge.DeviceAuthorization + requestID uint64 + err error +} +type loggedInMsg struct { + profile accessbridge.Profile + requestID uint64 + err error +} + +type mode uint8 + +const ( + modeProfiles mode = iota + modeAccounts + modeConsoleForm + modeDeviceLogin +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionCancelOperation + keyActionNextPanel + keyActionPreviousPanel + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionRefresh + keyActionDeviceLogin + keyActionAddConsole + keyActionImpersonate + keyActionStopImpersonating +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionCancelOperation: {"ctrl+c"}, + keyActionNextPanel: {"tab"}, + keyActionPreviousPanel: {"shift+tab"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, + keyActionRefresh: {"r"}, + keyActionDeviceLogin: {"n"}, + keyActionAddConsole: {"c"}, + keyActionImpersonate: {"i"}, + keyActionStopImpersonating: {"x"}, +} + +func actionForKeystroke(keystroke string) keyAction { + for action, keystrokes := range keyActionKeystrokes { + for _, candidate := range keystrokes { + if keystroke == candidate { + return action + } + } + } + + return keyActionNone +} + +// Model owns only Access-screen interaction state. +type Model struct { + ctx context.Context + manager accessbridge.Manager + theme theme.Theme + loading bool + snapshot accessbridge.Snapshot + err error + panel int + appCursor int + consoleCursor int + accountCursor int + mode mode + authorization accessbridge.DeviceAuthorization + operationCtx context.Context + cancel context.CancelFunc + form []textinput.Model + formIndex int + loginRequest uint64 +} + +func New(ctx context.Context, manager accessbridge.Manager, t theme.Theme) Model { + return Model{ctx: ctx, manager: manager, theme: t, loading: manager != nil, form: newConsoleForm(t)} +} + +func newConsoleForm(t theme.Theme) []textinput.Model { + values := make([]textinput.Model, 3) + for i, placeholder := range []string{"Profile name", "https://console.example.com", "Console token"} { + values[i] = textinput.New() + values[i].Prompt = "› " + values[i].Placeholder = placeholder + values[i].CharLimit = 256 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Blurred = styles.Focused + values[i].SetStyles(styles) + } + values[2].EchoMode = textinput.EchoPassword + return values +} + +func (m Model) Init() tea.Cmd { + if m.manager == nil { + return nil + } + return m.load +} +func (m Model) load() tea.Msg { + snapshot, err := m.manager.Load(m.ctx) + return loadedMsg{snapshot, err} +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case loadedMsg: + m.loading = false + m.err = msg.err + if msg.err == nil { + m.snapshot = msg.snapshot + } + return m, nil + case changedMsg: + m.loading = false + m.err = msg.err + if msg.err == nil { + m.mode = modeProfiles + return m, m.load + } + return m, nil + case accountsMsg: + m.loading = false + m.err = msg.err + if msg.err == nil { + m.snapshot.ServiceAccounts = msg.accounts + m.mode = modeAccounts + m.accountCursor = 0 + } + return m, nil + case authorizedMsg: + if msg.requestID != m.loginRequest { + return m, nil + } + m.loading = false + m.err = msg.err + if msg.err != nil { + m.cancel = nil + m.operationCtx = nil + m.mode = modeProfiles + return m, nil + } + m.authorization = msg.authorization + m.mode = modeDeviceLogin + m.loading = true + loginCtx := m.operationCtx + if loginCtx == nil { + var cancel context.CancelFunc + loginCtx, cancel = context.WithCancel(m.ctx) + m.operationCtx, m.cancel = loginCtx, cancel + } + return m, func() tea.Msg { + profile, err := m.manager.CompleteDeviceLogin(loginCtx, "default", msg.authorization, "app.plural.sh") + return loggedInMsg{profile: profile, requestID: msg.requestID, err: err} + } + case loggedInMsg: + if msg.requestID != m.loginRequest { + return m, nil + } + m.loading = false + m.cancel = nil + m.operationCtx = nil + m.err = msg.err + m.mode = modeProfiles + if msg.err == nil { + return m, m.load + } + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if m.mode == modeConsoleForm { + return m.updateForm(msg) + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + if action == keyActionBack || (action == keyActionCancelOperation && m.cancel != nil) { + if m.cancel != nil { + m.cancel() + m.loginRequest++ + m.cancel = nil + m.operationCtx = nil + m.loading = false + m.mode = modeProfiles + return m, nil + } + if m.mode != modeProfiles { + m.mode = modeProfiles + m.err = nil + return m, nil + } + return m, navigation.Navigate(navigation.Welcome) + } + if m.loading { + return m, nil + } + if m.manager == nil { + m.err = errors.New("access services are unavailable") + return m, nil + } + if m.mode == modeConsoleForm { + return m.updateForm(key) + } + if m.mode == modeAccounts { + return m.updateAccounts(key) + } + switch action { + case keyActionNextPanel: + m.panel = (m.panel + 1) % 2 + case keyActionPreviousPanel: + m.panel = (m.panel + 1) % 2 + case keyActionMoveUp: + m = m.move(-1) + case keyActionMoveDown: + m = m.move(1) + case keyActionConfirm: + return m.activate() + case keyActionRefresh: + m.loading = true + return m, m.load + case keyActionDeviceLogin: + m.loading = true + m.loginRequest++ + requestID := m.loginRequest + loginCtx, cancel := context.WithCancel(m.ctx) + m.operationCtx, m.cancel = loginCtx, cancel + return m, func() tea.Msg { + authorization, err := m.manager.BeginDeviceLogin(loginCtx, "app.plural.sh") + return authorizedMsg{authorization: authorization, requestID: requestID, err: err} + } + case keyActionAddConsole: + m.mode = modeConsoleForm + m.formIndex = 0 + for i := range m.form { + m.form[i].Reset() + m.form[i].Blur() + } + m.form[0].Focus() + case keyActionImpersonate: + m.loading = true + return m, func() tea.Msg { + accounts, err := m.manager.SearchServiceAccounts(m.ctx, "") + return accountsMsg{accounts, err} + } + case keyActionStopImpersonating: + m.manager.StopImpersonating() + m.loading = true + return m, m.load + } + return m, nil +} + +// HasCancellableOperation lets the shell route Ctrl+C to this screen before +// applying its global quit binding. +func (m Model) HasCancellableOperation() bool { return m.cancel != nil } + +func (m Model) updateAccounts(key tea.KeyPressMsg) (Model, tea.Cmd) { + count := len(m.snapshot.ServiceAccounts) + switch actionForKeystroke(key.Keystroke()) { + case keyActionMoveUp: + m.accountCursor = clampCursor(m.accountCursor-1, count) + case keyActionMoveDown: + m.accountCursor = clampCursor(m.accountCursor+1, count) + case keyActionConfirm: + if count > 0 { + email := m.snapshot.ServiceAccounts[m.accountCursor].Email + m.loading = true + return m, func() tea.Msg { return changedMsg{err: m.manager.Impersonate(m.ctx, email)} } + } + } + return m, nil +} + +func (m Model) updateForm(msg tea.Msg) (Model, tea.Cmd) { + if key, ok := msg.(tea.KeyPressMsg); ok && actionForKeystroke(key.Keystroke()) == keyActionConfirm { + if m.formIndex < len(m.form)-1 { + m.form[m.formIndex].Blur() + m.formIndex++ + m.form[m.formIndex].Focus() + return m, nil + } + name, url, token := m.form[0].Value(), m.form[1].Value(), m.form[2].Value() + m.loading = true + m.mode = modeProfiles + return m, func() tea.Msg { + _, err := m.manager.AddConsoleProfile(m.ctx, name, url, token) + return changedMsg{err: err} + } + } + var cmd tea.Cmd + m.form[m.formIndex], cmd = m.form[m.formIndex].Update(msg) + return m, cmd +} + +func (m Model) move(delta int) Model { + if m.panel == 0 { + m.appCursor = clampCursor(m.appCursor+delta, len(m.snapshot.State.Profiles)) + } else { + m.consoleCursor = clampCursor(m.consoleCursor+delta, len(m.snapshot.State.ConsoleProfiles)) + } + return m +} +func clampCursor(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} +func (m Model) activate() (Model, tea.Cmd) { + if m.panel == 0 && len(m.snapshot.State.Profiles) > 0 { + id := m.snapshot.State.Profiles[m.appCursor].ID + m.loading = true + return m, func() tea.Msg { return changedMsg{err: m.manager.ActivateProfile(m.ctx, id)} } + } + if m.panel == 1 && len(m.snapshot.State.ConsoleProfiles) > 0 { + id := m.snapshot.State.ConsoleProfiles[m.consoleCursor].ID + m.loading = true + return m, func() tea.Msg { return changedMsg{err: m.manager.ActivateConsole(m.ctx, id)} } + } + return m, nil +} diff --git a/tui/screens/access/model_test.go b/tui/screens/access/model_test.go new file mode 100644 index 000000000..6ca922d07 --- /dev/null +++ b/tui/screens/access/model_test.go @@ -0,0 +1,137 @@ +package access + +import ( + "context" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + + accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeManager struct { + snapshot accessbridge.Snapshot + activatedApp, activatedConsole, impersonated string + stopped bool +} + +func (f *fakeManager) Load(context.Context) (accessbridge.Snapshot, error) { return f.snapshot, nil } +func (f *fakeManager) BeginDeviceLogin(context.Context, string) (accessbridge.DeviceAuthorization, error) { + return accessbridge.DeviceAuthorization{LoginURL: "https://login.example.com", DeviceToken: "device"}, nil +} +func (f *fakeManager) CompleteDeviceLogin(context.Context, string, accessbridge.DeviceAuthorization, string) (accessbridge.Profile, error) { + return accessbridge.Profile{ID: "new"}, nil +} +func (f *fakeManager) AddConsoleProfile(context.Context, string, string, string) (accessbridge.ConsoleProfile, error) { + return accessbridge.ConsoleProfile{}, nil +} +func (f *fakeManager) ActivateProfile(_ context.Context, id string) error { + f.activatedApp = id + return nil +} +func (f *fakeManager) ActivateConsole(_ context.Context, id string) error { + f.activatedConsole = id + return nil +} +func (f *fakeManager) SearchServiceAccounts(context.Context, string) ([]accessbridge.ServiceAccount, error) { + return []accessbridge.ServiceAccount{{ID: "sa", Email: "deploy@example.com"}}, nil +} +func (f *fakeManager) Impersonate(_ context.Context, email string) error { + f.impersonated = email + return nil +} +func (f *fakeManager) StopImpersonating() { f.stopped = true } + +func loadedModel(t *testing.T, manager *fakeManager) Model { + model := New(t.Context(), manager, theme.New(colorprofile.ASCII)) + cmd := model.Init() + if cmd == nil { + t.Fatal("Init() returned nil") + } + model, _ = model.Update(cmd()) + return model +} + +func TestFirstRunCanSkipConsoleAndReturnToWelcome(t *testing.T) { + model := loadedModel(t, &fakeManager{}) + view := model.View(100, 30) + if !strings.Contains(view, "Skipped for now") || !strings.Contains(view, "Press n to sign in") { + t.Fatalf("first-run guidance missing:\n%s", view) + } + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if cmd == nil || cmd().(navigation.NavigateMsg).Route != navigation.Welcome { + t.Fatal("esc did not return to welcome") + } +} + +func TestPanelsActivateIndependently(t *testing.T) { + manager := &fakeManager{snapshot: accessbridge.Snapshot{State: accessbridge.State{ + Profiles: []accessbridge.Profile{{ID: "app-a"}, {ID: "app-b"}}, ActiveProfileID: "app-a", + ConsoleProfiles: []accessbridge.ConsoleProfile{{ID: "console-a"}, {ID: "console-b"}}, ActiveConsoleID: "console-a", + }}} + model := loadedModel(t, manager) + if model.loading || model.mode != modeProfiles || model.panel != 0 || len(model.snapshot.State.Profiles) != 2 { + t.Fatalf("loaded model = loading:%v mode:%v panel:%d profiles:%d", model.loading, model.mode, model.panel, len(model.snapshot.State.Profiles)) + } + down := tea.KeyPressMsg{Code: 'j', Text: "j"} + model, _ = model.Update(down) + if model.appCursor != 1 { + t.Fatalf("App cursor = %d after string=%q keystroke=%q (error %v)", model.appCursor, down.String(), down.Keystroke(), model.err) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + cmd() + if manager.activatedApp != "app-b" { + t.Fatalf("activated App = %q", manager.activatedApp) + } + model.loading = false + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'j', Text: "j"}) + _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + cmd() + if manager.activatedConsole != "console-b" { + t.Fatalf("activated Console = %q", manager.activatedConsole) + } +} + +func TestServiceAccountPickerCreatesSessionAction(t *testing.T) { + manager := &fakeManager{snapshot: accessbridge.Snapshot{State: accessbridge.State{Profiles: []accessbridge.Profile{{ID: "app"}}, ActiveProfileID: "app"}}} + model := loadedModel(t, manager) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'i', Text: "i"}) + model, _ = model.Update(cmd()) + if model.mode != modeAccounts { + t.Fatal("service-account picker did not open") + } + _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + cmd() + if manager.impersonated != "deploy@example.com" { + t.Fatalf("impersonated = %q", manager.impersonated) + } +} + +func TestConsoleTokenIsMasked(t *testing.T) { + model := New(t.Context(), &fakeManager{}, theme.New(colorprofile.ASCII)) + model.mode = modeConsoleForm + model.form[2].SetValue("super-secret") + model.formIndex = 2 + model.form[2].Focus() + view := model.View(100, 30) + if strings.Contains(view, "super-secret") { + t.Fatalf("Console token leaked in view:\n%s", view) + } +} + +func TestDeviceLoginCanBeCancelledBeforeGlobalQuit(t *testing.T) { + model := loadedModel(t, &fakeManager{}) + model, _ = model.Update(tea.KeyPressMsg{Code: 'n', Text: "n"}) + if !model.HasCancellableOperation() { + t.Fatal("device login is not cancellable") + } + model, cmd := model.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if cmd != nil || model.HasCancellableOperation() || model.mode != modeProfiles { + t.Fatalf("cancel left operation active: %#v", model) + } +} diff --git a/tui/screens/access/testdata/access-120.golden b/tui/screens/access/testdata/access-120.golden new file mode 100644 index 000000000..093bf1b2a --- /dev/null +++ b/tui/screens/access/testdata/access-120.golden @@ -0,0 +1,30 @@ + Plural Identity & connections ✓ context ready + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Plural App profiles ───────────────────────────────╮ ╭─ Console profiles ─────────────────────────────────────╮ + │ › personal ACTIVE │ │ production ACTIVE │ + │ alex@acme.io │ │ https://console.acme.io │ + │ consulting │ │ staging │ + │ alex@consulting.dev │ │ https://console.staging.acme.io │ + │ │ │ │ + │ │ │ │ + ╰───────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────╯ + + ╭─ Effective context ──────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Base account alex@acme.io via personal │ + │ Acting as deploy@acme.io · session only │ + │ Console production · https://console.acme.io │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + tab panel · ↑/↓ select · enter activate · n App login · c Console · i act as · x stop · r refresh · esc back diff --git a/tui/screens/access/testdata/access-80.golden b/tui/screens/access/testdata/access-80.golden new file mode 100644 index 000000000..bbf9c36d5 --- /dev/null +++ b/tui/screens/access/testdata/access-80.golden @@ -0,0 +1,24 @@ + Plural Identity & connections ✓ context ready + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Plural App profiles ───────────╮ ╭─ Console profiles ─────────────────╮ + │ › personal ACTIVE │ │ production ACTIVE │ + │ alex@acme.io │ │ https://console.acme.io │ + │ consulting │ │ staging │ + │ alex@consulting.dev │ │ https://console.staging.acme.… │ + │ │ │ │ + │ │ │ │ + ╰───────────────────────────────────╯ ╰────────────────────────────────────╯ + + ╭─ Effective context ──────────────────────────────────────────────────────╮ + │ Base account alex@acme.io via personal │ + │ Acting as deploy@acme.io · session only │ + │ Console production · https://console.acme.io │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + tab panel · ↑/↓ select · enter use · n App · c Console · i act as · esc back diff --git a/tui/screens/access/view.go b/tui/screens/access/view.go new file mode 100644 index 000000000..d4ac3f20f --- /dev/null +++ b/tui/screens/access/view.go @@ -0,0 +1,153 @@ +package access + +import ( + "fmt" + + "charm.land/lipgloss/v2" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +const profilePanelHeight = 8 + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + status := m.headerStatus() + + var body, help string + switch m.mode { + case modeDeviceLogin: + body = page.Panel(m.theme, "Plural App device login", m.deviceLoginLines(), contentWidth, 9, true) + help = "esc cancel · ctrl+c cancel" + case modeConsoleForm: + body = page.Panel(m.theme, "Add Console connection", m.consoleFormLines(), contentWidth, 9, true) + help = "enter next/save · esc cancel · token is stored securely" + case modeAccounts: + body = page.Panel(m.theme, "Choose acting identity", m.accountLines(), contentWidth, 10, true) + help = "↑/↓ select · enter use for this session · esc cancel" + default: + body = m.profileOverview(contentWidth) + if contentWidth < 100 { + help = "tab panel · ↑/↓ select · enter use · n App · c Console · i act as · esc back" + } else { + help = "tab panel · ↑/↓ select · enter activate · n App login · c Console · i act as · x stop · r refresh · esc back" + } + } + return page.Render(m.theme, width, height, "Identity & connections", status, body, help) +} + +func (m Model) headerStatus() string { + if m.loading { + return m.theme.Warning.Render("◌ working") + } + if m.err != nil { + return m.theme.Danger.Render("✗ attention required") + } + if m.snapshot.Context.Base == nil && m.snapshot.Context.Console == nil { + return m.theme.Warning.Render("○ setup available") + } + return m.theme.Success.Render("✓ context ready") +} + +func (m Model) profileOverview(width int) string { + gap := 1 + leftWidth := (width - gap) / 2 + rightWidth := width - gap - leftWidth + profiles := page.Panel(m.theme, "Plural App profiles", m.profileLines(), leftWidth, profilePanelHeight, m.panel == 0) + consoles := page.Panel(m.theme, "Console profiles", m.consoleLines(), rightWidth, profilePanelHeight, m.panel == 1) + columns := lipgloss.JoinHorizontal(lipgloss.Top, profiles, " ", consoles) + return columns + "\n\n" + page.Panel(m.theme, "Effective context", m.contextLines(), width, 6, false) +} + +func (m Model) profileLines() []string { + if len(m.snapshot.State.Profiles) == 0 { + return []string{m.theme.Warning.Render("○ Not connected"), m.theme.Muted.Render(" Press n to sign in with a device code.")} + } + lines := make([]string, 0, 2*len(m.snapshot.State.Profiles)) + for i, profile := range m.snapshot.State.Profiles { + cursor := " " + if m.panel == 0 && i == m.appCursor { + cursor = "› " + } + active := "" + if profile.ID == m.snapshot.State.ActiveProfileID { + active = " " + m.theme.Success.Render("ACTIVE") + } + lines = append(lines, cursor+profile.Name+active, " "+m.theme.Muted.Render(profile.Email)) + } + return lines +} + +func (m Model) consoleLines() []string { + if len(m.snapshot.State.ConsoleProfiles) == 0 { + return []string{m.theme.Warning.Render("○ Skipped for now"), m.theme.Muted.Render(" Press c to connect later.")} + } + lines := make([]string, 0, 2*len(m.snapshot.State.ConsoleProfiles)) + for i, profile := range m.snapshot.State.ConsoleProfiles { + cursor := " " + if m.panel == 1 && i == m.consoleCursor { + cursor = "› " + } + active := "" + if profile.ID == m.snapshot.State.ActiveConsoleID { + active = " " + m.theme.Success.Render("ACTIVE") + } + lines = append(lines, cursor+profile.Name+active, " "+m.theme.Muted.Render(profile.URL)) + } + return lines +} + +func (m Model) contextLines() []string { + base, acting, console := "not connected", "self", "not connected" + if m.snapshot.Context.Base != nil { + base = m.snapshot.Context.Base.Email + " via " + m.snapshot.Context.Base.Name + } + if m.snapshot.Context.Acting != nil { + acting = m.theme.Warning.Render(m.snapshot.Context.Acting.Email) + m.theme.Muted.Render(" · session only") + } + if m.snapshot.Context.Console != nil { + console = m.snapshot.Context.Console.Name + " · " + m.snapshot.Context.Console.URL + } + lines := []string{"Base account " + base, "Acting as " + acting, "Console " + console} + if m.err != nil { + lines = append(lines, m.theme.Danger.Render("Error "+m.err.Error())) + } + return lines +} + +func (m Model) accountLines() []string { + if len(m.snapshot.ServiceAccounts) == 0 { + return []string{m.theme.Warning.Render("○ No service accounts available."), m.theme.Muted.Render(" esc returns to profile selection")} + } + lines := make([]string, 0, len(m.snapshot.ServiceAccounts)+1) + for i, account := range m.snapshot.ServiceAccounts { + cursor := " " + if i == m.accountCursor { + cursor = "› " + } + lines = append(lines, cursor+account.Email) + } + lines = append(lines, "", m.theme.Muted.Render("The exchanged credential remains in memory only.")) + return lines +} + +func (m Model) consoleFormLines() []string { + labels := []string{"Name", "URL", "Token"} + lines := []string{m.theme.Muted.Render("Console is optional; press esc to finish setup without it."), ""} + for i := range m.form { + marker := " " + if i == m.formIndex { + marker = "› " + } + lines = append(lines, fmt.Sprintf("%s%-7s %s", marker, labels[i], m.form[i].View())) + } + return lines +} + +func (m Model) deviceLoginLines() []string { + return []string{m.theme.Muted.Render("Open this URL in your browser:"), m.theme.Link.Render(m.authorization.LoginURL), "", m.theme.Warning.Render("◌ Waiting for authorization…"), "", m.theme.Muted.Render("Console can be skipped and connected later from this screen.")} +} diff --git a/tui/screens/diagnostics/golden_test.go b/tui/screens/diagnostics/golden_test.go new file mode 100644 index 000000000..f3fe8a2de --- /dev/null +++ b/tui/screens/diagnostics/golden_test.go @@ -0,0 +1,67 @@ +package diagnostics + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + bridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + "github.com/pluralsh/plural-cli/tui/theme" +) + +func TestDiagnosticsGoldens(t *testing.T) { + snapshot := bridge.Snapshot{ + Version: "v0.13.0", + App: bridge.AppProfile{Configured: true, Name: "personal", Email: "alex@acme.io", Endpoint: "https://app.plural.sh"}, + Console: bridge.ConsoleConnection{Configured: true, URL: "https://console.acme.io"}, + Workspace: bridge.Workspace{Configured: true, Path: "/work/path/to/a/very/long/workspace", Name: "plrl-dev-aws", Provider: "aws", Region: "eu-west-1"}, + KubeContext: "plural-platform-prod", + Diagnostics: []string{"workspace owner does not match the active identity"}, + } + for _, width := range []int{80, 120} { + t.Run(strconv.Itoa(width), func(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model.snapshot = snapshot + height := 24 + if width == 120 { + height = 30 + } + got := normalizeGoldenView(model.View(width, height)) + golden := filepath.Join("testdata", "diagnostics-"+strconv.Itoa(width)+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + assertGoldenDimensions(t, got, width, height) + }) + } +} + +func normalizeGoldenView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} +func assertGoldenDimensions(t *testing.T, view string, width, height int) { + t.Helper() + lines := strings.Split(view, "\n") + if len(lines) != height { + t.Fatalf("view height = %d, want %d", len(lines), height) + } + for _, line := range lines { + if got := lipgloss.Width(line); got > width { + t.Fatalf("line width %d exceeds %d: %q", got, width, line) + } + } +} diff --git a/tui/screens/diagnostics/model.go b/tui/screens/diagnostics/model.go new file mode 100644 index 000000000..2bb1bc4e7 --- /dev/null +++ b/tui/screens/diagnostics/model.go @@ -0,0 +1,78 @@ +// Package diagnostics renders credential-free local context and startup +// diagnostics behind the same loader used by the welcome screen. +package diagnostics + +import ( + "context" + + tea "charm.land/bubbletea/v2" + + welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type loadedMsg struct { + snapshot welcomebridge.Snapshot + err error +} + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionRefresh +) + +var keyActionKeystrokes = map[keyAction]string{ + keyActionBack: "esc", + keyActionRefresh: "r", +} + +func actionForKeystroke(keystroke string) keyAction { + for action, candidate := range keyActionKeystrokes { + if keystroke == candidate { + return action + } + } + + return keyActionNone +} + +type Model struct { + ctx context.Context + loader welcomebridge.Loader + theme theme.Theme + loading bool + snapshot welcomebridge.Snapshot + err error +} + +func New(ctx context.Context, loader welcomebridge.Loader, t theme.Theme) Model { + return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil} +} +func (m Model) Init() tea.Cmd { + if m.loader == nil { + return nil + } + return m.load +} +func (m Model) load() tea.Msg { value, err := m.loader.Load(m.ctx); return loadedMsg{value, err} } +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case loadedMsg: + m.loading = false + m.snapshot = msg.snapshot + m.err = msg.err + case tea.KeyPressMsg: + switch actionForKeystroke(msg.Keystroke()) { + case keyActionBack: + return m, navigation.Navigate(navigation.Welcome) + case keyActionRefresh: + m.loading = true + return m, m.load + } + } + return m, nil +} diff --git a/tui/screens/diagnostics/model_test.go b/tui/screens/diagnostics/model_test.go new file mode 100644 index 000000000..1b502f170 --- /dev/null +++ b/tui/screens/diagnostics/model_test.go @@ -0,0 +1,33 @@ +package diagnostics + +import ( + "context" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + + bridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type loaderFunc func(context.Context) (bridge.Snapshot, error) + +func (f loaderFunc) Load(ctx context.Context) (bridge.Snapshot, error) { return f(ctx) } + +func TestDiagnosticsLoadsContextAndReturnsToWelcome(t *testing.T) { + model := New(t.Context(), loaderFunc(func(context.Context) (bridge.Snapshot, error) { + return bridge.Snapshot{App: bridge.AppProfile{Configured: true, Email: "dev@example.com"}, Diagnostics: []string{"workspace: invalid"}}, nil + }), theme.New(colorprofile.ASCII)) + model, _ = model.Update(model.Init()()) + view := model.View(100, 30) + if !strings.Contains(view, "dev@example.com") || !strings.Contains(view, "workspace: invalid") { + t.Fatalf("diagnostics missing context:\n%s", view) + } + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if cmd == nil || cmd().(navigation.NavigateMsg).Route != navigation.Welcome { + t.Fatal("esc did not return to welcome") + } +} diff --git a/tui/screens/diagnostics/testdata/diagnostics-120.golden b/tui/screens/diagnostics/testdata/diagnostics-120.golden new file mode 100644 index 000000000..f04334b6b --- /dev/null +++ b/tui/screens/diagnostics/testdata/diagnostics-120.golden @@ -0,0 +1,30 @@ + Plural Diagnostics ✓ local context ready + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ Local context ──────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Plural App ✓ OK alex@acme.io │ + │ Console ✓ OK https://console.acme.io │ + │ Workspace ✓ OK /work/path/to/a/very/long/workspace │ + │ Kubernetes ✓ OK plural-platform-prod │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + ╭─ › Checks ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ! WARN workspace owner does not match the active identity │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + r refresh · esc back · ctrl+c quit diff --git a/tui/screens/diagnostics/testdata/diagnostics-80.golden b/tui/screens/diagnostics/testdata/diagnostics-80.golden new file mode 100644 index 000000000..d07a9a5a5 --- /dev/null +++ b/tui/screens/diagnostics/testdata/diagnostics-80.golden @@ -0,0 +1,24 @@ + Plural Diagnostics ✓ local context ready + ──────────────────────────────────────────────────────────────────────────── + + ╭─ Local context ──────────────────────────────────────────────────────────╮ + │ Plural App ✓ OK alex@acme.io │ + │ Console ✓ OK https://console.acme.io │ + │ Workspace ✓ OK /work/path/to/a/very/long/workspace │ + │ Kubernetes ✓ OK plural-platform-prod │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + ╭─ › Checks ───────────────────────────────────────────────────────────────╮ + │ ! WARN workspace owner does not match the active identity │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + r refresh · esc back · ctrl+c quit diff --git a/tui/screens/diagnostics/view.go b/tui/screens/diagnostics/view.go new file mode 100644 index 000000000..66116c5c2 --- /dev/null +++ b/tui/screens/diagnostics/view.go @@ -0,0 +1,65 @@ +package diagnostics + +import ( + "strings" + + "charm.land/lipgloss/v2" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + status := m.theme.Success.Render("✓ local context ready") + if m.loading { + status = m.theme.Warning.Render("◌ loading") + } + if m.err != nil { + status = m.theme.Danger.Render("✗ load failed") + } + + contextLines, checkLines := m.viewLines() + body := page.Panel(m.theme, "Local context", contextLines, contentWidth, 8, false) + "\n\n" + + page.Panel(m.theme, "Checks", checkLines, contentWidth, 5, len(m.snapshot.Diagnostics) > 0 || m.err != nil) + return page.Render(m.theme, width, height, "Diagnostics", status, body, "r refresh · esc back · ctrl+c quit") +} + +func (m Model) viewLines() ([]string, []string) { + if m.loading { + return []string{m.theme.Warning.Render("◌ Loading credential-free local context…")}, []string{m.theme.Muted.Render("Checks begin after local context loads.")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to read local context")}, []string{m.theme.Danger.Render("Error " + m.err.Error()), m.theme.Muted.Render("Press r to retry.")} + } + contextLines := []string{ + m.contextLine("Plural App", m.snapshot.App.Configured, m.snapshot.App.Email), + m.contextLine("Console", m.snapshot.Console.Configured, m.snapshot.Console.URL), + m.contextLine("Workspace", m.snapshot.Workspace.Configured, m.snapshot.Workspace.Path), + m.contextLine("Kubernetes", m.snapshot.KubeContext != "", m.snapshot.KubeContext), + } + checks := []string{m.theme.Success.Render("✓ No local diagnostics reported")} + if len(m.snapshot.Diagnostics) > 0 { + checks = make([]string, 0, len(m.snapshot.Diagnostics)) + for _, diagnostic := range m.snapshot.Diagnostics { + checks = append(checks, m.theme.Warning.Render("! WARN")+" "+diagnostic) + } + } + return contextLines, checks +} + +func (m Model) contextLine(label string, configured bool, detail string) string { + status := m.theme.Warning.Render("○ NOT CONFIGURED") + if configured { + status = m.theme.Success.Render("✓ OK") + } + if detail == "" { + detail = "—" + } + label += strings.Repeat(" ", max(1, 12-len(label))) + status += strings.Repeat(" ", max(1, 16-lipgloss.Width(status))) + return label + " " + status + " " + detail +} diff --git a/tui/screens/welcome/model.go b/tui/screens/welcome/model.go new file mode 100644 index 000000000..8c0fa8d39 --- /dev/null +++ b/tui/screens/welcome/model.go @@ -0,0 +1,90 @@ +package welcome + +import ( + "context" + + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + + welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + "github.com/pluralsh/plural-cli/tui/components/commandbar" + pluralspinner "github.com/pluralsh/plural-cli/tui/components/spinner" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type loadedMsg struct{ snapshot welcomebridge.Snapshot } +type failedMsg struct{ err error } + +type Model struct { + ctx context.Context + loader welcomebridge.Loader + theme theme.Theme + spinner spinner.Model + command commandbar.Model + loading bool + snapshot welcomebridge.Snapshot + err error +} + +func New(ctx context.Context, loader welcomebridge.Loader, t theme.Theme) Model { + commands := []string{"access", "console", "diagnostics", "help", "profiles", "workspace"} + return Model{ + ctx: ctx, + loader: loader, + theme: t, + spinner: pluralspinner.New(t), + command: commandbar.New(t, commands), + loading: loader != nil, + } +} + +func (m Model) Init() tea.Cmd { + if !m.loading { + return nil + } + return tea.Batch(m.spinner.Tick, m.loadSnapshot) +} + +func (m Model) loadSnapshot() tea.Msg { + snapshot, err := m.loader.Load(m.ctx) + if err != nil { + return failedMsg{err: err} + } + return loadedMsg{snapshot: snapshot} +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case loadedMsg: + m.loading = false + m.snapshot = msg.snapshot + m.err = nil + return m, nil + case failedMsg: + m.loading = false + m.err = msg.err + return m, nil + case spinner.TickMsg: + if !m.loading { + return m, nil + } + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case commandbar.SubmittedMsg: + switch msg.Command { + case "access", "console", "profiles": + return m, navigation.Navigate(navigation.Access) + case "diagnostics", "workspace": + return m, navigation.Navigate(navigation.Diagnostics) + } + return m, nil + default: + var cmd tea.Cmd + m.command, cmd = m.command.Update(msg) + return m, cmd + } +} + +func (m Model) Snapshot() welcomebridge.Snapshot { return m.snapshot } diff --git a/tui/screens/welcome/model_test.go b/tui/screens/welcome/model_test.go new file mode 100644 index 000000000..714e2e9ca --- /dev/null +++ b/tui/screens/welcome/model_test.go @@ -0,0 +1,323 @@ +package welcome + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + bridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" + "github.com/pluralsh/plural-cli/tui/assets" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +func TestReadOnlyWelcomeGoldens(t *testing.T) { + snapshot := bridge.Snapshot{ + Version: "v0.13.0", + App: bridge.AppProfile{ + Configured: true, Name: "personal", Email: "alex@acme.io", + Endpoint: "https://app.plural.sh", SavedProfiles: 2, + }, + Console: bridge.ConsoleConnection{Configured: true, URL: "https://console.acme.io"}, + Workspace: bridge.Workspace{ + Configured: true, Path: "/work/path/to/a/very/long/workspace", Name: "plrl-dev-aws", + Project: "acme", Provider: "aws", Region: "eu-west-1", Owner: "sebastian@plural.sh", + }, + KubeContext: "plural-platform-prod", + } + + for _, width := range []int{80, 120} { + t.Run(strconv.Itoa(width), func(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(loadedMsg{snapshot: snapshot}) + height := 24 + if width == 120 { + height = 30 + } + got := normalizeView(model.View(width, height)) + golden := filepath.Join("testdata", "welcome-"+strconv.Itoa(width)+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + if strings.Contains(got, "secret-token") { + t.Fatal("welcome view exposed a credential") + } + }) + } +} + +func TestWelcomeCommandPopupGolden(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(loadedMsg{snapshot: bridge.Snapshot{ + Version: "v0.13.0", + App: bridge.AppProfile{Configured: true, Name: "personal", Email: "alex@acme.io"}, + Console: bridge.ConsoleConnection{Configured: true, URL: "https://console.acme.io"}, + Workspace: bridge.Workspace{Configured: true, Path: "/work/plural", Name: "plrl-dev-aws", Provider: "aws", Region: "eu-west-1", Owner: "alex@acme.io"}, + }}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + got := normalizeView(model.View(80, 24)) + want, err := os.ReadFile(filepath.Join("testdata", "welcome-popup-80.golden")) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + if lines := strings.Split(got, "\n"); len(lines) != 24 { + t.Fatalf("popup view height = %d, want 24", len(lines)) + } +} + +func TestConsoleURLStaysOnOneHighlightedHyperlink(t *testing.T) { + consoleURL := "https://console.production.example.com/deployments/overview" + model := New(t.Context(), nil, theme.New(colorprofile.TrueColor)) + model, _ = model.Update(loadedMsg{snapshot: bridge.Snapshot{ + App: bridge.AppProfile{Configured: true, Email: "alex@example.com", Endpoint: "https://app.plural.sh"}, + Console: bridge.ConsoleConnection{Configured: true, URL: consoleURL}, + }}) + view := model.View(100, 30) + if !strings.Contains(view, "\x1b]8;;"+consoleURL) { + t.Fatalf("console URL is not an OSC-8 hyperlink:\n%q", view) + } + if !strings.Contains(ansi.Strip(view), consoleURL) { + t.Fatalf("console URL was wrapped or truncated:\n%s", ansi.Strip(view)) + } +} + +func TestWorkspacePathUsesEllipsisWhenRightPaneIsNarrow(t *testing.T) { + workspacePath := "/work/path/to/a/very/long/workspace/directory" + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(loadedMsg{snapshot: bridge.Snapshot{ + Workspace: bridge.Workspace{ + Configured: true, + Name: "plrl-dev-aws", + Path: workspacePath, + }, + }}) + + narrow := ansi.Strip(model.View(80, 24)) + if !strings.Contains(narrow, "/work/path/...") { + t.Fatalf("narrow workspace path has no ellipsis:\n%s", narrow) + } + if strings.Contains(narrow, workspacePath) { + t.Fatalf("narrow workspace path was not truncated:\n%s", narrow) + } + + wide := ansi.Strip(model.View(160, 30)) + if !strings.Contains(wide, workspacePath) { + t.Fatalf("wide workspace path did not use available space:\n%s", wide) + } +} + +func TestConnectionGroupsStackWhenURLsNeedTheWidth(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(loadedMsg{snapshot: bridge.Snapshot{ + App: bridge.AppProfile{Configured: true, Endpoint: "https://app.plural.sh"}, + Console: bridge.ConsoleConnection{ + Configured: true, + URL: "https://console.production.example.com/a/long/context/path", + }, + }}) + view := model.View(80, 30) + for _, line := range strings.Split(view, "\n") { + if strings.Contains(line, "Plural App account") && strings.Contains(line, "Console connection") { + t.Fatalf("connection groups did not stack:\n%s", view) + } + } +} + +func TestWelcomeLogoUsesStaticEmbeddedAsset(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.TrueColor)) + got := ansi.Strip(model.logo()) + want := strings.TrimSpace(assets.Logo) + if got != want { + t.Fatalf("welcome logo differs from embedded asset\nwant:\n%s\n\ngot:\n%s", want, got) + } + if lipgloss.Width(got) == 0 || lipgloss.Height(got) == 0 { + t.Fatal("welcome logo is empty") + } +} + +func TestHeroBorderUsesPrimaryColor(t *testing.T) { + theme := theme.New(colorprofile.TrueColor) + model := New(t.Context(), nil, theme) + border := lipgloss.NewStyle().Foreground(theme.Colors.Primary) + + wide := model.renderHero(80, "dev") + if !strings.HasPrefix(wide, border.Render("╭─ ")) { + t.Fatalf("wide hero top border does not use the primary color: %q", wide) + } + bottom := strings.Split(wide, "\n")[8] + if !strings.HasPrefix(bottom, border.Render("╰─ ")) || !strings.Contains(bottom, theme.Muted.Render("dev")) { + t.Fatalf("wide hero bottom border does not use the primary color: %q", wide) + } + +} + +func TestStatusAdaptsToTerminalColorCapability(t *testing.T) { + plain := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + if got := plain.status(true); got != "✓" { + t.Fatalf("plain success status = %q, want tick", got) + } + if got := plain.status(false); got != "✗" { + t.Fatalf("plain failure status = %q, want cross", got) + } + + color := New(t.Context(), nil, theme.New(colorprofile.TrueColor)) + if got := ansi.Strip(color.status(true)); got != "●" { + t.Fatalf("color success status = %q, want dot", got) + } + if got := ansi.Strip(color.status(false)); got != "●" { + t.Fatalf("color failure status = %q, want dot", got) + } +} + +func TestWelcomeForwardsInputToCommandBar(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + if got := model.command.CurrentSuggestion(); got != "diagnostics" { + t.Fatalf("suggestion = %q, want diagnostics", got) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + if got := model.command.Value(); got != "diagnostics" { + t.Fatalf("completed input = %q, want diagnostics", got) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("selecting a command did not emit submission") + } + model, routeCmd := model.Update(cmd()) + if routeCmd == nil { + t.Fatal("submitted command did not route") + } + if got := routeCmd().(navigation.NavigateMsg).Route; got != navigation.Diagnostics { + t.Fatalf("route = %q, want diagnostics", got) + } + if got := model.command.Selected(); got != "diagnostics" { + t.Fatalf("selected command = %q, want diagnostics", got) + } +} + +func TestCommandInputIsAnchoredAtBottom(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + lines := strings.Split(normalizeView(model.View(80, 24)), "\n") + if len(lines) != 24 { + t.Fatalf("view height = %d, want 24", len(lines)) + } + if !strings.Contains(lines[len(lines)-4], "Command") { + t.Fatalf("command bar is not anchored above help:\n%s", strings.Join(lines, "\n")) + } + if !strings.Contains(lines[len(lines)-1], "ctrl+c quit") { + t.Fatalf("keymap is not at the bottom: %q", lines[len(lines)-1]) + } +} + +func TestWideLayoutStretchesRightPaneAndStaysLeftAligned(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(loadedMsg{snapshot: bridge.Snapshot{ + App: bridge.AppProfile{Configured: true, Name: "personal", Email: "alex@example.com"}, + Console: bridge.ConsoleConnection{Configured: true, URL: "https://console.example.com"}, + Workspace: bridge.Workspace{Configured: true, Name: "platform"}, + }}) + + dividerColumn := -1 + for _, width := range []int{80, 160} { + lines := strings.Split(normalizeView(model.View(width, 30)), "\n") + top := []rune(lines[0]) + if len(top) != width-2 { + t.Fatalf("hero line width at %d columns = %d, want %d", width, len(top), width-2) + } + if len(top) < 3 || top[0] != ' ' || top[1] != ' ' || top[2] != '╭' { + t.Fatalf("hero is not anchored at the two-cell left gutter: %q", lines[0]) + } + + body := []rune(lines[1]) + column := -1 + seenOuterBorder := false + for i, r := range body { + if r != '│' { + continue + } + if seenOuterBorder { + column = i + break + } + seenOuterBorder = true + } + if dividerColumn == -1 { + dividerColumn = column + } else if column != dividerColumn { + t.Fatalf("logo rail divider moved from column %d to %d", dividerColumn, column) + } + } +} + +func TestWelcomeRejectsUnsupportedTerminalSize(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + + for _, size := range []struct { + width int + height int + }{ + {width: 79, height: 24}, + {width: 80, height: 23}, + {width: 54, height: 12}, + {width: 80, height: 2}, + } { + view := ansi.Strip(model.View(size.width, size.height)) + if !strings.Contains(view, model.dimensions(size.width, size.height)) { + t.Fatalf("unsupported view does not show detected size %dx%d:\n%s", size.width, size.height, view) + } + if !strings.Contains(view, model.dimensions(minimumWidth, minimumHeight)) { + t.Fatalf("unsupported view does not show minimum size:\n%s", view) + } + if strings.Contains(view, "App not connected") { + t.Fatalf("unsupported terminal rendered the welcome hero:\n%s", view) + } + lines := strings.Split(view, "\n") + if len(lines) != size.height { + t.Fatalf("unsupported view height = %d, want %d", len(lines), size.height) + } + for _, line := range lines { + if got := lipgloss.Width(line); got > size.width { + t.Fatalf("unsupported view line width %d exceeds %d: %q", got, size.width, line) + } + } + } +} + +func TestWelcomeNeverExceedsSupportedTerminalWidth(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(loadedMsg{snapshot: bridge.Snapshot{ + App: bridge.AppProfile{Configured: true, Email: "alex@example.com", Endpoint: "https://app.plural.sh"}, + Console: bridge.ConsoleConnection{ + Configured: true, + URL: "https://console.production.example.com/a/long/context/path", + }, + }}) + for _, width := range []int{80, 100, 120, 160} { + for _, line := range strings.Split(model.View(width, 30), "\n") { + if got := lipgloss.Width(line); got > width { + t.Fatalf("line width %d exceeds terminal width %d: %q", got, width, ansi.Strip(line)) + } + } + } +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/welcome/testdata/welcome-120.golden b/tui/screens/welcome/testdata/welcome-120.golden new file mode 100644 index 000000000..4a13a1660 --- /dev/null +++ b/tui/screens/welcome/testdata/welcome-120.golden @@ -0,0 +1,30 @@ + ╭─ Plural ───────────────────────────────────────────────────────────────────────────── ✓ personal · alex@acme.io ─╮ + │ │ ✓ Console │ + │ ███████ ██ │ URL https://console.acme.io │ + │ ██ ██ │ ─────────────────────────────────────────────────────────────────────────────────────────── │ + │ ██ ██ ██ │ ✓ Workspace · plrl-dev-aws · /work/path/to/a/very/long/workspace │ + │ ██ ██ │ Provider aws · eu-west-1 │ + │ ██ ███████ │ Owner sebastian@plural.sh │ + │ │ │ + ╰─ v0.13.0 ────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + + + + + + ╭─ Command ────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ › Search commands… │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + tab complete · ↑/↓ suggestions · enter select · esc clear · ctrl+c quit diff --git a/tui/screens/welcome/testdata/welcome-80.golden b/tui/screens/welcome/testdata/welcome-80.golden new file mode 100644 index 000000000..62d680d42 --- /dev/null +++ b/tui/screens/welcome/testdata/welcome-80.golden @@ -0,0 +1,24 @@ + ╭─ Plural ───────────────────────────────────── ✓ personal · alex@acme.io ─╮ + │ │ ✓ Console │ + │ ███████ ██ │ URL https://console.acme.io │ + │ ██ ██ │ ─────────────────────────────────────────────────── │ + │ ██ ██ ██ │ ✓ Workspace · plrl-dev-aws · /work/path/... │ + │ ██ ██ │ Provider aws · eu-west-1 │ + │ ██ ███████ │ Owner sebastian@plural.sh │ + │ │ │ + ╰─ v0.13.0 ────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + ╭─ Command ────────────────────────────────────────────────────────────────╮ + │ › Search commands… │ + ╰──────────────────────────────────────────────────────────────────────────╯ + tab complete · ↑/↓ suggestions · enter select · esc clear · ctrl+c quit diff --git a/tui/screens/welcome/testdata/welcome-popup-80.golden b/tui/screens/welcome/testdata/welcome-popup-80.golden new file mode 100644 index 000000000..77665134c --- /dev/null +++ b/tui/screens/welcome/testdata/welcome-popup-80.golden @@ -0,0 +1,24 @@ + ╭─ Plural ───────────────────────────────────── ✓ personal · alex@acme.io ─╮ + │ │ ✓ Console │ + │ ███████ ██ │ URL https://console.acme.io │ + │ ██ ██ │ ─────────────────────────────────────────────────── │ + │ ██ ██ ██ │ ✓ Workspace · plrl-dev-aws · /work/plural │ + │ ██ ██ │ Provider aws · eu-west-1 │ + │ ██ ███████ │ Owner alex@acme.io │ + │ │ │ + ╰─ v0.13.0 ────────────────────────────────────────────────────────────────╯ + + + + ╭─ Available commands ───────────────╮ + │ › access │ + │ console │ + │ diagnostics │ + │ help │ + │ profiles │ + │ workspace │ + ╰────────────────────────────────────╯ + ╭─ Command ────────────────────────────────────────────────────────────────╮ + │ › Search commands… │ + ╰──────────────────────────────────────────────────────────────────────────╯ + ↑/↓ choose · enter open · esc close · type to filter · ctrl+c quit diff --git a/tui/screens/welcome/view.go b/tui/screens/welcome/view.go new file mode 100644 index 000000000..9d005b057 --- /dev/null +++ b/tui/screens/welcome/view.go @@ -0,0 +1,273 @@ +package welcome + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/tui/assets" +) + +const ( + defaultWidth = 100 + defaultHeight = 24 + minimumWidth = 80 + minimumHeight = 24 + sideMargin = 2 + minimumVerticalGap = 1 + defaultVerticalGap = 2 + heroDetailRows = 7 + logoRailWidth = 20 + logoIndent = 4 +) + +func (m Model) View(width, height int) string { + width, height = m.viewportSize(width, height) + if width < minimumWidth || height < minimumHeight { + return m.renderUnsupportedTerminal(width, height) + } + + contentWidth := width - 2*sideMargin + version := lo.CoalesceOrEmpty(m.snapshot.Version, "dev") + header := m.renderHero(contentWidth, version) + command := m.command.View(contentWidth) + gap := m.verticalGap(height, header, command) + + return m.indent(header+strings.Repeat("\n", gap)+command, sideMargin) +} + +func (m Model) viewportSize(width, height int) (int, int) { + if width <= 0 { + width = defaultWidth + } + + if height <= 0 { + height = defaultHeight + } + + return width, height +} + +func (m Model) verticalGap(height int, blocks ...string) int { + if height <= 0 { + return defaultVerticalGap + } + + occupied := 0 + for _, block := range blocks { + occupied += lipgloss.Height(block) + } + + // Joining two blocks with newlines consumes one fewer row than summing + // their individual heights. + return max(minimumVerticalGap, height-occupied+len(blocks)-1) +} + +func (m Model) indent(content string, width int) string { + padding := strings.Repeat(" ", width) + return padding + strings.ReplaceAll(content, "\n", "\n"+padding) +} + +func (m Model) renderUnsupportedTerminal(width, height int) string { + detected := "Unsupported terminal size: " + m.dimensions(width, height) + required := "Minimum supported size: " + m.dimensions(minimumWidth, minimumHeight) + + if height < 4 { + message := "Unsupported " + m.dimensions(width, height) + " · minimum " + m.dimensions(minimumWidth, minimumHeight) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, ansi.Truncate(message, width, "…")) + } + + content := strings.Join([]string{ + m.theme.Title.Render(ansi.Truncate("Plural", width, "…")), + "", + m.theme.Body.Render(ansi.Truncate(detected, width, "…")), + m.theme.Muted.Render(ansi.Truncate(required, width, "…")), + }, "\n") + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content) +} + +func (m Model) dimensions(width, height int) string { return fmt.Sprintf("%d×%d", width, height) } + +// renderHero draws the full welcome treatment. The logo rail remains fixed +// while the connection details consume all additional width. +func (m Model) renderHero(width int, version string) string { + border := m.primaryBorder() + top := m.renderHeroTop(width, border) + rightWidth := width - 2 - logoRailWidth - 1 + rightLines := m.heroDetails(rightWidth - 2) + leftLines := m.logoRail(len(rightLines)) + + rows := make([]string, 0, len(rightLines)+2) + rows = append(rows, top) + for i, rightLine := range rightLines { + left := m.fit(leftLines[i], logoRailWidth) + right := m.fit(rightLine, rightWidth-2) + rows = append(rows, border.Render("│")+left+border.Render("│")+" "+right+" "+border.Render("│")) + } + + rows = append(rows, m.renderVersionFooter(width, version, border, m.theme.Muted)) + return strings.Join(rows, "\n") +} + +func (m Model) renderHeroTop(width int, border lipgloss.Style) string { + const trailingRuleWidth = 1 + + title := m.theme.Title.Render("Plural") + titleRail := "─ " + title + " " + identityWidth := min(44, max(16, width-lipgloss.Width(titleRail)-trailingRuleWidth-7)) + identity := m.heroIdentity(identityWidth) + identityRail := " " + identity + " " + strings.Repeat("─", trailingRuleWidth) + middleRuleWidth := max(1, width-2-lipgloss.Width(titleRail)-lipgloss.Width(identityRail)) + + return border.Render("╭─ ") + title + border.Render(" "+strings.Repeat("─", middleRuleWidth)) + + " " + identity + " " + border.Render(strings.Repeat("─", trailingRuleWidth)+"╮") +} + +func (m Model) renderVersionFooter(width int, version string, border, versionStyle lipgloss.Style) string { + ruleWidth := max(1, width-lipgloss.Width(version)-5) + return border.Render("╰─ ") + versionStyle.Render(version) + + border.Render(" "+strings.Repeat("─", ruleWidth)+"╯") +} + +func (m Model) logoRail(rowCount int) []string { + rows := make([]string, rowCount) + for i, line := range strings.Split(strings.TrimSpace(assets.Logo), "\n") { + row := i + 1 + if row >= rowCount { + break + } + rows[row] = strings.Repeat(" ", logoIndent) + m.theme.Logo.Render(line) + } + return rows +} + +func (m Model) heroIdentity(maxWidth int) string { + if !m.snapshot.App.Configured { + return m.status(false) + " App not connected" + } + profile := lo.CoalesceOrEmpty(m.snapshot.App.Name, "default") + email := lo.CoalesceOrEmpty(m.snapshot.App.Email, m.snapshot.App.Name, "saved account") + display := ansi.Truncate(profile+" · "+email, max(1, maxWidth-2), "…") + return m.status(true) + " " + display +} + +func (m Model) heroConsole() string { + if !m.snapshot.Console.Configured { + return m.status(false) + " Console not connected" + } + return m.status(true) + m.theme.Title.Render(" Console") +} + +func (m Model) heroDetails(maxWidth int) []string { + if m.loading { + return m.padLines([]string{ + m.theme.Body.Render("Local context"), + m.spinner.View() + " " + m.theme.Muted.Render("Loading…"), + }, heroDetailRows) + } + if m.err != nil { + return m.padLines([]string{ + m.theme.Body.Render("Local context"), + m.theme.Danger.Render(m.err.Error()), + }, heroDetailRows) + } + + lines := []string{ + m.heroConsole(), + m.heroConsoleURL(maxWidth), + m.theme.Title.Render(strings.Repeat("─", max(1, maxWidth))), + } + lines = append(lines, m.workspaceDetails(maxWidth)...) + return m.padLines(lines, heroDetailRows) +} + +func (m Model) workspaceDetails(maxWidth int) []string { + workspace := m.snapshot.Workspace + if !workspace.Configured { + return []string{m.status(false) + " No workspace detected"} + } + + name := lo.CoalesceOrEmpty(workspace.Name, m.filepathBase(workspace.Path)) + prefix := m.status(true) + m.theme.Title.Render(" Workspace ") + "· " + name + " · " + pathWidth := max(1, maxWidth-lipgloss.Width(prefix)) + if maxWidth < 60 { + pathWidth = min(pathWidth, 14) + } + provider := lo.CoalesceOrEmpty(strings.Join(lo.Compact([]string{workspace.Provider, workspace.Region}), " · "), "—") + + return []string{ + prefix + m.theme.Muted.Render(ansi.Truncate(workspace.Path, pathWidth, "...")), + "Provider " + provider, + "Owner " + lo.CoalesceOrEmpty(workspace.Owner, "—"), + "", + } +} + +func (m Model) heroConsoleURL(maxWidth int) string { + if !m.snapshot.Console.Configured { + return "" + } + prefix := "URL " + if lipgloss.Width(prefix)+lipgloss.Width(m.snapshot.Console.URL) > maxWidth { + prefix = "URL " + } + return prefix + m.url(m.snapshot.Console.URL, max(1, maxWidth-lipgloss.Width(prefix))) +} + +func (m Model) status(ok bool) string { + if m.theme.Color { + if ok { + return m.theme.Success.Render("●") + } + return m.theme.Danger.Render("●") + } + if ok { + return "✓" + } + return "✗" +} + +func (m Model) url(target string, maxWidth int) string { + if target == "" { + return m.theme.Muted.Render("unknown") + } + display := ansi.Truncate(target, max(1, maxWidth), "…") + style := m.theme.Link.Inline(true) + if m.theme.Hyperlinks { + style = style.Hyperlink(target) + } + return style.Render(display) +} + +func (m Model) primaryBorder() lipgloss.Style { + return lipgloss.NewStyle().Foreground(m.theme.Colors.Primary) +} + +// logo is deliberately static. Animation is reserved for the compact spinner +// used while work is in progress, never for the welcome-screen brand mark. +func (m Model) logo() string { + return m.theme.Logo.Render(strings.TrimSpace(assets.Logo)) +} + +func (m Model) fit(value string, width int) string { + value = ansi.Truncate(value, max(1, width), "…") + return value + strings.Repeat(" ", max(0, width-lipgloss.Width(value))) +} + +func (m Model) padLines(lines []string, count int) []string { + if len(lines) >= count { + return lines + } + return append(lines, make([]string, count-len(lines))...) +} + +func (m Model) filepathBase(path string) string { + path = strings.TrimRight(path, "/\\") + if i := strings.LastIndexAny(path, "/\\"); i >= 0 { + return path[i+1:] + } + return path +} diff --git a/tui/theme/testdata/ansi16.golden b/tui/theme/testdata/ansi16.golden new file mode 100644 index 000000000..5e949b55b --- /dev/null +++ b/tui/theme/testdata/ansi16.golden @@ -0,0 +1 @@ +"\x1b[1;94mPlural\x1b[m\n\x1b[97mterminal operations\x1b[m\n\x1b[37mmuted\x1b[m\n\x1b[92msuccess\x1b[m\n\x1b[93mwarning\x1b[m\n\x1b[91mdanger\x1b[m" diff --git a/tui/theme/testdata/ansi256.golden b/tui/theme/testdata/ansi256.golden new file mode 100644 index 000000000..112551f03 --- /dev/null +++ b/tui/theme/testdata/ansi256.golden @@ -0,0 +1 @@ +"\x1b[1;38;5;105mPlural\x1b[m\n\x1b[38;5;255mterminal operations\x1b[m\n\x1b[38;5;248mmuted\x1b[m\n\x1b[38;5;85msuccess\x1b[m\n\x1b[38;5;228mwarning\x1b[m\n\x1b[38;5;210mdanger\x1b[m" diff --git a/tui/theme/testdata/no-color.golden b/tui/theme/testdata/no-color.golden new file mode 100644 index 000000000..05dca2f3a --- /dev/null +++ b/tui/theme/testdata/no-color.golden @@ -0,0 +1 @@ +"Plural\nterminal operations\nmuted\nsuccess\nwarning\ndanger" diff --git a/tui/theme/testdata/truecolor.golden b/tui/theme/testdata/truecolor.golden new file mode 100644 index 000000000..59327b626 --- /dev/null +++ b/tui/theme/testdata/truecolor.golden @@ -0,0 +1 @@ +"\x1b[1;38;2;116;122;246mPlural\x1b[m\n\x1b[38;2;238;240;241mterminal operations\x1b[m\n\x1b[38;2;161;165;176mmuted\x1b[m\n\x1b[38;2;60;236;175msuccess\x1b[m\n\x1b[38;2;255;244;143mwarning\x1b[m\n\x1b[38;2;242;120;141mdanger\x1b[m" diff --git a/tui/theme/theme.go b/tui/theme/theme.go new file mode 100644 index 000000000..58651c225 --- /dev/null +++ b/tui/theme/theme.go @@ -0,0 +1,98 @@ +// Package theme owns the semantic terminal palette used by TUI screens. Raw +// Console colors stop here so screens can describe intent instead of styling. +package theme + +import ( + "image/color" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" +) + +// Colors is the small semantic subset needed by the shell. It is derived from +// Console's dark semantic palette and Cloud Shell accents. +type Colors struct { + Background color.Color + Surface color.Color + Border color.Color + Text color.Color + Muted color.Color + Primary color.Color + Info color.Color + Success color.Color + Warning color.Color + Danger color.Color +} + +// Theme bundles semantic colors and reusable shell styles. +type Theme struct { + Colors Colors + Logo lipgloss.Style + Title lipgloss.Style + Body lipgloss.Style + Muted lipgloss.Style + Success lipgloss.Style + Warning lipgloss.Style + Danger lipgloss.Style + Link lipgloss.Style + Color bool + Hyperlinks bool +} + +// New down-samples the Console palette to the terminal's supported profile. +// ASCII is also the explicit NO_COLOR representation. +func New(profile colorprofile.Profile) Theme { + resolve := func(hex string) color.Color { + if profile <= colorprofile.ASCII { + return lipgloss.NoColor{} + } + return profile.Convert(lipgloss.Color(hex)) + } + + colors := Colors{ + Background: resolve("#12151B"), // fill-zero + Surface: resolve("#1B1F27"), // fill-one + Border: resolve("#252932"), // border + Text: resolve("#EEF0F1"), // text + Muted: resolve("#A1A5B0"), // text-xlight + Primary: resolve("#747AF6"), // icon-primary + Info: resolve("#99DAFF"), // semanticBlue + Success: resolve("#3CECAF"), // cloud-shell-green + Warning: resolve("#FFF48F"), // cloud-shell-dark-yellow + Danger: resolve("#F2788D"), // cloud-shell-dark-red + } + + title := lipgloss.NewStyle().Foreground(colors.Primary) + link := lipgloss.NewStyle().Foreground(colors.Info) + if profile > colorprofile.ASCII { + title = title.Bold(true) + link = link.Underline(true) + } + + return Theme{ + Colors: colors, + Title: title, + Logo: lipgloss.NewStyle().Foreground(colors.Text), + Body: lipgloss.NewStyle().Foreground(colors.Text), + Muted: lipgloss.NewStyle().Foreground(colors.Muted), + Success: lipgloss.NewStyle().Foreground(colors.Success), + Warning: lipgloss.NewStyle().Foreground(colors.Warning), + Danger: lipgloss.NewStyle().Foreground(colors.Danger), + Link: link, + Color: profile > colorprofile.ASCII, + Hyperlinks: profile > colorprofile.ASCII, + } +} + +// Sample renders a stable palette specimen used by golden tests. +func (t Theme) Sample() string { + return strings.Join([]string{ + t.Title.Render("Plural"), + t.Body.Render("terminal operations"), + t.Muted.Render("muted"), + t.Success.Render("success"), + t.Warning.Render("warning"), + t.Danger.Render("danger"), + }, "\n") +} diff --git a/tui/theme/theme_test.go b/tui/theme/theme_test.go new file mode 100644 index 000000000..7777005c8 --- /dev/null +++ b/tui/theme/theme_test.go @@ -0,0 +1,37 @@ +package theme + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/charmbracelet/colorprofile" +) + +func TestThemeGoldens(t *testing.T) { + tests := []struct { + name string + profile colorprofile.Profile + }{ + {name: "truecolor", profile: colorprofile.TrueColor}, + {name: "ansi256", profile: colorprofile.ANSI256}, + {name: "ansi16", profile: colorprofile.ANSI}, + {name: "no-color", profile: colorprofile.ASCII}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := strconv.Quote(New(tt.profile).Sample()) + path := filepath.Join("testdata", tt.name+".golden") + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read golden: %v\nactual: %q", err, got) + } + if got != strings.TrimSpace(string(want)) { + t.Fatalf("theme output differs from %s\nwant: %q\n got: %q", path, want, got) + } + }) + } +} From e93d66fcdf433b26b54f3126a2f21a411d54b092 Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Wed, 29 Jul 2026 11:27:36 +0200 Subject: [PATCH 02/11] add services --- cmd/command/tui/tui.go | 8 +- pkg/bridge/access/access.go | 37 ++ pkg/bridge/access/access_test.go | 32 ++ pkg/bridge/access/local.go | 42 ++ pkg/bridge/access/local_test.go | 27 ++ pkg/bridge/services/errors.go | 10 + pkg/bridge/services/services.go | 303 +++++++++++++ pkg/bridge/services/services_test.go | 118 +++++ tui/app/model.go | 13 +- tui/app/model_test.go | 5 + tui/app/view.go | 2 + tui/components/commandbar/commandbar.go | 2 +- tui/navigation/navigation.go | 1 + tui/screens/access/model_test.go | 3 + tui/screens/services/golden_test.go | 149 +++++++ tui/screens/services/model.go | 411 ++++++++++++++++++ tui/screens/services/model_test.go | 123 ++++++ .../testdata/services-clusters-120.golden | 30 ++ .../testdata/services-clusters-80.golden | 24 + .../testdata/services-detail-120.golden | 30 ++ .../testdata/services-detail-80.golden | 24 + .../testdata/services-list-120.golden | 30 ++ .../services/testdata/services-list-80.golden | 24 + tui/screens/services/view.go | 259 +++++++++++ tui/screens/welcome/model.go | 4 +- .../welcome/testdata/welcome-popup-80.golden | 2 +- 26 files changed, 1707 insertions(+), 6 deletions(-) create mode 100644 pkg/bridge/services/errors.go create mode 100644 pkg/bridge/services/services.go create mode 100644 pkg/bridge/services/services_test.go create mode 100644 tui/screens/services/golden_test.go create mode 100644 tui/screens/services/model.go create mode 100644 tui/screens/services/model_test.go create mode 100644 tui/screens/services/testdata/services-clusters-120.golden create mode 100644 tui/screens/services/testdata/services-clusters-80.golden create mode 100644 tui/screens/services/testdata/services-detail-120.golden create mode 100644 tui/screens/services/testdata/services-detail-80.golden create mode 100644 tui/screens/services/testdata/services-list-120.golden create mode 100644 tui/screens/services/testdata/services-list-80.golden create mode 100644 tui/screens/services/view.go diff --git a/cmd/command/tui/tui.go b/cmd/command/tui/tui.go index 7aeb871ca..e9cffa658 100644 --- a/cmd/command/tui/tui.go +++ b/cmd/command/tui/tui.go @@ -8,6 +8,7 @@ import ( "github.com/pluralsh/plural-cli/pkg/bridge" accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" "github.com/pluralsh/plural-cli/pkg/common" tuiapp "github.com/pluralsh/plural-cli/tui/app" @@ -19,7 +20,12 @@ func Command() cli.Command { welcome := welcomebridge.NewService(welcomebridge.NewLocalSource(common.Version)) auth := bridge.NewAuthService(bridge.PluralAuthFactory{}, 0) access := accessbridge.NewLocalManager("", auth, nil) - return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{Welcome: welcome, Access: access}) + services := servicesbridge.NewService(access) + return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{ + Welcome: welcome, + Access: access, + Services: services, + }) }) } diff --git a/pkg/bridge/access/access.go b/pkg/bridge/access/access.go index afd939f40..935883a3d 100644 --- a/pkg/bridge/access/access.go +++ b/pkg/bridge/access/access.go @@ -10,6 +10,7 @@ import ( "sync" "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" ) type Profile = bridge.Profile @@ -55,6 +56,7 @@ type Manager interface { AddConsoleProfile(context.Context, string, string, string) (ConsoleProfile, error) ActivateProfile(context.Context, string) error ActivateConsole(context.Context, string) error + ActiveConsole(context.Context) (url, token string, err error) SearchServiceAccounts(context.Context, string) ([]ServiceAccount, error) Impersonate(context.Context, string) error StopImpersonating() @@ -189,6 +191,32 @@ func (s *Service) ActivateConsole(ctx context.Context, id string) error { return s.repository.Save(ctx, state) } +// ActiveConsole resolves the active Console URL and token for API clients. +// It prefers the Access registry, then falls back to legacy console.yml so +// existing plural cd login sessions continue to work. +func (s *Service) ActiveConsole(ctx context.Context) (url, token string, err error) { + if err := ctx.Err(); err != nil { + return "", "", err + } + state, err := s.repository.Load(ctx) + if err != nil { + return "", "", err + } + if profile, ok := findConsole(state.ConsoleProfiles, state.ActiveConsoleID); ok && s.credentials != nil { + token, err := s.credentials.Get(ctx, profile.ID) + if err == nil && strings.TrimSpace(token) != "" && strings.TrimSpace(profile.URL) != "" { + return profile.URL, token, nil + } + } + if url, token, ok := readLegacyConsole(); ok { + return url, token, nil + } + return "", "", &bridge.Error{ + Code: bridge.ErrorUnauthenticated, + Err: errors.New("connect a Console profile before browsing Console resources"), + } +} + func (s *Service) SearchServiceAccounts(ctx context.Context, query string) ([]ServiceAccount, error) { snapshot, err := s.Load(ctx) if err != nil || snapshot.Context.Base == nil { @@ -285,3 +313,12 @@ func stableID(parts ...string) string { } return fmt.Sprintf("%s-%x", parts[0], hash) } + +// readLegacyConsole loads ~/.plural/console.yml for callers that have not +// migrated into the Access registry yet. Tests may replace it. +var readLegacyConsole = func() (url, token string, ok bool) { + conf := console.ReadConfig() + url = strings.TrimSpace(conf.Url) + token = strings.TrimSpace(conf.Token) + return url, token, url != "" && token != "" +} diff --git a/pkg/bridge/access/access_test.go b/pkg/bridge/access/access_test.go index 7415fc4c4..3290e5ceb 100644 --- a/pkg/bridge/access/access_test.go +++ b/pkg/bridge/access/access_test.go @@ -103,6 +103,38 @@ func TestAccessProfilesSwitchIndependentlyAndClearActingIdentity(t *testing.T) { } } +func TestActiveConsolePrefersRegistryThenLegacy(t *testing.T) { + repository := &memoryAccessRepository{state: State{ + ConsoleProfiles: []ConsoleProfile{{ID: "console-a", Name: "production", URL: "https://console.example.com"}}, + ActiveConsoleID: "console-a", + }} + credentials := &memoryCredentials{values: map[string]string{"console-a": "registry-token"}} + service := NewService(repository, credentials, nil, nil) + + url, token, err := service.ActiveConsole(t.Context()) + if err != nil { + t.Fatalf("ActiveConsole() error = %v", err) + } + if url != "https://console.example.com" || token != "registry-token" { + t.Fatalf("ActiveConsole() = %q, %q", url, token) + } + + empty := NewService(&memoryAccessRepository{}, &memoryCredentials{}, nil, nil) + original := readLegacyConsole + t.Cleanup(func() { readLegacyConsole = original }) + readLegacyConsole = func() (string, string, bool) { return "https://legacy.example.com", "legacy-token", true } + url, token, err = empty.ActiveConsole(t.Context()) + if err != nil || url != "https://legacy.example.com" || token != "legacy-token" { + t.Fatalf("legacy ActiveConsole() = %q, %q, %v", url, token, err) + } + + readLegacyConsole = func() (string, string, bool) { return "", "", false } + _, _, err = empty.ActiveConsole(t.Context()) + if !bridge.IsCode(err, bridge.ErrorUnauthenticated) { + t.Fatalf("missing console error = %v", err) + } +} + func TestCompleteDeviceLoginStoresBaseCredentialOutsideMetadata(t *testing.T) { repository := &memoryAccessRepository{} credentials := &memoryCredentials{} diff --git a/pkg/bridge/access/local.go b/pkg/bridge/access/local.go index 29cb2569a..019b39ad0 100644 --- a/pkg/bridge/access/local.go +++ b/pkg/bridge/access/local.go @@ -42,6 +42,15 @@ func (r *LocalRepository) Load(ctx context.Context) (State, error) { if err := yaml.Unmarshal(contents, &state); err != nil { return State{}, err } + state, changed, err := r.ensureLegacyConsole(ctx, state) + if err != nil { + return State{}, err + } + if changed { + if err := r.save(state); err != nil { + return State{}, err + } + } return state, nil } if !errors.Is(err, os.ErrNotExist) { @@ -176,6 +185,39 @@ func (r *LocalRepository) importLegacy(ctx context.Context) (State, error) { return state, nil } +// ensureLegacyConsole imports ~/.plural/console.yml when the Access registry +// has no usable Console profile yet (common after plural cd login while +// access.yml already existed from App login). +func (r *LocalRepository) ensureLegacyConsole(ctx context.Context, state State) (State, bool, error) { + if _, ok := findConsole(state.ConsoleProfiles, state.ActiveConsoleID); ok { + return state, false, nil + } + contents, err := os.ReadFile(filepath.Join(r.Dir, "console.yml")) + if errors.Is(err, os.ErrNotExist) { + if state.ActiveConsoleID != "" && len(state.ConsoleProfiles) == 0 { + state.ActiveConsoleID = "" + return state, true, nil + } + return state, false, nil + } + if err != nil { + return state, false, err + } + var legacy legacyConsoleConfig + if yaml.Unmarshal(contents, &legacy) != nil || legacy.Spec.URL == "" { + return state, false, nil + } + profile := ConsoleProfile{ID: stableID("console", "default", legacy.Spec.URL), Name: "default", URL: legacy.Spec.URL} + state.ConsoleProfiles = upsertConsole(state.ConsoleProfiles, profile) + state.ActiveConsoleID = profile.ID + if legacy.Spec.Token != "" && r.Credentials != nil { + if err := r.Credentials.Set(ctx, profile.ID, legacy.Spec.Token); err != nil { + return state, false, err + } + } + return state, true, nil +} + // NewLocalManager builds the production persistence stack. Callers can // still inject every boundary separately in tests or alternate frontends. func NewLocalManager(home string, auth *bridge.AuthService, serviceAccounts ServiceAccountSource) *Service { diff --git a/pkg/bridge/access/local_test.go b/pkg/bridge/access/local_test.go index 145349433..9ac9a5d9a 100644 --- a/pkg/bridge/access/local_test.go +++ b/pkg/bridge/access/local_test.go @@ -44,6 +44,33 @@ func TestLocalAccessRepositoryImportsLegacyProfilesOnce(t *testing.T) { } } +func TestLocalAccessRepositoryImportsLegacyConsoleIntoExistingRegistry(t *testing.T) { + home := t.TempDir() + dir := filepath.Join(home, ".plural") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, accessRegistryName), []byte("profiles: []\nconsoleProfiles: []\nactiveConsole: https://stale.example/gql\n"), 0600); err != nil { + t.Fatal(err) + } + legacy := "apiVersion: platform.plural.sh/v1alpha1\nkind: Console\nspec:\n url: https://console.example.com/gql\n token: console-secret\n" + if err := os.WriteFile(filepath.Join(dir, "console.yml"), []byte(legacy), 0600); err != nil { + t.Fatal(err) + } + credentials := &memoryCredentials{} + repository := NewLocalRepository(home, credentials) + state, err := repository.Load(t.Context()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if len(state.ConsoleProfiles) != 1 || state.ActiveConsoleID == "" || state.ActiveConsoleID == "https://stale.example/gql" { + t.Fatalf("state = %#v", state) + } + if credentials.values[state.ActiveConsoleID] != "console-secret" { + t.Fatalf("stored credential = %q", credentials.values[state.ActiveConsoleID]) + } +} + func containsSecret(value, secret string) bool { for i := 0; i+len(secret) <= len(value); i++ { if value[i:i+len(secret)] == secret { diff --git a/pkg/bridge/services/errors.go b/pkg/bridge/services/errors.go new file mode 100644 index 000000000..f748f17e6 --- /dev/null +++ b/pkg/bridge/services/errors.go @@ -0,0 +1,10 @@ +package services + +import "errors" + +var ( + errNoConsole = errors.New("connect a Console profile before browsing Console resources") + errMissingID = errors.New("service id is required") + errMissingCluster = errors.New("cluster id is required") + errMissingService = errors.New("service was not found") +) diff --git a/pkg/bridge/services/services.go b/pkg/bridge/services/services.go new file mode 100644 index 000000000..af64bf5a1 --- /dev/null +++ b/pkg/bridge/services/services.go @@ -0,0 +1,303 @@ +// Package services exposes read-only Console service list/get use cases to +// presentation layers without importing TUI code. +package services + +import ( + "context" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" +) + +const defaultPageSize int64 = 50 + +// Cluster is a credential-free Console cluster summary. +type Cluster struct { + ID string + Name string + Handle string +} + +// Summary is the credential-free list row for a service deployment. +type Summary struct { + ID string + Name string + Namespace string + Status string + GitRef string + GitFolder string +} + +// ServiceError is a redacted Console component/sync error. +type ServiceError struct { + Source string + Message string +} + +// Detail is the credential-free detail payload for a service deployment. +type Detail struct { + Summary + ClusterName string + ClusterHandle string + RevisionSHA string + RevisionRef string + Components int + Synced int + Errors []ServiceError +} + +// Page is one cursor page of service summaries for a single cluster. +type Page struct { + Items []Summary + EndCursor string + HasNext bool + TotalShown int +} + +// Loader is the narrow contract consumed by the Services screen. +type Loader interface { + ListClusters(ctx context.Context, query string) ([]Cluster, error) + List(ctx context.Context, clusterID string, after *string, query string) (Page, error) + Get(ctx context.Context, id string) (Detail, error) +} + +// ConsoleResolver supplies the active Console URL and token. +type ConsoleResolver interface { + ActiveConsole(ctx context.Context) (url, token string, err error) +} + +// API is the Console surface required by this package. +type API interface { + ListClusters() (*gqlclient.ListClusters, error) + ListClusterServices(clusterId, handle *string) ([]*gqlclient.ServiceDeploymentEdgeFragment, error) + GetClusterService(serviceId, serviceName, clusterName *string) (*gqlclient.ServiceDeploymentExtended, error) +} + +// ClientFactory builds a Console API for an authenticated endpoint. +type ClientFactory func(token, url string) (API, error) + +// Service implements Loader against Console GraphQL. +type Service struct { + resolve ConsoleResolver + newClient ClientFactory + pageSize int64 +} + +// NewService wires production Console credentials and client construction. +func NewService(resolve ConsoleResolver) *Service { + return &Service{ + resolve: resolve, + newClient: func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + }, + pageSize: defaultPageSize, + } +} + +func (s *Service) client(ctx context.Context) (API, error) { + if s.resolve == nil { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole} + } + url, token, err := s.resolve.ActiveConsole(ctx) + if err != nil { + return nil, err + } + factory := s.newClient + if factory == nil { + factory = func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + } + } + return factory(token, url) +} + +func (s *Service) ListClusters(ctx context.Context, query string) ([]Cluster, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + client, err := s.client(ctx) + if err != nil { + return nil, err + } + result, err := client.ListClusters() + if err != nil { + return nil, err + } + if result == nil || result.Clusters == nil { + return nil, nil + } + clusters := make([]Cluster, 0, len(result.Clusters.Edges)) + for _, edge := range result.Clusters.Edges { + if edge == nil || edge.Node == nil { + continue + } + cluster := Cluster{ID: edge.Node.ID, Name: edge.Node.Name} + if edge.Node.Handle != nil { + cluster.Handle = *edge.Node.Handle + } + if !matchesCluster(cluster, query) { + continue + } + clusters = append(clusters, cluster) + } + return clusters, nil +} + +func (s *Service) List(ctx context.Context, clusterID string, after *string, query string) (Page, error) { + if err := ctx.Err(); err != nil { + return Page{}, err + } + clusterID = strings.TrimSpace(clusterID) + if clusterID == "" { + return Page{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingCluster} + } + client, err := s.client(ctx) + if err != nil { + return Page{}, err + } + edges, err := client.ListClusterServices(&clusterID, nil) + if err != nil { + return Page{}, err + } + items := make([]Summary, 0, len(edges)) + for _, edge := range edges { + if edge == nil || edge.Node == nil { + continue + } + summary := summaryFromBase(edge.Node) + if !matchesQuery(summary, query) { + continue + } + items = append(items, summary) + } + return pageItems(items, after, s.pageSize), nil +} + +func (s *Service) Get(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + service, err := client.GetClusterService(&id, nil, nil) + if err != nil { + return Detail{}, err + } + if service == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingService} + } + return detailFromExtended(service), nil +} + +func pageItems(items []Summary, after *string, pageSize int64) Page { + if pageSize <= 0 { + pageSize = defaultPageSize + } + start := 0 + if after != nil && *after != "" { + for i, item := range items { + if item.ID == *after { + start = i + 1 + break + } + } + } + if start > len(items) { + start = len(items) + } + end := start + int(pageSize) + if end > len(items) { + end = len(items) + } + page := Page{Items: items[start:end], TotalShown: end - start, HasNext: end < len(items)} + if len(page.Items) > 0 { + page.EndCursor = page.Items[len(page.Items)-1].ID + } + return page +} + +func summaryFromBase(node *gqlclient.ServiceDeploymentBaseFragment) Summary { + summary := Summary{ + ID: node.ID, + Name: node.Name, + Namespace: node.Namespace, + Status: string(node.Status), + } + if node.Git != nil { + summary.GitRef = node.Git.Ref + summary.GitFolder = node.Git.Folder + } + return summary +} + +func detailFromExtended(service *gqlclient.ServiceDeploymentExtended) Detail { + detail := Detail{ + Summary: Summary{ + ID: service.ID, + Name: service.Name, + Namespace: service.Namespace, + Status: string(service.Status), + }, + Components: len(service.Components), + } + if service.Git != nil { + detail.GitRef = service.Git.Ref + detail.GitFolder = service.Git.Folder + } + if service.Cluster != nil { + detail.ClusterName = service.Cluster.Name + if service.Cluster.Handle != nil { + detail.ClusterHandle = *service.Cluster.Handle + } + } + if service.Revision != nil { + if service.Revision.Sha != nil { + detail.RevisionSHA = *service.Revision.Sha + } + if detail.RevisionSHA == "" { + detail.RevisionSHA = service.Revision.ID + } + if service.Revision.Git != nil { + detail.RevisionRef = service.Revision.Git.Ref + } + } + for _, component := range service.Components { + if component != nil && component.Synced { + detail.Synced++ + } + } + for _, item := range service.Errors { + if item == nil { + continue + } + detail.Errors = append(detail.Errors, ServiceError{Source: item.Source, Message: item.Message}) + } + return detail +} + +func matchesQuery(summary Summary, query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{summary.Name, summary.Namespace, summary.Status, summary.GitRef, summary.GitFolder}, " ")) + return strings.Contains(haystack, query) +} + +func matchesCluster(cluster Cluster, query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{cluster.Name, cluster.Handle, cluster.ID}, " ")) + return strings.Contains(haystack, query) +} diff --git a/pkg/bridge/services/services_test.go b/pkg/bridge/services/services_test.go new file mode 100644 index 000000000..29dc6bef6 --- /dev/null +++ b/pkg/bridge/services/services_test.go @@ -0,0 +1,118 @@ +package services + +import ( + "context" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeResolver struct { + url, token string + err error +} + +func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) { + return f.url, f.token, f.err +} + +type fakeAPI struct { + clusters *gqlclient.ListClusters + edges []*gqlclient.ServiceDeploymentEdgeFragment + listErr error + detail *gqlclient.ServiceDeploymentExtended + getErr error + clusterID string +} + +func (f *fakeAPI) ListClusters() (*gqlclient.ListClusters, error) { + return f.clusters, f.listErr +} +func (f *fakeAPI) ListClusterServices(clusterId, _ *string) ([]*gqlclient.ServiceDeploymentEdgeFragment, error) { + if clusterId != nil { + f.clusterID = *clusterId + } + return f.edges, f.listErr +} +func (f *fakeAPI) GetClusterService(*string, *string, *string) (*gqlclient.ServiceDeploymentExtended, error) { + return f.detail, f.getErr +} + +func TestListClustersAndScopedServices(t *testing.T) { + handle := "prod-eu" + api := &fakeAPI{ + clusters: &gqlclient.ListClusters{Clusters: &gqlclient.ListClusters_Clusters{Edges: []*gqlclient.ClusterEdgeFragment{ + {Node: &gqlclient.ClusterFragment{ID: "c1", Name: "production", Handle: &handle}}, + {Node: &gqlclient.ClusterFragment{ID: "c2", Name: "staging"}}, + }}}, + edges: []*gqlclient.ServiceDeploymentEdgeFragment{ + {Node: &gqlclient.ServiceDeploymentBaseFragment{ID: "1", Name: "api", Namespace: "default", Status: gqlclient.ServiceDeploymentStatusHealthy}}, + {Node: &gqlclient.ServiceDeploymentBaseFragment{ID: "2", Name: "worker", Namespace: "jobs", Status: gqlclient.ServiceDeploymentStatusFailed}}, + }, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + } + + clusters, err := service.ListClusters(t.Context(), "prod") + if err != nil || len(clusters) != 1 || clusters[0].Handle != "prod-eu" { + t.Fatalf("ListClusters() = %#v, %v", clusters, err) + } + + page, err := service.List(t.Context(), "c1", nil, "api") + if err != nil { + t.Fatalf("List() error = %v", err) + } + if api.clusterID != "c1" || len(page.Items) != 1 || page.Items[0].Name != "api" { + t.Fatalf("scoped page = %#v cluster=%q", page.Items, api.clusterID) + } +} + +func TestListRequiresCluster(t *testing.T) { + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return &fakeAPI{}, nil }, + } + _, err := service.List(t.Context(), "", nil, "") + if !bridge.IsCode(err, bridge.ErrorInvalid) { + t.Fatalf("List() error = %v", err) + } +} + +func TestGetMapsDetail(t *testing.T) { + handle := "prod-eu" + sha := "abc123" + api := &fakeAPI{detail: &gqlclient.ServiceDeploymentExtended{ + ID: "svc-1", Name: "api", Namespace: "default", Status: gqlclient.ServiceDeploymentStatusFailed, + Git: &gqlclient.GitRefFragment{Ref: "main", Folder: "services/api"}, + Cluster: &gqlclient.BaseClusterFragment{Name: "prod", Handle: &handle}, + Revision: &gqlclient.RevisionFragment{ID: "rev-1", Sha: &sha, Git: &gqlclient.RevisionFragment_Git{Ref: "main"}}, + Components: []*gqlclient.ServiceDeploymentExtended_Components{ + {Synced: true}, + {Synced: false}, + }, + Errors: []*gqlclient.ErrorFragment{{Source: "sync", Message: "rollout timed out"}}, + }} + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + } + detail, err := service.Get(t.Context(), "svc-1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if detail.ClusterHandle != "prod-eu" || detail.RevisionSHA != "abc123" || detail.Components != 2 || detail.Synced != 1 { + t.Fatalf("detail = %#v", detail) + } +} + +func TestMissingConsoleReturnsTypedError(t *testing.T) { + service := NewService(fakeResolver{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole}}) + _, err := service.ListClusters(t.Context(), "") + if !bridge.IsCode(err, bridge.ErrorUnauthenticated) { + t.Fatalf("ListClusters() error = %v", err) + } +} diff --git a/tui/app/model.go b/tui/app/model.go index 2ec377f55..a690ef772 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -8,18 +8,21 @@ import ( tea "charm.land/bubbletea/v2" accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" "github.com/pluralsh/plural-cli/tui/navigation" accessscreen "github.com/pluralsh/plural-cli/tui/screens/access" diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics" + servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" welcomescreen "github.com/pluralsh/plural-cli/tui/screens/welcome" "github.com/pluralsh/plural-cli/tui/theme" ) // Dependencies contains the services required by TUI screens. type Dependencies struct { - Welcome welcomebridge.Loader - Access accessbridge.Manager + Welcome welcomebridge.Loader + Access accessbridge.Manager + Services servicesbridge.Loader } // Model is the root TUI model. It owns global input and delegates screen state @@ -34,6 +37,7 @@ type Model struct { welcome welcomescreen.Model access accessscreen.Model diagnostics diagnosticsscreen.Model + services servicesscreen.Model route navigation.Route } @@ -44,6 +48,7 @@ func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { welcome: welcomescreen.New(ctx, dependencies.Welcome, t), access: accessscreen.New(ctx, dependencies.Access, t), diagnostics: diagnosticsscreen.New(ctx, dependencies.Welcome, t), + services: servicesscreen.New(ctx, dependencies.Services, t), route: navigation.Welcome, quit: key.NewBinding( key.WithKeys("ctrl+c"), @@ -63,6 +68,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.access.Init() case navigation.Diagnostics: return m, m.diagnostics.Init() + case navigation.Services: + return m, m.services.Init() default: return m, m.welcome.Init() } @@ -86,6 +93,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.access, cmd = m.access.Update(msg) case navigation.Diagnostics: m.diagnostics, cmd = m.diagnostics.Update(msg) + case navigation.Services: + m.services, cmd = m.services.Update(msg) default: m.welcome, cmd = m.welcome.Update(msg) } diff --git a/tui/app/model_test.go b/tui/app/model_test.go index 365edcf26..f9f32d9ad 100644 --- a/tui/app/model_test.go +++ b/tui/app/model_test.go @@ -45,6 +45,11 @@ func TestModelRoutesScreensWithoutRebuildingShell(t *testing.T) { if routed.route != navigation.Diagnostics || !strings.Contains(routed.View().Content, "Diagnostics") { t.Fatalf("route/view = %q\n%s", routed.route, routed.View().Content) } + updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Services}) + routed = updated.(Model) + if routed.route != navigation.Services || !strings.Contains(routed.View().Content, "Services") { + t.Fatalf("services route/view = %q\n%s", routed.route, routed.View().Content) + } updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Welcome}) if got := updated.(Model).route; got != navigation.Welcome { t.Fatalf("route = %q", got) diff --git a/tui/app/view.go b/tui/app/view.go index ec87c4c8f..87b8bc3a5 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -15,6 +15,8 @@ func (m Model) View() tea.View { content = m.access.View(m.width, m.height) case navigation.Diagnostics: content = m.diagnostics.View(m.width, m.height) + case navigation.Services: + content = m.services.View(m.width, m.height) } view := tea.NewView(content) view.AltScreen = true diff --git a/tui/components/commandbar/commandbar.go b/tui/components/commandbar/commandbar.go index 5cd48c570..36d94b026 100644 --- a/tui/components/commandbar/commandbar.go +++ b/tui/components/commandbar/commandbar.go @@ -18,7 +18,7 @@ const ( minimumWidth = 12 title = "Command" popupTitle = "Available commands" - maximumPopupRows = 6 + maximumPopupRows = 8 ) type keyAction uint8 diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index 186ed074f..070df9281 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -12,6 +12,7 @@ const ( Welcome Route = "welcome" Access Route = "access" Diagnostics Route = "diagnostics" + Services Route = "services" ) // NavigateMsg requests a top-level route change. diff --git a/tui/screens/access/model_test.go b/tui/screens/access/model_test.go index 6ca922d07..b7c5f4328 100644 --- a/tui/screens/access/model_test.go +++ b/tui/screens/access/model_test.go @@ -37,6 +37,9 @@ func (f *fakeManager) ActivateConsole(_ context.Context, id string) error { f.activatedConsole = id return nil } +func (f *fakeManager) ActiveConsole(context.Context) (string, string, error) { + return "https://console.example.com", "token", nil +} func (f *fakeManager) SearchServiceAccounts(context.Context, string) ([]accessbridge.ServiceAccount, error) { return []accessbridge.ServiceAccount{{ID: "sa", Email: "deploy@example.com"}}, nil } diff --git a/tui/screens/services/golden_test.go b/tui/screens/services/golden_test.go new file mode 100644 index 000000000..08f7dc94d --- /dev/null +++ b/tui/screens/services/golden_test.go @@ -0,0 +1,149 @@ +package services + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" + "github.com/pluralsh/plural-cli/tui/theme" +) + +func TestServicesGoldens(t *testing.T) { + clusters := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + clusters.loading = false + clusters.mode = modeClusters + clusters.clusters = []servicesbridge.Cluster{ + {ID: "c1", Name: "production", Handle: "prod-eu"}, + {ID: "c2", Name: "staging", Handle: "staging"}, + {ID: "c3", Name: "edge"}, + } + + list := clusters + list.mode = modeList + list.cluster = clusters.clusters[0] + list.page = servicesbridge.Page{Items: []servicesbridge.Summary{ + {ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY", GitRef: "main", GitFolder: "services/api"}, + {ID: "2", Name: "worker", Namespace: "jobs", Status: "FAILED", GitRef: "main", GitFolder: "services/worker"}, + {ID: "3", Name: "canary", Namespace: "default", Status: "SYNCED", GitRef: "release", GitFolder: "services/canary"}, + }, HasNext: true, EndCursor: "cursor-1"} + + detail := list + detail.mode = modeDetail + detail.detail = servicesbridge.Detail{ + Summary: servicesbridge.Summary{ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY", GitRef: "main", GitFolder: "services/api"}, + ClusterHandle: "prod-eu", + ClusterName: "production", + RevisionSHA: "91ca21f0deadbeef", + RevisionRef: "main", + Components: 14, + Synced: 13, + Errors: []servicesbridge.ServiceError{{Source: "Deployment/api", Message: "exceeded rollout deadline"}}, + } + + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"clusters-80", clusters, 80, 24}, + {"clusters-120", clusters, 120, 30}, + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + t.Run(tc.name, func(t *testing.T) { + got := normalizeGoldenView(tc.model.View(tc.width, tc.height)) + golden := filepath.Join("testdata", "services-"+tc.name+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + assertGoldenDimensions(t, got, tc.width, tc.height) + }) + } +} + +func normalizeGoldenView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} + +func assertGoldenDimensions(t *testing.T, view string, width, height int) { + t.Helper() + lines := strings.Split(view, "\n") + if len(lines) != height { + t.Fatalf("view height = %d, want %d", len(lines), height) + } + for _, line := range lines { + if got := lipgloss.Width(line); got > width { + t.Fatalf("line width %d exceeds %d: %q", got, width, line) + } + } +} + +func TestWriteServicesGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + clusters := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + clusters.loading = false + clusters.mode = modeClusters + clusters.clusters = []servicesbridge.Cluster{ + {ID: "c1", Name: "production", Handle: "prod-eu"}, + {ID: "c2", Name: "staging", Handle: "staging"}, + {ID: "c3", Name: "edge"}, + } + list := clusters + list.mode = modeList + list.cluster = clusters.clusters[0] + list.page = servicesbridge.Page{Items: []servicesbridge.Summary{ + {ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY", GitRef: "main", GitFolder: "services/api"}, + {ID: "2", Name: "worker", Namespace: "jobs", Status: "FAILED", GitRef: "main", GitFolder: "services/worker"}, + {ID: "3", Name: "canary", Namespace: "default", Status: "SYNCED", GitRef: "release", GitFolder: "services/canary"}, + }, HasNext: true, EndCursor: "cursor-1"} + detail := list + detail.mode = modeDetail + detail.detail = servicesbridge.Detail{ + Summary: servicesbridge.Summary{ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY", GitRef: "main", GitFolder: "services/api"}, + ClusterHandle: "prod-eu", + ClusterName: "production", + RevisionSHA: "91ca21f0deadbeef", + RevisionRef: "main", + Components: 14, + Synced: 13, + Errors: []servicesbridge.ServiceError{{Source: "Deployment/api", Message: "exceeded rollout deadline"}}, + } + _ = os.MkdirAll("testdata", 0o755) + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"clusters-80", clusters, 80, 24}, + {"clusters-120", clusters, 120, 30}, + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + got := normalizeGoldenView(tc.model.View(tc.width, tc.height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "services-"+tc.name+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } +} diff --git a/tui/screens/services/model.go b/tui/screens/services/model.go new file mode 100644 index 000000000..fa3b3e818 --- /dev/null +++ b/tui/screens/services/model.go @@ -0,0 +1,411 @@ +// Package services implements the Phase 2 read-only Console services browser. +package services + +import ( + "context" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeClusters mode = iota + modeList + modeDetail + modeFilter +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionRefresh + keyActionFilter + keyActionNextPage + keyActionPrevPage + keyActionConnectConsole +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, + keyActionRefresh: {"r"}, + keyActionFilter: {"/"}, + keyActionNextPage: {"n", "right"}, + keyActionPrevPage: {"p", "left"}, + keyActionConnectConsole: {"c"}, +} + +func actionForKeystroke(keystroke string) keyAction { + for action, keystrokes := range keyActionKeystrokes { + for _, candidate := range keystrokes { + if keystroke == candidate { + return action + } + } + } + return keyActionNone +} + +type initMsg struct{} +type clustersMsg struct { + clusters []servicesbridge.Cluster + err error + request uint64 +} +type listedMsg struct { + page servicesbridge.Page + err error + request uint64 +} +type detailMsg struct { + detail servicesbridge.Detail + err error + request uint64 +} + +// Model owns only Services-screen interaction state. +type Model struct { + ctx context.Context + loader servicesbridge.Loader + theme theme.Theme + mode mode + loading bool + err error + needsAuth bool + + clusters []servicesbridge.Cluster + clusterCursor int + cluster servicesbridge.Cluster + clusterFilter string + serviceFilter string + filterInput textinput.Model + filteringCluster bool + + page servicesbridge.Page + cursor int + after *string + prevCursors []string + request uint64 + + detail servicesbridge.Detail + detailID string + listCursor int + listAfter *string + listFilter string + listPrev []string +} + +func New(ctx context.Context, loader servicesbridge.Loader, t theme.Theme) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "filter" + input.CharLimit = 128 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Blurred = styles.Focused + input.SetStyles(styles) + return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, mode: modeClusters} +} + +func (m Model) Init() tea.Cmd { + return func() tea.Msg { return initMsg{} } +} + +func (m *Model) beginClusters() tea.Cmd { + m.loading = true + m.request++ + request := m.request + query := m.clusterFilter + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + clusters, err := loader.ListClusters(ctx, query) + return clustersMsg{clusters: clusters, err: err, request: request} + } +} + +func (m *Model) beginList(after *string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + query := m.serviceFilter + clusterID := m.cluster.ID + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + page, err := loader.List(ctx, clusterID, after, query) + return listedMsg{page: page, err: err, request: request} + } +} + +func (m *Model) beginDetail(id string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + detail, err := loader.Get(ctx, id) + return detailMsg{detail: detail, err: err, request: request} + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case initMsg: + m.mode = modeClusters + m.cluster = servicesbridge.Cluster{} + m.clusters = nil + m.clusterCursor = 0 + m.page = servicesbridge.Page{} + m.after = nil + m.prevCursors = nil + m.cursor = 0 + m.err = nil + m.needsAuth = false + if m.loader == nil { + m.loading = false + return m, nil + } + return m, m.beginClusters() + case clustersMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.clusters = msg.clusters + m.clusterCursor = clampCursor(m.clusterCursor, len(m.clusters)) + m.mode = modeClusters + } + return m, nil + case listedMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.page = msg.page + m.cursor = clampCursor(m.cursor, len(m.page.Items)) + m.mode = modeList + } + return m, nil + case detailMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.detail = msg.detail + m.mode = modeDetail + } + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if m.mode == modeFilter { + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + if m.mode == modeFilter { + switch action { + case keyActionBack: + m.mode = modeList + if m.filteringCluster { + m.mode = modeClusters + } + m.filterInput.Blur() + return m, nil + case keyActionConfirm: + value := strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + if m.filteringCluster { + m.clusterFilter = value + m.mode = modeClusters + m.clusterCursor = 0 + return m, m.beginClusters() + } + m.serviceFilter = value + m.mode = modeList + m.after = nil + m.prevCursors = nil + m.cursor = 0 + return m, m.beginList(nil) + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd + } + if action == keyActionBack { + switch m.mode { + case modeDetail: + m.mode = modeList + m.err = nil + m.cursor = m.listCursor + m.after = m.listAfter + m.serviceFilter = m.listFilter + m.prevCursors = append([]string(nil), m.listPrev...) + return m, nil + case modeList: + m.mode = modeClusters + m.page = servicesbridge.Page{} + m.cluster = servicesbridge.Cluster{} + m.err = nil + m.after = nil + m.prevCursors = nil + return m, nil + default: + return m, navigation.Navigate(navigation.Welcome) + } + } + if m.loading { + return m, nil + } + if m.needsAuth && action == keyActionConnectConsole { + return m, navigation.Navigate(navigation.Access) + } + if m.mode == modeDetail { + if action == keyActionRefresh && m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + return m, nil + } + if m.mode == modeClusters { + return m.updateClusters(action) + } + return m.updateList(action) +} + +func (m Model) updateClusters(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionMoveUp: + m.clusterCursor = clampCursor(m.clusterCursor-1, len(m.clusters)) + case keyActionMoveDown: + m.clusterCursor = clampCursor(m.clusterCursor+1, len(m.clusters)) + case keyActionConfirm: + if len(m.clusters) == 0 { + return m, nil + } + m.cluster = m.clusters[m.clusterCursor] + m.serviceFilter = "" + m.after = nil + m.prevCursors = nil + m.cursor = 0 + return m, m.beginList(nil) + case keyActionRefresh: + return m, m.beginClusters() + case keyActionFilter: + m.mode = modeFilter + m.filteringCluster = true + m.filterInput.Placeholder = "filter clusters" + m.filterInput.SetValue(m.clusterFilter) + m.filterInput.Focus() + } + return m, nil +} + +func (m Model) updateList(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionMoveUp: + m.cursor = clampCursor(m.cursor-1, len(m.page.Items)) + case keyActionMoveDown: + m.cursor = clampCursor(m.cursor+1, len(m.page.Items)) + case keyActionConfirm: + if len(m.page.Items) == 0 { + return m, nil + } + m.listCursor = m.cursor + m.listAfter = m.after + m.listFilter = m.serviceFilter + m.listPrev = append([]string(nil), m.prevCursors...) + m.detailID = m.page.Items[m.cursor].ID + return m, m.beginDetail(m.detailID) + case keyActionRefresh: + return m, m.beginList(m.after) + case keyActionFilter: + m.mode = modeFilter + m.filteringCluster = false + m.filterInput.Placeholder = "filter services" + m.filterInput.SetValue(m.serviceFilter) + m.filterInput.Focus() + case keyActionNextPage: + if !m.page.HasNext || m.page.EndCursor == "" { + return m, nil + } + if m.after != nil { + m.prevCursors = append(m.prevCursors, *m.after) + } else { + m.prevCursors = append(m.prevCursors, "") + } + cursor := m.page.EndCursor + m.after = &cursor + m.cursor = 0 + return m, m.beginList(m.after) + case keyActionPrevPage: + if len(m.prevCursors) == 0 { + return m, nil + } + previous := m.prevCursors[len(m.prevCursors)-1] + m.prevCursors = m.prevCursors[:len(m.prevCursors)-1] + if previous == "" { + m.after = nil + } else { + m.after = &previous + } + m.cursor = 0 + return m, m.beginList(m.after) + } + return m, nil +} + +func clampCursor(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} + +func clusterLabel(cluster servicesbridge.Cluster) string { + if cluster.Handle != "" { + return "@" + cluster.Handle + } + if cluster.Name != "" { + return cluster.Name + } + return cluster.ID +} diff --git a/tui/screens/services/model_test.go b/tui/screens/services/model_test.go new file mode 100644 index 000000000..09d2c2091 --- /dev/null +++ b/tui/screens/services/model_test.go @@ -0,0 +1,123 @@ +package services + +import ( + "context" + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + + "github.com/pluralsh/plural-cli/pkg/bridge" + servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeLoader struct { + clusters []servicesbridge.Cluster + page servicesbridge.Page + detail servicesbridge.Detail + err error + listedID string +} + +func (f *fakeLoader) ListClusters(context.Context, string) ([]servicesbridge.Cluster, error) { + return f.clusters, f.err +} +func (f *fakeLoader) List(_ context.Context, clusterID string, _ *string, _ string) (servicesbridge.Page, error) { + f.listedID = clusterID + return f.page, f.err +} +func (f *fakeLoader) Get(context.Context, string) (servicesbridge.Detail, error) { + return f.detail, f.err +} + +func loadClusters(t *testing.T, model Model) Model { + t.Helper() + cmd := model.Init() + model, cmd = model.Update(cmd()) + if cmd == nil { + t.Fatal("expected clusters command") + } + model, _ = model.Update(cmd()) + return model +} + +func TestSelectClusterThenOpenService(t *testing.T) { + loader := &fakeLoader{ + clusters: []servicesbridge.Cluster{ + {ID: "c1", Name: "production", Handle: "prod-eu"}, + {ID: "c2", Name: "staging", Handle: "staging"}, + }, + page: servicesbridge.Page{Items: []servicesbridge.Summary{ + {ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY"}, + }}, + detail: servicesbridge.Detail{ + Summary: servicesbridge.Summary{ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY"}, + ClusterHandle: "prod-eu", + }, + } + model := loadClusters(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if model.mode != modeClusters || len(model.clusters) != 2 { + t.Fatalf("clusters state = mode=%d count=%d", model.mode, len(model.clusters)) + } + view := model.View(80, 24) + if !strings.Contains(view, "@prod-eu") || !strings.Contains(view, "Choose a cluster") { + t.Fatalf("cluster view missing handle:\n%s", view) + } + + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("enter did not load services") + } + model, _ = model.Update(cmd()) + if model.mode != modeList || loader.listedID != "c1" || len(model.page.Items) != 1 { + t.Fatalf("list state = mode=%d id=%q items=%d", model.mode, loader.listedID, len(model.page.Items)) + } + if !strings.Contains(model.View(80, 24), "@prod-eu") { + t.Fatalf("service list missing cluster label:\n%s", model.View(80, 24)) + } + + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail || model.detail.ClusterHandle != "prod-eu" { + t.Fatalf("detail state = %#v", model.detail) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeList { + t.Fatalf("mode after detail esc = %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeClusters { + t.Fatalf("mode after list esc = %d", model.mode) + } +} + +func TestNoConsoleNavigatesToAccess(t *testing.T) { + loader := &fakeLoader{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect")}} + model := loadClusters(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !model.needsAuth { + t.Fatal("expected needsAuth") + } + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c'}) + if cmd == nil { + t.Fatal("expected access navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Access}) { + t.Fatalf("msg = %#v", msg) + } +} + +func TestBackFromClustersReturnsWelcome(t *testing.T) { + model := loadClusters(t, New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII))) + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil { + t.Fatal("expected welcome navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Welcome}) { + t.Fatalf("msg = %#v", msg) + } +} diff --git a/tui/screens/services/testdata/services-clusters-120.golden b/tui/screens/services/testdata/services-clusters-120.golden new file mode 100644 index 000000000..1095f872a --- /dev/null +++ b/tui/screens/services/testdata/services-clusters-120.golden @@ -0,0 +1,30 @@ + Plural Services 3 clusters + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Choose a cluster ─────────────────────────────────────────────────────────────────────────────────────────────╮ + │ HANDLE NAME ID │ + │ › @prod-eu production c1 │ + │ @staging staging c2 │ + │ — edge c3 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · enter open cluster · / filter · r refresh · esc back diff --git a/tui/screens/services/testdata/services-clusters-80.golden b/tui/screens/services/testdata/services-clusters-80.golden new file mode 100644 index 000000000..e3a532052 --- /dev/null +++ b/tui/screens/services/testdata/services-clusters-80.golden @@ -0,0 +1,24 @@ + Plural Services 3 clusters + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Choose a cluster ─────────────────────────────────────────────────────╮ + │ HANDLE NAME ID │ + │ › @prod-eu production c1 │ + │ @staging staging c2 │ + │ — edge c3 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · / filter · esc back diff --git a/tui/screens/services/testdata/services-detail-120.golden b/tui/screens/services/testdata/services-detail-120.golden new file mode 100644 index 000000000..fe9d6e42d --- /dev/null +++ b/tui/screens/services/testdata/services-detail-120.golden @@ -0,0 +1,30 @@ + Plural Services HEALTHY + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › api ──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Status HEALTHY │ + │ Namespace default │ + │ Cluster prod-eu · production │ + │ Revision main · 91ca21f0 │ + │ Git main / services/api │ + │ Components 13 / 14 synced │ + │ │ + │ Errors │ + │ ✗ Deployment/api exceeded rollout deadline │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + r refresh · esc back · ctrl+c quit diff --git a/tui/screens/services/testdata/services-detail-80.golden b/tui/screens/services/testdata/services-detail-80.golden new file mode 100644 index 000000000..3b6813cbd --- /dev/null +++ b/tui/screens/services/testdata/services-detail-80.golden @@ -0,0 +1,24 @@ + Plural Services HEALTHY + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › api ──────────────────────────────────────────────────────────────────╮ + │ Status HEALTHY │ + │ Namespace default │ + │ Cluster prod-eu · production │ + │ Revision main · 91ca21f0 │ + │ Git main / services/api │ + │ Components 13 / 14 synced │ + │ │ + │ Errors │ + │ ✗ Deployment/api exceeded rollout deadline │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + r refresh · esc back · ctrl+c quit diff --git a/tui/screens/services/testdata/services-list-120.golden b/tui/screens/services/testdata/services-list-120.golden new file mode 100644 index 000000000..a4eb8fbd8 --- /dev/null +++ b/tui/screens/services/testdata/services-list-120.golden @@ -0,0 +1,30 @@ + Plural Services 3 services · @prod-eu + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Services · @prod-eu ──────────────────────────────────────────────────────────────────────────────────────────╮ + │ NAME NAMESPACE STATUS GIT │ + │ › api default HEALTHY main services/api │ + │ worker jobs FAILED main services/worker │ + │ canary default SYNCED release services/canary │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · enter open · / filter · n/p page · r refresh · esc clusters diff --git a/tui/screens/services/testdata/services-list-80.golden b/tui/screens/services/testdata/services-list-80.golden new file mode 100644 index 000000000..ac83618ff --- /dev/null +++ b/tui/screens/services/testdata/services-list-80.golden @@ -0,0 +1,24 @@ + Plural Services 3 services · @prod-eu + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Services · @prod-eu ──────────────────────────────────────────────────╮ + │ NAME NAMESPACE STATUS GIT │ + │ › api default HEALTHY main services/api │ + │ worker jobs FAILED main services/wor… │ + │ canary default SYNCED release services/… │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · / filter · n/p page · esc clusters diff --git a/tui/screens/services/view.go b/tui/screens/services/view.go new file mode 100644 index 000000000..d27ce4e89 --- /dev/null +++ b/tui/screens/services/view.go @@ -0,0 +1,259 @@ +package services + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + status := m.headerStatus() + body, help := m.bodyAndHelp(contentWidth) + return page.Render(m.theme, width, height, "Services", status, body, help) +} + +func (m Model) headerStatus() string { + if m.loading { + return m.theme.Warning.Render("◌ loading") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + if m.err != nil { + return m.theme.Danger.Render("✗ load failed") + } + switch m.mode { + case modeDetail: + return m.statusBadge(m.detail.Status) + case modeList: + if m.serviceFilter != "" { + return m.theme.Muted.Render(fmt.Sprintf("%d matching · %s", len(m.page.Items), clusterLabel(m.cluster))) + } + return m.theme.Success.Render(fmt.Sprintf("%d services · %s", len(m.page.Items), clusterLabel(m.cluster))) + case modeClusters: + if m.clusterFilter != "" { + return m.theme.Muted.Render(fmt.Sprintf("%d matching clusters", len(m.clusters))) + } + return m.theme.Success.Render(fmt.Sprintf("%d clusters", len(m.clusters))) + default: + return m.theme.Muted.Render("select cluster") + } +} + +func (m Model) bodyAndHelp(width int) (string, string) { + if m.mode == modeFilter { + title := "Filter services" + hint := "Filter by name, namespace, status, or git path." + if m.filteringCluster { + title = "Filter clusters" + hint = "Filter by handle, name, or id." + } + lines := []string{m.theme.Muted.Render(hint), "", m.filterInput.View()} + return page.Panel(m.theme, title, lines, width, 6, true), "enter apply · esc cancel" + } + if m.needsAuth { + lines := []string{ + m.theme.Warning.Render("○ Console is not connected"), + m.theme.Muted.Render(" Connect a Console profile to browse services."), + "", + m.theme.Body.Render("Press c to open Access."), + } + return page.Panel(m.theme, "Console required", lines, width, 8, true), "c connect · esc back · ctrl+c quit" + } + if m.mode == modeDetail { + return page.Panel(m.theme, m.detail.Name, m.detailLines(), width, 14, true), "r refresh · esc back · ctrl+c quit" + } + if m.mode == modeClusters { + help := "↑/↓ select · enter open cluster · / filter · r refresh · esc back" + if width < 100 { + help = "↑/↓ · enter · / filter · esc back" + } + return page.Panel(m.theme, m.clusterTitle(), m.clusterLines(width), width, 14, true), help + } + help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc clusters" + if width < 100 { + help = "↑/↓ · enter · / filter · n/p page · esc clusters" + } + return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help +} + +func (m Model) clusterTitle() string { + if m.clusterFilter != "" { + return "Clusters · filter “" + m.clusterFilter + "”" + } + return "Choose a cluster" +} + +func (m Model) listTitle() string { + title := "Services · " + clusterLabel(m.cluster) + if m.serviceFilter != "" { + title += " · filter “" + m.serviceFilter + "”" + } + return title +} + +func (m Model) clusterLines(width int) []string { + if m.loading && len(m.clusters) == 0 { + return []string{m.theme.Warning.Render("◌ Loading clusters…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load clusters"), m.theme.Danger.Render("Error " + m.err.Error()), m.theme.Muted.Render("Press r to retry.")} + } + if len(m.clusters) == 0 { + return []string{m.theme.Warning.Render("○ No clusters found"), m.theme.Muted.Render(" Adjust the filter or connect another Console.")} + } + handleWidth := max(12, min(24, width/3)) + lines := []string{m.theme.Muted.Render(" " + pad("HANDLE", handleWidth) + " " + pad("NAME", 24) + " ID")} + for i, cluster := range m.clusters { + cursor := " " + if i == m.clusterCursor { + cursor = "› " + } + handle := cluster.Handle + if handle == "" { + handle = "—" + } else { + handle = "@" + handle + } + row := cursor + pad(handle, handleWidth) + " " + pad(cluster.Name, 24) + " " + cluster.ID + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + return lines +} + +func (m Model) listLines(width int) []string { + if m.loading && len(m.page.Items) == 0 { + return []string{m.theme.Warning.Render("◌ Loading services for " + clusterLabel(m.cluster) + "…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load services"), m.theme.Danger.Render("Error " + m.err.Error()), m.theme.Muted.Render("Press r to retry.")} + } + if len(m.page.Items) == 0 { + return []string{m.theme.Warning.Render("○ No services found"), m.theme.Muted.Render(" Adjust the filter or choose another cluster.")} + } + nameWidth := max(12, min(28, width/3)) + lines := []string{m.theme.Muted.Render(" " + pad("NAME", nameWidth) + " " + pad("NAMESPACE", 14) + " " + pad("STATUS", 10) + " GIT")} + for i, item := range m.page.Items { + cursor := " " + if i == m.cursor { + cursor = "› " + } + git := strings.TrimSpace(item.GitRef + " " + item.GitFolder) + if git == "" { + git = "—" + } + row := cursor + pad(item.Name, nameWidth) + " " + pad(item.Namespace, 14) + " " + pad(item.Status, 10) + " " + git + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + if m.page.HasNext || len(m.prevCursors) > 0 { + pager := "page" + if len(m.prevCursors) > 0 { + pager += " · p prev" + } + if m.page.HasNext { + pager += " · n next" + } + lines = append(lines, "", m.theme.Muted.Render(pager)) + } + return lines +} + +func (m Model) detailLines() []string { + if m.loading { + return []string{m.theme.Warning.Render("◌ Loading service detail…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load service"), m.theme.Danger.Render(m.err.Error())} + } + cluster := m.detail.ClusterName + if m.detail.ClusterHandle != "" { + cluster = m.detail.ClusterHandle + if m.detail.ClusterName != "" && m.detail.ClusterName != m.detail.ClusterHandle { + cluster = m.detail.ClusterHandle + " · " + m.detail.ClusterName + } + } + if cluster == "" { + cluster = clusterLabel(m.cluster) + } + revision := m.detail.RevisionSHA + if m.detail.RevisionRef != "" { + revision = m.detail.RevisionRef + if m.detail.RevisionSHA != "" { + revision += " · " + shortSHA(m.detail.RevisionSHA) + } + } + if revision == "" { + revision = "—" + } + git := strings.TrimSpace(m.detail.GitRef + " / " + m.detail.GitFolder) + if git == " / " || git == "" { + git = "—" + } + lines := []string{ + m.labelValue("Status", m.statusBadge(m.detail.Status)), + m.labelValue("Namespace", m.detail.Namespace), + m.labelValue("Cluster", cluster), + m.labelValue("Revision", revision), + m.labelValue("Git", git), + m.labelValue("Components", fmt.Sprintf("%d / %d synced", m.detail.Synced, m.detail.Components)), + "", + } + if len(m.detail.Errors) == 0 { + lines = append(lines, m.theme.Success.Render("✓ No service errors")) + return lines + } + lines = append(lines, m.theme.Danger.Render("Errors")) + for _, item := range m.detail.Errors { + source := item.Source + if source == "" { + source = "sync" + } + lines = append(lines, m.theme.Danger.Render("✗ "+source)+" "+item.Message) + } + return lines +} + +func (m Model) labelValue(label, value string) string { + label = label + strings.Repeat(" ", max(1, 12-len(label))) + return label + " " + value +} + +func (m Model) statusBadge(status string) string { + switch strings.ToUpper(status) { + case "HEALTHY", "SYNCED": + return m.theme.Success.Render(status) + case "FAILED": + return m.theme.Danger.Render(status) + case "PAUSED", "STALE": + return m.theme.Warning.Render(status) + default: + if status == "" { + return m.theme.Muted.Render("UNKNOWN") + } + return m.theme.Muted.Render(status) + } +} + +func pad(value string, width int) string { + value = ansi.Truncate(value, width, "…") + if lipgloss.Width(value) >= width { + return value + } + return value + strings.Repeat(" ", width-lipgloss.Width(value)) +} + +func shortSHA(value string) string { + if len(value) > 8 { + return value[:8] + } + return value +} diff --git a/tui/screens/welcome/model.go b/tui/screens/welcome/model.go index 8c0fa8d39..6da587715 100644 --- a/tui/screens/welcome/model.go +++ b/tui/screens/welcome/model.go @@ -28,7 +28,7 @@ type Model struct { } func New(ctx context.Context, loader welcomebridge.Loader, t theme.Theme) Model { - commands := []string{"access", "console", "diagnostics", "help", "profiles", "workspace"} + commands := []string{"access", "console", "diagnostics", "help", "profiles", "services", "workspace"} return Model{ ctx: ctx, loader: loader, @@ -78,6 +78,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m, navigation.Navigate(navigation.Access) case "diagnostics", "workspace": return m, navigation.Navigate(navigation.Diagnostics) + case "services": + return m, navigation.Navigate(navigation.Services) } return m, nil default: diff --git a/tui/screens/welcome/testdata/welcome-popup-80.golden b/tui/screens/welcome/testdata/welcome-popup-80.golden index 77665134c..870246618 100644 --- a/tui/screens/welcome/testdata/welcome-popup-80.golden +++ b/tui/screens/welcome/testdata/welcome-popup-80.golden @@ -9,13 +9,13 @@ ╰─ v0.13.0 ────────────────────────────────────────────────────────────────╯ - ╭─ Available commands ───────────────╮ │ › access │ │ console │ │ diagnostics │ │ help │ │ profiles │ + │ services │ │ workspace │ ╰────────────────────────────────────╯ ╭─ Command ────────────────────────────────────────────────────────────────╮ From 58bb50e0b269221e40ff57e83f5960501c5703df Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Wed, 29 Jul 2026 13:02:30 +0200 Subject: [PATCH 03/11] change layout --- tui/app/model.go | 8 + tui/app/model_test.go | 5 + tui/app/view.go | 2 + tui/navigation/navigation.go | 1 + tui/screens/deployments/model.go | 132 +++++++++++++++ tui/screens/deployments/model_test.go | 94 +++++++++++ .../testdata/deployments-120.golden | 30 ++++ .../testdata/deployments-80.golden | 24 +++ tui/screens/deployments/view.go | 66 ++++++++ tui/screens/services/model.go | 14 +- tui/screens/services/model_test.go | 6 +- tui/screens/welcome/groups.go | 30 ++++ tui/screens/welcome/model.go | 90 ++++++++-- tui/screens/welcome/model_test.go | 156 +++++++++++++----- .../welcome/testdata/welcome-120.golden | 16 +- .../welcome/testdata/welcome-80.golden | 16 +- .../welcome/testdata/welcome-popup-80.golden | 24 +-- tui/screens/welcome/view.go | 60 ++++++- 18 files changed, 675 insertions(+), 99 deletions(-) create mode 100644 tui/screens/deployments/model.go create mode 100644 tui/screens/deployments/model_test.go create mode 100644 tui/screens/deployments/testdata/deployments-120.golden create mode 100644 tui/screens/deployments/testdata/deployments-80.golden create mode 100644 tui/screens/deployments/view.go create mode 100644 tui/screens/welcome/groups.go diff --git a/tui/app/model.go b/tui/app/model.go index a690ef772..770d6d5bf 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -12,6 +12,7 @@ import ( welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" "github.com/pluralsh/plural-cli/tui/navigation" accessscreen "github.com/pluralsh/plural-cli/tui/screens/access" + deploymentsscreen "github.com/pluralsh/plural-cli/tui/screens/deployments" diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics" servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" welcomescreen "github.com/pluralsh/plural-cli/tui/screens/welcome" @@ -37,6 +38,7 @@ type Model struct { welcome welcomescreen.Model access accessscreen.Model diagnostics diagnosticsscreen.Model + deployments deploymentsscreen.Model services servicesscreen.Model route navigation.Route } @@ -48,6 +50,7 @@ func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { welcome: welcomescreen.New(ctx, dependencies.Welcome, t), access: accessscreen.New(ctx, dependencies.Access, t), diagnostics: diagnosticsscreen.New(ctx, dependencies.Welcome, t), + deployments: deploymentsscreen.New(ctx, t, ""), services: servicesscreen.New(ctx, dependencies.Services, t), route: navigation.Welcome, quit: key.NewBinding( @@ -68,6 +71,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.access.Init() case navigation.Diagnostics: return m, m.diagnostics.Init() + case navigation.Deployments: + m.deployments.SetConsoleURL(m.welcome.Snapshot().Console.URL) + return m, m.deployments.Init() case navigation.Services: return m, m.services.Init() default: @@ -93,6 +99,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.access, cmd = m.access.Update(msg) case navigation.Diagnostics: m.diagnostics, cmd = m.diagnostics.Update(msg) + case navigation.Deployments: + m.deployments, cmd = m.deployments.Update(msg) case navigation.Services: m.services, cmd = m.services.Update(msg) default: diff --git a/tui/app/model_test.go b/tui/app/model_test.go index f9f32d9ad..0926d4f09 100644 --- a/tui/app/model_test.go +++ b/tui/app/model_test.go @@ -45,6 +45,11 @@ func TestModelRoutesScreensWithoutRebuildingShell(t *testing.T) { if routed.route != navigation.Diagnostics || !strings.Contains(routed.View().Content, "Diagnostics") { t.Fatalf("route/view = %q\n%s", routed.route, routed.View().Content) } + updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Deployments}) + routed = updated.(Model) + if routed.route != navigation.Deployments || !strings.Contains(routed.View().Content, "CD / Deployments") { + t.Fatalf("deployments route/view = %q\n%s", routed.route, routed.View().Content) + } updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Services}) routed = updated.(Model) if routed.route != navigation.Services || !strings.Contains(routed.View().Content, "Services") { diff --git a/tui/app/view.go b/tui/app/view.go index 87b8bc3a5..0ac2fcbd2 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -15,6 +15,8 @@ func (m Model) View() tea.View { content = m.access.View(m.width, m.height) case navigation.Diagnostics: content = m.diagnostics.View(m.width, m.height) + case navigation.Deployments: + content = m.deployments.View(m.width, m.height) case navigation.Services: content = m.services.View(m.width, m.height) } diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index 070df9281..4cea2e030 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -12,6 +12,7 @@ const ( Welcome Route = "welcome" Access Route = "access" Diagnostics Route = "diagnostics" + Deployments Route = "deployments" Services Route = "services" ) diff --git a/tui/screens/deployments/model.go b/tui/screens/deployments/model.go new file mode 100644 index 000000000..07f882a44 --- /dev/null +++ b/tui/screens/deployments/model.go @@ -0,0 +1,132 @@ +package deployments + +import ( + "context" + + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type resourceID uint8 + +const ( + resourceServices resourceID = iota + resourceClusters + resourceRepositories + resourcePipelines + resourceNotifications + resourceProviders +) + +type resource struct { + id resourceID + number string + shortcut string + title string + blurb string + soon bool + route navigation.Route +} + +func resources() []resource { + return []resource{ + {id: resourceServices, number: "1", shortcut: "s", title: "Services", blurb: "list · describe · (kick later)", route: navigation.Services}, + {id: resourceClusters, number: "2", shortcut: "c", title: "Clusters", blurb: "list · describe", soon: true}, + {id: resourceRepositories, number: "3", shortcut: "r", title: "Repositories", blurb: "list · get", soon: true}, + {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "trigger", soon: true}, + {id: resourceNotifications, number: "5", shortcut: "n", title: "Notifications", blurb: "sinks", soon: true}, + {id: resourceProviders, number: "6", shortcut: "v", title: "Providers", blurb: "list", soon: true}, + } +} + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionUp + keyActionDown + keyActionConfirm + keyActionBack +) + +var keyActionKeystrokes = map[keyAction]string{ + keyActionUp: "up", + keyActionDown: "down", + keyActionConfirm: "enter", + keyActionBack: "esc", +} + +func actionForKeystroke(keystroke string) keyAction { + for action, candidate := range keyActionKeystrokes { + if keystroke == candidate { + return action + } + } + return keyActionNone +} + +// Model is the CD / Deployments hub. +type Model struct { + theme theme.Theme + items []resource + cursor int + console string +} + +// New creates the deployments hub. consoleURL is shown in the connection panel. +func New(_ context.Context, t theme.Theme, consoleURL string) Model { + return Model{ + theme: t, + items: resources(), + console: consoleURL, + } +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + key, ok := msg.(tea.KeyPressMsg) + if !ok { + return m, nil + } + switch actionForKeystroke(key.Keystroke()) { + case keyActionUp: + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case keyActionDown: + if m.cursor < len(m.items)-1 { + m.cursor++ + } + return m, nil + case keyActionConfirm: + return m.openResource(m.items[m.cursor]) + case keyActionBack: + return m, navigation.Navigate(navigation.Welcome) + } + + text := key.Text + if text == "" && key.Code > 0 && key.Code < 128 { + text = string(rune(key.Code)) + } + for i, item := range m.items { + if text == item.number || text == item.shortcut { + m.cursor = i + return m.openResource(item) + } + } + return m, nil +} + +func (m Model) openResource(item resource) (Model, tea.Cmd) { + if item.soon || item.route == "" { + return m, nil + } + return m, navigation.Navigate(item.route) +} + +// SetConsoleURL refreshes the connection panel when context changes. +func (m *Model) SetConsoleURL(url string) { m.console = url } diff --git a/tui/screens/deployments/model_test.go b/tui/screens/deployments/model_test.go new file mode 100644 index 000000000..c5943f3b5 --- /dev/null +++ b/tui/screens/deployments/model_test.go @@ -0,0 +1,94 @@ +package deployments + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +func TestDeploymentsGoldens(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + for _, width := range []int{80, 120} { + t.Run(strconv.Itoa(width), func(t *testing.T) { + height := 24 + if width == 120 { + height = 30 + } + got := normalizeView(model.View(width, height)) + golden := filepath.Join("testdata", "deployments-"+strconv.Itoa(width)+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + }) + } +} + +func TestUpdateGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + _ = os.MkdirAll("testdata", 0o755) + for _, width := range []int{80, 120} { + height := 24 + if width == 120 { + height = 30 + } + got := normalizeView(model.View(width, height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "deployments-"+strconv.Itoa(width)+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func TestServicesShortcutNavigates(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + _, cmd := model.Update(tea.KeyPressMsg{Code: 's', Text: "s"}) + if cmd == nil { + t.Fatal("expected navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Services}) { + t.Fatalf("msg = %#v", msg) + } +} + +func TestSoonResourceDoesNotNavigate(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "") + model.cursor = 1 + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd != nil { + t.Fatalf("unexpected cmd %#v", cmd()) + } +} + +func TestEscReturnsWelcome(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "") + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil { + t.Fatal("expected welcome navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Welcome}) { + t.Fatalf("msg = %#v", msg) + } +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/deployments/testdata/deployments-120.golden b/tui/screens/deployments/testdata/deployments-120.golden new file mode 100644 index 000000000..c8f415ecd --- /dev/null +++ b/tui/screens/deployments/testdata/deployments-120.golden @@ -0,0 +1,30 @@ + Plural CD / Deployments https://console.acme.io + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Resources ────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ › 1 s Services list · describe · (kick later) │ + │ 2 c Clusters list · describe [soon] │ + │ 3 r Repositories list · get [soon] │ + │ 4 p Pipelines trigger [soon] │ + │ 5 n Notifications sinks [soon] │ + │ 6 v Providers list [soon] │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + ╭─ Connection ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Console https://console.acme.io │ + │ Tip plural cd … remains the automation API │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + + 1–6 / letter · enter open · esc welcome · ctrl+c quit diff --git a/tui/screens/deployments/testdata/deployments-80.golden b/tui/screens/deployments/testdata/deployments-80.golden new file mode 100644 index 000000000..fc893a289 --- /dev/null +++ b/tui/screens/deployments/testdata/deployments-80.golden @@ -0,0 +1,24 @@ + Plural CD / Deployments https://console.acme.io + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Resources ────────────────────────────────────────────────────────────╮ + │ › 1 s Services list · describe · (kick later) │ + │ 2 c Clusters list · describe [soon] │ + │ 3 r Repositories list · get [soon] │ + │ 4 p Pipelines trigger [soon] │ + │ 5 n Notifications sinks [soon] │ + │ 6 v Providers list [soon] │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + ╭─ Connection ─────────────────────────────────────────────────────────────╮ + │ Console https://console.acme.io │ + │ Tip plural cd … remains the automation API │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + + 1–6 / letter · enter open · esc welcome · ctrl+c quit diff --git a/tui/screens/deployments/view.go b/tui/screens/deployments/view.go new file mode 100644 index 000000000..9639b3da7 --- /dev/null +++ b/tui/screens/deployments/view.go @@ -0,0 +1,66 @@ +package deployments + +import ( + "fmt" + + "github.com/charmbracelet/x/ansi" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + status := m.theme.Muted.Render("no console") + if m.console != "" { + status = m.theme.Success.Render(ansi.Truncate(m.console, 40, "…")) + } + + body := page.Panel(m.theme, "Resources", m.resourceLines(contentWidth-4), contentWidth, 8, true) + "\n\n" + + page.Panel(m.theme, "Connection", m.connectionLines(), contentWidth, 4, false) + + help := "1–6 / letter · enter open · esc welcome · ctrl+c quit" + return page.Render(m.theme, width, height, "CD / Deployments", status, body, help) +} + +func (m Model) resourceLines(innerWidth int) []string { + lines := make([]string, 0, len(m.items)) + for i, item := range m.items { + cursor := " " + if i == m.cursor { + cursor = "› " + } + soon := "" + if item.soon { + soon = " " + m.theme.Muted.Render("[soon]") + } + left := fmt.Sprintf("%s %s %-14s %s", item.number, item.shortcut, item.title, item.blurb) + var row string + switch { + case i == m.cursor && !item.soon: + row = cursor + m.theme.Title.Render(left) + soon + case item.soon: + row = cursor + m.theme.Muted.Render(left) + soon + default: + row = cursor + m.theme.Body.Render(left) + } + lines = append(lines, ansi.Truncate(row, max(1, innerWidth), "…")) + } + return lines +} + +func (m Model) connectionLines() []string { + url := lo.CoalesceOrEmpty(m.console, "not connected") + display := ansi.Truncate(url, 56, "…") + if m.console == "" { + display = m.theme.Warning.Render(display) + } + return []string{ + "Console " + display, + m.theme.Muted.Render("Tip plural cd … remains the automation API"), + } +} diff --git a/tui/screens/services/model.go b/tui/screens/services/model.go index fa3b3e818..4d8d15f27 100644 --- a/tui/screens/services/model.go +++ b/tui/screens/services/model.go @@ -88,12 +88,12 @@ type Model struct { err error needsAuth bool - clusters []servicesbridge.Cluster - clusterCursor int - cluster servicesbridge.Cluster - clusterFilter string - serviceFilter string - filterInput textinput.Model + clusters []servicesbridge.Cluster + clusterCursor int + cluster servicesbridge.Cluster + clusterFilter string + serviceFilter string + filterInput textinput.Model filteringCluster bool page servicesbridge.Page @@ -284,7 +284,7 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { m.prevCursors = nil return m, nil default: - return m, navigation.Navigate(navigation.Welcome) + return m, navigation.Navigate(navigation.Deployments) } } if m.loading { diff --git a/tui/screens/services/model_test.go b/tui/screens/services/model_test.go index 09d2c2091..6a5207d7c 100644 --- a/tui/screens/services/model_test.go +++ b/tui/screens/services/model_test.go @@ -111,13 +111,13 @@ func TestNoConsoleNavigatesToAccess(t *testing.T) { } } -func TestBackFromClustersReturnsWelcome(t *testing.T) { +func TestBackFromClustersReturnsDeployments(t *testing.T) { model := loadClusters(t, New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII))) _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) if cmd == nil { - t.Fatal("expected welcome navigation") + t.Fatal("expected deployments navigation") } - if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Welcome}) { + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Deployments}) { t.Fatalf("msg = %#v", msg) } } diff --git a/tui/screens/welcome/groups.go b/tui/screens/welcome/groups.go new file mode 100644 index 000000000..0bfd6e2b6 --- /dev/null +++ b/tui/screens/welcome/groups.go @@ -0,0 +1,30 @@ +package welcome + +import "github.com/pluralsh/plural-cli/tui/navigation" + +type groupID uint8 + +const ( + groupDeployments groupID = iota + groupAccess + groupDiagnose + groupHelp +) + +type group struct { + id groupID + number string + shortcut string + title string + blurb string + route navigation.Route // empty for Help stub +} + +func welcomeGroups() []group { + return []group{ + {id: groupDeployments, number: "1", shortcut: "d", title: "CD / Deployments", blurb: "clusters · services · repos", route: navigation.Deployments}, + {id: groupAccess, number: "2", shortcut: "a", title: "Access", blurb: "login · profiles · Console", route: navigation.Access}, + {id: groupDiagnose, number: "3", shortcut: "g", title: "Diagnose", blurb: "local context · checks", route: navigation.Diagnostics}, + {id: groupHelp, number: "4", shortcut: "?", title: "Help", blurb: "shortcuts · about"}, + } +} diff --git a/tui/screens/welcome/model.go b/tui/screens/welcome/model.go index 6da587715..9565a7db0 100644 --- a/tui/screens/welcome/model.go +++ b/tui/screens/welcome/model.go @@ -7,7 +7,6 @@ import ( tea "charm.land/bubbletea/v2" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" - "github.com/pluralsh/plural-cli/tui/components/commandbar" pluralspinner "github.com/pluralsh/plural-cli/tui/components/spinner" "github.com/pluralsh/plural-cli/tui/navigation" "github.com/pluralsh/plural-cli/tui/theme" @@ -16,25 +15,50 @@ import ( type loadedMsg struct{ snapshot welcomebridge.Snapshot } type failedMsg struct{ err error } +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionUp + keyActionDown + keyActionConfirm +) + +var keyActionKeystrokes = map[keyAction]string{ + keyActionUp: "up", + keyActionDown: "down", + keyActionConfirm: "enter", +} + +func actionForKeystroke(keystroke string) keyAction { + for action, candidate := range keyActionKeystrokes { + if keystroke == candidate { + return action + } + } + return keyActionNone +} + type Model struct { ctx context.Context loader welcomebridge.Loader theme theme.Theme spinner spinner.Model - command commandbar.Model + groups []group + cursor int loading bool snapshot welcomebridge.Snapshot err error + helpOpen bool } func New(ctx context.Context, loader welcomebridge.Loader, t theme.Theme) Model { - commands := []string{"access", "console", "diagnostics", "help", "profiles", "services", "workspace"} return Model{ ctx: ctx, loader: loader, theme: t, spinner: pluralspinner.New(t), - command: commandbar.New(t, commands), + groups: welcomeGroups(), loading: loader != nil, } } @@ -72,21 +96,55 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd - case commandbar.SubmittedMsg: - switch msg.Command { - case "access", "console", "profiles": - return m, navigation.Navigate(navigation.Access) - case "diagnostics", "workspace": - return m, navigation.Navigate(navigation.Diagnostics) - case "services": - return m, navigation.Navigate(navigation.Services) + case tea.KeyPressMsg: + return m.updateKey(msg) + default: + return m, nil + } +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + if m.helpOpen { + m.helpOpen = false + if key.Keystroke() == "esc" { + return m, nil + } + } + + switch actionForKeystroke(key.Keystroke()) { + case keyActionUp: + if m.cursor > 0 { + m.cursor-- } return m, nil - default: - var cmd tea.Cmd - m.command, cmd = m.command.Update(msg) - return m, cmd + case keyActionDown: + if m.cursor < len(m.groups)-1 { + m.cursor++ + } + return m, nil + case keyActionConfirm: + return m.openGroup(m.groups[m.cursor]) + } + + text := key.Text + if text == "" && key.Code > 0 && key.Code < 128 { + text = string(rune(key.Code)) + } + for i, g := range m.groups { + if text == g.number || text == g.shortcut { + m.cursor = i + return m.openGroup(g) + } + } + return m, nil +} + +func (m Model) openGroup(g group) (Model, tea.Cmd) { + if g.route == "" { + m.helpOpen = true + return m, nil } + return m, navigation.Navigate(g.route) } func (m Model) Snapshot() welcomebridge.Snapshot { return m.snapshot } diff --git a/tui/screens/welcome/model_test.go b/tui/screens/welcome/model_test.go index 714e2e9ca..5389fb1e6 100644 --- a/tui/screens/welcome/model_test.go +++ b/tui/screens/welcome/model_test.go @@ -56,7 +56,52 @@ func TestReadOnlyWelcomeGoldens(t *testing.T) { } } -func TestWelcomeCommandPopupGolden(t *testing.T) { +func TestUpdateWelcomeGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + snapshot := bridge.Snapshot{ + Version: "v0.13.0", + App: bridge.AppProfile{ + Configured: true, Name: "personal", Email: "alex@acme.io", + Endpoint: "https://app.plural.sh", SavedProfiles: 2, + }, + Console: bridge.ConsoleConnection{Configured: true, URL: "https://console.acme.io"}, + Workspace: bridge.Workspace{ + Configured: true, Path: "/work/path/to/a/very/long/workspace", Name: "plrl-dev-aws", + Project: "acme", Provider: "aws", Region: "eu-west-1", Owner: "sebastian@plural.sh", + }, + KubeContext: "plural-platform-prod", + } + popupSnapshot := bridge.Snapshot{ + Version: "v0.13.0", + App: bridge.AppProfile{Configured: true, Name: "personal", Email: "alex@acme.io"}, + Console: bridge.ConsoleConnection{Configured: true, URL: "https://console.acme.io"}, + Workspace: bridge.Workspace{Configured: true, Path: "/work/plural", Name: "plrl-dev-aws", Provider: "aws", Region: "eu-west-1", Owner: "alex@acme.io"}, + } + _ = os.MkdirAll("testdata", 0o755) + for _, width := range []int{80, 120} { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(loadedMsg{snapshot: snapshot}) + height := 24 + if width == 120 { + height = 30 + } + got := normalizeView(model.View(width, height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "welcome-"+strconv.Itoa(width)+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(loadedMsg{snapshot: popupSnapshot}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + got := normalizeView(model.View(80, 24)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "welcome-popup-80.golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestWelcomeGroupPickerGolden(t *testing.T) { model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) model, _ = model.Update(loadedMsg{snapshot: bridge.Snapshot{ Version: "v0.13.0", @@ -74,7 +119,74 @@ func TestWelcomeCommandPopupGolden(t *testing.T) { t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) } if lines := strings.Split(got, "\n"); len(lines) != 24 { - t.Fatalf("popup view height = %d, want 24", len(lines)) + t.Fatalf("group picker view height = %d, want 24", len(lines)) + } +} + +func TestWelcomeOpensDeploymentsFromShortcut(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, cmd := model.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + if cmd == nil { + t.Fatal("selecting CD did not emit navigation") + } + if got := cmd().(navigation.NavigateMsg).Route; got != navigation.Deployments { + t.Fatalf("route = %q, want deployments", got) + } + if model.cursor != 0 { + t.Fatalf("cursor = %d, want 0", model.cursor) + } +} + +func TestWelcomeOpensAccessFromNumber(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + _, cmd := model.Update(tea.KeyPressMsg{Code: '2', Text: "2"}) + if cmd == nil { + t.Fatal("selecting Access did not emit navigation") + } + if got := cmd().(navigation.NavigateMsg).Route; got != navigation.Access { + t.Fatalf("route = %q, want access", got) + } +} + +func TestWelcomeArrowAndEnterOpensDiagnose(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("enter did not emit navigation") + } + if got := cmd().(navigation.NavigateMsg).Route; got != navigation.Diagnostics { + t.Fatalf("route = %q, want diagnostics", got) + } +} + +func TestWelcomeHelpIsStub(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + model, cmd := model.Update(tea.KeyPressMsg{Code: '?', Text: "?"}) + if cmd != nil { + t.Fatal("help stub should not navigate") + } + if !model.helpOpen { + t.Fatal("expected help panel open") + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.helpOpen { + t.Fatal("esc should close help") + } +} + +func TestGroupPickerIsAnchoredAtBottom(t *testing.T) { + model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + lines := strings.Split(normalizeView(model.View(80, 24)), "\n") + if len(lines) != 24 { + t.Fatalf("view height = %d, want 24", len(lines)) + } + if !strings.Contains(lines[len(lines)-9], "Choose an area") { + t.Fatalf("group picker is not anchored above help:\n%s", strings.Join(lines, "\n")) + } + if !strings.Contains(lines[len(lines)-1], "ctrl+c quit") { + t.Fatalf("keymap is not at the bottom: %q", lines[len(lines)-1]) } } @@ -182,46 +294,6 @@ func TestStatusAdaptsToTerminalColorCapability(t *testing.T) { } } -func TestWelcomeForwardsInputToCommandBar(t *testing.T) { - model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) - model, _ = model.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) - if got := model.command.CurrentSuggestion(); got != "diagnostics" { - t.Fatalf("suggestion = %q, want diagnostics", got) - } - model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) - if got := model.command.Value(); got != "diagnostics" { - t.Fatalf("completed input = %q, want diagnostics", got) - } - model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - if cmd == nil { - t.Fatal("selecting a command did not emit submission") - } - model, routeCmd := model.Update(cmd()) - if routeCmd == nil { - t.Fatal("submitted command did not route") - } - if got := routeCmd().(navigation.NavigateMsg).Route; got != navigation.Diagnostics { - t.Fatalf("route = %q, want diagnostics", got) - } - if got := model.command.Selected(); got != "diagnostics" { - t.Fatalf("selected command = %q, want diagnostics", got) - } -} - -func TestCommandInputIsAnchoredAtBottom(t *testing.T) { - model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) - lines := strings.Split(normalizeView(model.View(80, 24)), "\n") - if len(lines) != 24 { - t.Fatalf("view height = %d, want 24", len(lines)) - } - if !strings.Contains(lines[len(lines)-4], "Command") { - t.Fatalf("command bar is not anchored above help:\n%s", strings.Join(lines, "\n")) - } - if !strings.Contains(lines[len(lines)-1], "ctrl+c quit") { - t.Fatalf("keymap is not at the bottom: %q", lines[len(lines)-1]) - } -} - func TestWideLayoutStretchesRightPaneAndStaysLeftAligned(t *testing.T) { model := New(t.Context(), nil, theme.New(colorprofile.ASCII)) model, _ = model.Update(loadedMsg{snapshot: bridge.Snapshot{ diff --git a/tui/screens/welcome/testdata/welcome-120.golden b/tui/screens/welcome/testdata/welcome-120.golden index 4a13a1660..8fcaacc42 100644 --- a/tui/screens/welcome/testdata/welcome-120.golden +++ b/tui/screens/welcome/testdata/welcome-120.golden @@ -19,12 +19,12 @@ - - - - - - ╭─ Command ────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - │ › Search commands… │ + ╭─ Choose an area ─────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ › 1 d CD / Deployments clusters · services · repos │ + │ 2 a Access login · profiles · Console │ + │ 3 g Diagnose local context · checks │ + │ 4 ? Help shortcuts · about │ + │ │ + │ Setup · Agents · Develop — later │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - tab complete · ↑/↓ suggestions · enter select · esc clear · ctrl+c quit + 1–4 open · letter shortcut · ↑/↓ · enter · ctrl+c quit diff --git a/tui/screens/welcome/testdata/welcome-80.golden b/tui/screens/welcome/testdata/welcome-80.golden index 62d680d42..b08356e67 100644 --- a/tui/screens/welcome/testdata/welcome-80.golden +++ b/tui/screens/welcome/testdata/welcome-80.golden @@ -13,12 +13,12 @@ - - - - - - ╭─ Command ────────────────────────────────────────────────────────────────╮ - │ › Search commands… │ + ╭─ Choose an area ─────────────────────────────────────────────────────────╮ + │ › 1 d CD / Deployments clusters · services · repos │ + │ 2 a Access login · profiles · Console │ + │ 3 g Diagnose local context · checks │ + │ 4 ? Help shortcuts · about │ + │ │ + │ Setup · Agents · Develop — later │ ╰──────────────────────────────────────────────────────────────────────────╯ - tab complete · ↑/↓ suggestions · enter select · esc clear · ctrl+c quit + 1–4 open · letter shortcut · ↑/↓ · enter · ctrl+c quit diff --git a/tui/screens/welcome/testdata/welcome-popup-80.golden b/tui/screens/welcome/testdata/welcome-popup-80.golden index 870246618..6176a4afa 100644 --- a/tui/screens/welcome/testdata/welcome-popup-80.golden +++ b/tui/screens/welcome/testdata/welcome-popup-80.golden @@ -9,16 +9,16 @@ ╰─ v0.13.0 ────────────────────────────────────────────────────────────────╯ - ╭─ Available commands ───────────────╮ - │ › access │ - │ console │ - │ diagnostics │ - │ help │ - │ profiles │ - │ services │ - │ workspace │ - ╰────────────────────────────────────╯ - ╭─ Command ────────────────────────────────────────────────────────────────╮ - │ › Search commands… │ + + + + + ╭─ Choose an area ─────────────────────────────────────────────────────────╮ + │ 1 d CD / Deployments clusters · services · repos │ + │ › 2 a Access login · profiles · Console │ + │ 3 g Diagnose local context · checks │ + │ 4 ? Help shortcuts · about │ + │ │ + │ Setup · Agents · Develop — later │ ╰──────────────────────────────────────────────────────────────────────────╯ - ↑/↓ choose · enter open · esc close · type to filter · ctrl+c quit + 1–4 open · letter shortcut · ↑/↓ · enter · ctrl+c quit diff --git a/tui/screens/welcome/view.go b/tui/screens/welcome/view.go index 9d005b057..2b71e8d22 100644 --- a/tui/screens/welcome/view.go +++ b/tui/screens/welcome/view.go @@ -33,10 +33,64 @@ func (m Model) View(width, height int) string { contentWidth := width - 2*sideMargin version := lo.CoalesceOrEmpty(m.snapshot.Version, "dev") header := m.renderHero(contentWidth, version) - command := m.command.View(contentWidth) - gap := m.verticalGap(height, header, command) + groups := m.renderGroups(contentWidth) + gap := m.verticalGap(height, header, groups) - return m.indent(header+strings.Repeat("\n", gap)+command, sideMargin) + return m.indent(header+strings.Repeat("\n", gap)+groups, sideMargin) +} + +func (m Model) renderGroups(width int) string { + border := m.primaryBorder() + title := "Choose an area" + if m.helpOpen { + title = "Help" + } + topRule := max(1, width-5-lipgloss.Width(title)) + top := border.Render("╭─ " + title + " " + strings.Repeat("─", topRule) + "╮") + bottom := border.Render("╰" + strings.Repeat("─", width-2) + "╯") + + innerWidth := width - 4 + var body []string + if m.helpOpen { + body = []string{ + m.theme.Body.Render("1–4 / letter opens an area"), + m.theme.Muted.Render("↑/↓ move · enter confirm · esc close help"), + m.theme.Muted.Render("ctrl+c quit"), + "", + m.theme.Muted.Render("More docs will land in a later phase."), + } + } else { + for i, g := range m.groups { + prefix := " " + label := fmt.Sprintf("%s %s %-18s %s", g.number, g.shortcut, g.title, g.blurb) + if i == m.cursor { + prefix = "› " + label = m.theme.Title.Render(label) + } else { + label = m.theme.Body.Render(fmt.Sprintf("%s %s ", g.number, g.shortcut)) + + m.theme.Body.Render(fmt.Sprintf("%-18s ", g.title)) + + m.theme.Muted.Render(g.blurb) + } + line := prefix + label + line = ansi.Truncate(line, innerWidth, "…") + body = append(body, line) + } + body = append(body, "", m.theme.Muted.Render("Setup · Agents · Develop — later")) + } + + rows := make([]string, 0, len(body)+2) + rows = append(rows, top) + for _, line := range body { + padded := line + strings.Repeat(" ", max(0, innerWidth-lipgloss.Width(line))) + rows = append(rows, border.Render("│")+" "+padded+" "+border.Render("│")) + } + rows = append(rows, bottom) + + help := m.theme.Muted.Render(ansi.Truncate("1–4 open · letter shortcut · ↑/↓ · enter · ctrl+c quit", max(1, width-2), "…")) + if m.helpOpen { + help = m.theme.Muted.Render(ansi.Truncate("any key closes help · ctrl+c quit", max(1, width-2), "…")) + } + return strings.Join(rows, "\n") + "\n " + help } func (m Model) viewportSize(width, height int) (int, int) { From 160f204305054015bf3e97fe7b7d3d0b3f1b34ed Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Wed, 29 Jul 2026 13:29:40 +0200 Subject: [PATCH 04/11] implement CD commands --- pkg/bridge/services/actions.go | 280 ++++++++ pkg/bridge/services/errors.go | 10 +- pkg/bridge/services/services.go | 18 +- pkg/bridge/services/services_test.go | 33 +- tui/screens/deployments/model.go | 2 +- .../testdata/deployments-120.golden | 2 +- .../testdata/deployments-80.golden | 2 +- tui/screens/services/actions.go | 100 +++ tui/screens/services/model.go | 672 ++++++++++++++++-- tui/screens/services/model_test.go | 76 ++ .../testdata/services-detail-120.golden | 26 +- .../testdata/services-detail-80.golden | 26 +- .../testdata/services-list-120.golden | 4 +- .../services/testdata/services-list-80.golden | 4 +- tui/screens/services/view.go | 205 +++++- 15 files changed, 1310 insertions(+), 150 deletions(-) create mode 100644 pkg/bridge/services/actions.go create mode 100644 tui/screens/services/actions.go diff --git a/pkg/bridge/services/actions.go b/pkg/bridge/services/actions.go new file mode 100644 index 000000000..cd6204d9d --- /dev/null +++ b/pkg/bridge/services/actions.go @@ -0,0 +1,280 @@ +package services + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/utils" +) + +// CreateInput is the credential-free create payload. +type CreateInput struct { + ClusterID string + Name string + Namespace string + RepoID string + GitRef string + GitFolder string + Kustomize string + Version string + DryRun bool +} + +// UpdateInput is the credential-free update payload. +type UpdateInput struct { + ID string + GitRef string + GitFolder string + Kustomize string + Version string + DryRun *bool +} + +// CloneInput is the credential-free clone payload. +type CloneInput struct { + SourceID string + DestClusterID string + Name string + Namespace string +} + +// Loader is the narrow contract consumed by the Services screen. +type Loader interface { + ListClusters(ctx context.Context, query string) ([]Cluster, error) + List(ctx context.Context, clusterID string, after *string, query string) (Page, error) + Get(ctx context.Context, id string) (Detail, error) + Kick(ctx context.Context, id string) (Detail, error) + Delete(ctx context.Context, id string) error + Create(ctx context.Context, input CreateInput) (Detail, error) + Update(ctx context.Context, input UpdateInput) (Detail, error) + Clone(ctx context.Context, input CloneInput) (Detail, error) + DownloadTarball(ctx context.Context, id, dir string) (string, error) +} + +// API is the Console surface required by this package. +type API interface { + ListClusters() (*gqlclient.ListClusters, error) + ListClusterServices(clusterId, handle *string) ([]*gqlclient.ServiceDeploymentEdgeFragment, error) + GetClusterService(serviceId, serviceName, clusterName *string) (*gqlclient.ServiceDeploymentExtended, error) + KickClusterService(serviceId, serviceName, clusterName *string) (*gqlclient.ServiceDeploymentExtended, error) + DeleteClusterService(serviceId string) (*gqlclient.DeleteServiceDeployment, error) + CreateClusterService(clusterId, clusterName *string, attr gqlclient.ServiceDeploymentAttributes) (*gqlclient.ServiceDeploymentExtended, error) + UpdateClusterService(serviceId, serviceName, clusterName *string, attributes gqlclient.ServiceUpdateAttributes) (*gqlclient.ServiceDeploymentExtended, error) + CloneService(clusterId string, serviceId, serviceName, clusterName *string, attributes gqlclient.ServiceCloneAttributes) (*gqlclient.ServiceDeploymentFragment, error) + GetDeployToken(clusterId, clusterName *string) (string, error) +} + +func (s *Service) Kick(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + service, err := client.KickClusterService(&id, nil, nil) + if err != nil { + return Detail{}, err + } + if service == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingService} + } + return detailFromExtended(service), nil +} + +func (s *Service) Delete(ctx context.Context, id string) error { + if err := ctx.Err(); err != nil { + return err + } + id = strings.TrimSpace(id) + if id == "" { + return &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return err + } + _, err = client.DeleteClusterService(id) + return err +} + +func (s *Service) Create(ctx context.Context, input CreateInput) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + input.ClusterID = strings.TrimSpace(input.ClusterID) + input.Name = strings.TrimSpace(input.Name) + input.RepoID = strings.TrimSpace(input.RepoID) + input.GitRef = strings.TrimSpace(input.GitRef) + input.GitFolder = strings.TrimSpace(input.GitFolder) + if input.ClusterID == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingCluster} + } + if input.Name == "" || input.RepoID == "" || input.GitRef == "" || input.GitFolder == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingCreateFields} + } + if input.Namespace == "" { + input.Namespace = "default" + } + if input.Version == "" { + input.Version = "0.0.1" + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + attrs := gqlclient.ServiceDeploymentAttributes{ + Name: input.Name, + Namespace: input.Namespace, + Version: lo.ToPtr(input.Version), + RepositoryID: lo.ToPtr(input.RepoID), + Git: &gqlclient.GitRefAttributes{Ref: input.GitRef, Folder: input.GitFolder}, + DryRun: lo.ToPtr(input.DryRun), + } + if input.Kustomize != "" { + attrs.Kustomize = &gqlclient.KustomizeAttributes{Path: input.Kustomize} + } + service, err := client.CreateClusterService(&input.ClusterID, nil, attrs) + if err != nil { + return Detail{}, err + } + if service == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingService} + } + return detailFromExtended(service), nil +} + +func (s *Service) Update(ctx context.Context, input UpdateInput) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + input.ID = strings.TrimSpace(input.ID) + if input.ID == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + attrs := gqlclient.ServiceUpdateAttributes{} + if input.GitRef != "" || input.GitFolder != "" { + attrs.Git = &gqlclient.GitRefAttributes{Ref: input.GitRef, Folder: input.GitFolder} + } + if input.Version != "" { + attrs.Version = lo.ToPtr(input.Version) + } + if input.DryRun != nil { + attrs.DryRun = input.DryRun + } + if input.Kustomize != "" { + attrs.Kustomize = &gqlclient.KustomizeAttributes{Path: input.Kustomize} + } + service, err := client.UpdateClusterService(&input.ID, nil, nil, attrs) + if err != nil { + return Detail{}, err + } + if service == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingService} + } + return detailFromExtended(service), nil +} + +func (s *Service) Clone(ctx context.Context, input CloneInput) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + input.SourceID = strings.TrimSpace(input.SourceID) + input.DestClusterID = strings.TrimSpace(input.DestClusterID) + input.Name = strings.TrimSpace(input.Name) + if input.SourceID == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + if input.DestClusterID == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingCluster} + } + if input.Name == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingCreateFields} + } + if input.Namespace == "" { + input.Namespace = "default" + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + attrs := gqlclient.ServiceCloneAttributes{Name: input.Name, Namespace: lo.ToPtr(input.Namespace)} + frag, err := client.CloneService(input.DestClusterID, &input.SourceID, nil, nil, attrs) + if err != nil { + return Detail{}, err + } + if frag == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingService} + } + return s.Get(ctx, frag.ID) +} + +func (s *Service) DownloadTarball(ctx context.Context, id, dir string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + id = strings.TrimSpace(id) + if id == "" { + return "", &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return "", err + } + service, err := client.GetClusterService(&id, nil, nil) + if err != nil { + return "", err + } + if service == nil { + return "", &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingService} + } + if service.Tarball == nil || strings.TrimSpace(*service.Tarball) == "" { + return "", &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingTarball} + } + dir = strings.TrimSpace(dir) + if dir == "" { + dir = filepath.Join(".", service.Name+"-tarball") + } + if err := utils.EnsureEmptyDir(dir); err != nil { + return "", err + } + if service.Cluster == nil { + return "", &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingCluster} + } + token, err := client.GetDeployToken(&service.Cluster.ID, nil) + if err != nil { + return "", err + } + resp, err := utils.ReadRemoteFileWithRetries(*service.Tarball, token, 3) + if err != nil { + return "", err + } + defer func(c io.Closer) { _ = c.Close() }(resp) + if err := utils.Untar(dir, resp); err != nil { + return "", err + } + abs, err := filepath.Abs(dir) + if err != nil { + return dir, nil + } + if _, err := os.Stat(abs); err != nil { + return "", fmt.Errorf("tarball directory missing after unpack: %w", err) + } + return abs, nil +} diff --git a/pkg/bridge/services/errors.go b/pkg/bridge/services/errors.go index f748f17e6..3df66e14c 100644 --- a/pkg/bridge/services/errors.go +++ b/pkg/bridge/services/errors.go @@ -3,8 +3,10 @@ package services import "errors" var ( - errNoConsole = errors.New("connect a Console profile before browsing Console resources") - errMissingID = errors.New("service id is required") - errMissingCluster = errors.New("cluster id is required") - errMissingService = errors.New("service was not found") + errNoConsole = errors.New("connect a Console profile before browsing Console resources") + errMissingID = errors.New("service id is required") + errMissingCluster = errors.New("cluster id is required") + errMissingService = errors.New("service was not found") + errMissingCreateFields = errors.New("name, repository id, git ref, and git folder are required") + errMissingTarball = errors.New("service does not have a tarball") ) diff --git a/pkg/bridge/services/services.go b/pkg/bridge/services/services.go index af64bf5a1..cdb8e3e1c 100644 --- a/pkg/bridge/services/services.go +++ b/pkg/bridge/services/services.go @@ -1,4 +1,4 @@ -// Package services exposes read-only Console service list/get use cases to +// Package services exposes Console service list/get/mutation use cases to // presentation layers without importing TUI code. package services @@ -40,6 +40,7 @@ type ServiceError struct { // Detail is the credential-free detail payload for a service deployment. type Detail struct { Summary + ClusterID string ClusterName string ClusterHandle string RevisionSHA string @@ -57,25 +58,11 @@ type Page struct { TotalShown int } -// Loader is the narrow contract consumed by the Services screen. -type Loader interface { - ListClusters(ctx context.Context, query string) ([]Cluster, error) - List(ctx context.Context, clusterID string, after *string, query string) (Page, error) - Get(ctx context.Context, id string) (Detail, error) -} - // ConsoleResolver supplies the active Console URL and token. type ConsoleResolver interface { ActiveConsole(ctx context.Context) (url, token string, err error) } -// API is the Console surface required by this package. -type API interface { - ListClusters() (*gqlclient.ListClusters, error) - ListClusterServices(clusterId, handle *string) ([]*gqlclient.ServiceDeploymentEdgeFragment, error) - GetClusterService(serviceId, serviceName, clusterName *string) (*gqlclient.ServiceDeploymentExtended, error) -} - // ClientFactory builds a Console API for an authenticated endpoint. type ClientFactory func(token, url string) (API, error) @@ -254,6 +241,7 @@ func detailFromExtended(service *gqlclient.ServiceDeploymentExtended) Detail { detail.GitFolder = service.Git.Folder } if service.Cluster != nil { + detail.ClusterID = service.Cluster.ID detail.ClusterName = service.Cluster.Name if service.Cluster.Handle != nil { detail.ClusterHandle = *service.Cluster.Handle diff --git a/pkg/bridge/services/services_test.go b/pkg/bridge/services/services_test.go index 29dc6bef6..ae63cf637 100644 --- a/pkg/bridge/services/services_test.go +++ b/pkg/bridge/services/services_test.go @@ -19,11 +19,11 @@ func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) { } type fakeAPI struct { - clusters *gqlclient.ListClusters - edges []*gqlclient.ServiceDeploymentEdgeFragment - listErr error - detail *gqlclient.ServiceDeploymentExtended - getErr error + clusters *gqlclient.ListClusters + edges []*gqlclient.ServiceDeploymentEdgeFragment + listErr error + detail *gqlclient.ServiceDeploymentExtended + getErr error clusterID string } @@ -39,6 +39,25 @@ func (f *fakeAPI) ListClusterServices(clusterId, _ *string) ([]*gqlclient.Servic func (f *fakeAPI) GetClusterService(*string, *string, *string) (*gqlclient.ServiceDeploymentExtended, error) { return f.detail, f.getErr } +func (f *fakeAPI) KickClusterService(*string, *string, *string) (*gqlclient.ServiceDeploymentExtended, error) { + return f.detail, f.getErr +} +func (f *fakeAPI) DeleteClusterService(string) (*gqlclient.DeleteServiceDeployment, error) { + return &gqlclient.DeleteServiceDeployment{}, f.getErr +} +func (f *fakeAPI) CreateClusterService(*string, *string, gqlclient.ServiceDeploymentAttributes) (*gqlclient.ServiceDeploymentExtended, error) { + return f.detail, f.getErr +} +func (f *fakeAPI) UpdateClusterService(*string, *string, *string, gqlclient.ServiceUpdateAttributes) (*gqlclient.ServiceDeploymentExtended, error) { + return f.detail, f.getErr +} +func (f *fakeAPI) CloneService(string, *string, *string, *string, gqlclient.ServiceCloneAttributes) (*gqlclient.ServiceDeploymentFragment, error) { + if f.detail == nil { + return nil, f.getErr + } + return &gqlclient.ServiceDeploymentFragment{ID: f.detail.ID, Name: f.detail.Name, Namespace: f.detail.Namespace}, f.getErr +} +func (f *fakeAPI) GetDeployToken(*string, *string) (string, error) { return "token", f.getErr } func TestListClustersAndScopedServices(t *testing.T) { handle := "prod-eu" @@ -87,8 +106,8 @@ func TestGetMapsDetail(t *testing.T) { sha := "abc123" api := &fakeAPI{detail: &gqlclient.ServiceDeploymentExtended{ ID: "svc-1", Name: "api", Namespace: "default", Status: gqlclient.ServiceDeploymentStatusFailed, - Git: &gqlclient.GitRefFragment{Ref: "main", Folder: "services/api"}, - Cluster: &gqlclient.BaseClusterFragment{Name: "prod", Handle: &handle}, + Git: &gqlclient.GitRefFragment{Ref: "main", Folder: "services/api"}, + Cluster: &gqlclient.BaseClusterFragment{Name: "prod", Handle: &handle}, Revision: &gqlclient.RevisionFragment{ID: "rev-1", Sha: &sha, Git: &gqlclient.RevisionFragment_Git{Ref: "main"}}, Components: []*gqlclient.ServiceDeploymentExtended_Components{ {Synced: true}, diff --git a/tui/screens/deployments/model.go b/tui/screens/deployments/model.go index 07f882a44..a77178f26 100644 --- a/tui/screens/deployments/model.go +++ b/tui/screens/deployments/model.go @@ -32,7 +32,7 @@ type resource struct { func resources() []resource { return []resource{ - {id: resourceServices, number: "1", shortcut: "s", title: "Services", blurb: "list · describe · (kick later)", route: navigation.Services}, + {id: resourceServices, number: "1", shortcut: "s", title: "Services", blurb: "browse · kick · create · …", route: navigation.Services}, {id: resourceClusters, number: "2", shortcut: "c", title: "Clusters", blurb: "list · describe", soon: true}, {id: resourceRepositories, number: "3", shortcut: "r", title: "Repositories", blurb: "list · get", soon: true}, {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "trigger", soon: true}, diff --git a/tui/screens/deployments/testdata/deployments-120.golden b/tui/screens/deployments/testdata/deployments-120.golden index c8f415ecd..a1d1c156c 100644 --- a/tui/screens/deployments/testdata/deployments-120.golden +++ b/tui/screens/deployments/testdata/deployments-120.golden @@ -2,7 +2,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╭─ › Resources ────────────────────────────────────────────────────────────────────────────────────────────────────╮ - │ › 1 s Services list · describe · (kick later) │ + │ › 1 s Services browse · kick · create · … │ │ 2 c Clusters list · describe [soon] │ │ 3 r Repositories list · get [soon] │ │ 4 p Pipelines trigger [soon] │ diff --git a/tui/screens/deployments/testdata/deployments-80.golden b/tui/screens/deployments/testdata/deployments-80.golden index fc893a289..0fbe23392 100644 --- a/tui/screens/deployments/testdata/deployments-80.golden +++ b/tui/screens/deployments/testdata/deployments-80.golden @@ -2,7 +2,7 @@ ──────────────────────────────────────────────────────────────────────────── ╭─ › Resources ────────────────────────────────────────────────────────────╮ - │ › 1 s Services list · describe · (kick later) │ + │ › 1 s Services browse · kick · create · … │ │ 2 c Clusters list · describe [soon] │ │ 3 r Repositories list · get [soon] │ │ 4 p Pipelines trigger [soon] │ diff --git a/tui/screens/services/actions.go b/tui/screens/services/actions.go new file mode 100644 index 000000000..092d50b0a --- /dev/null +++ b/tui/screens/services/actions.go @@ -0,0 +1,100 @@ +package services + +import ( + "fmt" + "strings" + + servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" +) + +type actionKind uint8 + +const ( + actionKick actionKind = iota + actionEdit + actionClone + actionTarball + actionWorkbench + actionDelete + actionCreate +) + +type detailAction struct { + kind actionKind + shortcut string + title string + blurb string + danger bool +} + +func detailActions() []detailAction { + return []detailAction{ + {kind: actionKick, shortcut: "k", title: "Kick", blurb: "force sync now"}, + {kind: actionEdit, shortcut: "e", title: "Edit", blurb: "git ref · folder · config · version"}, + {kind: actionClone, shortcut: "c", title: "Clone", blurb: "onto another cluster"}, + {kind: actionTarball, shortcut: "t", title: "Tarball", blurb: "download locally"}, + {kind: actionWorkbench, shortcut: "m", title: "Template…", blurb: "liquid / tpl / lua workbench"}, + {kind: actionDelete, shortcut: "d", title: "Delete", blurb: "remove service", danger: true}, + } +} + +type pendingOp struct { + kind actionKind + title string + cli string + lines []string + danger bool + create *servicesbridge.CreateInput + update *servicesbridge.UpdateInput + clone *servicesbridge.CloneInput + tarball string + deleteID string + kickID string +} + +func (m Model) kickPlan() pendingOp { + d := m.detail + cluster := clusterLabel(servicesbridge.Cluster{Handle: d.ClusterHandle, Name: d.ClusterName, ID: d.ClusterID}) + cli := "plural cd services kick " + d.ID + if d.ClusterHandle != "" { + cli = fmt.Sprintf("plural cd services kick @%s/%s", d.ClusterHandle, d.Name) + } + return pendingOp{ + kind: actionKick, + title: "Force sync · " + d.Name, + cli: cli, + kickID: d.ID, + lines: []string{ + "Action Kick / force sync", + "Cluster " + cluster, + "Service " + d.Name + " · " + d.Namespace, + "Revision " + loCoalesce(d.RevisionSHA, "—"), + "Status " + loCoalesce(d.Status, "—"), + }, + } +} + +func (m Model) deletePlan() pendingOp { + d := m.detail + return pendingOp{ + kind: actionDelete, + title: "Delete · " + d.Name, + cli: "plural cd services delete " + d.ID, + danger: true, + deleteID: d.ID, + lines: []string{ + "Action Delete service", + "Service " + d.Name + " · " + clusterLabel(servicesbridge.Cluster{Handle: d.ClusterHandle, Name: d.ClusterName}), + "Note Cluster workloads are not uninstalled automatically.", + }, + } +} + +func loCoalesce(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} diff --git a/tui/screens/services/model.go b/tui/screens/services/model.go index 4d8d15f27..45e3c5a9a 100644 --- a/tui/screens/services/model.go +++ b/tui/screens/services/model.go @@ -1,8 +1,10 @@ -// Package services implements the Phase 2 read-only Console services browser. +// Package services implements Console service browsing and contextual actions. package services import ( "context" + "fmt" + "path/filepath" "strings" "charm.land/bubbles/v2/textinput" @@ -21,6 +23,15 @@ const ( modeList modeDetail modeFilter + modeReview + modeOperating + modeResult + modeDeleteConfirm + modeTarball + modeCreate + modeEdit + modeClone + modeWorkbench ) type keyAction uint8 @@ -36,18 +47,22 @@ const ( keyActionNextPage keyActionPrevPage keyActionConnectConsole + keyActionCreate + keyActionBackground ) var keyActionKeystrokes = map[keyAction][]string{ keyActionBack: {"esc"}, - keyActionMoveUp: {"up", "k"}, - keyActionMoveDown: {"down", "j"}, + keyActionMoveUp: {"up"}, + keyActionMoveDown: {"down"}, keyActionConfirm: {"enter"}, keyActionRefresh: {"r"}, keyActionFilter: {"/"}, - keyActionNextPage: {"n", "right"}, - keyActionPrevPage: {"p", "left"}, + keyActionNextPage: {"right", "]"}, + keyActionPrevPage: {"left", "p", "["}, keyActionConnectConsole: {"c"}, + keyActionCreate: {"n"}, + keyActionBackground: {"b"}, } func actionForKeystroke(keystroke string) keyAction { @@ -77,8 +92,15 @@ type detailMsg struct { err error request uint64 } +type opDoneMsg struct { + err error + detail servicesbridge.Detail + path string + request uint64 + kind actionKind +} -// Model owns only Services-screen interaction state. +// Model owns Services-screen interaction state. type Model struct { ctx context.Context loader servicesbridge.Loader @@ -102,26 +124,49 @@ type Model struct { prevCursors []string request uint64 - detail servicesbridge.Detail - detailID string - listCursor int - listAfter *string - listFilter string - listPrev []string + detail servicesbridge.Detail + detailID string + listCursor int + listAfter *string + listFilter string + listPrev []string + actionCursor int + + pending pendingOp + opLog []string + result string + + formInput textinput.Model + formFields []formField + formIndex int + formValues map[string]string + formDryRun bool + wbTemplate bool + confirmName string +} + +type formField struct { + label string + key string } func New(ctx context.Context, loader servicesbridge.Loader, t theme.Theme) Model { input := textinput.New() input.Prompt = "› " input.Placeholder = "filter" - input.CharLimit = 128 + input.CharLimit = 256 styles := textinput.DefaultDarkStyles() styles.Focused.Text = t.Body styles.Focused.Prompt = t.Title styles.Focused.Placeholder = t.Muted styles.Blurred = styles.Focused input.SetStyles(styles) - return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, mode: modeClusters} + form := input + form.Placeholder = "" + return Model{ + ctx: ctx, loader: loader, theme: t, loading: loader != nil, + filterInput: input, formInput: form, mode: modeClusters, wbTemplate: true, + } } func (m Model) Init() tea.Cmd { @@ -167,6 +212,43 @@ func (m *Model) beginDetail(id string) tea.Cmd { } } +func (m *Model) beginPending() tea.Cmd { + m.mode = modeOperating + m.loading = true + m.err = nil + m.opLog = []string{"starting…"} + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + op := m.pending + detailID := m.detailID + return func() tea.Msg { + switch op.kind { + case actionKick: + detail, err := loader.Kick(ctx, op.kickID) + return opDoneMsg{err: err, detail: detail, request: request, kind: op.kind} + case actionDelete: + err := loader.Delete(ctx, op.deleteID) + return opDoneMsg{err: err, request: request, kind: op.kind} + case actionTarball: + path, err := loader.DownloadTarball(ctx, detailID, op.tarball) + return opDoneMsg{err: err, path: path, request: request, kind: op.kind} + case actionEdit: + detail, err := loader.Update(ctx, *op.update) + return opDoneMsg{err: err, detail: detail, request: request, kind: op.kind} + case actionClone: + detail, err := loader.Clone(ctx, *op.clone) + return opDoneMsg{err: err, detail: detail, request: request, kind: op.kind} + case actionCreate: + detail, err := loader.Create(ctx, *op.create) + return opDoneMsg{err: err, detail: detail, request: request, kind: op.kind} + default: + return opDoneMsg{err: fmt.Errorf("unsupported action"), request: request, kind: op.kind} + } + } +} + func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { case initMsg: @@ -220,15 +302,51 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) if msg.err == nil { m.detail = msg.detail + m.actionCursor = 0 m.mode = modeDetail } return m, nil + case opDoneMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.mode = modeResult + if msg.err != nil { + m.result = "failed" + m.opLog = append(m.opLog, "✗ "+msg.err.Error()) + return m, nil + } + m.result = "ok" + switch msg.kind { + case actionKick, actionEdit, actionCreate: + m.detail = msg.detail + m.detailID = msg.detail.ID + m.opLog = append(m.opLog, "✓ completed") + case actionClone: + m.detail = msg.detail + m.detailID = msg.detail.ID + m.opLog = append(m.opLog, "✓ cloned "+msg.detail.Name) + case actionDelete: + m.opLog = append(m.opLog, "✓ deleted") + case actionTarball: + m.opLog = append(m.opLog, "✓ wrote "+msg.path) + m.result = msg.path + default: + m.opLog = append(m.opLog, "✓ completed") + } + return m, nil case tea.KeyPressMsg: return m.updateKey(msg) } - if m.mode == modeFilter { + switch m.mode { + case modeFilter, modeDeleteConfirm, modeTarball, modeCreate, modeEdit, modeClone, modeWorkbench: var cmd tea.Cmd - m.filterInput, cmd = m.filterInput.Update(msg) + m.formInput, cmd = m.formInput.Update(msg) + if m.mode == modeFilter { + m.filterInput = m.formInput + } return m, cmd } return m, nil @@ -236,45 +354,34 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { action := actionForKeystroke(key.Keystroke()) - if m.mode == modeFilter { - switch action { - case keyActionBack: - m.mode = modeList - if m.filteringCluster { - m.mode = modeClusters - } - m.filterInput.Blur() - return m, nil - case keyActionConfirm: - value := strings.TrimSpace(m.filterInput.Value()) - m.filterInput.Blur() - if m.filteringCluster { - m.clusterFilter = value - m.mode = modeClusters - m.clusterCursor = 0 - return m, m.beginClusters() - } - m.serviceFilter = value - m.mode = modeList - m.after = nil - m.prevCursors = nil - m.cursor = 0 - return m, m.beginList(nil) - } - var cmd tea.Cmd - m.filterInput, cmd = m.filterInput.Update(key) - return m, cmd + text := key.Text + if text == "" && key.Code > 0 && key.Code < 128 { + text = string(rune(key.Code)) + } + + switch m.mode { + case modeFilter: + return m.updateFilter(action, key) + case modeReview: + return m.updateReview(action) + case modeOperating: + return m, nil + case modeResult: + return m.updateResult(action) + case modeDeleteConfirm: + return m.updateDeleteConfirm(action, key) + case modeTarball: + return m.updateTarball(action, key) + case modeCreate, modeEdit, modeClone: + return m.updateForm(action, key) + case modeWorkbench: + return m.updateWorkbench(action, key, text) + case modeDetail: + return m.updateDetail(action, text) } + if action == keyActionBack { switch m.mode { - case modeDetail: - m.mode = modeList - m.err = nil - m.cursor = m.listCursor - m.after = m.listAfter - m.serviceFilter = m.listFilter - m.prevCursors = append([]string(nil), m.listPrev...) - return m, nil case modeList: m.mode = modeClusters m.page = servicesbridge.Page{} @@ -290,19 +397,456 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { if m.loading { return m, nil } - if m.needsAuth && action == keyActionConnectConsole { + if m.needsAuth && (action == keyActionConnectConsole || text == "c") { return m, navigation.Navigate(navigation.Access) } - if m.mode == modeDetail { - if action == keyActionRefresh && m.detailID != "" { - return m, m.beginDetail(m.detailID) + if m.mode == modeClusters { + return m.updateClusters(action) + } + return m.updateList(action, text) +} + +func (m Model) updateFilter(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.mode = modeList + if m.filteringCluster { + m.mode = modeClusters } + m.filterInput.Blur() return m, nil + case keyActionConfirm: + value := strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + if m.filteringCluster { + m.clusterFilter = value + m.mode = modeClusters + m.clusterCursor = 0 + return m, m.beginClusters() + } + m.serviceFilter = value + m.mode = modeList + m.after = nil + m.prevCursors = nil + m.cursor = 0 + return m, m.beginList(nil) } - if m.mode == modeClusters { - return m.updateClusters(action) + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd +} + +func (m Model) updateDetail(action keyAction, text string) (Model, tea.Cmd) { + if action == keyActionBack { + m.mode = modeList + m.err = nil + m.cursor = m.listCursor + m.after = m.listAfter + m.serviceFilter = m.listFilter + m.prevCursors = append([]string(nil), m.listPrev...) + return m, nil + } + if m.loading { + return m, nil + } + if action == keyActionRefresh && m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + actions := detailActions() + switch action { + case keyActionMoveUp: + m.actionCursor = clampCursor(m.actionCursor-1, len(actions)) + return m, nil + case keyActionMoveDown: + m.actionCursor = clampCursor(m.actionCursor+1, len(actions)) + return m, nil + case keyActionConfirm: + return m.openAction(actions[m.actionCursor]) + } + for i, a := range actions { + if text == a.shortcut { + m.actionCursor = i + return m.openAction(a) + } + } + return m, nil +} + +func (m Model) openAction(a detailAction) (Model, tea.Cmd) { + switch a.kind { + case actionKick: + m.pending = m.kickPlan() + m.mode = modeReview + return m, nil + case actionDelete: + m.mode = modeDeleteConfirm + m.confirmName = "" + m.formInput.SetValue("") + m.formInput.Placeholder = m.detail.Name + m.formInput.Focus() + return m, nil + case actionTarball: + m.mode = modeTarball + dir := filepath.Join(".", m.detail.Name+"-tarball") + m.formInput.SetValue(dir) + m.formInput.Placeholder = dir + m.formInput.Focus() + return m, nil + case actionEdit: + return m.beginEditForm(), nil + case actionClone: + return m.beginCloneForm(), nil + case actionWorkbench: + m.mode = modeWorkbench + m.wbTemplate = true + m.formInput.SetValue("") + m.formInput.Placeholder = "./values.yaml.liquid" + m.formInput.Focus() + return m, nil } - return m.updateList(action) + return m, nil +} + +func (m Model) updateReview(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.mode = modeDetail + return m, nil + case keyActionConfirm: + return m, m.beginPending() + } + return m, nil +} + +func (m Model) updateResult(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionBack: + if m.pending.kind == actionDelete && m.result == "ok" { + m.mode = modeList + return m, m.beginList(m.after) + } + if m.pending.create != nil && m.result == "ok" { + m.mode = modeDetail + return m, nil + } + m.mode = modeDetail + return m, nil + case keyActionConfirm: + if m.result == "ok" { + if m.pending.kind == actionDelete { + m.mode = modeList + return m, m.beginList(m.after) + } + m.mode = modeDetail + if m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + return m, nil + } + m.mode = modeReview + return m, nil + case keyActionRefresh: + if m.result != "ok" { + m.mode = modeReview + return m, nil + } + } + return m, nil +} + +func (m Model) updateDeleteConfirm(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.formInput.Blur() + m.mode = modeDetail + return m, nil + case keyActionConfirm: + if strings.TrimSpace(m.formInput.Value()) != m.detail.Name { + m.err = fmt.Errorf("name does not match %q", m.detail.Name) + return m, nil + } + m.formInput.Blur() + m.err = nil + m.pending = m.deletePlan() + m.mode = modeReview + return m, nil + } + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd +} + +func (m Model) updateTarball(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.formInput.Blur() + m.mode = modeDetail + return m, nil + case keyActionConfirm: + dir := strings.TrimSpace(m.formInput.Value()) + if dir == "" { + dir = filepath.Join(".", m.detail.Name+"-tarball") + } + m.formInput.Blur() + m.pending = pendingOp{ + kind: actionTarball, + title: "Download tarball · " + m.detail.Name, + cli: "plural cd services tarball " + m.detail.ID + " --dir " + dir, + tarball: dir, + lines: []string{ + "Action Download tarball", + "Service " + m.detail.Name, + "Directory " + dir, + }, + } + m.mode = modeReview + return m, nil + } + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd +} + +func (m Model) beginCreateForm() Model { + m.mode = modeCreate + m.formFields = []formField{ + {label: "Name", key: "name"}, + {label: "Namespace", key: "namespace"}, + {label: "Repo ID", key: "repo"}, + {label: "Git ref", key: "ref"}, + {label: "Git folder", key: "folder"}, + {label: "Kustomize", key: "kustomize"}, + {label: "Version", key: "version"}, + } + m.formIndex = 0 + m.formDryRun = false + m.formValues = map[string]string{"namespace": "default", "version": "0.0.1", "ref": "main"} + m.formInput.SetValue("") + m.formInput.Placeholder = "service name" + m.formInput.Focus() + m.err = nil + return m +} + +func (m Model) beginEditForm() Model { + m.mode = modeEdit + m.formFields = []formField{ + {label: "Git ref", key: "ref"}, + {label: "Git folder", key: "folder"}, + {label: "Kustomize", key: "kustomize"}, + {label: "Version", key: "version"}, + } + m.formIndex = 0 + m.formDryRun = false + m.formValues = map[string]string{ + "ref": m.detail.GitRef, + "folder": m.detail.GitFolder, + "version": "0.0.1", + } + m.formInput.SetValue(m.formValues["ref"]) + m.formInput.Placeholder = "git ref" + m.formInput.Focus() + m.err = nil + return m +} + +func (m Model) beginCloneForm() Model { + m.mode = modeClone + m.formFields = []formField{ + {label: "Dest cluster ID", key: "cluster"}, + {label: "Name", key: "name"}, + {label: "Namespace", key: "namespace"}, + } + m.formIndex = 0 + m.formValues = map[string]string{ + "name": m.detail.Name + "-clone", + "namespace": m.detail.Namespace, + "cluster": m.detail.ClusterID, + } + m.formInput.SetValue(m.formValues["cluster"]) + m.formInput.Placeholder = "destination cluster id" + m.formInput.Focus() + m.err = nil + return m +} + +func (m Model) updateForm(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.formInput.Blur() + if m.mode == modeCreate { + m.mode = modeList + } else { + m.mode = modeDetail + } + return m, nil + case keyActionConfirm: + m.saveFormField() + if m.formIndex < len(m.formFields)-1 { + m.formIndex++ + m.loadFormField() + return m, nil + } + return m.submitForm() + case keyActionMoveDown: + m.saveFormField() + if m.formIndex < len(m.formFields)-1 { + m.formIndex++ + m.loadFormField() + } + return m, nil + case keyActionMoveUp: + m.saveFormField() + if m.formIndex > 0 { + m.formIndex-- + m.loadFormField() + } + return m, nil + } + if key.Text == "d" && key.Mod == tea.ModCtrl { + // ignore + } + if key.Keystroke() == "ctrl+d" { + m.formDryRun = !m.formDryRun + return m, nil + } + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd +} + +func (m *Model) saveFormField() { + if m.formValues == nil { + m.formValues = map[string]string{} + } + if m.formIndex >= 0 && m.formIndex < len(m.formFields) { + m.formValues[m.formFields[m.formIndex].key] = strings.TrimSpace(m.formInput.Value()) + } +} + +func (m *Model) loadFormField() { + if m.formIndex < 0 || m.formIndex >= len(m.formFields) { + return + } + field := m.formFields[m.formIndex] + m.formInput.SetValue(m.formValues[field.key]) + m.formInput.Placeholder = field.label + m.formInput.Focus() +} + +func (m Model) submitForm() (Model, tea.Cmd) { + m.formInput.Blur() + switch m.mode { + case modeCreate: + input := servicesbridge.CreateInput{ + ClusterID: m.cluster.ID, + Name: m.formValues["name"], + Namespace: m.formValues["namespace"], + RepoID: m.formValues["repo"], + GitRef: m.formValues["ref"], + GitFolder: m.formValues["folder"], + Kustomize: m.formValues["kustomize"], + Version: m.formValues["version"], + DryRun: m.formDryRun, + } + m.pending = pendingOp{ + kind: actionCreate, + title: "Create service · " + input.Name, + cli: fmt.Sprintf("plural cd services create %s --name %s --repo-id %s --git-ref %s --git-folder %s", clusterLabel(m.cluster), input.Name, input.RepoID, input.GitRef, input.GitFolder), + create: &input, + lines: []string{ + "Action Create service", + "Cluster " + clusterLabel(m.cluster), + "Name " + input.Name, + "Namespace " + input.Namespace, + "Repo " + input.RepoID, + "Git " + input.GitRef + " / " + input.GitFolder, + fmt.Sprintf("Dry-run %v (Console attribute)", input.DryRun), + }, + } + m.mode = modeReview + return m, nil + case modeEdit: + dry := m.formDryRun + input := servicesbridge.UpdateInput{ + ID: m.detail.ID, + GitRef: m.formValues["ref"], + GitFolder: m.formValues["folder"], + Kustomize: m.formValues["kustomize"], + Version: m.formValues["version"], + DryRun: &dry, + } + m.pending = pendingOp{ + kind: actionEdit, + title: "Update · " + m.detail.Name, + cli: "plural cd services update " + m.detail.ID, + update: &input, + lines: []string{ + "Action Update service", + "Service " + m.detail.Name, + "Git " + input.GitRef + " / " + input.GitFolder, + "Version " + input.Version, + fmt.Sprintf("Dry-run %v", dry), + }, + } + m.mode = modeReview + return m, nil + case modeClone: + input := servicesbridge.CloneInput{ + SourceID: m.detail.ID, + DestClusterID: m.formValues["cluster"], + Name: m.formValues["name"], + Namespace: m.formValues["namespace"], + } + m.pending = pendingOp{ + kind: actionClone, + title: "Clone · " + m.detail.Name, + cli: "plural cd services clone " + input.DestClusterID + " " + m.detail.ID + " --name " + input.Name, + clone: &input, + lines: []string{ + "Action Clone service", + "Source " + m.detail.Name, + "Dest " + input.DestClusterID, + "Name " + input.Name, + "Namespace " + input.Namespace, + }, + } + m.mode = modeReview + return m, nil + } + return m, nil +} + +func (m Model) updateWorkbench(action keyAction, key tea.KeyPressMsg, text string) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.formInput.Blur() + m.mode = modeDetail + return m, nil + case keyActionConfirm: + m.mode = modeResult + m.result = "ok" + m.pending = pendingOp{kind: actionWorkbench, title: "Workbench · " + m.detail.Name} + file := strings.TrimSpace(m.formInput.Value()) + mode := "template" + if !m.wbTemplate { + mode = "lua" + } + m.opLog = []string{ + "Dry-run workbench — rendering is CLI-backed for now.", + "", + fmt.Sprintf(" plural cd services %s --file %q --service %s/%s", mode, file, clusterLabel(servicesbridge.Cluster{Handle: m.detail.ClusterHandle, Name: m.detail.ClusterName}), m.detail.Name), + } + m.formInput.Blur() + return m, nil + } + if text == "tab" || key.Keystroke() == "tab" { + m.wbTemplate = !m.wbTemplate + return m, nil + } + var cmd tea.Cmd + m.formInput, cmd = m.formInput.Update(key) + return m, cmd } func (m Model) updateClusters(action keyAction) (Model, tea.Cmd) { @@ -329,11 +873,18 @@ func (m Model) updateClusters(action keyAction) (Model, tea.Cmd) { m.filterInput.Placeholder = "filter clusters" m.filterInput.SetValue(m.clusterFilter) m.filterInput.Focus() + m.formInput = m.filterInput } return m, nil } -func (m Model) updateList(action keyAction) (Model, tea.Cmd) { +func (m Model) updateList(action keyAction, text string) (Model, tea.Cmd) { + if action == keyActionCreate || text == "n" { + if m.cluster.ID == "" { + return m, nil + } + return m.beginCreateForm(), nil + } switch action { case keyActionMoveUp: m.cursor = clampCursor(m.cursor-1, len(m.page.Items)) @@ -357,6 +908,7 @@ func (m Model) updateList(action keyAction) (Model, tea.Cmd) { m.filterInput.Placeholder = "filter services" m.filterInput.SetValue(m.serviceFilter) m.filterInput.Focus() + m.formInput = m.filterInput case keyActionNextPage: if !m.page.HasNext || m.page.EndCursor == "" { return m, nil diff --git a/tui/screens/services/model_test.go b/tui/screens/services/model_test.go index 6a5207d7c..efda7f815 100644 --- a/tui/screens/services/model_test.go +++ b/tui/screens/services/model_test.go @@ -21,6 +21,8 @@ type fakeLoader struct { detail servicesbridge.Detail err error listedID string + kicked string + deleted string } func (f *fakeLoader) ListClusters(context.Context, string) ([]servicesbridge.Cluster, error) { @@ -33,6 +35,26 @@ func (f *fakeLoader) List(_ context.Context, clusterID string, _ *string, _ stri func (f *fakeLoader) Get(context.Context, string) (servicesbridge.Detail, error) { return f.detail, f.err } +func (f *fakeLoader) Kick(_ context.Context, id string) (servicesbridge.Detail, error) { + f.kicked = id + return f.detail, f.err +} +func (f *fakeLoader) Delete(_ context.Context, id string) error { + f.deleted = id + return f.err +} +func (f *fakeLoader) Create(context.Context, servicesbridge.CreateInput) (servicesbridge.Detail, error) { + return f.detail, f.err +} +func (f *fakeLoader) Update(context.Context, servicesbridge.UpdateInput) (servicesbridge.Detail, error) { + return f.detail, f.err +} +func (f *fakeLoader) Clone(context.Context, servicesbridge.CloneInput) (servicesbridge.Detail, error) { + return f.detail, f.err +} +func (f *fakeLoader) DownloadTarball(context.Context, string, string) (string, error) { + return "/tmp/tarball", f.err +} func loadClusters(t *testing.T, model Model) Model { t.Helper() @@ -111,6 +133,60 @@ func TestNoConsoleNavigatesToAccess(t *testing.T) { } } +func TestKickFromDetail(t *testing.T) { + loader := &fakeLoader{ + clusters: []servicesbridge.Cluster{{ID: "c1", Name: "production", Handle: "prod-eu"}}, + page: servicesbridge.Page{Items: []servicesbridge.Summary{{ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY"}}}, + detail: servicesbridge.Detail{Summary: servicesbridge.Summary{ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY"}, ClusterHandle: "prod-eu"}, + } + model := loadClusters(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail { + t.Fatalf("mode = %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: 'k', Text: "k"}) + if model.mode != modeReview || model.pending.kind != actionKick { + t.Fatalf("review = mode=%d kind=%d", model.mode, model.pending.kind) + } + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("expected kick command") + } + model, _ = model.Update(cmd()) + if model.mode != modeResult || loader.kicked != "1" || model.result != "ok" { + t.Fatalf("result mode=%d kicked=%q result=%q", model.mode, loader.kicked, model.result) + } +} + +func TestDeleteRequiresTypedName(t *testing.T) { + loader := &fakeLoader{ + clusters: []servicesbridge.Cluster{{ID: "c1", Handle: "prod-eu"}}, + page: servicesbridge.Page{Items: []servicesbridge.Summary{{ID: "1", Name: "api"}}}, + detail: servicesbridge.Detail{Summary: servicesbridge.Summary{ID: "1", Name: "api"}}, + } + model := loadClusters(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + model, _ = model.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + if model.mode != modeDeleteConfirm { + t.Fatalf("mode = %d", model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.err == nil { + t.Fatal("expected name mismatch error") + } + model.formInput.SetValue("api") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeReview || model.pending.kind != actionDelete { + t.Fatalf("expected delete review, mode=%d kind=%d", model.mode, model.pending.kind) + } +} + func TestBackFromClustersReturnsDeployments(t *testing.T) { model := loadClusters(t, New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII))) _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) diff --git a/tui/screens/services/testdata/services-detail-120.golden b/tui/screens/services/testdata/services-detail-120.golden index fe9d6e42d..4df4d543e 100644 --- a/tui/screens/services/testdata/services-detail-120.golden +++ b/tui/screens/services/testdata/services-detail-120.golden @@ -1,21 +1,24 @@ - Plural Services HEALTHY + Plural Services · api HEALTHY ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - ╭─ › api ──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + ╭─ Summary ────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Status HEALTHY │ │ Namespace default │ - │ Cluster prod-eu · production │ + │ Cluster @prod-eu · production │ │ Revision main · 91ca21f0 │ │ Git main / services/api │ │ Components 13 / 14 synced │ - │ │ - │ Errors │ - │ ✗ Deployment/api exceeded rollout deadline │ - │ │ - │ │ - │ │ + │ 1 errors │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭─ › Actions ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ › k Kick force sync now │ + │ e Edit git ref · folder · config · version │ + │ c Clone onto another cluster │ + │ t Tarball download locally │ + │ m Template… liquid / tpl / lua workbench │ + │ d Delete remove service [destructive] │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -24,7 +27,4 @@ - - - - r refresh · esc back · ctrl+c quit + ↑/↓ actions · enter · k e c t m d · r refresh · esc list diff --git a/tui/screens/services/testdata/services-detail-80.golden b/tui/screens/services/testdata/services-detail-80.golden index 3b6813cbd..7fbcd5ffa 100644 --- a/tui/screens/services/testdata/services-detail-80.golden +++ b/tui/screens/services/testdata/services-detail-80.golden @@ -1,24 +1,24 @@ - Plural Services HEALTHY + Plural Services · api HEALTHY ──────────────────────────────────────────────────────────────────────────── - ╭─ › api ──────────────────────────────────────────────────────────────────╮ + ╭─ Summary ────────────────────────────────────────────────────────────────╮ │ Status HEALTHY │ │ Namespace default │ - │ Cluster prod-eu · production │ + │ Cluster @prod-eu · production │ │ Revision main · 91ca21f0 │ │ Git main / services/api │ │ Components 13 / 14 synced │ - │ │ - │ Errors │ - │ ✗ Deployment/api exceeded rollout deadline │ - │ │ - │ │ - │ │ + │ 1 errors │ ╰──────────────────────────────────────────────────────────────────────────╯ + ╭─ › Actions ──────────────────────────────────────────────────────────────╮ + │ › k Kick force sync now │ + │ e Edit git ref · folder · config · version │ + │ c Clone onto another cluster │ + │ t Tarball download locally │ + │ m Template… liquid / tpl / lua workbench │ + │ d Delete remove service [destructive] │ + ╰──────────────────────────────────────────────────────────────────────────╯ - - - - r refresh · esc back · ctrl+c quit + ↑/↓ · enter · letters · r · esc diff --git a/tui/screens/services/testdata/services-list-120.golden b/tui/screens/services/testdata/services-list-120.golden index a4eb8fbd8..48cab48b5 100644 --- a/tui/screens/services/testdata/services-list-120.golden +++ b/tui/screens/services/testdata/services-list-120.golden @@ -7,7 +7,7 @@ │ worker jobs FAILED main services/worker │ │ canary default SYNCED release services/canary │ │ │ - │ page · n next │ + │ page · ] next │ │ │ │ │ │ │ @@ -27,4 +27,4 @@ - ↑/↓ select · enter open · / filter · n/p page · r refresh · esc clusters + ↑/↓ · enter detail · n create · / filter · ]/[ page · r refresh · esc diff --git a/tui/screens/services/testdata/services-list-80.golden b/tui/screens/services/testdata/services-list-80.golden index ac83618ff..4692ffe30 100644 --- a/tui/screens/services/testdata/services-list-80.golden +++ b/tui/screens/services/testdata/services-list-80.golden @@ -7,7 +7,7 @@ │ worker jobs FAILED main services/wor… │ │ canary default SYNCED release services/… │ │ │ - │ page · n next │ + │ page · ] next │ │ │ │ │ │ │ @@ -21,4 +21,4 @@ - ↑/↓ · enter · / filter · n/p page · esc clusters + ↑/↓ · enter · n create · / · esc diff --git a/tui/screens/services/view.go b/tui/screens/services/view.go index d27ce4e89..11ed73646 100644 --- a/tui/screens/services/view.go +++ b/tui/screens/services/view.go @@ -18,20 +18,58 @@ func (m Model) View(width, height int) string { contentWidth := page.ContentWidth(width) status := m.headerStatus() body, help := m.bodyAndHelp(contentWidth) - return page.Render(m.theme, width, height, "Services", status, body, help) + title := "Services" + switch m.mode { + case modeReview, modeOperating, modeResult: + title = m.pending.title + if title == "" { + title = "Services" + } + case modeDeleteConfirm: + title = "Delete · " + m.detail.Name + case modeTarball: + title = "Download tarball · " + m.detail.Name + case modeCreate: + title = "Create service · " + clusterLabel(m.cluster) + case modeEdit: + title = "Edit · " + m.detail.Name + case modeClone: + title = "Clone · " + m.detail.Name + case modeWorkbench: + title = "Workbench · " + m.detail.Name + case modeDetail: + title = "Services · " + m.detail.Name + } + return page.Render(m.theme, width, height, title, status, body, help) } func (m Model) headerStatus() string { - if m.loading { - return m.theme.Warning.Render("◌ loading") + if m.loading || m.mode == modeOperating { + return m.theme.Warning.Render("◌ working") } if m.needsAuth { return m.theme.Warning.Render("○ connect Console") } - if m.err != nil { - return m.theme.Danger.Render("✗ load failed") + if m.err != nil && m.mode != modeResult { + return m.theme.Danger.Render("✗ attention") } switch m.mode { + case modeReview: + if m.pending.danger { + return m.theme.Danger.Render("destructive") + } + return m.theme.Warning.Render("review") + case modeResult: + if m.result == "ok" || (m.result != "failed" && m.result != "") { + if m.pending.kind == actionTarball && m.result != "ok" { + return m.theme.Success.Render("saved") + } + if m.result == "failed" { + return m.theme.Danger.Render("failed") + } + return m.theme.Success.Render("done") + } + return m.theme.Danger.Render("failed") case modeDetail: return m.statusBadge(m.detail.Status) case modeList: @@ -44,8 +82,10 @@ func (m Model) headerStatus() string { return m.theme.Muted.Render(fmt.Sprintf("%d matching clusters", len(m.clusters))) } return m.theme.Success.Render(fmt.Sprintf("%d clusters", len(m.clusters))) + case modeWorkbench: + return m.theme.Muted.Render("dry-run only") default: - return m.theme.Muted.Render("select cluster") + return m.theme.Muted.Render("services") } } @@ -69,21 +109,134 @@ func (m Model) bodyAndHelp(width int) (string, string) { } return page.Panel(m.theme, "Console required", lines, width, 8, true), "c connect · esc back · ctrl+c quit" } - if m.mode == modeDetail { - return page.Panel(m.theme, m.detail.Name, m.detailLines(), width, 14, true), "r refresh · esc back · ctrl+c quit" - } - if m.mode == modeClusters { + switch m.mode { + case modeReview: + lines := append([]string{}, m.pending.lines...) + lines = append(lines, "", m.theme.Muted.Render("Equivalent CLI"), " "+m.pending.cli) + return page.Panel(m.theme, "Plan (immutable)", lines, width, 12, true), "enter confirm · esc back" + case modeOperating: + lines := []string{m.theme.Warning.Render("● Running…"), ""} + lines = append(lines, m.opLog...) + return page.Panel(m.theme, "Operation", lines, width, 10, true), "ctrl+c quit" + case modeResult: + head := m.theme.Success.Render("✓ Success") + help := "enter detail · esc back" + if m.result == "failed" { + head = m.theme.Danger.Render("✗ Failed") + help = "enter retry review · esc detail" + } else if m.pending.kind == actionDelete { + help = "enter list · esc list" + } else if m.pending.kind == actionTarball { + head = m.theme.Success.Render("✓ Wrote " + m.result) + } else if m.pending.kind == actionWorkbench { + head = m.theme.Muted.Render("CLI equivalent") + help = "esc detail" + } + lines := []string{head, ""} + lines = append(lines, m.opLog...) + return page.Panel(m.theme, "Result", lines, width, 12, true), help + case modeDeleteConfirm: + lines := []string{ + m.theme.Danger.Render("This permanently deletes the Console service record."), + m.theme.Muted.Render("Cluster workloads are not automatically uninstalled."), + "", + "Service " + m.detail.Name + " · " + clusterLabel(m.cluster), + "Type the service name to confirm:", + "", + m.formInput.View(), + } + if m.err != nil { + lines = append(lines, "", m.theme.Danger.Render(m.err.Error())) + } + return page.Panel(m.theme, "Confirm deletion", lines, width, 11, true), "enter continue · esc cancel" + case modeTarball: + lines := []string{ + "Directory " + m.formInput.View(), + "", + m.theme.Muted.Render("Fetches deploy token + tarball and unpacks into that directory."), + } + return page.Panel(m.theme, "Destination", lines, width, 7, true), "enter review · esc cancel" + case modeCreate, modeEdit, modeClone: + return m.formView(width) + case modeWorkbench: + mode := "› Template (.liquid / .tpl) Lua engine" + if !m.wbTemplate { + mode = " Template (.liquid / .tpl) › Lua engine" + } + lines := []string{ + mode, + "", + "File " + m.formInput.View(), + "Context service " + m.detail.Name + " " + clusterLabel(m.cluster), + "", + m.theme.Muted.Render("Enter shows the CLI equivalent. Full in-TUI render lands next."), + } + return page.Panel(m.theme, "Workbench", lines, width, 10, true), "tab mode · enter · esc detail" + case modeDetail: + summary := page.Panel(m.theme, "Summary", m.detailLines(), width, 9, false) + actions := page.Panel(m.theme, "Actions", m.actionLines(width), width, 8, true) + help := "↑/↓ actions · enter · k e c t m d · r refresh · esc list" + if width < 100 { + help = "↑/↓ · enter · letters · r · esc" + } + return summary + "\n\n" + actions, help + case modeClusters: help := "↑/↓ select · enter open cluster · / filter · r refresh · esc back" if width < 100 { help = "↑/↓ · enter · / filter · esc back" } return page.Panel(m.theme, m.clusterTitle(), m.clusterLines(width), width, 14, true), help + default: + help := "↑/↓ · enter detail · n create · / filter · ]/[ page · r refresh · esc" + if width < 100 { + help = "↑/↓ · enter · n create · / · esc" + } + return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help + } +} + +func (m Model) formView(width int) (string, string) { + lines := make([]string, 0, len(m.formFields)+3) + for i, field := range m.formFields { + value := m.formValues[field.key] + cursor := " " + if i == m.formIndex { + cursor = "› " + value = m.formInput.View() + } + lines = append(lines, cursor+pad(field.label, 12)+" "+value) } - help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc clusters" - if width < 100 { - help = "↑/↓ · enter · / filter · n/p page · esc clusters" + lines = append(lines, "", fmt.Sprintf("Dry-run attribute %v (ctrl+d toggle)", m.formDryRun)) + step := fmt.Sprintf("field %d/%d", m.formIndex+1, len(m.formFields)) + help := "↑/↓ fields · enter next/review · esc cancel · " + step + return page.Panel(m.theme, "Form", lines, width, 12, true), help +} + +func (m Model) actionLines(width int) []string { + lines := make([]string, 0, len(detailActions())) + for i, a := range detailActions() { + cursor := " " + if i == m.actionCursor { + cursor = "› " + } + label := fmt.Sprintf("%s %-10s %s", a.shortcut, a.title, a.blurb) + if a.danger { + label += " [destructive]" + if i == m.actionCursor { + lines = append(lines, cursor+m.theme.Danger.Render(label)) + } else { + lines = append(lines, cursor+m.theme.Muted.Render(label)) + } + continue + } + if i == m.actionCursor { + lines = append(lines, cursor+m.theme.Title.Render(label)) + } else { + lines = append(lines, cursor+m.theme.Body.Render(label)) + } + _ = width } - return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help + return lines } func (m Model) clusterTitle() string { @@ -138,7 +291,7 @@ func (m Model) listLines(width int) []string { return []string{m.theme.Danger.Render("✗ Unable to load services"), m.theme.Danger.Render("Error " + m.err.Error()), m.theme.Muted.Render("Press r to retry.")} } if len(m.page.Items) == 0 { - return []string{m.theme.Warning.Render("○ No services found"), m.theme.Muted.Render(" Adjust the filter or choose another cluster.")} + return []string{m.theme.Warning.Render("○ No services found"), m.theme.Muted.Render(" Press n to create, or adjust the filter.")} } nameWidth := max(12, min(28, width/3)) lines := []string{m.theme.Muted.Render(" " + pad("NAME", nameWidth) + " " + pad("NAMESPACE", 14) + " " + pad("STATUS", 10) + " GIT")} @@ -157,10 +310,10 @@ func (m Model) listLines(width int) []string { if m.page.HasNext || len(m.prevCursors) > 0 { pager := "page" if len(m.prevCursors) > 0 { - pager += " · p prev" + pager += " · [ prev" } if m.page.HasNext { - pager += " · n next" + pager += " · ] next" } lines = append(lines, "", m.theme.Muted.Render(pager)) } @@ -176,9 +329,9 @@ func (m Model) detailLines() []string { } cluster := m.detail.ClusterName if m.detail.ClusterHandle != "" { - cluster = m.detail.ClusterHandle + cluster = "@" + m.detail.ClusterHandle if m.detail.ClusterName != "" && m.detail.ClusterName != m.detail.ClusterHandle { - cluster = m.detail.ClusterHandle + " · " + m.detail.ClusterName + cluster += " · " + m.detail.ClusterName } } if cluster == "" { @@ -205,19 +358,9 @@ func (m Model) detailLines() []string { m.labelValue("Revision", revision), m.labelValue("Git", git), m.labelValue("Components", fmt.Sprintf("%d / %d synced", m.detail.Synced, m.detail.Components)), - "", - } - if len(m.detail.Errors) == 0 { - lines = append(lines, m.theme.Success.Render("✓ No service errors")) - return lines } - lines = append(lines, m.theme.Danger.Render("Errors")) - for _, item := range m.detail.Errors { - source := item.Source - if source == "" { - source = "sync" - } - lines = append(lines, m.theme.Danger.Render("✗ "+source)+" "+item.Message) + if len(m.detail.Errors) > 0 { + lines = append(lines, m.theme.Danger.Render(fmt.Sprintf("%d errors", len(m.detail.Errors)))) } return lines } From c9239198223a965f12b73d46a479f5cb60efb73a Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Wed, 29 Jul 2026 15:19:49 +0200 Subject: [PATCH 05/11] fix get tarball --- pkg/bridge/services/actions.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/bridge/services/actions.go b/pkg/bridge/services/actions.go index cd6204d9d..8874a3c01 100644 --- a/pkg/bridge/services/actions.go +++ b/pkg/bridge/services/actions.go @@ -3,12 +3,12 @@ package services import ( "context" "fmt" - "io" "os" "path/filepath" "strings" gqlclient "github.com/pluralsh/console/go/client" + "github.com/pluralsh/console/go/polly/fs" "github.com/samber/lo" "github.com/pluralsh/plural-cli/pkg/bridge" @@ -265,8 +265,8 @@ func (s *Service) DownloadTarball(ctx context.Context, id, dir string) (string, if err != nil { return "", err } - defer func(c io.Closer) { _ = c.Close() }(resp) - if err := utils.Untar(dir, resp); err != nil { + defer resp.Close() + if err := fs.Untar(dir, resp); err != nil { return "", err } abs, err := filepath.Abs(dir) From 504876eae6465b2af08f80dd4549efa6b3290f8c Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Wed, 29 Jul 2026 15:49:52 +0200 Subject: [PATCH 06/11] improve service clone --- tui/screens/services/model.go | 102 ++++++++++++++++++++++++----- tui/screens/services/model_test.go | 53 +++++++++++++++ tui/screens/services/view.go | 33 +++++++++- 3 files changed, 167 insertions(+), 21 deletions(-) diff --git a/tui/screens/services/model.go b/tui/screens/services/model.go index 45e3c5a9a..5ecf859e8 100644 --- a/tui/screens/services/model.go +++ b/tui/screens/services/model.go @@ -31,6 +31,7 @@ const ( modeCreate modeEdit modeClone + modeCloneCluster modeWorkbench ) @@ -143,6 +144,9 @@ type Model struct { formDryRun bool wbTemplate bool confirmName string + + pickingCloneDest bool + cloneDest servicesbridge.Cluster } type formField struct { @@ -262,6 +266,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.cursor = 0 m.err = nil m.needsAuth = false + m.pickingCloneDest = false + m.cloneDest = servicesbridge.Cluster{} if m.loader == nil { m.loading = false return m, nil @@ -277,7 +283,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if msg.err == nil { m.clusters = msg.clusters m.clusterCursor = clampCursor(m.clusterCursor, len(m.clusters)) - m.mode = modeClusters + if m.pickingCloneDest { + m.mode = modeCloneCluster + } else { + m.mode = modeClusters + } } return m, nil case listedMsg: @@ -374,6 +384,8 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { return m.updateTarball(action, key) case modeCreate, modeEdit, modeClone: return m.updateForm(action, key) + case modeCloneCluster: + return m.updateCloneCluster(action) case modeWorkbench: return m.updateWorkbench(action, key, text) case modeDetail: @@ -409,19 +421,26 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { func (m Model) updateFilter(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { switch action { case keyActionBack: - m.mode = modeList - if m.filteringCluster { + m.filterInput.Blur() + if m.pickingCloneDest { + m.mode = modeCloneCluster + } else if m.filteringCluster { m.mode = modeClusters + } else { + m.mode = modeList } - m.filterInput.Blur() return m, nil case keyActionConfirm: value := strings.TrimSpace(m.filterInput.Value()) m.filterInput.Blur() - if m.filteringCluster { + if m.pickingCloneDest || m.filteringCluster { m.clusterFilter = value - m.mode = modeClusters m.clusterCursor = 0 + if m.pickingCloneDest { + m.mode = modeCloneCluster + } else { + m.mode = modeClusters + } return m, m.beginClusters() } m.serviceFilter = value @@ -495,7 +514,7 @@ func (m Model) openAction(a detailAction) (Model, tea.Cmd) { case actionEdit: return m.beginEditForm(), nil case actionClone: - return m.beginCloneForm(), nil + return m.beginClone() case actionWorkbench: m.mode = modeWorkbench m.wbTemplate = true @@ -650,21 +669,63 @@ func (m Model) beginEditForm() Model { return m } +func (m Model) beginClone() (Model, tea.Cmd) { + m.pickingCloneDest = true + m.cloneDest = servicesbridge.Cluster{} + m.clusterFilter = "" + m.clusterCursor = 0 + m.err = nil + m.mode = modeCloneCluster + m.loading = true + return m, m.beginClusters() +} + +func (m Model) updateCloneCluster(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionBack: + m.pickingCloneDest = false + m.clusterFilter = "" + m.mode = modeDetail + m.err = nil + return m, nil + case keyActionMoveUp: + m.clusterCursor = clampCursor(m.clusterCursor-1, len(m.clusters)) + case keyActionMoveDown: + m.clusterCursor = clampCursor(m.clusterCursor+1, len(m.clusters)) + case keyActionConfirm: + if len(m.clusters) == 0 { + return m, nil + } + m.cloneDest = m.clusters[m.clusterCursor] + m.pickingCloneDest = false + m.clusterFilter = "" + return m.beginCloneForm(), nil + case keyActionRefresh: + return m, m.beginClusters() + case keyActionFilter: + m.mode = modeFilter + m.filteringCluster = true + m.filterInput.Placeholder = "filter destination clusters" + m.filterInput.SetValue(m.clusterFilter) + m.filterInput.Focus() + m.formInput = m.filterInput + } + return m, nil +} + func (m Model) beginCloneForm() Model { m.mode = modeClone m.formFields = []formField{ - {label: "Dest cluster ID", key: "cluster"}, {label: "Name", key: "name"}, {label: "Namespace", key: "namespace"}, } m.formIndex = 0 m.formValues = map[string]string{ "name": m.detail.Name + "-clone", - "namespace": m.detail.Namespace, - "cluster": m.detail.ClusterID, + "namespace": loCoalesce(m.detail.Namespace, "default"), } - m.formInput.SetValue(m.formValues["cluster"]) - m.formInput.Placeholder = "destination cluster id" + m.formInput.SetValue(m.formValues["name"]) + m.formInput.Placeholder = "cloned service name" m.formInput.Focus() m.err = nil return m @@ -674,9 +735,13 @@ func (m Model) updateForm(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd switch action { case keyActionBack: m.formInput.Blur() - if m.mode == modeCreate { + switch m.mode { + case modeCreate: m.mode = modeList - } else { + case modeClone: + m.pickingCloneDest = true + m.mode = modeCloneCluster + default: m.mode = modeDetail } return m, nil @@ -794,19 +859,20 @@ func (m Model) submitForm() (Model, tea.Cmd) { case modeClone: input := servicesbridge.CloneInput{ SourceID: m.detail.ID, - DestClusterID: m.formValues["cluster"], + DestClusterID: m.cloneDest.ID, Name: m.formValues["name"], Namespace: m.formValues["namespace"], } + dest := clusterLabel(m.cloneDest) m.pending = pendingOp{ kind: actionClone, title: "Clone · " + m.detail.Name, - cli: "plural cd services clone " + input.DestClusterID + " " + m.detail.ID + " --name " + input.Name, + cli: fmt.Sprintf("plural cd services clone %s %s --name %s --namespace %s", dest, m.detail.ID, input.Name, input.Namespace), clone: &input, lines: []string{ "Action Clone service", - "Source " + m.detail.Name, - "Dest " + input.DestClusterID, + "Source " + m.detail.Name + " · " + clusterLabel(m.cluster), + "Dest " + dest, "Name " + input.Name, "Namespace " + input.Namespace, }, diff --git a/tui/screens/services/model_test.go b/tui/screens/services/model_test.go index efda7f815..75240f688 100644 --- a/tui/screens/services/model_test.go +++ b/tui/screens/services/model_test.go @@ -8,6 +8,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" "github.com/pluralsh/plural-cli/pkg/bridge" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" @@ -187,6 +188,58 @@ func TestDeleteRequiresTypedName(t *testing.T) { } } +func TestClonePicksDestinationCluster(t *testing.T) { + loader := &fakeLoader{ + clusters: []servicesbridge.Cluster{ + {ID: "c1", Name: "production", Handle: "prod-eu"}, + {ID: "c2", Name: "staging", Handle: "staging"}, + }, + page: servicesbridge.Page{Items: []servicesbridge.Summary{{ID: "1", Name: "api", Namespace: "default"}}}, + detail: servicesbridge.Detail{ + Summary: servicesbridge.Summary{ID: "1", Name: "api", Namespace: "default"}, + ClusterID: "c1", ClusterHandle: "prod-eu", ClusterName: "production", + }, + } + model := loadClusters(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + model, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + + model, cmd = model.Update(tea.KeyPressMsg{Code: 'c', Text: "c"}) + if cmd == nil { + t.Fatal("expected cluster reload for clone") + } + model, _ = model.Update(cmd()) + if model.mode != modeCloneCluster || !model.pickingCloneDest { + t.Fatalf("clone cluster mode = %d picking=%v", model.mode, model.pickingCloneDest) + } + if !strings.Contains(model.View(80, 24), "Choose destination cluster") { + t.Fatalf("missing destination picker:\n%s", model.View(80, 24)) + } + if !strings.Contains(ansi.Strip(model.View(80, 24)), "(source)") { + t.Fatalf("source cluster not marked:\n%s", ansi.Strip(model.View(80, 24))) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if model.mode != modeClone || model.cloneDest.ID != "c2" { + t.Fatalf("clone form dest = %#v mode=%d", model.cloneDest, model.mode) + } + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // accept name + model.formInput.SetValue("default") + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // accept namespace → review + if model.mode != modeReview || model.pending.clone == nil || model.pending.clone.DestClusterID != "c2" { + t.Fatalf("review = mode=%d pending=%#v", model.mode, model.pending) + } + if model.pending.clone.Name != "api-clone" { + t.Fatalf("clone name = %q", model.pending.clone.Name) + } + if !strings.Contains(strings.Join(model.pending.lines, "\n"), "@staging") { + t.Fatalf("review missing dest label: %#v", model.pending.lines) + } +} + func TestBackFromClustersReturnsDeployments(t *testing.T) { model := loadClusters(t, New(t.Context(), &fakeLoader{}, theme.New(colorprofile.ASCII))) _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) diff --git a/tui/screens/services/view.go b/tui/screens/services/view.go index 11ed73646..ae1f978e5 100644 --- a/tui/screens/services/view.go +++ b/tui/screens/services/view.go @@ -35,6 +35,8 @@ func (m Model) View(width, height int) string { title = "Edit · " + m.detail.Name case modeClone: title = "Clone · " + m.detail.Name + case modeCloneCluster: + title = "Clone · choose destination" case modeWorkbench: title = "Workbench · " + m.detail.Name case modeDetail: @@ -158,6 +160,12 @@ func (m Model) bodyAndHelp(width int) (string, string) { return page.Panel(m.theme, "Destination", lines, width, 7, true), "enter review · esc cancel" case modeCreate, modeEdit, modeClone: return m.formView(width) + case modeCloneCluster: + help := "↑/↓ select · enter use cluster · / filter · r refresh · esc detail" + if width < 100 { + help = "↑/↓ · enter · / filter · esc detail" + } + return page.Panel(m.theme, m.cloneClusterTitle(), m.clusterLines(width), width, 14, true), help case modeWorkbench: mode := "› Template (.liquid / .tpl) Lua engine" if !m.wbTemplate { @@ -196,7 +204,14 @@ func (m Model) bodyAndHelp(width int) (string, string) { } func (m Model) formView(width int) (string, string) { - lines := make([]string, 0, len(m.formFields)+3) + lines := make([]string, 0, len(m.formFields)+4) + if m.mode == modeClone { + lines = append(lines, + m.theme.Muted.Render("Destination "+clusterLabel(m.cloneDest)), + m.theme.Muted.Render("Source "+m.detail.Name+" · "+clusterLabel(m.cluster)), + "", + ) + } for i, field := range m.formFields { value := m.formValues[field.key] cursor := " " @@ -206,12 +221,21 @@ func (m Model) formView(width int) (string, string) { } lines = append(lines, cursor+pad(field.label, 12)+" "+value) } - lines = append(lines, "", fmt.Sprintf("Dry-run attribute %v (ctrl+d toggle)", m.formDryRun)) + if m.mode != modeClone { + lines = append(lines, "", fmt.Sprintf("Dry-run attribute %v (ctrl+d toggle)", m.formDryRun)) + } step := fmt.Sprintf("field %d/%d", m.formIndex+1, len(m.formFields)) - help := "↑/↓ fields · enter next/review · esc cancel · " + step + help := "↑/↓ fields · enter next/review · esc back · " + step return page.Panel(m.theme, "Form", lines, width, 12, true), help } +func (m Model) cloneClusterTitle() string { + if m.clusterFilter != "" { + return "Destination clusters · filter “" + m.clusterFilter + "”" + } + return "Choose destination cluster" +} + func (m Model) actionLines(width int) []string { lines := make([]string, 0, len(detailActions())) for i, a := range detailActions() { @@ -278,6 +302,9 @@ func (m Model) clusterLines(width int) []string { handle = "@" + handle } row := cursor + pad(handle, handleWidth) + " " + pad(cluster.Name, 24) + " " + cluster.ID + if m.mode == modeCloneCluster && cluster.ID != "" && cluster.ID == m.detail.ClusterID { + row += " " + m.theme.Muted.Render("(source)") + } lines = append(lines, ansi.Truncate(row, width-2, "…")) } return lines From 786b8ae97d23f38a3fc76506d4e084cf656a0f5c Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Thu, 30 Jul 2026 12:02:43 +0200 Subject: [PATCH 07/11] add clusters --- cmd/command/tui/tui.go | 3 + pkg/bridge/clusters/clusters.go | 208 +++++++++++++ pkg/bridge/clusters/clusters_test.go | 84 ++++++ tui/app/model.go | 9 + tui/app/model_test.go | 5 + tui/app/view.go | 2 + tui/navigation/navigation.go | 1 + tui/screens/clusters/model.go | 274 ++++++++++++++++++ tui/screens/clusters/model_test.go | 204 +++++++++++++ .../testdata/clusters-detail-120.golden | 30 ++ .../testdata/clusters-detail-80.golden | 24 ++ .../testdata/clusters-list-120.golden | 30 ++ .../clusters/testdata/clusters-list-80.golden | 24 ++ tui/screens/clusters/view.go | 182 ++++++++++++ tui/screens/deployments/model.go | 2 +- tui/screens/deployments/model_test.go | 13 +- .../testdata/deployments-120.golden | 2 +- .../testdata/deployments-80.golden | 2 +- 18 files changed, 1095 insertions(+), 4 deletions(-) create mode 100644 pkg/bridge/clusters/clusters.go create mode 100644 pkg/bridge/clusters/clusters_test.go create mode 100644 tui/screens/clusters/model.go create mode 100644 tui/screens/clusters/model_test.go create mode 100644 tui/screens/clusters/testdata/clusters-detail-120.golden create mode 100644 tui/screens/clusters/testdata/clusters-detail-80.golden create mode 100644 tui/screens/clusters/testdata/clusters-list-120.golden create mode 100644 tui/screens/clusters/testdata/clusters-list-80.golden create mode 100644 tui/screens/clusters/view.go diff --git a/cmd/command/tui/tui.go b/cmd/command/tui/tui.go index e9cffa658..eb10eb010 100644 --- a/cmd/command/tui/tui.go +++ b/cmd/command/tui/tui.go @@ -8,6 +8,7 @@ import ( "github.com/pluralsh/plural-cli/pkg/bridge" accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" "github.com/pluralsh/plural-cli/pkg/common" @@ -21,10 +22,12 @@ func Command() cli.Command { auth := bridge.NewAuthService(bridge.PluralAuthFactory{}, 0) access := accessbridge.NewLocalManager("", auth, nil) services := servicesbridge.NewService(access) + clusters := clustersbridge.NewService(access) return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{ Welcome: welcome, Access: access, Services: services, + Clusters: clusters, }) }) } diff --git a/pkg/bridge/clusters/clusters.go b/pkg/bridge/clusters/clusters.go new file mode 100644 index 000000000..3a42e73b1 --- /dev/null +++ b/pkg/bridge/clusters/clusters.go @@ -0,0 +1,208 @@ +// Package clusters exposes read-only Console cluster list/get use cases to +// presentation layers without importing TUI code. +package clusters + +import ( + "context" + "errors" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" +) + +var ( + errNoConsole = errors.New("connect a Console profile before browsing Console resources") + errMissingID = errors.New("cluster id is required") + errMissingCluster = errors.New("cluster was not found") +) + +// Summary is a credential-free list row for a Console cluster. +type Summary struct { + ID string + Name string + Handle string + Version string + Distro string +} + +// Tag is a credential-free cluster tag. +type Tag struct { + Name string + Value string +} + +// Detail is the credential-free detail payload for a Console cluster. +type Detail struct { + Summary + Self bool + PingedAt string + Protect bool + DeletedAt string + Project string + Provider string + Tags []Tag + NodePools int +} + +// Loader is the narrow contract consumed by the Clusters screen. +type Loader interface { + List(ctx context.Context, query string) ([]Summary, error) + Get(ctx context.Context, id string) (Detail, error) +} + +// ConsoleResolver supplies the active Console URL and token. +type ConsoleResolver interface { + ActiveConsole(ctx context.Context) (url, token string, err error) +} + +// API is the Console surface required by this package. +type API interface { + ListClusters() (*gqlclient.ListClusters, error) + GetCluster(clusterId, clusterName *string) (*gqlclient.ClusterFragment, error) +} + +// ClientFactory builds a Console API for an authenticated endpoint. +type ClientFactory func(token, url string) (API, error) + +// Service implements Loader against Console GraphQL. +type Service struct { + resolve ConsoleResolver + newClient ClientFactory +} + +// NewService wires production Console credentials and client construction. +func NewService(resolve ConsoleResolver) *Service { + return &Service{ + resolve: resolve, + newClient: func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + }, + } +} + +func (s *Service) client(ctx context.Context) (API, error) { + if s.resolve == nil { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole} + } + url, token, err := s.resolve.ActiveConsole(ctx) + if err != nil { + return nil, err + } + factory := s.newClient + if factory == nil { + factory = func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + } + } + return factory(token, url) +} + +func (s *Service) List(ctx context.Context, query string) ([]Summary, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + client, err := s.client(ctx) + if err != nil { + return nil, err + } + result, err := client.ListClusters() + if err != nil { + return nil, err + } + if result == nil || result.Clusters == nil { + return nil, nil + } + items := make([]Summary, 0, len(result.Clusters.Edges)) + for _, edge := range result.Clusters.Edges { + if edge == nil || edge.Node == nil { + continue + } + summary := summaryFromFragment(edge.Node) + if !matchesQuery(summary, query) { + continue + } + items = append(items, summary) + } + return items, nil +} + +func (s *Service) Get(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + cluster, err := client.GetCluster(&id, nil) + if err != nil { + return Detail{}, err + } + if cluster == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingCluster} + } + return detailFromFragment(cluster), nil +} + +func summaryFromFragment(node *gqlclient.ClusterFragment) Summary { + summary := Summary{ID: node.ID, Name: node.Name} + if node.Handle != nil { + summary.Handle = *node.Handle + } + if node.CurrentVersion != nil { + summary.Version = *node.CurrentVersion + } + if node.Distro != nil { + summary.Distro = string(*node.Distro) + } + return summary +} + +func detailFromFragment(cluster *gqlclient.ClusterFragment) Detail { + detail := Detail{Summary: summaryFromFragment(cluster)} + if cluster.Self != nil { + detail.Self = *cluster.Self + } + if cluster.PingedAt != nil { + detail.PingedAt = *cluster.PingedAt + } + if cluster.Protect != nil { + detail.Protect = *cluster.Protect + } + if cluster.DeletedAt != nil { + detail.DeletedAt = *cluster.DeletedAt + } + if cluster.Project != nil { + detail.Project = cluster.Project.Name + } + if cluster.Provider != nil { + detail.Provider = cluster.Provider.Name + if cluster.Provider.Cloud != "" { + detail.Provider = strings.TrimSpace(detail.Provider + " · " + cluster.Provider.Cloud) + } + } + for _, tag := range cluster.Tags { + if tag == nil { + continue + } + detail.Tags = append(detail.Tags, Tag{Name: tag.Name, Value: tag.Value}) + } + detail.NodePools = len(cluster.NodePools) + return detail +} + +func matchesQuery(summary Summary, query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{summary.Name, summary.Handle, summary.ID, summary.Version, summary.Distro}, " ")) + return strings.Contains(haystack, query) +} diff --git a/pkg/bridge/clusters/clusters_test.go b/pkg/bridge/clusters/clusters_test.go new file mode 100644 index 000000000..b6226e7b0 --- /dev/null +++ b/pkg/bridge/clusters/clusters_test.go @@ -0,0 +1,84 @@ +package clusters + +import ( + "context" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeResolver struct { + url, token string + err error +} + +func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) { + return f.url, f.token, f.err +} + +type fakeAPI struct { + clusters *gqlclient.ListClusters + listErr error + detail *gqlclient.ClusterFragment + getErr error +} + +func (f *fakeAPI) ListClusters() (*gqlclient.ListClusters, error) { return f.clusters, f.listErr } +func (f *fakeAPI) GetCluster(*string, *string) (*gqlclient.ClusterFragment, error) { + return f.detail, f.getErr +} + +func TestListAndGet(t *testing.T) { + handle := "prod-eu" + version := "1.30.2" + distro := gqlclient.ClusterDistroEks + pinged := "2026-07-29T10:00:00Z" + api := &fakeAPI{ + clusters: &gqlclient.ListClusters{Clusters: &gqlclient.ListClusters_Clusters{Edges: []*gqlclient.ClusterEdgeFragment{ + {Node: &gqlclient.ClusterFragment{ + ID: "c1", Name: "production", Handle: &handle, + CurrentVersion: &version, Distro: &distro, + }}, + {Node: &gqlclient.ClusterFragment{ID: "c2", Name: "staging"}}, + }}}, + detail: &gqlclient.ClusterFragment{ + ID: "c1", Name: "production", Handle: &handle, + CurrentVersion: &version, Distro: &distro, + Self: lo.ToPtr(true), PingedAt: &pinged, Protect: lo.ToPtr(false), + Project: &gqlclient.TinyProjectFragment{Name: "acme"}, + Tags: []*gqlclient.ClusterTags{{Name: "env", Value: "prod"}}, + NodePools: []*gqlclient.NodePoolFragment{{}, {}}, + }, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + } + + items, err := service.List(t.Context(), "prod") + if err != nil || len(items) != 1 || items[0].Handle != "prod-eu" || items[0].Version != "1.30.2" { + t.Fatalf("List() = %#v, %v", items, err) + } + + detail, err := service.Get(t.Context(), "c1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if !detail.Self || detail.Project != "acme" || detail.NodePools != 2 || len(detail.Tags) != 1 { + t.Fatalf("detail = %#v", detail) + } +} + +func TestGetRequiresID(t *testing.T) { + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return &fakeAPI{}, nil }, + } + _, err := service.Get(t.Context(), "") + if !bridge.IsCode(err, bridge.ErrorInvalid) { + t.Fatalf("Get() error = %v", err) + } +} diff --git a/tui/app/model.go b/tui/app/model.go index 770d6d5bf..d10aab56e 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -8,10 +8,12 @@ import ( tea "charm.land/bubbletea/v2" accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" + clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" "github.com/pluralsh/plural-cli/tui/navigation" accessscreen "github.com/pluralsh/plural-cli/tui/screens/access" + clustersscreen "github.com/pluralsh/plural-cli/tui/screens/clusters" deploymentsscreen "github.com/pluralsh/plural-cli/tui/screens/deployments" diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics" servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" @@ -24,6 +26,7 @@ type Dependencies struct { Welcome welcomebridge.Loader Access accessbridge.Manager Services servicesbridge.Loader + Clusters clustersbridge.Loader } // Model is the root TUI model. It owns global input and delegates screen state @@ -40,6 +43,7 @@ type Model struct { diagnostics diagnosticsscreen.Model deployments deploymentsscreen.Model services servicesscreen.Model + clusters clustersscreen.Model route navigation.Route } @@ -52,6 +56,7 @@ func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { diagnostics: diagnosticsscreen.New(ctx, dependencies.Welcome, t), deployments: deploymentsscreen.New(ctx, t, ""), services: servicesscreen.New(ctx, dependencies.Services, t), + clusters: clustersscreen.New(ctx, dependencies.Clusters, t), route: navigation.Welcome, quit: key.NewBinding( key.WithKeys("ctrl+c"), @@ -76,6 +81,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.deployments.Init() case navigation.Services: return m, m.services.Init() + case navigation.Clusters: + return m, m.clusters.Init() default: return m, m.welcome.Init() } @@ -103,6 +110,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.deployments, cmd = m.deployments.Update(msg) case navigation.Services: m.services, cmd = m.services.Update(msg) + case navigation.Clusters: + m.clusters, cmd = m.clusters.Update(msg) default: m.welcome, cmd = m.welcome.Update(msg) } diff --git a/tui/app/model_test.go b/tui/app/model_test.go index 0926d4f09..212e5f9f7 100644 --- a/tui/app/model_test.go +++ b/tui/app/model_test.go @@ -55,6 +55,11 @@ func TestModelRoutesScreensWithoutRebuildingShell(t *testing.T) { if routed.route != navigation.Services || !strings.Contains(routed.View().Content, "Services") { t.Fatalf("services route/view = %q\n%s", routed.route, routed.View().Content) } + updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Clusters}) + routed = updated.(Model) + if routed.route != navigation.Clusters || !strings.Contains(routed.View().Content, "Clusters") { + t.Fatalf("clusters route/view = %q\n%s", routed.route, routed.View().Content) + } updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Welcome}) if got := updated.(Model).route; got != navigation.Welcome { t.Fatalf("route = %q", got) diff --git a/tui/app/view.go b/tui/app/view.go index 0ac2fcbd2..a0c5f6324 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -19,6 +19,8 @@ func (m Model) View() tea.View { content = m.deployments.View(m.width, m.height) case navigation.Services: content = m.services.View(m.width, m.height) + case navigation.Clusters: + content = m.clusters.View(m.width, m.height) } view := tea.NewView(content) view.AltScreen = true diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index 4cea2e030..e12b2dcb0 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -14,6 +14,7 @@ const ( Diagnostics Route = "diagnostics" Deployments Route = "deployments" Services Route = "services" + Clusters Route = "clusters" ) // NavigateMsg requests a top-level route change. diff --git a/tui/screens/clusters/model.go b/tui/screens/clusters/model.go new file mode 100644 index 000000000..aff81ec0b --- /dev/null +++ b/tui/screens/clusters/model.go @@ -0,0 +1,274 @@ +// Package clusters implements the read-only Console clusters browser. +package clusters + +import ( + "context" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeList mode = iota + modeDetail + modeFilter +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionRefresh + keyActionFilter + keyActionConnectConsole +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, + keyActionRefresh: {"r"}, + keyActionFilter: {"/"}, + keyActionConnectConsole: {"c"}, +} + +func actionForKeystroke(keystroke string) keyAction { + for action, keystrokes := range keyActionKeystrokes { + for _, candidate := range keystrokes { + if keystroke == candidate { + return action + } + } + } + return keyActionNone +} + +type initMsg struct{} +type listedMsg struct { + items []clustersbridge.Summary + err error + request uint64 +} +type detailMsg struct { + detail clustersbridge.Detail + err error + request uint64 +} + +// Model owns Clusters-screen interaction state. +type Model struct { + ctx context.Context + loader clustersbridge.Loader + theme theme.Theme + mode mode + loading bool + err error + needsAuth bool + request uint64 + + items []clustersbridge.Summary + cursor int + filter string + filterInput textinput.Model + + detail clustersbridge.Detail + detailID string + listCursor int + listFilter string +} + +func New(ctx context.Context, loader clustersbridge.Loader, t theme.Theme) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "filter clusters" + input.CharLimit = 128 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Blurred = styles.Focused + input.SetStyles(styles) + return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, mode: modeList} +} + +func (m Model) Init() tea.Cmd { + return func() tea.Msg { return initMsg{} } +} + +func (m *Model) beginList() tea.Cmd { + m.loading = true + m.request++ + request := m.request + query := m.filter + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + items, err := loader.List(ctx, query) + return listedMsg{items: items, err: err, request: request} + } +} + +func (m *Model) beginDetail(id string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + detail, err := loader.Get(ctx, id) + return detailMsg{detail: detail, err: err, request: request} + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case initMsg: + m.mode = modeList + m.items = nil + m.cursor = 0 + m.err = nil + m.needsAuth = false + if m.loader == nil { + m.loading = false + return m, nil + } + return m, m.beginList() + case listedMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.items = msg.items + m.cursor = clampCursor(m.cursor, len(m.items)) + m.mode = modeList + } + return m, nil + case detailMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.detail = msg.detail + m.mode = modeDetail + } + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if m.mode == modeFilter { + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + if m.mode == modeFilter { + switch action { + case keyActionBack: + m.mode = modeList + m.filterInput.Blur() + return m, nil + case keyActionConfirm: + m.filter = strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + m.mode = modeList + m.cursor = 0 + return m, m.beginList() + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd + } + if action == keyActionBack { + if m.mode == modeDetail { + m.mode = modeList + m.err = nil + m.cursor = m.listCursor + m.filter = m.listFilter + return m, nil + } + return m, navigation.Navigate(navigation.Deployments) + } + if m.loading { + return m, nil + } + if m.needsAuth && action == keyActionConnectConsole { + return m, navigation.Navigate(navigation.Access) + } + if m.mode == modeDetail { + if action == keyActionRefresh && m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + return m, nil + } + return m.updateList(action) +} + +func (m Model) updateList(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionMoveUp: + m.cursor = clampCursor(m.cursor-1, len(m.items)) + case keyActionMoveDown: + m.cursor = clampCursor(m.cursor+1, len(m.items)) + case keyActionConfirm: + if len(m.items) == 0 { + return m, nil + } + m.listCursor = m.cursor + m.listFilter = m.filter + m.detailID = m.items[m.cursor].ID + return m, m.beginDetail(m.detailID) + case keyActionRefresh: + return m, m.beginList() + case keyActionFilter: + m.mode = modeFilter + m.filterInput.SetValue(m.filter) + m.filterInput.Focus() + } + return m, nil +} + +func clampCursor(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} + +func clusterLabel(item clustersbridge.Summary) string { + if item.Handle != "" { + return "@" + item.Handle + } + if item.Name != "" { + return item.Name + } + return item.ID +} diff --git a/tui/screens/clusters/model_test.go b/tui/screens/clusters/model_test.go new file mode 100644 index 000000000..b0268a01e --- /dev/null +++ b/tui/screens/clusters/model_test.go @@ -0,0 +1,204 @@ +package clusters + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/pkg/bridge" + clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeLoader struct { + items []clustersbridge.Summary + detail clustersbridge.Detail + err error +} + +func (f *fakeLoader) List(context.Context, string) ([]clustersbridge.Summary, error) { + return f.items, f.err +} +func (f *fakeLoader) Get(context.Context, string) (clustersbridge.Detail, error) { + return f.detail, f.err +} + +func loadList(t *testing.T, model Model) Model { + t.Helper() + cmd := model.Init() + model, cmd = model.Update(cmd()) + if cmd == nil { + t.Fatal("expected list command") + } + model, _ = model.Update(cmd()) + return model +} + +func TestOpenClusterDetailAndBack(t *testing.T) { + loader := &fakeLoader{ + items: []clustersbridge.Summary{ + {ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, + {ID: "c2", Name: "staging", Handle: "staging", Version: "1.29.0", Distro: "EKS"}, + }, + detail: clustersbridge.Detail{ + Summary: clustersbridge.Summary{ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, + Self: true, + Project: "acme", + PingedAt: "2026-07-29T10:00:00Z", + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if model.mode != modeList || len(model.items) != 2 { + t.Fatalf("list state = mode=%d count=%d", model.mode, len(model.items)) + } + if !strings.Contains(model.View(80, 24), "@prod-eu") { + t.Fatalf("list missing handle:\n%s", model.View(80, 24)) + } + + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail || model.detail.Handle != "prod-eu" { + t.Fatalf("detail = %#v mode=%d", model.detail, model.mode) + } + if !strings.Contains(model.View(80, 24), "production") { + t.Fatalf("detail view missing name:\n%s", model.View(80, 24)) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeList { + t.Fatalf("mode after detail esc = %d", model.mode) + } + _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Deployments}) { + t.Fatalf("expected deployments navigation") + } +} + +func TestNoConsoleNavigatesToAccess(t *testing.T) { + loader := &fakeLoader{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect")}} + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !model.needsAuth { + t.Fatal("expected needsAuth") + } + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c'}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Access}) { + t.Fatalf("expected access navigation") + } +} + +func TestClustersGoldens(t *testing.T) { + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.items = []clustersbridge.Summary{ + {ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, + {ID: "c2", Name: "staging", Handle: "staging", Version: "1.29.0", Distro: "EKS"}, + {ID: "c3", Name: "edge", Version: "1.28.1", Distro: "K3S"}, + } + + detail := list + detail.mode = modeDetail + detail.detail = clustersbridge.Detail{ + Summary: clustersbridge.Summary{ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, + Self: true, + PingedAt: "2026-07-29T10:00:00Z", + Protect: false, + Project: "acme", + Provider: "aws · EKS", + NodePools: 2, + Tags: []clustersbridge.Tag{{Name: "env", Value: "prod"}}, + } + + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + t.Run(tc.name, func(t *testing.T) { + got := normalizeView(tc.model.View(tc.width, tc.height)) + golden := filepath.Join("testdata", "clusters-"+tc.name+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + lines := strings.Split(got, "\n") + if len(lines) != tc.height { + t.Fatalf("height = %d, want %d", len(lines), tc.height) + } + for _, line := range lines { + if w := lipgloss.Width(line); w > tc.width { + t.Fatalf("line width %d > %d: %q", w, tc.width, line) + } + } + }) + } +} + +func TestWriteClustersGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.items = []clustersbridge.Summary{ + {ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, + {ID: "c2", Name: "staging", Handle: "staging", Version: "1.29.0", Distro: "EKS"}, + {ID: "c3", Name: "edge", Version: "1.28.1", Distro: "K3S"}, + } + detail := list + detail.mode = modeDetail + detail.detail = clustersbridge.Detail{ + Summary: clustersbridge.Summary{ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, + Self: true, + PingedAt: "2026-07-29T10:00:00Z", + Protect: false, + Project: "acme", + Provider: "aws · EKS", + NodePools: 2, + Tags: []clustersbridge.Tag{{Name: "env", Value: "prod"}}, + } + _ = os.MkdirAll("testdata", 0o755) + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + got := normalizeView(tc.model.View(tc.width, tc.height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "clusters-"+tc.name+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/clusters/testdata/clusters-detail-120.golden b/tui/screens/clusters/testdata/clusters-detail-120.golden new file mode 100644 index 000000000..4cda1e764 --- /dev/null +++ b/tui/screens/clusters/testdata/clusters-detail-120.golden @@ -0,0 +1,30 @@ + Plural Clusters · production self + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Handle @prod-eu │ + │ Name production │ + │ Version 1.30.2 │ + │ Distro EKS │ + │ Project acme │ + │ Provider aws · EKS │ + │ Pinged 2026-07-29T10:00:00Z │ + │ Self true │ + │ Protect false │ + │ Node pools 2 │ + │ ID c1 │ + │ │ + │ Tags │ + │ env=prod │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/clusters/testdata/clusters-detail-80.golden b/tui/screens/clusters/testdata/clusters-detail-80.golden new file mode 100644 index 000000000..e1fd12860 --- /dev/null +++ b/tui/screens/clusters/testdata/clusters-detail-80.golden @@ -0,0 +1,24 @@ + Plural Clusters · production self + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────╮ + │ Handle @prod-eu │ + │ Name production │ + │ Version 1.30.2 │ + │ Distro EKS │ + │ Project acme │ + │ Provider aws · EKS │ + │ Pinged 2026-07-29T10:00:00Z │ + │ Self true │ + │ Protect false │ + │ Node pools 2 │ + │ ID c1 │ + │ │ + │ Tags │ + │ env=prod │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/clusters/testdata/clusters-list-120.golden b/tui/screens/clusters/testdata/clusters-list-120.golden new file mode 100644 index 000000000..51be6fea1 --- /dev/null +++ b/tui/screens/clusters/testdata/clusters-list-120.golden @@ -0,0 +1,30 @@ + Plural Clusters 3 clusters + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Clusters ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ HANDLE NAME VERSION DISTRO │ + │ › @prod-eu production 1.30.2 EKS │ + │ @staging staging 1.29.0 EKS │ + │ — edge 1.28.1 K3S │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · enter open · / filter · r refresh · esc back diff --git a/tui/screens/clusters/testdata/clusters-list-80.golden b/tui/screens/clusters/testdata/clusters-list-80.golden new file mode 100644 index 000000000..f5a5b9cf7 --- /dev/null +++ b/tui/screens/clusters/testdata/clusters-list-80.golden @@ -0,0 +1,24 @@ + Plural Clusters 3 clusters + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Clusters ─────────────────────────────────────────────────────────────╮ + │ HANDLE NAME VERSION DISTRO │ + │ › @prod-eu production 1.30.2 EKS │ + │ @staging staging 1.29.0 EKS │ + │ — edge 1.28.1 K3S │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · / filter · esc back diff --git a/tui/screens/clusters/view.go b/tui/screens/clusters/view.go new file mode 100644 index 000000000..5f7674164 --- /dev/null +++ b/tui/screens/clusters/view.go @@ -0,0 +1,182 @@ +package clusters + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + title := "Clusters" + if m.mode == modeDetail && m.detail.Name != "" { + title = "Clusters · " + m.detail.Name + } + body, help := m.bodyAndHelp(contentWidth) + return page.Render(m.theme, width, height, title, m.headerStatus(), body, help) +} + +func (m Model) headerStatus() string { + if m.loading { + return m.theme.Warning.Render("◌ loading") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + if m.err != nil { + return m.theme.Danger.Render("✗ load failed") + } + switch m.mode { + case modeDetail: + if m.detail.DeletedAt != "" { + return m.theme.Danger.Render("terminating") + } + if m.detail.Self { + return m.theme.Success.Render("self") + } + return m.theme.Success.Render(loCoalesce(m.detail.Distro, "ready")) + case modeList: + if m.filter != "" { + return m.theme.Muted.Render(fmt.Sprintf("%d matching", len(m.items))) + } + return m.theme.Success.Render(fmt.Sprintf("%d clusters", len(m.items))) + default: + return m.theme.Muted.Render("clusters") + } +} + +func (m Model) bodyAndHelp(width int) (string, string) { + if m.mode == modeFilter { + lines := []string{ + m.theme.Muted.Render("Filter by handle, name, id, version, or distro."), + "", + m.filterInput.View(), + } + return page.Panel(m.theme, "Filter clusters", lines, width, 6, true), "enter apply · esc cancel" + } + if m.needsAuth { + lines := []string{ + m.theme.Warning.Render("○ Console is not connected"), + m.theme.Muted.Render(" Connect a Console profile to browse clusters."), + "", + m.theme.Body.Render("Press c to open Access."), + } + return page.Panel(m.theme, "Console required", lines, width, 8, true), "c connect · esc back · ctrl+c quit" + } + if m.mode == modeDetail { + help := "r refresh · esc list · ctrl+c quit" + return page.Panel(m.theme, "Summary", m.detailLines(), width, 16, true), help + } + help := "↑/↓ select · enter open · / filter · r refresh · esc back" + if width < 100 { + help = "↑/↓ · enter · / filter · esc back" + } + return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help +} + +func (m Model) listTitle() string { + if m.filter != "" { + return "Clusters · filter “" + m.filter + "”" + } + return "Clusters" +} + +func (m Model) listLines(width int) []string { + if m.loading && len(m.items) == 0 { + return []string{m.theme.Warning.Render("◌ Loading clusters…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load clusters"), m.theme.Danger.Render("Error " + m.err.Error()), m.theme.Muted.Render("Press r to retry.")} + } + if len(m.items) == 0 { + return []string{m.theme.Warning.Render("○ No clusters found"), m.theme.Muted.Render(" Adjust the filter or connect another Console.")} + } + handleWidth := max(12, min(20, width/4)) + nameWidth := max(12, min(24, width/3)) + lines := []string{m.theme.Muted.Render(" " + pad("HANDLE", handleWidth) + " " + pad("NAME", nameWidth) + " " + pad("VERSION", 10) + " DISTRO")} + for i, item := range m.items { + cursor := " " + if i == m.cursor { + cursor = "› " + } + handle := item.Handle + if handle == "" { + handle = "—" + } else { + handle = "@" + handle + } + version := loCoalesce(item.Version, "—") + distro := loCoalesce(item.Distro, "—") + row := cursor + pad(handle, handleWidth) + " " + pad(item.Name, nameWidth) + " " + pad(version, 10) + " " + distro + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + return lines +} + +func (m Model) detailLines() []string { + if m.loading { + return []string{m.theme.Warning.Render("◌ Loading cluster detail…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load cluster"), m.theme.Danger.Render(m.err.Error())} + } + handle := m.detail.Handle + if handle != "" { + handle = "@" + handle + } else { + handle = "—" + } + lines := []string{ + m.labelValue("Handle", handle), + m.labelValue("Name", m.detail.Name), + m.labelValue("Version", loCoalesce(m.detail.Version, "—")), + m.labelValue("Distro", loCoalesce(m.detail.Distro, "—")), + m.labelValue("Project", loCoalesce(m.detail.Project, "—")), + m.labelValue("Provider", loCoalesce(m.detail.Provider, "—")), + m.labelValue("Pinged", loCoalesce(m.detail.PingedAt, "—")), + m.labelValue("Self", fmt.Sprintf("%v", m.detail.Self)), + m.labelValue("Protect", fmt.Sprintf("%v", m.detail.Protect)), + m.labelValue("Node pools", fmt.Sprintf("%d", m.detail.NodePools)), + m.labelValue("ID", m.detail.ID), + } + if m.detail.DeletedAt != "" { + lines = append(lines, m.theme.Danger.Render("Deleted "+m.detail.DeletedAt)) + } + if len(m.detail.Tags) > 0 { + lines = append(lines, "", m.theme.Muted.Render("Tags")) + for _, tag := range m.detail.Tags { + lines = append(lines, " "+tag.Name+"="+tag.Value) + } + } + return lines +} + +func (m Model) labelValue(label, value string) string { + label = label + strings.Repeat(" ", max(1, 12-len(label))) + return label + " " + value +} + +func pad(value string, width int) string { + value = ansi.Truncate(value, width, "…") + if lipgloss.Width(value) >= width { + return value + } + return value + strings.Repeat(" ", width-lipgloss.Width(value)) +} + +func loCoalesce(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} diff --git a/tui/screens/deployments/model.go b/tui/screens/deployments/model.go index a77178f26..a7c3c520e 100644 --- a/tui/screens/deployments/model.go +++ b/tui/screens/deployments/model.go @@ -33,7 +33,7 @@ type resource struct { func resources() []resource { return []resource{ {id: resourceServices, number: "1", shortcut: "s", title: "Services", blurb: "browse · kick · create · …", route: navigation.Services}, - {id: resourceClusters, number: "2", shortcut: "c", title: "Clusters", blurb: "list · describe", soon: true}, + {id: resourceClusters, number: "2", shortcut: "c", title: "Clusters", blurb: "list · describe", route: navigation.Clusters}, {id: resourceRepositories, number: "3", shortcut: "r", title: "Repositories", blurb: "list · get", soon: true}, {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "trigger", soon: true}, {id: resourceNotifications, number: "5", shortcut: "n", title: "Notifications", blurb: "sinks", soon: true}, diff --git a/tui/screens/deployments/model_test.go b/tui/screens/deployments/model_test.go index c5943f3b5..4b0ff56e6 100644 --- a/tui/screens/deployments/model_test.go +++ b/tui/screens/deployments/model_test.go @@ -65,9 +65,20 @@ func TestServicesShortcutNavigates(t *testing.T) { } } +func TestClustersNavigates(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c', Text: "c"}) + if cmd == nil { + t.Fatal("expected navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Clusters}) { + t.Fatalf("msg = %#v", msg) + } +} + func TestSoonResourceDoesNotNavigate(t *testing.T) { model := New(t.Context(), theme.New(colorprofile.ASCII), "") - model.cursor = 1 + model.cursor = 2 // repositories [soon] _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if cmd != nil { t.Fatalf("unexpected cmd %#v", cmd()) diff --git a/tui/screens/deployments/testdata/deployments-120.golden b/tui/screens/deployments/testdata/deployments-120.golden index a1d1c156c..37618cc26 100644 --- a/tui/screens/deployments/testdata/deployments-120.golden +++ b/tui/screens/deployments/testdata/deployments-120.golden @@ -3,7 +3,7 @@ ╭─ › Resources ────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ › 1 s Services browse · kick · create · … │ - │ 2 c Clusters list · describe [soon] │ + │ 2 c Clusters list · describe │ │ 3 r Repositories list · get [soon] │ │ 4 p Pipelines trigger [soon] │ │ 5 n Notifications sinks [soon] │ diff --git a/tui/screens/deployments/testdata/deployments-80.golden b/tui/screens/deployments/testdata/deployments-80.golden index 0fbe23392..1539b8906 100644 --- a/tui/screens/deployments/testdata/deployments-80.golden +++ b/tui/screens/deployments/testdata/deployments-80.golden @@ -3,7 +3,7 @@ ╭─ › Resources ────────────────────────────────────────────────────────────╮ │ › 1 s Services browse · kick · create · … │ - │ 2 c Clusters list · describe [soon] │ + │ 2 c Clusters list · describe │ │ 3 r Repositories list · get [soon] │ │ 4 p Pipelines trigger [soon] │ │ 5 n Notifications sinks [soon] │ From bf6085920b19bf71dce2c52d232701decb29f2cb Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Thu, 30 Jul 2026 12:53:16 +0200 Subject: [PATCH 08/11] add repositories --- cmd/command/tui/tui.go | 11 +- pkg/bridge/clusters/clusters.go | 53 ++- pkg/bridge/clusters/clusters_test.go | 31 +- pkg/bridge/repositories/repositories.go | 211 ++++++++++++ pkg/bridge/repositories/repositories_test.go | 114 +++++++ pkg/bridge/services/services.go | 2 +- pkg/bridge/services/services_test.go | 27 ++ tui/app/model.go | 47 +-- tui/app/model_test.go | 5 + tui/app/view.go | 2 + tui/navigation/navigation.go | 5 +- tui/screens/clusters/model.go | 72 ++++- tui/screens/clusters/model_test.go | 53 ++- .../testdata/clusters-list-120.golden | 4 +- .../clusters/testdata/clusters-list-80.golden | 4 +- tui/screens/clusters/view.go | 51 ++- tui/screens/deployments/model.go | 2 +- tui/screens/deployments/model_test.go | 13 +- .../testdata/deployments-120.golden | 2 +- .../testdata/deployments-80.golden | 2 +- tui/screens/repositories/model.go | 306 ++++++++++++++++++ tui/screens/repositories/model_test.go | 232 +++++++++++++ .../testdata/repositories-detail-120.golden | 30 ++ .../testdata/repositories-detail-80.golden | 24 ++ .../testdata/repositories-list-120.golden | 30 ++ .../testdata/repositories-list-80.golden | 24 ++ tui/screens/repositories/view.go | 209 ++++++++++++ tui/screens/services/model.go | 28 +- tui/screens/services/model_test.go | 78 +++++ .../testdata/services-clusters-120.golden | 2 +- .../testdata/services-list-120.golden | 4 +- .../services/testdata/services-list-80.golden | 4 +- tui/screens/services/view.go | 48 ++- 33 files changed, 1626 insertions(+), 104 deletions(-) create mode 100644 pkg/bridge/repositories/repositories.go create mode 100644 pkg/bridge/repositories/repositories_test.go create mode 100644 tui/screens/repositories/model.go create mode 100644 tui/screens/repositories/model_test.go create mode 100644 tui/screens/repositories/testdata/repositories-detail-120.golden create mode 100644 tui/screens/repositories/testdata/repositories-detail-80.golden create mode 100644 tui/screens/repositories/testdata/repositories-list-120.golden create mode 100644 tui/screens/repositories/testdata/repositories-list-80.golden create mode 100644 tui/screens/repositories/view.go diff --git a/cmd/command/tui/tui.go b/cmd/command/tui/tui.go index eb10eb010..982b54d98 100644 --- a/cmd/command/tui/tui.go +++ b/cmd/command/tui/tui.go @@ -9,6 +9,7 @@ import ( "github.com/pluralsh/plural-cli/pkg/bridge" accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" "github.com/pluralsh/plural-cli/pkg/common" @@ -23,11 +24,13 @@ func Command() cli.Command { access := accessbridge.NewLocalManager("", auth, nil) services := servicesbridge.NewService(access) clusters := clustersbridge.NewService(access) + repositories := repositoriesbridge.NewService(access) return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{ - Welcome: welcome, - Access: access, - Services: services, - Clusters: clusters, + Welcome: welcome, + Access: access, + Services: services, + Clusters: clusters, + Repositories: repositories, }) }) } diff --git a/pkg/bridge/clusters/clusters.go b/pkg/bridge/clusters/clusters.go index 3a42e73b1..1ccfdb4b2 100644 --- a/pkg/bridge/clusters/clusters.go +++ b/pkg/bridge/clusters/clusters.go @@ -13,6 +13,8 @@ import ( "github.com/pluralsh/plural-cli/pkg/console" ) +const defaultPageSize int64 = 10 + var ( errNoConsole = errors.New("connect a Console profile before browsing Console resources") errMissingID = errors.New("cluster id is required") @@ -47,9 +49,17 @@ type Detail struct { NodePools int } +// Page is one cursor page of cluster summaries. +type Page struct { + Items []Summary + EndCursor string + HasNext bool + TotalShown int +} + // Loader is the narrow contract consumed by the Clusters screen. type Loader interface { - List(ctx context.Context, query string) ([]Summary, error) + List(ctx context.Context, after *string, query string) (Page, error) Get(ctx context.Context, id string) (Detail, error) } @@ -71,6 +81,7 @@ type ClientFactory func(token, url string) (API, error) type Service struct { resolve ConsoleResolver newClient ClientFactory + pageSize int64 } // NewService wires production Console credentials and client construction. @@ -80,6 +91,7 @@ func NewService(resolve ConsoleResolver) *Service { newClient: func(token, url string) (API, error) { return console.NewConsoleClient(token, url) }, + pageSize: defaultPageSize, } } @@ -100,20 +112,20 @@ func (s *Service) client(ctx context.Context) (API, error) { return factory(token, url) } -func (s *Service) List(ctx context.Context, query string) ([]Summary, error) { +func (s *Service) List(ctx context.Context, after *string, query string) (Page, error) { if err := ctx.Err(); err != nil { - return nil, err + return Page{}, err } client, err := s.client(ctx) if err != nil { - return nil, err + return Page{}, err } result, err := client.ListClusters() if err != nil { - return nil, err + return Page{}, err } if result == nil || result.Clusters == nil { - return nil, nil + return Page{}, nil } items := make([]Summary, 0, len(result.Clusters.Edges)) for _, edge := range result.Clusters.Edges { @@ -126,7 +138,7 @@ func (s *Service) List(ctx context.Context, query string) ([]Summary, error) { } items = append(items, summary) } - return items, nil + return pageItems(items, after, s.pageSize), nil } func (s *Service) Get(ctx context.Context, id string) (Detail, error) { @@ -151,6 +163,33 @@ func (s *Service) Get(ctx context.Context, id string) (Detail, error) { return detailFromFragment(cluster), nil } +func pageItems(items []Summary, after *string, pageSize int64) Page { + if pageSize <= 0 { + pageSize = defaultPageSize + } + start := 0 + if after != nil && *after != "" { + for i, item := range items { + if item.ID == *after { + start = i + 1 + break + } + } + } + if start > len(items) { + start = len(items) + } + end := start + int(pageSize) + if end > len(items) { + end = len(items) + } + page := Page{Items: items[start:end], TotalShown: end - start, HasNext: end < len(items)} + if len(page.Items) > 0 { + page.EndCursor = page.Items[len(page.Items)-1].ID + } + return page +} + func summaryFromFragment(node *gqlclient.ClusterFragment) Summary { summary := Summary{ID: node.ID, Name: node.Name} if node.Handle != nil { diff --git a/pkg/bridge/clusters/clusters_test.go b/pkg/bridge/clusters/clusters_test.go index b6226e7b0..39d7f9aa8 100644 --- a/pkg/bridge/clusters/clusters_test.go +++ b/pkg/bridge/clusters/clusters_test.go @@ -56,11 +56,12 @@ func TestListAndGet(t *testing.T) { service := &Service{ resolve: fakeResolver{url: "https://console.example.com", token: "token"}, newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 50, } - items, err := service.List(t.Context(), "prod") - if err != nil || len(items) != 1 || items[0].Handle != "prod-eu" || items[0].Version != "1.30.2" { - t.Fatalf("List() = %#v, %v", items, err) + page, err := service.List(t.Context(), nil, "prod") + if err != nil || len(page.Items) != 1 || page.Items[0].Handle != "prod-eu" || page.Items[0].Version != "1.30.2" { + t.Fatalf("List() = %#v, %v", page, err) } detail, err := service.Get(t.Context(), "c1") @@ -72,6 +73,30 @@ func TestListAndGet(t *testing.T) { } } +func TestListPages(t *testing.T) { + api := &fakeAPI{ + clusters: &gqlclient.ListClusters{Clusters: &gqlclient.ListClusters_Clusters{Edges: []*gqlclient.ClusterEdgeFragment{ + {Node: &gqlclient.ClusterFragment{ID: "c1", Name: "a"}}, + {Node: &gqlclient.ClusterFragment{ID: "c2", Name: "b"}}, + {Node: &gqlclient.ClusterFragment{ID: "c3", Name: "c"}}, + }}}, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 2, + } + first, err := service.List(t.Context(), nil, "") + if err != nil || len(first.Items) != 2 || !first.HasNext || first.EndCursor != "c2" { + t.Fatalf("first = %#v, %v", first, err) + } + after := first.EndCursor + second, err := service.List(t.Context(), &after, "") + if err != nil || len(second.Items) != 1 || second.HasNext || second.Items[0].ID != "c3" { + t.Fatalf("second = %#v, %v", second, err) + } +} + func TestGetRequiresID(t *testing.T) { service := &Service{ resolve: fakeResolver{url: "https://console.example.com", token: "token"}, diff --git a/pkg/bridge/repositories/repositories.go b/pkg/bridge/repositories/repositories.go new file mode 100644 index 000000000..ff53ad503 --- /dev/null +++ b/pkg/bridge/repositories/repositories.go @@ -0,0 +1,211 @@ +// Package repositories exposes read-only Console git repository list/get use +// cases to presentation layers without importing TUI code. +package repositories + +import ( + "context" + "errors" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" +) + +const defaultPageSize int64 = 10 + +var ( + errNoConsole = errors.New("connect a Console profile before browsing Console resources") + errMissingID = errors.New("repository id is required") + errMissingRepository = errors.New("repository was not found") +) + +// Summary is a credential-free list row for a Console git repository. +type Summary struct { + ID string + URL string + Health string + Error string + AuthMethod string +} + +// Detail is the credential-free detail payload for a Console git repository. +type Detail struct { + Summary + Decrypt bool +} + +// Page is one cursor page of repository summaries. +type Page struct { + Items []Summary + EndCursor string + HasNext bool + TotalShown int +} + +// Loader is the narrow contract consumed by the Repositories screen. +type Loader interface { + List(ctx context.Context, after *string, query string) (Page, error) + Get(ctx context.Context, id string) (Detail, error) +} + +// ConsoleResolver supplies the active Console URL and token. +type ConsoleResolver interface { + ActiveConsole(ctx context.Context) (url, token string, err error) +} + +// API is the Console surface required by this package. +type API interface { + ListRepositories() (*gqlclient.ListGitRepositories, error) + GetRepository(id string) (*gqlclient.GetGitRepository, error) +} + +// ClientFactory builds a Console API for an authenticated endpoint. +type ClientFactory func(token, url string) (API, error) + +// Service implements Loader against Console GraphQL. +type Service struct { + resolve ConsoleResolver + newClient ClientFactory + pageSize int64 +} + +// NewService wires production Console credentials and client construction. +func NewService(resolve ConsoleResolver) *Service { + return &Service{ + resolve: resolve, + newClient: func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + }, + pageSize: defaultPageSize, + } +} + +func (s *Service) client(ctx context.Context) (API, error) { + if s.resolve == nil { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole} + } + url, token, err := s.resolve.ActiveConsole(ctx) + if err != nil { + return nil, err + } + factory := s.newClient + if factory == nil { + factory = func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + } + } + return factory(token, url) +} + +func (s *Service) List(ctx context.Context, after *string, query string) (Page, error) { + if err := ctx.Err(); err != nil { + return Page{}, err + } + client, err := s.client(ctx) + if err != nil { + return Page{}, err + } + result, err := client.ListRepositories() + if err != nil { + return Page{}, err + } + if result == nil || result.GitRepositories == nil { + return Page{}, nil + } + items := make([]Summary, 0, len(result.GitRepositories.Edges)) + for _, edge := range result.GitRepositories.Edges { + if edge == nil || edge.Node == nil { + continue + } + summary := summaryFromFragment(edge.Node) + if !matchesQuery(summary, query) { + continue + } + items = append(items, summary) + } + return pageItems(items, after, s.pageSize), nil +} + +func (s *Service) Get(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + result, err := client.GetRepository(id) + if err != nil { + return Detail{}, err + } + if result == nil || result.GitRepository == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingRepository} + } + return detailFromFragment(result.GitRepository), nil +} + +func pageItems(items []Summary, after *string, pageSize int64) Page { + if pageSize <= 0 { + pageSize = defaultPageSize + } + start := 0 + if after != nil && *after != "" { + for i, item := range items { + if item.ID == *after { + start = i + 1 + break + } + } + } + if start > len(items) { + start = len(items) + } + end := start + int(pageSize) + if end > len(items) { + end = len(items) + } + page := Page{Items: items[start:end], TotalShown: end - start, HasNext: end < len(items)} + if len(page.Items) > 0 { + page.EndCursor = page.Items[len(page.Items)-1].ID + } + return page +} + +func summaryFromFragment(node *gqlclient.GitRepositoryFragment) Summary { + summary := Summary{ID: node.ID, URL: node.URL, Health: "UNKNOWN"} + if node.Health != nil { + summary.Health = string(*node.Health) + } + if node.Error != nil { + summary.Error = *node.Error + } + if node.AuthMethod != nil { + summary.AuthMethod = string(*node.AuthMethod) + } + return summary +} + +func detailFromFragment(node *gqlclient.GitRepositoryFragment) Detail { + detail := Detail{Summary: summaryFromFragment(node)} + if node.Decrypt != nil { + detail.Decrypt = *node.Decrypt + } + return detail +} + +func matchesQuery(summary Summary, query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{ + summary.URL, summary.ID, summary.Health, summary.Error, summary.AuthMethod, + }, " ")) + return strings.Contains(haystack, query) +} diff --git a/pkg/bridge/repositories/repositories_test.go b/pkg/bridge/repositories/repositories_test.go new file mode 100644 index 000000000..3224c5550 --- /dev/null +++ b/pkg/bridge/repositories/repositories_test.go @@ -0,0 +1,114 @@ +package repositories + +import ( + "context" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeResolver struct { + url, token string + err error +} + +func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) { + return f.url, f.token, f.err +} + +type fakeAPI struct { + repos *gqlclient.ListGitRepositories + listErr error + detail *gqlclient.GetGitRepository + getErr error +} + +func (f *fakeAPI) ListRepositories() (*gqlclient.ListGitRepositories, error) { + return f.repos, f.listErr +} +func (f *fakeAPI) GetRepository(string) (*gqlclient.GetGitRepository, error) { + return f.detail, f.getErr +} + +func TestListAndGet(t *testing.T) { + health := gqlclient.GitHealthPullable + auth := gqlclient.AuthMethodSSH + errMsg := "auth failed" + failed := gqlclient.GitHealthFailed + api := &fakeAPI{ + repos: &gqlclient.ListGitRepositories{GitRepositories: &gqlclient.ListGitRepositories_GitRepositories{ + Edges: []*gqlclient.GitRepositoryEdgeFragment{ + {Node: &gqlclient.GitRepositoryFragment{ + ID: "r1", URL: "git@github.com:acme/infra.git", Health: &health, AuthMethod: &auth, + }}, + {Node: &gqlclient.GitRepositoryFragment{ + ID: "r2", URL: "https://github.com/acme/apps.git", Health: &failed, Error: &errMsg, + }}, + }, + }}, + detail: &gqlclient.GetGitRepository{GitRepository: &gqlclient.GitRepositoryFragment{ + ID: "r1", URL: "git@github.com:acme/infra.git", Health: &health, AuthMethod: &auth, + Decrypt: lo.ToPtr(true), + }}, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 50, + } + + page, err := service.List(t.Context(), nil, "infra") + if err != nil || len(page.Items) != 1 || page.Items[0].ID != "r1" || page.Items[0].Health != "PULLABLE" || page.Items[0].AuthMethod != "SSH" { + t.Fatalf("List() = %#v, %v", page, err) + } + + detail, err := service.Get(t.Context(), "r1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if !detail.Decrypt || detail.URL != "git@github.com:acme/infra.git" || detail.Health != "PULLABLE" { + t.Fatalf("detail = %#v", detail) + } +} + +func TestListPages(t *testing.T) { + health := gqlclient.GitHealthPullable + edges := make([]*gqlclient.GitRepositoryEdgeFragment, 0, 3) + for _, id := range []string{"r1", "r2", "r3"} { + edges = append(edges, &gqlclient.GitRepositoryEdgeFragment{ + Node: &gqlclient.GitRepositoryFragment{ID: id, URL: "git@github.com:acme/" + id + ".git", Health: &health}, + }) + } + api := &fakeAPI{ + repos: &gqlclient.ListGitRepositories{GitRepositories: &gqlclient.ListGitRepositories_GitRepositories{Edges: edges}}, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 2, + } + + first, err := service.List(t.Context(), nil, "") + if err != nil || len(first.Items) != 2 || !first.HasNext || first.EndCursor != "r2" { + t.Fatalf("first page = %#v, %v", first, err) + } + after := first.EndCursor + second, err := service.List(t.Context(), &after, "") + if err != nil || len(second.Items) != 1 || second.HasNext || second.Items[0].ID != "r3" { + t.Fatalf("second page = %#v, %v", second, err) + } +} + +func TestGetRequiresID(t *testing.T) { + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return &fakeAPI{}, nil }, + } + _, err := service.Get(t.Context(), "") + if !bridge.IsCode(err, bridge.ErrorInvalid) { + t.Fatalf("Get() error = %v", err) + } +} diff --git a/pkg/bridge/services/services.go b/pkg/bridge/services/services.go index cdb8e3e1c..b2c76560b 100644 --- a/pkg/bridge/services/services.go +++ b/pkg/bridge/services/services.go @@ -12,7 +12,7 @@ import ( "github.com/pluralsh/plural-cli/pkg/console" ) -const defaultPageSize int64 = 50 +const defaultPageSize int64 = 10 // Cluster is a credential-free Console cluster summary. type Cluster struct { diff --git a/pkg/bridge/services/services_test.go b/pkg/bridge/services/services_test.go index ae63cf637..6b4494d19 100644 --- a/pkg/bridge/services/services_test.go +++ b/pkg/bridge/services/services_test.go @@ -101,6 +101,33 @@ func TestListRequiresCluster(t *testing.T) { } } +func TestListPages(t *testing.T) { + edges := make([]*gqlclient.ServiceDeploymentEdgeFragment, 0, 12) + for i := 0; i < 12; i++ { + id := string(rune('a' + i)) + edges = append(edges, &gqlclient.ServiceDeploymentEdgeFragment{ + Node: &gqlclient.ServiceDeploymentBaseFragment{ + ID: id, Name: "svc-" + id, Namespace: "default", Status: gqlclient.ServiceDeploymentStatusHealthy, + }, + }) + } + api := &fakeAPI{edges: edges} + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 10, + } + first, err := service.List(t.Context(), "c1", nil, "") + if err != nil || len(first.Items) != 10 || !first.HasNext || first.EndCursor != "j" { + t.Fatalf("first = %#v, %v", first, err) + } + after := first.EndCursor + second, err := service.List(t.Context(), "c1", &after, "") + if err != nil || len(second.Items) != 2 || second.HasNext || second.Items[0].ID != "k" { + t.Fatalf("second = %#v, %v", second, err) + } +} + func TestGetMapsDetail(t *testing.T) { handle := "prod-eu" sha := "abc123" diff --git a/tui/app/model.go b/tui/app/model.go index d10aab56e..d591564ff 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -9,6 +9,7 @@ import ( accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" "github.com/pluralsh/plural-cli/tui/navigation" @@ -16,6 +17,7 @@ import ( clustersscreen "github.com/pluralsh/plural-cli/tui/screens/clusters" deploymentsscreen "github.com/pluralsh/plural-cli/tui/screens/deployments" diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics" + repositoriesscreen "github.com/pluralsh/plural-cli/tui/screens/repositories" servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" welcomescreen "github.com/pluralsh/plural-cli/tui/screens/welcome" "github.com/pluralsh/plural-cli/tui/theme" @@ -23,10 +25,11 @@ import ( // Dependencies contains the services required by TUI screens. type Dependencies struct { - Welcome welcomebridge.Loader - Access accessbridge.Manager - Services servicesbridge.Loader - Clusters clustersbridge.Loader + Welcome welcomebridge.Loader + Access accessbridge.Manager + Services servicesbridge.Loader + Clusters clustersbridge.Loader + Repositories repositoriesbridge.Loader } // Model is the root TUI model. It owns global input and delegates screen state @@ -38,26 +41,28 @@ type Model struct { theme theme.Theme quit key.Binding - welcome welcomescreen.Model - access accessscreen.Model - diagnostics diagnosticsscreen.Model - deployments deploymentsscreen.Model - services servicesscreen.Model - clusters clustersscreen.Model - route navigation.Route + welcome welcomescreen.Model + access accessscreen.Model + diagnostics diagnosticsscreen.Model + deployments deploymentsscreen.Model + services servicesscreen.Model + clusters clustersscreen.Model + repositories repositoriesscreen.Model + route navigation.Route } // New composes the root model with caller-provided dependencies. func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { return Model{ - theme: t, - welcome: welcomescreen.New(ctx, dependencies.Welcome, t), - access: accessscreen.New(ctx, dependencies.Access, t), - diagnostics: diagnosticsscreen.New(ctx, dependencies.Welcome, t), - deployments: deploymentsscreen.New(ctx, t, ""), - services: servicesscreen.New(ctx, dependencies.Services, t), - clusters: clustersscreen.New(ctx, dependencies.Clusters, t), - route: navigation.Welcome, + theme: t, + welcome: welcomescreen.New(ctx, dependencies.Welcome, t), + access: accessscreen.New(ctx, dependencies.Access, t), + diagnostics: diagnosticsscreen.New(ctx, dependencies.Welcome, t), + deployments: deploymentsscreen.New(ctx, t, ""), + services: servicesscreen.New(ctx, dependencies.Services, t), + clusters: clustersscreen.New(ctx, dependencies.Clusters, t), + repositories: repositoriesscreen.New(ctx, dependencies.Repositories, t), + route: navigation.Welcome, quit: key.NewBinding( key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit"), @@ -83,6 +88,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.services.Init() case navigation.Clusters: return m, m.clusters.Init() + case navigation.Repositories: + return m, m.repositories.Init() default: return m, m.welcome.Init() } @@ -112,6 +119,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.services, cmd = m.services.Update(msg) case navigation.Clusters: m.clusters, cmd = m.clusters.Update(msg) + case navigation.Repositories: + m.repositories, cmd = m.repositories.Update(msg) default: m.welcome, cmd = m.welcome.Update(msg) } diff --git a/tui/app/model_test.go b/tui/app/model_test.go index 212e5f9f7..1f5dd892d 100644 --- a/tui/app/model_test.go +++ b/tui/app/model_test.go @@ -60,6 +60,11 @@ func TestModelRoutesScreensWithoutRebuildingShell(t *testing.T) { if routed.route != navigation.Clusters || !strings.Contains(routed.View().Content, "Clusters") { t.Fatalf("clusters route/view = %q\n%s", routed.route, routed.View().Content) } + updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Repositories}) + routed = updated.(Model) + if routed.route != navigation.Repositories || !strings.Contains(routed.View().Content, "Repositories") { + t.Fatalf("repositories route/view = %q\n%s", routed.route, routed.View().Content) + } updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Welcome}) if got := updated.(Model).route; got != navigation.Welcome { t.Fatalf("route = %q", got) diff --git a/tui/app/view.go b/tui/app/view.go index a0c5f6324..d9d1c4c0f 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -21,6 +21,8 @@ func (m Model) View() tea.View { content = m.services.View(m.width, m.height) case navigation.Clusters: content = m.clusters.View(m.width, m.height) + case navigation.Repositories: + content = m.repositories.View(m.width, m.height) } view := tea.NewView(content) view.AltScreen = true diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index e12b2dcb0..8b4ddda5b 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -13,8 +13,9 @@ const ( Access Route = "access" Diagnostics Route = "diagnostics" Deployments Route = "deployments" - Services Route = "services" - Clusters Route = "clusters" + Services Route = "services" + Clusters Route = "clusters" + Repositories Route = "repositories" ) // NavigateMsg requests a top-level route change. diff --git a/tui/screens/clusters/model.go b/tui/screens/clusters/model.go index aff81ec0b..5fa072bf6 100644 --- a/tui/screens/clusters/model.go +++ b/tui/screens/clusters/model.go @@ -33,6 +33,8 @@ const ( keyActionRefresh keyActionFilter keyActionConnectConsole + keyActionNextPage + keyActionPrevPage ) var keyActionKeystrokes = map[keyAction][]string{ @@ -43,6 +45,8 @@ var keyActionKeystrokes = map[keyAction][]string{ keyActionRefresh: {"r"}, keyActionFilter: {"/"}, keyActionConnectConsole: {"c"}, + keyActionNextPage: {"n", "right", "]"}, + keyActionPrevPage: {"p", "left", "["}, } func actionForKeystroke(keystroke string) keyAction { @@ -58,7 +62,7 @@ func actionForKeystroke(keystroke string) keyAction { type initMsg struct{} type listedMsg struct { - items []clustersbridge.Summary + page clustersbridge.Page err error request uint64 } @@ -79,15 +83,19 @@ type Model struct { needsAuth bool request uint64 - items []clustersbridge.Summary + page clustersbridge.Page cursor int filter string filterInput textinput.Model + after *string + prevCursors []string detail clustersbridge.Detail detailID string listCursor int + listAfter *string listFilter string + listPrev []string } func New(ctx context.Context, loader clustersbridge.Loader, t theme.Theme) Model { @@ -108,7 +116,7 @@ func (m Model) Init() tea.Cmd { return func() tea.Msg { return initMsg{} } } -func (m *Model) beginList() tea.Cmd { +func (m *Model) beginList(after *string) tea.Cmd { m.loading = true m.request++ request := m.request @@ -116,8 +124,8 @@ func (m *Model) beginList() tea.Cmd { loader := m.loader ctx := m.ctx return func() tea.Msg { - items, err := loader.List(ctx, query) - return listedMsg{items: items, err: err, request: request} + page, err := loader.List(ctx, after, query) + return listedMsg{page: page, err: err, request: request} } } @@ -137,15 +145,17 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { case initMsg: m.mode = modeList - m.items = nil + m.page = clustersbridge.Page{} m.cursor = 0 + m.after = nil + m.prevCursors = nil m.err = nil m.needsAuth = false if m.loader == nil { m.loading = false return m, nil } - return m, m.beginList() + return m, m.beginList(nil) case listedMsg: if msg.request != m.request { return m, nil @@ -154,8 +164,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.err = msg.err m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) if msg.err == nil { - m.items = msg.items - m.cursor = clampCursor(m.cursor, len(m.items)) + m.page = msg.page + m.cursor = clampCursor(m.cursor, len(m.page.Items)) m.mode = modeList } return m, nil @@ -195,7 +205,9 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { m.filterInput.Blur() m.mode = modeList m.cursor = 0 - return m, m.beginList() + m.after = nil + m.prevCursors = nil + return m, m.beginList(nil) } var cmd tea.Cmd m.filterInput, cmd = m.filterInput.Update(key) @@ -206,7 +218,9 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { m.mode = modeList m.err = nil m.cursor = m.listCursor + m.after = m.listAfter m.filter = m.listFilter + m.prevCursors = append([]string(nil), m.listPrev...) return m, nil } return m, navigation.Navigate(navigation.Deployments) @@ -229,23 +243,51 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { func (m Model) updateList(action keyAction) (Model, tea.Cmd) { switch action { case keyActionMoveUp: - m.cursor = clampCursor(m.cursor-1, len(m.items)) + m.cursor = clampCursor(m.cursor-1, len(m.page.Items)) case keyActionMoveDown: - m.cursor = clampCursor(m.cursor+1, len(m.items)) + m.cursor = clampCursor(m.cursor+1, len(m.page.Items)) case keyActionConfirm: - if len(m.items) == 0 { + if len(m.page.Items) == 0 { return m, nil } m.listCursor = m.cursor + m.listAfter = m.after m.listFilter = m.filter - m.detailID = m.items[m.cursor].ID + m.listPrev = append([]string(nil), m.prevCursors...) + m.detailID = m.page.Items[m.cursor].ID return m, m.beginDetail(m.detailID) case keyActionRefresh: - return m, m.beginList() + return m, m.beginList(m.after) case keyActionFilter: m.mode = modeFilter m.filterInput.SetValue(m.filter) m.filterInput.Focus() + case keyActionNextPage: + if !m.page.HasNext || m.page.EndCursor == "" { + return m, nil + } + if m.after != nil { + m.prevCursors = append(m.prevCursors, *m.after) + } else { + m.prevCursors = append(m.prevCursors, "") + } + cursor := m.page.EndCursor + m.after = &cursor + m.cursor = 0 + return m, m.beginList(m.after) + case keyActionPrevPage: + if len(m.prevCursors) == 0 { + return m, nil + } + previous := m.prevCursors[len(m.prevCursors)-1] + m.prevCursors = m.prevCursors[:len(m.prevCursors)-1] + if previous == "" { + m.after = nil + } else { + m.after = &previous + } + m.cursor = 0 + return m, m.beginList(m.after) } return m, nil } diff --git a/tui/screens/clusters/model_test.go b/tui/screens/clusters/model_test.go index b0268a01e..32bc082a9 100644 --- a/tui/screens/clusters/model_test.go +++ b/tui/screens/clusters/model_test.go @@ -20,13 +20,13 @@ import ( ) type fakeLoader struct { - items []clustersbridge.Summary + page clustersbridge.Page detail clustersbridge.Detail err error } -func (f *fakeLoader) List(context.Context, string) ([]clustersbridge.Summary, error) { - return f.items, f.err +func (f *fakeLoader) List(context.Context, *string, string) (clustersbridge.Page, error) { + return f.page, f.err } func (f *fakeLoader) Get(context.Context, string) (clustersbridge.Detail, error) { return f.detail, f.err @@ -45,10 +45,10 @@ func loadList(t *testing.T, model Model) Model { func TestOpenClusterDetailAndBack(t *testing.T) { loader := &fakeLoader{ - items: []clustersbridge.Summary{ + page: clustersbridge.Page{Items: []clustersbridge.Summary{ {ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, {ID: "c2", Name: "staging", Handle: "staging", Version: "1.29.0", Distro: "EKS"}, - }, + }}, detail: clustersbridge.Detail{ Summary: clustersbridge.Summary{ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, Self: true, @@ -57,8 +57,8 @@ func TestOpenClusterDetailAndBack(t *testing.T) { }, } model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) - if model.mode != modeList || len(model.items) != 2 { - t.Fatalf("list state = mode=%d count=%d", model.mode, len(model.items)) + if model.mode != modeList || len(model.page.Items) != 2 { + t.Fatalf("list state = mode=%d count=%d", model.mode, len(model.page.Items)) } if !strings.Contains(model.View(80, 24), "@prod-eu") { t.Fatalf("list missing handle:\n%s", model.View(80, 24)) @@ -83,6 +83,37 @@ func TestOpenClusterDetailAndBack(t *testing.T) { } } +func TestNextPrevPage(t *testing.T) { + loader := &fakeLoader{ + page: clustersbridge.Page{ + Items: []clustersbridge.Summary{{ID: "c1", Name: "a", Handle: "a"}}, + EndCursor: "c1", + HasNext: true, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !strings.Contains(model.View(80, 24), "n next") { + t.Fatalf("missing next pager:\n%s", model.View(80, 24)) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: 'n'}) + if cmd == nil { + t.Fatal("expected next-page list command") + } + loader.page = clustersbridge.Page{Items: []clustersbridge.Summary{{ID: "c2", Name: "b", Handle: "b"}}} + model, _ = model.Update(cmd()) + if model.after == nil || *model.after != "c1" || len(model.prevCursors) != 1 { + t.Fatalf("after page turn after=%v prev=%v", model.after, model.prevCursors) + } + model, cmd = model.Update(tea.KeyPressMsg{Code: 'p'}) + if cmd == nil { + t.Fatal("expected prev-page list command") + } + model, _ = model.Update(cmd()) + if model.after != nil || len(model.prevCursors) != 0 { + t.Fatalf("after prev after=%v prev=%v", model.after, model.prevCursors) + } +} + func TestNoConsoleNavigatesToAccess(t *testing.T) { loader := &fakeLoader{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect")}} model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) @@ -99,11 +130,11 @@ func TestClustersGoldens(t *testing.T) { list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) list.loading = false list.mode = modeList - list.items = []clustersbridge.Summary{ + list.page = clustersbridge.Page{Items: []clustersbridge.Summary{ {ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, {ID: "c2", Name: "staging", Handle: "staging", Version: "1.29.0", Distro: "EKS"}, {ID: "c3", Name: "edge", Version: "1.28.1", Distro: "K3S"}, - } + }, HasNext: true, EndCursor: "c3"} detail := list detail.mode = modeDetail @@ -159,11 +190,11 @@ func TestWriteClustersGoldens(t *testing.T) { list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) list.loading = false list.mode = modeList - list.items = []clustersbridge.Summary{ + list.page = clustersbridge.Page{Items: []clustersbridge.Summary{ {ID: "c1", Name: "production", Handle: "prod-eu", Version: "1.30.2", Distro: "EKS"}, {ID: "c2", Name: "staging", Handle: "staging", Version: "1.29.0", Distro: "EKS"}, {ID: "c3", Name: "edge", Version: "1.28.1", Distro: "K3S"}, - } + }, HasNext: true, EndCursor: "c3"} detail := list detail.mode = modeDetail detail.detail = clustersbridge.Detail{ diff --git a/tui/screens/clusters/testdata/clusters-list-120.golden b/tui/screens/clusters/testdata/clusters-list-120.golden index 51be6fea1..be037dd59 100644 --- a/tui/screens/clusters/testdata/clusters-list-120.golden +++ b/tui/screens/clusters/testdata/clusters-list-120.golden @@ -7,7 +7,7 @@ │ @staging staging 1.29.0 EKS │ │ — edge 1.28.1 K3S │ │ │ - │ │ + │ page · n next │ │ │ │ │ │ │ @@ -27,4 +27,4 @@ - ↑/↓ select · enter open · / filter · r refresh · esc back + ↑/↓ select · enter open · / filter · n/p page · r refresh · esc back diff --git a/tui/screens/clusters/testdata/clusters-list-80.golden b/tui/screens/clusters/testdata/clusters-list-80.golden index f5a5b9cf7..080e76006 100644 --- a/tui/screens/clusters/testdata/clusters-list-80.golden +++ b/tui/screens/clusters/testdata/clusters-list-80.golden @@ -7,7 +7,7 @@ │ @staging staging 1.29.0 EKS │ │ — edge 1.28.1 K3S │ │ │ - │ │ + │ page · n next │ │ │ │ │ │ │ @@ -21,4 +21,4 @@ - ↑/↓ · enter · / filter · esc back + ↑/↓ · enter · / · n/p page · esc back diff --git a/tui/screens/clusters/view.go b/tui/screens/clusters/view.go index 5f7674164..cb20a6c6e 100644 --- a/tui/screens/clusters/view.go +++ b/tui/screens/clusters/view.go @@ -45,9 +45,9 @@ func (m Model) headerStatus() string { return m.theme.Success.Render(loCoalesce(m.detail.Distro, "ready")) case modeList: if m.filter != "" { - return m.theme.Muted.Render(fmt.Sprintf("%d matching", len(m.items))) + return m.theme.Muted.Render(fmt.Sprintf("%d matching", len(m.page.Items))) } - return m.theme.Success.Render(fmt.Sprintf("%d clusters", len(m.items))) + return m.theme.Success.Render(fmt.Sprintf("%d clusters", len(m.page.Items))) default: return m.theme.Muted.Render("clusters") } @@ -75,9 +75,9 @@ func (m Model) bodyAndHelp(width int) (string, string) { help := "r refresh · esc list · ctrl+c quit" return page.Panel(m.theme, "Summary", m.detailLines(), width, 16, true), help } - help := "↑/↓ select · enter open · / filter · r refresh · esc back" + help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc back" if width < 100 { - help = "↑/↓ · enter · / filter · esc back" + help = "↑/↓ · enter · / · n/p page · esc back" } return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help } @@ -90,19 +90,21 @@ func (m Model) listTitle() string { } func (m Model) listLines(width int) []string { - if m.loading && len(m.items) == 0 { + if m.loading && len(m.page.Items) == 0 { return []string{m.theme.Warning.Render("◌ Loading clusters…")} } if m.err != nil { return []string{m.theme.Danger.Render("✗ Unable to load clusters"), m.theme.Danger.Render("Error " + m.err.Error()), m.theme.Muted.Render("Press r to retry.")} } - if len(m.items) == 0 { + if len(m.page.Items) == 0 { return []string{m.theme.Warning.Render("○ No clusters found"), m.theme.Muted.Render(" Adjust the filter or connect another Console.")} } handleWidth := max(12, min(20, width/4)) nameWidth := max(12, min(24, width/3)) lines := []string{m.theme.Muted.Render(" " + pad("HANDLE", handleWidth) + " " + pad("NAME", nameWidth) + " " + pad("VERSION", 10) + " DISTRO")} - for i, item := range m.items { + start, end := visibleWindow(m.cursor, len(m.page.Items), 8) + for i := start; i < end; i++ { + item := m.page.Items[i] cursor := " " if i == m.cursor { cursor = "› " @@ -118,9 +120,44 @@ func (m Model) listLines(width int) []string { row := cursor + pad(handle, handleWidth) + " " + pad(item.Name, nameWidth) + " " + pad(version, 10) + " " + distro lines = append(lines, ansi.Truncate(row, width-2, "…")) } + if start > 0 || end < len(m.page.Items) { + lines = append(lines, m.theme.Muted.Render(fmt.Sprintf(" … %d–%d of %d", start+1, end, len(m.page.Items)))) + } + if m.page.HasNext || len(m.prevCursors) > 0 { + pager := "page" + if len(m.prevCursors) > 0 { + pager += " · p prev" + } + if m.page.HasNext { + pager += " · n next" + } + lines = append(lines, "", m.theme.Muted.Render(pager)) + } return lines } +func visibleWindow(cursor, count, size int) (start, end int) { + if count <= 0 { + return 0, 0 + } + if size <= 0 { + size = count + } + if count <= size { + return 0, count + } + start = cursor - size/2 + if start < 0 { + start = 0 + } + end = start + size + if end > count { + end = count + start = end - size + } + return start, end +} + func (m Model) detailLines() []string { if m.loading { return []string{m.theme.Warning.Render("◌ Loading cluster detail…")} diff --git a/tui/screens/deployments/model.go b/tui/screens/deployments/model.go index a7c3c520e..9442db561 100644 --- a/tui/screens/deployments/model.go +++ b/tui/screens/deployments/model.go @@ -34,7 +34,7 @@ func resources() []resource { return []resource{ {id: resourceServices, number: "1", shortcut: "s", title: "Services", blurb: "browse · kick · create · …", route: navigation.Services}, {id: resourceClusters, number: "2", shortcut: "c", title: "Clusters", blurb: "list · describe", route: navigation.Clusters}, - {id: resourceRepositories, number: "3", shortcut: "r", title: "Repositories", blurb: "list · get", soon: true}, + {id: resourceRepositories, number: "3", shortcut: "r", title: "Repositories", blurb: "list · describe", route: navigation.Repositories}, {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "trigger", soon: true}, {id: resourceNotifications, number: "5", shortcut: "n", title: "Notifications", blurb: "sinks", soon: true}, {id: resourceProviders, number: "6", shortcut: "v", title: "Providers", blurb: "list", soon: true}, diff --git a/tui/screens/deployments/model_test.go b/tui/screens/deployments/model_test.go index 4b0ff56e6..39cbd4529 100644 --- a/tui/screens/deployments/model_test.go +++ b/tui/screens/deployments/model_test.go @@ -76,9 +76,20 @@ func TestClustersNavigates(t *testing.T) { } } +func TestRepositoriesNavigates(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + _, cmd := model.Update(tea.KeyPressMsg{Code: 'r', Text: "r"}) + if cmd == nil { + t.Fatal("expected navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Repositories}) { + t.Fatalf("msg = %#v", msg) + } +} + func TestSoonResourceDoesNotNavigate(t *testing.T) { model := New(t.Context(), theme.New(colorprofile.ASCII), "") - model.cursor = 2 // repositories [soon] + model.cursor = 3 // pipelines [soon] _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if cmd != nil { t.Fatalf("unexpected cmd %#v", cmd()) diff --git a/tui/screens/deployments/testdata/deployments-120.golden b/tui/screens/deployments/testdata/deployments-120.golden index 37618cc26..a5b6a7f64 100644 --- a/tui/screens/deployments/testdata/deployments-120.golden +++ b/tui/screens/deployments/testdata/deployments-120.golden @@ -4,7 +4,7 @@ ╭─ › Resources ────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ › 1 s Services browse · kick · create · … │ │ 2 c Clusters list · describe │ - │ 3 r Repositories list · get [soon] │ + │ 3 r Repositories list · describe │ │ 4 p Pipelines trigger [soon] │ │ 5 n Notifications sinks [soon] │ │ 6 v Providers list [soon] │ diff --git a/tui/screens/deployments/testdata/deployments-80.golden b/tui/screens/deployments/testdata/deployments-80.golden index 1539b8906..4230cd22d 100644 --- a/tui/screens/deployments/testdata/deployments-80.golden +++ b/tui/screens/deployments/testdata/deployments-80.golden @@ -4,7 +4,7 @@ ╭─ › Resources ────────────────────────────────────────────────────────────╮ │ › 1 s Services browse · kick · create · … │ │ 2 c Clusters list · describe │ - │ 3 r Repositories list · get [soon] │ + │ 3 r Repositories list · describe │ │ 4 p Pipelines trigger [soon] │ │ 5 n Notifications sinks [soon] │ │ 6 v Providers list [soon] │ diff --git a/tui/screens/repositories/model.go b/tui/screens/repositories/model.go new file mode 100644 index 000000000..d7e31813b --- /dev/null +++ b/tui/screens/repositories/model.go @@ -0,0 +1,306 @@ +// Package repositories implements the read-only Console git repositories browser. +package repositories + +import ( + "context" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeList mode = iota + modeDetail + modeFilter +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionRefresh + keyActionFilter + keyActionConnectConsole + keyActionNextPage + keyActionPrevPage +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, + keyActionRefresh: {"r"}, + keyActionFilter: {"/"}, + keyActionConnectConsole: {"c"}, + keyActionNextPage: {"n", "right", "]"}, + keyActionPrevPage: {"p", "left", "["}, +} + +func actionForKeystroke(keystroke string) keyAction { + for action, keystrokes := range keyActionKeystrokes { + for _, candidate := range keystrokes { + if keystroke == candidate { + return action + } + } + } + return keyActionNone +} + +type initMsg struct{} +type listedMsg struct { + page repositoriesbridge.Page + err error + request uint64 +} +type detailMsg struct { + detail repositoriesbridge.Detail + err error + request uint64 +} + +// Model owns Repositories-screen interaction state. +type Model struct { + ctx context.Context + loader repositoriesbridge.Loader + theme theme.Theme + mode mode + loading bool + err error + needsAuth bool + request uint64 + + page repositoriesbridge.Page + cursor int + filter string + filterInput textinput.Model + after *string + prevCursors []string + + detail repositoriesbridge.Detail + detailID string + listCursor int + listAfter *string + listFilter string + listPrev []string +} + +func New(ctx context.Context, loader repositoriesbridge.Loader, t theme.Theme) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "filter repositories" + input.CharLimit = 128 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Blurred = styles.Focused + input.SetStyles(styles) + return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, mode: modeList} +} + +func (m Model) Init() tea.Cmd { + return func() tea.Msg { return initMsg{} } +} + +func (m *Model) beginList(after *string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + query := m.filter + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + page, err := loader.List(ctx, after, query) + return listedMsg{page: page, err: err, request: request} + } +} + +func (m *Model) beginDetail(id string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + detail, err := loader.Get(ctx, id) + return detailMsg{detail: detail, err: err, request: request} + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case initMsg: + m.mode = modeList + m.page = repositoriesbridge.Page{} + m.cursor = 0 + m.after = nil + m.prevCursors = nil + m.err = nil + m.needsAuth = false + if m.loader == nil { + m.loading = false + return m, nil + } + return m, m.beginList(nil) + case listedMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.page = msg.page + m.cursor = clampCursor(m.cursor, len(m.page.Items)) + m.mode = modeList + } + return m, nil + case detailMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.detail = msg.detail + m.mode = modeDetail + } + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if m.mode == modeFilter { + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + if m.mode == modeFilter { + switch action { + case keyActionBack: + m.mode = modeList + m.filterInput.Blur() + return m, nil + case keyActionConfirm: + m.filter = strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + m.mode = modeList + m.cursor = 0 + m.after = nil + m.prevCursors = nil + return m, m.beginList(nil) + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd + } + if action == keyActionBack { + if m.mode == modeDetail { + m.mode = modeList + m.err = nil + m.cursor = m.listCursor + m.after = m.listAfter + m.filter = m.listFilter + m.prevCursors = append([]string(nil), m.listPrev...) + return m, nil + } + return m, navigation.Navigate(navigation.Deployments) + } + if m.loading { + return m, nil + } + if m.needsAuth && action == keyActionConnectConsole { + return m, navigation.Navigate(navigation.Access) + } + if m.mode == modeDetail { + if action == keyActionRefresh && m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + return m, nil + } + return m.updateList(action) +} + +func (m Model) updateList(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionMoveUp: + m.cursor = clampCursor(m.cursor-1, len(m.page.Items)) + case keyActionMoveDown: + m.cursor = clampCursor(m.cursor+1, len(m.page.Items)) + case keyActionConfirm: + if len(m.page.Items) == 0 { + return m, nil + } + m.listCursor = m.cursor + m.listAfter = m.after + m.listFilter = m.filter + m.listPrev = append([]string(nil), m.prevCursors...) + m.detailID = m.page.Items[m.cursor].ID + return m, m.beginDetail(m.detailID) + case keyActionRefresh: + return m, m.beginList(m.after) + case keyActionFilter: + m.mode = modeFilter + m.filterInput.SetValue(m.filter) + m.filterInput.Focus() + case keyActionNextPage: + if !m.page.HasNext || m.page.EndCursor == "" { + return m, nil + } + if m.after != nil { + m.prevCursors = append(m.prevCursors, *m.after) + } else { + m.prevCursors = append(m.prevCursors, "") + } + cursor := m.page.EndCursor + m.after = &cursor + m.cursor = 0 + return m, m.beginList(m.after) + case keyActionPrevPage: + if len(m.prevCursors) == 0 { + return m, nil + } + previous := m.prevCursors[len(m.prevCursors)-1] + m.prevCursors = m.prevCursors[:len(m.prevCursors)-1] + if previous == "" { + m.after = nil + } else { + m.after = &previous + } + m.cursor = 0 + return m, m.beginList(m.after) + } + return m, nil +} + +func clampCursor(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} diff --git a/tui/screens/repositories/model_test.go b/tui/screens/repositories/model_test.go new file mode 100644 index 000000000..6cee2ec04 --- /dev/null +++ b/tui/screens/repositories/model_test.go @@ -0,0 +1,232 @@ +package repositories + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/pkg/bridge" + repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeLoader struct { + page repositoriesbridge.Page + detail repositoriesbridge.Detail + err error +} + +func (f *fakeLoader) List(context.Context, *string, string) (repositoriesbridge.Page, error) { + return f.page, f.err +} +func (f *fakeLoader) Get(context.Context, string) (repositoriesbridge.Detail, error) { + return f.detail, f.err +} + +func loadList(t *testing.T, model Model) Model { + t.Helper() + cmd := model.Init() + model, cmd = model.Update(cmd()) + if cmd == nil { + t.Fatal("expected list command") + } + model, _ = model.Update(cmd()) + return model +} + +func TestOpenRepositoryDetailAndBack(t *testing.T) { + loader := &fakeLoader{ + page: repositoriesbridge.Page{Items: []repositoriesbridge.Summary{ + {ID: "r1", URL: "git@github.com:acme/infra.git", Health: "PULLABLE", AuthMethod: "SSH"}, + {ID: "r2", URL: "https://github.com/acme/apps.git", Health: "FAILED", Error: "auth failed"}, + }}, + detail: repositoriesbridge.Detail{ + Summary: repositoriesbridge.Summary{ + ID: "r1", URL: "git@github.com:acme/infra.git", Health: "PULLABLE", AuthMethod: "SSH", + }, + Decrypt: true, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if model.mode != modeList || len(model.page.Items) != 2 { + t.Fatalf("list state = mode=%d count=%d", model.mode, len(model.page.Items)) + } + if !strings.Contains(model.View(80, 24), "git@github.com:acme/infra.git") { + t.Fatalf("list missing url:\n%s", model.View(80, 24)) + } + + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail || model.detail.URL != "git@github.com:acme/infra.git" { + t.Fatalf("detail = %#v mode=%d", model.detail, model.mode) + } + if !strings.Contains(model.View(80, 24), "SSH") { + t.Fatalf("detail view missing auth:\n%s", model.View(80, 24)) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeList { + t.Fatalf("mode after detail esc = %d", model.mode) + } + _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Deployments}) { + t.Fatalf("expected deployments navigation") + } +} + +func TestNextPrevPage(t *testing.T) { + loader := &fakeLoader{ + page: repositoriesbridge.Page{ + Items: []repositoriesbridge.Summary{{ID: "r1", URL: "git@github.com:acme/a.git", Health: "PULLABLE"}}, + EndCursor: "r1", + HasNext: true, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !strings.Contains(model.View(80, 24), "n next") { + t.Fatalf("missing next pager:\n%s", model.View(80, 24)) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: 'n'}) + if cmd == nil { + t.Fatal("expected next-page list command") + } + loader.page = repositoriesbridge.Page{ + Items: []repositoriesbridge.Summary{{ID: "r2", URL: "git@github.com:acme/b.git", Health: "PULLABLE"}}, + } + model, _ = model.Update(cmd()) + if model.after == nil || *model.after != "r1" || len(model.prevCursors) != 1 { + t.Fatalf("after page turn after=%v prev=%v", model.after, model.prevCursors) + } + if !strings.Contains(model.View(80, 24), "p prev") { + t.Fatalf("missing prev pager:\n%s", model.View(80, 24)) + } + model, cmd = model.Update(tea.KeyPressMsg{Code: 'p'}) + if cmd == nil { + t.Fatal("expected prev-page list command") + } + model, _ = model.Update(cmd()) + if model.after != nil || len(model.prevCursors) != 0 { + t.Fatalf("after prev after=%v prev=%v", model.after, model.prevCursors) + } +} + +func TestNoConsoleNavigatesToAccess(t *testing.T) { + loader := &fakeLoader{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect")}} + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !model.needsAuth { + t.Fatal("expected needsAuth") + } + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c'}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Access}) { + t.Fatalf("expected access navigation") + } +} + +func TestRepositoriesGoldens(t *testing.T) { + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = repositoriesbridge.Page{Items: []repositoriesbridge.Summary{ + {ID: "r1", URL: "git@github.com:acme/infra.git", Health: "PULLABLE", AuthMethod: "SSH"}, + {ID: "r2", URL: "https://github.com/acme/apps.git", Health: "FAILED", Error: "auth failed"}, + {ID: "r3", URL: "git@gitlab.com:acme/charts.git", Health: "PULLABLE", AuthMethod: "SSH"}, + }, HasNext: true, EndCursor: "r3"} + + detail := list + detail.mode = modeDetail + detail.detail = repositoriesbridge.Detail{ + Summary: repositoriesbridge.Summary{ + ID: "r1", URL: "git@github.com:acme/infra.git", Health: "PULLABLE", AuthMethod: "SSH", + }, + Decrypt: true, + } + + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + t.Run(tc.name, func(t *testing.T) { + got := normalizeView(tc.model.View(tc.width, tc.height)) + golden := filepath.Join("testdata", "repositories-"+tc.name+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + lines := strings.Split(got, "\n") + if len(lines) != tc.height { + t.Fatalf("height = %d, want %d", len(lines), tc.height) + } + for _, line := range lines { + if w := lipgloss.Width(line); w > tc.width { + t.Fatalf("line width %d > %d: %q", w, tc.width, line) + } + } + }) + } +} + +func TestWriteRepositoriesGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = repositoriesbridge.Page{Items: []repositoriesbridge.Summary{ + {ID: "r1", URL: "git@github.com:acme/infra.git", Health: "PULLABLE", AuthMethod: "SSH"}, + {ID: "r2", URL: "https://github.com/acme/apps.git", Health: "FAILED", Error: "auth failed"}, + {ID: "r3", URL: "git@gitlab.com:acme/charts.git", Health: "PULLABLE", AuthMethod: "SSH"}, + }, HasNext: true, EndCursor: "r3"} + detail := list + detail.mode = modeDetail + detail.detail = repositoriesbridge.Detail{ + Summary: repositoriesbridge.Summary{ + ID: "r1", URL: "git@github.com:acme/infra.git", Health: "PULLABLE", AuthMethod: "SSH", + }, + Decrypt: true, + } + _ = os.MkdirAll("testdata", 0o755) + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + got := normalizeView(tc.model.View(tc.width, tc.height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "repositories-"+tc.name+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/repositories/testdata/repositories-detail-120.golden b/tui/screens/repositories/testdata/repositories-detail-120.golden new file mode 100644 index 000000000..af585ab34 --- /dev/null +++ b/tui/screens/repositories/testdata/repositories-detail-120.golden @@ -0,0 +1,30 @@ + Plural Repositories · git@github.com:acme/infra.git PULLABLE + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ URL git@github.com:acme/infra.git │ + │ Health PULLABLE │ + │ Auth SSH │ + │ Decrypt true │ + │ ID r1 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/repositories/testdata/repositories-detail-80.golden b/tui/screens/repositories/testdata/repositories-detail-80.golden new file mode 100644 index 000000000..8a0cc838d --- /dev/null +++ b/tui/screens/repositories/testdata/repositories-detail-80.golden @@ -0,0 +1,24 @@ + Plural Repositories · git@github.com:acme/infra.git PULLABLE + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────╮ + │ URL git@github.com:acme/infra.git │ + │ Health PULLABLE │ + │ Auth SSH │ + │ Decrypt true │ + │ ID r1 │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/repositories/testdata/repositories-list-120.golden b/tui/screens/repositories/testdata/repositories-list-120.golden new file mode 100644 index 000000000..380b32d95 --- /dev/null +++ b/tui/screens/repositories/testdata/repositories-list-120.golden @@ -0,0 +1,30 @@ + Plural Repositories 3 repositories + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Repositories ─────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ URL HEALTH ERROR │ + │ › git@github.com:acme/infra.git PULLABLE — │ + │ https://github.com/acme/apps.git FAILED auth failed │ + │ git@gitlab.com:acme/charts.git PULLABLE — │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · enter open · / filter · n/p page · r refresh · esc back diff --git a/tui/screens/repositories/testdata/repositories-list-80.golden b/tui/screens/repositories/testdata/repositories-list-80.golden new file mode 100644 index 000000000..dee3af16f --- /dev/null +++ b/tui/screens/repositories/testdata/repositories-list-80.golden @@ -0,0 +1,24 @@ + Plural Repositories 3 repositories + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Repositories ─────────────────────────────────────────────────────────╮ + │ URL HEALTH ERROR │ + │ › git@github.com:acme/infra.git PULLABLE — │ + │ https://github.com/acme/apps.git FAILED auth fail… │ + │ git@gitlab.com:acme/charts.git PULLABLE — │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · / · n/p page · esc back diff --git a/tui/screens/repositories/view.go b/tui/screens/repositories/view.go new file mode 100644 index 000000000..eb1243e65 --- /dev/null +++ b/tui/screens/repositories/view.go @@ -0,0 +1,209 @@ +package repositories + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + title := "Repositories" + if m.mode == modeDetail && m.detail.URL != "" { + title = "Repositories · " + shortURL(m.detail.URL, 40) + } + body, help := m.bodyAndHelp(contentWidth) + return page.Render(m.theme, width, height, title, m.headerStatus(), body, help) +} + +func (m Model) headerStatus() string { + if m.loading { + return m.theme.Warning.Render("◌ loading") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + if m.err != nil { + return m.theme.Danger.Render("✗ load failed") + } + switch m.mode { + case modeDetail: + switch m.detail.Health { + case "PULLABLE": + return m.theme.Success.Render("PULLABLE") + case "FAILED": + return m.theme.Danger.Render("FAILED") + default: + return m.theme.Muted.Render(loCoalesce(m.detail.Health, "UNKNOWN")) + } + case modeList: + if m.filter != "" { + return m.theme.Muted.Render(fmt.Sprintf("%d matching", len(m.page.Items))) + } + return m.theme.Success.Render(fmt.Sprintf("%d repositories", len(m.page.Items))) + default: + return m.theme.Muted.Render("repositories") + } +} + +func (m Model) bodyAndHelp(width int) (string, string) { + if m.mode == modeFilter { + lines := []string{ + m.theme.Muted.Render("Filter by url, id, health, error, or auth method."), + "", + m.filterInput.View(), + } + return page.Panel(m.theme, "Filter repositories", lines, width, 6, true), "enter apply · esc cancel" + } + if m.needsAuth { + lines := []string{ + m.theme.Warning.Render("○ Console is not connected"), + m.theme.Muted.Render(" Connect a Console profile to browse repositories."), + "", + m.theme.Body.Render("Press c to open Access."), + } + return page.Panel(m.theme, "Console required", lines, width, 8, true), "c connect · esc back · ctrl+c quit" + } + if m.mode == modeDetail { + help := "r refresh · esc list · ctrl+c quit" + return page.Panel(m.theme, "Summary", m.detailLines(), width, 16, true), help + } + help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc back" + if width < 100 { + help = "↑/↓ · enter · / · n/p page · esc back" + } + return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help +} + +func (m Model) listTitle() string { + if m.filter != "" { + return "Repositories · filter “" + m.filter + "”" + } + return "Repositories" +} + +func (m Model) listLines(width int) []string { + if m.loading && len(m.page.Items) == 0 { + return []string{m.theme.Warning.Render("◌ Loading repositories…")} + } + if m.err != nil { + return []string{ + m.theme.Danger.Render("✗ Unable to load repositories"), + m.theme.Danger.Render("Error " + m.err.Error()), + m.theme.Muted.Render("Press r to retry."), + } + } + if len(m.page.Items) == 0 { + return []string{ + m.theme.Warning.Render("○ No repositories found"), + m.theme.Muted.Render(" Adjust the filter or connect another Console."), + } + } + urlWidth := max(24, min(48, width*2/3)) + lines := []string{m.theme.Muted.Render(" " + pad("URL", urlWidth) + " " + pad("HEALTH", 10) + " ERROR")} + start, end := visibleWindow(m.cursor, len(m.page.Items), 8) + for i := start; i < end; i++ { + item := m.page.Items[i] + cursor := " " + if i == m.cursor { + cursor = "› " + } + health := loCoalesce(item.Health, "UNKNOWN") + errText := loCoalesce(item.Error, "—") + row := cursor + pad(item.URL, urlWidth) + " " + pad(health, 10) + " " + errText + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + if start > 0 || end < len(m.page.Items) { + lines = append(lines, m.theme.Muted.Render(fmt.Sprintf(" … %d–%d of %d", start+1, end, len(m.page.Items)))) + } + if m.page.HasNext || len(m.prevCursors) > 0 { + pager := "page" + if len(m.prevCursors) > 0 { + pager += " · p prev" + } + if m.page.HasNext { + pager += " · n next" + } + lines = append(lines, "", m.theme.Muted.Render(pager)) + } + return lines +} + +func visibleWindow(cursor, count, size int) (start, end int) { + if count <= 0 { + return 0, 0 + } + if size <= 0 { + size = count + } + if count <= size { + return 0, count + } + start = cursor - size/2 + if start < 0 { + start = 0 + } + end = start + size + if end > count { + end = count + start = end - size + } + return start, end +} + +func (m Model) detailLines() []string { + if m.loading { + return []string{m.theme.Warning.Render("◌ Loading repository detail…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load repository"), m.theme.Danger.Render(m.err.Error())} + } + lines := []string{ + m.labelValue("URL", m.detail.URL), + m.labelValue("Health", loCoalesce(m.detail.Health, "UNKNOWN")), + m.labelValue("Auth", loCoalesce(m.detail.AuthMethod, "—")), + m.labelValue("Decrypt", fmt.Sprintf("%v", m.detail.Decrypt)), + m.labelValue("ID", m.detail.ID), + } + if m.detail.Error != "" { + lines = append(lines, m.theme.Danger.Render("Error "+m.detail.Error)) + } + return lines +} + +func (m Model) labelValue(label, value string) string { + label = label + strings.Repeat(" ", max(1, 12-len(label))) + return label + " " + value +} + +func pad(value string, width int) string { + value = ansi.Truncate(value, width, "…") + if lipgloss.Width(value) >= width { + return value + } + return value + strings.Repeat(" ", width-lipgloss.Width(value)) +} + +func loCoalesce(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func shortURL(url string, maxLen int) string { + if lipgloss.Width(url) <= maxLen { + return url + } + return ansi.Truncate(url, maxLen, "…") +} diff --git a/tui/screens/services/model.go b/tui/screens/services/model.go index 5ecf859e8..bb62664eb 100644 --- a/tui/screens/services/model.go +++ b/tui/screens/services/model.go @@ -54,15 +54,15 @@ const ( var keyActionKeystrokes = map[keyAction][]string{ keyActionBack: {"esc"}, - keyActionMoveUp: {"up"}, - keyActionMoveDown: {"down"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, keyActionConfirm: {"enter"}, keyActionRefresh: {"r"}, keyActionFilter: {"/"}, - keyActionNextPage: {"right", "]"}, - keyActionPrevPage: {"left", "p", "["}, + keyActionNextPage: {"n", "right", "]"}, + keyActionPrevPage: {"p", "left", "["}, keyActionConnectConsole: {"c"}, - keyActionCreate: {"n"}, + keyActionCreate: {"a"}, keyActionBackground: {"b"}, } @@ -415,7 +415,7 @@ func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { if m.mode == modeClusters { return m.updateClusters(action) } - return m.updateList(action, text) + return m.updateList(action) } func (m Model) updateFilter(action keyAction, key tea.KeyPressMsg) (Model, tea.Cmd) { @@ -472,6 +472,12 @@ func (m Model) updateDetail(action keyAction, text string) (Model, tea.Cmd) { return m, m.beginDetail(m.detailID) } actions := detailActions() + for i, a := range actions { + if text == a.shortcut { + m.actionCursor = i + return m.openAction(a) + } + } switch action { case keyActionMoveUp: m.actionCursor = clampCursor(m.actionCursor-1, len(actions)) @@ -482,12 +488,6 @@ func (m Model) updateDetail(action keyAction, text string) (Model, tea.Cmd) { case keyActionConfirm: return m.openAction(actions[m.actionCursor]) } - for i, a := range actions { - if text == a.shortcut { - m.actionCursor = i - return m.openAction(a) - } - } return m, nil } @@ -944,8 +944,8 @@ func (m Model) updateClusters(action keyAction) (Model, tea.Cmd) { return m, nil } -func (m Model) updateList(action keyAction, text string) (Model, tea.Cmd) { - if action == keyActionCreate || text == "n" { +func (m Model) updateList(action keyAction) (Model, tea.Cmd) { + if action == keyActionCreate { if m.cluster.ID == "" { return m, nil } diff --git a/tui/screens/services/model_test.go b/tui/screens/services/model_test.go index 75240f688..d268fb4fb 100644 --- a/tui/screens/services/model_test.go +++ b/tui/screens/services/model_test.go @@ -134,6 +134,55 @@ func TestNoConsoleNavigatesToAccess(t *testing.T) { } } +func TestNextPrevPage(t *testing.T) { + loader := &fakeLoader{ + clusters: []servicesbridge.Cluster{{ID: "c1", Handle: "prod-eu"}}, + page: servicesbridge.Page{ + Items: []servicesbridge.Summary{{ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY"}}, + EndCursor: "1", + HasNext: true, + }, + } + model := loadClusters(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeList { + t.Fatalf("mode = %d", model.mode) + } + if !strings.Contains(model.View(80, 24), "n next") { + t.Fatalf("missing next pager:\n%s", model.View(80, 24)) + } + + model, cmd = model.Update(tea.KeyPressMsg{Code: 'n', Text: "n"}) + if cmd == nil { + t.Fatal("expected next-page list command") + } + loader.page = servicesbridge.Page{ + Items: []servicesbridge.Summary{{ID: "2", Name: "worker", Namespace: "jobs", Status: "FAILED"}}, + } + model, _ = model.Update(cmd()) + if model.after == nil || *model.after != "1" || len(model.prevCursors) != 1 { + t.Fatalf("after page turn after=%v prev=%v", model.after, model.prevCursors) + } + if !strings.Contains(model.View(80, 24), "worker") || !strings.Contains(model.View(80, 24), "p prev") { + t.Fatalf("second page view:\n%s", model.View(80, 24)) + } + + model, cmd = model.Update(tea.KeyPressMsg{Code: 'p', Text: "p"}) + if cmd == nil { + t.Fatal("expected prev-page list command") + } + loader.page = servicesbridge.Page{ + Items: []servicesbridge.Summary{{ID: "1", Name: "api", Namespace: "default", Status: "HEALTHY"}}, + EndCursor: "1", + HasNext: true, + } + model, _ = model.Update(cmd()) + if model.after != nil || len(model.prevCursors) != 0 { + t.Fatalf("after prev after=%v prev=%v", model.after, model.prevCursors) + } +} + func TestKickFromDetail(t *testing.T) { loader := &fakeLoader{ clusters: []servicesbridge.Cluster{{ID: "c1", Name: "production", Handle: "prod-eu"}}, @@ -250,3 +299,32 @@ func TestBackFromClustersReturnsDeployments(t *testing.T) { t.Fatalf("msg = %#v", msg) } } + +func TestListScrollKeepsCursorVisible(t *testing.T) { + items := make([]servicesbridge.Summary, 0, 20) + for i := 0; i < 20; i++ { + items = append(items, servicesbridge.Summary{ + ID: string(rune('a'+i)), Name: "svc-" + string(rune('a'+i)), Namespace: "default", Status: "HEALTHY", + }) + } + loader := &fakeLoader{ + clusters: []servicesbridge.Cluster{{ID: "c1", Handle: "prod-eu"}}, + page: servicesbridge.Page{Items: items}, + } + model := loadClusters(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeList { + t.Fatalf("mode = %d", model.mode) + } + for i := 0; i < 12; i++ { + model, _ = model.Update(tea.KeyPressMsg{Code: 'j'}) + } + view := ansi.Strip(model.View(80, 24)) + if !strings.Contains(view, "svc-m") { + t.Fatalf("cursor row not visible after scroll:\n%s", view) + } + if !strings.Contains(view, "…") { + t.Fatalf("expected window indicator:\n%s", view) + } +} diff --git a/tui/screens/services/testdata/services-clusters-120.golden b/tui/screens/services/testdata/services-clusters-120.golden index 1095f872a..c23b0357d 100644 --- a/tui/screens/services/testdata/services-clusters-120.golden +++ b/tui/screens/services/testdata/services-clusters-120.golden @@ -27,4 +27,4 @@ - ↑/↓ select · enter open cluster · / filter · r refresh · esc back + ↑/↓ · j/k select · enter open cluster · / filter · r refresh · esc back diff --git a/tui/screens/services/testdata/services-list-120.golden b/tui/screens/services/testdata/services-list-120.golden index 48cab48b5..b361cad4e 100644 --- a/tui/screens/services/testdata/services-list-120.golden +++ b/tui/screens/services/testdata/services-list-120.golden @@ -7,7 +7,7 @@ │ worker jobs FAILED main services/worker │ │ canary default SYNCED release services/canary │ │ │ - │ page · ] next │ + │ page · n next │ │ │ │ │ │ │ @@ -27,4 +27,4 @@ - ↑/↓ · enter detail · n create · / filter · ]/[ page · r refresh · esc + ↑/↓ · j/k · enter detail · a create · / filter · n/p page · r refresh · esc diff --git a/tui/screens/services/testdata/services-list-80.golden b/tui/screens/services/testdata/services-list-80.golden index 4692ffe30..c2c9b3336 100644 --- a/tui/screens/services/testdata/services-list-80.golden +++ b/tui/screens/services/testdata/services-list-80.golden @@ -7,7 +7,7 @@ │ worker jobs FAILED main services/wor… │ │ canary default SYNCED release services/… │ │ │ - │ page · ] next │ + │ page · n next │ │ │ │ │ │ │ @@ -21,4 +21,4 @@ - ↑/↓ · enter · n create · / · esc + ↑/↓ · enter · a create · n/p page · esc diff --git a/tui/screens/services/view.go b/tui/screens/services/view.go index ae1f978e5..f08837bac 100644 --- a/tui/screens/services/view.go +++ b/tui/screens/services/view.go @@ -189,15 +189,15 @@ func (m Model) bodyAndHelp(width int) (string, string) { } return summary + "\n\n" + actions, help case modeClusters: - help := "↑/↓ select · enter open cluster · / filter · r refresh · esc back" + help := "↑/↓ · j/k select · enter open cluster · / filter · r refresh · esc back" if width < 100 { help = "↑/↓ · enter · / filter · esc back" } return page.Panel(m.theme, m.clusterTitle(), m.clusterLines(width), width, 14, true), help default: - help := "↑/↓ · enter detail · n create · / filter · ]/[ page · r refresh · esc" + help := "↑/↓ · j/k · enter detail · a create · / filter · n/p page · r refresh · esc" if width < 100 { - help = "↑/↓ · enter · n create · / · esc" + help = "↑/↓ · enter · a create · n/p page · esc" } return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help } @@ -290,7 +290,9 @@ func (m Model) clusterLines(width int) []string { } handleWidth := max(12, min(24, width/3)) lines := []string{m.theme.Muted.Render(" " + pad("HANDLE", handleWidth) + " " + pad("NAME", 24) + " ID")} - for i, cluster := range m.clusters { + start, end := visibleWindow(m.clusterCursor, len(m.clusters), 8) + for i := start; i < end; i++ { + cluster := m.clusters[i] cursor := " " if i == m.clusterCursor { cursor = "› " @@ -307,6 +309,9 @@ func (m Model) clusterLines(width int) []string { } lines = append(lines, ansi.Truncate(row, width-2, "…")) } + if start > 0 || end < len(m.clusters) { + lines = append(lines, m.theme.Muted.Render(fmt.Sprintf(" … %d–%d of %d", start+1, end, len(m.clusters)))) + } return lines } @@ -318,11 +323,13 @@ func (m Model) listLines(width int) []string { return []string{m.theme.Danger.Render("✗ Unable to load services"), m.theme.Danger.Render("Error " + m.err.Error()), m.theme.Muted.Render("Press r to retry.")} } if len(m.page.Items) == 0 { - return []string{m.theme.Warning.Render("○ No services found"), m.theme.Muted.Render(" Press n to create, or adjust the filter.")} + return []string{m.theme.Warning.Render("○ No services found"), m.theme.Muted.Render(" Press a to create, or adjust the filter.")} } nameWidth := max(12, min(28, width/3)) lines := []string{m.theme.Muted.Render(" " + pad("NAME", nameWidth) + " " + pad("NAMESPACE", 14) + " " + pad("STATUS", 10) + " GIT")} - for i, item := range m.page.Items { + start, end := visibleWindow(m.cursor, len(m.page.Items), 8) + for i := start; i < end; i++ { + item := m.page.Items[i] cursor := " " if i == m.cursor { cursor = "› " @@ -334,19 +341,44 @@ func (m Model) listLines(width int) []string { row := cursor + pad(item.Name, nameWidth) + " " + pad(item.Namespace, 14) + " " + pad(item.Status, 10) + " " + git lines = append(lines, ansi.Truncate(row, width-2, "…")) } + if start > 0 || end < len(m.page.Items) { + lines = append(lines, m.theme.Muted.Render(fmt.Sprintf(" … %d–%d of %d", start+1, end, len(m.page.Items)))) + } if m.page.HasNext || len(m.prevCursors) > 0 { pager := "page" if len(m.prevCursors) > 0 { - pager += " · [ prev" + pager += " · p prev" } if m.page.HasNext { - pager += " · ] next" + pager += " · n next" } lines = append(lines, "", m.theme.Muted.Render(pager)) } return lines } +func visibleWindow(cursor, count, size int) (start, end int) { + if count <= 0 { + return 0, 0 + } + if size <= 0 { + size = count + } + if count <= size { + return 0, count + } + start = cursor - size/2 + if start < 0 { + start = 0 + } + end = start + size + if end > count { + end = count + start = end - size + } + return start, end +} + func (m Model) detailLines() []string { if m.loading { return []string{m.theme.Warning.Render("◌ Loading service detail…")} From e40dad3a494dbfc316767e38dc28019a3f859599 Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Thu, 30 Jul 2026 14:00:27 +0200 Subject: [PATCH 09/11] add pipelines --- cmd/command/tui/tui.go | 3 + pkg/bridge/pipelines/pipelines.go | 235 ++++++++++++++ pkg/bridge/pipelines/pipelines_test.go | 115 +++++++ pkg/console/console.go | 2 + pkg/console/pipelines.go | 30 ++ pkg/test/mocks/ConsoleClient.go | 60 ++++ tui/app/model.go | 9 + tui/app/model_test.go | 5 + tui/app/view.go | 2 + tui/navigation/navigation.go | 1 + tui/screens/deployments/model.go | 2 +- tui/screens/deployments/model_test.go | 13 +- .../testdata/deployments-120.golden | 2 +- .../testdata/deployments-80.golden | 2 +- tui/screens/pipelines/model.go | 306 ++++++++++++++++++ tui/screens/pipelines/model_test.go | 233 +++++++++++++ .../testdata/pipelines-detail-120.golden | 30 ++ .../testdata/pipelines-detail-80.golden | 24 ++ .../testdata/pipelines-list-120.golden | 30 ++ .../testdata/pipelines-list-80.golden | 24 ++ tui/screens/pipelines/view.go | 207 ++++++++++++ 21 files changed, 1331 insertions(+), 4 deletions(-) create mode 100644 pkg/bridge/pipelines/pipelines.go create mode 100644 pkg/bridge/pipelines/pipelines_test.go create mode 100644 tui/screens/pipelines/model.go create mode 100644 tui/screens/pipelines/model_test.go create mode 100644 tui/screens/pipelines/testdata/pipelines-detail-120.golden create mode 100644 tui/screens/pipelines/testdata/pipelines-detail-80.golden create mode 100644 tui/screens/pipelines/testdata/pipelines-list-120.golden create mode 100644 tui/screens/pipelines/testdata/pipelines-list-80.golden create mode 100644 tui/screens/pipelines/view.go diff --git a/cmd/command/tui/tui.go b/cmd/command/tui/tui.go index 982b54d98..f7b22dc47 100644 --- a/cmd/command/tui/tui.go +++ b/cmd/command/tui/tui.go @@ -9,6 +9,7 @@ import ( "github.com/pluralsh/plural-cli/pkg/bridge" accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" @@ -25,12 +26,14 @@ func Command() cli.Command { services := servicesbridge.NewService(access) clusters := clustersbridge.NewService(access) repositories := repositoriesbridge.NewService(access) + pipelines := pipelinesbridge.NewService(access) return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{ Welcome: welcome, Access: access, Services: services, Clusters: clusters, Repositories: repositories, + Pipelines: pipelines, }) }) } diff --git a/pkg/bridge/pipelines/pipelines.go b/pkg/bridge/pipelines/pipelines.go new file mode 100644 index 000000000..f4f9b868c --- /dev/null +++ b/pkg/bridge/pipelines/pipelines.go @@ -0,0 +1,235 @@ +// Package pipelines exposes read-only Console pipeline list/get use cases to +// presentation layers without importing TUI code. +package pipelines + +import ( + "context" + "errors" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" +) + +const defaultPageSize int64 = 10 + +var ( + errNoConsole = errors.New("connect a Console profile before browsing Console resources") + errMissingID = errors.New("pipeline id is required") + errMissingPipeline = errors.New("pipeline was not found") +) + +// Summary is a credential-free list row for a Console pipeline. +type Summary struct { + ID string + Name string + Project string + StageCount int +} + +// Stage is a credential-free pipeline stage summary. +type Stage struct { + Name string + Services []string +} + +// Edge is a credential-free stage promotion edge. +type Edge struct { + From string + To string +} + +// Detail is the credential-free detail payload for a Console pipeline. +type Detail struct { + Summary + Stages []Stage + Edges []Edge +} + +// Page is one cursor page of pipeline summaries. +type Page struct { + Items []Summary + EndCursor string + HasNext bool + TotalShown int +} + +// Loader is the narrow contract consumed by the Pipelines screen. +type Loader interface { + List(ctx context.Context, after *string, query string) (Page, error) + Get(ctx context.Context, id string) (Detail, error) +} + +// ConsoleResolver supplies the active Console URL and token. +type ConsoleResolver interface { + ActiveConsole(ctx context.Context) (url, token string, err error) +} + +// API is the Console surface required by this package. +type API interface { + ListPipelines() (*gqlclient.GetPipelines, error) + GetPipeline(id string) (*gqlclient.PipelineFragment, error) +} + +// ClientFactory builds a Console API for an authenticated endpoint. +type ClientFactory func(token, url string) (API, error) + +// Service implements Loader against Console GraphQL. +type Service struct { + resolve ConsoleResolver + newClient ClientFactory + pageSize int64 +} + +// NewService wires production Console credentials and client construction. +func NewService(resolve ConsoleResolver) *Service { + return &Service{ + resolve: resolve, + newClient: func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + }, + pageSize: defaultPageSize, + } +} + +func (s *Service) client(ctx context.Context) (API, error) { + if s.resolve == nil { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole} + } + url, token, err := s.resolve.ActiveConsole(ctx) + if err != nil { + return nil, err + } + factory := s.newClient + if factory == nil { + factory = func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + } + } + return factory(token, url) +} + +func (s *Service) List(ctx context.Context, after *string, query string) (Page, error) { + if err := ctx.Err(); err != nil { + return Page{}, err + } + client, err := s.client(ctx) + if err != nil { + return Page{}, err + } + result, err := client.ListPipelines() + if err != nil { + return Page{}, err + } + if result == nil || result.Pipelines == nil { + return Page{}, nil + } + items := make([]Summary, 0, len(result.Pipelines.Edges)) + for _, edge := range result.Pipelines.Edges { + if edge == nil || edge.Node == nil { + continue + } + summary := summaryFromFragment(edge.Node) + if !matchesQuery(summary, query) { + continue + } + items = append(items, summary) + } + return pageItems(items, after, s.pageSize), nil +} + +func (s *Service) Get(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + pipeline, err := client.GetPipeline(id) + if err != nil { + return Detail{}, err + } + if pipeline == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingPipeline} + } + return detailFromFragment(pipeline), nil +} + +func pageItems(items []Summary, after *string, pageSize int64) Page { + if pageSize <= 0 { + pageSize = defaultPageSize + } + start := 0 + if after != nil && *after != "" { + for i, item := range items { + if item.ID == *after { + start = i + 1 + break + } + } + } + if start > len(items) { + start = len(items) + } + end := start + int(pageSize) + if end > len(items) { + end = len(items) + } + page := Page{Items: items[start:end], TotalShown: end - start, HasNext: end < len(items)} + if len(page.Items) > 0 { + page.EndCursor = page.Items[len(page.Items)-1].ID + } + return page +} + +func summaryFromFragment(node *gqlclient.PipelineFragment) Summary { + summary := Summary{ID: node.ID, Name: node.Name, StageCount: len(node.Stages)} + if node.Project != nil { + summary.Project = node.Project.Name + } + return summary +} + +func detailFromFragment(node *gqlclient.PipelineFragment) Detail { + detail := Detail{Summary: summaryFromFragment(node)} + for _, stage := range node.Stages { + if stage == nil { + continue + } + item := Stage{Name: stage.Name} + for _, svc := range stage.Services { + if svc == nil || svc.Service == nil { + continue + } + name := svc.Service.Name + if svc.Service.Namespace != "" { + name = svc.Service.Namespace + "/" + name + } + item.Services = append(item.Services, name) + } + detail.Stages = append(detail.Stages, item) + } + for _, edge := range node.Edges { + if edge == nil { + continue + } + detail.Edges = append(detail.Edges, Edge{From: edge.From.Name, To: edge.To.Name}) + } + return detail +} + +func matchesQuery(summary Summary, query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{summary.Name, summary.Project, summary.ID}, " ")) + return strings.Contains(haystack, query) +} diff --git a/pkg/bridge/pipelines/pipelines_test.go b/pkg/bridge/pipelines/pipelines_test.go new file mode 100644 index 000000000..dca1b1943 --- /dev/null +++ b/pkg/bridge/pipelines/pipelines_test.go @@ -0,0 +1,115 @@ +package pipelines + +import ( + "context" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeResolver struct { + url, token string + err error +} + +func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) { + return f.url, f.token, f.err +} + +type fakeAPI struct { + pipelines *gqlclient.GetPipelines + listErr error + detail *gqlclient.PipelineFragment + getErr error +} + +func (f *fakeAPI) ListPipelines() (*gqlclient.GetPipelines, error) { return f.pipelines, f.listErr } +func (f *fakeAPI) GetPipeline(string) (*gqlclient.PipelineFragment, error) { + return f.detail, f.getErr +} + +func TestListAndGet(t *testing.T) { + api := &fakeAPI{ + pipelines: &gqlclient.GetPipelines{Pipelines: &gqlclient.GetPipelines_Pipelines{ + Edges: []*gqlclient.PipelineEdgeFragment{ + {Node: &gqlclient.PipelineFragment{ + ID: "p1", Name: "deploy-prod", + Project: &gqlclient.TinyProjectFragment{Name: "acme"}, + Stages: []*gqlclient.PipelineStageFragment{{Name: "dev"}, {Name: "prod"}}, + }}, + {Node: &gqlclient.PipelineFragment{ID: "p2", Name: "canary"}}, + }, + }}, + detail: &gqlclient.PipelineFragment{ + ID: "p1", Name: "deploy-prod", + Project: &gqlclient.TinyProjectFragment{Name: "acme"}, + Stages: []*gqlclient.PipelineStageFragment{ + {Name: "dev", Services: []*gqlclient.PipelineStageFragment_Services{ + {Service: &gqlclient.ServiceDeploymentBaseFragment{Name: "api", Namespace: "default"}}, + }}, + {Name: "prod"}, + }, + Edges: []*gqlclient.PipelineStageEdgeFragment{ + {From: gqlclient.PipelineStageFragment{Name: "dev"}, To: gqlclient.PipelineStageFragment{Name: "prod"}}, + }, + }, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 10, + } + + page, err := service.List(t.Context(), nil, "deploy") + if err != nil || len(page.Items) != 1 || page.Items[0].Name != "deploy-prod" || page.Items[0].StageCount != 2 { + t.Fatalf("List() = %#v, %v", page, err) + } + + detail, err := service.Get(t.Context(), "p1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if detail.Project != "acme" || len(detail.Stages) != 2 || len(detail.Edges) != 1 || detail.Edges[0].From != "dev" { + t.Fatalf("detail = %#v", detail) + } + if len(detail.Stages[0].Services) != 1 || detail.Stages[0].Services[0] != "default/api" { + t.Fatalf("stage services = %#v", detail.Stages[0].Services) + } +} + +func TestListPages(t *testing.T) { + edges := make([]*gqlclient.PipelineEdgeFragment, 0, 3) + for _, id := range []string{"p1", "p2", "p3"} { + edges = append(edges, &gqlclient.PipelineEdgeFragment{ + Node: &gqlclient.PipelineFragment{ID: id, Name: id}, + }) + } + api := &fakeAPI{pipelines: &gqlclient.GetPipelines{Pipelines: &gqlclient.GetPipelines_Pipelines{Edges: edges}}} + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 2, + } + first, err := service.List(t.Context(), nil, "") + if err != nil || len(first.Items) != 2 || !first.HasNext || first.EndCursor != "p2" { + t.Fatalf("first = %#v, %v", first, err) + } + after := first.EndCursor + second, err := service.List(t.Context(), &after, "") + if err != nil || len(second.Items) != 1 || second.HasNext || second.Items[0].ID != "p3" { + t.Fatalf("second = %#v, %v", second, err) + } +} + +func TestGetRequiresID(t *testing.T) { + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return &fakeAPI{}, nil }, + } + _, err := service.Get(t.Context(), "") + if !bridge.IsCode(err, bridge.ErrorInvalid) { + t.Fatalf("Get() error = %v", err) + } +} diff --git a/pkg/console/console.go b/pkg/console/console.go index 0cc271406..887f2d4d9 100644 --- a/pkg/console/console.go +++ b/pkg/console/console.go @@ -44,6 +44,8 @@ type ConsoleClient interface { CreateProviderCredentials(name string, attr consoleclient.ProviderCredentialAttributes) (*consoleclient.CreateProviderCredential, error) DeleteProviderCredentials(id string) (*consoleclient.DeleteProviderCredential, error) SavePipeline(name string, attrs consoleclient.PipelineAttributes) (*consoleclient.PipelineFragmentMinimal, error) + ListPipelines() (*consoleclient.GetPipelines, error) + GetPipeline(id string) (*consoleclient.PipelineFragment, error) CreatePipelineContext(id string, attrs consoleclient.PipelineContextAttributes) (*consoleclient.PipelineContextFragment, error) GetPipelineContext(id string) (*consoleclient.PipelineContextFragment, error) CreateCluster(attributes consoleclient.ClusterAttributes) (*consoleclient.CreateCluster, error) diff --git a/pkg/console/pipelines.go b/pkg/console/pipelines.go index 2061d61c2..3e2029e5e 100644 --- a/pkg/console/pipelines.go +++ b/pkg/console/pipelines.go @@ -1,6 +1,7 @@ package console import ( + "fmt" "strings" gqlclient "github.com/pluralsh/console/go/client" @@ -53,6 +54,35 @@ func (c *consoleClient) SavePipeline(name string, attrs gqlclient.PipelineAttrib return result.SavePipeline, nil } +func (c *consoleClient) ListPipelines() (*gqlclient.GetPipelines, error) { + result, err := c.client.GetPipelines(c.ctx, nil) + if err != nil { + return nil, api.GetErrorResponse(err, "GetPipelines") + } + return result, nil +} + +// GetPipeline returns a full PipelineFragment. The generated GetPipeline query only +// returns id/name, so this resolves detail from the list query instead. +func (c *consoleClient) GetPipeline(id string) (*gqlclient.PipelineFragment, error) { + result, err := c.ListPipelines() + if err != nil { + return nil, err + } + if result == nil || result.Pipelines == nil { + return nil, fmt.Errorf("pipeline %s was not found", id) + } + for _, edge := range result.Pipelines.Edges { + if edge == nil || edge.Node == nil { + continue + } + if edge.Node.ID == id { + return edge.Node, nil + } + } + return nil, fmt.Errorf("pipeline %s was not found", id) +} + func (c *consoleClient) CreatePipelineContext(id string, attrs gqlclient.PipelineContextAttributes) (*gqlclient.PipelineContextFragment, error) { result, err := c.client.CreatePipelineContext(c.ctx, id, attrs) if err != nil { diff --git a/pkg/test/mocks/ConsoleClient.go b/pkg/test/mocks/ConsoleClient.go index 520d16e89..c8c6f1254 100644 --- a/pkg/test/mocks/ConsoleClient.go +++ b/pkg/test/mocks/ConsoleClient.go @@ -721,6 +721,36 @@ func (_m *ConsoleClient) GetGlobalSettingsMinimal() (*client.DeploymentSettingsF return r0, r1 } +// GetPipeline provides a mock function with given fields: id +func (_m *ConsoleClient) GetPipeline(id string) (*client.PipelineFragment, error) { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for GetPipeline") + } + + var r0 *client.PipelineFragment + var r1 error + if rf, ok := ret.Get(0).(func(string) (*client.PipelineFragment, error)); ok { + return rf(id) + } + if rf, ok := ret.Get(0).(func(string) *client.PipelineFragment); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.PipelineFragment) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetPipelineContext provides a mock function with given fields: id func (_m *ConsoleClient) GetPipelineContext(id string) (*client.PipelineContextFragment, error) { ret := _m.Called(id) @@ -1111,6 +1141,36 @@ func (_m *ConsoleClient) ListProviders() (*client.ListProviders, error) { return r0, r1 } +// ListPipelines provides a mock function with no fields +func (_m *ConsoleClient) ListPipelines() (*client.GetPipelines, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for ListPipelines") + } + + var r0 *client.GetPipelines + var r1 error + if rf, ok := ret.Get(0).(func() (*client.GetPipelines, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() *client.GetPipelines); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.GetPipelines) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // ListRepositories provides a mock function with no fields func (_m *ConsoleClient) ListRepositories() (*client.ListGitRepositories, error) { ret := _m.Called() diff --git a/tui/app/model.go b/tui/app/model.go index d591564ff..778d0cb05 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -9,6 +9,7 @@ import ( accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" @@ -17,6 +18,7 @@ import ( clustersscreen "github.com/pluralsh/plural-cli/tui/screens/clusters" deploymentsscreen "github.com/pluralsh/plural-cli/tui/screens/deployments" diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics" + pipelinesscreen "github.com/pluralsh/plural-cli/tui/screens/pipelines" repositoriesscreen "github.com/pluralsh/plural-cli/tui/screens/repositories" servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" welcomescreen "github.com/pluralsh/plural-cli/tui/screens/welcome" @@ -30,6 +32,7 @@ type Dependencies struct { Services servicesbridge.Loader Clusters clustersbridge.Loader Repositories repositoriesbridge.Loader + Pipelines pipelinesbridge.Loader } // Model is the root TUI model. It owns global input and delegates screen state @@ -48,6 +51,7 @@ type Model struct { services servicesscreen.Model clusters clustersscreen.Model repositories repositoriesscreen.Model + pipelines pipelinesscreen.Model route navigation.Route } @@ -62,6 +66,7 @@ func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { services: servicesscreen.New(ctx, dependencies.Services, t), clusters: clustersscreen.New(ctx, dependencies.Clusters, t), repositories: repositoriesscreen.New(ctx, dependencies.Repositories, t), + pipelines: pipelinesscreen.New(ctx, dependencies.Pipelines, t), route: navigation.Welcome, quit: key.NewBinding( key.WithKeys("ctrl+c"), @@ -90,6 +95,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.clusters.Init() case navigation.Repositories: return m, m.repositories.Init() + case navigation.Pipelines: + return m, m.pipelines.Init() default: return m, m.welcome.Init() } @@ -121,6 +128,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.clusters, cmd = m.clusters.Update(msg) case navigation.Repositories: m.repositories, cmd = m.repositories.Update(msg) + case navigation.Pipelines: + m.pipelines, cmd = m.pipelines.Update(msg) default: m.welcome, cmd = m.welcome.Update(msg) } diff --git a/tui/app/model_test.go b/tui/app/model_test.go index 1f5dd892d..b01d3f9c6 100644 --- a/tui/app/model_test.go +++ b/tui/app/model_test.go @@ -65,6 +65,11 @@ func TestModelRoutesScreensWithoutRebuildingShell(t *testing.T) { if routed.route != navigation.Repositories || !strings.Contains(routed.View().Content, "Repositories") { t.Fatalf("repositories route/view = %q\n%s", routed.route, routed.View().Content) } + updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Pipelines}) + routed = updated.(Model) + if routed.route != navigation.Pipelines || !strings.Contains(routed.View().Content, "Pipelines") { + t.Fatalf("pipelines route/view = %q\n%s", routed.route, routed.View().Content) + } updated, _ = routed.Update(navigation.NavigateMsg{Route: navigation.Welcome}) if got := updated.(Model).route; got != navigation.Welcome { t.Fatalf("route = %q", got) diff --git a/tui/app/view.go b/tui/app/view.go index d9d1c4c0f..c2009fb38 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -23,6 +23,8 @@ func (m Model) View() tea.View { content = m.clusters.View(m.width, m.height) case navigation.Repositories: content = m.repositories.View(m.width, m.height) + case navigation.Pipelines: + content = m.pipelines.View(m.width, m.height) } view := tea.NewView(content) view.AltScreen = true diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index 8b4ddda5b..0f7dd7382 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -16,6 +16,7 @@ const ( Services Route = "services" Clusters Route = "clusters" Repositories Route = "repositories" + Pipelines Route = "pipelines" ) // NavigateMsg requests a top-level route change. diff --git a/tui/screens/deployments/model.go b/tui/screens/deployments/model.go index 9442db561..ca1dc1eed 100644 --- a/tui/screens/deployments/model.go +++ b/tui/screens/deployments/model.go @@ -35,7 +35,7 @@ func resources() []resource { {id: resourceServices, number: "1", shortcut: "s", title: "Services", blurb: "browse · kick · create · …", route: navigation.Services}, {id: resourceClusters, number: "2", shortcut: "c", title: "Clusters", blurb: "list · describe", route: navigation.Clusters}, {id: resourceRepositories, number: "3", shortcut: "r", title: "Repositories", blurb: "list · describe", route: navigation.Repositories}, - {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "trigger", soon: true}, + {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "list · describe", route: navigation.Pipelines}, {id: resourceNotifications, number: "5", shortcut: "n", title: "Notifications", blurb: "sinks", soon: true}, {id: resourceProviders, number: "6", shortcut: "v", title: "Providers", blurb: "list", soon: true}, } diff --git a/tui/screens/deployments/model_test.go b/tui/screens/deployments/model_test.go index 39cbd4529..dba070dd0 100644 --- a/tui/screens/deployments/model_test.go +++ b/tui/screens/deployments/model_test.go @@ -87,9 +87,20 @@ func TestRepositoriesNavigates(t *testing.T) { } } +func TestPipelinesNavigates(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + _, cmd := model.Update(tea.KeyPressMsg{Code: 'p', Text: "p"}) + if cmd == nil { + t.Fatal("expected navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Pipelines}) { + t.Fatalf("msg = %#v", msg) + } +} + func TestSoonResourceDoesNotNavigate(t *testing.T) { model := New(t.Context(), theme.New(colorprofile.ASCII), "") - model.cursor = 3 // pipelines [soon] + model.cursor = 4 // notifications [soon] _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if cmd != nil { t.Fatalf("unexpected cmd %#v", cmd()) diff --git a/tui/screens/deployments/testdata/deployments-120.golden b/tui/screens/deployments/testdata/deployments-120.golden index a5b6a7f64..a30a1c5a8 100644 --- a/tui/screens/deployments/testdata/deployments-120.golden +++ b/tui/screens/deployments/testdata/deployments-120.golden @@ -5,7 +5,7 @@ │ › 1 s Services browse · kick · create · … │ │ 2 c Clusters list · describe │ │ 3 r Repositories list · describe │ - │ 4 p Pipelines trigger [soon] │ + │ 4 p Pipelines list · describe │ │ 5 n Notifications sinks [soon] │ │ 6 v Providers list [soon] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/tui/screens/deployments/testdata/deployments-80.golden b/tui/screens/deployments/testdata/deployments-80.golden index 4230cd22d..449f93584 100644 --- a/tui/screens/deployments/testdata/deployments-80.golden +++ b/tui/screens/deployments/testdata/deployments-80.golden @@ -5,7 +5,7 @@ │ › 1 s Services browse · kick · create · … │ │ 2 c Clusters list · describe │ │ 3 r Repositories list · describe │ - │ 4 p Pipelines trigger [soon] │ + │ 4 p Pipelines list · describe │ │ 5 n Notifications sinks [soon] │ │ 6 v Providers list [soon] │ ╰──────────────────────────────────────────────────────────────────────────╯ diff --git a/tui/screens/pipelines/model.go b/tui/screens/pipelines/model.go new file mode 100644 index 000000000..77c664b20 --- /dev/null +++ b/tui/screens/pipelines/model.go @@ -0,0 +1,306 @@ +// Package pipelines implements the read-only Console pipelines browser. +package pipelines + +import ( + "context" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeList mode = iota + modeDetail + modeFilter +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionRefresh + keyActionFilter + keyActionConnectConsole + keyActionNextPage + keyActionPrevPage +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, + keyActionRefresh: {"r"}, + keyActionFilter: {"/"}, + keyActionConnectConsole: {"c"}, + keyActionNextPage: {"n", "right", "]"}, + keyActionPrevPage: {"p", "left", "["}, +} + +func actionForKeystroke(keystroke string) keyAction { + for action, keystrokes := range keyActionKeystrokes { + for _, candidate := range keystrokes { + if keystroke == candidate { + return action + } + } + } + return keyActionNone +} + +type initMsg struct{} +type listedMsg struct { + page pipelinesbridge.Page + err error + request uint64 +} +type detailMsg struct { + detail pipelinesbridge.Detail + err error + request uint64 +} + +// Model owns Pipelines-screen interaction state. +type Model struct { + ctx context.Context + loader pipelinesbridge.Loader + theme theme.Theme + mode mode + loading bool + err error + needsAuth bool + request uint64 + + page pipelinesbridge.Page + cursor int + filter string + filterInput textinput.Model + after *string + prevCursors []string + + detail pipelinesbridge.Detail + detailID string + listCursor int + listAfter *string + listFilter string + listPrev []string +} + +func New(ctx context.Context, loader pipelinesbridge.Loader, t theme.Theme) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "filter pipelines" + input.CharLimit = 128 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Blurred = styles.Focused + input.SetStyles(styles) + return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, mode: modeList} +} + +func (m Model) Init() tea.Cmd { + return func() tea.Msg { return initMsg{} } +} + +func (m *Model) beginList(after *string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + query := m.filter + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + page, err := loader.List(ctx, after, query) + return listedMsg{page: page, err: err, request: request} + } +} + +func (m *Model) beginDetail(id string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + detail, err := loader.Get(ctx, id) + return detailMsg{detail: detail, err: err, request: request} + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case initMsg: + m.mode = modeList + m.page = pipelinesbridge.Page{} + m.cursor = 0 + m.after = nil + m.prevCursors = nil + m.err = nil + m.needsAuth = false + if m.loader == nil { + m.loading = false + return m, nil + } + return m, m.beginList(nil) + case listedMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.page = msg.page + m.cursor = clampCursor(m.cursor, len(m.page.Items)) + m.mode = modeList + } + return m, nil + case detailMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.detail = msg.detail + m.mode = modeDetail + } + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if m.mode == modeFilter { + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + if m.mode == modeFilter { + switch action { + case keyActionBack: + m.mode = modeList + m.filterInput.Blur() + return m, nil + case keyActionConfirm: + m.filter = strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + m.mode = modeList + m.cursor = 0 + m.after = nil + m.prevCursors = nil + return m, m.beginList(nil) + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd + } + if action == keyActionBack { + if m.mode == modeDetail { + m.mode = modeList + m.err = nil + m.cursor = m.listCursor + m.after = m.listAfter + m.filter = m.listFilter + m.prevCursors = append([]string(nil), m.listPrev...) + return m, nil + } + return m, navigation.Navigate(navigation.Deployments) + } + if m.loading { + return m, nil + } + if m.needsAuth && action == keyActionConnectConsole { + return m, navigation.Navigate(navigation.Access) + } + if m.mode == modeDetail { + if action == keyActionRefresh && m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + return m, nil + } + return m.updateList(action) +} + +func (m Model) updateList(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionMoveUp: + m.cursor = clampCursor(m.cursor-1, len(m.page.Items)) + case keyActionMoveDown: + m.cursor = clampCursor(m.cursor+1, len(m.page.Items)) + case keyActionConfirm: + if len(m.page.Items) == 0 { + return m, nil + } + m.listCursor = m.cursor + m.listAfter = m.after + m.listFilter = m.filter + m.listPrev = append([]string(nil), m.prevCursors...) + m.detailID = m.page.Items[m.cursor].ID + return m, m.beginDetail(m.detailID) + case keyActionRefresh: + return m, m.beginList(m.after) + case keyActionFilter: + m.mode = modeFilter + m.filterInput.SetValue(m.filter) + m.filterInput.Focus() + case keyActionNextPage: + if !m.page.HasNext || m.page.EndCursor == "" { + return m, nil + } + if m.after != nil { + m.prevCursors = append(m.prevCursors, *m.after) + } else { + m.prevCursors = append(m.prevCursors, "") + } + cursor := m.page.EndCursor + m.after = &cursor + m.cursor = 0 + return m, m.beginList(m.after) + case keyActionPrevPage: + if len(m.prevCursors) == 0 { + return m, nil + } + previous := m.prevCursors[len(m.prevCursors)-1] + m.prevCursors = m.prevCursors[:len(m.prevCursors)-1] + if previous == "" { + m.after = nil + } else { + m.after = &previous + } + m.cursor = 0 + return m, m.beginList(m.after) + } + return m, nil +} + +func clampCursor(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} diff --git a/tui/screens/pipelines/model_test.go b/tui/screens/pipelines/model_test.go new file mode 100644 index 000000000..933aa77de --- /dev/null +++ b/tui/screens/pipelines/model_test.go @@ -0,0 +1,233 @@ +package pipelines + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/pkg/bridge" + pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeLoader struct { + page pipelinesbridge.Page + detail pipelinesbridge.Detail + err error +} + +func (f *fakeLoader) List(context.Context, *string, string) (pipelinesbridge.Page, error) { + return f.page, f.err +} +func (f *fakeLoader) Get(context.Context, string) (pipelinesbridge.Detail, error) { + return f.detail, f.err +} + +func loadList(t *testing.T, model Model) Model { + t.Helper() + cmd := model.Init() + model, cmd = model.Update(cmd()) + if cmd == nil { + t.Fatal("expected list command") + } + model, _ = model.Update(cmd()) + return model +} + +func TestOpenPipelineDetailAndBack(t *testing.T) { + loader := &fakeLoader{ + page: pipelinesbridge.Page{Items: []pipelinesbridge.Summary{ + {ID: "p1", Name: "deploy-prod", Project: "acme", StageCount: 2}, + {ID: "p2", Name: "canary", Project: "acme", StageCount: 1}, + }}, + detail: pipelinesbridge.Detail{ + Summary: pipelinesbridge.Summary{ID: "p1", Name: "deploy-prod", Project: "acme", StageCount: 2}, + Stages: []pipelinesbridge.Stage{ + {Name: "dev", Services: []string{"default/api"}}, + {Name: "prod"}, + }, + Edges: []pipelinesbridge.Edge{{From: "dev", To: "prod"}}, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if model.mode != modeList || len(model.page.Items) != 2 { + t.Fatalf("list state = mode=%d count=%d", model.mode, len(model.page.Items)) + } + if !strings.Contains(model.View(80, 24), "deploy-prod") { + t.Fatalf("list missing name:\n%s", model.View(80, 24)) + } + + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail || model.detail.Name != "deploy-prod" { + t.Fatalf("detail = %#v mode=%d", model.detail, model.mode) + } + if !strings.Contains(model.View(80, 24), "dev → prod") { + t.Fatalf("detail missing edge:\n%s", model.View(80, 24)) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeList { + t.Fatalf("mode after detail esc = %d", model.mode) + } + _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Deployments}) { + t.Fatalf("expected deployments navigation") + } +} + +func TestNextPrevPage(t *testing.T) { + loader := &fakeLoader{ + page: pipelinesbridge.Page{ + Items: []pipelinesbridge.Summary{{ID: "p1", Name: "a", StageCount: 1}}, + EndCursor: "p1", + HasNext: true, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !strings.Contains(model.View(80, 24), "n next") { + t.Fatalf("missing next pager:\n%s", model.View(80, 24)) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: 'n'}) + if cmd == nil { + t.Fatal("expected next-page list command") + } + loader.page = pipelinesbridge.Page{Items: []pipelinesbridge.Summary{{ID: "p2", Name: "b", StageCount: 1}}} + model, _ = model.Update(cmd()) + if model.after == nil || *model.after != "p1" || len(model.prevCursors) != 1 { + t.Fatalf("after page turn after=%v prev=%v", model.after, model.prevCursors) + } + model, cmd = model.Update(tea.KeyPressMsg{Code: 'p'}) + if cmd == nil { + t.Fatal("expected prev-page list command") + } + model, _ = model.Update(cmd()) + if model.after != nil || len(model.prevCursors) != 0 { + t.Fatalf("after prev after=%v prev=%v", model.after, model.prevCursors) + } +} + +func TestNoConsoleNavigatesToAccess(t *testing.T) { + loader := &fakeLoader{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect")}} + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !model.needsAuth { + t.Fatal("expected needsAuth") + } + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c'}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Access}) { + t.Fatalf("expected access navigation") + } +} + +func TestPipelinesGoldens(t *testing.T) { + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = pipelinesbridge.Page{Items: []pipelinesbridge.Summary{ + {ID: "p1", Name: "deploy-prod", Project: "acme", StageCount: 2}, + {ID: "p2", Name: "canary", Project: "acme", StageCount: 3}, + {ID: "p3", Name: "hotfix", StageCount: 1}, + }, HasNext: true, EndCursor: "p3"} + + detail := list + detail.mode = modeDetail + detail.detail = pipelinesbridge.Detail{ + Summary: pipelinesbridge.Summary{ID: "p1", Name: "deploy-prod", Project: "acme", StageCount: 2}, + Stages: []pipelinesbridge.Stage{ + {Name: "dev", Services: []string{"default/api"}}, + {Name: "prod", Services: []string{"default/api"}}, + }, + Edges: []pipelinesbridge.Edge{{From: "dev", To: "prod"}}, + } + + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + t.Run(tc.name, func(t *testing.T) { + got := normalizeView(tc.model.View(tc.width, tc.height)) + golden := filepath.Join("testdata", "pipelines-"+tc.name+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + lines := strings.Split(got, "\n") + if len(lines) != tc.height { + t.Fatalf("height = %d, want %d", len(lines), tc.height) + } + for _, line := range lines { + if w := lipgloss.Width(line); w > tc.width { + t.Fatalf("line width %d > %d: %q", w, tc.width, line) + } + } + }) + } +} + +func TestWritePipelinesGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = pipelinesbridge.Page{Items: []pipelinesbridge.Summary{ + {ID: "p1", Name: "deploy-prod", Project: "acme", StageCount: 2}, + {ID: "p2", Name: "canary", Project: "acme", StageCount: 3}, + {ID: "p3", Name: "hotfix", StageCount: 1}, + }, HasNext: true, EndCursor: "p3"} + detail := list + detail.mode = modeDetail + detail.detail = pipelinesbridge.Detail{ + Summary: pipelinesbridge.Summary{ID: "p1", Name: "deploy-prod", Project: "acme", StageCount: 2}, + Stages: []pipelinesbridge.Stage{ + {Name: "dev", Services: []string{"default/api"}}, + {Name: "prod", Services: []string{"default/api"}}, + }, + Edges: []pipelinesbridge.Edge{{From: "dev", To: "prod"}}, + } + _ = os.MkdirAll("testdata", 0o755) + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + got := normalizeView(tc.model.View(tc.width, tc.height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "pipelines-"+tc.name+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/pipelines/testdata/pipelines-detail-120.golden b/tui/screens/pipelines/testdata/pipelines-detail-120.golden new file mode 100644 index 000000000..a1d67c269 --- /dev/null +++ b/tui/screens/pipelines/testdata/pipelines-detail-120.golden @@ -0,0 +1,30 @@ + Plural Pipelines · deploy-prod 2 stages + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Name deploy-prod │ + │ Project acme │ + │ Stages 2 │ + │ ID p1 │ + │ │ + │ Stages │ + │ dev default/api │ + │ prod default/api │ + │ │ + │ Edges │ + │ dev → prod │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/pipelines/testdata/pipelines-detail-80.golden b/tui/screens/pipelines/testdata/pipelines-detail-80.golden new file mode 100644 index 000000000..c7d93873c --- /dev/null +++ b/tui/screens/pipelines/testdata/pipelines-detail-80.golden @@ -0,0 +1,24 @@ + Plural Pipelines · deploy-prod 2 stages + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────╮ + │ Name deploy-prod │ + │ Project acme │ + │ Stages 2 │ + │ ID p1 │ + │ │ + │ Stages │ + │ dev default/api │ + │ prod default/api │ + │ │ + │ Edges │ + │ dev → prod │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/pipelines/testdata/pipelines-list-120.golden b/tui/screens/pipelines/testdata/pipelines-list-120.golden new file mode 100644 index 000000000..4cb6e8fad --- /dev/null +++ b/tui/screens/pipelines/testdata/pipelines-list-120.golden @@ -0,0 +1,30 @@ + Plural Pipelines 3 pipelines + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Pipelines ────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ NAME PROJECT STAGES │ + │ › deploy-prod acme 2 │ + │ canary acme 3 │ + │ hotfix — 1 │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · enter open · / filter · n/p page · r refresh · esc back diff --git a/tui/screens/pipelines/testdata/pipelines-list-80.golden b/tui/screens/pipelines/testdata/pipelines-list-80.golden new file mode 100644 index 000000000..25d44f9b9 --- /dev/null +++ b/tui/screens/pipelines/testdata/pipelines-list-80.golden @@ -0,0 +1,24 @@ + Plural Pipelines 3 pipelines + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Pipelines ────────────────────────────────────────────────────────────╮ + │ NAME PROJECT STAGES │ + │ › deploy-prod acme 2 │ + │ canary acme 3 │ + │ hotfix — 1 │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · / · n/p page · esc back diff --git a/tui/screens/pipelines/view.go b/tui/screens/pipelines/view.go new file mode 100644 index 000000000..31e40e09b --- /dev/null +++ b/tui/screens/pipelines/view.go @@ -0,0 +1,207 @@ +package pipelines + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + title := "Pipelines" + if m.mode == modeDetail && m.detail.Name != "" { + title = "Pipelines · " + m.detail.Name + } + body, help := m.bodyAndHelp(contentWidth) + return page.Render(m.theme, width, height, title, m.headerStatus(), body, help) +} + +func (m Model) headerStatus() string { + if m.loading { + return m.theme.Warning.Render("◌ loading") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + if m.err != nil { + return m.theme.Danger.Render("✗ load failed") + } + switch m.mode { + case modeDetail: + return m.theme.Success.Render(fmt.Sprintf("%d stages", m.detail.StageCount)) + case modeList: + if m.filter != "" { + return m.theme.Muted.Render(fmt.Sprintf("%d matching", len(m.page.Items))) + } + return m.theme.Success.Render(fmt.Sprintf("%d pipelines", len(m.page.Items))) + default: + return m.theme.Muted.Render("pipelines") + } +} + +func (m Model) bodyAndHelp(width int) (string, string) { + if m.mode == modeFilter { + lines := []string{ + m.theme.Muted.Render("Filter by name, project, or id."), + "", + m.filterInput.View(), + } + return page.Panel(m.theme, "Filter pipelines", lines, width, 6, true), "enter apply · esc cancel" + } + if m.needsAuth { + lines := []string{ + m.theme.Warning.Render("○ Console is not connected"), + m.theme.Muted.Render(" Connect a Console profile to browse pipelines."), + "", + m.theme.Body.Render("Press c to open Access."), + } + return page.Panel(m.theme, "Console required", lines, width, 8, true), "c connect · esc back · ctrl+c quit" + } + if m.mode == modeDetail { + help := "r refresh · esc list · ctrl+c quit" + return page.Panel(m.theme, "Summary", m.detailLines(), width, 16, true), help + } + help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc back" + if width < 100 { + help = "↑/↓ · enter · / · n/p page · esc back" + } + return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help +} + +func (m Model) listTitle() string { + if m.filter != "" { + return "Pipelines · filter “" + m.filter + "”" + } + return "Pipelines" +} + +func (m Model) listLines(width int) []string { + if m.loading && len(m.page.Items) == 0 { + return []string{m.theme.Warning.Render("◌ Loading pipelines…")} + } + if m.err != nil { + return []string{ + m.theme.Danger.Render("✗ Unable to load pipelines"), + m.theme.Danger.Render("Error " + m.err.Error()), + m.theme.Muted.Render("Press r to retry."), + } + } + if len(m.page.Items) == 0 { + return []string{ + m.theme.Warning.Render("○ No pipelines found"), + m.theme.Muted.Render(" Adjust the filter or connect another Console."), + } + } + nameWidth := max(16, min(28, width/3)) + projectWidth := max(10, min(20, width/4)) + lines := []string{m.theme.Muted.Render(" " + pad("NAME", nameWidth) + " " + pad("PROJECT", projectWidth) + " STAGES")} + start, end := visibleWindow(m.cursor, len(m.page.Items), 8) + for i := start; i < end; i++ { + item := m.page.Items[i] + cursor := " " + if i == m.cursor { + cursor = "› " + } + project := loCoalesce(item.Project, "—") + row := cursor + pad(item.Name, nameWidth) + " " + pad(project, projectWidth) + " " + fmt.Sprintf("%d", item.StageCount) + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + if start > 0 || end < len(m.page.Items) { + lines = append(lines, m.theme.Muted.Render(fmt.Sprintf(" … %d–%d of %d", start+1, end, len(m.page.Items)))) + } + if m.page.HasNext || len(m.prevCursors) > 0 { + pager := "page" + if len(m.prevCursors) > 0 { + pager += " · p prev" + } + if m.page.HasNext { + pager += " · n next" + } + lines = append(lines, "", m.theme.Muted.Render(pager)) + } + return lines +} + +func visibleWindow(cursor, count, size int) (start, end int) { + if count <= 0 { + return 0, 0 + } + if size <= 0 { + size = count + } + if count <= size { + return 0, count + } + start = cursor - size/2 + if start < 0 { + start = 0 + } + end = start + size + if end > count { + end = count + start = end - size + } + return start, end +} + +func (m Model) detailLines() []string { + if m.loading { + return []string{m.theme.Warning.Render("◌ Loading pipeline detail…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load pipeline"), m.theme.Danger.Render(m.err.Error())} + } + lines := []string{ + m.labelValue("Name", m.detail.Name), + m.labelValue("Project", loCoalesce(m.detail.Project, "—")), + m.labelValue("Stages", fmt.Sprintf("%d", m.detail.StageCount)), + m.labelValue("ID", m.detail.ID), + } + if len(m.detail.Stages) > 0 { + lines = append(lines, "", m.theme.Muted.Render("Stages")) + for _, stage := range m.detail.Stages { + services := strings.Join(stage.Services, ", ") + if services == "" { + services = "—" + } + lines = append(lines, " "+stage.Name+" "+m.theme.Muted.Render(services)) + } + } + if len(m.detail.Edges) > 0 { + lines = append(lines, "", m.theme.Muted.Render("Edges")) + for _, edge := range m.detail.Edges { + lines = append(lines, " "+edge.From+" → "+edge.To) + } + } + return lines +} + +func (m Model) labelValue(label, value string) string { + label = label + strings.Repeat(" ", max(1, 12-len(label))) + return label + " " + value +} + +func pad(value string, width int) string { + value = ansi.Truncate(value, width, "…") + if lipgloss.Width(value) >= width { + return value + } + return value + strings.Repeat(" ", width-lipgloss.Width(value)) +} + +func loCoalesce(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} From afa3d65ef11dacff46165b38d60b3e48a90b2877 Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Thu, 30 Jul 2026 14:08:30 +0200 Subject: [PATCH 10/11] add notifications --- cmd/command/tui/tui.go | 15 +- pkg/bridge/notifications/notifications.go | 235 ++++++++++++++ .../notifications/notifications_test.go | 123 +++++++ pkg/console/console.go | 1 + pkg/console/notification.go | 21 +- pkg/test/mocks/ConsoleClient.go | 30 ++ tui/app/model.go | 59 ++-- tui/app/view.go | 2 + tui/navigation/navigation.go | 17 +- tui/screens/deployments/model.go | 2 +- tui/screens/deployments/model_test.go | 13 +- .../testdata/deployments-120.golden | 2 +- .../testdata/deployments-80.golden | 2 +- tui/screens/notifications/model.go | 306 ++++++++++++++++++ tui/screens/notifications/model_test.go | 230 +++++++++++++ .../testdata/notifications-detail-120.golden | 30 ++ .../testdata/notifications-detail-80.golden | 24 ++ .../testdata/notifications-list-120.golden | 30 ++ .../testdata/notifications-list-80.golden | 24 ++ tui/screens/notifications/view.go | 198 ++++++++++++ 20 files changed, 1319 insertions(+), 45 deletions(-) create mode 100644 pkg/bridge/notifications/notifications.go create mode 100644 pkg/bridge/notifications/notifications_test.go create mode 100644 tui/screens/notifications/model.go create mode 100644 tui/screens/notifications/model_test.go create mode 100644 tui/screens/notifications/testdata/notifications-detail-120.golden create mode 100644 tui/screens/notifications/testdata/notifications-detail-80.golden create mode 100644 tui/screens/notifications/testdata/notifications-list-120.golden create mode 100644 tui/screens/notifications/testdata/notifications-list-80.golden create mode 100644 tui/screens/notifications/view.go diff --git a/cmd/command/tui/tui.go b/cmd/command/tui/tui.go index f7b22dc47..0bc39d6d7 100644 --- a/cmd/command/tui/tui.go +++ b/cmd/command/tui/tui.go @@ -9,6 +9,7 @@ import ( "github.com/pluralsh/plural-cli/pkg/bridge" accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + notificationsbridge "github.com/pluralsh/plural-cli/pkg/bridge/notifications" pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" @@ -27,13 +28,15 @@ func Command() cli.Command { clusters := clustersbridge.NewService(access) repositories := repositoriesbridge.NewService(access) pipelines := pipelinesbridge.NewService(access) + notifications := notificationsbridge.NewService(access) return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{ - Welcome: welcome, - Access: access, - Services: services, - Clusters: clusters, - Repositories: repositories, - Pipelines: pipelines, + Welcome: welcome, + Access: access, + Services: services, + Clusters: clusters, + Repositories: repositories, + Pipelines: pipelines, + Notifications: notifications, }) }) } diff --git a/pkg/bridge/notifications/notifications.go b/pkg/bridge/notifications/notifications.go new file mode 100644 index 000000000..444d3e4e5 --- /dev/null +++ b/pkg/bridge/notifications/notifications.go @@ -0,0 +1,235 @@ +// Package notifications exposes read-only Console notification sink list/get +// use cases to presentation layers without importing TUI code. +package notifications + +import ( + "context" + "errors" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" +) + +const ( + defaultPageSize int64 = 10 + fetchPageSize int64 = 100 +) + +var ( + errNoConsole = errors.New("connect a Console profile before browsing Console resources") + errMissingID = errors.New("notification sink id is required") + errMissingSink = errors.New("notification sink was not found") +) + +// Summary is a credential-free list row for a notification sink. +type Summary struct { + ID string + Name string + Type string + URL string +} + +// Binding is a credential-free notification binding (user or group). +type Binding struct { + Kind string + Name string +} + +// Detail is the credential-free detail payload for a notification sink. +type Detail struct { + Summary + Bindings []Binding +} + +// Page is one cursor page of sink summaries. +type Page struct { + Items []Summary + EndCursor string + HasNext bool + TotalShown int +} + +// Loader is the narrow contract consumed by the Notifications screen. +type Loader interface { + List(ctx context.Context, after *string, query string) (Page, error) + Get(ctx context.Context, id string) (Detail, error) +} + +// ConsoleResolver supplies the active Console URL and token. +type ConsoleResolver interface { + ActiveConsole(ctx context.Context) (url, token string, err error) +} + +// API is the Console surface required by this package. +type API interface { + ListNotificationSinks(after *string, first *int64) (*gqlclient.ListNotificationSinks_NotificationSinks, error) + GetNotificationSink(id string) (*gqlclient.NotificationSinkFragment, error) +} + +// ClientFactory builds a Console API for an authenticated endpoint. +type ClientFactory func(token, url string) (API, error) + +// Service implements Loader against Console GraphQL. +type Service struct { + resolve ConsoleResolver + newClient ClientFactory + pageSize int64 +} + +// NewService wires production Console credentials and client construction. +func NewService(resolve ConsoleResolver) *Service { + return &Service{ + resolve: resolve, + newClient: func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + }, + pageSize: defaultPageSize, + } +} + +func (s *Service) client(ctx context.Context) (API, error) { + if s.resolve == nil { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole} + } + url, token, err := s.resolve.ActiveConsole(ctx) + if err != nil { + return nil, err + } + factory := s.newClient + if factory == nil { + factory = func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + } + } + return factory(token, url) +} + +func (s *Service) List(ctx context.Context, after *string, query string) (Page, error) { + if err := ctx.Err(); err != nil { + return Page{}, err + } + client, err := s.client(ctx) + if err != nil { + return Page{}, err + } + first := fetchPageSize + result, err := client.ListNotificationSinks(nil, &first) + if err != nil { + return Page{}, err + } + if result == nil { + return Page{}, nil + } + items := make([]Summary, 0, len(result.Edges)) + for _, edge := range result.Edges { + if edge == nil || edge.Node == nil { + continue + } + summary := summaryFromFragment(edge.Node) + if !matchesQuery(summary, query) { + continue + } + items = append(items, summary) + } + return pageItems(items, after, s.pageSize), nil +} + +func (s *Service) Get(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + sink, err := client.GetNotificationSink(id) + if err != nil { + return Detail{}, err + } + if sink == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingSink} + } + return detailFromFragment(sink), nil +} + +func pageItems(items []Summary, after *string, pageSize int64) Page { + if pageSize <= 0 { + pageSize = defaultPageSize + } + start := 0 + if after != nil && *after != "" { + for i, item := range items { + if item.ID == *after { + start = i + 1 + break + } + } + } + if start > len(items) { + start = len(items) + } + end := start + int(pageSize) + if end > len(items) { + end = len(items) + } + page := Page{Items: items[start:end], TotalShown: end - start, HasNext: end < len(items)} + if len(page.Items) > 0 { + page.EndCursor = page.Items[len(page.Items)-1].ID + } + return page +} + +func summaryFromFragment(node *gqlclient.NotificationSinkFragment) Summary { + return Summary{ + ID: node.ID, + Name: node.Name, + Type: string(node.Type), + URL: sinkURL(node), + } +} + +func detailFromFragment(node *gqlclient.NotificationSinkFragment) Detail { + detail := Detail{Summary: summaryFromFragment(node)} + for _, binding := range node.NotificationBindings { + if binding == nil { + continue + } + switch { + case binding.User != nil: + name := binding.User.Email + if name == "" { + name = binding.User.Name + } + detail.Bindings = append(detail.Bindings, Binding{Kind: "user", Name: name}) + case binding.Group != nil: + detail.Bindings = append(detail.Bindings, Binding{Kind: "group", Name: binding.Group.Name}) + } + } + return detail +} + +func sinkURL(node *gqlclient.NotificationSinkFragment) string { + if node.Configuration.Slack != nil { + return node.Configuration.Slack.URL + } + if node.Configuration.Teams != nil { + return node.Configuration.Teams.URL + } + return "" +} + +func matchesQuery(summary Summary, query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{summary.Name, summary.Type, summary.URL, summary.ID}, " ")) + return strings.Contains(haystack, query) +} diff --git a/pkg/bridge/notifications/notifications_test.go b/pkg/bridge/notifications/notifications_test.go new file mode 100644 index 000000000..e5f79fad3 --- /dev/null +++ b/pkg/bridge/notifications/notifications_test.go @@ -0,0 +1,123 @@ +package notifications + +import ( + "context" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeResolver struct { + url, token string + err error +} + +func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) { + return f.url, f.token, f.err +} + +type fakeAPI struct { + sinks *gqlclient.ListNotificationSinks_NotificationSinks + listErr error + detail *gqlclient.NotificationSinkFragment + getErr error +} + +func (f *fakeAPI) ListNotificationSinks(*string, *int64) (*gqlclient.ListNotificationSinks_NotificationSinks, error) { + return f.sinks, f.listErr +} +func (f *fakeAPI) GetNotificationSink(string) (*gqlclient.NotificationSinkFragment, error) { + return f.detail, f.getErr +} + +func TestListAndGet(t *testing.T) { + api := &fakeAPI{ + sinks: &gqlclient.ListNotificationSinks_NotificationSinks{ + Edges: []*gqlclient.NotificationSinkEdgeFragment{ + {Node: &gqlclient.NotificationSinkFragment{ + ID: "s1", Name: "ops-slack", Type: gqlclient.SinkTypeSLACk, + Configuration: gqlclient.SinkConfigurationFragment{ + Slack: &gqlclient.URLSinkConfigurationFragment{URL: "https://hooks.slack.com/x"}, + }, + }}, + {Node: &gqlclient.NotificationSinkFragment{ + ID: "s2", Name: "ops-teams", Type: gqlclient.SinkTypeTeams, + Configuration: gqlclient.SinkConfigurationFragment{ + Teams: &gqlclient.URLSinkConfigurationFragment{URL: "https://teams.example/hook"}, + }, + }}, + }, + }, + detail: &gqlclient.NotificationSinkFragment{ + ID: "s1", Name: "ops-slack", Type: gqlclient.SinkTypeSLACk, + Configuration: gqlclient.SinkConfigurationFragment{ + Slack: &gqlclient.URLSinkConfigurationFragment{URL: "https://hooks.slack.com/x"}, + }, + NotificationBindings: []*gqlclient.PolicyBindingFragment{ + {User: &gqlclient.UserFragment{Email: "ops@acme.io", Name: "ops"}}, + {Group: &gqlclient.GroupFragment{Name: "platform"}}, + }, + }, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 10, + } + + page, err := service.List(t.Context(), nil, "slack") + if err != nil || len(page.Items) != 1 || page.Items[0].Name != "ops-slack" || page.Items[0].Type != "SLACK" { + t.Fatalf("List() = %#v, %v", page, err) + } + + detail, err := service.Get(t.Context(), "s1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if detail.URL != "https://hooks.slack.com/x" || len(detail.Bindings) != 2 { + t.Fatalf("detail = %#v", detail) + } + if detail.Bindings[0].Kind != "user" || detail.Bindings[0].Name != "ops@acme.io" { + t.Fatalf("user binding = %#v", detail.Bindings[0]) + } + if detail.Bindings[1].Kind != "group" || detail.Bindings[1].Name != "platform" { + t.Fatalf("group binding = %#v", detail.Bindings[1]) + } +} + +func TestListPages(t *testing.T) { + edges := make([]*gqlclient.NotificationSinkEdgeFragment, 0, 3) + for _, id := range []string{"s1", "s2", "s3"} { + edges = append(edges, &gqlclient.NotificationSinkEdgeFragment{ + Node: &gqlclient.NotificationSinkFragment{ID: id, Name: id, Type: gqlclient.SinkTypeSLACk}, + }) + } + api := &fakeAPI{sinks: &gqlclient.ListNotificationSinks_NotificationSinks{Edges: edges}} + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 2, + } + first, err := service.List(t.Context(), nil, "") + if err != nil || len(first.Items) != 2 || !first.HasNext || first.EndCursor != "s2" { + t.Fatalf("first = %#v, %v", first, err) + } + after := first.EndCursor + second, err := service.List(t.Context(), &after, "") + if err != nil || len(second.Items) != 1 || second.HasNext || second.Items[0].ID != "s3" { + t.Fatalf("second = %#v, %v", second, err) + } +} + +func TestGetRequiresID(t *testing.T) { + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return &fakeAPI{}, nil }, + } + _, err := service.Get(t.Context(), "") + if !bridge.IsCode(err, bridge.ErrorInvalid) { + t.Fatalf("Get() error = %v", err) + } +} diff --git a/pkg/console/console.go b/pkg/console/console.go index 887f2d4d9..f1b1c6377 100644 --- a/pkg/console/console.go +++ b/pkg/console/console.go @@ -55,6 +55,7 @@ type ConsoleClient interface { GetServiceContext(name string) (*consoleclient.ServiceContextFragment, error) KickClusterService(serviceId, serviceName, clusterName *string) (*consoleclient.ServiceDeploymentExtended, error) ListNotificationSinks(after *string, first *int64) (*consoleclient.ListNotificationSinks_NotificationSinks, error) + GetNotificationSink(id string) (*consoleclient.NotificationSinkFragment, error) CreateNotificationSinks(attr consoleclient.NotificationSinkAttributes) (*consoleclient.NotificationSinkFragment, error) UpdateDeploymentSettings(attr consoleclient.DeploymentSettingsAttributes) (*consoleclient.UpdateDeploymentSettings, error) GetGlobalSettings() (*consoleclient.DeploymentSettingsFragment, error) diff --git a/pkg/console/notification.go b/pkg/console/notification.go index 88e2b4284..2a88e6ef4 100644 --- a/pkg/console/notification.go +++ b/pkg/console/notification.go @@ -1,21 +1,38 @@ package console import ( + "fmt" + gqlclient "github.com/pluralsh/console/go/client" + "github.com/pluralsh/plural-cli/pkg/api" ) func (c *consoleClient) ListNotificationSinks(after *string, first *int64) (*gqlclient.ListNotificationSinks_NotificationSinks, error) { response, err := c.client.ListNotificationSinks(c.ctx, after, first, nil, nil) if err != nil { - return nil, err + return nil, api.GetErrorResponse(err, "ListNotificationSinks") + } + if response == nil { + return nil, fmt.Errorf("the result from ListNotificationSinks is null") } return response.NotificationSinks, nil } +func (c *consoleClient) GetNotificationSink(id string) (*gqlclient.NotificationSinkFragment, error) { + response, err := c.client.GetNotificationSink(c.ctx, id) + if err != nil { + return nil, api.GetErrorResponse(err, "GetNotificationSink") + } + if response == nil || response.NotificationSink == nil { + return nil, fmt.Errorf("notification sink %s was not found", id) + } + return response.NotificationSink, nil +} + func (c *consoleClient) CreateNotificationSinks(attr gqlclient.NotificationSinkAttributes) (*gqlclient.NotificationSinkFragment, error) { response, err := c.client.UpsertNotificationSink(c.ctx, attr) if err != nil { - return nil, err + return nil, api.GetErrorResponse(err, "UpsertNotificationSink") } return response.UpsertNotificationSink, nil } diff --git a/pkg/test/mocks/ConsoleClient.go b/pkg/test/mocks/ConsoleClient.go index c8c6f1254..03fcf5eef 100644 --- a/pkg/test/mocks/ConsoleClient.go +++ b/pkg/test/mocks/ConsoleClient.go @@ -721,6 +721,36 @@ func (_m *ConsoleClient) GetGlobalSettingsMinimal() (*client.DeploymentSettingsF return r0, r1 } +// GetNotificationSink provides a mock function with given fields: id +func (_m *ConsoleClient) GetNotificationSink(id string) (*client.NotificationSinkFragment, error) { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for GetNotificationSink") + } + + var r0 *client.NotificationSinkFragment + var r1 error + if rf, ok := ret.Get(0).(func(string) (*client.NotificationSinkFragment, error)); ok { + return rf(id) + } + if rf, ok := ret.Get(0).(func(string) *client.NotificationSinkFragment); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.NotificationSinkFragment) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetPipeline provides a mock function with given fields: id func (_m *ConsoleClient) GetPipeline(id string) (*client.PipelineFragment, error) { ret := _m.Called(id) diff --git a/tui/app/model.go b/tui/app/model.go index 778d0cb05..88f518654 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -9,6 +9,7 @@ import ( accessbridge "github.com/pluralsh/plural-cli/pkg/bridge/access" clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" + notificationsbridge "github.com/pluralsh/plural-cli/pkg/bridge/notifications" pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" @@ -18,6 +19,7 @@ import ( clustersscreen "github.com/pluralsh/plural-cli/tui/screens/clusters" deploymentsscreen "github.com/pluralsh/plural-cli/tui/screens/deployments" diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics" + notificationsscreen "github.com/pluralsh/plural-cli/tui/screens/notifications" pipelinesscreen "github.com/pluralsh/plural-cli/tui/screens/pipelines" repositoriesscreen "github.com/pluralsh/plural-cli/tui/screens/repositories" servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" @@ -27,12 +29,13 @@ import ( // Dependencies contains the services required by TUI screens. type Dependencies struct { - Welcome welcomebridge.Loader - Access accessbridge.Manager - Services servicesbridge.Loader - Clusters clustersbridge.Loader - Repositories repositoriesbridge.Loader - Pipelines pipelinesbridge.Loader + Welcome welcomebridge.Loader + Access accessbridge.Manager + Services servicesbridge.Loader + Clusters clustersbridge.Loader + Repositories repositoriesbridge.Loader + Pipelines pipelinesbridge.Loader + Notifications notificationsbridge.Loader } // Model is the root TUI model. It owns global input and delegates screen state @@ -44,30 +47,32 @@ type Model struct { theme theme.Theme quit key.Binding - welcome welcomescreen.Model - access accessscreen.Model - diagnostics diagnosticsscreen.Model - deployments deploymentsscreen.Model - services servicesscreen.Model - clusters clustersscreen.Model - repositories repositoriesscreen.Model - pipelines pipelinesscreen.Model - route navigation.Route + welcome welcomescreen.Model + access accessscreen.Model + diagnostics diagnosticsscreen.Model + deployments deploymentsscreen.Model + services servicesscreen.Model + clusters clustersscreen.Model + repositories repositoriesscreen.Model + pipelines pipelinesscreen.Model + notifications notificationsscreen.Model + route navigation.Route } // New composes the root model with caller-provided dependencies. func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { return Model{ - theme: t, - welcome: welcomescreen.New(ctx, dependencies.Welcome, t), - access: accessscreen.New(ctx, dependencies.Access, t), - diagnostics: diagnosticsscreen.New(ctx, dependencies.Welcome, t), - deployments: deploymentsscreen.New(ctx, t, ""), - services: servicesscreen.New(ctx, dependencies.Services, t), - clusters: clustersscreen.New(ctx, dependencies.Clusters, t), - repositories: repositoriesscreen.New(ctx, dependencies.Repositories, t), - pipelines: pipelinesscreen.New(ctx, dependencies.Pipelines, t), - route: navigation.Welcome, + theme: t, + welcome: welcomescreen.New(ctx, dependencies.Welcome, t), + access: accessscreen.New(ctx, dependencies.Access, t), + diagnostics: diagnosticsscreen.New(ctx, dependencies.Welcome, t), + deployments: deploymentsscreen.New(ctx, t, ""), + services: servicesscreen.New(ctx, dependencies.Services, t), + clusters: clustersscreen.New(ctx, dependencies.Clusters, t), + repositories: repositoriesscreen.New(ctx, dependencies.Repositories, t), + pipelines: pipelinesscreen.New(ctx, dependencies.Pipelines, t), + notifications: notificationsscreen.New(ctx, dependencies.Notifications, t), + route: navigation.Welcome, quit: key.NewBinding( key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit"), @@ -97,6 +102,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.repositories.Init() case navigation.Pipelines: return m, m.pipelines.Init() + case navigation.Notifications: + return m, m.notifications.Init() default: return m, m.welcome.Init() } @@ -130,6 +137,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.repositories, cmd = m.repositories.Update(msg) case navigation.Pipelines: m.pipelines, cmd = m.pipelines.Update(msg) + case navigation.Notifications: + m.notifications, cmd = m.notifications.Update(msg) default: m.welcome, cmd = m.welcome.Update(msg) } diff --git a/tui/app/view.go b/tui/app/view.go index c2009fb38..f9731f00e 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -25,6 +25,8 @@ func (m Model) View() tea.View { content = m.repositories.View(m.width, m.height) case navigation.Pipelines: content = m.pipelines.View(m.width, m.height) + case navigation.Notifications: + content = m.notifications.View(m.width, m.height) } view := tea.NewView(content) view.AltScreen = true diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index 0f7dd7382..a48fb9e2f 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -9,14 +9,15 @@ import tea "charm.land/bubbletea/v2" type Route string const ( - Welcome Route = "welcome" - Access Route = "access" - Diagnostics Route = "diagnostics" - Deployments Route = "deployments" - Services Route = "services" - Clusters Route = "clusters" - Repositories Route = "repositories" - Pipelines Route = "pipelines" + Welcome Route = "welcome" + Access Route = "access" + Diagnostics Route = "diagnostics" + Deployments Route = "deployments" + Services Route = "services" + Clusters Route = "clusters" + Repositories Route = "repositories" + Pipelines Route = "pipelines" + Notifications Route = "notifications" ) // NavigateMsg requests a top-level route change. diff --git a/tui/screens/deployments/model.go b/tui/screens/deployments/model.go index ca1dc1eed..183e82b77 100644 --- a/tui/screens/deployments/model.go +++ b/tui/screens/deployments/model.go @@ -36,7 +36,7 @@ func resources() []resource { {id: resourceClusters, number: "2", shortcut: "c", title: "Clusters", blurb: "list · describe", route: navigation.Clusters}, {id: resourceRepositories, number: "3", shortcut: "r", title: "Repositories", blurb: "list · describe", route: navigation.Repositories}, {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "list · describe", route: navigation.Pipelines}, - {id: resourceNotifications, number: "5", shortcut: "n", title: "Notifications", blurb: "sinks", soon: true}, + {id: resourceNotifications, number: "5", shortcut: "n", title: "Notifications", blurb: "list · describe", route: navigation.Notifications}, {id: resourceProviders, number: "6", shortcut: "v", title: "Providers", blurb: "list", soon: true}, } } diff --git a/tui/screens/deployments/model_test.go b/tui/screens/deployments/model_test.go index dba070dd0..e02e02e55 100644 --- a/tui/screens/deployments/model_test.go +++ b/tui/screens/deployments/model_test.go @@ -98,9 +98,20 @@ func TestPipelinesNavigates(t *testing.T) { } } +func TestNotificationsNavigates(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + _, cmd := model.Update(tea.KeyPressMsg{Code: 'n', Text: "n"}) + if cmd == nil { + t.Fatal("expected navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Notifications}) { + t.Fatalf("msg = %#v", msg) + } +} + func TestSoonResourceDoesNotNavigate(t *testing.T) { model := New(t.Context(), theme.New(colorprofile.ASCII), "") - model.cursor = 4 // notifications [soon] + model.cursor = 5 // providers [soon] _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if cmd != nil { t.Fatalf("unexpected cmd %#v", cmd()) diff --git a/tui/screens/deployments/testdata/deployments-120.golden b/tui/screens/deployments/testdata/deployments-120.golden index a30a1c5a8..fe4e37a05 100644 --- a/tui/screens/deployments/testdata/deployments-120.golden +++ b/tui/screens/deployments/testdata/deployments-120.golden @@ -6,7 +6,7 @@ │ 2 c Clusters list · describe │ │ 3 r Repositories list · describe │ │ 4 p Pipelines list · describe │ - │ 5 n Notifications sinks [soon] │ + │ 5 n Notifications list · describe │ │ 6 v Providers list [soon] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/tui/screens/deployments/testdata/deployments-80.golden b/tui/screens/deployments/testdata/deployments-80.golden index 449f93584..c67a6789d 100644 --- a/tui/screens/deployments/testdata/deployments-80.golden +++ b/tui/screens/deployments/testdata/deployments-80.golden @@ -6,7 +6,7 @@ │ 2 c Clusters list · describe │ │ 3 r Repositories list · describe │ │ 4 p Pipelines list · describe │ - │ 5 n Notifications sinks [soon] │ + │ 5 n Notifications list · describe │ │ 6 v Providers list [soon] │ ╰──────────────────────────────────────────────────────────────────────────╯ diff --git a/tui/screens/notifications/model.go b/tui/screens/notifications/model.go new file mode 100644 index 000000000..b15e8469d --- /dev/null +++ b/tui/screens/notifications/model.go @@ -0,0 +1,306 @@ +// Package notifications implements the read-only Console notification sinks browser. +package notifications + +import ( + "context" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + notificationsbridge "github.com/pluralsh/plural-cli/pkg/bridge/notifications" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeList mode = iota + modeDetail + modeFilter +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionRefresh + keyActionFilter + keyActionConnectConsole + keyActionNextPage + keyActionPrevPage +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, + keyActionRefresh: {"r"}, + keyActionFilter: {"/"}, + keyActionConnectConsole: {"c"}, + keyActionNextPage: {"n", "right", "]"}, + keyActionPrevPage: {"p", "left", "["}, +} + +func actionForKeystroke(keystroke string) keyAction { + for action, keystrokes := range keyActionKeystrokes { + for _, candidate := range keystrokes { + if keystroke == candidate { + return action + } + } + } + return keyActionNone +} + +type initMsg struct{} +type listedMsg struct { + page notificationsbridge.Page + err error + request uint64 +} +type detailMsg struct { + detail notificationsbridge.Detail + err error + request uint64 +} + +// Model owns Notifications-screen interaction state. +type Model struct { + ctx context.Context + loader notificationsbridge.Loader + theme theme.Theme + mode mode + loading bool + err error + needsAuth bool + request uint64 + + page notificationsbridge.Page + cursor int + filter string + filterInput textinput.Model + after *string + prevCursors []string + + detail notificationsbridge.Detail + detailID string + listCursor int + listAfter *string + listFilter string + listPrev []string +} + +func New(ctx context.Context, loader notificationsbridge.Loader, t theme.Theme) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "filter notification sinks" + input.CharLimit = 128 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Blurred = styles.Focused + input.SetStyles(styles) + return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, mode: modeList} +} + +func (m Model) Init() tea.Cmd { + return func() tea.Msg { return initMsg{} } +} + +func (m *Model) beginList(after *string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + query := m.filter + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + page, err := loader.List(ctx, after, query) + return listedMsg{page: page, err: err, request: request} + } +} + +func (m *Model) beginDetail(id string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + detail, err := loader.Get(ctx, id) + return detailMsg{detail: detail, err: err, request: request} + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case initMsg: + m.mode = modeList + m.page = notificationsbridge.Page{} + m.cursor = 0 + m.after = nil + m.prevCursors = nil + m.err = nil + m.needsAuth = false + if m.loader == nil { + m.loading = false + return m, nil + } + return m, m.beginList(nil) + case listedMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.page = msg.page + m.cursor = clampCursor(m.cursor, len(m.page.Items)) + m.mode = modeList + } + return m, nil + case detailMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.detail = msg.detail + m.mode = modeDetail + } + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if m.mode == modeFilter { + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + if m.mode == modeFilter { + switch action { + case keyActionBack: + m.mode = modeList + m.filterInput.Blur() + return m, nil + case keyActionConfirm: + m.filter = strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + m.mode = modeList + m.cursor = 0 + m.after = nil + m.prevCursors = nil + return m, m.beginList(nil) + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd + } + if action == keyActionBack { + if m.mode == modeDetail { + m.mode = modeList + m.err = nil + m.cursor = m.listCursor + m.after = m.listAfter + m.filter = m.listFilter + m.prevCursors = append([]string(nil), m.listPrev...) + return m, nil + } + return m, navigation.Navigate(navigation.Deployments) + } + if m.loading { + return m, nil + } + if m.needsAuth && action == keyActionConnectConsole { + return m, navigation.Navigate(navigation.Access) + } + if m.mode == modeDetail { + if action == keyActionRefresh && m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + return m, nil + } + return m.updateList(action) +} + +func (m Model) updateList(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionMoveUp: + m.cursor = clampCursor(m.cursor-1, len(m.page.Items)) + case keyActionMoveDown: + m.cursor = clampCursor(m.cursor+1, len(m.page.Items)) + case keyActionConfirm: + if len(m.page.Items) == 0 { + return m, nil + } + m.listCursor = m.cursor + m.listAfter = m.after + m.listFilter = m.filter + m.listPrev = append([]string(nil), m.prevCursors...) + m.detailID = m.page.Items[m.cursor].ID + return m, m.beginDetail(m.detailID) + case keyActionRefresh: + return m, m.beginList(m.after) + case keyActionFilter: + m.mode = modeFilter + m.filterInput.SetValue(m.filter) + m.filterInput.Focus() + case keyActionNextPage: + if !m.page.HasNext || m.page.EndCursor == "" { + return m, nil + } + if m.after != nil { + m.prevCursors = append(m.prevCursors, *m.after) + } else { + m.prevCursors = append(m.prevCursors, "") + } + cursor := m.page.EndCursor + m.after = &cursor + m.cursor = 0 + return m, m.beginList(m.after) + case keyActionPrevPage: + if len(m.prevCursors) == 0 { + return m, nil + } + previous := m.prevCursors[len(m.prevCursors)-1] + m.prevCursors = m.prevCursors[:len(m.prevCursors)-1] + if previous == "" { + m.after = nil + } else { + m.after = &previous + } + m.cursor = 0 + return m, m.beginList(m.after) + } + return m, nil +} + +func clampCursor(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} diff --git a/tui/screens/notifications/model_test.go b/tui/screens/notifications/model_test.go new file mode 100644 index 000000000..b288005f5 --- /dev/null +++ b/tui/screens/notifications/model_test.go @@ -0,0 +1,230 @@ +package notifications + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/pkg/bridge" + notificationsbridge "github.com/pluralsh/plural-cli/pkg/bridge/notifications" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeLoader struct { + page notificationsbridge.Page + detail notificationsbridge.Detail + err error +} + +func (f *fakeLoader) List(context.Context, *string, string) (notificationsbridge.Page, error) { + return f.page, f.err +} +func (f *fakeLoader) Get(context.Context, string) (notificationsbridge.Detail, error) { + return f.detail, f.err +} + +func loadList(t *testing.T, model Model) Model { + t.Helper() + cmd := model.Init() + model, cmd = model.Update(cmd()) + if cmd == nil { + t.Fatal("expected list command") + } + model, _ = model.Update(cmd()) + return model +} + +func TestOpenSinkDetailAndBack(t *testing.T) { + loader := &fakeLoader{ + page: notificationsbridge.Page{Items: []notificationsbridge.Summary{ + {ID: "s1", Name: "ops-slack", Type: "SLACK", URL: "https://hooks.slack.com/x"}, + {ID: "s2", Name: "ops-teams", Type: "TEAMS", URL: "https://teams.example/hook"}, + }}, + detail: notificationsbridge.Detail{ + Summary: notificationsbridge.Summary{ID: "s1", Name: "ops-slack", Type: "SLACK", URL: "https://hooks.slack.com/x"}, + Bindings: []notificationsbridge.Binding{ + {Kind: "user", Name: "ops@acme.io"}, + {Kind: "group", Name: "platform"}, + }, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if model.mode != modeList || len(model.page.Items) != 2 { + t.Fatalf("list state = mode=%d count=%d", model.mode, len(model.page.Items)) + } + if !strings.Contains(model.View(80, 24), "ops-slack") { + t.Fatalf("list missing name:\n%s", model.View(80, 24)) + } + + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail || model.detail.Name != "ops-slack" { + t.Fatalf("detail = %#v mode=%d", model.detail, model.mode) + } + if !strings.Contains(model.View(80, 24), "ops@acme.io") { + t.Fatalf("detail missing binding:\n%s", model.View(80, 24)) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeList { + t.Fatalf("mode after detail esc = %d", model.mode) + } + _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Deployments}) { + t.Fatalf("expected deployments navigation") + } +} + +func TestNextPrevPage(t *testing.T) { + loader := &fakeLoader{ + page: notificationsbridge.Page{ + Items: []notificationsbridge.Summary{{ID: "s1", Name: "a", Type: "SLACK"}}, + EndCursor: "s1", + HasNext: true, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !strings.Contains(model.View(80, 24), "n next") { + t.Fatalf("missing next pager:\n%s", model.View(80, 24)) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: 'n'}) + if cmd == nil { + t.Fatal("expected next-page list command") + } + loader.page = notificationsbridge.Page{Items: []notificationsbridge.Summary{{ID: "s2", Name: "b", Type: "TEAMS"}}} + model, _ = model.Update(cmd()) + if model.after == nil || *model.after != "s1" || len(model.prevCursors) != 1 { + t.Fatalf("after page turn after=%v prev=%v", model.after, model.prevCursors) + } + model, cmd = model.Update(tea.KeyPressMsg{Code: 'p'}) + if cmd == nil { + t.Fatal("expected prev-page list command") + } + model, _ = model.Update(cmd()) + if model.after != nil || len(model.prevCursors) != 0 { + t.Fatalf("after prev after=%v prev=%v", model.after, model.prevCursors) + } +} + +func TestNoConsoleNavigatesToAccess(t *testing.T) { + loader := &fakeLoader{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect")}} + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !model.needsAuth { + t.Fatal("expected needsAuth") + } + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c'}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Access}) { + t.Fatalf("expected access navigation") + } +} + +func TestNotificationsGoldens(t *testing.T) { + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = notificationsbridge.Page{Items: []notificationsbridge.Summary{ + {ID: "s1", Name: "ops-slack", Type: "SLACK", URL: "https://hooks.slack.com/services/T/B/x"}, + {ID: "s2", Name: "ops-teams", Type: "TEAMS", URL: "https://teams.example/webhook"}, + {ID: "s3", Name: "plural-inbox", Type: "PLURAL"}, + }, HasNext: true, EndCursor: "s3"} + + detail := list + detail.mode = modeDetail + detail.detail = notificationsbridge.Detail{ + Summary: notificationsbridge.Summary{ID: "s1", Name: "ops-slack", Type: "SLACK", URL: "https://hooks.slack.com/services/T/B/x"}, + Bindings: []notificationsbridge.Binding{ + {Kind: "user", Name: "ops@acme.io"}, + {Kind: "group", Name: "platform"}, + }, + } + + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + t.Run(tc.name, func(t *testing.T) { + got := normalizeView(tc.model.View(tc.width, tc.height)) + golden := filepath.Join("testdata", "notifications-"+tc.name+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + lines := strings.Split(got, "\n") + if len(lines) != tc.height { + t.Fatalf("height = %d, want %d", len(lines), tc.height) + } + for _, line := range lines { + if w := lipgloss.Width(line); w > tc.width { + t.Fatalf("line width %d > %d: %q", w, tc.width, line) + } + } + }) + } +} + +func TestWriteNotificationsGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = notificationsbridge.Page{Items: []notificationsbridge.Summary{ + {ID: "s1", Name: "ops-slack", Type: "SLACK", URL: "https://hooks.slack.com/services/T/B/x"}, + {ID: "s2", Name: "ops-teams", Type: "TEAMS", URL: "https://teams.example/webhook"}, + {ID: "s3", Name: "plural-inbox", Type: "PLURAL"}, + }, HasNext: true, EndCursor: "s3"} + detail := list + detail.mode = modeDetail + detail.detail = notificationsbridge.Detail{ + Summary: notificationsbridge.Summary{ID: "s1", Name: "ops-slack", Type: "SLACK", URL: "https://hooks.slack.com/services/T/B/x"}, + Bindings: []notificationsbridge.Binding{ + {Kind: "user", Name: "ops@acme.io"}, + {Kind: "group", Name: "platform"}, + }, + } + _ = os.MkdirAll("testdata", 0o755) + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + got := normalizeView(tc.model.View(tc.width, tc.height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "notifications-"+tc.name+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/notifications/testdata/notifications-detail-120.golden b/tui/screens/notifications/testdata/notifications-detail-120.golden new file mode 100644 index 000000000..247fa535d --- /dev/null +++ b/tui/screens/notifications/testdata/notifications-detail-120.golden @@ -0,0 +1,30 @@ + Plural Notifications · ops-slack SLACK + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Name ops-slack │ + │ Type SLACK │ + │ URL https://hooks.slack.com/services/T/B/x │ + │ ID s1 │ + │ │ + │ Bindings │ + │ user ops@acme.io │ + │ group platform │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/notifications/testdata/notifications-detail-80.golden b/tui/screens/notifications/testdata/notifications-detail-80.golden new file mode 100644 index 000000000..696097745 --- /dev/null +++ b/tui/screens/notifications/testdata/notifications-detail-80.golden @@ -0,0 +1,24 @@ + Plural Notifications · ops-slack SLACK + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────╮ + │ Name ops-slack │ + │ Type SLACK │ + │ URL https://hooks.slack.com/services/T/B/x │ + │ ID s1 │ + │ │ + │ Bindings │ + │ user ops@acme.io │ + │ group platform │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/notifications/testdata/notifications-list-120.golden b/tui/screens/notifications/testdata/notifications-list-120.golden new file mode 100644 index 000000000..d6b30b0ab --- /dev/null +++ b/tui/screens/notifications/testdata/notifications-list-120.golden @@ -0,0 +1,30 @@ + Plural Notifications 3 sinks + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Notification sinks ───────────────────────────────────────────────────────────────────────────────────────────╮ + │ NAME TYPE URL │ + │ › ops-slack SLACK https://hooks.slack.com/services/T/B/x │ + │ ops-teams TEAMS https://teams.example/webhook │ + │ plural-inbox PLURAL — │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · enter open · / filter · n/p page · r refresh · esc back diff --git a/tui/screens/notifications/testdata/notifications-list-80.golden b/tui/screens/notifications/testdata/notifications-list-80.golden new file mode 100644 index 000000000..2ab3e7335 --- /dev/null +++ b/tui/screens/notifications/testdata/notifications-list-80.golden @@ -0,0 +1,24 @@ + Plural Notifications 3 sinks + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Notification sinks ───────────────────────────────────────────────────╮ + │ NAME TYPE URL │ + │ › ops-slack SLACK https://hooks.slack.com/services/T/B/x │ + │ ops-teams TEAMS https://teams.example/webhook │ + │ plural-inbox PLURAL — │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · / · n/p page · esc back diff --git a/tui/screens/notifications/view.go b/tui/screens/notifications/view.go new file mode 100644 index 000000000..2d5d669bc --- /dev/null +++ b/tui/screens/notifications/view.go @@ -0,0 +1,198 @@ +package notifications + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + title := "Notifications" + if m.mode == modeDetail && m.detail.Name != "" { + title = "Notifications · " + m.detail.Name + } + body, help := m.bodyAndHelp(contentWidth) + return page.Render(m.theme, width, height, title, m.headerStatus(), body, help) +} + +func (m Model) headerStatus() string { + if m.loading { + return m.theme.Warning.Render("◌ loading") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + if m.err != nil { + return m.theme.Danger.Render("✗ load failed") + } + switch m.mode { + case modeDetail: + return m.theme.Success.Render(loCoalesce(m.detail.Type, "sink")) + case modeList: + if m.filter != "" { + return m.theme.Muted.Render(fmt.Sprintf("%d matching", len(m.page.Items))) + } + return m.theme.Success.Render(fmt.Sprintf("%d sinks", len(m.page.Items))) + default: + return m.theme.Muted.Render("notifications") + } +} + +func (m Model) bodyAndHelp(width int) (string, string) { + if m.mode == modeFilter { + lines := []string{ + m.theme.Muted.Render("Filter by name, type, url, or id."), + "", + m.filterInput.View(), + } + return page.Panel(m.theme, "Filter sinks", lines, width, 6, true), "enter apply · esc cancel" + } + if m.needsAuth { + lines := []string{ + m.theme.Warning.Render("○ Console is not connected"), + m.theme.Muted.Render(" Connect a Console profile to browse notification sinks."), + "", + m.theme.Body.Render("Press c to open Access."), + } + return page.Panel(m.theme, "Console required", lines, width, 8, true), "c connect · esc back · ctrl+c quit" + } + if m.mode == modeDetail { + help := "r refresh · esc list · ctrl+c quit" + return page.Panel(m.theme, "Summary", m.detailLines(), width, 16, true), help + } + help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc back" + if width < 100 { + help = "↑/↓ · enter · / · n/p page · esc back" + } + return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help +} + +func (m Model) listTitle() string { + if m.filter != "" { + return "Sinks · filter “" + m.filter + "”" + } + return "Notification sinks" +} + +func (m Model) listLines(width int) []string { + if m.loading && len(m.page.Items) == 0 { + return []string{m.theme.Warning.Render("◌ Loading notification sinks…")} + } + if m.err != nil { + return []string{ + m.theme.Danger.Render("✗ Unable to load notification sinks"), + m.theme.Danger.Render("Error " + m.err.Error()), + m.theme.Muted.Render("Press r to retry."), + } + } + if len(m.page.Items) == 0 { + return []string{ + m.theme.Warning.Render("○ No notification sinks found"), + m.theme.Muted.Render(" Adjust the filter or connect another Console."), + } + } + nameWidth := max(12, min(24, width/4)) + typeWidth := 8 + urlWidth := max(20, min(40, width/2)) + lines := []string{m.theme.Muted.Render(" " + pad("NAME", nameWidth) + " " + pad("TYPE", typeWidth) + " URL")} + start, end := visibleWindow(m.cursor, len(m.page.Items), 8) + for i := start; i < end; i++ { + item := m.page.Items[i] + cursor := " " + if i == m.cursor { + cursor = "› " + } + url := loCoalesce(item.URL, "—") + row := cursor + pad(item.Name, nameWidth) + " " + pad(item.Type, typeWidth) + " " + pad(url, urlWidth) + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + if start > 0 || end < len(m.page.Items) { + lines = append(lines, m.theme.Muted.Render(fmt.Sprintf(" … %d–%d of %d", start+1, end, len(m.page.Items)))) + } + if m.page.HasNext || len(m.prevCursors) > 0 { + pager := "page" + if len(m.prevCursors) > 0 { + pager += " · p prev" + } + if m.page.HasNext { + pager += " · n next" + } + lines = append(lines, "", m.theme.Muted.Render(pager)) + } + return lines +} + +func visibleWindow(cursor, count, size int) (start, end int) { + if count <= 0 { + return 0, 0 + } + if size <= 0 { + size = count + } + if count <= size { + return 0, count + } + start = cursor - size/2 + if start < 0 { + start = 0 + } + end = start + size + if end > count { + end = count + start = end - size + } + return start, end +} + +func (m Model) detailLines() []string { + if m.loading { + return []string{m.theme.Warning.Render("◌ Loading sink detail…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load sink"), m.theme.Danger.Render(m.err.Error())} + } + lines := []string{ + m.labelValue("Name", m.detail.Name), + m.labelValue("Type", loCoalesce(m.detail.Type, "—")), + m.labelValue("URL", loCoalesce(m.detail.URL, "—")), + m.labelValue("ID", m.detail.ID), + } + if len(m.detail.Bindings) > 0 { + lines = append(lines, "", m.theme.Muted.Render("Bindings")) + for _, binding := range m.detail.Bindings { + lines = append(lines, " "+binding.Kind+" "+binding.Name) + } + } + return lines +} + +func (m Model) labelValue(label, value string) string { + label = label + strings.Repeat(" ", max(1, 12-len(label))) + return label + " " + value +} + +func pad(value string, width int) string { + value = ansi.Truncate(value, width, "…") + if lipgloss.Width(value) >= width { + return value + } + return value + strings.Repeat(" ", width-lipgloss.Width(value)) +} + +func loCoalesce(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} From 0cf0b6dc528b561dab2ec4e9c11b9eb018adfd96 Mon Sep 17 00:00:00 2001 From: Lukasz Zajaczkowski Date: Thu, 30 Jul 2026 14:32:41 +0200 Subject: [PATCH 11/11] add providers --- cmd/command/tui/tui.go | 3 + pkg/bridge/providers/providers.go | 241 ++++++++++++++ pkg/bridge/providers/providers_test.go | 112 +++++++ pkg/console/console.go | 1 + pkg/console/providers.go | 13 + pkg/test/mocks/ConsoleClient.go | 30 ++ tui/app/model.go | 9 + tui/app/view.go | 2 + tui/navigation/navigation.go | 1 + tui/screens/deployments/model.go | 2 +- tui/screens/deployments/model_test.go | 14 +- .../testdata/deployments-120.golden | 2 +- .../testdata/deployments-80.golden | 2 +- tui/screens/providers/model.go | 306 ++++++++++++++++++ tui/screens/providers/model_test.go | 230 +++++++++++++ .../testdata/providers-detail-120.golden | 30 ++ .../testdata/providers-detail-80.golden | 24 ++ .../testdata/providers-list-120.golden | 30 ++ .../testdata/providers-list-80.golden | 24 ++ tui/screens/providers/view.go | 203 ++++++++++++ 20 files changed, 1270 insertions(+), 9 deletions(-) create mode 100644 pkg/bridge/providers/providers.go create mode 100644 pkg/bridge/providers/providers_test.go create mode 100644 tui/screens/providers/model.go create mode 100644 tui/screens/providers/model_test.go create mode 100644 tui/screens/providers/testdata/providers-detail-120.golden create mode 100644 tui/screens/providers/testdata/providers-detail-80.golden create mode 100644 tui/screens/providers/testdata/providers-list-120.golden create mode 100644 tui/screens/providers/testdata/providers-list-80.golden create mode 100644 tui/screens/providers/view.go diff --git a/cmd/command/tui/tui.go b/cmd/command/tui/tui.go index 0bc39d6d7..f35e4c19e 100644 --- a/cmd/command/tui/tui.go +++ b/cmd/command/tui/tui.go @@ -11,6 +11,7 @@ import ( clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" notificationsbridge "github.com/pluralsh/plural-cli/pkg/bridge/notifications" pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" + providersbridge "github.com/pluralsh/plural-cli/pkg/bridge/providers" repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" @@ -29,6 +30,7 @@ func Command() cli.Command { repositories := repositoriesbridge.NewService(access) pipelines := pipelinesbridge.NewService(access) notifications := notificationsbridge.NewService(access) + providers := providersbridge.NewService(access) return tuiapp.Run(ctx, os.Stdin, os.Stdout, tuiapp.Dependencies{ Welcome: welcome, Access: access, @@ -37,6 +39,7 @@ func Command() cli.Command { Repositories: repositories, Pipelines: pipelines, Notifications: notifications, + Providers: providers, }) }) } diff --git a/pkg/bridge/providers/providers.go b/pkg/bridge/providers/providers.go new file mode 100644 index 000000000..8e1c8a182 --- /dev/null +++ b/pkg/bridge/providers/providers.go @@ -0,0 +1,241 @@ +// Package providers exposes read-only Console cluster provider list/get +// use cases to presentation layers without importing TUI code. +package providers + +import ( + "context" + "errors" + "strconv" + "strings" + + gqlclient "github.com/pluralsh/console/go/client" + + "github.com/pluralsh/plural-cli/pkg/bridge" + "github.com/pluralsh/plural-cli/pkg/console" +) + +const defaultPageSize int64 = 10 + +var ( + errNoConsole = errors.New("connect a Console profile before browsing Console resources") + errMissingID = errors.New("provider id is required") + errMissingProvider = errors.New("cluster provider was not found") +) + +// Summary is a credential-free list row for a cluster provider. +type Summary struct { + ID string + Name string + Cloud string + Namespace string + Editable string + RepoURL string +} + +// Credential is a credential-free provider credential summary. +type Credential struct { + Name string + Namespace string + Kind string +} + +// Detail is the credential-free detail payload for a cluster provider. +type Detail struct { + Summary + Service string + DeletedAt string + Credentials []Credential +} + +// Page is one cursor page of provider summaries. +type Page struct { + Items []Summary + EndCursor string + HasNext bool + TotalShown int +} + +// Loader is the narrow contract consumed by the Providers screen. +type Loader interface { + List(ctx context.Context, after *string, query string) (Page, error) + Get(ctx context.Context, id string) (Detail, error) +} + +// ConsoleResolver supplies the active Console URL and token. +type ConsoleResolver interface { + ActiveConsole(ctx context.Context) (url, token string, err error) +} + +// API is the Console surface required by this package. +type API interface { + ListProviders() (*gqlclient.ListProviders, error) + GetProvider(id string) (*gqlclient.ClusterProviderFragment, error) +} + +// ClientFactory builds a Console API for an authenticated endpoint. +type ClientFactory func(token, url string) (API, error) + +// Service implements Loader against Console GraphQL. +type Service struct { + resolve ConsoleResolver + newClient ClientFactory + pageSize int64 +} + +// NewService wires production Console credentials and client construction. +func NewService(resolve ConsoleResolver) *Service { + return &Service{ + resolve: resolve, + newClient: func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + }, + pageSize: defaultPageSize, + } +} + +func (s *Service) client(ctx context.Context) (API, error) { + if s.resolve == nil { + return nil, &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errNoConsole} + } + url, token, err := s.resolve.ActiveConsole(ctx) + if err != nil { + return nil, err + } + factory := s.newClient + if factory == nil { + factory = func(token, url string) (API, error) { + return console.NewConsoleClient(token, url) + } + } + return factory(token, url) +} + +func (s *Service) List(ctx context.Context, after *string, query string) (Page, error) { + if err := ctx.Err(); err != nil { + return Page{}, err + } + client, err := s.client(ctx) + if err != nil { + return Page{}, err + } + result, err := client.ListProviders() + if err != nil { + return Page{}, err + } + if result == nil || result.ClusterProviders == nil { + return Page{}, nil + } + items := make([]Summary, 0, len(result.ClusterProviders.Edges)) + for _, edge := range result.ClusterProviders.Edges { + if edge == nil || edge.Node == nil { + continue + } + summary := summaryFromFragment(edge.Node) + if !matchesQuery(summary, query) { + continue + } + items = append(items, summary) + } + return pageItems(items, after, s.pageSize), nil +} + +func (s *Service) Get(ctx context.Context, id string) (Detail, error) { + if err := ctx.Err(); err != nil { + return Detail{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return Detail{}, &bridge.Error{Code: bridge.ErrorInvalid, Err: errMissingID} + } + client, err := s.client(ctx) + if err != nil { + return Detail{}, err + } + provider, err := client.GetProvider(id) + if err != nil { + return Detail{}, err + } + if provider == nil { + return Detail{}, &bridge.Error{Code: bridge.ErrorUnavailable, Err: errMissingProvider} + } + return detailFromFragment(provider), nil +} + +func pageItems(items []Summary, after *string, pageSize int64) Page { + if pageSize <= 0 { + pageSize = defaultPageSize + } + start := 0 + if after != nil && *after != "" { + for i, item := range items { + if item.ID == *after { + start = i + 1 + break + } + } + } + if start > len(items) { + start = len(items) + } + end := start + int(pageSize) + if end > len(items) { + end = len(items) + } + page := Page{Items: items[start:end], TotalShown: end - start, HasNext: end < len(items)} + if len(page.Items) > 0 { + page.EndCursor = page.Items[len(page.Items)-1].ID + } + return page +} + +func summaryFromFragment(node *gqlclient.ClusterProviderFragment) Summary { + summary := Summary{ + ID: node.ID, + Name: node.Name, + Cloud: node.Cloud, + Namespace: node.Namespace, + } + if node.Editable != nil { + summary.Editable = strconv.FormatBool(*node.Editable) + } + if node.Repository != nil { + summary.RepoURL = node.Repository.URL + } + return summary +} + +func detailFromFragment(node *gqlclient.ClusterProviderFragment) Detail { + detail := Detail{Summary: summaryFromFragment(node)} + if node.DeletedAt != nil { + detail.DeletedAt = *node.DeletedAt + } + if node.Service != nil { + name := node.Service.Name + if node.Service.Namespace != "" { + name = node.Service.Namespace + "/" + name + } + detail.Service = name + } + for _, credential := range node.Credentials { + if credential == nil { + continue + } + detail.Credentials = append(detail.Credentials, Credential{ + Name: credential.Name, + Namespace: credential.Namespace, + Kind: credential.Kind, + }) + } + return detail +} + +func matchesQuery(summary Summary, query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return true + } + haystack := strings.ToLower(strings.Join([]string{ + summary.Name, summary.Cloud, summary.Namespace, summary.RepoURL, summary.Editable, summary.ID, + }, " ")) + return strings.Contains(haystack, query) +} diff --git a/pkg/bridge/providers/providers_test.go b/pkg/bridge/providers/providers_test.go new file mode 100644 index 000000000..fc4b3264e --- /dev/null +++ b/pkg/bridge/providers/providers_test.go @@ -0,0 +1,112 @@ +package providers + +import ( + "context" + "testing" + + gqlclient "github.com/pluralsh/console/go/client" + "github.com/samber/lo" + + "github.com/pluralsh/plural-cli/pkg/bridge" +) + +type fakeResolver struct { + url, token string + err error +} + +func (f fakeResolver) ActiveConsole(context.Context) (string, string, error) { + return f.url, f.token, f.err +} + +type fakeAPI struct { + providers *gqlclient.ListProviders + listErr error + detail *gqlclient.ClusterProviderFragment + getErr error +} + +func (f *fakeAPI) ListProviders() (*gqlclient.ListProviders, error) { return f.providers, f.listErr } +func (f *fakeAPI) GetProvider(string) (*gqlclient.ClusterProviderFragment, error) { + return f.detail, f.getErr +} + +func TestListAndGet(t *testing.T) { + api := &fakeAPI{ + providers: &gqlclient.ListProviders{ClusterProviders: &gqlclient.ListProviders_ClusterProviders{ + Edges: []*gqlclient.ListProviders_ClusterProviders_Edges{ + {Node: &gqlclient.ClusterProviderFragment{ + ID: "pr1", Name: "aws-west", Cloud: "aws", Namespace: "infra", + Editable: lo.ToPtr(true), + Repository: &gqlclient.GitRepositoryFragment{URL: "https://github.com/acme/infra"}, + }}, + {Node: &gqlclient.ClusterProviderFragment{ID: "pr2", Name: "gcp-east", Cloud: "gcp"}}, + }, + }}, + detail: &gqlclient.ClusterProviderFragment{ + ID: "pr1", Name: "aws-west", Cloud: "aws", Namespace: "infra", + Editable: lo.ToPtr(true), + Repository: &gqlclient.GitRepositoryFragment{URL: "https://github.com/acme/infra"}, + Service: &gqlclient.ServiceDeploymentFragment{Name: "provider", Namespace: "infra"}, + Credentials: []*gqlclient.ProviderCredentialFragment{ + {Name: "aws-creds", Namespace: "infra", Kind: "Secret"}, + }, + }, + } + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 10, + } + + page, err := service.List(t.Context(), nil, "aws") + if err != nil || len(page.Items) != 1 || page.Items[0].Name != "aws-west" || page.Items[0].Cloud != "aws" { + t.Fatalf("List() = %#v, %v", page, err) + } + if page.Items[0].Editable != "true" || page.Items[0].RepoURL != "https://github.com/acme/infra" { + t.Fatalf("summary = %#v", page.Items[0]) + } + + detail, err := service.Get(t.Context(), "pr1") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if detail.Service != "infra/provider" || len(detail.Credentials) != 1 || detail.Credentials[0].Name != "aws-creds" { + t.Fatalf("detail = %#v", detail) + } +} + +func TestListPages(t *testing.T) { + edges := make([]*gqlclient.ListProviders_ClusterProviders_Edges, 0, 3) + for _, id := range []string{"pr1", "pr2", "pr3"} { + edges = append(edges, &gqlclient.ListProviders_ClusterProviders_Edges{ + Node: &gqlclient.ClusterProviderFragment{ID: id, Name: id, Cloud: "aws"}, + }) + } + api := &fakeAPI{providers: &gqlclient.ListProviders{ClusterProviders: &gqlclient.ListProviders_ClusterProviders{Edges: edges}}} + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return api, nil }, + pageSize: 2, + } + first, err := service.List(t.Context(), nil, "") + if err != nil || len(first.Items) != 2 || !first.HasNext || first.EndCursor != "pr2" { + t.Fatalf("first = %#v, %v", first, err) + } + after := first.EndCursor + second, err := service.List(t.Context(), &after, "") + if err != nil || len(second.Items) != 1 || second.HasNext || second.Items[0].ID != "pr3" { + t.Fatalf("second = %#v, %v", second, err) + } +} + +func TestGetRequiresID(t *testing.T) { + service := &Service{ + resolve: fakeResolver{url: "https://console.example.com", token: "token"}, + newClient: func(string, string) (API, error) { return &fakeAPI{}, nil }, + } + _, err := service.Get(t.Context(), "") + if !bridge.IsCode(err, bridge.ErrorInvalid) { + t.Fatalf("Get() error = %v", err) + } +} diff --git a/pkg/console/console.go b/pkg/console/console.go index f1b1c6377..ca81abfe7 100644 --- a/pkg/console/console.go +++ b/pkg/console/console.go @@ -41,6 +41,7 @@ type ConsoleClient interface { GetClusterService(serviceId, serviceName, clusterName *string) (*consoleclient.ServiceDeploymentExtended, error) DeleteClusterService(serviceId string) (*consoleclient.DeleteServiceDeployment, error) ListProviders() (*consoleclient.ListProviders, error) + GetProvider(id string) (*consoleclient.ClusterProviderFragment, error) CreateProviderCredentials(name string, attr consoleclient.ProviderCredentialAttributes) (*consoleclient.CreateProviderCredential, error) DeleteProviderCredentials(id string) (*consoleclient.DeleteProviderCredential, error) SavePipeline(name string, attrs consoleclient.PipelineAttributes) (*consoleclient.PipelineFragmentMinimal, error) diff --git a/pkg/console/providers.go b/pkg/console/providers.go index 2f1dbdb25..96ab6e618 100644 --- a/pkg/console/providers.go +++ b/pkg/console/providers.go @@ -1,6 +1,8 @@ package console import ( + "fmt" + consoleclient "github.com/pluralsh/console/go/client" "github.com/pluralsh/plural-cli/pkg/api" ) @@ -14,6 +16,17 @@ func (c *consoleClient) ListProviders() (*consoleclient.ListProviders, error) { return result, nil } +func (c *consoleClient) GetProvider(id string) (*consoleclient.ClusterProviderFragment, error) { + response, err := c.client.GetClusterProvider(c.ctx, id) + if err != nil { + return nil, api.GetErrorResponse(err, "GetClusterProvider") + } + if response == nil || response.ClusterProvider == nil { + return nil, fmt.Errorf("cluster provider %s was not found", id) + } + return response.ClusterProvider, nil +} + func (c *consoleClient) CreateProviderCredentials(name string, attr consoleclient.ProviderCredentialAttributes) (*consoleclient.CreateProviderCredential, error) { result, err := c.client.CreateProviderCredential(c.ctx, attr, name) if err != nil { diff --git a/pkg/test/mocks/ConsoleClient.go b/pkg/test/mocks/ConsoleClient.go index 03fcf5eef..47329bddd 100644 --- a/pkg/test/mocks/ConsoleClient.go +++ b/pkg/test/mocks/ConsoleClient.go @@ -871,6 +871,36 @@ func (_m *ConsoleClient) GetProject(name string) (*client.ProjectFragment, error return r0, r1 } +// GetProvider provides a mock function with given fields: id +func (_m *ConsoleClient) GetProvider(id string) (*client.ClusterProviderFragment, error) { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for GetProvider") + } + + var r0 *client.ClusterProviderFragment + var r1 error + if rf, ok := ret.Get(0).(func(string) (*client.ClusterProviderFragment, error)); ok { + return rf(id) + } + if rf, ok := ret.Get(0).(func(string) *client.ClusterProviderFragment); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*client.ClusterProviderFragment) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetRepository provides a mock function with given fields: id func (_m *ConsoleClient) GetRepository(id string) (*client.GetGitRepository, error) { ret := _m.Called(id) diff --git a/tui/app/model.go b/tui/app/model.go index 88f518654..167a5b4fb 100644 --- a/tui/app/model.go +++ b/tui/app/model.go @@ -11,6 +11,7 @@ import ( clustersbridge "github.com/pluralsh/plural-cli/pkg/bridge/clusters" notificationsbridge "github.com/pluralsh/plural-cli/pkg/bridge/notifications" pipelinesbridge "github.com/pluralsh/plural-cli/pkg/bridge/pipelines" + providersbridge "github.com/pluralsh/plural-cli/pkg/bridge/providers" repositoriesbridge "github.com/pluralsh/plural-cli/pkg/bridge/repositories" servicesbridge "github.com/pluralsh/plural-cli/pkg/bridge/services" welcomebridge "github.com/pluralsh/plural-cli/pkg/bridge/welcome" @@ -21,6 +22,7 @@ import ( diagnosticsscreen "github.com/pluralsh/plural-cli/tui/screens/diagnostics" notificationsscreen "github.com/pluralsh/plural-cli/tui/screens/notifications" pipelinesscreen "github.com/pluralsh/plural-cli/tui/screens/pipelines" + providersscreen "github.com/pluralsh/plural-cli/tui/screens/providers" repositoriesscreen "github.com/pluralsh/plural-cli/tui/screens/repositories" servicesscreen "github.com/pluralsh/plural-cli/tui/screens/services" welcomescreen "github.com/pluralsh/plural-cli/tui/screens/welcome" @@ -36,6 +38,7 @@ type Dependencies struct { Repositories repositoriesbridge.Loader Pipelines pipelinesbridge.Loader Notifications notificationsbridge.Loader + Providers providersbridge.Loader } // Model is the root TUI model. It owns global input and delegates screen state @@ -56,6 +59,7 @@ type Model struct { repositories repositoriesscreen.Model pipelines pipelinesscreen.Model notifications notificationsscreen.Model + providers providersscreen.Model route navigation.Route } @@ -72,6 +76,7 @@ func New(ctx context.Context, t theme.Theme, dependencies Dependencies) Model { repositories: repositoriesscreen.New(ctx, dependencies.Repositories, t), pipelines: pipelinesscreen.New(ctx, dependencies.Pipelines, t), notifications: notificationsscreen.New(ctx, dependencies.Notifications, t), + providers: providersscreen.New(ctx, dependencies.Providers, t), route: navigation.Welcome, quit: key.NewBinding( key.WithKeys("ctrl+c"), @@ -104,6 +109,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.pipelines.Init() case navigation.Notifications: return m, m.notifications.Init() + case navigation.Providers: + return m, m.providers.Init() default: return m, m.welcome.Init() } @@ -139,6 +146,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pipelines, cmd = m.pipelines.Update(msg) case navigation.Notifications: m.notifications, cmd = m.notifications.Update(msg) + case navigation.Providers: + m.providers, cmd = m.providers.Update(msg) default: m.welcome, cmd = m.welcome.Update(msg) } diff --git a/tui/app/view.go b/tui/app/view.go index f9731f00e..c907ea314 100644 --- a/tui/app/view.go +++ b/tui/app/view.go @@ -27,6 +27,8 @@ func (m Model) View() tea.View { content = m.pipelines.View(m.width, m.height) case navigation.Notifications: content = m.notifications.View(m.width, m.height) + case navigation.Providers: + content = m.providers.View(m.width, m.height) } view := tea.NewView(content) view.AltScreen = true diff --git a/tui/navigation/navigation.go b/tui/navigation/navigation.go index a48fb9e2f..08dea8245 100644 --- a/tui/navigation/navigation.go +++ b/tui/navigation/navigation.go @@ -18,6 +18,7 @@ const ( Repositories Route = "repositories" Pipelines Route = "pipelines" Notifications Route = "notifications" + Providers Route = "providers" ) // NavigateMsg requests a top-level route change. diff --git a/tui/screens/deployments/model.go b/tui/screens/deployments/model.go index 183e82b77..5f2d7fc7d 100644 --- a/tui/screens/deployments/model.go +++ b/tui/screens/deployments/model.go @@ -37,7 +37,7 @@ func resources() []resource { {id: resourceRepositories, number: "3", shortcut: "r", title: "Repositories", blurb: "list · describe", route: navigation.Repositories}, {id: resourcePipelines, number: "4", shortcut: "p", title: "Pipelines", blurb: "list · describe", route: navigation.Pipelines}, {id: resourceNotifications, number: "5", shortcut: "n", title: "Notifications", blurb: "list · describe", route: navigation.Notifications}, - {id: resourceProviders, number: "6", shortcut: "v", title: "Providers", blurb: "list", soon: true}, + {id: resourceProviders, number: "6", shortcut: "v", title: "Providers", blurb: "list · describe", route: navigation.Providers}, } } diff --git a/tui/screens/deployments/model_test.go b/tui/screens/deployments/model_test.go index e02e02e55..9e4ad535a 100644 --- a/tui/screens/deployments/model_test.go +++ b/tui/screens/deployments/model_test.go @@ -109,12 +109,14 @@ func TestNotificationsNavigates(t *testing.T) { } } -func TestSoonResourceDoesNotNavigate(t *testing.T) { - model := New(t.Context(), theme.New(colorprofile.ASCII), "") - model.cursor = 5 // providers [soon] - _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - if cmd != nil { - t.Fatalf("unexpected cmd %#v", cmd()) +func TestProvidersNavigates(t *testing.T) { + model := New(t.Context(), theme.New(colorprofile.ASCII), "https://console.acme.io") + _, cmd := model.Update(tea.KeyPressMsg{Code: 'v', Text: "v"}) + if cmd == nil { + t.Fatal("expected navigation") + } + if msg := cmd(); msg != (navigation.NavigateMsg{Route: navigation.Providers}) { + t.Fatalf("msg = %#v", msg) } } diff --git a/tui/screens/deployments/testdata/deployments-120.golden b/tui/screens/deployments/testdata/deployments-120.golden index fe4e37a05..5c5661628 100644 --- a/tui/screens/deployments/testdata/deployments-120.golden +++ b/tui/screens/deployments/testdata/deployments-120.golden @@ -7,7 +7,7 @@ │ 3 r Repositories list · describe │ │ 4 p Pipelines list · describe │ │ 5 n Notifications list · describe │ - │ 6 v Providers list [soon] │ + │ 6 v Providers list · describe │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─ Connection ─────────────────────────────────────────────────────────────────────────────────────────────────────╮ diff --git a/tui/screens/deployments/testdata/deployments-80.golden b/tui/screens/deployments/testdata/deployments-80.golden index c67a6789d..435b6ba7f 100644 --- a/tui/screens/deployments/testdata/deployments-80.golden +++ b/tui/screens/deployments/testdata/deployments-80.golden @@ -7,7 +7,7 @@ │ 3 r Repositories list · describe │ │ 4 p Pipelines list · describe │ │ 5 n Notifications list · describe │ - │ 6 v Providers list [soon] │ + │ 6 v Providers list · describe │ ╰──────────────────────────────────────────────────────────────────────────╯ ╭─ Connection ─────────────────────────────────────────────────────────────╮ diff --git a/tui/screens/providers/model.go b/tui/screens/providers/model.go new file mode 100644 index 000000000..7c20b2764 --- /dev/null +++ b/tui/screens/providers/model.go @@ -0,0 +1,306 @@ +// Package providers implements the read-only Console cluster providers browser. +package providers + +import ( + "context" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/pluralsh/plural-cli/pkg/bridge" + providersbridge "github.com/pluralsh/plural-cli/pkg/bridge/providers" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type mode uint8 + +const ( + modeList mode = iota + modeDetail + modeFilter +) + +type keyAction uint8 + +const ( + keyActionNone keyAction = iota + keyActionBack + keyActionMoveUp + keyActionMoveDown + keyActionConfirm + keyActionRefresh + keyActionFilter + keyActionConnectConsole + keyActionNextPage + keyActionPrevPage +) + +var keyActionKeystrokes = map[keyAction][]string{ + keyActionBack: {"esc"}, + keyActionMoveUp: {"up", "k"}, + keyActionMoveDown: {"down", "j"}, + keyActionConfirm: {"enter"}, + keyActionRefresh: {"r"}, + keyActionFilter: {"/"}, + keyActionConnectConsole: {"c"}, + keyActionNextPage: {"n", "right", "]"}, + keyActionPrevPage: {"p", "left", "["}, +} + +func actionForKeystroke(keystroke string) keyAction { + for action, keystrokes := range keyActionKeystrokes { + for _, candidate := range keystrokes { + if keystroke == candidate { + return action + } + } + } + return keyActionNone +} + +type initMsg struct{} +type listedMsg struct { + page providersbridge.Page + err error + request uint64 +} +type detailMsg struct { + detail providersbridge.Detail + err error + request uint64 +} + +// Model owns Providers-screen interaction state. +type Model struct { + ctx context.Context + loader providersbridge.Loader + theme theme.Theme + mode mode + loading bool + err error + needsAuth bool + request uint64 + + page providersbridge.Page + cursor int + filter string + filterInput textinput.Model + after *string + prevCursors []string + + detail providersbridge.Detail + detailID string + listCursor int + listAfter *string + listFilter string + listPrev []string +} + +func New(ctx context.Context, loader providersbridge.Loader, t theme.Theme) Model { + input := textinput.New() + input.Prompt = "› " + input.Placeholder = "filter providers" + input.CharLimit = 128 + styles := textinput.DefaultDarkStyles() + styles.Focused.Text = t.Body + styles.Focused.Prompt = t.Title + styles.Focused.Placeholder = t.Muted + styles.Blurred = styles.Focused + input.SetStyles(styles) + return Model{ctx: ctx, loader: loader, theme: t, loading: loader != nil, filterInput: input, mode: modeList} +} + +func (m Model) Init() tea.Cmd { + return func() tea.Msg { return initMsg{} } +} + +func (m *Model) beginList(after *string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + query := m.filter + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + page, err := loader.List(ctx, after, query) + return listedMsg{page: page, err: err, request: request} + } +} + +func (m *Model) beginDetail(id string) tea.Cmd { + m.loading = true + m.request++ + request := m.request + loader := m.loader + ctx := m.ctx + return func() tea.Msg { + detail, err := loader.Get(ctx, id) + return detailMsg{detail: detail, err: err, request: request} + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case initMsg: + m.mode = modeList + m.page = providersbridge.Page{} + m.cursor = 0 + m.after = nil + m.prevCursors = nil + m.err = nil + m.needsAuth = false + if m.loader == nil { + m.loading = false + return m, nil + } + return m, m.beginList(nil) + case listedMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.page = msg.page + m.cursor = clampCursor(m.cursor, len(m.page.Items)) + m.mode = modeList + } + return m, nil + case detailMsg: + if msg.request != m.request { + return m, nil + } + m.loading = false + m.err = msg.err + m.needsAuth = bridge.IsCode(msg.err, bridge.ErrorUnauthenticated) + if msg.err == nil { + m.detail = msg.detail + m.mode = modeDetail + } + return m, nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + if m.mode == modeFilter { + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) updateKey(key tea.KeyPressMsg) (Model, tea.Cmd) { + action := actionForKeystroke(key.Keystroke()) + if m.mode == modeFilter { + switch action { + case keyActionBack: + m.mode = modeList + m.filterInput.Blur() + return m, nil + case keyActionConfirm: + m.filter = strings.TrimSpace(m.filterInput.Value()) + m.filterInput.Blur() + m.mode = modeList + m.cursor = 0 + m.after = nil + m.prevCursors = nil + return m, m.beginList(nil) + } + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(key) + return m, cmd + } + if action == keyActionBack { + if m.mode == modeDetail { + m.mode = modeList + m.err = nil + m.cursor = m.listCursor + m.after = m.listAfter + m.filter = m.listFilter + m.prevCursors = append([]string(nil), m.listPrev...) + return m, nil + } + return m, navigation.Navigate(navigation.Deployments) + } + if m.loading { + return m, nil + } + if m.needsAuth && action == keyActionConnectConsole { + return m, navigation.Navigate(navigation.Access) + } + if m.mode == modeDetail { + if action == keyActionRefresh && m.detailID != "" { + return m, m.beginDetail(m.detailID) + } + return m, nil + } + return m.updateList(action) +} + +func (m Model) updateList(action keyAction) (Model, tea.Cmd) { + switch action { + case keyActionMoveUp: + m.cursor = clampCursor(m.cursor-1, len(m.page.Items)) + case keyActionMoveDown: + m.cursor = clampCursor(m.cursor+1, len(m.page.Items)) + case keyActionConfirm: + if len(m.page.Items) == 0 { + return m, nil + } + m.listCursor = m.cursor + m.listAfter = m.after + m.listFilter = m.filter + m.listPrev = append([]string(nil), m.prevCursors...) + m.detailID = m.page.Items[m.cursor].ID + return m, m.beginDetail(m.detailID) + case keyActionRefresh: + return m, m.beginList(m.after) + case keyActionFilter: + m.mode = modeFilter + m.filterInput.SetValue(m.filter) + m.filterInput.Focus() + case keyActionNextPage: + if !m.page.HasNext || m.page.EndCursor == "" { + return m, nil + } + if m.after != nil { + m.prevCursors = append(m.prevCursors, *m.after) + } else { + m.prevCursors = append(m.prevCursors, "") + } + cursor := m.page.EndCursor + m.after = &cursor + m.cursor = 0 + return m, m.beginList(m.after) + case keyActionPrevPage: + if len(m.prevCursors) == 0 { + return m, nil + } + previous := m.prevCursors[len(m.prevCursors)-1] + m.prevCursors = m.prevCursors[:len(m.prevCursors)-1] + if previous == "" { + m.after = nil + } else { + m.after = &previous + } + m.cursor = 0 + return m, m.beginList(m.after) + } + return m, nil +} + +func clampCursor(cursor, count int) int { + if count == 0 { + return 0 + } + if cursor < 0 { + return count - 1 + } + if cursor >= count { + return 0 + } + return cursor +} diff --git a/tui/screens/providers/model_test.go b/tui/screens/providers/model_test.go new file mode 100644 index 000000000..6acb2744f --- /dev/null +++ b/tui/screens/providers/model_test.go @@ -0,0 +1,230 @@ +package providers + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/pkg/bridge" + providersbridge "github.com/pluralsh/plural-cli/pkg/bridge/providers" + "github.com/pluralsh/plural-cli/tui/navigation" + "github.com/pluralsh/plural-cli/tui/theme" +) + +type fakeLoader struct { + page providersbridge.Page + detail providersbridge.Detail + err error +} + +func (f *fakeLoader) List(context.Context, *string, string) (providersbridge.Page, error) { + return f.page, f.err +} +func (f *fakeLoader) Get(context.Context, string) (providersbridge.Detail, error) { + return f.detail, f.err +} + +func loadList(t *testing.T, model Model) Model { + t.Helper() + cmd := model.Init() + model, cmd = model.Update(cmd()) + if cmd == nil { + t.Fatal("expected list command") + } + model, _ = model.Update(cmd()) + return model +} + +func TestOpenProviderDetailAndBack(t *testing.T) { + loader := &fakeLoader{ + page: providersbridge.Page{Items: []providersbridge.Summary{ + {ID: "pr1", Name: "aws-west", Cloud: "aws", Editable: "true", RepoURL: "https://github.com/acme/infra"}, + {ID: "pr2", Name: "gcp-east", Cloud: "gcp"}, + }}, + detail: providersbridge.Detail{ + Summary: providersbridge.Summary{ID: "pr1", Name: "aws-west", Cloud: "aws", Namespace: "infra", Editable: "true", RepoURL: "https://github.com/acme/infra"}, + Service: "infra/provider", + Credentials: []providersbridge.Credential{ + {Name: "aws-creds", Namespace: "infra", Kind: "Secret"}, + }, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if model.mode != modeList || len(model.page.Items) != 2 { + t.Fatalf("list state = mode=%d count=%d", model.mode, len(model.page.Items)) + } + if !strings.Contains(model.View(80, 24), "aws-west") { + t.Fatalf("list missing name:\n%s", model.View(80, 24)) + } + + model, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model, _ = model.Update(cmd()) + if model.mode != modeDetail || model.detail.Name != "aws-west" { + t.Fatalf("detail = %#v mode=%d", model.detail, model.mode) + } + if !strings.Contains(model.View(80, 24), "aws-creds") { + t.Fatalf("detail missing credential:\n%s", model.View(80, 24)) + } + + model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.mode != modeList { + t.Fatalf("mode after detail esc = %d", model.mode) + } + _, cmd = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Deployments}) { + t.Fatalf("expected deployments navigation") + } +} + +func TestNextPrevPage(t *testing.T) { + loader := &fakeLoader{ + page: providersbridge.Page{ + Items: []providersbridge.Summary{{ID: "pr1", Name: "a", Cloud: "aws"}}, + EndCursor: "pr1", + HasNext: true, + }, + } + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !strings.Contains(model.View(80, 24), "n next") { + t.Fatalf("missing next pager:\n%s", model.View(80, 24)) + } + model, cmd := model.Update(tea.KeyPressMsg{Code: 'n'}) + if cmd == nil { + t.Fatal("expected next-page list command") + } + loader.page = providersbridge.Page{Items: []providersbridge.Summary{{ID: "pr2", Name: "b", Cloud: "gcp"}}} + model, _ = model.Update(cmd()) + if model.after == nil || *model.after != "pr1" || len(model.prevCursors) != 1 { + t.Fatalf("after page turn after=%v prev=%v", model.after, model.prevCursors) + } + model, cmd = model.Update(tea.KeyPressMsg{Code: 'p'}) + if cmd == nil { + t.Fatal("expected prev-page list command") + } + model, _ = model.Update(cmd()) + if model.after != nil || len(model.prevCursors) != 0 { + t.Fatalf("after prev after=%v prev=%v", model.after, model.prevCursors) + } +} + +func TestNoConsoleNavigatesToAccess(t *testing.T) { + loader := &fakeLoader{err: &bridge.Error{Code: bridge.ErrorUnauthenticated, Err: errors.New("connect")}} + model := loadList(t, New(t.Context(), loader, theme.New(colorprofile.ASCII))) + if !model.needsAuth { + t.Fatal("expected needsAuth") + } + _, cmd := model.Update(tea.KeyPressMsg{Code: 'c'}) + if cmd == nil || cmd() != (navigation.NavigateMsg{Route: navigation.Access}) { + t.Fatalf("expected access navigation") + } +} + +func TestProvidersGoldens(t *testing.T) { + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = providersbridge.Page{Items: []providersbridge.Summary{ + {ID: "pr1", Name: "aws-west", Cloud: "aws", Editable: "true", RepoURL: "https://github.com/acme/infra"}, + {ID: "pr2", Name: "gcp-east", Cloud: "gcp", Editable: "false"}, + {ID: "pr3", Name: "azure-central", Cloud: "azure", RepoURL: "https://github.com/acme/azure"}, + }, HasNext: true, EndCursor: "pr3"} + + detail := list + detail.mode = modeDetail + detail.detail = providersbridge.Detail{ + Summary: providersbridge.Summary{ID: "pr1", Name: "aws-west", Cloud: "aws", Namespace: "infra", Editable: "true", RepoURL: "https://github.com/acme/infra"}, + Service: "infra/provider", + Credentials: []providersbridge.Credential{ + {Name: "aws-creds", Namespace: "infra", Kind: "Secret"}, + }, + } + + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + t.Run(tc.name, func(t *testing.T) { + got := normalizeView(tc.model.View(tc.width, tc.height)) + golden := filepath.Join("testdata", "providers-"+tc.name+".golden") + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v\nactual:\n%s", err, got) + } + if got != strings.TrimSuffix(string(want), "\n") { + t.Fatalf("view changed\nwant:\n%s\n\ngot:\n%s", want, got) + } + lines := strings.Split(got, "\n") + if len(lines) != tc.height { + t.Fatalf("height = %d, want %d", len(lines), tc.height) + } + for _, line := range lines { + if w := lipgloss.Width(line); w > tc.width { + t.Fatalf("line width %d > %d: %q", w, tc.width, line) + } + } + }) + } +} + +func TestWriteProvidersGoldens(t *testing.T) { + if os.Getenv("UPDATE_GOLDEN") == "" { + t.Skip("set UPDATE_GOLDEN=1 to refresh fixtures") + } + list := New(t.Context(), nil, theme.New(colorprofile.ASCII)) + list.loading = false + list.mode = modeList + list.page = providersbridge.Page{Items: []providersbridge.Summary{ + {ID: "pr1", Name: "aws-west", Cloud: "aws", Editable: "true", RepoURL: "https://github.com/acme/infra"}, + {ID: "pr2", Name: "gcp-east", Cloud: "gcp", Editable: "false"}, + {ID: "pr3", Name: "azure-central", Cloud: "azure", RepoURL: "https://github.com/acme/azure"}, + }, HasNext: true, EndCursor: "pr3"} + detail := list + detail.mode = modeDetail + detail.detail = providersbridge.Detail{ + Summary: providersbridge.Summary{ID: "pr1", Name: "aws-west", Cloud: "aws", Namespace: "infra", Editable: "true", RepoURL: "https://github.com/acme/infra"}, + Service: "infra/provider", + Credentials: []providersbridge.Credential{ + {Name: "aws-creds", Namespace: "infra", Kind: "Secret"}, + }, + } + _ = os.MkdirAll("testdata", 0o755) + for _, tc := range []struct { + name string + model Model + width int + height int + }{ + {"list-80", list, 80, 24}, + {"list-120", list, 120, 30}, + {"detail-80", detail, 80, 24}, + {"detail-120", detail, 120, 30}, + } { + got := normalizeView(tc.model.View(tc.width, tc.height)) + "\n" + if err := os.WriteFile(filepath.Join("testdata", "providers-"+tc.name+".golden"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func normalizeView(view string) string { + lines := strings.Split(ansi.Strip(view), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " ") + } + return strings.Join(lines, "\n") +} diff --git a/tui/screens/providers/testdata/providers-detail-120.golden b/tui/screens/providers/testdata/providers-detail-120.golden new file mode 100644 index 000000000..6796d6638 --- /dev/null +++ b/tui/screens/providers/testdata/providers-detail-120.golden @@ -0,0 +1,30 @@ + Plural Providers · aws-west aws + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Name aws-west │ + │ Cloud aws │ + │ Namespace infra │ + │ Editable true │ + │ Repo https://github.com/acme/infra │ + │ Service infra/provider │ + │ ID pr1 │ + │ │ + │ Credentials │ + │ aws-creds infra · Secret │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/providers/testdata/providers-detail-80.golden b/tui/screens/providers/testdata/providers-detail-80.golden new file mode 100644 index 000000000..8374f8c00 --- /dev/null +++ b/tui/screens/providers/testdata/providers-detail-80.golden @@ -0,0 +1,24 @@ + Plural Providers · aws-west aws + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Summary ──────────────────────────────────────────────────────────────╮ + │ Name aws-west │ + │ Cloud aws │ + │ Namespace infra │ + │ Editable true │ + │ Repo https://github.com/acme/infra │ + │ Service infra/provider │ + │ ID pr1 │ + │ │ + │ Credentials │ + │ aws-creds infra · Secret │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + r refresh · esc list · ctrl+c quit diff --git a/tui/screens/providers/testdata/providers-list-120.golden b/tui/screens/providers/testdata/providers-list-120.golden new file mode 100644 index 000000000..71eec88e4 --- /dev/null +++ b/tui/screens/providers/testdata/providers-list-120.golden @@ -0,0 +1,30 @@ + Plural Providers 3 providers + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + ╭─ › Providers ────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ NAME CLOUD EDITABLE REPO │ + │ › aws-west aws true https://github.com/acme/infra │ + │ gcp-east gcp false — │ + │ azure-central azure — https://github.com/acme/azure │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + + ↑/↓ select · enter open · / filter · n/p page · r refresh · esc back diff --git a/tui/screens/providers/testdata/providers-list-80.golden b/tui/screens/providers/testdata/providers-list-80.golden new file mode 100644 index 000000000..9facac59f --- /dev/null +++ b/tui/screens/providers/testdata/providers-list-80.golden @@ -0,0 +1,24 @@ + Plural Providers 3 providers + ──────────────────────────────────────────────────────────────────────────── + + ╭─ › Providers ────────────────────────────────────────────────────────────╮ + │ NAME CLOUD EDITABLE REPO │ + │ › aws-west aws true https://github.com/acme/infra │ + │ gcp-east gcp false — │ + │ azure-central azure — https://github.com/acme/azure │ + │ │ + │ page · n next │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ + + + + + + + ↑/↓ · enter · / · n/p page · esc back diff --git a/tui/screens/providers/view.go b/tui/screens/providers/view.go new file mode 100644 index 000000000..605b2f3a6 --- /dev/null +++ b/tui/screens/providers/view.go @@ -0,0 +1,203 @@ +package providers + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/pluralsh/plural-cli/tui/components/page" +) + +func (m Model) View(width, height int) string { + width, height = page.Size(width, height) + if width < page.MinimumWidth || height < page.MinimumHeight { + return page.Unsupported(m.theme, width, height) + } + contentWidth := page.ContentWidth(width) + title := "Providers" + if m.mode == modeDetail && m.detail.Name != "" { + title = "Providers · " + m.detail.Name + } + body, help := m.bodyAndHelp(contentWidth) + return page.Render(m.theme, width, height, title, m.headerStatus(), body, help) +} + +func (m Model) headerStatus() string { + if m.loading { + return m.theme.Warning.Render("◌ loading") + } + if m.needsAuth { + return m.theme.Warning.Render("○ connect Console") + } + if m.err != nil { + return m.theme.Danger.Render("✗ load failed") + } + switch m.mode { + case modeDetail: + return m.theme.Success.Render(loCoalesce(m.detail.Cloud, "provider")) + case modeList: + if m.filter != "" { + return m.theme.Muted.Render(fmt.Sprintf("%d matching", len(m.page.Items))) + } + return m.theme.Success.Render(fmt.Sprintf("%d providers", len(m.page.Items))) + default: + return m.theme.Muted.Render("providers") + } +} + +func (m Model) bodyAndHelp(width int) (string, string) { + if m.mode == modeFilter { + lines := []string{ + m.theme.Muted.Render("Filter by name, cloud, namespace, or id."), + "", + m.filterInput.View(), + } + return page.Panel(m.theme, "Filter providers", lines, width, 6, true), "enter apply · esc cancel" + } + if m.needsAuth { + lines := []string{ + m.theme.Warning.Render("○ Console is not connected"), + m.theme.Muted.Render(" Connect a Console profile to browse providers."), + "", + m.theme.Body.Render("Press c to open Access."), + } + return page.Panel(m.theme, "Console required", lines, width, 8, true), "c connect · esc back · ctrl+c quit" + } + if m.mode == modeDetail { + help := "r refresh · esc list · ctrl+c quit" + return page.Panel(m.theme, "Summary", m.detailLines(), width, 16, true), help + } + help := "↑/↓ select · enter open · / filter · n/p page · r refresh · esc back" + if width < 100 { + help = "↑/↓ · enter · / · n/p page · esc back" + } + return page.Panel(m.theme, m.listTitle(), m.listLines(width), width, 14, true), help +} + +func (m Model) listTitle() string { + if m.filter != "" { + return "Providers · filter “" + m.filter + "”" + } + return "Providers" +} + +func (m Model) listLines(width int) []string { + if m.loading && len(m.page.Items) == 0 { + return []string{m.theme.Warning.Render("◌ Loading providers…")} + } + if m.err != nil { + return []string{ + m.theme.Danger.Render("✗ Unable to load providers"), + m.theme.Danger.Render("Error " + m.err.Error()), + m.theme.Muted.Render("Press r to retry."), + } + } + if len(m.page.Items) == 0 { + return []string{ + m.theme.Warning.Render("○ No providers found"), + m.theme.Muted.Render(" Adjust the filter or connect another Console."), + } + } + nameWidth := max(14, min(24, width/4)) + cloudWidth := max(6, min(10, width/8)) + editWidth := 8 + lines := []string{m.theme.Muted.Render(" " + pad("NAME", nameWidth) + " " + pad("CLOUD", cloudWidth) + " " + pad("EDITABLE", editWidth) + " REPO")} + start, end := visibleWindow(m.cursor, len(m.page.Items), 8) + for i := start; i < end; i++ { + item := m.page.Items[i] + cursor := " " + if i == m.cursor { + cursor = "› " + } + row := cursor + pad(item.Name, nameWidth) + " " + pad(item.Cloud, cloudWidth) + " " + pad(loCoalesce(item.Editable, "—"), editWidth) + " " + loCoalesce(item.RepoURL, "—") + lines = append(lines, ansi.Truncate(row, width-2, "…")) + } + if start > 0 || end < len(m.page.Items) { + lines = append(lines, m.theme.Muted.Render(fmt.Sprintf(" … %d–%d of %d", start+1, end, len(m.page.Items)))) + } + if m.page.HasNext || len(m.prevCursors) > 0 { + pager := "page" + if len(m.prevCursors) > 0 { + pager += " · p prev" + } + if m.page.HasNext { + pager += " · n next" + } + lines = append(lines, "", m.theme.Muted.Render(pager)) + } + return lines +} + +func visibleWindow(cursor, count, size int) (start, end int) { + if count <= 0 { + return 0, 0 + } + if size <= 0 { + size = count + } + if count <= size { + return 0, count + } + start = cursor - size/2 + if start < 0 { + start = 0 + } + end = start + size + if end > count { + end = count + start = end - size + } + return start, end +} + +func (m Model) detailLines() []string { + if m.loading { + return []string{m.theme.Warning.Render("◌ Loading provider detail…")} + } + if m.err != nil { + return []string{m.theme.Danger.Render("✗ Unable to load provider"), m.theme.Danger.Render(m.err.Error())} + } + lines := []string{ + m.labelValue("Name", m.detail.Name), + m.labelValue("Cloud", loCoalesce(m.detail.Cloud, "—")), + m.labelValue("Namespace", loCoalesce(m.detail.Namespace, "—")), + m.labelValue("Editable", loCoalesce(m.detail.Editable, "—")), + m.labelValue("Repo", loCoalesce(m.detail.RepoURL, "—")), + m.labelValue("Service", loCoalesce(m.detail.Service, "—")), + m.labelValue("ID", m.detail.ID), + } + if m.detail.DeletedAt != "" { + lines = append(lines, m.labelValue("Deleted", m.detail.DeletedAt)) + } + if len(m.detail.Credentials) > 0 { + lines = append(lines, "", m.theme.Muted.Render("Credentials")) + for _, credential := range m.detail.Credentials { + lines = append(lines, " "+credential.Name+" "+m.theme.Muted.Render(credential.Namespace+" · "+credential.Kind)) + } + } + return lines +} + +func (m Model) labelValue(label, value string) string { + label = label + strings.Repeat(" ", max(1, 12-len(label))) + return label + " " + value +} + +func pad(value string, width int) string { + value = ansi.Truncate(value, width, "…") + if lipgloss.Width(value) >= width { + return value + } + return value + strings.Repeat(" ", width-lipgloss.Width(value)) +} + +func loCoalesce(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +}