chore: introduce ui package (#31)

* move the Propter under screen package

* add color formatters

* add progress

This module allows for printing a sequence of lines with check marks.
Useful to show progress on a long running action (example: container
init).

* implement progess on create

* implement progress on assemble

* implement progress on enter

* implement color

* move internal/screen to pkg/ui

* add printer

* implement printer

* fix progress finalize method

* fix progress implementation for enter command
This commit is contained in:
Emanuele De Cupis
2026-06-11 18:24:40 +02:00
committed by Alessio Biancalana
parent a738eaba81
commit 9b401c4543
16 changed files with 256 additions and 71 deletions
+4 -3
View File
@@ -9,10 +9,10 @@ import (
"github.com/urfave/cli/v3"
"github.com/89luca89/distrobox/internal/prompt"
"github.com/89luca89/distrobox/pkg/commands"
"github.com/89luca89/distrobox/pkg/containermanager"
"github.com/89luca89/distrobox/pkg/manifest"
"github.com/89luca89/distrobox/pkg/ui"
)
func newAssembleCommand() *cli.Command {
@@ -108,9 +108,10 @@ func assembleAction(ctx context.Context, cmd *cli.Command, deleteFlag bool) erro
opts.Replace = cmd.Bool("replace")
}
prompter := prompt.NewPrompter(*bufio.NewReader(os.Stdin), os.Stdout)
prompter := ui.NewPrompter(*bufio.NewReader(os.Stdin), os.Stdout)
progress := ui.NewProgress(os.Stderr)
assembleCmd := commands.NewAssembleCommand(containerManager, prompter)
assembleCmd := commands.NewAssembleCommand(containerManager, prompter, progress)
err = assembleCmd.Execute(ctx, opts)
if err != nil {
+5 -1
View File
@@ -4,11 +4,13 @@ import (
"context"
"errors"
"fmt"
"os"
"github.com/urfave/cli/v3"
"github.com/89luca89/distrobox/pkg/commands"
"github.com/89luca89/distrobox/pkg/containermanager"
"github.com/89luca89/distrobox/pkg/ui"
)
//nolint:funlen // function length is acceptable for CLI command definition
@@ -208,7 +210,9 @@ func createAction(ctx context.Context, cmd *cli.Command) error {
Rootful: cmd.Bool("root"),
}
createCmd := commands.NewCreateCommand(containerManager)
progress := ui.NewProgress(os.Stderr)
createCmd := commands.NewCreateCommand(containerManager, progress)
err := createCmd.Execute(ctx, opts)
if err != nil {
return fmt.Errorf("create command failed: %w", err)
+6 -1
View File
@@ -4,11 +4,13 @@ import (
"context"
"errors"
"fmt"
"os"
"github.com/urfave/cli/v3"
"github.com/89luca89/distrobox/pkg/commands"
"github.com/89luca89/distrobox/pkg/containermanager"
"github.com/89luca89/distrobox/pkg/ui"
)
func newEnterCommand() *cli.Command {
@@ -77,7 +79,10 @@ func enterAction(ctx context.Context, cmd *cli.Command) error {
Verbose: cmd.Bool("verbose"),
}
enterCmd := commands.NewEnterCommand(containerManager, options)
progress := ui.NewProgress(os.Stderr)
printer := ui.NewPrinter(os.Stderr, true)
enterCmd := commands.NewEnterCommand(containerManager, options, progress, printer)
_, err := enterCmd.Execute(ctx)
if err != nil {
return fmt.Errorf("failed to execute create command: %w", err)
+15 -20
View File
@@ -10,12 +10,7 @@ import (
"github.com/89luca89/distrobox/pkg/commands"
"github.com/89luca89/distrobox/pkg/containermanager"
)
const (
colorGreen = "\033[32m"
colorYellow = "\033[33m"
colorReset = "\033[0m"
"github.com/89luca89/distrobox/pkg/ui"
)
func newListCommand() *cli.Command {
@@ -51,24 +46,24 @@ func listAction(ctx context.Context, cmd *cli.Command) error {
}
func printResult(result *commands.ListResult, noColor bool) {
rowFormat := "%-12s | %-20s | %-18s | %-30s\n"
//nolint:forbidigo // Using fmt.Printf is acceptable here for CLI output
fmt.Printf("%-12s | %-20s | %-18s | %-30s\n",
"ID", "NAME", "STATUS", "IMAGE")
fmt.Printf(rowFormat, "ID", "NAME", "STATUS", "IMAGE")
for _, c := range result.Containers {
if noColor {
//nolint:forbidigo // Using fmt.Printf is acceptable here for CLI output
fmt.Printf("%-12s | %-20s | %-18s | %-30s\n",
c.ID, c.Name, c.Status, c.Image)
} else {
color := colorYellow
if c.IsRunning() {
color = colorGreen
}
//nolint:forbidigo // Using fmt.Printf is acceptable here for CLI output
fmt.Printf("%s%-12s | %-20s | %-18s | %-30s%s\n",
color, c.ID, c.Name, c.Status, c.Image, colorReset)
var line string
switch {
case noColor:
line = rowFormat
case c.IsRunning():
line = ui.Green(rowFormat)
default:
line = ui.Yellow(rowFormat)
}
//nolint:forbidigo // Using fmt.Printf is acceptable here for CLI output
fmt.Printf(line, c.ID, c.Name, c.Status, c.Image)
}
}
+2 -2
View File
@@ -9,9 +9,9 @@ import (
"github.com/urfave/cli/v3"
"github.com/89luca89/distrobox/internal/prompt"
"github.com/89luca89/distrobox/pkg/commands"
"github.com/89luca89/distrobox/pkg/containermanager"
"github.com/89luca89/distrobox/pkg/ui"
)
func newRmCommand() *cli.Command {
@@ -55,7 +55,7 @@ func rmAction(ctx context.Context, cmd *cli.Command) error {
ContainerNames: cmd.Args().Slice(),
}
prompter := prompt.NewPrompter(*bufio.NewReader(os.Stdin), os.Stdout)
prompter := ui.NewPrompter(*bufio.NewReader(os.Stdin), os.Stdout)
rmCmd := commands.NewRmCommand(containerManager, prompter)
_, err := rmCmd.Execute(ctx, options)
+20 -4
View File
@@ -7,9 +7,9 @@ import (
"slices"
"strings"
"github.com/89luca89/distrobox/internal/prompt"
"github.com/89luca89/distrobox/pkg/containermanager"
"github.com/89luca89/distrobox/pkg/manifest"
"github.com/89luca89/distrobox/pkg/ui"
)
type AssembleOptions struct {
@@ -30,13 +30,19 @@ type AssembleCommand struct {
containermanager containermanager.ContainerManager
createCmd *CreateCommand
rmCmd *RmCommand
progress *ui.Progress
}
func NewAssembleCommand(cm containermanager.ContainerManager, prompter prompt.Prompter) *AssembleCommand {
func NewAssembleCommand(
cm containermanager.ContainerManager,
prompter ui.Prompter,
progress *ui.Progress,
) *AssembleCommand {
return &AssembleCommand{
containermanager: cm,
createCmd: NewCreateCommand(cm),
createCmd: NewCreateCommand(cm, ui.NewDevNullProgress()),
rmCmd: NewRmCommand(cm, prompter),
progress: progress,
}
}
@@ -75,6 +81,7 @@ func (ac *AssembleCommand) Execute(ctx context.Context, opts AssembleOptions) er
}
func (ac *AssembleCommand) deleteItem(ctx context.Context, item manifest.Item, dryRun bool) error {
ac.progress.Next("Deleting %s...", item.Name)
opts := RmOptions{
NoTTY: dryRun,
Force: true,
@@ -85,8 +92,10 @@ func (ac *AssembleCommand) deleteItem(ctx context.Context, item manifest.Item, d
_, err := ac.rmCmd.Execute(ctx, opts)
if err != nil {
ac.progress.Fail()
return fmt.Errorf("failed to execute delete item '%s': %w", item.Name, err)
}
ac.progress.Done()
return nil
}
@@ -100,6 +109,7 @@ func (ac *AssembleCommand) replaceItem(ctx context.Context, item manifest.Item,
}
func (ac *AssembleCommand) createItem(ctx context.Context, item manifest.Item, dryRun bool) error {
ac.progress.Next("Creating %s...", item.Name)
opts := CreateOptions{
ContainerClone: item.Clone,
ContainerName: item.Name,
@@ -126,7 +136,13 @@ func (ac *AssembleCommand) createItem(ctx context.Context, item manifest.Item, d
// TODO: pull image if needed
// https://github.com/89luca89/distrobox/blob/main/distrobox-create#L1016
return ac.createCmd.Execute(ctx, opts)
err := ac.createCmd.Execute(ctx, opts)
if err != nil {
ac.progress.Fail()
return err
}
ac.progress.Done()
return nil
}
func (ac *AssembleCommand) joinHooks(hooks []string) string {
+9 -1
View File
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/89luca89/distrobox/pkg/containermanager"
"github.com/89luca89/distrobox/pkg/ui"
)
const (
@@ -24,6 +25,7 @@ var ErrHostnameTooLong = fmt.Errorf("hostname too long, must be less than %d cha
type CreateCommand struct {
containerManager containermanager.ContainerManager
generateEntryCmd *GenerateEntryCommand
progress *ui.Progress
}
type CreateOptions struct {
@@ -67,10 +69,11 @@ type CreateOptions struct {
Rootful bool
}
func NewCreateCommand(cm containermanager.ContainerManager) *CreateCommand {
func NewCreateCommand(cm containermanager.ContainerManager, progress *ui.Progress) *CreateCommand {
return &CreateCommand{
containerManager: cm,
generateEntryCmd: NewGenerateEntryCommand(NewListCommand(cm)),
progress: progress,
}
}
@@ -153,6 +156,8 @@ func (c *CreateCommand) Execute(ctx context.Context, opts CreateOptions) error {
// TODO: pull image if needed
// https://github.com/89luca89/distrobox/blob/main/distrobox-create#L1016
c.progress.Next("Creating '%s' using image %s", containerName, containerImage)
err := c.containerManager.Create(
ctx,
containermanager.CreateOptions{
@@ -180,9 +185,12 @@ func (c *CreateCommand) Execute(ctx context.Context, opts CreateOptions) error {
)
if err != nil {
c.progress.Fail()
return fmt.Errorf("failed to create container: %w", err)
}
c.progress.Done()
if opts.GenerateEntry && !opts.DryRun && !opts.Rootful {
err := c.generateEntryCmd.Execute(
ctx,
+12 -2
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"github.com/89luca89/distrobox/pkg/containermanager"
"github.com/89luca89/distrobox/pkg/ui"
)
type EnterResult struct{}
@@ -12,17 +13,26 @@ type EnterResult struct{}
type EnterCommand struct {
containerManager containermanager.ContainerManager
options containermanager.EnterOptions
progress *ui.Progress
printer *ui.Printer
}
func NewEnterCommand(cm containermanager.ContainerManager, options containermanager.EnterOptions) *EnterCommand {
func NewEnterCommand(
cm containermanager.ContainerManager,
options containermanager.EnterOptions,
progress *ui.Progress,
printer *ui.Printer,
) *EnterCommand {
return &EnterCommand{
containerManager: cm,
options: options,
progress: progress,
printer: printer,
}
}
func (c *EnterCommand) Execute(ctx context.Context) (*EnterResult, error) {
err := c.containerManager.Enter(ctx, c.options)
err := c.containerManager.Enter(ctx, c.options, c.progress, c.printer)
if err != nil {
return nil, fmt.Errorf("failed to enter the container: %w", err)
}
+3 -3
View File
@@ -6,8 +6,8 @@ import (
"slices"
"strings"
"github.com/89luca89/distrobox/internal/prompt"
"github.com/89luca89/distrobox/pkg/containermanager"
"github.com/89luca89/distrobox/pkg/ui"
)
type RmResult struct {
@@ -17,7 +17,7 @@ type RmResult struct {
type RmCommand struct {
containerManager containermanager.ContainerManager
listCmd *ListCommand
prompter prompt.Prompter
prompter ui.Prompter
}
type RmOptions struct {
@@ -30,7 +30,7 @@ type RmOptions struct {
func NewRmCommand(
cm containermanager.ContainerManager,
prompter prompt.Prompter,
prompter ui.Prompter,
) *RmCommand {
return &RmCommand{
containerManager: cm,
@@ -4,7 +4,7 @@ import (
"context"
"strings"
"github.com/89luca89/distrobox/internal/prompt"
"github.com/89luca89/distrobox/pkg/ui"
)
type Container struct {
@@ -68,8 +68,8 @@ type ContanerManagerType string
type ContainerManager interface {
Name() string
Enter(ctx context.Context, options EnterOptions) error
Enter(ctx context.Context, options EnterOptions, progress *ui.Progress, printer *ui.Printer) error
ListContainers(ctx context.Context) ([]Container, error)
Create(ctx context.Context, opts CreateOptions) error
Remove(ctx context.Context, containerName string, opts RmOptions, prompter prompt.Prompter) error
Remove(ctx context.Context, containerName string, opts RmOptions, prompter ui.Prompter) error
}
@@ -15,9 +15,9 @@ import (
"time"
insidedistrobox "github.com/89luca89/distrobox/internal/inside-distrobox"
"github.com/89luca89/distrobox/internal/prompt"
"github.com/89luca89/distrobox/internal/userenv"
"github.com/89luca89/distrobox/pkg/containermanager"
"github.com/89luca89/distrobox/pkg/ui"
)
const (
@@ -487,6 +487,8 @@ func (d *Docker) run(ctx context.Context, args []string, opts runOptions) (strin
func (d *Docker) Enter(
ctx context.Context,
options containermanager.EnterOptions,
progress *ui.Progress,
printer *ui.Printer,
) error {
userEnv := userenv.LoadUserEnvironment(ctx)
user := userEnv.User
@@ -508,7 +510,18 @@ func (d *Docker) Enter(
inspectResult, err := d.InspectContainer(ctx, options.ContainerName)
if err != nil || inspectResult.ContainerStatus != RunningStatus {
_ = d.startContainer(ctx, options.ContainerName)
logTimestamp := timestampNow()
_ = d.startContainer(ctx, options.ContainerName, progress)
// Monitor logs for setup completion
if err := d.waitForSetup(ctx, options.ContainerName, logTimestamp, progress, printer); err != nil {
return err
}
progress.Finalize("Container Setup Complete!")
return nil
}
_, _ = d.run(ctx, append(command, commandArgs...), runOptions{Interactive: !options.NoTTY})
@@ -520,7 +533,7 @@ func (d *Docker) Remove(
ctx context.Context,
containerName string,
options containermanager.RmOptions,
prompter prompt.Prompter,
prompter ui.Prompter,
) error {
userEnv := userenv.LoadUserEnvironment(ctx)
userHome := userEnv.Home
@@ -908,9 +921,7 @@ func buildCommandArgs(customCommand string, user string, noTTY bool, unshareGrou
return args
}
func (d *Docker) startContainer(ctx context.Context, containerName string) error {
logTimestamp := timestampNow()
func (d *Docker) startContainer(ctx context.Context, containerName string, progress *ui.Progress) error {
// Start the container
_, err := d.run(ctx, []string{"start", containerName}, runOptions{Interactive: true})
if err != nil {
@@ -927,7 +938,7 @@ func (d *Docker) startContainer(ctx context.Context, containerName string) error
return fmt.Errorf("could not start entrypoint.\n%s", logs)
}
fmt.Fprintf(os.Stderr, "%-40s\t", "Starting container...")
progress.Next("Starting container...")
userEnv := userenv.LoadUserEnvironment(ctx)
@@ -942,21 +953,21 @@ func (d *Docker) startContainer(ctx context.Context, containerName string) error
return fmt.Errorf("failed to create cache directory: %w", err)
}
// Monitor logs for setup completion
if err := d.waitForSetup(ctx, containerName, logTimestamp); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "\nContainer Setup Complete!")
return nil
}
func (d *Docker) waitForSetup(ctx context.Context, containerName string, since string) error {
func (d *Docker) waitForSetup(
ctx context.Context,
containerName string,
since string,
progress *ui.Progress,
printer *ui.Printer,
) error {
for {
// Check container is still running
inspectResult, err := d.InspectContainer(ctx, containerName)
if err != nil || inspectResult.ContainerStatus != RunningStatus {
fmt.Fprintln(os.Stderr, "\nContainer Setup Failure!")
printer.PrintError("\nContainer Setup Failure!")
return fmt.Errorf("container stopped during setup: %w", err)
}
@@ -979,20 +990,21 @@ func (d *Docker) waitForSetup(ctx context.Context, containerName string, since s
continue
case strings.HasPrefix(line, "Error:"):
fmt.Fprintf(os.Stderr, "\033[31m %s\n\033[0m", line)
progress.Fail()
printer.PrintError(line)
return fmt.Errorf("container setup error: %s", line)
case strings.HasPrefix(line, "Warning:"):
fmt.Fprintf(os.Stderr, "\n\033[33m %s\033[0m", line)
printer.PrintWarning(line)
case strings.HasPrefix(line, "distrobox:"):
parts := strings.SplitN(line, " ", Two)
if len(parts) > 1 {
fmt.Fprintf(os.Stderr, "\033[32m [ OK ]\n\033[0m%-40s\t", parts[1])
progress.Done()
progress.Next("%s", parts[1])
}
case strings.HasPrefix(line, "container_setup_done"):
fmt.Fprintf(os.Stderr, "\033[32m [ OK ]\n\033[0m")
return nil
}
}
+20
View File
@@ -0,0 +1,20 @@
package ui
const (
colorRed = "\033[31m"
colorGreen = "\033[32m"
colorYellow = "\033[33m"
colorReset = "\033[0m"
)
func Red(text string) string {
return colorRed + text + colorReset
}
func Green(text string) string {
return colorGreen + text + colorReset
}
func Yellow(text string) string {
return colorYellow + text + colorReset
}
+54
View File
@@ -0,0 +1,54 @@
package ui
import (
"fmt"
"io"
)
type Printer struct {
writer io.Writer
colorful bool
}
func NewPrinter(writer io.Writer, colorful bool) *Printer {
return &Printer{
writer: writer,
colorful: colorful,
}
}
func (p *Printer) Print(msg string, a ...any) {
fmt.Fprintf(p.writer, msg, a...)
}
func (p *Printer) Println(msg string, a ...any) {
p.Print(msg+"\n", a...)
}
func (p *Printer) PrintWarning(msg string, a ...any) {
if p.colorful {
msg = Yellow(msg)
}
p.Print(msg, a...)
}
func (p *Printer) PrintWarningln(msg string, a ...any) {
if p.colorful {
msg = Yellow(msg)
}
p.Println(msg, a...)
}
func (p *Printer) PrintError(msg string, a ...any) {
if p.colorful {
msg = Red(msg)
}
p.Print(msg, a...)
}
func (p *Printer) PrintErrorln(msg string, a ...any) {
if p.colorful {
msg = Red(msg)
}
p.Println(msg, a...)
}
+60
View File
@@ -0,0 +1,60 @@
package ui
import (
"fmt"
"io"
)
type Progress struct {
pending bool
writer io.Writer
}
func NewProgress(writer io.Writer) *Progress {
return &Progress{
pending: false,
writer: writer,
}
}
func NewDevNullProgress() *Progress {
return &Progress{
pending: false,
writer: io.Discard,
}
}
func (p *Progress) Next(message string, a ...any) {
if p.pending {
p.Done()
}
p.pending = true
msg := fmt.Sprintf(message, a...)
fmt.Fprintf(p.writer, "%-40s\t", msg)
}
func (p *Progress) Finalize(message string, a ...any) {
p.Done()
p.Next(message, a...)
fmt.Fprintf(p.writer, "\n")
p.pending = false
}
func (p *Progress) Done() {
if !p.pending {
return
}
p.pending = false
fmt.Fprintf(p.writer, "%s\n", Green("[ OK ] "))
}
func (p *Progress) Fail() {
if !p.pending {
return
}
p.pending = false
fmt.Fprintf(p.writer, "%s\n", Red("[ ERR ]"))
}
@@ -1,4 +1,4 @@
package prompt
package ui
import (
"bufio"
@@ -1,4 +1,4 @@
package prompt_test
package ui_test
import (
"bufio"
@@ -6,13 +6,13 @@ import (
"strings"
"testing"
"github.com/89luca89/distrobox/internal/prompt"
"github.com/89luca89/distrobox/pkg/ui"
)
func TestPrompt_YesReturnsTrue(t *testing.T) {
reader := bufio.NewReader(strings.NewReader("yes\n"))
writer := &bytes.Buffer{}
p := prompt.NewPrompter(*reader, writer)
p := ui.NewPrompter(*reader, writer)
result := p.Prompt("Continue?", false)
@@ -24,7 +24,7 @@ func TestPrompt_YesReturnsTrue(t *testing.T) {
func TestPrompt_YReturnsTrue(t *testing.T) {
reader := bufio.NewReader(strings.NewReader("y\n"))
writer := &bytes.Buffer{}
p := prompt.NewPrompter(*reader, writer)
p := ui.NewPrompter(*reader, writer)
result := p.Prompt("Continue?", false)
@@ -36,7 +36,7 @@ func TestPrompt_YReturnsTrue(t *testing.T) {
func TestPrompt_NoReturnsFalse(t *testing.T) {
reader := bufio.NewReader(strings.NewReader("no\n"))
writer := &bytes.Buffer{}
p := prompt.NewPrompter(*reader, writer)
p := ui.NewPrompter(*reader, writer)
result := p.Prompt("Continue?", true)
@@ -48,7 +48,7 @@ func TestPrompt_NoReturnsFalse(t *testing.T) {
func TestPrompt_NReturnsFalse(t *testing.T) {
reader := bufio.NewReader(strings.NewReader("n\n"))
writer := &bytes.Buffer{}
p := prompt.NewPrompter(*reader, writer)
p := ui.NewPrompter(*reader, writer)
result := p.Prompt("Continue?", true)
@@ -60,7 +60,7 @@ func TestPrompt_NReturnsFalse(t *testing.T) {
func TestPrompt_InvalidInputReturnsDefaultTrue(t *testing.T) {
reader := bufio.NewReader(strings.NewReader("maybe\n"))
writer := &bytes.Buffer{}
p := prompt.NewPrompter(*reader, writer)
p := ui.NewPrompter(*reader, writer)
result := p.Prompt("Continue?", true)
@@ -72,7 +72,7 @@ func TestPrompt_InvalidInputReturnsDefaultTrue(t *testing.T) {
func TestPrompt_InvalidInputReturnsDefaultFalse(t *testing.T) {
reader := bufio.NewReader(strings.NewReader("maybe\n"))
writer := &bytes.Buffer{}
p := prompt.NewPrompter(*reader, writer)
p := ui.NewPrompter(*reader, writer)
result := p.Prompt("Continue?", false)
@@ -84,7 +84,7 @@ func TestPrompt_InvalidInputReturnsDefaultFalse(t *testing.T) {
func TestPrompt_EmptyInputReturnsDefault(t *testing.T) {
reader := bufio.NewReader(strings.NewReader("\n"))
writer := &bytes.Buffer{}
p := prompt.NewPrompter(*reader, writer)
p := ui.NewPrompter(*reader, writer)
result := p.Prompt("Continue?", true)
@@ -96,7 +96,7 @@ func TestPrompt_EmptyInputReturnsDefault(t *testing.T) {
func TestPrompt_WritesPromptToWriter(t *testing.T) {
reader := bufio.NewReader(strings.NewReader("y\n"))
writer := &bytes.Buffer{}
p := prompt.NewPrompter(*reader, writer)
p := ui.NewPrompter(*reader, writer)
p.Prompt("Continue?", true)
@@ -109,7 +109,7 @@ func TestPrompt_WritesPromptToWriter(t *testing.T) {
func TestPrompt_WritesPromptWithDefaultNo(t *testing.T) {
reader := bufio.NewReader(strings.NewReader("n\n"))
writer := &bytes.Buffer{}
p := prompt.NewPrompter(*reader, writer)
p := ui.NewPrompter(*reader, writer)
p.Prompt("Delete file?", false)