diff --git a/.execs/build.flow b/.execs/build.flow index 1c5c013c..44bf988d 100644 --- a/.execs/build.flow +++ b/.execs/build.flow @@ -25,7 +25,7 @@ executables: - envKey: BIN_NAME text: flow cmd: | - go build -o ${BIN_PATH}/${BIN_NAME} + go build -ldflags "-X github.com/flowexec/flow/internal/version.version=dev" -o ${BIN_PATH}/${BIN_NAME} echo "flow built at ${BIN_PATH}/${BIN_NAME}" - verb: generate diff --git a/.execs/container.flow b/.execs/container.flow index 6c6a5eb2..9c4835be 100644 --- a/.execs/container.flow +++ b/.execs/container.flow @@ -80,7 +80,7 @@ executables: # First build the flow binary with version info GOOS=linux GOARCH=${CURRENT_ARCH} go build \ - -ldflags="-s -w -X github.com/flowexec/flow/cmd/internal/version.gitCommit=${GIT_COMMIT} -X github.com/flowexec/flow/cmd/internal/version.version=${VERSION} -X github.com/flowexec/flow/cmd/internal/version.buildDate=${BUILD_DATE}" \ + -ldflags="-s -w -X github.com/flowexec/flow/internal/version.gitCommit=${GIT_COMMIT} -X github.com/flowexec/flow/internal/version.version=${VERSION} -X github.com/flowexec/flow/internal/version.buildDate=${BUILD_DATE}" \ -o flow # Build Docker image for local platform @@ -109,12 +109,12 @@ executables: # Build amd64 GOOS=linux GOARCH=amd64 go build \ - -ldflags="-s -w -X github.com/flowexec/flow/cmd/internal/version.gitCommit=${GIT_COMMIT} -X github.com/flowexec/flow/cmd/internal/version.version=${VERSION} -X github.com/flowexec/flow/cmd/internal/version.buildDate=${BUILD_DATE}" \ + -ldflags="-s -w -X github.com/flowexec/flow/internal/version.gitCommit=${GIT_COMMIT} -X github.com/flowexec/flow/internal/version.version=${VERSION} -X github.com/flowexec/flow/internal/version.buildDate=${BUILD_DATE}" \ -o flow-amd64 # Build arm64 GOOS=linux GOARCH=arm64 go build \ - -ldflags="-s -w -X github.com/flowexec/flow/cmd/internal/version.gitCommit=${GIT_COMMIT} -X github.com/flowexec/flow/cmd/internal/version.version=${VERSION} -X github.com/flowexec/flow/cmd/internal/version.buildDate=${BUILD_DATE}" \ + -ldflags="-s -w -X github.com/flowexec/flow/internal/version.gitCommit=${GIT_COMMIT} -X github.com/flowexec/flow/internal/version.version=${VERSION} -X github.com/flowexec/flow/internal/version.buildDate=${BUILD_DATE}" \ -o flow-arm64 # Create temporary Dockerfile that uses TARGETARCH for multi-arch builds @@ -180,7 +180,7 @@ executables: flag: workspace - envKey: GIT_REPO default: https://github.com/jahvon/flow.git - flag: repo + flag: gitrepo - envKey: BRANCH default: main flag: branch diff --git a/.goreleaser.yaml b/.goreleaser.yaml index ba2408b9..9c381071 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -21,9 +21,9 @@ builds: mod_timestamp: '{{ .CommitTimestamp }}' ldflags: >- -s -w - -X "github.com/flowexec/flow/cmd/internal/version.gitCommit={{ .Commit }}" - -X "github.com/flowexec/flow/cmd/internal/version.version={{ .Version }}" - -X "github.com/flowexec/flow/cmd/internal/version.buildDate={{ .Date }}" + -X "github.com/flowexec/flow/internal/version.gitCommit={{ .Commit }}" + -X "github.com/flowexec/flow/internal/version.version={{ .Version }}" + -X "github.com/flowexec/flow/internal/version.buildDate={{ .Date }}" gomod: proxy: false diff --git a/bin/golangci-lint b/bin/golangci-lint new file mode 100755 index 00000000..39d3e7a1 Binary files /dev/null and b/bin/golangci-lint differ diff --git a/cmd/internal/browse.go b/cmd/internal/browse.go index 27ebc982..811e1463 100644 --- a/cmd/internal/browse.go +++ b/cmd/internal/browse.go @@ -9,7 +9,6 @@ import ( "github.com/flowexec/flow/cmd/internal/flags" "github.com/flowexec/flow/internal/io" execIO "github.com/flowexec/flow/internal/io/executable" - "github.com/flowexec/flow/internal/io/library" "github.com/flowexec/flow/pkg/context" flowErrors "github.com/flowexec/flow/pkg/errors" "github.com/flowexec/flow/pkg/logger" @@ -127,9 +126,9 @@ func executableLibrary(ctx *context.Context, cmd *cobra.Command, _ []string) { } runFunc := func(ref string) error { return runByRef(ctx, cmd, ref) } - libraryModel := library.NewLibraryView( + libraryModel := execIO.NewLibraryView( ctx, allWs, allExecs, - library.Filter{ + execIO.Filter{ Workspace: wsFilter, Namespace: nsFilter, Verb: executable.Verb(verbFilter), @@ -137,7 +136,6 @@ func executableLibrary(ctx *context.Context, cmd *cobra.Command, _ []string) { Substring: subStr, Visibility: visibilityFilter, }, - logger.Theme(ctx.Config.Theme.String()), runFunc, ) SetView(ctx, cmd, libraryModel) diff --git a/cmd/internal/cache.go b/cmd/internal/cache.go index 5d73e2f9..c9816a61 100644 --- a/cmd/internal/cache.go +++ b/cmd/internal/cache.go @@ -90,7 +90,7 @@ func cacheSetFunc(ctx *context.Context, cmd *cobra.Command, args []string) { } defer func() { if err = s.Close(); err != nil { - logger.Log().Error(err, "cleanup failure") + logger.Log().WrapError(err, "cleanup failure") } }() if err = s.Set(key, value); err != nil { @@ -131,7 +131,7 @@ func cacheGetFunc(_ *context.Context, cmd *cobra.Command, args []string) { } defer func() { if err := s.Close(); err != nil { - logger.Log().Error(err, "cleanup failure") + logger.Log().WrapError(err, "cleanup failure") } }() value, err := s.Get(key) @@ -168,7 +168,7 @@ func cacheListFunc(ctx *context.Context, cmd *cobra.Command, _ []string) { } defer func() { if err := s.Close(); err != nil { - logger.Log().Error(err, "cleanup failure") + logger.Log().WrapError(err, "cleanup failure") } }() data, err := s.GetAll() @@ -216,7 +216,7 @@ func cacheRemoveFunc(_ *context.Context, cmd *cobra.Command, args []string) { } defer func() { if err := s.Close(); err != nil { - logger.Log().Error(err, "cleanup failure") + logger.Log().WrapError(err, "cleanup failure") } }() if err = s.Delete(key); err != nil { @@ -255,7 +255,7 @@ func cacheClearFunc(_ *context.Context, cmd *cobra.Command, _ []string) { } defer func() { if err := s.Close(); err != nil { - logger.Log().Error(err, "cleanup failure") + logger.Log().WrapError(err, "cleanup failure") } }() if err := s.DeleteBucket(store.EnvironmentBucket()); err != nil { diff --git a/cmd/internal/exec.go b/cmd/internal/exec.go index 383bdbd3..3397503c 100644 --- a/cmd/internal/exec.go +++ b/cmd/internal/exec.go @@ -194,7 +194,7 @@ func execFunc(ctx *context.Context, cmd *cobra.Command, verb executable.Verb, ar } _ = processStore.Close() } - logger.Log().Debugx(fmt.Sprintf("%s flow completed", ref), "Elapsed", dur.Round(time.Millisecond)) + logger.Log().Debug(fmt.Sprintf("%s flow completed", ref), "Elapsed", dur.Round(time.Millisecond)) if TUIEnabled(ctx, cmd) { if dur > 1*time.Minute && ctx.Config.SendSoundNotification() { _ = beeep.Beep(beeep.DefaultFreq, beeep.DefaultDuration) diff --git a/cmd/internal/helpers.go b/cmd/internal/helpers.go index b5763345..6f5eae5e 100644 --- a/cmd/internal/helpers.go +++ b/cmd/internal/helpers.go @@ -9,6 +9,7 @@ import ( "github.com/flowexec/flow/cmd/internal/flags" flowIO "github.com/flowexec/flow/internal/io" + "github.com/flowexec/flow/internal/version" "github.com/flowexec/flow/pkg/context" "github.com/flowexec/flow/pkg/filesystem" "github.com/flowexec/flow/pkg/logger" @@ -73,10 +74,10 @@ func TUIEnabled(ctx *context.Context, cmd *cobra.Command) bool { func SetView(ctx *context.Context, cmd *cobra.Command, view tuikit.View) { if TUIEnabled(ctx, cmd) { if err := ctx.SetView(view); err != nil { - logger.Log().Fatalx("unable to set view", "view", view.Type(), "error", err) + logger.Log().Fatal("unable to set view", "view", view.Type(), "error", err) } } else { - logger.Log().Errorx("interactive mode is disabled", "view", view.Type()) + logger.Log().Error("interactive mode is disabled", "view", view.Type()) } } @@ -99,7 +100,7 @@ func WaitForTUI(ctx *context.Context, cmd *cobra.Command) { func printContext(ctx *context.Context, cmd *cobra.Command) { if TUIEnabled(ctx, cmd) { logger.Log().Println(logger.Theme(ctx.Config.Theme.String()). - RenderHeader(context.AppName, context.HeaderCtxKey, ctx.String(), 0)) + RenderHeader(context.AppName, version.SemVer(), context.HeaderCtxKey, ctx.String(), 0)) } } @@ -116,7 +117,7 @@ func workspaceOrCurrent(ctx *context.Context, workspaceName string) *workspace.W var err error ws, err = filesystem.LoadWorkspaceConfig(workspaceName, wsPath) if err != nil { - logger.Log().Error(err, "unable to load workspace config") + logger.Log().WrapError(err, "unable to load workspace config") } ws.SetContext(workspaceName, wsPath) } @@ -143,7 +144,7 @@ func loadFlowfileTemplate(ctx *context.Context, name, path string) *executable.T } tmpl, err := filesystem.LoadFlowFileTemplate(name, path) if err != nil { - logger.Log().Error(err, "unable to load flowfile template") + logger.Log().WrapError(err, "unable to load flowfile template") return nil } return tmpl diff --git a/cmd/internal/logs.go b/cmd/internal/logs.go index d2e9a005..7531f2d0 100644 --- a/cmd/internal/logs.go +++ b/cmd/internal/logs.go @@ -4,7 +4,6 @@ import ( "fmt" tuikitIO "github.com/flowexec/tuikit/io" - "github.com/flowexec/tuikit/views" "github.com/spf13/cobra" "github.com/flowexec/flow/cmd/internal/flags" @@ -38,7 +37,7 @@ func logFunc(ctx *context.Context, cmd *cobra.Command, _ []string) { logger.Log().FatalErr(err) } if TUIEnabled(ctx, cmd) { - view := views.NewLogArchiveView(ctx.TUIContainer.RenderState(), filesystem.LogsDir(), lastEntry) + view := logs.NewLogView(ctx.TUIContainer, filesystem.LogsDir(), lastEntry) SetView(ctx, cmd, view) return } diff --git a/cmd/internal/secret.go b/cmd/internal/secret.go index 974e8d90..6fc45109 100644 --- a/cmd/internal/secret.go +++ b/cmd/internal/secret.go @@ -133,7 +133,7 @@ func setSecretFunc(ctx *context.Context, cmd *cobra.Command, args []string) { case len(args) == 2: value = args[1] default: - logger.Log().Warnx("merging multiple arguments into a single value", "count", len(args)) + logger.Log().Warn("merging multiple arguments into a single value", "count", len(args)) value = strings.Join(args[1:], " ") } @@ -239,7 +239,7 @@ func getSecretFunc(ctx *context.Context, cmd *cobra.Command, args []string) { } if copyValue { if err := clipboard.WriteAll(s.PlainTextString()); err != nil { - logger.Log().Error(err, "\nunable to copy secret value to clipboard") + logger.Log().WrapError(err, "\nunable to copy secret value to clipboard") } else { logger.Log().PlainTextSuccess("\ncopied secret value to clipboard") } diff --git a/cmd/internal/sync.go b/cmd/internal/sync.go index d33bbe0d..e61f655e 100644 --- a/cmd/internal/sync.go +++ b/cmd/internal/sync.go @@ -1,6 +1,9 @@ package internal import ( + "fmt" + "time" + "github.com/spf13/cobra" "github.com/flowexec/flow/pkg/cache" @@ -24,8 +27,10 @@ func RegisterSyncCmd(ctx *context.Context, rootCmd *cobra.Command) { } func syncFunc(_ *context.Context, _ *cobra.Command, _ []string) { + start := time.Now() if err := cache.UpdateAll(); err != nil { logger.Log().FatalErr(err) } - logger.Log().PlainTextSuccess("Synced flow cache") + duration := time.Since(start) + logger.Log().PlainTextSuccess(fmt.Sprintf("Synced flow cache (%s)", duration.Round(time.Second))) } diff --git a/cmd/internal/workspace.go b/cmd/internal/workspace.go index 74efdb6f..79258ebc 100644 --- a/cmd/internal/workspace.go +++ b/cmd/internal/workspace.go @@ -227,7 +227,7 @@ func listWorkspaceFunc(ctx *context.Context, cmd *cobra.Command, _ []string) { workspaceCache, err := ctx.WorkspacesCache.GetLatestData() if err != nil { - logger.Log().Fatalx("failure loading workspace configs from cache", "err", err) + logger.Log().Fatal("failure loading workspace configs from cache", "err", err) } filteredWorkspaces := make([]*workspace.Workspace, 0) diff --git a/cmd/root.go b/cmd/root.go index 4cd616eb..5d261036 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,7 +8,7 @@ import ( "github.com/flowexec/flow/cmd/internal" "github.com/flowexec/flow/cmd/internal/flags" - "github.com/flowexec/flow/cmd/internal/version" + "github.com/flowexec/flow/internal/version" "github.com/flowexec/flow/pkg/cache" "github.com/flowexec/flow/pkg/context" "github.com/flowexec/flow/pkg/logger" diff --git a/go.mod b/go.mod index ea3ab381..90b9b5cb 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,18 @@ module github.com/flowexec/flow -go 1.25 +go 1.25.8 require ( + charm.land/bubbletea/v2 v2.0.2 + charm.land/lipgloss/v2 v2.0.2 github.com/atotto/clipboard v0.1.4 - github.com/charmbracelet/bubbles v1.0.0 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 - github.com/charmbracelet/x/exp/teatest v0.0.0-20250806222409-83e3a29d542f - github.com/flowexec/tuikit v0.2.3 + github.com/charmbracelet/colorprofile v0.4.3 + github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260406091427-a791e22d5143 + github.com/flowexec/tuikit v0.3.1 github.com/flowexec/vault v0.2.1 github.com/gen2brain/beeep v0.11.2 github.com/jahvon/expression v0.1.4 - github.com/jahvon/glamour v0.8.1-patch3 github.com/mark3labs/mcp-go v0.43.2 - github.com/mattn/go-runewidth v0.0.20 - github.com/muesli/termenv v0.16.0 github.com/onsi/ginkgo/v2 v2.27.3 github.com/onsi/gomega v1.38.3 github.com/otiai10/copy v1.14.1 @@ -32,32 +29,34 @@ require ( require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect + charm.land/bubbles/v2 v2.1.0 // indirect + charm.land/glamour/v2 v2.0.0 // indirect + charm.land/huh/v2 v2.0.3 // indirect + charm.land/log/v2 v2.0.0 // indirect filippo.io/age v1.2.1 // indirect git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/aymanbagabas/go-udiff v0.3.1 // indirect + github.com/aymanbagabas/go-udiff v0.4.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/huh v0.7.0 // indirect - github.com/charmbracelet/log v0.4.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20250806222409-83e3a29d542f // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/esiqveland/notify v0.13.3 // indirect github.com/expr-lang/expr v1.17.7 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect @@ -74,11 +73,9 @@ require ( github.com/jackmordaunt/icns/v3 v3.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect @@ -100,8 +97,8 @@ require ( golang.org/x/crypto v0.45.0 // indirect golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/tools v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index e18346ef..cc63543c 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,18 @@ al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXy al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= +charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= +charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X1exT94mmJRdzTNs+s= +charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0= filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE= @@ -18,10 +30,8 @@ github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= @@ -30,44 +40,38 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= -github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc= -github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= -github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +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-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= -github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= -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/exp/golden v0.0.0-20251109135125-8916d276318f h1:8CnFOYzrMArVN42jYaGvnBo3mxdONgt09fly+9B96GY= +github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f/go.mod h1:V8n/g3qVKNxr2FR37Y+otCsMySvZr601T0C7coEP0bw= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/exp/strings v0.0.0-20250806222409-83e3a29d542f h1:kMvEqh377X/NPzcGeXUY4FwZRdRHrIddB2a4Bg/bN68= github.com/charmbracelet/x/exp/strings v0.0.0-20250806222409-83e3a29d542f/go.mod h1:Rgw3/F+xlcUc5XygUtimVSxAqCOsqyvJjqF5UHRvc5k= -github.com/charmbracelet/x/exp/teatest v0.0.0-20250806222409-83e3a29d542f h1:VBb5vbTgXYhG9inCJGCicF8+C1P05MbOKbTnWHfuiRw= -github.com/charmbracelet/x/exp/teatest v0.0.0-20250806222409-83e3a29d542f/go.mod h1:RXbDhep1qKL/SEz2IuOhOUrsNHDKGqRmGks1nZStKyU= +github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260406091427-a791e22d5143 h1:jY/PutKuygZ7g9tn1RDRADSbWw4U/6IV2LCDE83SWTY= +github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260406091427-a791e22d5143/go.mod h1:aRoQwQWmN9LBG2xi3sVByMFt2fdkPCagd0GAJ1qwOfw= 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/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= -github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +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/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -82,14 +86,12 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o= github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE= github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8= github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= -github.com/flowexec/tuikit v0.2.3 h1:hGlBc8yXvj4AXaKFp+IUNQ9nO7xOYY4W99m1BfNT13Q= -github.com/flowexec/tuikit v0.2.3/go.mod h1:fjMwEM7FkxbP7bIV4CfEjsixgjicgQqPrejoBZAHf5s= +github.com/flowexec/tuikit v0.3.1 h1:66/x19WE1alZGbN2pwstIQza5suBnvJqYNevV4GrSYQ= +github.com/flowexec/tuikit v0.3.1/go.mod h1:ZD+hSmN6Ls/mXAqEOhIhmfXXiR32u/IFys3KafOajpQ= github.com/flowexec/vault v0.2.1 h1:IYII6iXhhzUc4o0arJVH8281so67L9V8HY8ary/kTps= github.com/flowexec/vault v0.2.1/go.mod h1:6JHONK+fTf8Zn7bOwejzbKTWuIh1BYHxgAwBc/XPXeY= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -136,8 +138,6 @@ github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8 github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= github.com/jahvon/expression v0.1.4 h1:4q/jvM5G2mBJDqXtTUDThtJ4Sfajx+vIhUf4r6EAy6A= github.com/jahvon/expression v0.1.4/go.mod h1:4HJB2k+epW5vFeptF6ILlXbFRQ+CuCyCSO4QdnGT3AE= -github.com/jahvon/glamour v0.8.1-patch3 h1:LfyMACZavV8yxK4UsPENNQQOqafWuq4ZdLuEAP2ZLE8= -github.com/jahvon/glamour v0.8.1-patch3/go.mod h1:30MVJwG3rcEHrN277NrA4DKzndSL9lBtEmpcfOygwCQ= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -154,27 +154,19 @@ github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= -github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8= @@ -258,13 +250,11 @@ golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= diff --git a/internal/fileparser/fileparser.go b/internal/fileparser/fileparser.go index b63b494f..af591586 100644 --- a/internal/fileparser/fileparser.go +++ b/internal/fileparser/fileparser.go @@ -27,10 +27,10 @@ func ExecutablesFromImports( expandedFile := utils.ExpandPath(file, filepath.Dir(flowFilePath), nil) if info, err := os.Stat(expandedFile); err != nil { - logger.Log().Error(err, fmt.Sprintf("unable to import executables from file %s", file)) + logger.Log().WrapError(err, fmt.Sprintf("unable to import executables from file %s", file)) continue } else if info.IsDir() { - logger.Log().Errorx("unable to import executables", "err", fmt.Sprintf("%s is not a file", file)) + logger.Log().Error("unable to import executables", "err", fmt.Sprintf("%s is not a file", file)) continue } @@ -38,7 +38,7 @@ func ExecutablesFromImports( case "package.json": execs, err := ExecutablesFromPackageJSON(wsPath, expandedFile) if err != nil { - logger.Log().Error(err, fmt.Sprintf("unable to import executables from file (%s)", file)) + logger.Log().WrapError(err, fmt.Sprintf("unable to import executables from file (%s)", file)) } for _, exec := range execs { exec.SetContext(wsName, wsPath, flowFileNs, flowFilePath) @@ -48,7 +48,7 @@ func ExecutablesFromImports( case "makefile": execs, err := ExecutablesFromMakefile(wsPath, expandedFile) if err != nil { - logger.Log().Error(err, fmt.Sprintf("unable to import executables from file (%s)", file)) + logger.Log().WrapError(err, fmt.Sprintf("unable to import executables from file (%s)", file)) } for _, exec := range execs { exec.SetContext(wsName, wsPath, flowFileNs, flowFilePath) @@ -58,7 +58,7 @@ func ExecutablesFromImports( case "docker-compose.yml", "docker-compose.yaml": execs, err := ExecutablesFromDockerCompose(wsPath, expandedFile) if err != nil { - logger.Log().Error(err, fmt.Sprintf("unable to import executables from file (%s)", file)) + logger.Log().WrapError(err, fmt.Sprintf("unable to import executables from file (%s)", file)) } for _, exec := range execs { exec.SetContext(wsName, wsPath, flowFileNs, flowFilePath) @@ -68,12 +68,12 @@ func ExecutablesFromImports( default: ext := filepath.Ext(fn) if ext != ".sh" { - logger.Log().Warnx("unable to import executables - unsupported file type", "file", file) + logger.Log().Warn("unable to import executables - unsupported file type", "file", file) continue } exec, err := ExecutablesFromShFile(wsPath, expandedFile) if err != nil { - logger.Log().Error(err, fmt.Sprintf("unable to import executables from file (%s)", file)) + logger.Log().WrapError(err, fmt.Sprintf("unable to import executables from file (%s)", file)) continue } exec.SetContext(wsName, wsPath, flowFileNs, flowFilePath) diff --git a/internal/fileparser/fileparser_test.go b/internal/fileparser/fileparser_test.go index 71b054bf..b021d455 100644 --- a/internal/fileparser/fileparser_test.go +++ b/internal/fileparser/fileparser_test.go @@ -60,7 +60,7 @@ var _ = Describe("ExecutablesFromImports", func() { }) It("should log a warning for invalid file type", func() { - mockLogger.EXPECT().Warnx(gomock.Any(), "file", "invalidfile").AnyTimes() + mockLogger.EXPECT().Warn(gomock.Any(), "file", "invalidfile").AnyTimes() flowFile.Imports = append(flowFile.Imports, "invalidfile") result, err := fileparser.ExecutablesFromImports("ws", flowFile) Expect(err).NotTo(HaveOccurred()) @@ -68,7 +68,7 @@ var _ = Describe("ExecutablesFromImports", func() { }) It("should log an error for dir instead of file", func() { - mockLogger.EXPECT().Errorx(gomock.Any(), "err", "invaliddir is not a file").AnyTimes() + mockLogger.EXPECT().Error(gomock.Any(), "err", "invaliddir is not a file").AnyTimes() flowFile.Imports = append(flowFile.Imports, "invaliddir") result, err := fileparser.ExecutablesFromImports("ws", flowFile) Expect(err).NotTo(HaveOccurred()) @@ -76,7 +76,7 @@ var _ = Describe("ExecutablesFromImports", func() { }) It("should log an error for non-existent file", func() { - mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().WrapError(gomock.Any(), gomock.Any()).AnyTimes() flowFile.Imports = append(flowFile.Imports, "nonexistent.sh") result, err := fileparser.ExecutablesFromImports("ws", flowFile) Expect(err).NotTo(HaveOccurred()) @@ -84,7 +84,7 @@ var _ = Describe("ExecutablesFromImports", func() { }) It("should log an error when configuration key is not recognized", func() { - mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().WrapError(gomock.Any(), gomock.Any()).AnyTimes() flowFile.Imports = append(flowFile.Imports, "unknownkey.sh") result, err := fileparser.ExecutablesFromImports("ws", flowFile) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/io/cache/views.go b/internal/io/cache/views.go index dff38937..f2ab6df5 100644 --- a/internal/io/cache/views.go +++ b/internal/io/cache/views.go @@ -2,6 +2,8 @@ package cache import ( "encoding/json" + "fmt" + "sort" "github.com/flowexec/tuikit" "github.com/flowexec/tuikit/types" @@ -53,6 +55,19 @@ func NewCacheListView( container *tuikit.Container, cache map[string]string, ) tuikit.View { - data := &cacheData{Cache: cache} - return views.NewCollectionView(container.RenderState(), data, types.CollectionFormatList, nil) + keys := make([]string, 0, len(cache)) + for k := range cache { + keys = append(keys, k) + } + sort.Strings(keys) + + columns := []views.TableColumn{ + {Title: fmt.Sprintf("Entries (%d)", len(keys)), Percentage: 40}, + {Title: "Value", Percentage: 60}, + } + rows := make([]views.TableRow, 0, len(keys)) + for _, k := range keys { + rows = append(rows, views.TableRow{Data: []string{k, cache[k]}}) + } + return views.NewTable(container.RenderState(), columns, rows, views.TableDisplayMini) } diff --git a/internal/io/common/clipboard.go b/internal/io/common/clipboard.go new file mode 100644 index 00000000..f9a57bb0 --- /dev/null +++ b/internal/io/common/clipboard.go @@ -0,0 +1,20 @@ +package common + +import ( + "github.com/atotto/clipboard" + "github.com/flowexec/tuikit" + "github.com/flowexec/tuikit/themes" +) + +// CopyToClipboard writes text to the system clipboard in a goroutine +// to avoid blocking the bubbletea event loop (pbcopy/xclip can hang +// under raw terminal mode). Shows a toast notice on success or failure. +func CopyToClipboard(container *tuikit.Container, text, successMsg string) { + go func() { + if err := clipboard.WriteAll(text); err != nil { + container.SetNotice("unable to copy to clipboard", themes.OutputLevelError) + } else { + container.SetNotice(successMsg, themes.OutputLevelSuccess) + } + }() +} diff --git a/internal/io/common/common.go b/internal/io/common/common.go index 0bbf73d8..de017be2 100644 --- a/internal/io/common/common.go +++ b/internal/io/common/common.go @@ -22,7 +22,7 @@ func OpenInEditor(path string, stdIn, stdOut *os.File) error { if preferred == "" { preferred = "vim" } - cmd := exec.Command(preferred, path) // #nosec G204 + cmd := exec.Command(preferred, path) // #nosec G204,G702 cmd.Stdin = stdIn cmd.Stdout = stdOut return cmd.Run() diff --git a/internal/io/common/paths.go b/internal/io/common/paths.go new file mode 100644 index 00000000..7ec3c52e --- /dev/null +++ b/internal/io/common/paths.go @@ -0,0 +1,16 @@ +package common + +import ( + "path/filepath" + "strings" +) + +// ShortenPath returns the last two directory components of a path prefixed +// with "…/". If the path has two or fewer components, it is returned as-is. +func ShortenPath(p string) string { + parts := strings.Split(filepath.ToSlash(p), "/") + if len(parts) <= 2 { + return p + } + return "…/" + strings.Join(parts[len(parts)-2:], "/") +} diff --git a/internal/io/common/tags.go b/internal/io/common/tags.go new file mode 100644 index 00000000..0fcef04c --- /dev/null +++ b/internal/io/common/tags.go @@ -0,0 +1,75 @@ +package common + +import ( + "fmt" + "hash/fnv" + "image/color" + "math" + "slices" + "strings" + + "charm.land/lipgloss/v2" +) + +// TagColor derives a deterministic color from the tag text using FNV hashing +// mapped to the HSL hue wheel with fixed saturation/lightness. +func TagColor(tag string) color.Color { + h := fnv.New32a() + h.Write([]byte(tag)) + hue := float64(h.Sum32()%360) / 360.0 + r, g, b := hslToRGB(hue, 0.55, 0.65) + return lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", r, g, b)) +} + +// ColorizeTags sorts tags and renders each as a colored pill badge. +func ColorizeTags(tags []string) string { + if len(tags) == 0 { + return "" + } + sorted := make([]string, len(tags)) + copy(sorted, tags) + slices.Sort(sorted) + parts := make([]string, len(sorted)) + for i, t := range sorted { + c := TagColor(t) + parts[i] = lipgloss.NewStyle().Foreground(c).Render(t) + } + return strings.Join(parts, ", ") +} + +func hslToRGB(h, s, l float64) (uint8, uint8, uint8) { + if s == 0 { + v := uint8(math.Round(l * 255)) + return v, v, v + } + var q float64 + if l < 0.5 { + q = l * (1 + s) + } else { + q = l + s - l*s + } + p := 2*l - q + r := hueToRGB(p, q, h+1.0/3.0) + g := hueToRGB(p, q, h) + b := hueToRGB(p, q, h-1.0/3.0) + return uint8(math.Round(r * 255)), uint8(math.Round(g * 255)), uint8(math.Round(b * 255)) +} + +func hueToRGB(p, q, t float64) float64 { + if t < 0 { + t++ + } + if t > 1 { + t-- + } + switch { + case t < 1.0/6.0: + return p + (q-p)*6*t + case t < 1.0/2.0: + return q + case t < 2.0/3.0: + return p + (q-p)*(2.0/3.0-t)*6 + default: + return p + } +} diff --git a/internal/io/executable/detail.go b/internal/io/executable/detail.go new file mode 100644 index 00000000..8dd682f2 --- /dev/null +++ b/internal/io/executable/detail.go @@ -0,0 +1,449 @@ +package executable + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/flowexec/tuikit/views" + + "github.com/flowexec/flow/internal/io/common" + "github.com/flowexec/flow/types/executable" +) + +const mdArgsHeader = " - **Arguments**\n" + +func executableDetailOpts(exec *executable.Executable) views.DetailContentOpts { + return views.DetailContentOpts{ + Title: exec.Ref().String(), + Subtitle: execTypeName(exec), + Tags: common.ColorizeTags(exec.Tags), + Metadata: execMetadataFields(exec), + Body: execBodyMarkdown(exec), + Footer: fmt.Sprintf("_Located in %s_", exec.FlowFilePath()), + Entity: exec, + } +} + +func execTypeName(exec *executable.Executable) string { + switch { + case exec.Exec != nil: + return "Shell Executable" + case exec.Launch != nil: + return "Launch Executable" + case exec.Request != nil: + return "Request Executable" + case exec.Render != nil: + return "Render Executable" + case exec.Serial != nil: + return "Serial Executable" + case exec.Parallel != nil: + return "Parallel Executable" + default: + return "Executable" + } +} + +func execMetadataFields(exec *executable.Executable) []views.DetailField { + const maxFields = 2 + + // Collect all candidate fields in priority order: + // 1. Visibility (always present) + // 2. Aliases (if set) + // 3. Verb Aliases (if set) + // 4. Executed From / dir (if set) + // 5. Timeout (if set) + var candidates []views.DetailField + + visibility := "private" + if exec.Visibility != nil { + visibility = string(*exec.Visibility) + } + candidates = append(candidates, views.DetailField{Key: "Visibility", Value: visibility}) + + if len(exec.Aliases) > 0 { + candidates = append(candidates, views.DetailField{Key: "Aliases", Value: strings.Join(exec.Aliases, ", ")}) + } + if len(exec.VerbAliases) > 0 { + verbs := make([]string, len(exec.VerbAliases)) + for i, v := range exec.VerbAliases { + verbs[i] = string(v) + } + candidates = append(candidates, views.DetailField{Key: "Verb Aliases", Value: strings.Join(verbs, ", ")}) + } + if dir := execDir(exec); dir != "" { + candidates = append(candidates, views.DetailField{Key: "Executed From", Value: dir}) + } + if exec.Timeout != nil { + candidates = append(candidates, views.DetailField{Key: "Timeout", Value: exec.Timeout.String()}) + } + + if len(candidates) > maxFields { + candidates = candidates[:maxFields] + } + return candidates +} + +// execDir returns the working directory for exec/render types, empty otherwise. +func execDir(exec *executable.Executable) string { + switch { + case exec.Exec != nil && exec.Exec.Dir != "": + return string(exec.Exec.Dir) + case exec.Render != nil && exec.Render.Dir != "": + return string(exec.Render.Dir) + default: + return "" + } +} + +func execBodyMarkdown(exec *executable.Executable) string { + var sections []string + + // Description + if desc := execDescription(exec); desc != "" { + sections = append(sections, desc) + } + + // Type-specific configuration + if cfg := execTypeConfig(exec); cfg != "" { + sections = append(sections, cfg) + } + + return strings.Join(sections, "\n\n") +} + +func execDescription(exec *executable.Executable) string { + var parts []string + if d := strings.TrimSpace(exec.Description); d != "" { + parts = append(parts, d) + } + return strings.Join(parts, "\n\n") +} + +func execTypeConfig(spec *executable.Executable) string { + switch { + case spec.Exec != nil: + return shellExecConfig(spec.Env(), spec.Exec) + case spec.Launch != nil: + return launchExecConfig(spec.Env(), spec.Launch) + case spec.Request != nil: + return requestExecConfig(spec.Env(), spec.Request) + case spec.Render != nil: + return renderExecConfig(spec.Env(), spec.Render) + case spec.Serial != nil: + return serialExecConfig(spec.Env(), spec.Serial) + case spec.Parallel != nil: + return parallelExecConfig(spec.Env(), spec.Parallel) + default: + return "" + } +} + +func shellExecConfig(e *executable.ExecutableEnvironment, s *executable.ExecExecutableType) string { + if s == nil { + return "" + } + md := "## Shell Configuration\n" + if s.LogMode != "" { + md += fmt.Sprintf("**Log Mode:** %s\n\n", s.LogMode) + } + if s.Cmd != "" { + md += fmt.Sprintf("**Command**\n```sh\n%s\n```\n", s.Cmd) + } else if s.File != "" { + md += fmt.Sprintf("**File:** `%s`\n\n", s.File) + } + md += envTable(e) + return md +} + +func launchExecConfig(e *executable.ExecutableEnvironment, l *executable.LaunchExecutableType) string { + if l == nil { + return "" + } + md := "## Launch Configuration\n" + if l.App != "" { + md += fmt.Sprintf("**App:** `%s`\n\n", l.App) + } + if l.URI != "" { + md += fmt.Sprintf("**URI:** [%s](%s)\n\n", l.URI, l.URI) + } + md += envTable(e) + return md +} + +func requestExecConfig(e *executable.ExecutableEnvironment, r *executable.RequestExecutableType) string { + if r == nil { + return "" + } + md := "## Request Configuration\n" + md += fmt.Sprintf("**Method:** %s\n\n", r.Method) + md += fmt.Sprintf("**URL:** [%s](%s)\n\n", r.URL, r.URL) + + if r.Timeout != 0 { + md += fmt.Sprintf("**Request Timeout:** %s\n\n", r.Timeout) + } + if r.LogResponse { + md += "**Log Response:** enabled\n\n" + } + if r.Body != "" { + md += fmt.Sprintf("**Body:**\n```\n%s\n```\n", r.Body) + } + if len(r.Headers) > 0 { + md += "\n**Headers**\n" + for k, v := range r.Headers { + md += fmt.Sprintf("- %s: %s\n", k, v) + } + md += "\n" + } + if len(r.ValidStatusCodes) > 0 { + md += "**Accepted Status Codes**\n" + for _, code := range r.ValidStatusCodes { + md += fmt.Sprintf("- %d\n", code) + } + md += "\n" + } + if r.ResponseFile != nil { + md += fmt.Sprintf("**Response Saved To:** %s\n\n", r.ResponseFile.Filename) + if r.ResponseFile.SaveAs != "" { + md += fmt.Sprintf("**Response Saved As:** %s\n\n", r.ResponseFile.SaveAs) + } + } + if r.TransformResponse != "" { + md += fmt.Sprintf("**Transformation Expression:**\n```\n%s\n```\n", r.TransformResponse) + } + md += envTable(e) + return md +} + +func renderExecConfig(e *executable.ExecutableEnvironment, r *executable.RenderExecutableType) string { + if r == nil { + return "" + } + md := "## Render Configuration\n" + if r.TemplateFile != "" { + md += fmt.Sprintf("**Template File:** `%s`\n\n", r.TemplateFile) + } + if r.TemplateDataFile != "" { + md += fmt.Sprintf("**Template Store File:** `%s`\n\n", r.TemplateDataFile) + } + md += envTable(e) + return md +} + +func serialExecConfig(e *executable.ExecutableEnvironment, s *executable.SerialExecutableType) string { + if s == nil { + return "" + } + md := "## Serial Configuration\n" + if s.FailFast != nil && *s.FailFast { + md += "**Fail Fast:** enabled\n\n" + } else if s.FailFast != nil && !*s.FailFast { + md += "**Fail Fast:** disabled\n\n" + } + md += "**Executables**\n" + for i, refCfg := range s.Execs { + if refCfg.Ref != "" { + md += fmt.Sprintf("%d. ref: %s\n", i+1, refCfg.Ref) + } else if refCfg.Cmd != "" { + md += fmt.Sprintf("%d. cmd:\n```sh\n%s\n```\n", i+1, refCfg.Cmd) + } + if refCfg.Retries > 0 { + md += fmt.Sprintf(" - **Retries:** %d\n", refCfg.Retries) + } + if refCfg.ReviewRequired { + md += fmt.Sprintf(" - **Review Required:** %v\n", refCfg.ReviewRequired) + } + if len(refCfg.Args) > 0 { + md += mdArgsHeader + for _, arg := range refCfg.Args { + md += fmt.Sprintf(" - %s\n", arg) + } + } + } + md += envTable(e) + return md +} + +func parallelExecConfig(e *executable.ExecutableEnvironment, p *executable.ParallelExecutableType) string { + if p == nil { + return "" + } + md := "## Parallel Configuration\n" + if p.MaxThreads > 0 { + md += fmt.Sprintf("**Max Threads:** %d\n\n", p.MaxThreads) + } + if p.FailFast != nil && *p.FailFast { + md += "**Fail Fast:** enabled\n\n" + } else if p.FailFast != nil && !*p.FailFast { + md += "**Fail Fast:** disabled\n\n" + } + md += "**Executables**\n" + for i, refCfg := range p.Execs { + if refCfg.Ref != "" { + md += fmt.Sprintf("%d. ref: %s\n", i+1, refCfg.Ref) + } else if refCfg.Cmd != "" { + md += fmt.Sprintf("%d. cmd:\n```sh\n%s\n```\n", i+1, refCfg.Cmd) + } + if refCfg.Retries > 0 { + md += fmt.Sprintf(" - **Retries:** %d\n", refCfg.Retries) + } + if len(refCfg.Args) > 0 { + md += mdArgsHeader + for _, arg := range refCfg.Args { + md += fmt.Sprintf(" - %s\n", arg) + } + } + } + md += envTable(e) + return md +} + +func envTable(env *executable.ExecutableEnvironment) string { + if env == nil { + return "" + } + var table string + if len(env.Params) > 0 { + table += "\n### Parameters\n" + table += "| Env Key | Type | Value |\n| --- | --- | --- |\n" + for _, p := range env.Params { + var valueType, valueInput string + switch { + case p.Text != "": + valueType = "text" + valueInput = p.Text + case p.SecretRef != "": + valueType = "secret" + valueInput = p.SecretRef + case p.Prompt != "": + valueType = "prompt" + valueInput = p.Prompt + } + table += fmt.Sprintf("| `%s` | %s | %s |\n", p.EnvKey, valueType, valueInput) + } + } + + if len(env.Args) > 0 { + table += "\n### Arguments\n" + table += "| Env Key | Arg Type | Input Type | Default | Required |\n| --- | --- | --- | --- | --- |\n" + for _, a := range env.Args { + var argType string + switch { + case a.Pos != nil && *a.Pos > 0: + argType = "positional" + case a.Flag != "": + argType = "flag" + } + table += fmt.Sprintf( + "| `%s` | %s | %s | %s | %t |\n", + a.EnvKey, argType, a.Type, a.Default, a.Required, + ) + } + } + return table +} + +// templateDetailOpts builds detail view options for a template. +func templateDetailOpts(t *executable.Template) views.DetailContentOpts { + return views.DetailContentOpts{ + Title: t.Name(), + Subtitle: "Template", + Metadata: templateMetadataFields(t), + Body: templateBodyMarkdown(t), + Footer: fmt.Sprintf("_Located in %s_", t.Location()), + Entity: t, + } +} + +func templateMetadataFields(t *executable.Template) []views.DetailField { + const maxFields = 2 + var candidates []views.DetailField + + if len(t.Form) > 0 { + candidates = append(candidates, views.DetailField{Key: "Form Fields", Value: fmt.Sprintf("%d", len(t.Form))}) + } + if len(t.Artifacts) > 0 { + candidates = append(candidates, views.DetailField{Key: "Artifacts", Value: fmt.Sprintf("%d", len(t.Artifacts))}) + } + if len(t.PreRun) > 0 || len(t.PostRun) > 0 { + steps := len(t.PreRun) + len(t.PostRun) + candidates = append(candidates, views.DetailField{Key: "Run Steps", Value: fmt.Sprintf("%d", steps)}) + } + + if len(candidates) > maxFields { + candidates = candidates[:maxFields] + } + return candidates +} + +func templateBodyMarkdown(t *executable.Template) string { + var sections []string + + if form := templateFormMarkdown(t); form != "" { + sections = append(sections, form) + } + if artifacts := templateArtifactsMarkdown(t); artifacts != "" { + sections = append(sections, artifacts) + } + if len(t.PreRun) > 0 { + sections = append(sections, execRefsMarkdown("Pre-Run", t.PreRun)) + } + if len(t.PostRun) > 0 { + sections = append(sections, execRefsMarkdown("Post-Run", t.PostRun)) + } + sections = append(sections, fmt.Sprintf("## Flow File\n```yaml\n%s\n```", t.Template)) + + return strings.Join(sections, "\n\n") +} + +func templateFormMarkdown(t *executable.Template) string { + if len(t.Form) == 0 { + return "" + } + md := "## Form Fields\n" + md += "| Field | Prompt | Description | Default | Required |\n" + md += "| --- | --- | --- | --- | --- |\n" + for _, f := range t.Form { + md += fmt.Sprintf("| %s | %s | %s | %s | %t |\n", + f.Key, f.Prompt, f.Description, f.Default, f.Required) + } + return md +} + +func templateArtifactsMarkdown(t *executable.Template) string { + if len(t.Artifacts) == 0 { + return "" + } + md := "## Artifacts\n" + for _, a := range t.Artifacts { + md += fmt.Sprintf("- Source: `%s`\n", filepath.Join(a.SrcDir, a.SrcName)) + if a.DstDir != "" { + md += fmt.Sprintf(" Destination: `%s`\n", filepath.Join(a.DstDir, a.DstName)) + } else if a.DstName != "" { + md += fmt.Sprintf(" Destination: `%s`\n", a.DstName) + } + if a.If != "" { + md += fmt.Sprintf(" Conditional: `%s`\n", a.If) + } + md += fmt.Sprintf(" Rendered as template: %t\n", a.AsTemplate) + } + return md +} + +func execRefsMarkdown(title string, refs []executable.TemplateRefConfig) string { + md := fmt.Sprintf("## %s\n", title) + for i, e := range refs { + if e.Ref != "" { + md += fmt.Sprintf("%d. ref: %s\n", i+1, e.Ref) + } else if e.Cmd != "" { + md += fmt.Sprintf("%d. cmd:\n```sh\n%s\n```\n", i+1, e.Cmd) + } + if len(e.Args) > 0 { + md += mdArgsHeader + for _, arg := range e.Args { + md += fmt.Sprintf(" - %s\n", arg) + } + } + } + return md +} diff --git a/internal/io/executable/library.go b/internal/io/executable/library.go new file mode 100644 index 00000000..ed14576a --- /dev/null +++ b/internal/io/executable/library.go @@ -0,0 +1,493 @@ +package executable + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "sort" + + tea "charm.land/bubbletea/v2" + "github.com/flowexec/tuikit" + "github.com/flowexec/tuikit/themes" + "github.com/flowexec/tuikit/types" + "github.com/flowexec/tuikit/views" + + "github.com/flowexec/flow/internal/io/common" + "github.com/flowexec/flow/internal/services/open" + "github.com/flowexec/flow/pkg/context" + "github.com/flowexec/flow/pkg/filesystem" + "github.com/flowexec/flow/pkg/logger" + flowCommon "github.com/flowexec/flow/types/common" + "github.com/flowexec/flow/types/executable" + "github.com/flowexec/flow/types/workspace" +) + +// Filter narrows the executables shown by the library view. +type Filter struct { + Workspace, Namespace string + Verb executable.Verb + Tags flowCommon.Tags + Substring string + Visibility flowCommon.Visibility +} + +// Sentinel labels rendered in the workspace table for top-level entries +// and the implicit "no namespace" child row. +const ( + allWorkspacesLabel = "All Workspaces" + rootNamespaceLabel = "Root Namespace" +) + +// Hidden cells appended to TableRow.Data so the executable page can +// recover the workspace/namespace context that the user selected. The +// table only renders cells that map to a configured column, so cells +// past the column count are silently dropped from display while still +// being returned by Selectable.SelectedData(). +const ( + wsRowCellKind = 4 // "all" | "workspace" | "namespace" + wsRowCellWsName = 5 // workspace name (for namespace children) + wsRowCellNsName = 6 // namespace name ("" for root namespace) + wsRowKindAll = "all" + wsRowKindWS = "workspace" + wsRowKindNS = "namespace" +) + +// NewLibraryView builds the multi-page library browser. Pages drill down +// from workspaces -> executables -> executable details. +func NewLibraryView( + ctx *context.Context, + workspaces workspace.WorkspaceList, + execs executable.ExecutableList, + filter Filter, + runFunc func(string) error, +) tuikit.View { + container := ctx.TUIContainer + return views.NewLibrary( + container.RenderState(), + workspacesPage(ctx, workspaces, execs, filter, runFunc), + executablesPage(ctx, execs, filter, runFunc), + executableDetailPage(ctx, execs, filter, runFunc), + ) +} + +// applyFilter returns the executables visible after applying the user's +// filter (workspace, namespace, verb, tags, substring, visibility). +func applyFilter(execs executable.ExecutableList, filter Filter) executable.ExecutableList { + visibility := filter.Visibility + if visibility == "" { + visibility = flowCommon.VisibilityPrivate + } + return execs. + FilterByWorkspaceWithVisibility(filter.Workspace, visibility). + FilterByNamespace(filter.Namespace). + FilterByVerb(filter.Verb). + FilterByTags(filter.Tags). + FilterBySubstring(filter.Substring) +} + +// workspaceList returns the workspaces matching the filter, sorted by +// assigned name. When no filter is set, all workspaces are returned. +func workspaceList(workspaces workspace.WorkspaceList, filter Filter) workspace.WorkspaceList { + if filter.Workspace == "" || filter.Workspace == executable.WildcardWorkspace { + out := append(workspace.WorkspaceList{}, workspaces...) + sort.Slice(out, func(i, j int) bool { + return out[i].AssignedName() < out[j].AssignedName() + }) + return out + } + for _, ws := range workspaces { + if ws.AssignedName() == filter.Workspace { + return workspace.WorkspaceList{ws} + } + } + return workspace.WorkspaceList{} +} + +// namespacesForWorkspace returns the namespaces (and whether a root +// namespace exists) for the given workspace, after applying the +// non-namespace parts of the user's filter. Passing an empty wsName +// returns the union across all workspaces. +func namespacesForWorkspace( + execs executable.ExecutableList, + wsName string, + filter Filter, +) (hasRoot bool, namespaces []string) { + wsFilter := filter + wsFilter.Workspace = wsName + wsFilter.Namespace = executable.WildcardNamespace + visible := applyFilter(execs, wsFilter) + nsSet := map[string]struct{}{} + for _, ex := range visible { + ns := ex.Namespace() + if ns == "" || ns == executable.WildcardNamespace { + hasRoot = true + continue + } + nsSet[ns] = struct{}{} + } + for ns := range nsSet { + namespaces = append(namespaces, ns) + } + sort.Strings(namespaces) + return hasRoot, namespaces +} + +// namespaceChildren builds the table child rows for a workspace's +// namespaces. wsName is the parent workspace ("" for "All Workspaces"). +func namespaceChildren(execs executable.ExecutableList, wsName string, filter Filter) []views.TableRow { + hasRoot, namespaces := namespacesForWorkspace(execs, wsName, filter) + children := make([]views.TableRow, 0, len(namespaces)+1) + if hasRoot { + nsFilter := filter + nsFilter.Workspace = wsName + nsFilter.Namespace = "" + count := len(applyFilter(execs, nsFilter)) + children = append(children, views.TableRow{ + Data: padRow([]string{rootNamespaceLabel, fmt.Sprintf("%d", count), "", ""}, wsRowKindNS, wsName, ""), + }) + } + for _, ns := range namespaces { + nsFilter := filter + nsFilter.Workspace = wsName + nsFilter.Namespace = ns + count := len(applyFilter(execs, nsFilter)) + children = append(children, views.TableRow{ + Data: padRow([]string{ns, fmt.Sprintf("%d", count), "", ""}, wsRowKindNS, wsName, ns), + }) + } + return children +} + +func workspacePageRows( + wsList workspace.WorkspaceList, + execs executable.ExecutableList, + filter Filter, +) []views.TableRow { + allCount := len(applyFilter(execs, withWorkspace(filter, ""))) + rows := []views.TableRow{ + { + Data: padRow([]string{ + allWorkspacesLabel, + fmt.Sprintf("%d", allCount), + "", + "", + }, wsRowKindAll, "", ""), + Children: namespaceChildren(execs, "", filter), + }, + } + + for _, ws := range wsList { + count := len(applyFilter(execs, withWorkspace(filter, ws.AssignedName()))) + tags := common.ColorizeTags(ws.Tags) + name := ws.AssignedName() + if ws.DisplayName != "" { + name = ws.DisplayName + } + rows = append(rows, views.TableRow{ + Data: padRow([]string{ + name, fmt.Sprintf("%d", count), tags, common.ShortenPath(ws.Location()), + }, wsRowKindWS, ws.AssignedName(), ""), + Children: namespaceChildren(execs, ws.AssignedName(), filter), + }) + } + return rows +} + +func workspacePageKeys(ctx *context.Context, table *views.Table, wsList workspace.WorkspaceList) []types.KeyCallback { + container := ctx.TUIContainer + return []types.KeyCallback{ + {Key: "o", Label: "open", Callback: func() error { + ws := selectedWorkspace(table, wsList) + if ws == nil { + container.SetNotice("no workspace selected", themes.OutputLevelError) + return nil + } + if err := open.Open(ws.Location()); err != nil { + logger.Log().WrapError(err, "unable to open workspace") + container.SetNotice("unable to open workspace", themes.OutputLevelError) + } + return nil + }}, + {Key: "e", Label: "edit", Callback: func() error { + ws := selectedWorkspace(table, wsList) + if ws == nil { + container.SetNotice("no workspace selected", themes.OutputLevelError) + return nil + } + path := filepath.Join(ws.Location(), filesystem.WorkspaceConfigFileName) + if err := common.OpenInEditor(path, ctx.StdIn(), ctx.StdOut()); err != nil { + logger.Log().WrapError(err, "unable to open workspace in editor") + container.SetNotice("unable to open workspace in editor", themes.OutputLevelError) + } + return nil + }}, + {Key: "s", Label: "set context", Callback: func() error { + wsName, nsName := decodeSelection(table) + if wsName == "" { + container.SetNotice("no workspace selected", themes.OutputLevelError) + return nil + } + ws := findWorkspace(wsList, wsName) + if ws == nil { + container.SetNotice("no workspace selected", themes.OutputLevelError) + return nil + } + curCfg, err := filesystem.LoadConfig() + if err != nil { + logger.Log().WrapError(err, "unable to load user config") + container.SetNotice("unable to load user config", themes.OutputLevelError) + return nil + } + curCfg.CurrentWorkspace = ws.AssignedName() + curCfg.CurrentNamespace = nsName + if err := filesystem.WriteConfig(curCfg); err != nil { + logger.Log().WrapError(err, "unable to write user config") + container.SetNotice("unable to write user config", themes.OutputLevelError) + return nil + } + ctx.Config.CurrentWorkspace = curCfg.CurrentWorkspace + ctx.Config.CurrentNamespace = curCfg.CurrentNamespace + container.SetNotice("context updated", themes.OutputLevelSuccess) + return nil + }}, + } +} + +func workspacesPage( + ctx *context.Context, + workspaces workspace.WorkspaceList, + execs executable.ExecutableList, + filter Filter, + _ func(string) error, +) views.LibraryPage { + return views.LibraryPage{ + Title: "Workspaces", + Factory: func(render *types.RenderState, _ []views.PageSelection) (tea.Model, []types.KeyCallback) { + wsList := workspaceList(workspaces, filter) + rows := workspacePageRows(wsList, execs, filter) + + columns := []views.TableColumn{ + {Title: fmt.Sprintf("Workspaces (%d)", len(wsList)), Percentage: 32}, + {Title: "#", Percentage: 8}, + {Title: "Tags", Percentage: 30}, + {Title: "Location", Percentage: 30}, + } + table := views.NewTable(render, columns, rows, views.TableDisplayFull) + keys := workspacePageKeys(ctx, table, wsList) + return table, keys + }, + } +} + +func executablePageKeys( + ctx *context.Context, + table *views.Table, + visible executable.ExecutableList, + runFunc func(string) error, +) []types.KeyCallback { + container := ctx.TUIContainer + selectedExec := func() *executable.Executable { + row := table.GetSelectedRow() + if row == nil { + return nil + } + data := row.Data() + if len(data) < 4 { + return nil + } + // data[0] is the full ref string ("verb workspace/ns:name") + ref := executable.Ref(data[0]) + ex, err := visible.FindByVerbAndID(ref.Verb(), ref.ID()) + if err != nil { + return nil + } + return ex + } + return []types.KeyCallback{ + {Key: "r", Label: "run", Callback: func() error { + ex := selectedExec() + if ex == nil { + container.SetNotice("no executable selected", themes.OutputLevelError) + return nil + } + container.Shutdown(func() { + if err := runFunc(ex.Ref().String()); err != nil { + logger.Log().Fatal("unable to execute command", "error", err) + } + }) + os.Exit(0) // prevent the app from hanging after the command is run + return nil + }}, + {Key: "e", Label: "edit", Callback: func() error { + ex := selectedExec() + if ex == nil { + container.SetNotice("no executable selected", themes.OutputLevelError) + return nil + } + if err := common.OpenInEditor(ex.FlowFilePath(), ctx.StdIn(), ctx.StdOut()); err != nil { + logger.Log().WrapError(err, "unable to open executable in editor") + container.SetNotice("unable to open executable in editor", themes.OutputLevelError) + } + return nil + }}, + {Key: "c", Label: "copy ref", Callback: func() error { + ex := selectedExec() + if ex == nil { + container.SetNotice("no executable selected", themes.OutputLevelError) + return nil + } + common.CopyToClipboard(container, ex.Ref().String(), "copied reference to clipboard") + return nil + }}, + } +} + +func executablesPage( + ctx *context.Context, + execs executable.ExecutableList, + filter Filter, + runFunc func(string) error, +) views.LibraryPage { + return views.LibraryPage{ + Title: "Executables", + Factory: func(render *types.RenderState, selections []views.PageSelection) (tea.Model, []types.KeyCallback) { + pageFilter := executableFilterFromSelection(filter, selections) + visible := applyFilter(execs, pageFilter) + slices.SortFunc(visible, sortByID) + + columns := []views.TableColumn{ + {Title: fmt.Sprintf("Executables (%d)", len(visible)), Percentage: 40}, + {Title: "Flowfile", Percentage: 30}, + {Title: "Tags", Percentage: 30}, + } + rows := make([]views.TableRow, 0, len(visible)) + for _, ex := range visible { + tags := common.ColorizeTags(ex.Tags) + flowfile := filepath.Base(ex.FlowFilePath()) + // Visible: [0]=ID, [1]=Flowfile, [2]=Tags; Hidden: [3]=Verb (used by key callbacks) + rows = append(rows, views.TableRow{ + Data: []string{ex.Ref().String(), flowfile, tags, string(ex.Verb)}, + }) + } + table := views.NewTable(render, columns, rows, views.TableDisplayFull) + keys := executablePageKeys(ctx, table, visible, runFunc) + return table, keys + }, + } +} + +func executableDetailPage( + ctx *context.Context, + execs executable.ExecutableList, + filter Filter, + runFunc func(string) error, +) views.LibraryPage { + return views.LibraryPage{ + Title: "Details", + Factory: func(render *types.RenderState, selections []views.PageSelection) (tea.Model, []types.KeyCallback) { + var ex *executable.Executable + if len(selections) > 1 && len(selections[1].Data) >= 4 { + row := selections[1].Data + pageFilter := executableFilterFromSelection(filter, selections) + visible := applyFilter(execs, pageFilter) + // row[0] is the full ref string ("verb workspace/ns:name"); + // parse it as a Ref to extract just the ID for lookup. + ref := executable.Ref(row[0]) + if found, err := visible.FindByVerbAndID(ref.Verb(), ref.ID()); err == nil { + ex = found + } + } + if ex == nil { + return views.NewErrorView( + fmt.Errorf("no executable selected"), render.Theme, + ), nil + } + view := NewExecutableView(ctx, ex, runFunc) + model, ok := view.(tea.Model) + if !ok { + return views.NewErrorView( + fmt.Errorf("executable view is not a tea.Model"), render.Theme, + ), nil + } + return model, nil + }, + } +} + +// executableFilterFromSelection translates the workspace-page selection +// (workspace row, namespace child row, or "All Workspaces") into a +// concrete Filter for the executable page. +func executableFilterFromSelection(base Filter, selections []views.PageSelection) Filter { + if len(selections) == 0 { + return base + } + data := selections[0].Data + if len(data) <= wsRowCellKind { + return base + } + out := base + switch data[wsRowCellKind] { + case wsRowKindAll: + out.Workspace = "" + out.Namespace = executable.WildcardNamespace + case wsRowKindWS: + out.Workspace = data[wsRowCellWsName] + out.Namespace = executable.WildcardNamespace + case wsRowKindNS: + out.Workspace = data[wsRowCellWsName] + out.Namespace = data[wsRowCellNsName] + } + return out +} + +// padRow returns a row data slice with a hidden context cell appended at +// `wsRowCellKind`. The first three cells are the visible columns; remaining +// cells are not rendered by the table but survive Selectable.SelectedData(). +func padRow(visible []string, kind, wsName, nsName string) []string { + row := make([]string, wsRowCellKind+3) + copy(row, visible) + row[wsRowCellKind] = kind + row[wsRowCellWsName] = wsName + row[wsRowCellNsName] = nsName + return row +} + +// withWorkspace overrides the workspace field of a filter and resets the +// namespace to the wildcard so workspace-level counts include every +// executable regardless of namespace. +func withWorkspace(filter Filter, wsName string) Filter { + out := filter + out.Workspace = wsName + out.Namespace = executable.WildcardNamespace + return out +} + +func decodeSelection(table *views.Table) (wsName, nsName string) { + row := table.GetSelectedRow() + if row == nil { + return "", "" + } + data := row.Data() + if len(data) <= wsRowCellKind { + return "", "" + } + return data[wsRowCellWsName], data[wsRowCellNsName] +} + +func selectedWorkspace(table *views.Table, wsList workspace.WorkspaceList) *workspace.Workspace { + wsName, _ := decodeSelection(table) + if wsName == "" { + return nil + } + return findWorkspace(wsList, wsName) +} + +func findWorkspace(wsList workspace.WorkspaceList, wsName string) *workspace.Workspace { + for _, ws := range wsList { + if ws.AssignedName() == wsName { + return ws + } + } + return nil +} diff --git a/internal/io/executable/views.go b/internal/io/executable/views.go index 2acebc41..c1b35f40 100644 --- a/internal/io/executable/views.go +++ b/internal/io/executable/views.go @@ -2,9 +2,11 @@ package executable import ( "fmt" + "path/filepath" + "slices" + "sort" "strings" - "github.com/atotto/clipboard" "github.com/flowexec/tuikit" "github.com/flowexec/tuikit/themes" "github.com/flowexec/tuikit/types" @@ -16,6 +18,10 @@ import ( "github.com/flowexec/flow/types/executable" ) +func sortByID(a, b *executable.Executable) int { + return strings.Compare(a.ID(), b.ID()) +} + func NewExecutableView( ctx *context.Context, exec *executable.Executable, @@ -29,7 +35,7 @@ func NewExecutableView( ctx.TUIContainer.Shutdown(func() { err := runFunc(exec.Ref().String()) if err != nil { - logger.Log().Error(err, "executable view runner error") + logger.Log().WrapError(err, "executable view runner error") } }) return nil @@ -38,11 +44,7 @@ func NewExecutableView( { Key: "c", Label: "copy ref", Callback: func() error { - if err := clipboard.WriteAll(exec.Ref().String()); err != nil { - container.HandleError(fmt.Errorf("unable to copy reference to clipboard: %w", err)) - } else { - container.SetNotice("copied reference to clipboard", themes.OutputLevelInfo) - } + common.CopyToClipboard(container, exec.Ref().String(), "copied reference to clipboard") return nil }, }, @@ -56,10 +58,10 @@ func NewExecutableView( }, }, } - return views.NewEntityView( + opts := executableDetailOpts(exec) + return views.NewDetailContentView( container.RenderState(), - exec, - types.EntityFormatDocument, + opts, executableKeyCallbacks..., ) } @@ -70,24 +72,68 @@ func NewExecutableListView( runFunc func(string) error, ) tuikit.View { container := ctx.TUIContainer - if len(executables.Items()) == 0 { - container.HandleError(fmt.Errorf("no workspaces found")) + if len(executables) == 0 { + container.HandleError(fmt.Errorf("no executables found")) } - selectFunc := func(filterVal string) error { - s := strings.Split(filterVal, " ") - if len(s) != 2 { - return fmt.Errorf("invalid filter value") + slices.SortFunc(executables, sortByID) + + columns := []views.TableColumn{ + {Title: fmt.Sprintf("Executables (%d)", len(executables)), Percentage: 40}, + {Title: "Flowfile", Percentage: 30}, + {Title: "Tags", Percentage: 30}, + } + rows := make([]views.TableRow, 0, len(executables)) + for _, ex := range executables { + tags := common.ColorizeTags(ex.Tags) + flowfile := filepath.Base(ex.FlowFilePath()) + rows = append(rows, views.TableRow{ + Data: []string{fmt.Sprintf("%s %s", ex.Verb, ex.Ref().ID()), flowfile, tags}, + }) + } + table := views.NewTable(container.RenderState(), columns, rows, views.TableDisplayMini) + selectedExec := func() *executable.Executable { + row := table.GetSelectedRow() + if row == nil { + return nil + } + data := row.Data() + if len(data) < 1 { + return nil } - verb, id := s[0], s[1] - exec, err := executables.FindByVerbAndID(executable.Verb(verb), id) + parts := strings.SplitN(data[0], " ", 2) + if len(parts) < 2 { + return nil + } + ex, err := executables.FindByVerbAndID(executable.Verb(parts[0]), parts[1]) if err != nil { - return fmt.Errorf("executable not found") + return nil } - return ctx.SetView(NewExecutableView(ctx, exec, runFunc)) + return ex } - - return views.NewCollectionView(container.RenderState(), executables, types.CollectionFormatList, selectFunc) + table.SetOnSelect(func(_ int) error { + ex := selectedExec() + if ex == nil { + return fmt.Errorf("no executable selected") + } + return ctx.SetView(NewExecutableView(ctx, ex, runFunc)) + }) + table.SetKeyCallbacks([]types.KeyCallback{ + {Key: "r", Label: "run", Callback: func() error { + ex := selectedExec() + if ex == nil { + container.SetNotice("no executable selected", themes.OutputLevelError) + return nil + } + container.Shutdown(func() { + if err := runFunc(ex.Ref().String()); err != nil { + logger.Log().WrapError(err, "executable list view runner error") + } + }) + return nil + }}, + }) + return table } func NewTemplateView( @@ -107,11 +153,7 @@ func NewTemplateView( { Key: "c", Label: "copy location", Callback: func() error { - if err := clipboard.WriteAll(template.Location()); err != nil { - container.HandleError(fmt.Errorf("unable to copy location to clipboard: %w", err)) - } else { - container.SetNotice("copied location to clipboard", themes.OutputLevelInfo) - } + common.CopyToClipboard(container, template.Location(), "copied location to clipboard") return nil }, }, @@ -125,10 +167,10 @@ func NewTemplateView( }, }, } - return views.NewEntityView( + opts := templateDetailOpts(template) + return views.NewDetailContentView( container.RenderState(), - template, - types.EntityFormatDocument, + opts, templateKeyCallbacks..., ) } @@ -139,18 +181,35 @@ func NewTemplateListView( runFunc func(string) error, ) tuikit.View { container := ctx.TUIContainer - if len(templates.Items()) == 0 { + if len(templates) == 0 { container.HandleError(fmt.Errorf("no templates found")) } - selectFunc := func(filterVal string) error { - template := templates.Find(filterVal) - if template == nil { - return fmt.Errorf("template %s not found", filterVal) - } + sort.Slice(templates, func(i, j int) bool { + return templates[i].Name() < templates[j].Name() + }) - return ctx.SetView(NewTemplateView(ctx, template, runFunc)) + columns := []views.TableColumn{ + {Title: fmt.Sprintf("Templates (%d)", len(templates)), Percentage: 50}, + {Title: "Location", Percentage: 50}, } - - return views.NewCollectionView(container.RenderState(), templates, types.CollectionFormatList, selectFunc) + rows := make([]views.TableRow, 0, len(templates)) + for _, t := range templates { + rows = append(rows, views.TableRow{ + Data: []string{t.Name(), common.ShortenPath(t.Location())}, + }) + } + table := views.NewTable(container.RenderState(), columns, rows, views.TableDisplayMini) + table.SetOnSelect(func(_ int) error { + row := table.GetSelectedRow() + if row == nil || len(row.Data()) == 0 { + return fmt.Errorf("no template selected") + } + t := templates.Find(row.Data()[0]) + if t == nil { + return fmt.Errorf("template %s not found", row.Data()[0]) + } + return ctx.SetView(NewTemplateView(ctx, t, runFunc)) + }) + return table } diff --git a/internal/io/library/init.go b/internal/io/library/init.go deleted file mode 100644 index 7e902c69..00000000 --- a/internal/io/library/init.go +++ /dev/null @@ -1,171 +0,0 @@ -//nolint:gocritic -package library - -import ( - "slices" - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/flowexec/tuikit/types" - - "github.com/flowexec/flow/types/executable" - "github.com/flowexec/flow/types/workspace" -) - -func (l *Library) Init() tea.Cmd { - cmds := make([]tea.Cmd, 0) - cmds = append( - cmds, - tea.SetWindowTitle("flow library"), - tea.Tick(time.Millisecond*250, func(t time.Time) tea.Msg { - return types.Tick() - }), - ) - cmds = append( - cmds, - l.paneZeroViewport.Init(), - l.paneOneViewport.Init(), - l.paneTwoViewport.Init(), - ) - - if l.ctx.TUIContainer.Width() >= 150 { - l.splitView = true - } - l.setSize() - go func() { - l.setVisibleWorkspaces() - l.setVisibleNamespaces() - l.setVisibleExecs() - }() - - return tea.Batch(cmds...) -} - -func (l *Library) setVisibleExecs() { - l.mu.RLock() - if len(l.allExecutables) == 0 || len(l.visibleWorkspaces) == 0 { - l.mu.RUnlock() - return - } - - curWs := l.filter.Workspace - - if label := l.visibleWorkspaces[l.currentWorkspace]; label != "" && label != allWorkspacesLabel { - curWs = label - } else if curWs == allWorkspacesLabel { - curWs = "" - } - - curNs := l.filter.Namespace - if l.showNamespaces && len(l.visibleNamespaces) > 0 { - if label := l.visibleNamespaces[l.currentNamespace]; label != "" { - switch label { - case withoutNamespaceLabel: - curNs = "" - case allNamespacesLabel: - curNs = executable.WildcardNamespace - default: - curNs = label - } - } - } - l.mu.RUnlock() - - filter := l.filter - filteredExec := l.allExecutables - filteredExec = filteredExec. - FilterByWorkspaceWithVisibility(curWs, filter.Visibility). - FilterByNamespace(curNs). - FilterByVerb(filter.Verb). - FilterByTags(filter.Tags). - FilterBySubstring(filter.Substring) - - slices.SortFunc(filteredExec, func(i, j *executable.Executable) int { - return strings.Compare(i.Ref().String(), j.Ref().String()) - }) - - l.mu.Lock() - l.visibleExecutables = filteredExec - l.mu.Unlock() -} - -func (l *Library) setVisibleWorkspaces() { - if l.allWorkspaces == nil { - return - } - - filter := l.filter - filteredWs := l.allWorkspaces - switch filter.Workspace { - case "": - // do nothing - case allWorkspacesLabel, executable.WildcardWorkspace: - // do nothing - default: - for _, ws := range filteredWs { - if ws.AssignedName() == filter.Workspace { - filteredWs = workspace.WorkspaceList{ws} - break - } - } - } - - var labels, prepend []string - if len(filteredWs) > 1 { - prepend = []string{allWorkspacesLabel} - } - for _, ws := range filteredWs { - labels = append(labels, ws.AssignedName()) - } - slices.Sort(labels) - - l.mu.Lock() - l.visibleWorkspaces = append(prepend, labels...) //nolint:gocritic - l.mu.Unlock() -} - -func (l *Library) setVisibleNamespaces() { - l.mu.RLock() - if l.allExecutables == nil || len(l.visibleWorkspaces) == 0 { - l.mu.RUnlock() - return - } - - var labels, prepend []string - var someWithoutNs bool - filter := l.filter - - filterWs := l.visibleWorkspaces[l.currentWorkspace] - l.mu.RUnlock() - - nsSet := make(map[string]struct{}) - for _, ex := range l.allExecutables { - ns := ex.Ref().Namespace() - ws := ex.Ref().Workspace() - if filter.Namespace != executable.WildcardNamespace && filter.Namespace != "" && ns != filter.Namespace { - continue - } else if filterWs != allWorkspacesLabel && filterWs != "" && ws != filterWs { - continue - } else if ns == "" || ns == executable.WildcardNamespace { - someWithoutNs = true - continue - } - - if _, ok := nsSet[ns]; !ok { - nsSet[ns] = struct{}{} - labels = append(labels, ns) - } - } - slices.Sort(labels) - if len(labels) > 1 { - prepend = append(prepend, allNamespacesLabel) - } - if someWithoutNs { - prepend = append(prepend, withoutNamespaceLabel) - } - - l.mu.Lock() - l.visibleNamespaces = append(prepend, labels...) //nolint:gocritic - l.mu.Unlock() -} diff --git a/internal/io/library/library.go b/internal/io/library/library.go deleted file mode 100644 index 6a013ea8..00000000 --- a/internal/io/library/library.go +++ /dev/null @@ -1,101 +0,0 @@ -package library - -import ( - "fmt" - "sync" - - "github.com/charmbracelet/bubbles/viewport" - "github.com/flowexec/tuikit" - "github.com/flowexec/tuikit/themes" - "github.com/flowexec/tuikit/views" - - "github.com/flowexec/flow/pkg/context" - "github.com/flowexec/flow/types/common" - "github.com/flowexec/flow/types/executable" - "github.com/flowexec/flow/types/workspace" -) - -const ( - appName = "flow library" -) - -type Library struct { - ctx *context.Context - termWidth, termHeight int - noticeText string - showHelp, showNamespaces, splitView bool - - visibleWorkspaces []string - visibleNamespaces []string - visibleExecutables executable.ExecutableList - allWorkspaces workspace.WorkspaceList - allExecutables executable.ExecutableList - filter Filter - theme themes.Theme - - currentPane, currentWorkspace, currentNamespace, currentExecutable uint - currentFormat, currentHelpPage uint - paneZeroViewport, paneOneViewport, paneTwoViewport viewport.Model - - cmdRunFunc func(string) error - - // Mutex to protect concurrent access to shared fields - mu sync.RWMutex -} - -type Filter struct { - Workspace, Namespace string - Verb executable.Verb - Tags common.Tags - Substring string - Visibility common.Visibility -} - -func NewLibrary( - ctx *context.Context, - workspaces workspace.WorkspaceList, - execs executable.ExecutableList, - filter Filter, - theme themes.Theme, - runFunc func(string) error, -) *Library { - p1 := viewport.New(0, 0) - p2 := viewport.New(0, 0) - p3 := viewport.New(0, 0) - return &Library{ - ctx: ctx, - allWorkspaces: workspaces, - allExecutables: execs, - filter: filter, - paneZeroViewport: p1, - paneOneViewport: p2, - paneTwoViewport: p3, - theme: theme, - cmdRunFunc: runFunc, - visibleWorkspaces: make([]string, 0), - visibleNamespaces: make([]string, 0), - visibleExecutables: make(executable.ExecutableList, 0), - } -} - -func NewLibraryView( - ctx *context.Context, - workspaces workspace.WorkspaceList, - execs executable.ExecutableList, - filter Filter, - theme themes.Theme, - runFunc func(string) error, -) tuikit.View { - l := NewLibrary(ctx, workspaces, execs, filter, theme, runFunc) - return views.NewFrameView(l) -} - -func ctxVal(ws, ns string) string { - if ws == "" { - ws = "unk" - } - if ns == "" { - ns = executable.WildcardNamespace - } - return fmt.Sprintf("%s/%s", ws, ns) -} diff --git a/internal/io/library/styles.go b/internal/io/library/styles.go deleted file mode 100644 index b258e9b2..00000000 --- a/internal/io/library/styles.go +++ /dev/null @@ -1,101 +0,0 @@ -package library - -import ( - "fmt" - "math" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/flowexec/tuikit/themes" - "github.com/mattn/go-runewidth" - - "github.com/flowexec/flow/types/executable" -) - -func renderSelection(s string, theme themes.Theme) string { - style := lipgloss.NewStyle().Foreground(theme.ColorPalette().PrimaryColor()) - return style.Render(s) -} - -func renderSecondarySelection(s string, theme themes.Theme) string { - style := lipgloss.NewStyle().Foreground(theme.ColorPalette().SecondaryColor()) - return style.Render(s) -} - -func renderInactive(s string, theme themes.Theme) string { - style := lipgloss.NewStyle().Foreground(theme.ColorPalette().GrayColor()) - return style.Render(s) -} - -func renderDescription(s string, theme themes.Theme) string { - style := lipgloss.NewStyle().Foreground(theme.ColorPalette().BodyColor()) - return style.Render(s) -} - -func renderPaneTitle(s string, count int, active bool, theme themes.Theme) string { - var title string - if count == 0 { - title = s - } else { - title = fmt.Sprintf("%s (%d)", s, count) - } - style := lipgloss.NewStyle().Foreground(theme.ColorPalette().SecondaryColor()).Padding(0, 1).Bold(true) - if active { - style = style.Underline(true) - } - return style.Render(title) + "\n\n" -} - -func paneStyle(pos int, theme themes.Theme, splitView bool) lipgloss.Style { - style := lipgloss.NewStyle().Padding(0, 1) - if pos == 2 && splitView { - style = style.BorderStyle(lipgloss.OuterHalfBlockBorder()). - BorderForeground(theme.ColorPalette().BorderColor()).BorderLeft(true) - } - - return style -} - -func calculateViewportWidths(termWidth int, splitView bool) (int, int, int) { - if splitView { - paneOne := math.Floor(float64(termWidth) * 0.20) - paneTwo := math.Floor(float64(termWidth) * 0.30) - paneThree := termWidth - int(paneOne) - int(paneTwo) - return int(paneOne), int(paneTwo), paneThree - } else { - paneOne := math.Floor(float64(termWidth) * 0.33) - paneTwo := math.Floor(float64(termWidth) * 0.67) - paneThree := termWidth - return int(paneOne), int(paneTwo), paneThree - } -} - -func shortRef(ref executable.Ref, ws, ns string) string { - shortID := ref.ID() - if ws != "" && ref.Workspace() == ws { - shortID = strings.Replace(shortID, ws+"/", "", 1) - } - if ns != "" && ref.Namespace() == ns { - shortID = strings.Replace(shortID, ns+":", "", 1) - } - return executable.NewRef(shortID, ref.Verb()).String() -} - -func truncateText(s string, w int) string { - padding := 10 - if runewidth.StringWidth(s) <= w-padding { - // Don't truncate strings that fit - return s - } - - runes := []rune(s) - width := 0 - for i := len(runes) - 1; i >= 0; i-- { - r := runes[i] - width += runewidth.RuneWidth(r) - if width >= w-padding { - return "..." + string(runes[i+1:]) - } - } - return string(runes) -} diff --git a/internal/io/library/update.go b/internal/io/library/update.go deleted file mode 100644 index 3d580c50..00000000 --- a/internal/io/library/update.go +++ /dev/null @@ -1,283 +0,0 @@ -//nolint:funlen,gocritic,gocognit,gocyclo,cyclop -package library - -import ( - "os" - "path/filepath" - - "github.com/atotto/clipboard" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/flowexec/tuikit/io" - "github.com/flowexec/tuikit/themes" - - "github.com/flowexec/flow/internal/io/common" - "github.com/flowexec/flow/internal/services/open" - "github.com/flowexec/flow/pkg/filesystem" - "github.com/flowexec/flow/pkg/logger" -) - -var log io.Logger - -func (l *Library) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - log = logger.Log() - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case tea.WindowSizeMsg: - l.setSize() - case tea.KeyMsg: - key := msg.String() - switch key { - case tea.KeyLeft.String(): - if l.currentPane == 0 { - break - } - l.currentPane-- - - // Reset the current executable when switching back to the workspaces pane - if l.currentPane == 0 { - l.currentExecutable = 0 - l.paneOneViewport.GotoTop() - } - case tea.KeyRight.String(), tea.KeyEnter.String(): - if l.currentPane == 2 { - break - } - l.currentPane++ - case tea.KeyTab.String(): - l.splitView = !l.splitView - l.setSize() - case "h": - switch { - case l.showHelp && l.currentHelpPage == 0: - l.currentHelpPage = 1 - default: - l.showHelp = !l.showHelp - l.currentHelpPage = 0 - } - } - l.noticeText = "" - } - - wsPane, wsCmd := l.updateWsPane(msg) - l.paneZeroViewport = wsPane - execPane, execCmd := l.updateExecPanes(msg) - switch l.currentPane { - case 1: - l.paneOneViewport = execPane - case 2: - l.paneTwoViewport = execPane - } - - l.setVisibleWorkspaces() - l.setVisibleNamespaces() - l.setVisibleExecs() - - cmds = append(cmds, wsCmd, execCmd) - return l, tea.Batch(cmds...) -} - -func (l *Library) updateWsPane(msg tea.Msg) (viewport.Model, tea.Cmd) { - if l.currentPane != 0 { - return l.paneZeroViewport, nil - } - - l.mu.RLock() - numWs := len(l.visibleWorkspaces) - numNs := len(l.visibleNamespaces) - if numWs == 0 { - l.mu.RUnlock() - return l.paneZeroViewport, nil - } - - curWs := l.visibleWorkspaces[l.currentWorkspace] - l.mu.RUnlock() - - curWsCfg := l.allWorkspaces.FindByName(curWs) - wsCanMoveUp := numWs > 1 && l.currentWorkspace >= 1 && l.currentWorkspace < uint(numWs) - wsCanMoveDown := numWs > 1 && l.currentWorkspace < uint(numWs-1) - - var curNs string - l.mu.RLock() - if len(l.visibleNamespaces) > 0 { - curNs = l.visibleNamespaces[l.currentNamespace] - } - l.mu.RUnlock() - - nsCanMoveUp := curNs != "" && numNs > 1 && l.currentNamespace >= 1 && l.currentNamespace < uint(numNs) - nsCanMoveDown := curNs != "" && numNs > 1 && l.currentNamespace < uint(numNs-1) - - switch msg := msg.(type) { - case tea.KeyMsg: - key := msg.String() - - switch key { - case tea.KeyDown.String(): - if l.showNamespaces && nsCanMoveDown { - l.currentNamespace++ - } else if !l.showNamespaces && wsCanMoveDown { - l.currentWorkspace++ - } - case tea.KeyUp.String(): - if l.showNamespaces && nsCanMoveUp { - l.currentNamespace-- - } else if !l.showNamespaces && wsCanMoveUp { - l.currentWorkspace-- - } - case tea.KeySpace.String(): - if numNs > 0 { - l.showNamespaces = !l.showNamespaces - l.currentNamespace = 0 - } - case "o": - if curWsCfg == nil { - l.SetNotice("no workspace selected", themes.OutputLevelError) - break - } - - if err := open.Open(curWsCfg.Location()); err != nil { - log.Error(err, "unable to open workspace") - l.SetNotice("unable to open workspace", themes.OutputLevelError) - } - case "e": - if curWsCfg == nil { - l.SetNotice("no workspace selected", themes.OutputLevelError) - break - } - - if err := common.OpenInEditor( - filepath.Join(curWsCfg.Location(), filesystem.WorkspaceConfigFileName), - l.ctx.StdIn(), l.ctx.StdOut(), - ); err != nil { - log.Error(err, "unable to open workspace in editor") - l.SetNotice("unable to open workspace in editor", themes.OutputLevelError) - } - case "s": - if curWsCfg == nil { - l.SetNotice("no workspace selected", themes.OutputLevelError) - break - } - - curCfg, err := filesystem.LoadConfig() - if err != nil { - log.Error(err, "unable to load user config") - l.SetNotice("unable to load user config", themes.OutputLevelError) - break - } - - switch { - case l.showNamespaces && curNs == withoutNamespaceLabel: - curCfg.CurrentNamespace = "" - case l.showNamespaces && curNs == allNamespacesLabel: - l.SetNotice("no namespace selected", themes.OutputLevelError) - case l.showNamespaces && curNs != "": - curCfg.CurrentNamespace = curNs - case !l.showNamespaces && curWs == allWorkspacesLabel: - l.SetNotice("no workspace selected", themes.OutputLevelError) - case !l.showNamespaces && curWs != "": - if curWs != curWsCfg.AssignedName() { - l.SetNotice("current workspace out of sync", themes.OutputLevelError) - break - } - curCfg.CurrentWorkspace = curWsCfg.AssignedName() - } - - if err := filesystem.WriteConfig(curCfg); err != nil { - log.Error(err, "unable to write user config") - l.SetNotice("unable to write user config", themes.OutputLevelError) - break - } - - l.ctx.Config.CurrentWorkspace = curCfg.CurrentWorkspace - l.ctx.Config.CurrentNamespace = curCfg.CurrentNamespace - l.SetNotice("context updated", themes.OutputLevelInfo) - } - } - - return l.paneZeroViewport.Update(msg) -} - -func (l *Library) updateExecPanes(msg tea.Msg) (viewport.Model, tea.Cmd) { - if l.currentPane != 1 && l.currentPane != 2 { - return l.paneOneViewport, nil - } - - var pane viewport.Model - switch l.currentPane { - case 1: - pane = l.paneOneViewport - case 2: - pane = l.paneTwoViewport - } - - l.mu.RLock() - numExecs := len(l.visibleExecutables) - if numExecs == 0 { - l.mu.RUnlock() - return pane, nil - } - - curExec := l.visibleExecutables[l.currentExecutable] - l.mu.RUnlock() - - canMoveUp := numExecs > 1 && l.currentExecutable >= 1 && l.currentExecutable < uint(numExecs) - canMoveDown := numExecs > 1 && l.currentExecutable < uint(numExecs-1) - - switch msg := msg.(type) { //nolint:gocritic - case tea.KeyMsg: - key := msg.String() - - switch key { - case tea.KeyDown.String(): - if l.currentPane == 1 && canMoveDown { - l.currentExecutable++ - } - case tea.KeyUp.String(): - if l.currentPane == 1 && canMoveUp { - l.currentExecutable-- - } - case "e": - if curExec == nil { - l.SetNotice("no executable selected", themes.OutputLevelError) - break - } - - if err := common.OpenInEditor(curExec.FlowFilePath(), l.ctx.StdIn(), l.ctx.StdOut()); err != nil { - log.Error(err, "unable to open executable in editor") - l.SetNotice("unable to open executable in editor", themes.OutputLevelError) - } - case "c": - if curExec == nil { - l.SetNotice("no executable selected", themes.OutputLevelError) - break - } - - if err := clipboard.WriteAll(curExec.Ref().String()); err != nil { - log.Error(err, "unable to copy reference to clipboard") - l.SetNotice("unable to copy reference to clipboard", themes.OutputLevelError) - } else { - l.SetNotice("copied reference to clipboard", themes.OutputLevelInfo) - } - case "r": - if curExec == nil { - l.SetNotice("no executable selected", themes.OutputLevelError) - break - } - - l.ctx.TUIContainer.Shutdown(func() { - if err := l.cmdRunFunc(curExec.Ref().String()); err != nil { - log.Fatalx("unable to execute command", "error", err) - } - }) - os.Exit(0) // This is necessary to prevent the app from hanging after the command is run - case "f": - if l.currentPane == 1 { - break - } - l.currentFormat = (l.currentFormat + 1) % 3 - pane.GotoTop() - } - } - - return pane.Update(msg) -} diff --git a/internal/io/library/view.go b/internal/io/library/view.go deleted file mode 100644 index 6bd79e0e..00000000 --- a/internal/io/library/view.go +++ /dev/null @@ -1,332 +0,0 @@ -//nolint:gocognit,gocritic,nestif -package library - -import ( - "fmt" - "math" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/flowexec/tuikit/themes" - "github.com/jahvon/glamour" - - "github.com/flowexec/flow/pkg/logger" - "github.com/flowexec/flow/types/common" - "github.com/flowexec/flow/types/executable" - "github.com/flowexec/flow/types/workspace" -) - -const ( - // widthPadding is used when determining the width of the panes. - // It is used to account for the spacing on left/right spacing of the panes - widthPadding = 4 - - allWorkspacesLabel = "all workspaces" - withoutNamespaceLabel = "w/o namespace" - allNamespacesLabel = "all namespaces" - - containerHelp = "[ tab: split view ] [ ↑/↓: navigate pane ] [ ←/→: change pane ]" - paneZeroHelp = "[ o: open ] [ e: edit ] [ s:set ] ● [ space: show namespaces ]" - paneZeroExpandedHelp = "[ o: open ] [ e: edit ] [ s:set ] ● [ space: hide namespaces ]" - paneOneHelp = "[ r: run ] [ e: edit ] [ c: copy ref ]" - paneTwoHelp = "[ r: run ] [ e: edit ] [ c: copy ref ] ● [ f: change format ]" -) - -var ( - // heightPadding is used when determining the height of the panes. - // It is used to account for the header and footer - heightPadding = themes.HeaderHeight + themes.FooterHeight -) - -func (l *Library) View() string { - l.paneZeroViewport.Style = paneStyle(0, l.theme, l.splitView) - l.paneZeroViewport.SetContent(l.paneZeroContent()) - l.paneZeroViewport.SetYOffset(int(l.currentWorkspace + l.currentNamespace)) - - l.paneOneViewport.Style = paneStyle(1, l.theme, l.splitView) - l.paneOneViewport.SetContent(l.paneOneContent()) - - l.paneTwoViewport.Style = paneStyle(2, l.theme, l.splitView) - l.paneTwoViewport.SetContent(l.paneTwoContent()) - v := ctxVal(l.ctx.CurrentWorkspace.AssignedName(), l.ctx.Config.CurrentNamespace) - header := l.theme.RenderHeader(appName, "ctx", v, l.termWidth) - var panes string - if l.splitView { - panes = lipgloss.JoinHorizontal( - lipgloss.Top, - l.paneZeroViewport.View(), - l.paneOneViewport.View(), - l.paneTwoViewport.View(), - ) - } else { - switch l.currentPane { - case 0, 1: - panes = lipgloss.JoinHorizontal( - lipgloss.Top, - l.paneZeroViewport.View(), - l.paneOneViewport.View(), - ) - case 2: - panes = l.paneTwoViewport.View() - } - } - - footer := l.footerContent() - - return lipgloss.JoinVertical(lipgloss.Top, header, panes, footer) -} - -func (l *Library) SetNotice(notice string, level themes.OutputLevel) { - if level == "" { - level = themes.OutputLevelInfo - } - l.noticeText = l.theme.RenderLevel(notice, level) -} - -func (l *Library) setSize() { - l.termWidth = l.ctx.TUIContainer.Width() - l.termHeight = l.ctx.TUIContainer.Height() - p0, p1, p2 := calculateViewportWidths(l.termWidth-widthPadding, l.splitView) - l.paneZeroViewport.Width = p0 - l.paneOneViewport.Width = p1 - l.paneTwoViewport.Width = p2 - l.paneZeroViewport.Height = l.termHeight - heightPadding - l.paneOneViewport.Height = l.termHeight - heightPadding - l.paneTwoViewport.Height = l.termHeight - heightPadding -} - -func (l *Library) paneZeroContent() string { - if !l.splitView && l.currentPane == 2 { - return "" - } - - var sb strings.Builder - l.mu.RLock() - workspaces := l.visibleWorkspaces - namespaces := l.visibleNamespaces - l.mu.RUnlock() - - sb.WriteString(renderPaneTitle("Workspaces", len(workspaces), l.currentPane == 0, l.theme)) - - numWs := len(workspaces) - numNs := len(namespaces) - if numWs == 0 { - sb.WriteString(l.theme.RenderError("No workspaces found")) - return sb.String() - } - paneWidth, _, _ := calculateViewportWidths(l.termWidth, l.splitView) - - for i, ws := range workspaces { - prefix := "◌ " - if uint(i) == l.currentWorkspace && !l.showNamespaces { - prefix = "● " - } else if uint(i) == l.currentWorkspace && l.showNamespaces { - prefix = "◉ " - } - - if uint(i) == l.currentWorkspace { - sb.WriteString(renderSelection(prefix+truncateText(ws, paneWidth), l.theme)) - sb.WriteString("\n") - if numNs == 1 { - sb.WriteString(renderDescription(fmt.Sprintf(" %d namespace", numNs), l.theme)) - } else { - sb.WriteString(renderDescription(fmt.Sprintf(" %d namespaces", numNs), l.theme)) - } - sb.WriteString("\n") - - if l.showNamespaces { - for j, ns := range namespaces { - if uint(j) == l.currentNamespace { - sb.WriteString(renderSecondarySelection(" > "+truncateText(ns, paneWidth), l.theme)) - } else { - sb.WriteString(renderInactive(" "+truncateText(ns, paneWidth), l.theme)) - } - sb.WriteString("\n") - } - } - } else { - sb.WriteString(renderInactive(prefix+truncateText(ws, paneWidth), l.theme)) - sb.WriteString("\n") - } - sb.WriteString("\n") - } - - return sb.String() -} - -func (l *Library) paneOneContent() string { - if !l.splitView && l.currentPane == 2 { - return "" - } - - var sb strings.Builder - l.mu.RLock() - sb.WriteString(renderPaneTitle("Executables", len(l.visibleExecutables), l.currentPane == 1, l.theme)) - if len(l.visibleExecutables) == 0 { - l.mu.RUnlock() - sb.WriteString(l.theme.RenderError("No executables found")) - return sb.String() - } - - _, paneWidth, _ := calculateViewportWidths(l.termWidth, l.splitView) - - curWs := l.visibleWorkspaces[l.currentWorkspace] - var curNs string - if len(l.visibleNamespaces) > 0 { - curNs = l.visibleNamespaces[l.currentNamespace] - } - visibleExecutables := l.visibleExecutables - l.mu.RUnlock() - - for i, ex := range visibleExecutables { - if uint(i) == l.currentExecutable { - indicator := "*" - if (l.ctx.CurrentWorkspace != nil && ex.Workspace() == l.ctx.CurrentWorkspace.AssignedName()) || - (ex.Visibility != nil && *ex.Visibility == executable.ExecutableVisibility(common.VisibilityPublic)) { - // indicate if runnable from the current ctx - indicator = "▶" - } - refStr := indicator + " " + truncateText(shortRef(ex.Ref(), curWs, curNs), paneWidth) - sb.WriteString(renderSelection(refStr, l.theme)) - } else { - sb.WriteString(renderInactive(" "+truncateText(shortRef(ex.Ref(), curWs, curNs), paneWidth), l.theme)) - } - sb.WriteString("\n") - } - return sb.String() -} - -func (l *Library) paneTwoContent() string { - l.mu.RLock() - if len(l.visibleExecutables) == 0 { - l.mu.RUnlock() - return "" - } else if !l.splitView && l.currentPane != 2 { - l.mu.RUnlock() - return "" - } - - ex := l.visibleExecutables[l.currentExecutable] - l.mu.RUnlock() - - _, _, maxWidth := calculateViewportWidths(l.termWidth, l.splitView) - paneTwoMaxWidth := math.Floor(float64(maxWidth) * 0.95) - mdStyles, err := l.theme.GlamourMarkdownStyleJSON() - if err != nil { - return l.theme.RenderError(fmt.Sprintf("unable to render markdown: %s", err.Error())) - } - renderer, err := glamour.NewTermRenderer( - glamour.WithStylesFromJSONBytes([]byte(mdStyles)), - glamour.WithPreservedNewLines(), - glamour.WithWordWrap(int(paneTwoMaxWidth)), - ) - if err != nil { - return l.theme.RenderError(fmt.Sprintf("unable to render markdown: %s", err.Error())) - } - - content := ex.Markdown() - switch l.currentFormat { - case 0: - content = ex.Markdown() - case 1: - content, err = ex.YAML() - if err != nil { - return l.theme.RenderError(fmt.Sprintf("unable to render yaml: %s", err.Error())) - } - content = fmt.Sprintf("```yaml\n%s\n```", content) - case 2: - content, err = ex.JSON() - if err != nil { - return l.theme.RenderError(fmt.Sprintf("unable to render json: %s", err.Error())) - } - content = fmt.Sprintf("```json\n%s\n```", content) - } - viewStr, err := renderer.Render(content) - if err != nil { - return l.theme.RenderError(fmt.Sprintf("unable to render markdown: %s", err.Error())) - } - - return viewStr -} - -func (l *Library) footerContent() string { - help := l.showHelp - if help && l.currentHelpPage != 0 { - return l.theme.RenderFooter(fmt.Sprintf("2/2 %s ● %s", "[ h: exit help ]", containerHelp), l.termWidth) - } - - footerPrefix := "[ q/ctrl+c: quit] [ h: help ]" - if help { - footerPrefix = "1/2 [ h: show more ]" - } - switch l.currentPane { - case 0: - if help && l.showNamespaces { - return l.theme.RenderFooter( - fmt.Sprintf("%s ● %s", footerPrefix, paneZeroExpandedHelp), l.termWidth, - ) - } else if help { - return l.theme.RenderFooter(fmt.Sprintf("%s ● %s", footerPrefix, paneZeroHelp), l.termWidth) - } else { - l.mu.RLock() - if l.currentWorkspace < uint(len(l.visibleWorkspaces)) { - ws := l.visibleWorkspaces[l.currentWorkspace] - l.mu.RUnlock() - if ws == allWorkspacesLabel { - break - } - var wsCfg *workspace.Workspace - for i, w := range l.allWorkspaces { - if w.AssignedName() == ws { - wsCfg = l.allWorkspaces[i] - } - } - if wsCfg == nil { - logger.Log().Errorf("unable to find workspace config for %s", ws) - break - } - - var info string - switch { - case l.noticeText != "": - info = l.noticeText - case len(wsCfg.Tags) > 0: - info = fmt.Sprintf("%s(%s) -> %s", wsCfg.DisplayName, common.Tags(wsCfg.Tags).PreviewString(), wsCfg.Location()) - default: - info = fmt.Sprintf("%s -> %s", wsCfg.DisplayName, wsCfg.Location()) - } - return l.theme.RenderFooter(fmt.Sprintf("%s ● %s", footerPrefix, info), l.termWidth) - } else { - l.mu.RUnlock() - } - } - case 1, 2: - if help { - helpStr := paneOneHelp - if l.currentPane == 2 { - helpStr = paneTwoHelp - } - - return l.theme.RenderFooter( - fmt.Sprintf("%s ● %s", footerPrefix, helpStr), l.termWidth, - ) - } else { - l.mu.RLock() - if l.currentExecutable < uint(len(l.visibleExecutables)) { - var info string - switch { - case l.noticeText != "": - info = l.noticeText - default: - exec := l.visibleExecutables[l.currentExecutable] - l.mu.RUnlock() - info = exec.FlowFilePath() - } - return l.theme.RenderFooter(fmt.Sprintf("%s ● %s", footerPrefix, info), l.termWidth) - } else { - l.mu.RUnlock() - } - } - } - return l.theme.RenderFooter(footerPrefix, l.termWidth) -} diff --git a/internal/io/logs/output.go b/internal/io/logs/output.go index bb0434ad..891d7f8b 100644 --- a/internal/io/logs/output.go +++ b/internal/io/logs/output.go @@ -11,7 +11,7 @@ import ( ) type entry struct { - Args string `json:"args" yaml:"args"` + ID string `json:"id" yaml:"id"` Time string `json:"time" yaml:"time"` File string `json:"file" yaml:"file"` } @@ -22,7 +22,7 @@ type entryResponse struct { func tuikitToEntry(e io.ArchiveEntry) entry { return entry{ - Args: e.Args, + ID: e.ID, Time: e.Time.String(), File: e.Path, } diff --git a/internal/io/logs/views.go b/internal/io/logs/views.go new file mode 100644 index 00000000..22e918e7 --- /dev/null +++ b/internal/io/logs/views.go @@ -0,0 +1,98 @@ +package logs + +import ( + "fmt" + "slices" + + "github.com/flowexec/tuikit" + tuikitIO "github.com/flowexec/tuikit/io" + "github.com/flowexec/tuikit/themes" + "github.com/flowexec/tuikit/types" + "github.com/flowexec/tuikit/views" +) + +func NewLogView( + container *tuikit.Container, + archiveDir string, + lastEntry bool, +) tuikit.View { + entries, err := tuikitIO.ListArchiveEntries(archiveDir) + if err != nil { + return views.NewErrorView(err, container.RenderState().Theme) + } + if len(entries) == 0 { + return views.NewErrorView(fmt.Errorf("no log entries found"), container.RenderState().Theme) + } + + // Most recent first + slices.Reverse(entries) + + if lastEntry { + return logDetailView(container, entries[0]) + } + + return logListView(container, entries) +} + +func logDetailView(container *tuikit.Container, entry tuikitIO.ArchiveEntry) tuikit.View { + content, err := entry.Read() + if err != nil { + return views.NewErrorView(err, container.RenderState().Theme) + } + if content == "" { + content = "no data found in log entry" + } + + metadata := []views.DetailField{ + {Key: "Executable", Value: entry.ID}, + {Key: "Time", Value: entry.Description()}, + } + + detail := views.NewDetailView(container.RenderState(), content, metadata...) + detail.SetKeyCallbacks([]types.KeyCallback{ + {Key: "d", Label: "delete", Callback: func() error { + if err := tuikitIO.DeleteArchiveEntry(entry.Path); err != nil { + container.SetNotice("unable to delete log entry", themes.OutputLevelError) + } else { + container.SetNotice("log entry deleted", themes.OutputLevelSuccess) + } + return nil + }}, + }) + return detail +} + +func logListView(container *tuikit.Container, entries []tuikitIO.ArchiveEntry) tuikit.View { + columns := []views.TableColumn{ + {Title: fmt.Sprintf("Logs (%d)", len(entries)), Percentage: 50}, + {Title: "Time", Percentage: 50}, + } + rows := make([]views.TableRow, 0, len(entries)) + for i, e := range entries { + rows = append(rows, views.TableRow{ + Data: []string{e.ID, e.Description(), fmt.Sprintf("%d", i)}, + }) + } + table := views.NewTable(container.RenderState(), columns, rows, views.TableDisplayMini) + table.SetOnSelect(func(_ int) error { + row := table.GetSelectedRow() + if row == nil || len(row.Data()) < 3 { + return fmt.Errorf("no log entry selected") + } + var idx int + if _, err := fmt.Sscanf(row.Data()[2], "%d", &idx); err != nil || idx >= len(entries) { + return fmt.Errorf("invalid log entry") + } + return container.SetView(logDetailView(container, entries[idx])) + }) + table.SetKeyCallbacks([]types.KeyCallback{ + {Key: "x", Label: "delete all", Callback: func() error { + for _, e := range entries { + _ = tuikitIO.DeleteArchiveEntry(e.Path) + } + container.SetNotice("all log entries deleted", themes.OutputLevelSuccess) + return nil + }}, + }) + return table +} diff --git a/internal/io/secret/views.go b/internal/io/secret/views.go index 03d9e8ca..001d96ae 100644 --- a/internal/io/secret/views.go +++ b/internal/io/secret/views.go @@ -3,21 +3,23 @@ package secret import ( "fmt" + "sort" "github.com/flowexec/tuikit" "github.com/flowexec/tuikit/themes" "github.com/flowexec/tuikit/types" "github.com/flowexec/tuikit/views" - vault2 "github.com/flowexec/flow/internal/vault" + ioCommon "github.com/flowexec/flow/internal/io/common" + "github.com/flowexec/flow/internal/vault" "github.com/flowexec/flow/pkg/context" "github.com/flowexec/flow/pkg/logger" ) func NewSecretView( ctx *context.Context, - vlt vault2.Vault, - ref vault2.SecretRef, + vlt vault.Vault, + ref vault.SecretRef, asPlainText bool, ) tuikit.View { container := ctx.TUIContainer @@ -37,7 +39,7 @@ func NewSecretView( return nil } - secret, err := vault2.NewSecret(vlt.ID(), ref.Key(), s) + secret, err := vault.NewSecret(vlt.ID(), ref.Key(), s) if err != nil { container.HandleError(fmt.Errorf("failure while initializing the secret view secret: %w", err)) return nil @@ -107,7 +109,7 @@ func NewSecretView( return nil } newValue := form.FindByKey("value").Value() - secretValue := vault2.NewSecretValue([]byte(newValue)) + secretValue := vault.NewSecretValue([]byte(newValue)) if err := vlt.SetSecret(ref.Key(), secretValue); err != nil { container.HandleError(fmt.Errorf("unable to edit secret: %w", err)) return nil @@ -117,6 +119,13 @@ func NewSecretView( return nil }, }, + { + Key: "c", Label: "copy", + Callback: func() error { + ioCommon.CopyToClipboard(container, secret.PlainTextString(), "secret copied to clipboard") + return nil + }, + }, { Key: "x", Label: "delete", Callback: func() error { @@ -131,12 +140,25 @@ func NewSecretView( }, } - return views.NewEntityView(container.RenderState(), secret, types.EntityFormatDocument, secretKeyCallbacks...) + valueStr := secret.String() + if asPlainText { + valueStr = secret.PlainTextString() + } + + body := valueStr + metadata := []views.DetailField{ + {Key: "Name", Value: ref.Key()}, + {Key: "Vault", Value: vlt.ID()}, + } + + detail := views.NewDetailView(container.RenderState(), body, metadata...) + detail.SetKeyCallbacks(secretKeyCallbacks) + return detail } func NewSecretListView( ctx *context.Context, - vlt vault2.Vault, + vlt vault.Vault, asPlainText bool, ) tuikit.View { container := ctx.TUIContainer @@ -146,14 +168,17 @@ func NewSecretListView( container.HandleError(fmt.Errorf("failed to list secrets: %w", err)) return nil } - secrets := make(vault2.SecretList, 0, len(keys)) + + sort.Strings(keys) + + secrets := make(vault.SecretList, 0, len(keys)) for _, key := range keys { s, err := vlt.GetSecret(key) if err != nil { container.HandleError(fmt.Errorf("failed to get secret %s: %w", key, err)) continue } - secret, err := vault2.NewSecret(vlt.ID(), key, s) + secret, err := vault.NewSecret(vlt.ID(), key, s) if err != nil { container.HandleError(fmt.Errorf("failed to create secret object for %s: %w", key, err)) continue @@ -166,29 +191,37 @@ func NewSecretListView( secrets = append(secrets, secret) } - if len(secrets.Items()) == 0 { + if len(secrets) == 0 { container.HandleError(fmt.Errorf("no secrets found")) } - selectFunc := func(filterVal string) error { - var secret vault2.Secret - var found bool + columns := []views.TableColumn{ + {Title: fmt.Sprintf("Secrets (%d)", len(secrets)), Percentage: 60}, + {Title: "Vault", Percentage: 40}, + } + rows := make([]views.TableRow, 0, len(secrets)) + for _, s := range secrets { + if s == nil { + continue + } + // Hidden cell [2] holds the full ref for lookup + rows = append(rows, views.TableRow{ + Data: []string{s.Ref().Key(), vlt.ID(), string(s.Ref())}, + }) + } + table := views.NewTable(container.RenderState(), columns, rows, views.TableDisplayMini) + table.SetOnSelect(func(_ int) error { + row := table.GetSelectedRow() + if row == nil || len(row.Data()) < 3 { + return fmt.Errorf("no secret selected") + } + ref := vault.SecretRef(row.Data()[2]) for _, s := range secrets { - if s == nil { - continue + if s != nil && s.Ref() == ref { + return container.SetView(NewSecretView(ctx, vlt, s.Ref(), asPlainText)) } - if string(s.Ref()) == filterVal { - secret = s - found = true - break - } - } - if !found || secret == nil { - return fmt.Errorf("secret not found") } - - return container.SetView(NewSecretView(ctx, vlt, secret.Ref(), asPlainText)) - } - - return views.NewCollectionView(container.RenderState(), secrets, types.CollectionFormatList, selectFunc) + return fmt.Errorf("secret not found") + }) + return table } diff --git a/internal/io/vault/view.go b/internal/io/vault/view.go index 191521c2..6ca9947c 100644 --- a/internal/io/vault/view.go +++ b/internal/io/vault/view.go @@ -4,14 +4,16 @@ import ( "encoding/json" "fmt" "slices" + "sort" + "strings" "github.com/flowexec/tuikit" - "github.com/flowexec/tuikit/types" "github.com/flowexec/tuikit/views" extVault "github.com/flowexec/vault" "golang.org/x/exp/maps" "gopkg.in/yaml.v3" + "github.com/flowexec/flow/internal/io/common" "github.com/flowexec/flow/internal/vault" ) @@ -32,7 +34,7 @@ func (v *vaultEntity) YAML() (string, error) { } func (v *vaultEntity) JSON() (string, error) { - data, err := yaml.Marshal(v) + data, err := json.MarshalIndent(v, "", " ") if err != nil { return "", err } @@ -65,31 +67,28 @@ func NewVaultView( if err != nil || v == nil { return views.NewErrorView(err, container.RenderState().Theme) } - return views.NewEntityView(container.RenderState(), v, types.EntityFormatDocument) -} -type vaultCollection struct { - Vaults []*vaultEntity `json:"vaults" yaml:"vaults"` -} + var footer string + if v.Path != "" { + footer = fmt.Sprintf("_Located in %s_", v.Path) + } -func (vc *vaultCollection) Singular() string { - return "vault" -} + opts := views.DetailContentOpts{ + Title: v.Name, + Subtitle: "Vault", + Metadata: []views.DetailField{ + {Key: "Type", Value: v.Type}, + }, + Body: vaultBodyMarkdown(v), + Footer: footer, + Entity: v, + } -func (vc *vaultCollection) Plural() string { - return "vaults" + return views.NewDetailContentView(container.RenderState(), opts) } -func (vc *vaultCollection) Items() []*types.EntityInfo { - items := make([]*types.EntityInfo, len(vc.Vaults)) - for i, v := range vc.Vaults { - items[i] = &types.EntityInfo{ - Header: v.Name, - SubHeader: v.Path, - ID: v.Name, - } - } - return items +type vaultCollection struct { + Vaults []*vaultEntity `json:"vaults" yaml:"vaults"` } func (vc *vaultCollection) YAML() (string, error) { @@ -112,7 +111,7 @@ func NewVaultListView( container *tuikit.Container, vaultNames []string, ) tuikit.View { - vaults := &vaultCollection{Vaults: make([]*vaultEntity, 0, len(vaultNames))} + vaults := make([]*vaultEntity, 0, len(vaultNames)) for _, name := range vaultNames { v, err := vaultFromName(name) if err != nil || v == nil { @@ -121,27 +120,101 @@ func NewVaultListView( container.RenderState().Theme, ) } - vaults.Vaults = append(vaults.Vaults, v) + vaults = append(vaults, v) } - if len(vaults.Vaults) == 0 { + if len(vaults) == 0 { return views.NewErrorView(fmt.Errorf("no vaults found"), container.RenderState().Theme) } - selectFunc := func(filterVal string) error { - for _, v := range vaults.Vaults { - if v.Name == filterVal { - return container.SetView(NewVaultView(container, v.Name)) - } + sort.Slice(vaults, func(i, j int) bool { + return vaults[i].Name < vaults[j].Name + }) + + columns := []views.TableColumn{ + {Title: fmt.Sprintf("Vaults (%d)", len(vaults)), Percentage: 35}, + {Title: "Type", Percentage: 25}, + {Title: "Path", Percentage: 40}, + } + rows := make([]views.TableRow, 0, len(vaults)) + for _, v := range vaults { + rows = append(rows, views.TableRow{ + Data: []string{v.Name, v.Type, common.ShortenPath(v.Path)}, + }) + } + table := views.NewTable(container.RenderState(), columns, rows, views.TableDisplayMini) + table.SetOnSelect(func(_ int) error { + row := table.GetSelectedRow() + if row == nil || len(row.Data()) == 0 { + return fmt.Errorf("no vault selected") } - return fmt.Errorf("vault not found") + return container.SetView(NewVaultView(container, row.Data()[0])) + }) + return table +} + +func vaultBodyMarkdown(v *vaultEntity) string { + if v.Data == nil { + return "" } - return views.NewCollectionView( - container.RenderState(), - vaults, - types.CollectionFormatList, - selectFunc, - ) + var sections []string + + if created, ok := v.Data["created"]; ok { + sections = append(sections, fmt.Sprintf("**Created:** %v", created)) + } + if modified, ok := v.Data["lastModified"]; ok { + sections = append(sections, fmt.Sprintf("**Last Modified:** %v", modified)) + } + + if sources, ok := v.Data["sources"]; ok { + sections = append(sections, formatSourceList("Sources", sources)) + } + if recipients, ok := v.Data["recipients"]; ok { + sections = append(sections, formatSourceList("Recipients", recipients)) + } + + return strings.Join(sections, "\n\n") +} + +// formatSourceList formats KeySource, IdentitySource slices, or plain strings +// into a readable markdown list. +func formatSourceList(label string, value any) string { + switch v := value.(type) { + case []extVault.KeySource: + if len(v) == 0 { + return "" + } + md := fmt.Sprintf("**%s**\n", label) + for _, src := range v { + md += fmt.Sprintf("- `%s`", src.Type) + if src.Path != "" { + md += fmt.Sprintf(" — %s", src.Path) + } else if src.Name != "" { + md += fmt.Sprintf(" — $%s", src.Name) + } + md += "\n" + } + return md + case []extVault.IdentitySource: + if len(v) == 0 { + return "" + } + md := fmt.Sprintf("**%s**\n", label) + for _, src := range v { + md += fmt.Sprintf("- `%s`", src.Type) + if src.Path != "" { + md += fmt.Sprintf(" — %s", src.Path) + } else if src.Name != "" { + md += fmt.Sprintf(" — $%s", src.Name) + } + md += "\n" + } + return md + case string: + return fmt.Sprintf("**%s:** `%s`", label, v) + default: + return fmt.Sprintf("**%s:** %v", label, v) + } } func vaultFromName(vaultName string) (*vaultEntity, error) { diff --git a/internal/io/workspace/detail.go b/internal/io/workspace/detail.go new file mode 100644 index 00000000..28518b5f --- /dev/null +++ b/internal/io/workspace/detail.go @@ -0,0 +1,106 @@ +package workspace + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/flowexec/tuikit/views" + + "github.com/flowexec/flow/internal/io/common" + "github.com/flowexec/flow/types/workspace" +) + +func workspaceDetailOpts(ws *workspace.Workspace) views.DetailContentOpts { + title := ws.AssignedName() + if ws.DisplayName != "" { + title = ws.DisplayName + } + + return views.DetailContentOpts{ + Title: title, + Subtitle: "Workspace", + Tags: common.ColorizeTags(ws.Tags), + Metadata: workspaceMetadataFields(ws), + Body: workspaceBodyMarkdown(ws), + Footer: fmt.Sprintf("_Located in %s_", ws.Location()), + Entity: ws, + } +} + +func workspaceMetadataFields(ws *workspace.Workspace) []views.DetailField { + const maxFields = 2 + var candidates []views.DetailField + + // Show assigned name only when it differs from the display title + if ws.DisplayName != "" && ws.DisplayName != ws.AssignedName() { + candidates = append(candidates, views.DetailField{Key: "Name", Value: ws.AssignedName()}) + } + + if len(ws.EnvFiles) > 0 { + candidates = append(candidates, views.DetailField{Key: "Env Files", Value: strings.Join(ws.EnvFiles, ", ")}) + } + + if len(candidates) > maxFields { + candidates = candidates[:maxFields] + } + return candidates +} + +func workspaceBodyMarkdown(ws *workspace.Workspace) string { + var sections []string + + if desc := wsDescription(ws); desc != "" { + sections = append(sections, desc) + } + + if ws.VerbAliases != nil && len(*ws.VerbAliases) > 0 { + md := "## Verb Aliases\n" + for verb, mapped := range *ws.VerbAliases { + md += fmt.Sprintf("- **%s** → %s\n", verb, strings.Join(mapped, ", ")) + } + sections = append(sections, md) + } + + if ws.Executables != nil { + var lines []string + if len(ws.Executables.Included) > 0 { + lines = append(lines, "**Included**") + for _, p := range ws.Executables.Included { + lines = append(lines, fmt.Sprintf("- `%s`", p)) + } + } + if len(ws.Executables.Excluded) > 0 { + lines = append(lines, "**Excluded**") + for _, p := range ws.Executables.Excluded { + lines = append(lines, fmt.Sprintf("- `%s`", p)) + } + } + if len(lines) > 0 { + sections = append(sections, "## Executable Filters\n"+strings.Join(lines, "\n")) + } + } + + if len(sections) == 0 { + return "*No description available.*" + } + return strings.Join(sections, "\n\n") +} + +func wsDescription(ws *workspace.Workspace) string { + var parts []string + if d := strings.TrimSpace(ws.Description); d != "" { + parts = append(parts, d) + } + if ws.DescriptionFile != "" { + wsFile := filepath.Join(ws.Location(), ws.DescriptionFile) + mdBytes, err := os.ReadFile(filepath.Clean(wsFile)) + if err != nil { + parts = append(parts, fmt.Sprintf("**Error loading description file:** %s", err)) + } else if d := strings.TrimSpace(string(mdBytes)); d != "" { + parts = append(parts, d) + } + } + return strings.Join(parts, "\n\n") +} diff --git a/internal/io/workspace/views.go b/internal/io/workspace/views.go index d33a9e69..3a0e8819 100644 --- a/internal/io/workspace/views.go +++ b/internal/io/workspace/views.go @@ -3,6 +3,7 @@ package workspace import ( "fmt" "path/filepath" + "sort" "github.com/flowexec/tuikit" "github.com/flowexec/tuikit/themes" @@ -60,7 +61,8 @@ func NewWorkspaceView( }, } - return views.NewEntityView(container.RenderState(), ws, types.EntityFormatDocument, workspaceKeyCallbacks...) + opts := workspaceDetailOpts(ws) + return views.NewDetailContentView(container.RenderState(), opts, workspaceKeyCallbacks...) } func NewWorkspaceListView( @@ -68,24 +70,42 @@ func NewWorkspaceListView( workspaces workspace.WorkspaceList, ) tuikit.View { container := ctx.TUIContainer - if len(workspaces.Items()) == 0 { + if len(workspaces) == 0 { container.HandleError(fmt.Errorf("no workspaces found")) } - selectFunc := func(filterVal string) error { - var ws *workspace.Workspace - for _, s := range workspaces { - if s.AssignedName() == filterVal || s.DisplayName == filterVal { - ws = s - break - } + sort.Slice(workspaces, func(i, j int) bool { + return workspaces[i].AssignedName() < workspaces[j].AssignedName() + }) + + columns := []views.TableColumn{ + {Title: fmt.Sprintf("Workspaces (%d)", len(workspaces)), Percentage: 35}, + {Title: "Tags", Percentage: 30}, + {Title: "Location", Percentage: 35}, + } + rows := make([]views.TableRow, 0, len(workspaces)) + for _, ws := range workspaces { + name := ws.AssignedName() + if ws.DisplayName != "" { + name = ws.DisplayName + } + tags := common.ColorizeTags(ws.Tags) + rows = append(rows, views.TableRow{ + Data: []string{name, tags, common.ShortenPath(ws.Location()), ws.AssignedName()}, + }) + } + table := views.NewTable(container.RenderState(), columns, rows, views.TableDisplayMini) + table.SetOnSelect(func(_ int) error { + row := table.GetSelectedRow() + if row == nil || len(row.Data()) < 4 { + return fmt.Errorf("no workspace selected") } + // Hidden cell [3] holds the assigned name for lookup + ws := workspaces.FindByName(row.Data()[3]) if ws == nil { return fmt.Errorf("workspace not found") } - return ctx.SetView(NewWorkspaceView(ctx, ws)) - } - - return views.NewCollectionView(container.RenderState(), workspaces, types.CollectionFormatList, selectFunc) + }) + return table } diff --git a/internal/mcp/command_executor.go b/internal/mcp/command_executor.go index 1c67a8bc..e2f41077 100644 --- a/internal/mcp/command_executor.go +++ b/internal/mcp/command_executor.go @@ -27,7 +27,7 @@ func (c *FlowCLIExecutor) Execute(args ...string) (string, error) { if envName := os.Getenv(cliBinaryEnvKey); envName != "" { name = envName } - cmd := exec.Command(name, args...) + cmd := exec.Command(name, args...) // #nosec G204,G702 output, err := cmd.CombinedOutput() if err != nil { // Only return an error if it's not an exit error. diff --git a/internal/runner/exec/exec.go b/internal/runner/exec/exec.go index a6e8fac0..081200e4 100644 --- a/internal/runner/exec/exec.go +++ b/internal/runner/exec/exec.go @@ -79,9 +79,9 @@ func (r *execRunner) Exec( case execSpec.Cmd != "" && execSpec.File != "": return errors.New("cannot set both cmd and file") case execSpec.Cmd != "": - return run.RunCmd(execSpec.Cmd, targetDir, envList, logMode, logger.Log(), ctx.StdIn(), logFields) + return run.RunCmd(execSpec.Cmd, targetDir, envList, logMode, logger.Log(), ctx.StdIn(), logFields, ctx.CurrentTask) case execSpec.File != "": - return run.RunFile(execSpec.File, targetDir, envList, logMode, logger.Log(), ctx.StdIn(), logFields) + return run.RunFile(execSpec.File, targetDir, envList, logMode, logger.Log(), ctx.StdIn(), logFields, ctx.CurrentTask) default: return errors.New("unable to determine how e should be run") } diff --git a/internal/runner/parallel/parallel.go b/internal/runner/parallel/parallel.go index 53819fef..24466023 100644 --- a/internal/runner/parallel/parallel.go +++ b/internal/runner/parallel/parallel.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "github.com/flowexec/tuikit/io" "github.com/jahvon/expression" "github.com/pkg/errors" "golang.org/x/sync/errgroup" @@ -109,11 +110,14 @@ func handleExec( ctx.ProcessTmpDir = targetDir } - // Build the list of steps to execute - var execs []engine.Exec - + // Resolve all executables first to count duplicate refs + type resolvedExec struct { + exec *executable.Executable + ref string + } + resolved := make([]resolvedExec, 0, len(parallelSpec.Execs)) + refCounts := make(map[string]int) for i, refConfig := range parallelSpec.Execs { - // Get the executable for the step var exec *executable.Executable switch { case len(refConfig.Ref) > 0: @@ -127,6 +131,18 @@ func handleExec( default: return errors.New("parallel executable must have a ref or cmd") } + ref := exec.Ref().String() + refCounts[ref]++ + resolved = append(resolved, resolvedExec{exec: exec, ref: ref}) + } + refIdx := make(map[string]int) + + // Build the list of steps to execute + tracker := io.NewTaskTracker() + var execs []engine.Exec + + for i, refConfig := range parallelSpec.Execs { + exec := resolved[i].exec // Prepare the environment and arguments for the child executable childEnv := make(map[string]string) @@ -158,7 +174,7 @@ func handleExec( a, err := envUtils.BuildArgsEnvMap(execEnv.Args, childArgs, childEnv) if err != nil { - logger.Log().Error(err, "unable to process arguments") + logger.Log().WrapError(err, "unable to process arguments") } maps.Copy(childEnv, a) } @@ -190,11 +206,23 @@ func handleExec( } } + ref := resolved[i].ref + refIdx[ref]++ + taskName := ref + if refCounts[ref] > 1 { + taskName = fmt.Sprintf("%s · %d", ref, refIdx[ref]) + } runExec := func() error { - err := runner.Exec(ctx, exec, eng, childEnv, childArgs) + task := tracker.StartTask(taskName) + // Shallow-copy the context so each goroutine has its own CurrentTask + taskCtx := *ctx + taskCtx.CurrentTask = task + err := runner.Exec(&taskCtx, exec, eng, childEnv, childArgs) if err != nil { + tracker.CompleteTask(task, io.TaskFailed, err) return err } + tracker.CompleteTask(task, io.TaskSuccess, nil) return nil } @@ -219,7 +247,7 @@ func handleExec( return false, err } if err := str.Close(); err != nil { - logger.Log().Error(err, "unable to close store") + logger.Log().WrapError(err, "unable to close store") } conditionalData := runner.ExpressionEnv(ctx, parent, cacheData, inputEnv) @@ -228,6 +256,7 @@ func handleExec( return false, err } if !truthy { + tracker.StartTask(taskName).Status = io.TaskSkipped logger.Log().Debugf("skipping execution %d/%d", stepNum, totalSteps) } else { logger.Log().Debugf("condition %s is true", ifCondition) @@ -244,14 +273,28 @@ func handleExec( }) } + parentTask := ctx.CurrentTask + if parentTask == nil { + if tal, ok := logger.Log().(io.TaskAwareLogger); ok { + tal.BeginGroup(parent.Ref().String()) + } + } results := eng.Execute( ctx, execs, engine.WithMode(engine.Parallel), engine.WithFailFast(parent.Parallel.FailFast), engine.WithMaxThreads(parent.Parallel.MaxThreads), ) + if parentTask != nil { + parentTask.Children = append(parentTask.Children, tracker.Tasks()...) + } else { + if tal, ok := logger.Log().(io.TaskAwareLogger); ok { + tal.EndGroup() + tal.PrintTaskSummary(tracker.Tasks()) + } + } if results.HasErrors() { - return errors.New(results.String()) + return fmt.Errorf("parallel execution failed") } return nil } diff --git a/internal/runner/request/request.go b/internal/runner/request/request.go index 6c00ac87..9ae41c97 100644 --- a/internal/runner/request/request.go +++ b/internal/runner/request/request.go @@ -85,7 +85,7 @@ func (r *requestRunner) Exec( log := logger.Log() if requestSpec.LogResponse { - log.Infox(fmt.Sprintf("Successfully sent request to %s", requestSpec.URL), "response", respStr) + log.Info(fmt.Sprintf("Successfully sent request to %s", requestSpec.URL), "response", respStr) } else { log.Infof("Successfully sent request to %s", requestSpec.URL) } @@ -160,7 +160,7 @@ func writeResponseToFile(resp, responseFile string, format executable.RequestRes } if conversionErr != nil { - logger.Log().Error(conversionErr, "unable to convert response") + logger.Log().WrapError(conversionErr, "unable to convert response") } file, err := os.Create(filepath.Clean(responseFile)) diff --git a/internal/runner/request/request_test.go b/internal/runner/request/request_test.go index a01dd084..ebb52677 100644 --- a/internal/runner/request/request_test.go +++ b/internal/runner/request/request_test.go @@ -88,7 +88,7 @@ var _ = Describe("Request Runner", func() { }, } - ctx.Logger.EXPECT().Infox(gomock.Any(), gomock.Any(), gomock.Any()).Times(1) + ctx.Logger.EXPECT().Info(gomock.Any(), gomock.Any(), gomock.Any()).Times(1) err := requestRnr.Exec(ctx.Ctx, exec, mockEngine, make(map[string]string), nil) Expect(err).NotTo(HaveOccurred()) }) @@ -103,7 +103,7 @@ var _ = Describe("Request Runner", func() { }, } - ctx.Logger.EXPECT().Infox(gomock.Any(), gomock.Any(), gomock.Regex("value")).Times(1) + ctx.Logger.EXPECT().Info(gomock.Any(), gomock.Any(), gomock.Regex("value")).Times(1) err := requestRnr.Exec(ctx.Ctx, exec, mockEngine, make(map[string]string), nil) Expect(err).NotTo(HaveOccurred()) }) @@ -140,7 +140,7 @@ var _ = Describe("Request Runner", func() { }, } - ctx.Logger.EXPECT().Infox(gomock.Any(), gomock.Any(), gomock.Regex("SUCCESSFUL")).Times(1) + ctx.Logger.EXPECT().Info(gomock.Any(), gomock.Any(), gomock.Regex("SUCCESSFUL")).Times(1) err := requestRnr.Exec(ctx.Ctx, exec, mockEngine, make(map[string]string), nil) Expect(err).NotTo(HaveOccurred()) }) diff --git a/internal/runner/serial/serial.go b/internal/runner/serial/serial.go index 10df16ce..45bdfb0e 100644 --- a/internal/runner/serial/serial.go +++ b/internal/runner/serial/serial.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/flowexec/tuikit/io" "github.com/jahvon/expression" "github.com/pkg/errors" @@ -98,11 +99,14 @@ func handleExec( ctx.ProcessTmpDir = targetDir } - // Build the list of steps to execute - var execs []engine.Exec - + // Resolve all executables first to count duplicate refs + type resolvedExec struct { + exec *executable.Executable + ref string + } + resolved := make([]resolvedExec, 0, len(serialSpec.Execs)) + refCounts := make(map[string]int) for i, refConfig := range serialSpec.Execs { - // Get the executable for the step var exec *executable.Executable switch { case refConfig.Ref != "": @@ -116,6 +120,18 @@ func handleExec( default: return errors.New("serial executable must have a ref or cmd") } + ref := exec.Ref().String() + refCounts[ref]++ + resolved = append(resolved, resolvedExec{exec: exec, ref: ref}) + } + refIdx := make(map[string]int) + + // Build the list of steps to execute + tracker := io.NewTaskTracker() + var execs []engine.Exec + + for i, refConfig := range serialSpec.Execs { + exec := resolved[i].exec // Prepare the environment and arguments for the child executable childEnv := make(map[string]string) @@ -147,7 +163,7 @@ func handleExec( a, err := envUtils.BuildArgsEnvMap(execEnv.Args, childArgs, childEnv) if err != nil { - logger.Log().Error(err, "unable to process arguments") + logger.Log().WrapError(err, "unable to process arguments") } maps.Copy(childEnv, a) } @@ -179,8 +195,22 @@ func handleExec( } } + ref := resolved[i].ref + refIdx[ref]++ + taskName := ref + if refCounts[ref] > 1 { + taskName = fmt.Sprintf("%s · %d", ref, refIdx[ref]) + } runExec := func() error { - return runSerialExecFunc(ctx, i, refConfig, exec, eng, childEnv, childArgs, serialSpec) + task := tracker.StartTask(taskName) + ctx.CurrentTask = task + err := runSerialExecFunc(ctx, i, refConfig, exec, eng, childEnv, childArgs, serialSpec) + if err != nil { + tracker.CompleteTask(task, io.TaskFailed, err) + return err + } + tracker.CompleteTask(task, io.TaskSuccess, nil) + return nil } // Create condition function if needed @@ -204,7 +234,7 @@ func handleExec( return false, err } if err := str.Close(); err != nil { - logger.Log().Error(err, "unable to close store") + logger.Log().WrapError(err, "unable to close store") } conditionalData := runner.ExpressionEnv(ctx, parent, cacheData, inputEnv) @@ -213,6 +243,7 @@ func handleExec( return false, err } if !truthy { + tracker.StartTask(taskName).Status = io.TaskSkipped logger.Log().Debugf("skipping execution %d/%d", stepNum, totalSteps) } else { logger.Log().Debugf("condition %s is true", ifCondition) @@ -229,9 +260,24 @@ func handleExec( }) } + parentTask := ctx.CurrentTask + if parentTask == nil { + if tal, ok := logger.Log().(io.TaskAwareLogger); ok { + tal.BeginGroup(parent.Ref().String()) + } + } results := eng.Execute(ctx, execs, engine.WithMode(engine.Serial), engine.WithFailFast(parent.Serial.FailFast)) + ctx.CurrentTask = nil + if parentTask != nil { + parentTask.Children = append(parentTask.Children, tracker.Tasks()...) + } else { + if tal, ok := logger.Log().(io.TaskAwareLogger); ok { + tal.EndGroup() + tal.PrintTaskSummary(tracker.Tasks()) + } + } if results.HasErrors() { - return errors.New(results.String()) + return fmt.Errorf("serial execution failed") } return nil } diff --git a/internal/services/run/run.go b/internal/services/run/run.go index 35c692a7..a617d2f4 100644 --- a/internal/services/run/run.go +++ b/internal/services/run/run.go @@ -27,6 +27,7 @@ func RunCmd( logger io.Logger, stdIn *os.File, logFields map[string]interface{}, + task *io.TaskContext, ) error { logger.Debugf("running command in dir (%s):\n%s", dir, strings.TrimSpace(commandStr)) @@ -52,8 +53,8 @@ func RunCmd( interp.Env(expand.ListEnviron(envList...)), interp.StdIO( stdIn, - stdOutWriter(logMode, logger, flattenedFields...), - stdErrWriter(logMode, logger, flattenedFields...), + stdOutWriter(logMode, logger, task, flattenedFields...), + stdErrWriter(logMode, logger, task, flattenedFields...), ), ) if err != nil { @@ -80,6 +81,7 @@ func RunFile( logger io.Logger, stdIn *os.File, logFields map[string]interface{}, + task *io.TaskContext, ) error { logger.Debugf("executing file (%s)", filepath.Join(dir, filename)) @@ -113,8 +115,8 @@ func RunFile( interp.Env(expand.ListEnviron(envList...)), interp.StdIO( stdIn, - stdOutWriter(logMode, logger, flattenedFields...), - stdErrWriter(logMode, logger, flattenedFields...), + stdOutWriter(logMode, logger, task, flattenedFields...), + stdErrWriter(logMode, logger, task, flattenedFields...), ), ) if err != nil { @@ -132,12 +134,12 @@ func RunFile( return nil } -func stdOutWriter(mode io.LogMode, logger io.Logger, logFields ...any) stdio.Writer { - return io.StdOutWriter{LogFields: logFields, Logger: logger, LogMode: &mode} +func stdOutWriter(mode io.LogMode, logger io.Logger, task *io.TaskContext, logFields ...any) stdio.Writer { + return io.StdOutWriter{LogFields: logFields, Logger: logger, LogMode: &mode, Task: task} } -func stdErrWriter(mode io.LogMode, logger io.Logger, logFields ...any) stdio.Writer { - return io.StdErrWriter{LogFields: logFields, Logger: logger, LogMode: &mode} +func stdErrWriter(mode io.LogMode, logger io.Logger, task *io.TaskContext, logFields ...any) stdio.Writer { + return io.StdErrWriter{LogFields: logFields, Logger: logger, LogMode: &mode, Task: task} } func setupColorEnvironment() { diff --git a/internal/services/run/run_test.go b/internal/services/run/run_test.go index d144b463..b05ecc1a 100644 --- a/internal/services/run/run_test.go +++ b/internal/services/run/run_test.go @@ -42,7 +42,7 @@ var _ = Describe("Run", func() { logger.EXPECT().LogMode().DoAndReturn(func() tuikitIO.LogMode { return tuikitIO.Hidden }).AnyTimes() - err := run.RunCmd("echo \"foo\"", "", nil, tuikitIO.Hidden, logger, os.Stdin, nil) + err := run.RunCmd("echo \"foo\"", "", nil, tuikitIO.Hidden, logger, os.Stdin, nil, nil) Expect(err).NotTo(HaveOccurred()) }) }) @@ -54,7 +54,7 @@ var _ = Describe("Run", func() { }).AnyTimes() logger.EXPECT().Print("foo").Times(1) logger.EXPECT().Print("\n").Times(1) - err := run.RunCmd("echo \"foo\"", "", nil, tuikitIO.Text, logger, os.Stdin, nil) + err := run.RunCmd("echo \"foo\"", "", nil, tuikitIO.Text, logger, os.Stdin, nil, nil) Expect(err).NotTo(HaveOccurred()) }) }) @@ -64,8 +64,8 @@ var _ = Describe("Run", func() { logger.EXPECT().LogMode().DoAndReturn(func() tuikitIO.LogMode { return tuikitIO.Logfmt }).AnyTimes() - logger.EXPECT().Infof("foo", gomock.Any()).Times(1) - err := run.RunCmd("echo \"foo\"", "", nil, tuikitIO.Logfmt, logger, os.Stdin, nil) + logger.EXPECT().Info("foo").Times(1) + err := run.RunCmd("echo \"foo\"", "", nil, tuikitIO.Logfmt, logger, os.Stdin, nil, nil) Expect(err).NotTo(HaveOccurred()) }) }) @@ -75,8 +75,8 @@ var _ = Describe("Run", func() { logger.EXPECT().LogMode().DoAndReturn(func() tuikitIO.LogMode { return tuikitIO.JSON }).AnyTimes() - logger.EXPECT().Infof("foo", gomock.Any()).Times(1) - err := run.RunCmd("echo \"foo\"", "", nil, tuikitIO.JSON, logger, os.Stdin, nil) + logger.EXPECT().Info("foo").Times(1) + err := run.RunCmd("echo \"foo\"", "", nil, tuikitIO.JSON, logger, os.Stdin, nil, nil) Expect(err).NotTo(HaveOccurred()) }) }) @@ -87,8 +87,8 @@ var _ = Describe("Run", func() { return tuikitIO.Logfmt }).AnyTimes() fields := map[string]interface{}{"key": "value"} - logger.EXPECT().Infox("foo", "key", "value").Times(1) - err := run.RunCmd("echo \"foo\"", "", nil, tuikitIO.JSON, logger, os.Stdin, fields) + logger.EXPECT().Info("foo", "key", "value").Times(1) + err := run.RunCmd("echo \"foo\"", "", nil, tuikitIO.JSON, logger, os.Stdin, fields, nil) Expect(err).NotTo(HaveOccurred()) }) }) @@ -99,8 +99,8 @@ var _ = Describe("Run", func() { return tuikitIO.Logfmt }).AnyTimes() env := []string{"key=value"} - logger.EXPECT().Infof("value", gomock.Any()).Times(1) - err := run.RunCmd("echo \"$key\"", "", env, tuikitIO.JSON, logger, os.Stdin, nil) + logger.EXPECT().Info("value").Times(1) + err := run.RunCmd("echo \"$key\"", "", env, tuikitIO.JSON, logger, os.Stdin, nil, nil) Expect(err).NotTo(HaveOccurred()) }) }) @@ -131,7 +131,7 @@ var _ = Describe("Run", func() { logger.EXPECT().Print("\n").Times(1) filename := filepath.Base(testfile.Name()) filedir := filepath.Dir(testfile.Name()) - err := run.RunFile(filename, filedir, nil, tuikitIO.Logfmt, logger, os.Stdin, nil) + err := run.RunFile(filename, filedir, nil, tuikitIO.Logfmt, logger, os.Stdin, nil, nil) Expect(err).NotTo(HaveOccurred()) }) }) diff --git a/internal/templates/artifacts.go b/internal/templates/artifacts.go index 0f43ec05..7edbf44f 100644 --- a/internal/templates/artifacts.go +++ b/internal/templates/artifacts.go @@ -121,10 +121,10 @@ func copyArtifact( return errors.Wrap(err, "unable to create destination directory") } - logger.Log().Debugx("copying artifact", "name", name, "src", srcPath, "dst", dstPath) + logger.Log().Debug("copying artifact", "name", name, "src", srcPath, "dst", dstPath) if _, e := os.Stat(dstPath); e == nil { // TODO: Add a flag to overwrite existing files - logger.Log().Warnx("Overwriting existing file", "dst", dstPath) + logger.Log().Warn("Overwriting existing file", "dst", dstPath) } if err := filesystem.CopyFile(srcPath, dstPath); err != nil { return errors.Wrap(err, "unable to copy artifact") diff --git a/internal/templates/templates.go b/internal/templates/templates.go index 4724eb0c..810a4745 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -55,7 +55,7 @@ func ProcessTemplate( } flowfileDir = utils.ExpandDirectory(flowfileDir, ws.Location(), template.Location(), envMap) fullPath := filepath.Join(flowfileDir, flowfileName) - logger.Log().Debugx( + logger.Log().Debug( fmt.Sprintf("processing %s template", flowfileName), "template", template.Location(), "output", fullPath, ) @@ -89,7 +89,7 @@ func ProcessTemplate( if _, e := os.Stat(fullPath); e == nil { // TODO: Add a flag to overwrite existing files - logger.Log().Warnx("Overwriting existing file", "dst", fullPath) + logger.Log().Warn("Overwriting existing file", "dst", fullPath) } if err := filesystem.WriteFlowFile(fullPath, flowfile); err != nil { @@ -166,7 +166,7 @@ func runExecutables( } else { a, err := argUtils.BuildArgsEnvMap(execEnv.Args, inputArgs, ee) if err != nil { - logger.Log().Error(err, "unable to process arguments") + logger.Log().WrapError(err, "unable to process arguments") } maps.Copy(inputEnv, a) } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index ce32c8c0..c8ad9893 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -29,7 +29,7 @@ func ExpandPath(path, fallbackDir string, env map[string]string) string { case path == "." || strings.HasPrefix(path, "./"): wd, err := os.Getwd() if err != nil { - logger.Log().Warnx("unable to get working directory for relative path expansion", "err", err) + logger.Log().Warn("unable to get working directory for relative path expansion", "err", err) targetPath = filepath.Join(fallbackDir, path) } else { targetPath = filepath.Join(wd, path[1:]) @@ -37,7 +37,7 @@ func ExpandPath(path, fallbackDir string, env map[string]string) string { case strings.HasPrefix(path, "~/"): homeDir, err := os.UserHomeDir() if err != nil { - logger.Log().Warnx("unable to get user home directory for relative path expansion", "err", err) + logger.Log().Warn("unable to get user home directory for relative path expansion", "err", err) targetPath = filepath.Join(fallbackDir, path) } else { targetPath = filepath.Join(homeDir, path[2:]) @@ -51,13 +51,13 @@ func ExpandPath(path, fallbackDir string, env map[string]string) string { targetPath = os.Expand(targetPath, func(key string) string { val, found := env[key] if !found { - logger.Log().Warnx("unable to find env key in path expansion", "key", key) + logger.Log().Warn("unable to find env key in path expansion", "key", key) } return val }) if err := validateSecurePath(targetPath); err != nil { - logger.Log().Fatalx("path failed security validation", "path", targetPath, "err", err) + logger.Log().Fatal("path failed security validation", "path", targetPath, "err", err) return "" // Shouldn't get here with fatal logger, but just in case } diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go index 61430e3e..4dcd4049 100644 --- a/internal/utils/utils_test.go +++ b/internal/utils/utils_test.go @@ -69,7 +69,7 @@ var _ = Describe("Utils", func() { }) It("logs a warning if the env var is not found", func() { envMap := map[string]string{"VAR1": "one"} - mockLogger.EXPECT().Warnx("unable to find env key in path expansion", "key", "VAR2") + mockLogger.EXPECT().Warn("unable to find env key in path expansion", "key", "VAR2") Expect(utils.ExpandDirectory("/${VAR1}/${VAR2}", wsDir, execPath, envMap)). To(Equal("/one")) }) diff --git a/internal/vault/demo.go b/internal/vault/demo.go index fb91224e..a1657efa 100644 --- a/internal/vault/demo.go +++ b/internal/vault/demo.go @@ -70,7 +70,7 @@ func newDemoVaultConfig() *VaultConfig { //nolint:lll func demoData() map[string]string { - return map[string]string{ + return map[string]string{ // #nosec G101 // Basic secrets (common use cases) "api-key": "demo-api-key-12345-dont-use-in-production", "database-url": "postgres://demo:password@localhost:5432/flowdb", diff --git a/internal/vault/vault.go b/internal/vault/vault.go index a8eac540..6a63ed5a 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -61,7 +61,7 @@ func NewAES256Vault(name, storagePath, keyEnv, keyFile, logLevel string) { } opts = append(opts, vault.WithAESKeyFromFile(keyFile)) if err := writeKeyToFile(key, keyFile); err != nil { - logger.Log().Warnx("unable to write key to file", "err", err) + logger.Log().Warn("unable to write key to file", "err", err) } } @@ -267,7 +267,7 @@ func writeKeyToFile(key, filePath string) error { return fmt.Errorf("unable to create directory for key file: %w", err) } - if err := os.WriteFile(filePath, []byte(key), 0600); err != nil { + if err := os.WriteFile(filePath, []byte(key), 0600); err != nil { // #nosec G703 return fmt.Errorf("unable to write key to file: %w", err) } logger.Log().Infof("Key written to file: %s", filePath) diff --git a/cmd/internal/version/version.go b/internal/version/version.go similarity index 91% rename from cmd/internal/version/version.go rename to internal/version/version.go index 3f00151c..becaa04f 100644 --- a/cmd/internal/version/version.go +++ b/internal/version/version.go @@ -53,3 +53,11 @@ OS / Arch : %s func String() string { return generateOutput() } + +func SemVer() string { + if version == unknown { + return "" + } + + return strings.TrimSpace(version) +} diff --git a/pkg/cache/executables_cache.go b/pkg/cache/executables_cache.go index 74259fc4..f70e8ed6 100644 --- a/pkg/cache/executables_cache.go +++ b/pkg/cache/executables_cache.go @@ -67,14 +67,14 @@ func (c *ExecutableCacheImpl) Update() error { //nolint:gocognit wsCfg.SetContext(name, wsCacheData.WorkspaceLocations[name]) flowFiles, err := filesystem.LoadWorkspaceFlowFiles(wsCfg) if err != nil { - logger.Log().Errorx("failed to load workspace executable configs", "workspace", wsCfg.AssignedName(), "err", err) + logger.Log().Error("failed to load workspace executable configs", "workspace", wsCfg.AssignedName(), "err", err) continue } for _, flowFile := range flowFiles { if len(flowFile.FromFile) > 0 || len(flowFile.Imports) > 0 { generated, err := fileparser.ExecutablesFromImports(name, flowFile) if err != nil { - logger.Log().Errorx( + logger.Log().Error( "failed to generate executables from files", "flowFilePath", flowFile.ConfigPath(), "err", err, @@ -90,7 +90,7 @@ func (c *ExecutableCacheImpl) Update() error { //nolint:gocognit } for _, e := range flowFile.Executables { if vErr := e.Validate(); vErr != nil { - logger.Log().Warnx( + logger.Log().Warn( "invalid executable found during cache update", "ref", e.Ref().String(), "workspace", wsCfg.AssignedName(), @@ -104,7 +104,7 @@ func (c *ExecutableCacheImpl) Update() error { //nolint:gocognit } if existingPath, exists := cacheData.ExecutableMap[e.Ref()]; exists && existingPath != flowFile.ConfigPath() { - logger.Log().Warnx( + logger.Log().Warn( "duplicate executable found during cache update", "ref", e.Ref().String(), "conflictPath", existingPath, @@ -117,7 +117,7 @@ func (c *ExecutableCacheImpl) Update() error { //nolint:gocognit for _, ref := range enumerateExecutableAliasRefs(e, wsCfg.VerbAliases) { if existingPrimaryRef, exists := cacheData.AliasMap[ref]; exists && existingPrimaryRef != e.Ref() { - logger.Log().Warnx( + logger.Log().Warn( "duplicate executable alias found during cache update", "aliasRef", ref.String(), "conflictRef", existingPrimaryRef.String(), @@ -146,7 +146,7 @@ func (c *ExecutableCacheImpl) Update() error { //nolint:gocognit return errors.Wrap(err, "unable to write cache data") } - logger.Log().Debugx("Successfully updated executable cache data", "count", len(cacheData.ExecutableMap)) + logger.Log().Debug("Successfully updated executable cache data", "count", len(cacheData.ExecutableMap)) return nil } @@ -196,7 +196,7 @@ func (c *ExecutableCacheImpl) GetExecutableByRef(ref executable.Ref) (*executabl generated, err := fileparser.ExecutablesFromImports(wsInfo.WorkspaceName, cfg) if err != nil { - logger.Log().Warnx( + logger.Log().Warn( "failed to generate executables from files", "cfgPath", cfgPath, "err", err, @@ -229,12 +229,12 @@ func (c *ExecutableCacheImpl) GetExecutableList() (executable.ExecutableList, er for cfgPath := range c.Data.ConfigMap { cfg, err := filesystem.LoadFlowFile(cfgPath) if err != nil { - logger.Log().Errorx("unable to load executable config", "cfgPath", cfgPath, "err", err) + logger.Log().Error("unable to load executable config", "cfgPath", cfgPath, "err", err) continue } wsInfo, found := c.Data.ConfigMap[cfgPath] if !found { - logger.Log().Errorx("unable to find workspace info for config", "cfgPath", cfgPath) + logger.Log().Error("unable to find workspace info for config", "cfgPath", cfgPath) continue } cfg.SetDefaults() @@ -242,7 +242,7 @@ func (c *ExecutableCacheImpl) GetExecutableList() (executable.ExecutableList, er generated, err := fileparser.ExecutablesFromImports(wsInfo.WorkspaceName, cfg) if err != nil { - logger.Log().Warnx( + logger.Log().Warn( "failed to generate executables from files", "cfgPath", cfgPath, "err", err, diff --git a/pkg/cache/executables_cache_test.go b/pkg/cache/executables_cache_test.go index da5607d2..b1540127 100644 --- a/pkg/cache/executables_cache_test.go +++ b/pkg/cache/executables_cache_test.go @@ -82,8 +82,8 @@ var _ = Describe("ExecutableCacheImpl", func() { Describe("Update and GetExecutableList", func() { It("should update the executable cache from filesystem and retrieve the expected data", func() { mockLogger.EXPECT().Debugf(gomock.Any()).Times(1) - mockLogger.EXPECT().Debugx(gomock.Any(), "workspace", wsName).Times(1) - mockLogger.EXPECT().Debugx(gomock.Any(), "count", 1).Times(1) + mockLogger.EXPECT().Debug(gomock.Any(), "workspace", wsName).Times(1) + mockLogger.EXPECT().Debug(gomock.Any(), "count", 1).Times(1) err := execCache.Update() Expect(err).ToNot(HaveOccurred()) @@ -114,8 +114,8 @@ var _ = Describe("ExecutableCacheImpl", func() { Expect(err).NotTo(HaveOccurred()) mockLogger.EXPECT().Debugf(gomock.Any()).Times(1) - mockLogger.EXPECT().Debugx(gomock.Any(), "workspace", wsName).Times(1) - mockLogger.EXPECT().Debugx(gomock.Any(), "count", 1).Times(1) + mockLogger.EXPECT().Debug(gomock.Any(), "workspace", wsName).Times(1) + mockLogger.EXPECT().Debug(gomock.Any(), "count", 1).Times(1) err = execCache.Update() Expect(err).ToNot(HaveOccurred()) @@ -132,8 +132,8 @@ var _ = Describe("ExecutableCacheImpl", func() { Describe("Update and GetExecutableList", func() { It("should update the executable cache from filesystem and retrieve the expected data", func() { mockLogger.EXPECT().Debugf(gomock.Any()).Times(1) - mockLogger.EXPECT().Debugx(gomock.Any(), "workspace", wsName).Times(1) - mockLogger.EXPECT().Debugx(gomock.Any(), "count", 1).Times(1) + mockLogger.EXPECT().Debug(gomock.Any(), "workspace", wsName).Times(1) + mockLogger.EXPECT().Debug(gomock.Any(), "count", 1).Times(1) err := execCache.Update() Expect(err).ToNot(HaveOccurred()) @@ -195,7 +195,7 @@ var _ = Describe("ExecutableCacheImpl", func() { Expect(err).NotTo(HaveOccurred()) mockLogger.EXPECT().Debugf(gomock.Any()).AnyTimes() - mockLogger.EXPECT().Debugx(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() Expect(execCache.Update()).To(Succeed()) // Should be able to access via verb alias "activate" @@ -218,7 +218,7 @@ var _ = Describe("ExecutableCacheImpl", func() { }, nil).AnyTimes() mockLogger.EXPECT().Debugf(gomock.Any()).AnyTimes() - mockLogger.EXPECT().Debugx(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() Expect(execCache.Update()).To(Succeed()) // Should be able to access via default verb "run" @@ -255,7 +255,7 @@ var _ = Describe("ExecutableCacheImpl", func() { }, nil).AnyTimes() mockLogger.EXPECT().Debugf(gomock.Any()).AnyTimes() - mockLogger.EXPECT().Debugx(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() Expect(execCache.Update()).To(Succeed()) // Should NOT be able to access via default aliases like "exec" @@ -291,7 +291,7 @@ var _ = Describe("ExecutableCacheImpl", func() { }, nil).AnyTimes() mockLogger.EXPECT().Debugf(gomock.Any()).AnyTimes() - mockLogger.EXPECT().Debugx(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() Expect(execCache.Update()).To(Succeed()) // Should be able to access via custom aliases @@ -333,7 +333,7 @@ var _ = Describe("ExecutableCacheImpl", func() { }, nil).AnyTimes() mockLogger.EXPECT().Debugf(gomock.Any()).AnyTimes() - mockLogger.EXPECT().Debugx(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() Expect(execCache.Update()).To(Succeed()) // Should NOT be able to access via any aliases since no aliases are configured for "run" @@ -379,8 +379,8 @@ var _ = Describe("ExecutableCacheImpl", func() { Expect(err).NotTo(HaveOccurred()) mockLogger.EXPECT().Debugf(gomock.Any()).AnyTimes() - mockLogger.EXPECT().Debugx(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - mockLogger.EXPECT().Warnx( + mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Warn( "duplicate executable found during cache update", "ref", "run test/testdata:duplicate-exec", "conflictPath", execCfg1.ConfigPath(), @@ -419,8 +419,8 @@ var _ = Describe("ExecutableCacheImpl", func() { Expect(err).NotTo(HaveOccurred()) mockLogger.EXPECT().Debugf(gomock.Any()).AnyTimes() - mockLogger.EXPECT().Debugx(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - mockLogger.EXPECT().Warnx( + mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Warn( "duplicate executable alias found during cache update", gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), ).AnyTimes() @@ -458,8 +458,8 @@ var _ = Describe("ExecutableCacheImpl", func() { Expect(err).NotTo(HaveOccurred()) mockLogger.EXPECT().Debugf(gomock.Any()).AnyTimes() - mockLogger.EXPECT().Debugx(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - mockLogger.EXPECT().Warnx( + mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Warn( "duplicate executable alias found during cache update", "aliasRef", gomock.Any(), "conflictRef", gomock.Any(), diff --git a/pkg/cache/workspaces_cache.go b/pkg/cache/workspaces_cache.go index 64dc796c..30c4a973 100644 --- a/pkg/cache/workspaces_cache.go +++ b/pkg/cache/workspaces_cache.go @@ -53,7 +53,7 @@ func (c *WorkspaceCacheImpl) Update() error { if err != nil { return errors.Wrap(err, "failed loading workspace config") } else if wsCfg == nil { - logger.Log().Errorx("config not found for workspace", "name", name, "path", path) + logger.Log().Error("config not found for workspace", "name", name, "path", path) continue } cacheData.Workspaces[name] = wsCfg @@ -69,7 +69,7 @@ func (c *WorkspaceCacheImpl) Update() error { return errors.Wrap(err, "unable to write cache data") } - logger.Log().Debugx("Successfully updated workspace cache data", "count", len(cacheData.Workspaces)) + logger.Log().Debug("Successfully updated workspace cache data", "count", len(cacheData.Workspaces)) return nil } diff --git a/pkg/cache/workspaces_cache_test.go b/pkg/cache/workspaces_cache_test.go index 056036b5..e95bcd54 100644 --- a/pkg/cache/workspaces_cache_test.go +++ b/pkg/cache/workspaces_cache_test.go @@ -57,7 +57,7 @@ var _ = Describe("WorkspaceCacheImpl", func() { Describe("Update and GetLatestData", func() { It("should update the workspace cache and retrieve the same data", func() { mockLogger.EXPECT().Debugf(gomock.Any()).Times(1) - mockLogger.EXPECT().Debugx(gomock.Any(), "count", 2).Times(1) + mockLogger.EXPECT().Debug(gomock.Any(), "count", 2).Times(1) err := wsCache.Update() Expect(err).ToNot(HaveOccurred()) diff --git a/pkg/context/context.go b/pkg/context/context.go index a686694b..556685fb 100644 --- a/pkg/context/context.go +++ b/pkg/context/context.go @@ -8,9 +8,11 @@ import ( "time" "github.com/flowexec/tuikit" + "github.com/flowexec/tuikit/io" "github.com/flowexec/tuikit/themes" "github.com/pkg/errors" + "github.com/flowexec/flow/internal/version" "github.com/flowexec/flow/pkg/cache" "github.com/flowexec/flow/pkg/filesystem" "github.com/flowexec/flow/pkg/logger" @@ -43,6 +45,11 @@ type Context struct { // ProcessTmpDir is the temporary directory for the current process. If set, it will be // used to store temporary files all executable runs when the tmpDir value is specified. ProcessTmpDir string + + // CurrentTask holds the task context for the currently executing step in a + // parallel or serial runner. It is set per-goroutine (via shallow copy) so + // that downstream writers can prefix output with the task name. + CurrentTask *io.TaskContext } func NewContext(ctx context.Context, cancelFunc context.CancelFunc, stdIn, stdOut *os.File) *Context { @@ -81,6 +88,7 @@ func NewContext(ctx context.Context, cancelFunc context.CancelFunc, stdIn, stdOu app := tuikit.NewApplication( AppName, tuikit.WithState(HeaderCtxKey, c.String()), + tuikit.WithVersion(version.SemVer()), tuikit.WithLoadingMsg("thinking..."), ) @@ -179,24 +187,24 @@ func (ctx *Context) Finalize() { for _, cb := range ctx.callbacks { if err := cb(ctx); err != nil { - logger.Log().Error(err, "callback execution error") + logger.Log().WrapError(err, "callback execution error") } } if ctx.ProcessTmpDir != "" { files, err := filepath.Glob(filepath.Join(ctx.ProcessTmpDir, "*")) if err != nil { - logger.Log().Error(err, fmt.Sprintf("unable to list files in temp dir %s", ctx.ProcessTmpDir)) + logger.Log().WrapError(err, fmt.Sprintf("unable to list files in temp dir %s", ctx.ProcessTmpDir)) return } for _, f := range files { err = os.RemoveAll(f) if err != nil { - logger.Log().Error(err, fmt.Sprintf("unable to remove file %s", f)) + logger.Log().WrapError(err, fmt.Sprintf("unable to remove file %s", f)) } } if err := os.Remove(ctx.ProcessTmpDir); err != nil { - logger.Log().Error(err, fmt.Sprintf("unable to remove temp dir %s", ctx.ProcessTmpDir)) + logger.Log().WrapError(err, fmt.Sprintf("unable to remove temp dir %s", ctx.ProcessTmpDir)) } } } diff --git a/pkg/context/context_test.go b/pkg/context/context_test.go index fc36cd47..bfb54323 100644 --- a/pkg/context/context_test.go +++ b/pkg/context/context_test.go @@ -6,7 +6,7 @@ import ( "path/filepath" "testing" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/flowexec/tuikit/themes" "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" diff --git a/pkg/filesystem/executables.go b/pkg/filesystem/executables.go index 77a8961a..c530f8af 100644 --- a/pkg/filesystem/executables.go +++ b/pkg/filesystem/executables.go @@ -71,14 +71,14 @@ func LoadWorkspaceFlowFiles( for _, cfgFile := range cfgFiles { cfg, err := LoadFlowFile(cfgFile) if err != nil { - logger.Log().Errorx("unable to load executable config file", "configFile", cfgFile, "err", err) + logger.Log().Error("unable to load executable config file", "configFile", cfgFile, "err", err) continue } cfg.SetDefaults() cfg.SetContext(workspaceCfg.AssignedName(), workspaceCfg.Location(), cfgFile) cfgs = append(cfgs, cfg) } - logger.Log().Debugx( + logger.Log().Debug( fmt.Sprintf("loaded %d config files", len(cfgs)), "workspace", workspaceCfg.AssignedName(), @@ -113,7 +113,7 @@ func findFlowFiles(workspaceCfg *workspace.Workspace) ([]string, error) { walkDirFunc := func(path string, entry fs.DirEntry, err error) error { if err != nil { if errors.Is(err, fs.ErrNotExist) { - logger.Log().Debugx("cfg path does not exist", "path", path) + logger.Log().Debug("cfg path does not exist", "path", path) return nil } return err diff --git a/pkg/filesystem/executables_test.go b/pkg/filesystem/executables_test.go index 7be3ee6f..52be7bd6 100644 --- a/pkg/filesystem/executables_test.go +++ b/pkg/filesystem/executables_test.go @@ -79,7 +79,7 @@ var _ = Describe("Executables", func() { ctrl := gomock.NewController(GinkgoT()) logger := mocks.NewMockLogger(ctrl) - logger.EXPECT().Debugx(gomock.Any(), gomock.Any()).AnyTimes() + logger.EXPECT().Debug(gomock.Any(), gomock.Any()).AnyTimes() definitions, err := filesystem.LoadWorkspaceFlowFiles(workspaceCfg) Expect(err).NotTo(HaveOccurred()) @@ -111,7 +111,7 @@ var _ = Describe("Executables", func() { ctrl := gomock.NewController(GinkgoT()) mockLogger := mocks.NewMockLogger(ctrl) logger.Init(logger.InitOptions{Logger: mockLogger, TestingTB: GinkgoTB()}) - mockLogger.EXPECT().Debugx(gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any()).AnyTimes() definitions, err := filesystem.LoadWorkspaceFlowFiles(workspaceCfg) Expect(err).NotTo(HaveOccurred()) @@ -147,7 +147,7 @@ var _ = Describe("Executables", func() { ctrl := gomock.NewController(GinkgoT()) mockLogger := mocks.NewMockLogger(ctrl) logger.Init(logger.InitOptions{Logger: mockLogger, TestingTB: GinkgoTB()}) - mockLogger.EXPECT().Debugx(gomock.Any(), gomock.Any()).AnyTimes() + mockLogger.EXPECT().Debug(gomock.Any(), gomock.Any()).AnyTimes() definitions, err := filesystem.LoadWorkspaceFlowFiles(workspaceCfg) Expect(err).NotTo(HaveOccurred()) diff --git a/tests/browse_cmds_e2e_test.go b/tests/browse_cmds_e2e_test.go index 231fed70..6b258588 100644 --- a/tests/browse_cmds_e2e_test.go +++ b/tests/browse_cmds_e2e_test.go @@ -9,15 +9,13 @@ import ( "path/filepath" "time" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/x/exp/teatest" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/exp/teatest/v2" "github.com/flowexec/tuikit" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" execIO "github.com/flowexec/flow/internal/io/executable" - "github.com/flowexec/flow/internal/io/library" - "github.com/flowexec/flow/pkg/logger" "github.com/flowexec/flow/tests/utils" "github.com/flowexec/flow/types/executable" ) @@ -57,10 +55,9 @@ var _ = Describe("browse TUI", func() { execList, err := ctx.ExecutableCache.GetExecutableList() Expect(err).NotTo(HaveOccurred()) - libraryView := library.NewLibraryView( + libraryView := execIO.NewLibraryView( ctx.Context, wsList, execList, - library.Filter{}, - logger.Theme(ctx.Config.Theme.String()), + execIO.Filter{}, runFunc, ) Expect(container.SetView(libraryView)).To(Succeed()) @@ -86,10 +83,9 @@ var _ = Describe("browse TUI", func() { execList, err := ctx.ExecutableCache.GetExecutableList() Expect(err).NotTo(HaveOccurred()) - libraryView := library.NewLibraryView( + libraryView := execIO.NewLibraryView( ctx.Context, wsList, execList, - library.Filter{}, - logger.Theme(ctx.Config.Theme.String()), + execIO.Filter{}, runFunc, ) Expect(container.SetView(libraryView)).To(Succeed()) diff --git a/tests/e2e_test.go b/tests/e2e_test.go index b77fb73f..fbdbcc0c 100644 --- a/tests/e2e_test.go +++ b/tests/e2e_test.go @@ -8,9 +8,9 @@ import ( "os" "strconv" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" "github.com/flowexec/tuikit" - "github.com/muesli/termenv" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -18,7 +18,7 @@ import ( ) func init() { - lipgloss.SetColorProfile(termenv.Ascii) + lipgloss.Writer.Profile = colorprofile.Ascii } func TestE2E(t *testing.T) { diff --git a/tests/utils/context.go b/tests/utils/context.go index ea02098e..682a374d 100644 --- a/tests/utils/context.go +++ b/tests/utils/context.go @@ -296,7 +296,7 @@ func setTestEnv(tb testing.TB, configDir, cacheDir string) { func expectInternalMockLoggerCalls(logger *tuikitIOMocks.MockLogger) { logger.EXPECT().Debugf(gomock.Any(), gomock.Any()).AnyTimes() - logger.EXPECT().Debugx(gomock.Any(), gomock.Any()).AnyTimes() + logger.EXPECT().Debug(gomock.Any(), gomock.Any()).AnyTimes() logger.EXPECT().LogMode().AnyTimes() logger.EXPECT().SetMode(gomock.Any()).AnyTimes() } diff --git a/tests/utils/golden.go b/tests/utils/golden.go index 9386de95..20bf052c 100644 --- a/tests/utils/golden.go +++ b/tests/utils/golden.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/charmbracelet/x/exp/teatest" + "github.com/charmbracelet/x/exp/teatest/v2" ) var updateEnvKey = "UPDATE_GOLDEN_FILES" diff --git a/types/config/config.go b/types/config/config.go index 145c3d18..d9ae604e 100644 --- a/types/config/config.go +++ b/types/config/config.go @@ -135,65 +135,78 @@ func (c *Config) JSON() (string, error) { } func (c *Config) Markdown() string { - mkdwn := "# Global Configurations\n" - mkdwn += fmt.Sprintf("**Current workspace:** `%s`\n", c.CurrentWorkspace) - switch c.WorkspaceMode { - case ConfigWorkspaceModeFixed: - mkdwn += "*Workspace mode is set to fixed. This means that your working directory will have no impact on the " + - "current workspace.*\n\n" - case ConfigWorkspaceModeDynamic: - mkdwn += "*Workspace mode is set to dynamic. This means that your current workspace is also determined by " + - "your working directory.*\n\n" - } + var sections []string + // General settings + general := "## General\n" + general += fmt.Sprintf("**Workspace:** `%s`\n\n", c.CurrentWorkspace) if c.CurrentNamespace != "" { - mkdwn += fmt.Sprintf("**Current namespace**: %s\n\n", c.CurrentNamespace) - } else { - mkdwn += "*No namespace is set*\n\n" + general += fmt.Sprintf("**Namespace:** `%s`\n\n", c.CurrentNamespace) } - if c.DefaultTimeout != 0 { - mkdwn += fmt.Sprintf("**Default timeout**: %s\n", c.DefaultTimeout) + + mode := string(c.WorkspaceMode) + if mode == "" { + mode = "dynamic" } + general += fmt.Sprintf("**Workspace Mode:** %s\n\n", mode) + if c.Theme != "" { - mkdwn += fmt.Sprintf("**Theme**: %s\n", c.Theme) + general += fmt.Sprintf("**Theme:** %s\n\n", c.Theme) + } + if c.DefaultTimeout != 0 { + general += fmt.Sprintf("**Default Timeout:** %s\n\n", c.DefaultTimeout) + } + if c.DefaultLogMode != "" { + general += fmt.Sprintf("**Log Mode:** %s\n\n", c.DefaultLogMode) } + sections = append(sections, general) + + // Interactive settings if c.Interactive != nil { //nolint:nestif - mkdwn += "## Interactivity Settings\n" + interactive := "## Interactive\n" if c.Interactive.Enabled { - mkdwn += "**Interactive mode is enabled**\n" - if c.Interactive.NotifyOnCompletion != nil { - mkdwn += "*Notify on completion is enabled*\n" + interactive += "**Enabled:** yes\n\n" + if c.Interactive.NotifyOnCompletion != nil && *c.Interactive.NotifyOnCompletion { + interactive += "**Notify on Completion:** yes\n\n" } - if c.Interactive.SoundOnCompletion != nil { - mkdwn += "*Sound on completion is enabled*\n" + if c.Interactive.SoundOnCompletion != nil && *c.Interactive.SoundOnCompletion { + interactive += "**Sound on Completion:** yes\n\n" } } else { - mkdwn += "**Interactive mode is disabled**\n" + interactive += "**Enabled:** no\n\n" } + sections = append(sections, interactive) } - mkdwn += "## Registered Workspaces\n" - allWs := make([]string, 0, len(c.Workspaces)) - for name := range c.Workspaces { - allWs = append(allWs, name) - } - slices.Sort(allWs) - for _, name := range allWs { - mkdwn += fmt.Sprintf("- %s: %s\n", name, c.Workspaces[name]) + + // Workspaces + if len(c.Workspaces) > 0 { + ws := fmt.Sprintf("## Workspaces (%d)\n", len(c.Workspaces)) + allWs := make([]string, 0, len(c.Workspaces)) + for name := range c.Workspaces { + allWs = append(allWs, name) + } + slices.Sort(allWs) + for _, name := range allWs { + ws += fmt.Sprintf("- **%s** — %s\n", name, c.Workspaces[name]) + } + sections = append(sections, ws) } + // Templates if len(c.Templates) > 0 { - mkdwn += "## Registered Templates\n" + tmpl := fmt.Sprintf("## Templates (%d)\n", len(c.Templates)) allTmpl := make([]string, 0, len(c.Templates)) for name := range c.Templates { allTmpl = append(allTmpl, name) } slices.Sort(allTmpl) for _, name := range allTmpl { - mkdwn += fmt.Sprintf("- %s: %s\n", name, c.Templates[name]) + tmpl += fmt.Sprintf("- **%s** — %s\n", name, c.Templates[name]) } + sections = append(sections, tmpl) } - return mkdwn + return strings.Join(sections, "\n") } func (ct ConfigTheme) String() string {