diff --git a/.github/workflows/lint-test.yaml b/.github/workflows/lint-test.yaml index dc45ab2..27608bb 100644 --- a/.github/workflows/lint-test.yaml +++ b/.github/workflows/lint-test.yaml @@ -8,10 +8,10 @@ jobs: lint-test: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Go - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version: ">=1.25.8" diff --git a/cmd/config.go b/cmd/config.go index ca88036..c17a301 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -115,7 +115,7 @@ var getSitesCmd = &cobra.Command{ sites := map[string][]config.Context{} for _, ctx := range cfg.Contexts { - site := firstNonEmptyString(ctx.Site, "-") + site := helpers.FirstNonEmpty(ctx.Site, "-") sites[site] = append(sites[site], ctx) } @@ -132,9 +132,9 @@ var getSitesCmd = &cobra.Command{ sort.Slice(contexts, func(i, j int) bool { return contexts[i].Name < contexts[j].Name }) - plugin := firstNonEmptyString(contexts[0].Plugin, "-") + plugin := helpers.FirstNonEmpty(contexts[0].Plugin, "-") envs := uniqueSortedContextValues(contexts, func(ctx config.Context) string { - return firstNonEmptyString(ctx.Environment, "-") + return helpers.FirstNonEmpty(ctx.Environment, "-") }) names := make([]string, 0, len(contexts)) for _, ctx := range contexts { @@ -205,18 +205,18 @@ var getEnvironmentsCmd = &cobra.Command{ for _, ctx := range contexts { host := "-" if ctx.DockerHostType == config.ContextRemote { - host = firstNonEmptyString(ctx.SSHHostname, "-") + host = helpers.FirstNonEmpty(ctx.SSHHostname, "-") } name := ctx.Name if ctx.Name == cfg.CurrentContext { name += " *" } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", - firstNonEmptyString(ctx.Site, "-"), - firstNonEmptyString(ctx.Environment, "-"), + helpers.FirstNonEmpty(ctx.Site, "-"), + helpers.FirstNonEmpty(ctx.Environment, "-"), name, - firstNonEmptyString(ctx.Plugin, "-"), - firstNonEmptyString(string(ctx.DockerHostType), "-"), + helpers.FirstNonEmpty(ctx.Plugin, "-"), + helpers.FirstNonEmpty(string(ctx.DockerHostType), "-"), host, ) } @@ -589,22 +589,22 @@ func runCreateConfig(cmd *cobra.Command, args []string) error { } } if !f.Changed("project-name") && placeholderProjectName(context.ProjectName) { - context.ProjectName = firstNonEmptyString(filepath.Base(context.ProjectDir), "docker-compose") + context.ProjectName = helpers.FirstNonEmpty(filepath.Base(context.ProjectDir), "docker-compose") } if strings.TrimSpace(context.ProjectName) == "" { - context.ProjectName = firstNonEmptyString(filepath.Base(context.ProjectDir), "docker-compose") + context.ProjectName = helpers.FirstNonEmpty(filepath.Base(context.ProjectDir), "docker-compose") } if !f.Changed("compose-project-name") && strings.TrimSpace(context.ComposeProjectName) == "" { - context.ComposeProjectName = firstNonEmptyString(config.DetectComposeProjectName(context.ProjectDir), context.ProjectName) + context.ComposeProjectName = helpers.FirstNonEmpty(config.DetectComposeProjectName(context.ProjectDir), context.ProjectName) } if !f.Changed("compose-network") && strings.TrimSpace(context.ComposeNetwork) == "" { context.ComposeNetwork = config.DetectComposeNetworkName(context.ProjectDir, context.EffectiveComposeProjectName()) } if !f.Changed("site") && placeholderProjectName(context.Site) { - context.Site = firstNonEmptyString(filepath.Base(context.ProjectDir), context.ProjectName, context.Name) + context.Site = helpers.FirstNonEmpty(filepath.Base(context.ProjectDir), context.ProjectName, context.Name) } if strings.TrimSpace(context.Site) == "" { - context.Site = firstNonEmptyString(filepath.Base(context.ProjectDir), context.ProjectName, context.Name) + context.Site = helpers.FirstNonEmpty(filepath.Base(context.ProjectDir), context.ProjectName, context.Name) } if context.DockerHostType == config.ContextRemote { @@ -663,17 +663,17 @@ func promptContextPlugin(defaultPlugin string) (string, error) { choices = append(choices, corecomponent.Choice{ Value: name, Label: name, - Help: firstNonEmptyString(discovered.Description, "Use the "+name+" plugin for this site."), + Help: helpers.FirstNonEmpty(discovered.Description, "Use the "+name+" plugin for this site."), Aliases: nil, }) } if len(choices) == 1 { - return firstNonEmptyString(defaultPlugin, "core"), nil + return helpers.FirstNonEmpty(defaultPlugin, "core"), nil } selected, err := createConfigPromptChoice( "plugin", choices, - firstNonEmptyString(strings.TrimSpace(defaultPlugin), "core"), + helpers.FirstNonEmpty(strings.TrimSpace(defaultPlugin), "core"), createConfigInput, strings.Split(corecomponent.RenderSection("plugin", "If this project belongs to a known sitectl plugin, pick it here. For example, an Islandora stack would usually use the isle plugin."), "\n")..., ) @@ -747,7 +747,7 @@ func promptRemoteEnvironmentContext(localCtx, previousRemote *config.Context) (* projectDir, err := promptRequiredValueWithDefault( "Full directory path to the remote project (directory where docker-compose.yml is located)", - firstNonEmptyString(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.ProjectDir })), + helpers.FirstNonEmpty(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.ProjectDir })), ) if err != nil { return nil, err @@ -759,17 +759,17 @@ func promptRemoteEnvironmentContext(localCtx, previousRemote *config.Context) (* } defaultKey := filepath.Join(os.Getenv("HOME"), ".ssh", "id_rsa") hostname := "" - sshUser := firstNonEmptyString(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.SSHUser }), currentUser, "root") + sshUser := helpers.FirstNonEmpty(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.SSHUser }), currentUser, "root") sshPort := remoteContextUint(previousRemote, func(ctx *config.Context) uint { return ctx.SSHPort }, 22) - sshKey := firstNonEmptyString(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.SSHKeyPath }), defaultKey) - dockerSocket := firstNonEmptyString(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.DockerSocket }), "/var/run/docker.sock") - projectName := firstNonEmptyString(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.ProjectName }), localCtx.ProjectName, "docker-compose") - composeProjectName := firstNonEmptyString( + sshKey := helpers.FirstNonEmpty(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.SSHKeyPath }), defaultKey) + dockerSocket := helpers.FirstNonEmpty(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.DockerSocket }), "/var/run/docker.sock") + projectName := helpers.FirstNonEmpty(remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.ProjectName }), localCtx.ProjectName, "docker-compose") + composeProjectName := helpers.FirstNonEmpty( remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.ComposeProjectName }), localCtx.EffectiveComposeProjectName(), projectName, ) - composeNetwork := firstNonEmptyString( + composeNetwork := helpers.FirstNonEmpty( remoteContextValue(previousRemote, func(ctx *config.Context) string { return ctx.ComposeNetwork }), localCtx.ComposeNetwork, localCtx.EffectiveComposeNetwork(), @@ -794,12 +794,12 @@ func promptRemoteEnvironmentContext(localCtx, previousRemote *config.Context) (* remoteCtx := &config.Context{ Name: name, - Site: firstNonEmptyString(localCtx.Site, localCtx.ProjectName, localCtx.Name), - Plugin: firstNonEmptyString(localCtx.Plugin, "core"), + Site: helpers.FirstNonEmpty(localCtx.Site, localCtx.ProjectName, localCtx.Name), + Plugin: helpers.FirstNonEmpty(localCtx.Plugin, "core"), DockerHostType: config.ContextRemote, Environment: environment, ProjectDir: strings.TrimSpace(projectDir), - ProjectName: firstNonEmptyString(localCtx.ProjectName, "docker-compose"), + ProjectName: helpers.FirstNonEmpty(localCtx.ProjectName, "docker-compose"), ComposeProjectName: composeProjectName, ComposeNetwork: composeNetwork, SSHHostname: strings.TrimSpace(hostname), @@ -945,18 +945,18 @@ func validateRemoteDockerAccess(ctx *config.Context) error { if promptErr != nil { return promptErr } - projectName, promptErr := promptRequiredValueWithDefault("Logical project name", firstNonEmptyString(ctx.ProjectName, "docker-compose")) + projectName, promptErr := promptRequiredValueWithDefault("Logical project name", helpers.FirstNonEmpty(ctx.ProjectName, "docker-compose")) if promptErr != nil { return promptErr } - dockerSocket, promptErr := promptRequiredValueWithDefault("Docker socket", firstNonEmptyString(ctx.DockerSocket, "/var/run/docker.sock")) + dockerSocket, promptErr := promptRequiredValueWithDefault("Docker socket", helpers.FirstNonEmpty(ctx.DockerSocket, "/var/run/docker.sock")) if promptErr != nil { return promptErr } ctx.ProjectDir = projectDir ctx.ProjectName = projectName - ctx.ComposeProjectName = firstNonEmptyString(ctx.ComposeProjectName, projectName) - ctx.ComposeNetwork = firstNonEmptyString(config.DetectContextComposeNetwork(ctx), ctx.ComposeNetwork, ctx.EffectiveComposeNetwork()) + ctx.ComposeProjectName = helpers.FirstNonEmpty(ctx.ComposeProjectName, projectName) + ctx.ComposeNetwork = helpers.FirstNonEmpty(config.DetectContextComposeNetwork(ctx), ctx.ComposeNetwork, ctx.EffectiveComposeNetwork()) ctx.DockerSocket = dockerSocket continue } @@ -1012,15 +1012,6 @@ func suggestedEnvironmentContextName(localCtx *config.Context, environment strin return base } -func firstNonEmptyString(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - func writeContextTable(out io.Writer, cfg *config.Config) { w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "CURRENT\tCONTEXT\tSITE\tPLUGIN\tENVIRONMENT\tTYPE\tPROJECT") @@ -1032,11 +1023,11 @@ func writeContextTable(out io.Writer, cfg *config.Config) { fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", activeMark, ctx.Name, - firstNonEmptyString(ctx.Site, "-"), - firstNonEmptyString(ctx.Plugin, "-"), - firstNonEmptyString(ctx.Environment, "-"), - firstNonEmptyString(string(ctx.DockerHostType), "-"), - firstNonEmptyString(ctx.ProjectName, "-"), + helpers.FirstNonEmpty(ctx.Site, "-"), + helpers.FirstNonEmpty(ctx.Plugin, "-"), + helpers.FirstNonEmpty(ctx.Environment, "-"), + helpers.FirstNonEmpty(string(ctx.DockerHostType), "-"), + helpers.FirstNonEmpty(ctx.ProjectName, "-"), ) } _ = w.Flush() diff --git a/cmd/debug.go b/cmd/debug.go index b3271e5..96ad7a4 100644 --- a/cmd/debug.go +++ b/cmd/debug.go @@ -3,16 +3,16 @@ package cmd import ( "context" "fmt" - "io" "log/slog" "os" "regexp" "sort" "strconv" "strings" - "sync" "time" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" dockercontainer "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" @@ -21,6 +21,7 @@ import ( "github.com/libops/sitectl/internal/debugreport" "github.com/libops/sitectl/pkg/config" "github.com/libops/sitectl/pkg/docker" + "github.com/libops/sitectl/pkg/helpers" "github.com/libops/sitectl/pkg/plugin" "github.com/spf13/cobra" "golang.org/x/term" @@ -145,9 +146,23 @@ func init() { func runDebugCollectionWithProgress(cmd *cobra.Command, contextName string, ctx config.Context) (string, error) { debugProgressUIActive = true defer func() { debugProgressUIActive = false }() - progress := newDebugProgressLine(cmd.ErrOrStderr()) - defer progress.Close() - return collectDebugReport(cmd.Context(), contextName, ctx, progress.Report) + + p := tea.NewProgram(newDebugSpinnerModel(), tea.WithOutput(cmd.ErrOrStderr())) + + go func() { + reporter := debugProgressReporter(func(title, detail string) { + p.Send(debugProgressUpdateMsg{title: title, detail: detail}) + }) + result, err := collectDebugReport(cmd.Context(), contextName, ctx, reporter) + p.Send(debugProgressDoneMsg{result: result, err: err}) + }() + + finalModel, err := p.Run() + if err != nil { + return "", fmt.Errorf("running debug progress: %w", err) + } + done := finalModel.(debugSpinnerModel) + return done.result, done.err } func reportProgress(reporter debugProgressReporter, title, detail string) { @@ -165,7 +180,7 @@ func renderCoreDebug(runCtx context.Context, ctx config.Context) string { meta := []debugRow{ {Label: "Generated", Value: time.Now().UTC().Format(time.RFC3339)}, {Label: "Context", Value: ctx.Name}, - {Label: "Plugin owner", Value: firstNonEmpty(ctx.Plugin, "core")}, + {Label: "Plugin owner", Value: helpers.FirstNonEmpty(ctx.Plugin, "core")}, {Label: "Docker host type", Value: string(ctx.DockerHostType)}, {Label: "Project dir", Value: ctx.ProjectDir}, } @@ -271,98 +286,64 @@ type imageDiagnostics struct { type debugProgressReporter func(title, detail string) -type debugProgressLine struct { - out *os.File - frames []string - index int - title string - detail string - mu sync.Mutex - done chan struct{} - once sync.Once -} +// debugProgressUpdateMsg carries a status update for the spinner TUI. +type debugProgressUpdateMsg struct{ title, detail string } -func newDebugProgressLine(w io.Writer) *debugProgressLine { - file, ok := w.(*os.File) - if !ok { - return &debugProgressLine{frames: []string{".", "o", "O", "o"}} - } - progress := &debugProgressLine{ - out: file, - frames: []string{"-", "\\", "|", "/"}, - title: "Preparing Debug Bundle", - detail: "Starting diagnostic collection", - done: make(chan struct{}), - } - go progress.animate(120 * time.Millisecond) - return progress +// debugProgressDoneMsg signals collection has finished and carries the result. +type debugProgressDoneMsg struct { + result string + err error } -func (p *debugProgressLine) Report(title, detail string) { - if p == nil { - return - } - p.mu.Lock() - p.title = strings.TrimSpace(title) - p.detail = strings.TrimSpace(detail) - p.renderLocked() - p.mu.Unlock() +type debugSpinnerModel struct { + spin spinner.Model + title string + detail string + result string + err error + quitting bool } -func (p *debugProgressLine) Close() { - if p == nil || p.out == nil { - return +func newDebugSpinnerModel() debugSpinnerModel { + return debugSpinnerModel{ + spin: spinner.New(spinner.WithSpinner(spinner.Line), spinner.WithStyle(debugMutedStyle)), + title: "Preparing Debug Bundle", + detail: "Starting diagnostic collection", } - p.once.Do(func() { - close(p.done) - p.mu.Lock() - defer p.mu.Unlock() - fmt.Fprint(p.out, "\r\033[2K") - }) } -func (p *debugProgressLine) animate(interval time.Duration) { - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-ticker.C: - p.mu.Lock() - p.renderLocked() - p.mu.Unlock() - case <-p.done: - return - } - } +func (m debugSpinnerModel) Init() tea.Cmd { + return m.spin.Tick } -func (p *debugProgressLine) renderLocked() { - if p.out == nil { - return +func (m debugSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case spinner.TickMsg: + var cmd tea.Cmd + m.spin, cmd = m.spin.Update(msg) + return m, cmd + case debugProgressUpdateMsg: + m.title = strings.TrimSpace(msg.title) + m.detail = strings.TrimSpace(msg.detail) + return m, nil + case debugProgressDoneMsg: + m.result = msg.result + m.err = msg.err + m.quitting = true + return m, tea.Quit } - frame := p.frames[p.index%len(p.frames)] - p.index++ - line := fmt.Sprintf("\r%s %s", frame, strings.TrimSpace(strings.Join([]string{p.title, p.detail}, " - "))) - fmt.Fprint(p.out, truncateDebugProgress(line)) + return m, nil } -func truncateDebugProgress(line string) string { - width := debugPanelWidth() - if width <= 0 { - return line - } - plain := ansiPattern.ReplaceAllString(line, "") - if lipgloss.Width(plain) <= width { - return line +func (m debugSpinnerModel) View() tea.View { + if m.quitting { + return tea.NewView("") } - runes := []rune(plain) - if len(runes) <= width { - return string(runes) + label := m.title + if m.detail != "" { + label += " - " + m.detail } - if width <= 1 { - return string(runes[:width]) - } - return string(runes[:width-1]) + "…" + return tea.NewView(m.spin.View() + " " + label + "\n") } func collectLogDiagnosticsWithClient(runCtx context.Context, ctxCfg *config.Context, cli *docker.DockerClient) (logDiagnostics, error) { @@ -385,8 +366,8 @@ func collectLogDiagnosticsWithClient(runCtx context.Context, ctxCfg *config.Cont if err := runCtx.Err(); err != nil { return logDiagnostics{}, err } - name := trimContainerName(summary.Names) - service := firstNonEmpty(summary.Labels["com.docker.compose.service"], name) + name := docker.TrimContainerName(summary.Names) + service := helpers.FirstNonEmpty(summary.Labels["com.docker.compose.service"], name) inspect, err := cli.CLI.ContainerInspect(runCtx, name) if err != nil { return logDiagnostics{}, err @@ -728,16 +709,6 @@ func humanBytes(size int64) string { return fmt.Sprintf("%.1f%ciB", float64(size)/float64(div), "KMGTPE"[exp]) } -func trimContainerName(names []string) string { - for _, name := range names { - trimmed := strings.TrimPrefix(strings.TrimSpace(name), "/") - if trimmed != "" { - return trimmed - } - } - return "" -} - func renderPlainDebugReport(value string) string { lines := strings.Split(ansiPattern.ReplaceAllString(value, ""), "\n") for i := range lines { @@ -745,12 +716,3 @@ func renderPlainDebugReport(value string) string { } return strings.TrimSpace(strings.Join(lines, "\n")) } - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - return "" -} diff --git a/cmd/job.go b/cmd/job.go index b8e8ca5..32bb6ac 100644 --- a/cmd/job.go +++ b/cmd/job.go @@ -8,6 +8,7 @@ import ( "strings" "text/tabwriter" + tea "charm.land/bubbletea/v2" "github.com/libops/sitectl/pkg/config" corejob "github.com/libops/sitectl/pkg/job" "github.com/libops/sitectl/pkg/plugin" @@ -183,21 +184,37 @@ func init() { func runJobCommand(cmd *cobra.Command, contextName, owner, name string, jobArgs []string) (string, error) { invocation := append([]string{"--context", contextName, "__job", name}, jobArgs...) if stderrFile, ok := cmd.ErrOrStderr().(*os.File); ok && term.IsTerminal(int(stderrFile.Fd())) { - progress := newDebugProgressLine(cmd.ErrOrStderr()) - progress.Report("Running Job", fmt.Sprintf("%s on %s", name, contextName)) - defer progress.Close() - return pluginSDK.InvokePluginCommand(owner, invocation, plugin.CommandExecOptions{ - Context: RootCmd.Context(), - Capture: true, - }) + return runJobWithProgress(cmd, owner, name, contextName, invocation) } - return pluginSDK.InvokePluginCommand(owner, invocation, plugin.CommandExecOptions{ Context: RootCmd.Context(), Capture: true, }) } +func runJobWithProgress(cmd *cobra.Command, owner, name, contextName string, invocation []string) (string, error) { + m := newDebugSpinnerModel() + m.title = "Running Job" + m.detail = fmt.Sprintf("%s on %s", name, contextName) + + p := tea.NewProgram(m, tea.WithOutput(cmd.ErrOrStderr())) + + go func() { + result, err := pluginSDK.InvokePluginCommand(owner, invocation, plugin.CommandExecOptions{ + Context: RootCmd.Context(), + Capture: true, + }) + p.Send(debugProgressDoneMsg{result: result, err: err}) + }() + + finalModel, err := p.Run() + if err != nil { + return "", fmt.Errorf("running job progress: %w", err) + } + done := finalModel.(debugSpinnerModel) + return done.result, done.err +} + func requestsHelp(args []string) bool { for _, arg := range args { switch arg { diff --git a/go.mod b/go.mod index 60a872b..a451114 100644 --- a/go.mod +++ b/go.mod @@ -86,6 +86,6 @@ require ( golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/grpc v1.77.0 // indirect + google.golang.org/grpc v1.79.3 // indirect gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index ac4a346..921cd92 100644 --- a/go.sum +++ b/go.sum @@ -204,8 +204,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1: google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/debugreport/collect.go b/internal/debugreport/collect.go index 2937264..4233733 100644 --- a/internal/debugreport/collect.go +++ b/internal/debugreport/collect.go @@ -14,6 +14,7 @@ import ( "github.com/kballard/go-shellquote" "github.com/libops/sitectl/pkg/config" "github.com/libops/sitectl/pkg/docker" + "github.com/libops/sitectl/pkg/helpers" "github.com/pkg/sftp" "golang.org/x/crypto/ssh" yaml "gopkg.in/yaml.v3" @@ -460,7 +461,7 @@ func availableDiskBytes(ctxCfg *config.Context) (int64, error) { } func availableDiskBytesAtPathWithSession(ctxCfg *config.Context, session *Session, path string) (int64, error) { - trimmedPath := firstNonEmpty(strings.TrimSpace(path), "/") + trimmedPath := helpers.FirstNonEmpty(strings.TrimSpace(path), "/") if ctxCfg.DockerHostType == config.ContextLocal { return localAvailableDiskBytes(trimmedPath) } @@ -549,12 +550,3 @@ func parseOSRelease(data string) string { } return "" } - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - return "" -} diff --git a/pkg/config/context.go b/pkg/config/context.go index 81713c7..4183bc2 100644 --- a/pkg/config/context.go +++ b/pkg/config/context.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/libops/sitectl/pkg/helpers" "github.com/spf13/pflag" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/knownhosts" @@ -218,11 +219,11 @@ func (c *Context) ReadSmallFile(filename string) (string, error) { } func (c Context) EffectiveComposeProjectName() string { - return firstNonEmpty(c.ComposeProjectName, c.ProjectName) + return helpers.FirstNonEmpty(c.ComposeProjectName, c.ProjectName) } func (c Context) EffectiveComposeNetwork() string { - return firstNonEmpty(c.ComposeNetwork, c.EffectiveComposeProjectName()+"_default") + return helpers.FirstNonEmpty(c.ComposeNetwork, c.EffectiveComposeProjectName()+"_default") } func (c *Context) DialSSH() (*ssh.Client, error) { diff --git a/pkg/config/local_context.go b/pkg/config/local_context.go index bb5f1ba..ee64dad 100644 --- a/pkg/config/local_context.go +++ b/pkg/config/local_context.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/libops/sitectl/pkg/helpers" ) type InputFunc func(question ...string) (string, error) @@ -87,13 +89,13 @@ func PromptAndSaveLocalContext(opts LocalContextCreateOptions) (*Context, error) return nil, fmt.Errorf("project directory cannot be empty") } - projectName := firstNonEmpty(opts.ProjectName, existing.ProjectName, opts.DefaultProjectName, "docker-compose") - composeProjectName := firstNonEmpty(opts.ComposeProjectName, existing.ComposeProjectName, DetectComposeProjectName(projectDir), projectName) - composeNetwork := firstNonEmpty(opts.ComposeNetwork, existing.ComposeNetwork, DetectComposeNetworkName(projectDir, composeProjectName)) - site := firstNonEmpty(opts.Site, existing.Site, opts.DefaultSite, projectName, name) - plugin := firstNonEmpty(opts.Plugin, existing.Plugin, opts.DefaultPlugin, "core") - environment := firstNonEmpty(opts.Environment, existing.Environment, "local") - dockerSocket := GetDefaultLocalDockerSocket(firstNonEmpty(opts.DockerSocket, existing.DockerSocket, "/var/run/docker.sock")) + projectName := helpers.FirstNonEmpty(opts.ProjectName, existing.ProjectName, opts.DefaultProjectName, "docker-compose") + composeProjectName := helpers.FirstNonEmpty(opts.ComposeProjectName, existing.ComposeProjectName, DetectComposeProjectName(projectDir), projectName) + composeNetwork := helpers.FirstNonEmpty(opts.ComposeNetwork, existing.ComposeNetwork, DetectComposeNetworkName(projectDir, composeProjectName)) + site := helpers.FirstNonEmpty(opts.Site, existing.Site, opts.DefaultSite, projectName, name) + plugin := helpers.FirstNonEmpty(opts.Plugin, existing.Plugin, opts.DefaultPlugin, "core") + environment := helpers.FirstNonEmpty(opts.Environment, existing.Environment, "local") + dockerSocket := GetDefaultLocalDockerSocket(helpers.FirstNonEmpty(opts.DockerSocket, existing.DockerSocket, "/var/run/docker.sock")) ctx := &Context{ Name: name, @@ -106,8 +108,8 @@ func PromptAndSaveLocalContext(opts LocalContextCreateOptions) (*Context, error) ComposeProjectName: composeProjectName, ComposeNetwork: composeNetwork, ProjectDir: projectDir, - DrupalRootfs: firstNonEmpty(opts.DrupalRootfs, existing.DrupalRootfs), - DrupalContainerRoot: firstNonEmpty(opts.DrupalContainerRoot, existing.DrupalContainerRoot), + DrupalRootfs: helpers.FirstNonEmpty(opts.DrupalRootfs, existing.DrupalRootfs), + DrupalContainerRoot: helpers.FirstNonEmpty(opts.DrupalContainerRoot, existing.DrupalContainerRoot), } if err := SaveContext(ctx, opts.SetDefault); err != nil { @@ -122,7 +124,7 @@ func resolveLocalContextName(existing Context, opts LocalContextCreateOptions, i return strings.TrimSpace(opts.Name), nil } - baseName := firstNonEmpty(existing.Name, opts.DefaultName) + baseName := helpers.FirstNonEmpty(existing.Name, opts.DefaultName) if strings.TrimSpace(baseName) == "" { return "", fmt.Errorf("context name cannot be empty") } @@ -198,7 +200,7 @@ func resolveLocalProjectDir(existing Context, opts LocalContextCreateOptions, in if err != nil { return "", err } - defaultDir := firstNonEmpty(existing.ProjectDir, opts.DefaultProjectDir, cwd) + defaultDir := helpers.FirstNonEmpty(existing.ProjectDir, opts.DefaultProjectDir, cwd) prompt := opts.ProjectDirPrompt if len(prompt) == 0 { prompt = []string{fmt.Sprintf("Full directory path to the project (directory where docker-compose.yml is located) [%s]: ", defaultDir)} @@ -302,15 +304,6 @@ func ValidateExistingComposeProjectDir(projectDir string) error { return nil } -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - return "" -} - func isAffirmative(value string) bool { value = strings.TrimSpace(strings.ToLower(value)) return value == "y" || value == "yes" diff --git a/pkg/docker/summary.go b/pkg/docker/summary.go index 68bcc6b..27bf35b 100644 --- a/pkg/docker/summary.go +++ b/pkg/docker/summary.go @@ -13,6 +13,7 @@ import ( dockercontainer "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/helpers" ) type ServiceSummary struct { @@ -102,10 +103,10 @@ func SummarizeProjectWithClient(ctx context.Context, cli DockerAPI, ctxCfg *conf } for _, container := range containers { - service := firstNonEmpty(container.Labels["com.docker.compose.service"], trimContainerName(container.Names)) + service := helpers.FirstNonEmpty(container.Labels["com.docker.compose.service"], TrimContainerName(container.Names)) item := ServiceSummary{ Service: service, - Name: trimContainerName(container.Names), + Name: TrimContainerName(container.Names), State: container.State, Status: container.Status, Healthy: strings.Contains(strings.ToLower(container.Status), "healthy"), @@ -257,15 +258,15 @@ func parseComposePSOutput(output string) (ProjectSummary, error) { continue } item := ServiceSummary{ - Service: firstNonEmpty(composeField(row, "service")), - Name: firstNonEmpty(composeField(row, "name")), - State: strings.ToLower(firstNonEmpty(composeField(row, "state"), "unknown")), - Status: firstNonEmpty(composeField(row, "status")), + Service: helpers.FirstNonEmpty(composeField(row, "service")), + Name: helpers.FirstNonEmpty(composeField(row, "name")), + State: strings.ToLower(helpers.FirstNonEmpty(composeField(row, "state"), "unknown")), + Status: helpers.FirstNonEmpty(composeField(row, "status")), } if item.Service == "" { item.Service = item.Name } - item.Healthy = strings.Contains(strings.ToLower(firstNonEmpty(composeField(row, "health"), item.Status)), "healthy") + item.Healthy = strings.Contains(strings.ToLower(helpers.FirstNonEmpty(composeField(row, "health"), item.Status)), "healthy") summary.Total++ if item.State == "running" { summary.Running++ @@ -469,7 +470,7 @@ func finalizeSummary(summary *ProjectSummary) { } } -func trimContainerName(names []string) string { +func TrimContainerName(names []string) string { for _, name := range names { name = strings.TrimPrefix(strings.TrimSpace(name), "/") if name != "" { @@ -478,12 +479,3 @@ func trimContainerName(names []string) string { } return "" } - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - return "" -} diff --git a/pkg/helpers/helpers.go b/pkg/helpers/helpers.go index f86ed75..36d74b6 100644 --- a/pkg/helpers/helpers.go +++ b/pkg/helpers/helpers.go @@ -16,6 +16,17 @@ func ExitOnError(err error) { os.Exit(1) } +// FirstNonEmpty returns the first non-empty (after trimming whitespace) string +// from the provided values, or empty string if all are empty. +func FirstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + // open a URL from the terminal func OpenURL(url string) error { var cmd *exec.Cmd diff --git a/pkg/tui/dashboard.go b/pkg/tui/dashboard.go index 1895ab6..0e95773 100644 --- a/pkg/tui/dashboard.go +++ b/pkg/tui/dashboard.go @@ -24,6 +24,7 @@ import ( "github.com/libops/sitectl/internal/tuitour" "github.com/libops/sitectl/pkg/config" "github.com/libops/sitectl/pkg/docker" + "github.com/libops/sitectl/pkg/helpers" "github.com/libops/sitectl/pkg/plugin" zone "github.com/lrstanley/bubblezone/v2" ) @@ -41,17 +42,6 @@ const ( screenTour ) -type overlayMode int - -const ( - overlayNone overlayMode = iota - overlayActions - overlaySettings - overlayChooser - overlayInfo - overlayCommands -) - type refreshTickMsg time.Time type summaryLoadedMsg struct { @@ -99,11 +89,7 @@ type keyMap struct { Right key.Binding Up key.Binding Down key.Binding - Actions key.Binding - Settings key.Binding - NewApp key.Binding Command key.Binding - Palette key.Binding Terminal key.Binding Refresh key.Binding Enter key.Binding @@ -117,11 +103,7 @@ func defaultKeyMap() keyMap { Right: key.NewBinding(key.WithKeys("right", "l", "tab"), key.WithHelp("l/right", "next site")), Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("k/up", "env up")), Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("j/down", "env down")), - Actions: key.NewBinding(key.WithKeys("ctrl+a"), key.WithHelp("ctrl+a", "actions")), - Settings: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "settings")), - NewApp: key.NewBinding(key.WithKeys("ctrl+n"), key.WithHelp("ctrl+n", "choose app")), Command: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "command bar")), - Palette: key.NewBinding(key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "palette")), Terminal: key.NewBinding(key.WithKeys("ctrl+x"), key.WithHelp("ctrl+x", "run in terminal")), Refresh: key.NewBinding(key.WithKeys("ctrl+r"), key.WithHelp("ctrl+r", "refresh")), Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), @@ -131,11 +113,11 @@ func defaultKeyMap() keyMap { } func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Left, k.Up, k.Command, k.Palette, k.Refresh, k.Terminal, k.Back, k.Quit} + return []key.Binding{k.Left, k.Up, k.Command, k.Refresh, k.Terminal, k.Back, k.Quit} } func (k keyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.Left, k.Right, k.Up, k.Down}, {k.Command, k.Palette, k.Refresh}, {k.Actions, k.Settings, k.NewApp, k.Terminal, k.Enter, k.Back, k.Quit}} + return [][]key.Binding{{k.Left, k.Right, k.Up, k.Down}, {k.Command, k.Refresh, k.Terminal, k.Enter, k.Back, k.Quit}} } type dashboardModel struct { @@ -150,8 +132,7 @@ type dashboardModel struct { width int height int - screen screenMode - overlay overlayMode + screen screenMode loading bool loadingLog bool @@ -160,8 +141,6 @@ type dashboardModel struct { logsErr error lastMessage string - infoTitle string - infoBody string logsTitle string logTarget string detailBody string @@ -172,16 +151,12 @@ type dashboardModel struct { historyNet map[string][]float64 lastNetSample map[string]networkSample - help help.Model - keys keyMap - spin spinner.Model - detail viewport.Model - logs viewport.Model - actions list.Model - settings list.Model - chooser list.Model - commands list.Model - commandParent string + help help.Model + keys keyMap + spin spinner.Model + detail viewport.Model + logs viewport.Model + chooser list.Model commandInput textinput.Model commandRunning bool @@ -245,17 +220,7 @@ func newDashboardModel(cfg *config.Config, plugins []plugin.InstalledPlugin) *da m.logsBody = "No logs loaded." m.logs.SetContent(m.logsBody) m.logsTitle = "Logs" - m.actions = newMenuModel("Actions", []menuItem{ - {title: "Refresh", desc: "Reload summary for the selected environment", action: "refresh"}, - {title: "Logs", desc: "Open a log view for this environment", action: "logs"}, - {title: "Choose App", desc: "Open the plugin-backed app chooser", action: "chooser"}, - }) - m.settings = newMenuModel("Settings", []menuItem{ - {title: "Context Details", desc: "Inspect context configuration for the selected environment", action: "context-info"}, - {title: "Plugin Details", desc: "Inspect the selected plugin and template repo", action: "plugin-info"}, - }) m.chooser = newMenuModel(chooserTitle(m.sites), chooserItems(m.sites, m.plugins)) - m.commands = newMenuModel("Commands", commandPaletteItems("", m.selectedContextName(), m.selectedSiteName(), m.selectedPluginName())) m.commandInput = textinput.New() m.commandInput.Prompt = "sitectl --context " + m.selectedContextName() + " " m.commandInput.Placeholder = "compose ps" @@ -380,7 +345,6 @@ func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.logsErr = nil m.logsTitle = "Logs" m.screen = screenDashboard - m.overlay = overlayNone m.chooser = newMenuModel(chooserTitle(m.sites), chooserItems(m.sites, m.plugins)) m.refreshCommandSuggestions() m.syncLayout() @@ -396,9 +360,6 @@ func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case tea.MouseMsg: - if m.overlay != overlayNone { - return m.updateOverlay(msg) - } if release, ok := msg.(tea.MouseReleaseMsg); ok { return m.handleMouseRelease(release) } @@ -419,9 +380,6 @@ func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleKey(msg) } - if m.overlay != overlayNone { - return m.updateOverlay(msg) - } if m.screen == screenLogs { var cmd tea.Cmd m.logs, cmd = m.logs.Update(msg) @@ -450,7 +408,7 @@ func (m *dashboardModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } } - if m.overlay == overlayNone && m.commandInput.Focused() { + if m.commandInput.Focused() { switch { case msg.String() == "ctrl+c": if strings.TrimSpace(m.commandInput.Value()) != "" { @@ -491,13 +449,6 @@ func (m *dashboardModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Quit): return m, tea.Quit case key.Matches(msg, m.keys.Back): - if m.overlay != overlayNone { - if m.overlay == overlayInfo { - m.syncDetailContent() - } - m.overlay = overlayNone - return m, nil - } if m.screen == screenLogs { m.screen = screenDashboard m.syncLayout() @@ -506,13 +457,6 @@ func (m *dashboardModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, tea.Quit } - if m.overlay != overlayNone { - if msg.String() == "enter" { - return m.handleOverlaySelection() - } - return m.updateOverlay(msg) - } - if m.screen == screenLogs { switch { case key.Matches(msg, m.keys.Refresh): @@ -556,23 +500,9 @@ func (m *dashboardModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.envIndex++ return m.reloadSelected() } - case key.Matches(msg, m.keys.Actions): - m.overlay = overlayActions - return m, nil - case key.Matches(msg, m.keys.Settings): - m.overlay = overlaySettings - return m, nil - case key.Matches(msg, m.keys.NewApp): - m.overlay = overlayChooser - return m, nil case key.Matches(msg, m.keys.Command): m.commandInput.Focus() return m, nil - case key.Matches(msg, m.keys.Palette): - m.commandParent = "" - m.commands = newMenuModel("Commands", commandPaletteItems("", m.selectedContextName(), m.selectedSiteName(), m.selectedPluginName())) - m.overlay = overlayCommands - return m, nil case key.Matches(msg, m.keys.Refresh): if ctx, ok := m.selectedContext(); ok { m.loading = true @@ -632,146 +562,9 @@ func (m *dashboardModel) handleMouseRelease(msg tea.MouseReleaseMsg) (tea.Model, } } - if z := zone.Get("chip:actions"); z != nil && z.InBounds(msg) { - m.overlay = overlayActions - } - if z := zone.Get("chip:settings"); z != nil && z.InBounds(msg) { - m.overlay = overlaySettings - } - if z := zone.Get("chip:new"); z != nil && z.InBounds(msg) { - m.overlay = overlayChooser - } - if z := zone.Get("chip:refresh"); z != nil && z.InBounds(msg) { - if ctx, ok := m.selectedContext(); ok { - m.loading = true - return m, loadSummaryCmd(ctx) - } - } - if z := zone.Get("chip:command"); z != nil && z.InBounds(msg) { - m.commandInput.Focus() - return m, nil - } - if z := zone.Get("chip:palette"); z != nil && z.InBounds(msg) { - m.commandParent = "" - m.commands = newMenuModel("Commands", commandPaletteItems("", m.selectedContextName(), m.selectedSiteName(), m.selectedPluginName())) - m.overlay = overlayCommands - } - return m, nil } -func (m *dashboardModel) updateOverlay(msg tea.Msg) (tea.Model, tea.Cmd) { - switch m.overlay { - case overlayActions: - var cmd tea.Cmd - m.actions, cmd = m.actions.Update(msg) - return m, cmd - case overlaySettings: - var cmd tea.Cmd - m.settings, cmd = m.settings.Update(msg) - return m, cmd - case overlayChooser: - var cmd tea.Cmd - m.chooser, cmd = m.chooser.Update(msg) - return m, cmd - case overlayCommands: - var cmd tea.Cmd - m.commands, cmd = m.commands.Update(msg) - return m, cmd - case overlayInfo: - var cmd tea.Cmd - m.detail, cmd = m.detail.Update(msg) - return m, cmd - default: - return m, nil - } -} - -func (m *dashboardModel) handleOverlaySelection() (tea.Model, tea.Cmd) { - var item menuItem - switch m.overlay { - case overlayActions: - selected, _ := m.actions.SelectedItem().(menuItem) - item = selected - case overlaySettings: - selected, _ := m.settings.SelectedItem().(menuItem) - item = selected - case overlayChooser: - selected, _ := m.chooser.SelectedItem().(menuItem) - item = selected - case overlayCommands: - selected, _ := m.commands.SelectedItem().(menuItem) - item = selected - } - - switch item.action { - case "refresh": - m.overlay = overlayNone - if ctx, ok := m.selectedContext(); ok { - m.loading = true - return m, loadSummaryCmd(ctx) - } - case "logs": - m.overlay = overlayNone - return m.openLogs() - case "chooser": - m.overlay = overlayChooser - return m, nil - case "context-info": - if ctx, ok := m.selectedContext(); ok { - m.infoTitle = "Context Details" - m.infoBody = renderContextInfo(ctx) - m.detailBody = m.infoBody - m.detail.SetContent(m.detailBody) - m.detail.GotoTop() - m.overlay = overlayInfo - return m, nil - } - case "plugin-info": - if ctx, ok := m.selectedContext(); ok { - m.infoTitle = "Plugin Details" - m.infoBody = renderPluginInfo(findPlugin(m.plugins, ctx.Plugin), ctx.Plugin) - m.detail.SetContent(m.infoBody) - m.detail.GotoTop() - m.overlay = overlayInfo - return m, nil - } - default: - if item.action == "config-create" || strings.HasPrefix(item.action, "plugin:") { - m.overlay = overlayNone - return m.executeChooserAction(item.action) - } - if strings.HasPrefix(item.action, "palette:") { - parent := strings.TrimPrefix(item.action, "palette:") - m.commandParent = parent - m.commands = newMenuModel(commandPaletteTitle(parent), commandPaletteItems(parent, m.selectedContextName(), m.selectedSiteName(), m.selectedPluginName())) - return m, nil - } - if strings.HasPrefix(item.action, "fill:") { - m.commandInput.SetValue(strings.TrimPrefix(item.action, "fill:")) - m.overlay = overlayNone - m.commandInput.Focus() - return m, nil - } - } - - m.overlay = overlayNone - return m, nil -} - -func (m *dashboardModel) openLogs() (tea.Model, tea.Cmd) { - ctx, ok := m.selectedContext() - if !ok { - return m, nil - } - m.logTarget = "" - m.screen = screenLogs - m.loadingLog = true - m.logsTitle = "Logs | tail 20 | auto-refresh" - m.syncLayout() - return m, loadLogsCmd(ctx) -} - func (m *dashboardModel) openContainerLogs(containerName string) (tea.Model, tea.Cmd) { ctx, ok := m.selectedContext() if !ok { @@ -823,7 +616,6 @@ func (m *dashboardModel) render() string { body := lipgloss.JoinVertical(lipgloss.Left, m.renderTabs(), - m.renderHeaderChips(), m.renderTitle(), m.renderResourceHeader(), m.renderMainArea(), @@ -835,11 +627,7 @@ func (m *dashboardModel) render() string { body = lipgloss.JoinVertical(lipgloss.Left, body, subtleStyle.Render(m.lastMessage)) } - rendered := docStyle.Render(body) - if m.overlay != overlayNone { - return overlay(rendered, m.renderOverlay(), m.width, 1) - } - return rendered + return docStyle.Render(body) } func (m *dashboardModel) renderTabs() string { @@ -855,18 +643,6 @@ func (m *dashboardModel) renderTabs() string { return lipgloss.JoinHorizontal(lipgloss.Left, tabs...) } -func (m *dashboardModel) renderHeaderChips() string { - chips := []string{ - zone.Mark("chip:actions", chipStyle.Render("[ctrl+a] Actions")), - zone.Mark("chip:settings", chipStyle.Render("[ctrl+s] Settings")), - zone.Mark("chip:new", chipStyle.Render("[ctrl+n] Choose App")), - zone.Mark("chip:command", chipStyle.Render("[/] Command")), - zone.Mark("chip:palette", chipStyle.Render("[ctrl+p] Palette")), - zone.Mark("chip:refresh", chipStyle.Render("[ctrl+r] Refresh")), - } - return lipgloss.JoinHorizontal(lipgloss.Left, chips...) -} - func (m *dashboardModel) renderTitle() string { site := m.sites[m.siteIndex] ctx, _ := m.selectedContext() @@ -987,7 +763,7 @@ func (m *dashboardModel) renderEnvironmentCards(width int) string { lines := []string{strings.ToUpper(envLabel(ctx)), ctx.Name} if selected { cardWidth = selectedWidth - statusText := strings.ToUpper(firstNonEmpty(m.summary.Status, "unknown")) + statusText := strings.ToUpper(helpers.FirstNonEmpty(m.summary.Status, "unknown")) containersText := fmt.Sprintf( "containers: %d total, %d running, %d stopped", m.summary.Total, @@ -1004,7 +780,7 @@ func (m *dashboardModel) renderEnvironmentCards(width int) string { fmt.Sprintf("healthy: %d", m.summary.Healthy), ) } else { - lines = append(lines, firstNonEmpty(ctx.Plugin, "core")) + lines = append(lines, helpers.FirstNonEmpty(ctx.Plugin, "core")) } if ctx.Name == m.currentContext { lines = append(lines, accentStyle.Render("current")) @@ -1033,30 +809,6 @@ func (m *dashboardModel) renderDetailsPanel(width int) string { ) } -func (m *dashboardModel) renderOverlay() string { - title := "Menu" - content := "" - switch m.overlay { - case overlayActions: - title = "Actions" - content = m.actions.View() - case overlaySettings: - title = "Settings" - content = m.settings.View() - case overlayChooser: - title = "Choose An App" - content = m.chooser.View() - case overlayInfo: - title = m.infoTitle - overlayWidth := min(72, max(48, m.width-12)) - content = renderViewportWithScrollbar(m.detail, m.detailBody, overlayWidth-6) - case overlayCommands: - title = commandPaletteTitle(m.commandParent) - content = m.commands.View() - } - return overlayPanelStyle.Width(min(72, max(48, m.width-12))).Render(sectionTitleStyle.Render(title) + "\n" + content) -} - func (m *dashboardModel) renderOnboarding() string { width := max(56, min(88, m.width-10)) intro := panelStyle.Width(width).Render(strings.Join([]string{ @@ -1117,12 +869,8 @@ func (m *dashboardModel) syncLayout() { m.logs.SetWidth(max(30, m.width-hpad-8)) m.logs.SetHeight(logHeight) - menuWidth := min(58, max(36, m.width/2)) - menuHeight := min(18, max(10, m.height/2)) - m.actions.SetSize(menuWidth, menuHeight) - m.settings.SetSize(menuWidth, menuHeight) - m.chooser.SetSize(menuWidth, menuHeight) - m.commands.SetSize(menuWidth, menuHeight) + chooserWidth := min(72, max(48, m.width-12)) + m.chooser.SetSize(chooserWidth, min(18, max(10, m.height/2))) m.commandInput.SetWidth(max(20, m.width-18)) m.commandInput.Prompt = "sitectl --context " + m.selectedContextName() + " " @@ -1139,11 +887,6 @@ func (m *dashboardModel) syncDetailContent() { m.detail.SetContent(m.detailBody) return } - if m.overlay == overlayInfo && strings.TrimSpace(m.infoBody) != "" { - m.detailBody = m.infoBody - m.detail.SetContent(m.detailBody) - return - } if m.summaryErr != nil { m.detailBody = m.summaryErr.Error() m.detail.SetContent(m.detailBody) @@ -1163,10 +906,10 @@ func (m *dashboardModel) syncDetailContent() { for _, service := range m.summary.Services { line := fmt.Sprintf( " %-36s %6.1f%% %-22s %s", - truncateMetricText(firstNonEmpty(service.Name, service.Service), 36), + truncateMetricText(helpers.FirstNonEmpty(service.Name, service.Service), 36), service.CPUPercent, truncateMetricText(containerMemorySummary(service), 22), - truncateMetricText(firstNonEmpty(service.Status, service.State), 12), + truncateMetricText(helpers.FirstNonEmpty(service.Status, service.State), 12), ) lines = append(lines, zone.Mark(containerZoneID(service.Name), line)) } @@ -1215,6 +958,7 @@ func newMenuModel(title string, items []menuItem) list.Model { } m := list.New(converted, delegate, 48, 12) m.Title = title + m.SetShowTitle(false) m.SetFilteringEnabled(false) m.SetShowStatusBar(false) m.SetShowHelp(false) @@ -1307,7 +1051,7 @@ func groupContexts(cfg *config.Config) []siteGroup { siteMap := map[string][]config.Context{} for _, ctx := range cfg.Contexts { - siteName := firstNonEmpty(ctx.Site, ctx.ProjectName, ctx.Name, "default") + siteName := helpers.FirstNonEmpty(ctx.Site, ctx.ProjectName, ctx.Name, "default") siteMap[siteName] = append(siteMap[siteName], ctx) } @@ -1360,7 +1104,7 @@ func defaultEnvIndex(contexts []config.Context, current string) int { } func envLabel(ctx config.Context) string { - return firstNonEmpty(ctx.Environment, "unknown") + return helpers.FirstNonEmpty(ctx.Environment, "unknown") } func envSortRank(value string) int { @@ -1379,24 +1123,6 @@ func envSortRank(value string) int { } } -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - return "" -} - -func findPlugin(plugins []plugin.InstalledPlugin, name string) plugin.InstalledPlugin { - for _, p := range plugins { - if p.Name == name { - return p - } - } - return plugin.InstalledPlugin{Name: name} -} - func (m *dashboardModel) selectedContextName() string { if ctx, ok := m.selectedContext(); ok { return ctx.Name @@ -1420,9 +1146,6 @@ func (m *dashboardModel) selectedPluginName() string { func (m *dashboardModel) refreshCommandSuggestions() { m.commandInput.SetSuggestions(commandSuggestions(m.selectedContextName(), m.selectedSiteName(), m.selectedPluginName())) - if m.commandParent != "" { - m.commands = newMenuModel(commandPaletteTitle(m.commandParent), commandPaletteItems(m.commandParent, m.selectedContextName(), m.selectedSiteName(), m.selectedPluginName())) - } } func (m *dashboardModel) hasContexts() bool { @@ -1464,107 +1187,6 @@ func (m *dashboardModel) handleOnboardingSelection() (tea.Model, tea.Cmd) { return m.executeChooserAction(selected.action) } -func renderContextInfo(ctx config.Context) string { - lines := []string{ - fmt.Sprintf("Name: %s", ctx.Name), - fmt.Sprintf("Site: %s", firstNonEmpty(ctx.Site, "-")), - fmt.Sprintf("Environment: %s", envLabel(ctx)), - fmt.Sprintf("Plugin: %s", firstNonEmpty(ctx.Plugin, "-")), - fmt.Sprintf("Docker Host Type: %s", firstNonEmpty(string(ctx.DockerHostType), "-")), - fmt.Sprintf("Project Name: %s", firstNonEmpty(ctx.ProjectName, "-")), - fmt.Sprintf("Compose Project: %s", firstNonEmpty(ctx.EffectiveComposeProjectName(), "-")), - fmt.Sprintf("Compose Network: %s", firstNonEmpty(ctx.EffectiveComposeNetwork(), "-")), - fmt.Sprintf("Project Dir: %s", firstNonEmpty(ctx.ProjectDir, "-")), - fmt.Sprintf("Docker Socket: %s", firstNonEmpty(ctx.DockerSocket, "-")), - } - if ctx.DockerHostType == config.ContextRemote { - lines = append(lines, - fmt.Sprintf("SSH Host: %s", firstNonEmpty(ctx.SSHHostname, "-")), - fmt.Sprintf("SSH User: %s", firstNonEmpty(ctx.SSHUser, "-")), - fmt.Sprintf("SSH Port: %d", ctx.SSHPort), - ) - } - if len(ctx.ComposeFile) > 0 { - lines = append(lines, "", "Compose Files:") - for _, file := range ctx.ComposeFile { - lines = append(lines, " "+file) - } - } - if len(ctx.EnvFile) > 0 { - lines = append(lines, "", "Env Files:") - for _, file := range ctx.EnvFile { - lines = append(lines, " "+file) - } - } - return strings.Join(lines, "\n") -} - -func renderPluginInfo(p plugin.InstalledPlugin, fallbackName string) string { - name := firstNonEmpty(p.Name, fallbackName, "unknown") - lines := []string{ - fmt.Sprintf("Name: %s", name), - fmt.Sprintf("Description: %s", firstNonEmpty(p.Description, "-")), - fmt.Sprintf("Version: %s", firstNonEmpty(p.Version, "-")), - fmt.Sprintf("Author: %s", firstNonEmpty(p.Author, "-")), - fmt.Sprintf("Binary: %s", firstNonEmpty(p.BinaryName, "-")), - fmt.Sprintf("Path: %s", firstNonEmpty(p.Path, "-")), - fmt.Sprintf("Template Repo: %s", firstNonEmpty(p.TemplateRepo, "-")), - } - return strings.Join(lines, "\n") -} - -func commandPaletteTitle(parent string) string { - if strings.TrimSpace(parent) == "" { - return "Commands" - } - return strings.ToUpper(parent[:1]) + parent[1:] + " Commands" -} - -func commandPaletteItems(parent, contextName, siteName, pluginName string) []menuItem { - switch parent { - case "compose": - return []menuItem{ - {title: "ps", desc: "Show compose service status", action: "fill:compose ps"}, - {title: "logs", desc: "Fetch recent compose logs", action: "fill:compose logs --tail 80 --no-color"}, - {title: "up", desc: "Start services in detached mode", action: "fill:compose up"}, - {title: "down", desc: "Stop and remove services", action: "fill:compose down"}, - {title: "restart", desc: "Restart all services", action: "fill:compose restart"}, - {title: "exec", desc: "Open a shell in a service container", action: "fill:compose exec -it drupal bash"}, - } - case "config": - return []menuItem{ - {title: "validate", desc: "Validate the selected context", action: "fill:config validate"}, - {title: "current-context", desc: "Show active context resolution", action: "fill:config current-context"}, - {title: "get-environments", desc: "List environments for this site", action: "fill:config get-environments " + siteName}, - {title: "get-sites", desc: "List configured sites", action: "fill:config get-sites"}, - } - case "port-forward": - return []menuItem{ - {title: "traefik", desc: "Forward a common HTTP admin port", action: "fill:port-forward 8080:traefik:8080"}, - {title: "solr", desc: "Forward Solr admin for a remote site", action: "fill:port-forward 8983:solr:8983"}, - } - case "plugin": - return []menuItem{ - {title: pluginName, desc: "Open plugin help", action: "fill:" + pluginName + " --help"}, - } - default: - items := []menuItem{ - {title: "compose", desc: "Docker Compose commands for the selected environment", action: "palette:compose"}, - {title: "config", desc: "Context-aware configuration commands", action: "palette:config"}, - {title: "make", desc: "Run project make targets through sitectl", action: "fill:make"}, - {title: "port-forward", desc: "Forward ports to remote services", action: "palette:port-forward"}, - {title: "sequelace", desc: "Open database tooling for this context", action: "fill:sequelace"}, - } - if strings.TrimSpace(pluginName) != "" && pluginName != "core" { - items = append(items, menuItem{title: pluginName, desc: "Plugin-specific commands", action: "palette:plugin"}) - } - if strings.TrimSpace(contextName) != "" { - items = append(items, menuItem{title: "help", desc: "Show sitectl help", action: "fill:--help"}) - } - return items - } -} - func commandSuggestions(contextName, siteName, pluginName string) []string { items := []string{ "compose ps", @@ -2039,10 +1661,10 @@ func renderContainerHeader(summary docker.ProjectSummary, containerName string) } return fmt.Sprintf( "Container: %s | CPU %.1f%% | Mem %s | %s", - firstNonEmpty(service.Name, service.Service), + helpers.FirstNonEmpty(service.Name, service.Service), service.CPUPercent, containerMemorySummary(service), - firstNonEmpty(service.Status, service.State), + helpers.FirstNonEmpty(service.Status, service.State), ) } return "Container: " + containerName diff --git a/pkg/tui/styles.go b/pkg/tui/styles.go index 2a02d9e..3fe8c9c 100644 --- a/pkg/tui/styles.go +++ b/pkg/tui/styles.go @@ -1,9 +1,6 @@ package tui import ( - "fmt" - "strings" - "charm.land/bubbles/v2/help" "charm.land/lipgloss/v2" ) @@ -37,10 +34,6 @@ var ( MarginRight(1). MarginBottom(1) - overlayPanelStyle = panelStyle. - BorderForeground(lipgloss.Color("#98C1D9")). - Background(lipgloss.Color("#112235")) - cardStyle = panelStyle.Width(40) selectedCardStyle = cardStyle. @@ -89,28 +82,6 @@ func helpStyles() help.Styles { return styles } -func overlay(base, top string, width int, progress float64) string { - if strings.TrimSpace(top) == "" { - return base - } - baseLines := strings.Split(base, "\n") - topLines := strings.Split(top, "\n") - x := max(2, width/2-lipgloss.Width(top)/2+int((1-progress)*10)) - y := 4 - - for i, line := range topLines { - idx := y + i - if idx >= len(baseLines) { - break - } - prefixWidth := min(x, lipgloss.Width(baseLines[idx])) - prefix := lipgloss.NewStyle().Width(prefixWidth).Render(baseLines[idx]) - baseLines[idx] = fmt.Sprintf("%s%s", prefix, line) - } - - return strings.Join(baseLines, "\n") -} - func splitWidth(total, columns int) []int { if columns <= 0 { return nil diff --git a/pkg/validate/validate.go b/pkg/validate/validate.go index f02455e..928c4f7 100644 --- a/pkg/validate/validate.go +++ b/pkg/validate/validate.go @@ -10,6 +10,7 @@ import ( corecomponent "github.com/libops/sitectl/pkg/component" "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/helpers" yaml "gopkg.in/yaml.v3" ) @@ -96,11 +97,11 @@ func WriteReports(out io.Writer, reports []Report, format string) error { func writeSections(out io.Writer, reports []Report) error { for i, report := range reports { - title := firstNonEmpty(report.Context, "validation") + title := helpers.FirstNonEmpty(report.Context, "validation") body := []string{ - fmt.Sprintf("Site: `%s`", firstNonEmpty(report.Site, "-")), - fmt.Sprintf("Plugin: `%s`", firstNonEmpty(report.Plugin, "-")), - fmt.Sprintf("Environment: `%s`", firstNonEmpty(report.Environment, "-")), + fmt.Sprintf("Site: `%s`", helpers.FirstNonEmpty(report.Site, "-")), + fmt.Sprintf("Plugin: `%s`", helpers.FirstNonEmpty(report.Plugin, "-")), + fmt.Sprintf("Environment: `%s`", helpers.FirstNonEmpty(report.Environment, "-")), fmt.Sprintf("Valid: `%t`", report.Valid), } fmt.Fprintln(out, corecomponent.RenderSection(title, strings.Join(body, "\n"))) @@ -134,13 +135,13 @@ func writeTable(out io.Writer, reports []Report) error { detail += "fix: " + strings.TrimSpace(result.FixHint) } fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - firstNonEmpty(report.Context, "-"), - firstNonEmpty(report.Site, "-"), - firstNonEmpty(report.Plugin, "-"), - firstNonEmpty(report.Environment, "-"), - firstNonEmpty(result.Name, "-"), - firstNonEmpty(result.Status, "-"), - firstNonEmpty(detail, "-"), + helpers.FirstNonEmpty(report.Context, "-"), + helpers.FirstNonEmpty(report.Site, "-"), + helpers.FirstNonEmpty(report.Plugin, "-"), + helpers.FirstNonEmpty(report.Environment, "-"), + helpers.FirstNonEmpty(result.Name, "-"), + helpers.FirstNonEmpty(result.Status, "-"), + helpers.FirstNonEmpty(detail, "-"), ) } } @@ -162,15 +163,6 @@ func normalizeFormat(format string) string { } } -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - func SortResults(results []Result) { sort.Slice(results, func(i, j int) bool { if results[i].Status != results[j].Status {