Compare commits
1 commit
main
...
phase4-fou
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
228170d610 |
14 changed files with 2271 additions and 88 deletions
|
|
@ -12,10 +12,9 @@ import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// AppName is the default CLI application name.
|
||||||
// AppName is the CLI application name.
|
// Override with WithAppName before calling Main.
|
||||||
AppName = "core"
|
var AppName = "core"
|
||||||
)
|
|
||||||
|
|
||||||
// Build-time variables set via ldflags (SemVer 2.0.0):
|
// Build-time variables set via ldflags (SemVer 2.0.0):
|
||||||
//
|
//
|
||||||
|
|
@ -48,6 +47,15 @@ func SemVer() string {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithAppName sets the application name used in help text and shell completion.
|
||||||
|
// Call before Main for variant binaries (e.g. "lem", "devops").
|
||||||
|
//
|
||||||
|
// cli.WithAppName("lem")
|
||||||
|
// cli.Main()
|
||||||
|
func WithAppName(name string) {
|
||||||
|
AppName = name
|
||||||
|
}
|
||||||
|
|
||||||
// Main initialises and runs the CLI application.
|
// Main initialises and runs the CLI application.
|
||||||
// This is the main entry point for the CLI.
|
// This is the main entry point for the CLI.
|
||||||
// Exits with code 1 on error or panic.
|
// Exits with code 1 on error or panic.
|
||||||
|
|
@ -80,7 +88,7 @@ func Main() {
|
||||||
defer Shutdown()
|
defer Shutdown()
|
||||||
|
|
||||||
// Add completion command to the CLI's root
|
// Add completion command to the CLI's root
|
||||||
RootCmd().AddCommand(completionCmd)
|
RootCmd().AddCommand(newCompletionCmd())
|
||||||
|
|
||||||
if err := Execute(); err != nil {
|
if err := Execute(); err != nil {
|
||||||
code := 1
|
code := 1
|
||||||
|
|
@ -93,22 +101,23 @@ func Main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// completionCmd generates shell completion scripts.
|
// newCompletionCmd creates the shell completion command using the current AppName.
|
||||||
var completionCmd = &cobra.Command{
|
func newCompletionCmd() *cobra.Command {
|
||||||
Use: "completion [bash|zsh|fish|powershell]",
|
return &cobra.Command{
|
||||||
Short: "Generate shell completion script",
|
Use: "completion [bash|zsh|fish|powershell]",
|
||||||
Long: `Generate shell completion script for the specified shell.
|
Short: "Generate shell completion script",
|
||||||
|
Long: fmt.Sprintf(`Generate shell completion script for the specified shell.
|
||||||
|
|
||||||
To load completions:
|
To load completions:
|
||||||
|
|
||||||
Bash:
|
Bash:
|
||||||
$ source <(core completion bash)
|
$ source <(%s completion bash)
|
||||||
|
|
||||||
# To load completions for each session, execute once:
|
# To load completions for each session, execute once:
|
||||||
# Linux:
|
# Linux:
|
||||||
$ core completion bash > /etc/bash_completion.d/core
|
$ %s completion bash > /etc/bash_completion.d/%s
|
||||||
# macOS:
|
# macOS:
|
||||||
$ core completion bash > $(brew --prefix)/etc/bash_completion.d/core
|
$ %s completion bash > $(brew --prefix)/etc/bash_completion.d/%s
|
||||||
|
|
||||||
Zsh:
|
Zsh:
|
||||||
# If shell completion is not already enabled in your environment,
|
# If shell completion is not already enabled in your environment,
|
||||||
|
|
@ -116,36 +125,39 @@ Zsh:
|
||||||
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
|
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
|
||||||
|
|
||||||
# To load completions for each session, execute once:
|
# To load completions for each session, execute once:
|
||||||
$ core completion zsh > "${fpath[1]}/_core"
|
$ %s completion zsh > "${fpath[1]}/_%s"
|
||||||
|
|
||||||
# You will need to start a new shell for this setup to take effect.
|
# You will need to start a new shell for this setup to take effect.
|
||||||
|
|
||||||
Fish:
|
Fish:
|
||||||
$ core completion fish | source
|
$ %s completion fish | source
|
||||||
|
|
||||||
# To load completions for each session, execute once:
|
# To load completions for each session, execute once:
|
||||||
$ core completion fish > ~/.config/fish/completions/core.fish
|
$ %s completion fish > ~/.config/fish/completions/%s.fish
|
||||||
|
|
||||||
PowerShell:
|
PowerShell:
|
||||||
PS> core completion powershell | Out-String | Invoke-Expression
|
PS> %s completion powershell | Out-String | Invoke-Expression
|
||||||
|
|
||||||
# To load completions for every new session, run:
|
# To load completions for every new session, run:
|
||||||
PS> core completion powershell > core.ps1
|
PS> %s completion powershell > %s.ps1
|
||||||
# and source this file from your PowerShell profile.
|
# and source this file from your PowerShell profile.
|
||||||
`,
|
`, AppName, AppName, AppName, AppName, AppName,
|
||||||
DisableFlagsInUseLine: true,
|
AppName, AppName, AppName, AppName, AppName,
|
||||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
AppName, AppName, AppName),
|
||||||
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
|
DisableFlagsInUseLine: true,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||||
switch args[0] {
|
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
|
||||||
case "bash":
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
_ = cmd.Root().GenBashCompletion(os.Stdout)
|
switch args[0] {
|
||||||
case "zsh":
|
case "bash":
|
||||||
_ = cmd.Root().GenZshCompletion(os.Stdout)
|
_ = cmd.Root().GenBashCompletion(os.Stdout)
|
||||||
case "fish":
|
case "zsh":
|
||||||
_ = cmd.Root().GenFishCompletion(os.Stdout, true)
|
_ = cmd.Root().GenZshCompletion(os.Stdout)
|
||||||
case "powershell":
|
case "fish":
|
||||||
_ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
_ = cmd.Root().GenFishCompletion(os.Stdout, true)
|
||||||
}
|
case "powershell":
|
||||||
},
|
_ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,23 @@ func NewRun(use, short, long string, run func(cmd *Command, args []string)) *Com
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewPassthrough creates a command that passes all arguments (including flags)
|
||||||
|
// to the given function. Used for commands that do their own flag parsing
|
||||||
|
// (e.g. incremental migration from flag.FlagSet to cobra).
|
||||||
|
//
|
||||||
|
// cmd := cli.NewPassthrough("train", "Train a model", func(args []string) {
|
||||||
|
// // args includes all flags: ["--model", "gemma-3-1b", "--epochs", "10"]
|
||||||
|
// fs := flag.NewFlagSet("train", flag.ExitOnError)
|
||||||
|
// // ...
|
||||||
|
// })
|
||||||
|
func NewPassthrough(use, short string, fn func(args []string)) *Command {
|
||||||
|
cmd := NewRun(use, short, "", func(_ *Command, args []string) {
|
||||||
|
fn(args)
|
||||||
|
})
|
||||||
|
cmd.DisableFlagParsing = true
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Flag Helpers
|
// Flag Helpers
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
185
pkg/cli/commands_test.go
Normal file
185
pkg/cli/commands_test.go
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resetGlobals clears the CLI singleton and command registry for test isolation.
|
||||||
|
func resetGlobals(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
// Restore clean state after each test.
|
||||||
|
registeredCommandsMu.Lock()
|
||||||
|
registeredCommands = nil
|
||||||
|
commandsAttached = false
|
||||||
|
registeredCommandsMu.Unlock()
|
||||||
|
if instance != nil {
|
||||||
|
Shutdown()
|
||||||
|
}
|
||||||
|
instance = nil
|
||||||
|
once = sync.Once{}
|
||||||
|
})
|
||||||
|
|
||||||
|
registeredCommandsMu.Lock()
|
||||||
|
registeredCommands = nil
|
||||||
|
commandsAttached = false
|
||||||
|
registeredCommandsMu.Unlock()
|
||||||
|
if instance != nil {
|
||||||
|
Shutdown()
|
||||||
|
}
|
||||||
|
instance = nil
|
||||||
|
once = sync.Once{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterCommands_Good tests the happy path for command registration.
|
||||||
|
func TestRegisterCommands_Good(t *testing.T) {
|
||||||
|
t.Run("registers on startup", func(t *testing.T) {
|
||||||
|
resetGlobals(t)
|
||||||
|
|
||||||
|
RegisterCommands(func(root *cobra.Command) {
|
||||||
|
root.AddCommand(&cobra.Command{Use: "hello", Short: "Say hello"})
|
||||||
|
})
|
||||||
|
|
||||||
|
err := Init(Options{AppName: "test"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The "hello" command should be on the root.
|
||||||
|
cmd, _, err := RootCmd().Find([]string{"hello"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "hello", cmd.Use)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multiple groups compose", func(t *testing.T) {
|
||||||
|
resetGlobals(t)
|
||||||
|
|
||||||
|
RegisterCommands(func(root *cobra.Command) {
|
||||||
|
root.AddCommand(&cobra.Command{Use: "alpha", Short: "Alpha"})
|
||||||
|
})
|
||||||
|
RegisterCommands(func(root *cobra.Command) {
|
||||||
|
root.AddCommand(&cobra.Command{Use: "beta", Short: "Beta"})
|
||||||
|
})
|
||||||
|
|
||||||
|
err := Init(Options{AppName: "test"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, name := range []string{"alpha", "beta"} {
|
||||||
|
cmd, _, err := RootCmd().Find([]string{name})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, name, cmd.Use)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("group with subcommands", func(t *testing.T) {
|
||||||
|
resetGlobals(t)
|
||||||
|
|
||||||
|
RegisterCommands(func(root *cobra.Command) {
|
||||||
|
grp := &cobra.Command{Use: "ml", Short: "ML commands"}
|
||||||
|
grp.AddCommand(&cobra.Command{Use: "train", Short: "Train a model"})
|
||||||
|
grp.AddCommand(&cobra.Command{Use: "serve", Short: "Serve a model"})
|
||||||
|
root.AddCommand(grp)
|
||||||
|
})
|
||||||
|
|
||||||
|
err := Init(Options{AppName: "test"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cmd, _, err := RootCmd().Find([]string{"ml", "train"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "train", cmd.Use)
|
||||||
|
|
||||||
|
cmd, _, err = RootCmd().Find([]string{"ml", "serve"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "serve", cmd.Use)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("executes registered command", func(t *testing.T) {
|
||||||
|
resetGlobals(t)
|
||||||
|
|
||||||
|
executed := false
|
||||||
|
RegisterCommands(func(root *cobra.Command) {
|
||||||
|
root.AddCommand(&cobra.Command{
|
||||||
|
Use: "ping",
|
||||||
|
Short: "Ping",
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
executed = true
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
err := Init(Options{AppName: "test"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
RootCmd().SetArgs([]string{"ping"})
|
||||||
|
err = Execute()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, executed, "registered command should have been executed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterCommands_Bad tests expected error conditions.
|
||||||
|
func TestRegisterCommands_Bad(t *testing.T) {
|
||||||
|
t.Run("late registration attaches immediately", func(t *testing.T) {
|
||||||
|
resetGlobals(t)
|
||||||
|
|
||||||
|
err := Init(Options{AppName: "test"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Register after Init — should attach immediately.
|
||||||
|
RegisterCommands(func(root *cobra.Command) {
|
||||||
|
root.AddCommand(&cobra.Command{Use: "late", Short: "Late arrival"})
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd, _, err := RootCmd().Find([]string{"late"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "late", cmd.Use)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWithAppName_Good tests the app name override.
|
||||||
|
func TestWithAppName_Good(t *testing.T) {
|
||||||
|
t.Run("overrides root command use", func(t *testing.T) {
|
||||||
|
resetGlobals(t)
|
||||||
|
|
||||||
|
WithAppName("lem")
|
||||||
|
defer WithAppName("core") // restore
|
||||||
|
|
||||||
|
err := Init(Options{AppName: AppName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "lem", RootCmd().Use)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("default is core", func(t *testing.T) {
|
||||||
|
resetGlobals(t)
|
||||||
|
|
||||||
|
err := Init(Options{AppName: AppName})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "core", RootCmd().Use)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewPassthrough_Good tests the passthrough command builder.
|
||||||
|
func TestNewPassthrough_Good(t *testing.T) {
|
||||||
|
t.Run("passes all args including flags", func(t *testing.T) {
|
||||||
|
var received []string
|
||||||
|
cmd := NewPassthrough("train", "Train", func(args []string) {
|
||||||
|
received = args
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd.SetArgs([]string{"--model", "gemma", "--epochs", "10"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"--model", "gemma", "--epochs", "10"}, received)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("flag parsing is disabled", func(t *testing.T) {
|
||||||
|
cmd := NewPassthrough("run", "Run", func(_ []string) {})
|
||||||
|
assert.True(t, cmd.DisableFlagParsing)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,11 @@ package cli
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/io"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
@ -27,39 +28,27 @@ func TestDetectMode(t *testing.T) {
|
||||||
|
|
||||||
func TestPIDFile(t *testing.T) {
|
func TestPIDFile(t *testing.T) {
|
||||||
t.Run("acquire and release", func(t *testing.T) {
|
t.Run("acquire and release", func(t *testing.T) {
|
||||||
m := io.NewMockMedium()
|
pidPath := filepath.Join(t.TempDir(), "test.pid")
|
||||||
pidPath := "/tmp/test.pid"
|
|
||||||
|
|
||||||
pid := NewPIDFile(m, pidPath)
|
pid := NewPIDFile(pidPath)
|
||||||
|
|
||||||
// Acquire should succeed
|
|
||||||
err := pid.Acquire()
|
err := pid.Acquire()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// File should exist with our PID
|
|
||||||
data, err := m.Read(pidPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotEmpty(t, data)
|
|
||||||
|
|
||||||
// Release should remove file
|
|
||||||
err = pid.Release()
|
err = pid.Release()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.False(t, m.Exists(pidPath))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("stale pid file", func(t *testing.T) {
|
t.Run("stale pid file", func(t *testing.T) {
|
||||||
m := io.NewMockMedium()
|
pidPath := filepath.Join(t.TempDir(), "stale.pid")
|
||||||
pidPath := "/tmp/stale.pid"
|
|
||||||
|
|
||||||
// Write a stale PID (non-existent process)
|
// Write a stale PID (non-existent process).
|
||||||
err := m.Write(pidPath, "999999999")
|
require.NoError(t, os.WriteFile(pidPath, []byte("999999999"), 0644))
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
pid := NewPIDFile(m, pidPath)
|
pid := NewPIDFile(pidPath)
|
||||||
|
|
||||||
// Should acquire successfully (stale PID removed)
|
// Should acquire successfully (stale PID removed).
|
||||||
err = pid.Acquire()
|
err := pid.Acquire()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
err = pid.Release()
|
err = pid.Release()
|
||||||
|
|
@ -67,23 +56,19 @@ func TestPIDFile(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("creates parent directory", func(t *testing.T) {
|
t.Run("creates parent directory", func(t *testing.T) {
|
||||||
m := io.NewMockMedium()
|
pidPath := filepath.Join(t.TempDir(), "subdir", "nested", "test.pid")
|
||||||
pidPath := "/tmp/subdir/nested/test.pid"
|
|
||||||
|
|
||||||
pid := NewPIDFile(m, pidPath)
|
pid := NewPIDFile(pidPath)
|
||||||
|
|
||||||
err := pid.Acquire()
|
err := pid.Acquire()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.True(t, m.Exists(pidPath))
|
|
||||||
|
|
||||||
err = pid.Release()
|
err = pid.Release()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("path getter", func(t *testing.T) {
|
t.Run("path getter", func(t *testing.T) {
|
||||||
m := io.NewMockMedium()
|
pid := NewPIDFile("/tmp/test.pid")
|
||||||
pid := NewPIDFile(m, "/tmp/test.pid")
|
|
||||||
assert.Equal(t, "/tmp/test.pid", pid.Path())
|
assert.Equal(t, "/tmp/test.pid", pid.Path())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -155,11 +140,9 @@ func TestHealthServer(t *testing.T) {
|
||||||
|
|
||||||
func TestDaemon(t *testing.T) {
|
func TestDaemon(t *testing.T) {
|
||||||
t.Run("start and stop", func(t *testing.T) {
|
t.Run("start and stop", func(t *testing.T) {
|
||||||
m := io.NewMockMedium()
|
pidPath := filepath.Join(t.TempDir(), "test.pid")
|
||||||
pidPath := "/tmp/test.pid"
|
|
||||||
|
|
||||||
d := NewDaemon(DaemonOptions{
|
d := NewDaemon(DaemonOptions{
|
||||||
Medium: m,
|
|
||||||
PIDFile: pidPath,
|
PIDFile: pidPath,
|
||||||
HealthAddr: "127.0.0.1:0",
|
HealthAddr: "127.0.0.1:0",
|
||||||
ShutdownTimeout: 5 * time.Second,
|
ShutdownTimeout: 5 * time.Second,
|
||||||
|
|
@ -180,9 +163,6 @@ func TestDaemon(t *testing.T) {
|
||||||
// Stop should succeed
|
// Stop should succeed
|
||||||
err = d.Stop()
|
err = d.Stop()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// PID file should be removed
|
|
||||||
assert.False(t, m.Exists(pidPath))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("double start fails", func(t *testing.T) {
|
t.Run("double start fails", func(t *testing.T) {
|
||||||
|
|
|
||||||
358
pkg/cli/frame.go
Normal file
358
pkg/cli/frame.go
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model is the interface for components that slot into Frame regions.
|
||||||
|
// View receives the allocated width and height and returns rendered text.
|
||||||
|
type Model interface {
|
||||||
|
View(width, height int) string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelFunc is a convenience adapter for using a function as a Model.
|
||||||
|
type ModelFunc func(width, height int) string
|
||||||
|
|
||||||
|
// View implements Model.
|
||||||
|
func (f ModelFunc) View(width, height int) string { return f(width, height) }
|
||||||
|
|
||||||
|
// Frame is a live compositional AppShell for TUI.
|
||||||
|
// Uses HLCRF variant strings for region layout — same as the static Layout system,
|
||||||
|
// but with live-updating Model components instead of static strings.
|
||||||
|
//
|
||||||
|
// frame := cli.NewFrame("HCF")
|
||||||
|
// frame.Header(cli.StatusLine("core dev", "18 repos", "main"))
|
||||||
|
// frame.Content(myTableModel)
|
||||||
|
// frame.Footer(cli.KeyHints("↑/↓ navigate", "enter select", "q quit"))
|
||||||
|
// frame.Run()
|
||||||
|
type Frame struct {
|
||||||
|
variant string
|
||||||
|
layout *Composite
|
||||||
|
models map[Region]Model
|
||||||
|
history []Model // content region stack for Navigate/Back
|
||||||
|
out io.Writer
|
||||||
|
done chan struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFrame creates a new Frame with the given HLCRF variant string.
|
||||||
|
//
|
||||||
|
// frame := cli.NewFrame("HCF") // header, content, footer
|
||||||
|
// frame := cli.NewFrame("H[LC]F") // header, [left + content], footer
|
||||||
|
func NewFrame(variant string) *Frame {
|
||||||
|
return &Frame{
|
||||||
|
variant: variant,
|
||||||
|
layout: Layout(variant),
|
||||||
|
models: make(map[Region]Model),
|
||||||
|
out: os.Stdout,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header sets the Header region model.
|
||||||
|
func (f *Frame) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f }
|
||||||
|
|
||||||
|
// Left sets the Left sidebar region model.
|
||||||
|
func (f *Frame) Left(m Model) *Frame { f.setModel(RegionLeft, m); return f }
|
||||||
|
|
||||||
|
// Content sets the Content region model.
|
||||||
|
func (f *Frame) Content(m Model) *Frame { f.setModel(RegionContent, m); return f }
|
||||||
|
|
||||||
|
// Right sets the Right sidebar region model.
|
||||||
|
func (f *Frame) Right(m Model) *Frame { f.setModel(RegionRight, m); return f }
|
||||||
|
|
||||||
|
// Footer sets the Footer region model.
|
||||||
|
func (f *Frame) Footer(m Model) *Frame { f.setModel(RegionFooter, m); return f }
|
||||||
|
|
||||||
|
func (f *Frame) setModel(r Region, m Model) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.models[r] = m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate replaces the Content region with a new model, pushing the current one
|
||||||
|
// onto the history stack for Back().
|
||||||
|
func (f *Frame) Navigate(m Model) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if current, ok := f.models[RegionContent]; ok {
|
||||||
|
f.history = append(f.history, current)
|
||||||
|
}
|
||||||
|
f.models[RegionContent] = m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back pops the content history stack, restoring the previous Content model.
|
||||||
|
// Returns false if the history is empty.
|
||||||
|
func (f *Frame) Back() bool {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if len(f.history) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
f.models[RegionContent] = f.history[len(f.history)-1]
|
||||||
|
f.history = f.history[:len(f.history)-1]
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop signals the Frame to exit its Run loop.
|
||||||
|
func (f *Frame) Stop() {
|
||||||
|
select {
|
||||||
|
case <-f.done:
|
||||||
|
default:
|
||||||
|
close(f.done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run renders the frame and blocks. In TTY mode, it live-refreshes at ~12fps.
|
||||||
|
// In non-TTY mode, it renders once and returns immediately.
|
||||||
|
func (f *Frame) Run() {
|
||||||
|
if !f.isTTY() {
|
||||||
|
fmt.Fprint(f.out, f.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.runLive()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunFor runs the frame for a fixed duration, then stops.
|
||||||
|
// Useful for dashboards that refresh periodically.
|
||||||
|
func (f *Frame) RunFor(d time.Duration) {
|
||||||
|
go func() {
|
||||||
|
timer := time.NewTimer(d)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
f.Stop()
|
||||||
|
case <-f.done:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
f.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// String renders the frame as a static string (no ANSI, no live updates).
|
||||||
|
// This is the non-TTY fallback path.
|
||||||
|
func (f *Frame) String() string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
w, h := f.termSize()
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
|
||||||
|
for _, r := range order {
|
||||||
|
if _, exists := f.layout.regions[r]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m, ok := f.models[r]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rw, rh := f.regionSize(r, w, h)
|
||||||
|
view := m.View(rw, rh)
|
||||||
|
if view != "" {
|
||||||
|
sb.WriteString(view)
|
||||||
|
if !strings.HasSuffix(view, "\n") {
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Frame) isTTY() bool {
|
||||||
|
if file, ok := f.out.(*os.File); ok {
|
||||||
|
return term.IsTerminal(int(file.Fd()))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Frame) termSize() (int, int) {
|
||||||
|
if file, ok := f.out.(*os.File); ok {
|
||||||
|
w, h, err := term.GetSize(int(file.Fd()))
|
||||||
|
if err == nil {
|
||||||
|
return w, h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 80, 24 // sensible default
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Frame) regionSize(r Region, totalW, totalH int) (int, int) {
|
||||||
|
// Simple allocation: Header/Footer get 1 line, sidebars get 1/4 width,
|
||||||
|
// Content gets the rest.
|
||||||
|
switch r {
|
||||||
|
case RegionHeader, RegionFooter:
|
||||||
|
return totalW, 1
|
||||||
|
case RegionLeft, RegionRight:
|
||||||
|
return totalW / 4, totalH - 2 // minus header + footer
|
||||||
|
case RegionContent:
|
||||||
|
sideW := 0
|
||||||
|
if _, ok := f.models[RegionLeft]; ok {
|
||||||
|
sideW += totalW / 4
|
||||||
|
}
|
||||||
|
if _, ok := f.models[RegionRight]; ok {
|
||||||
|
sideW += totalW / 4
|
||||||
|
}
|
||||||
|
return totalW - sideW, totalH - 2
|
||||||
|
}
|
||||||
|
return totalW, totalH
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Frame) runLive() {
|
||||||
|
// Enter alt-screen.
|
||||||
|
fmt.Fprint(f.out, "\033[?1049h")
|
||||||
|
// Hide cursor.
|
||||||
|
fmt.Fprint(f.out, "\033[?25l")
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
// Show cursor.
|
||||||
|
fmt.Fprint(f.out, "\033[?25h")
|
||||||
|
// Leave alt-screen.
|
||||||
|
fmt.Fprint(f.out, "\033[?1049l")
|
||||||
|
}()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(80 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
f.renderFrame()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-f.done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Frame) renderFrame() {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
w, h := f.termSize()
|
||||||
|
|
||||||
|
// Move to top-left.
|
||||||
|
fmt.Fprint(f.out, "\033[H")
|
||||||
|
// Clear screen.
|
||||||
|
fmt.Fprint(f.out, "\033[2J")
|
||||||
|
|
||||||
|
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
|
||||||
|
for _, r := range order {
|
||||||
|
if _, exists := f.layout.regions[r]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m, ok := f.models[r]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rw, rh := f.regionSize(r, w, h)
|
||||||
|
view := m.View(rw, rh)
|
||||||
|
if view != "" {
|
||||||
|
fmt.Fprint(f.out, view)
|
||||||
|
if !strings.HasSuffix(view, "\n") {
|
||||||
|
fmt.Fprintln(f.out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Built-in Region Components
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// statusLineModel renders a "title key:value key:value" bar.
|
||||||
|
type statusLineModel struct {
|
||||||
|
title string
|
||||||
|
pairs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusLine creates a header/footer bar with a title and key:value pairs.
|
||||||
|
//
|
||||||
|
// frame.Header(cli.StatusLine("core dev", "18 repos", "main"))
|
||||||
|
func StatusLine(title string, pairs ...string) Model {
|
||||||
|
return &statusLineModel{title: title, pairs: pairs}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *statusLineModel) View(width, _ int) string {
|
||||||
|
parts := []string{BoldStyle.Render(s.title)}
|
||||||
|
for _, p := range s.pairs {
|
||||||
|
parts = append(parts, DimStyle.Render(p))
|
||||||
|
}
|
||||||
|
line := strings.Join(parts, " ")
|
||||||
|
if width > 0 {
|
||||||
|
line = Truncate(line, width)
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyHintsModel renders keyboard shortcut hints.
|
||||||
|
type keyHintsModel struct {
|
||||||
|
hints []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyHints creates a footer showing keyboard shortcuts.
|
||||||
|
//
|
||||||
|
// frame.Footer(cli.KeyHints("↑/↓ navigate", "enter select", "q quit"))
|
||||||
|
func KeyHints(hints ...string) Model {
|
||||||
|
return &keyHintsModel{hints: hints}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *keyHintsModel) View(width, _ int) string {
|
||||||
|
parts := make([]string, len(k.hints))
|
||||||
|
for i, h := range k.hints {
|
||||||
|
parts[i] = DimStyle.Render(h)
|
||||||
|
}
|
||||||
|
line := strings.Join(parts, " ")
|
||||||
|
if width > 0 {
|
||||||
|
line = Truncate(line, width)
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
// breadcrumbModel renders a navigation path.
|
||||||
|
type breadcrumbModel struct {
|
||||||
|
parts []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breadcrumb creates a navigation breadcrumb bar.
|
||||||
|
//
|
||||||
|
// frame.Header(cli.Breadcrumb("core", "dev", "health"))
|
||||||
|
func Breadcrumb(parts ...string) Model {
|
||||||
|
return &breadcrumbModel{parts: parts}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *breadcrumbModel) View(width, _ int) string {
|
||||||
|
styled := make([]string, len(b.parts))
|
||||||
|
for i, p := range b.parts {
|
||||||
|
if i == len(b.parts)-1 {
|
||||||
|
styled[i] = BoldStyle.Render(p)
|
||||||
|
} else {
|
||||||
|
styled[i] = DimStyle.Render(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line := strings.Join(styled, DimStyle.Render(" > "))
|
||||||
|
if width > 0 {
|
||||||
|
line = Truncate(line, width)
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
// staticModel wraps a plain string as a Model.
|
||||||
|
type staticModel struct {
|
||||||
|
text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaticModel wraps a static string as a Model, for use in Frame regions.
|
||||||
|
func StaticModel(text string) Model {
|
||||||
|
return &staticModel{text: text}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *staticModel) View(_, _ int) string {
|
||||||
|
return s.text
|
||||||
|
}
|
||||||
207
pkg/cli/frame_test.go
Normal file
207
pkg/cli/frame_test.go
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFrame_Good(t *testing.T) {
|
||||||
|
t.Run("static render HCF", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
f := NewFrame("HCF")
|
||||||
|
f.out = &bytes.Buffer{}
|
||||||
|
f.Header(StaticModel("header"))
|
||||||
|
f.Content(StaticModel("content"))
|
||||||
|
f.Footer(StaticModel("footer"))
|
||||||
|
|
||||||
|
out := f.String()
|
||||||
|
assert.Contains(t, out, "header")
|
||||||
|
assert.Contains(t, out, "content")
|
||||||
|
assert.Contains(t, out, "footer")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("region order preserved", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
f := NewFrame("HCF")
|
||||||
|
f.out = &bytes.Buffer{}
|
||||||
|
f.Header(StaticModel("AAA"))
|
||||||
|
f.Content(StaticModel("BBB"))
|
||||||
|
f.Footer(StaticModel("CCC"))
|
||||||
|
|
||||||
|
out := f.String()
|
||||||
|
posA := indexOf(out, "AAA")
|
||||||
|
posB := indexOf(out, "BBB")
|
||||||
|
posC := indexOf(out, "CCC")
|
||||||
|
assert.Less(t, posA, posB, "header before content")
|
||||||
|
assert.Less(t, posB, posC, "content before footer")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("navigate and back", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
f := NewFrame("HCF")
|
||||||
|
f.out = &bytes.Buffer{}
|
||||||
|
f.Header(StaticModel("nav"))
|
||||||
|
f.Content(StaticModel("page-1"))
|
||||||
|
f.Footer(StaticModel("hints"))
|
||||||
|
|
||||||
|
assert.Contains(t, f.String(), "page-1")
|
||||||
|
|
||||||
|
// Navigate to page 2
|
||||||
|
f.Navigate(StaticModel("page-2"))
|
||||||
|
assert.Contains(t, f.String(), "page-2")
|
||||||
|
assert.NotContains(t, f.String(), "page-1")
|
||||||
|
|
||||||
|
// Navigate to page 3
|
||||||
|
f.Navigate(StaticModel("page-3"))
|
||||||
|
assert.Contains(t, f.String(), "page-3")
|
||||||
|
|
||||||
|
// Back to page 2
|
||||||
|
ok := f.Back()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Contains(t, f.String(), "page-2")
|
||||||
|
|
||||||
|
// Back to page 1
|
||||||
|
ok = f.Back()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Contains(t, f.String(), "page-1")
|
||||||
|
|
||||||
|
// No more history
|
||||||
|
ok = f.Back()
|
||||||
|
assert.False(t, ok)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty regions skipped", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
f := NewFrame("HCF")
|
||||||
|
f.out = &bytes.Buffer{}
|
||||||
|
f.Content(StaticModel("only content"))
|
||||||
|
|
||||||
|
out := f.String()
|
||||||
|
assert.Equal(t, "only content\n", out)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-TTY run renders once", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
f := NewFrame("HCF")
|
||||||
|
f.out = &buf
|
||||||
|
f.Header(StaticModel("h"))
|
||||||
|
f.Content(StaticModel("c"))
|
||||||
|
f.Footer(StaticModel("f"))
|
||||||
|
|
||||||
|
f.Run() // non-TTY, should return immediately
|
||||||
|
assert.Contains(t, buf.String(), "h")
|
||||||
|
assert.Contains(t, buf.String(), "c")
|
||||||
|
assert.Contains(t, buf.String(), "f")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ModelFunc adapter", func(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
m := ModelFunc(func(w, h int) string {
|
||||||
|
called = true
|
||||||
|
return "dynamic"
|
||||||
|
})
|
||||||
|
|
||||||
|
out := m.View(80, 24)
|
||||||
|
assert.True(t, called)
|
||||||
|
assert.Equal(t, "dynamic", out)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RunFor exits after duration", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
f := NewFrame("C")
|
||||||
|
f.out = &buf // non-TTY → RunFor renders once and returns
|
||||||
|
f.Content(StaticModel("timed"))
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
f.RunFor(50 * time.Millisecond)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
assert.Less(t, elapsed, 200*time.Millisecond)
|
||||||
|
assert.Contains(t, buf.String(), "timed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrame_Bad(t *testing.T) {
|
||||||
|
t.Run("empty frame", func(t *testing.T) {
|
||||||
|
f := NewFrame("HCF")
|
||||||
|
f.out = &bytes.Buffer{}
|
||||||
|
assert.Equal(t, "", f.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("back on empty history", func(t *testing.T) {
|
||||||
|
f := NewFrame("C")
|
||||||
|
f.out = &bytes.Buffer{}
|
||||||
|
f.Content(StaticModel("x"))
|
||||||
|
assert.False(t, f.Back())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid variant degrades gracefully", func(t *testing.T) {
|
||||||
|
f := NewFrame("XYZ")
|
||||||
|
f.out = &bytes.Buffer{}
|
||||||
|
// No valid regions, so nothing renders
|
||||||
|
assert.Equal(t, "", f.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusLine_Good(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
m := StatusLine("core dev", "18 repos", "main")
|
||||||
|
out := m.View(80, 1)
|
||||||
|
assert.Contains(t, out, "core dev")
|
||||||
|
assert.Contains(t, out, "18 repos")
|
||||||
|
assert.Contains(t, out, "main")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyHints_Good(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
m := KeyHints("↑/↓ navigate", "q quit")
|
||||||
|
out := m.View(80, 1)
|
||||||
|
assert.Contains(t, out, "navigate")
|
||||||
|
assert.Contains(t, out, "quit")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBreadcrumb_Good(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
m := Breadcrumb("core", "dev", "health")
|
||||||
|
out := m.View(80, 1)
|
||||||
|
assert.Contains(t, out, "core")
|
||||||
|
assert.Contains(t, out, "dev")
|
||||||
|
assert.Contains(t, out, "health")
|
||||||
|
assert.Contains(t, out, ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaticModel_Good(t *testing.T) {
|
||||||
|
m := StaticModel("hello")
|
||||||
|
assert.Equal(t, "hello", m.View(80, 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexOf returns the position of substr in s, or -1 if not found.
|
||||||
|
func indexOf(s, substr string) int {
|
||||||
|
for i := range len(s) - len(substr) + 1 {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
140
pkg/cli/stream.go
Normal file
140
pkg/cli/stream.go
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StreamOption configures a Stream.
|
||||||
|
type StreamOption func(*Stream)
|
||||||
|
|
||||||
|
// WithWordWrap sets the word-wrap column width.
|
||||||
|
func WithWordWrap(cols int) StreamOption {
|
||||||
|
return func(s *Stream) { s.wrap = cols }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithStreamOutput sets the output writer (default: os.Stdout).
|
||||||
|
func WithStreamOutput(w io.Writer) StreamOption {
|
||||||
|
return func(s *Stream) { s.out = w }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream renders growing text as tokens arrive, with optional word-wrap.
|
||||||
|
// Safe for concurrent writes from a single producer goroutine.
|
||||||
|
//
|
||||||
|
// stream := cli.NewStream(cli.WithWordWrap(80))
|
||||||
|
// go func() {
|
||||||
|
// for token := range tokens {
|
||||||
|
// stream.Write(token)
|
||||||
|
// }
|
||||||
|
// stream.Done()
|
||||||
|
// }()
|
||||||
|
// stream.Wait()
|
||||||
|
type Stream struct {
|
||||||
|
out io.Writer
|
||||||
|
wrap int
|
||||||
|
col int // current column position (visible characters)
|
||||||
|
done chan struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStream creates a streaming text renderer.
|
||||||
|
func NewStream(opts ...StreamOption) *Stream {
|
||||||
|
s := &Stream{
|
||||||
|
out: os.Stdout,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(s)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write appends text to the stream. Handles word-wrap if configured.
|
||||||
|
func (s *Stream) Write(text string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.wrap <= 0 {
|
||||||
|
fmt.Fprint(s.out, text)
|
||||||
|
// Track column across newlines for Done() trailing-newline logic.
|
||||||
|
if idx := strings.LastIndex(text, "\n"); idx >= 0 {
|
||||||
|
s.col = utf8.RuneCountInString(text[idx+1:])
|
||||||
|
} else {
|
||||||
|
s.col += utf8.RuneCountInString(text)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range text {
|
||||||
|
if r == '\n' {
|
||||||
|
fmt.Fprintln(s.out)
|
||||||
|
s.col = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.col >= s.wrap {
|
||||||
|
fmt.Fprintln(s.out)
|
||||||
|
s.col = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprint(s.out, string(r))
|
||||||
|
s.col++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFrom reads from r and streams all content until EOF.
|
||||||
|
func (s *Stream) WriteFrom(r io.Reader) error {
|
||||||
|
buf := make([]byte, 256)
|
||||||
|
for {
|
||||||
|
n, err := r.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
s.Write(string(buf[:n]))
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done signals that no more text will arrive.
|
||||||
|
func (s *Stream) Done() {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.col > 0 {
|
||||||
|
fmt.Fprintln(s.out) // ensure trailing newline
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
close(s.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait blocks until Done is called.
|
||||||
|
func (s *Stream) Wait() {
|
||||||
|
<-s.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content returns the current column position (for testing).
|
||||||
|
func (s *Stream) Column() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.col
|
||||||
|
}
|
||||||
|
|
||||||
|
// Captured returns the stream output as a string when using a bytes.Buffer.
|
||||||
|
// Panics if the output writer is not a *strings.Builder or fmt.Stringer.
|
||||||
|
func (s *Stream) Captured() string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if sb, ok := s.out.(*strings.Builder); ok {
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
if st, ok := s.out.(fmt.Stringer); ok {
|
||||||
|
return st.String()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
159
pkg/cli/stream_test.go
Normal file
159
pkg/cli/stream_test.go
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStream_Good(t *testing.T) {
|
||||||
|
t.Run("basic write", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
s := NewStream(WithStreamOutput(&buf))
|
||||||
|
|
||||||
|
s.Write("hello ")
|
||||||
|
s.Write("world")
|
||||||
|
s.Done()
|
||||||
|
s.Wait()
|
||||||
|
|
||||||
|
assert.Equal(t, "hello world\n", buf.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("write with newlines", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
s := NewStream(WithStreamOutput(&buf))
|
||||||
|
|
||||||
|
s.Write("line1\nline2\n")
|
||||||
|
s.Done()
|
||||||
|
s.Wait()
|
||||||
|
|
||||||
|
assert.Equal(t, "line1\nline2\n", buf.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("word wrap", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
s := NewStream(WithWordWrap(10), WithStreamOutput(&buf))
|
||||||
|
|
||||||
|
s.Write("1234567890ABCDE")
|
||||||
|
s.Done()
|
||||||
|
s.Wait()
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||||
|
assert.Equal(t, 2, len(lines))
|
||||||
|
assert.Equal(t, "1234567890", lines[0])
|
||||||
|
assert.Equal(t, "ABCDE", lines[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("word wrap preserves explicit newlines", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
s := NewStream(WithWordWrap(20), WithStreamOutput(&buf))
|
||||||
|
|
||||||
|
s.Write("short\nanother")
|
||||||
|
s.Done()
|
||||||
|
s.Wait()
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||||
|
assert.Equal(t, 2, len(lines))
|
||||||
|
assert.Equal(t, "short", lines[0])
|
||||||
|
assert.Equal(t, "another", lines[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("word wrap resets column on newline", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
s := NewStream(WithWordWrap(5), WithStreamOutput(&buf))
|
||||||
|
|
||||||
|
s.Write("12345\n67890ABCDE")
|
||||||
|
s.Done()
|
||||||
|
s.Wait()
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||||
|
assert.Equal(t, 3, len(lines))
|
||||||
|
assert.Equal(t, "12345", lines[0])
|
||||||
|
assert.Equal(t, "67890", lines[1])
|
||||||
|
assert.Equal(t, "ABCDE", lines[2])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no wrap when disabled", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
s := NewStream(WithStreamOutput(&buf))
|
||||||
|
|
||||||
|
s.Write(strings.Repeat("x", 200))
|
||||||
|
s.Done()
|
||||||
|
s.Wait()
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||||
|
assert.Equal(t, 1, len(lines))
|
||||||
|
assert.Equal(t, 200, len(lines[0]))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("column tracking", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
s := NewStream(WithStreamOutput(&buf))
|
||||||
|
|
||||||
|
s.Write("hello")
|
||||||
|
assert.Equal(t, 5, s.Column())
|
||||||
|
|
||||||
|
s.Write(" world")
|
||||||
|
assert.Equal(t, 11, s.Column())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("WriteFrom io.Reader", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
s := NewStream(WithStreamOutput(&buf))
|
||||||
|
|
||||||
|
r := strings.NewReader("streamed content")
|
||||||
|
err := s.WriteFrom(r)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
s.Done()
|
||||||
|
s.Wait()
|
||||||
|
|
||||||
|
assert.Equal(t, "streamed content\n", buf.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("channel pattern", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
s := NewStream(WithStreamOutput(&buf))
|
||||||
|
|
||||||
|
tokens := make(chan string, 3)
|
||||||
|
tokens <- "one "
|
||||||
|
tokens <- "two "
|
||||||
|
tokens <- "three"
|
||||||
|
close(tokens)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for tok := range tokens {
|
||||||
|
s.Write(tok)
|
||||||
|
}
|
||||||
|
s.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
s.Wait()
|
||||||
|
assert.Equal(t, "one two three\n", buf.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Done adds trailing newline only if needed", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
s := NewStream(WithStreamOutput(&buf))
|
||||||
|
|
||||||
|
s.Write("text\n") // ends with newline, col=0
|
||||||
|
s.Done()
|
||||||
|
s.Wait()
|
||||||
|
|
||||||
|
assert.Equal(t, "text\n", buf.String()) // no double newline
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStream_Bad(t *testing.T) {
|
||||||
|
t.Run("empty stream", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
s := NewStream(WithStreamOutput(&buf))
|
||||||
|
|
||||||
|
s.Done()
|
||||||
|
s.Wait()
|
||||||
|
|
||||||
|
assert.Equal(t, "", buf.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -105,12 +105,66 @@ func FormatAge(t time.Time) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Border Styles
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// BorderStyle selects the box-drawing character set for table borders.
|
||||||
|
type BorderStyle int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// BorderNone disables borders (default).
|
||||||
|
BorderNone BorderStyle = iota
|
||||||
|
// BorderNormal uses standard box-drawing: ┌─┬┐ │ ├─┼┤ └─┴┘
|
||||||
|
BorderNormal
|
||||||
|
// BorderRounded uses rounded corners: ╭─┬╮ │ ├─┼┤ ╰─┴╯
|
||||||
|
BorderRounded
|
||||||
|
// BorderHeavy uses heavy box-drawing: ┏━┳┓ ┃ ┣━╋┫ ┗━┻┛
|
||||||
|
BorderHeavy
|
||||||
|
// BorderDouble uses double-line box-drawing: ╔═╦╗ ║ ╠═╬╣ ╚═╩╝
|
||||||
|
BorderDouble
|
||||||
|
)
|
||||||
|
|
||||||
|
type borderSet struct {
|
||||||
|
tl, tr, bl, br string // corners
|
||||||
|
h, v string // horizontal, vertical
|
||||||
|
tt, bt, lt, rt string // tees (top, bottom, left, right)
|
||||||
|
x string // cross
|
||||||
|
}
|
||||||
|
|
||||||
|
var borderSets = map[BorderStyle]borderSet{
|
||||||
|
BorderNormal: {"┌", "┐", "└", "┘", "─", "│", "┬", "┴", "├", "┤", "┼"},
|
||||||
|
BorderRounded: {"╭", "╮", "╰", "╯", "─", "│", "┬", "┴", "├", "┤", "┼"},
|
||||||
|
BorderHeavy: {"┏", "┓", "┗", "┛", "━", "┃", "┳", "┻", "┣", "┫", "╋"},
|
||||||
|
BorderDouble: {"╔", "╗", "╚", "╝", "═", "║", "╦", "╩", "╠", "╣", "╬"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// CellStyleFn returns a style based on the cell's raw value.
|
||||||
|
// Return nil to use the table's default CellStyle.
|
||||||
|
type CellStyleFn func(value string) *AnsiStyle
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Table
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Table renders tabular data with aligned columns.
|
// Table renders tabular data with aligned columns.
|
||||||
// HLCRF is for layout; Table is for tabular data - they serve different purposes.
|
// Supports optional box-drawing borders and per-column cell styling.
|
||||||
|
//
|
||||||
|
// t := cli.NewTable("REPO", "STATUS", "BRANCH").
|
||||||
|
// WithBorders(cli.BorderRounded).
|
||||||
|
// WithCellStyle(1, func(val string) *cli.AnsiStyle {
|
||||||
|
// if val == "clean" { return cli.SuccessStyle }
|
||||||
|
// return cli.WarningStyle
|
||||||
|
// })
|
||||||
|
// t.AddRow("core-php", "clean", "main")
|
||||||
|
// t.Render()
|
||||||
type Table struct {
|
type Table struct {
|
||||||
Headers []string
|
Headers []string
|
||||||
Rows [][]string
|
Rows [][]string
|
||||||
Style TableStyle
|
Style TableStyle
|
||||||
|
borders BorderStyle
|
||||||
|
cellStyleFns map[int]CellStyleFn
|
||||||
|
maxWidth int
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableStyle configures the appearance of table output.
|
// TableStyle configures the appearance of table output.
|
||||||
|
|
@ -143,17 +197,55 @@ func (t *Table) AddRow(cells ...string) *Table {
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithBorders enables box-drawing borders on the table.
|
||||||
|
func (t *Table) WithBorders(style BorderStyle) *Table {
|
||||||
|
t.borders = style
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCellStyle sets a per-column style function.
|
||||||
|
// The function receives the raw cell value and returns a style.
|
||||||
|
func (t *Table) WithCellStyle(col int, fn CellStyleFn) *Table {
|
||||||
|
if t.cellStyleFns == nil {
|
||||||
|
t.cellStyleFns = make(map[int]CellStyleFn)
|
||||||
|
}
|
||||||
|
t.cellStyleFns[col] = fn
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMaxWidth sets the maximum table width, truncating columns to fit.
|
||||||
|
func (t *Table) WithMaxWidth(w int) *Table {
|
||||||
|
t.maxWidth = w
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
// String renders the table.
|
// String renders the table.
|
||||||
func (t *Table) String() string {
|
func (t *Table) String() string {
|
||||||
if len(t.Headers) == 0 && len(t.Rows) == 0 {
|
if len(t.Headers) == 0 && len(t.Rows) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate column widths
|
if t.borders != BorderNone {
|
||||||
|
return t.renderBordered()
|
||||||
|
}
|
||||||
|
return t.renderPlain()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render prints the table to stdout.
|
||||||
|
func (t *Table) Render() {
|
||||||
|
fmt.Print(t.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Table) colCount() int {
|
||||||
cols := len(t.Headers)
|
cols := len(t.Headers)
|
||||||
if cols == 0 && len(t.Rows) > 0 {
|
if cols == 0 && len(t.Rows) > 0 {
|
||||||
cols = len(t.Rows[0])
|
cols = len(t.Rows[0])
|
||||||
}
|
}
|
||||||
|
return cols
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Table) columnWidths() []int {
|
||||||
|
cols := t.colCount()
|
||||||
widths := make([]int, cols)
|
widths := make([]int, cols)
|
||||||
|
|
||||||
for i, h := range t.Headers {
|
for i, h := range t.Headers {
|
||||||
|
|
@ -169,43 +261,180 @@ func (t *Table) String() string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if t.maxWidth > 0 {
|
||||||
|
t.constrainWidths(widths)
|
||||||
|
}
|
||||||
|
return widths
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Table) constrainWidths(widths []int) {
|
||||||
|
cols := len(widths)
|
||||||
|
overhead := 0
|
||||||
|
if t.borders != BorderNone {
|
||||||
|
// │ cell │ cell │ = (cols+1) verticals + 2*cols padding spaces
|
||||||
|
overhead = (cols + 1) + (cols * 2)
|
||||||
|
} else {
|
||||||
|
// separator between columns
|
||||||
|
overhead = (cols - 1) * len(t.Style.Separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
total := overhead
|
||||||
|
for _, w := range widths {
|
||||||
|
total += w
|
||||||
|
}
|
||||||
|
|
||||||
|
if total <= t.maxWidth {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shrink widest columns first until we fit.
|
||||||
|
budget := t.maxWidth - overhead
|
||||||
|
if budget < cols {
|
||||||
|
budget = cols
|
||||||
|
}
|
||||||
|
for total-overhead > budget {
|
||||||
|
maxIdx, maxW := 0, 0
|
||||||
|
for i, w := range widths {
|
||||||
|
if w > maxW {
|
||||||
|
maxIdx, maxW = i, w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
widths[maxIdx]--
|
||||||
|
total--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Table) resolveStyle(col int, value string) *AnsiStyle {
|
||||||
|
if t.cellStyleFns != nil {
|
||||||
|
if fn, ok := t.cellStyleFns[col]; ok {
|
||||||
|
if s := fn(value); s != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t.Style.CellStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Table) renderPlain() string {
|
||||||
|
widths := t.columnWidths()
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sep := t.Style.Separator
|
sep := t.Style.Separator
|
||||||
|
|
||||||
// Headers
|
|
||||||
if len(t.Headers) > 0 {
|
if len(t.Headers) > 0 {
|
||||||
for i, h := range t.Headers {
|
for i, h := range t.Headers {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
sb.WriteString(sep)
|
sb.WriteString(sep)
|
||||||
}
|
}
|
||||||
styled := Pad(h, widths[i])
|
cell := Pad(Truncate(h, widths[i]), widths[i])
|
||||||
if t.Style.HeaderStyle != nil {
|
if t.Style.HeaderStyle != nil {
|
||||||
styled = t.Style.HeaderStyle.Render(styled)
|
cell = t.Style.HeaderStyle.Render(cell)
|
||||||
}
|
}
|
||||||
sb.WriteString(styled)
|
sb.WriteString(cell)
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteByte('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rows
|
|
||||||
for _, row := range t.Rows {
|
for _, row := range t.Rows {
|
||||||
for i, cell := range row {
|
for i := range t.colCount() {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
sb.WriteString(sep)
|
sb.WriteString(sep)
|
||||||
}
|
}
|
||||||
styled := Pad(cell, widths[i])
|
val := ""
|
||||||
if t.Style.CellStyle != nil {
|
if i < len(row) {
|
||||||
styled = t.Style.CellStyle.Render(styled)
|
val = row[i]
|
||||||
}
|
}
|
||||||
sb.WriteString(styled)
|
cell := Pad(Truncate(val, widths[i]), widths[i])
|
||||||
|
if style := t.resolveStyle(i, val); style != nil {
|
||||||
|
cell = style.Render(cell)
|
||||||
|
}
|
||||||
|
sb.WriteString(cell)
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteByte('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render prints the table to stdout.
|
func (t *Table) renderBordered() string {
|
||||||
func (t *Table) Render() {
|
b := borderSets[t.borders]
|
||||||
fmt.Print(t.String())
|
widths := t.columnWidths()
|
||||||
|
cols := t.colCount()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Top border: ╭──────┬──────╮
|
||||||
|
sb.WriteString(b.tl)
|
||||||
|
for i := range cols {
|
||||||
|
sb.WriteString(strings.Repeat(b.h, widths[i]+2))
|
||||||
|
if i < cols-1 {
|
||||||
|
sb.WriteString(b.tt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(b.tr)
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
|
||||||
|
// Header row
|
||||||
|
if len(t.Headers) > 0 {
|
||||||
|
sb.WriteString(b.v)
|
||||||
|
for i := range cols {
|
||||||
|
h := ""
|
||||||
|
if i < len(t.Headers) {
|
||||||
|
h = t.Headers[i]
|
||||||
|
}
|
||||||
|
cell := Pad(Truncate(h, widths[i]), widths[i])
|
||||||
|
if t.Style.HeaderStyle != nil {
|
||||||
|
cell = t.Style.HeaderStyle.Render(cell)
|
||||||
|
}
|
||||||
|
sb.WriteByte(' ')
|
||||||
|
sb.WriteString(cell)
|
||||||
|
sb.WriteByte(' ')
|
||||||
|
sb.WriteString(b.v)
|
||||||
|
}
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
|
||||||
|
// Header separator: ├──────┼──────┤
|
||||||
|
sb.WriteString(b.lt)
|
||||||
|
for i := range cols {
|
||||||
|
sb.WriteString(strings.Repeat(b.h, widths[i]+2))
|
||||||
|
if i < cols-1 {
|
||||||
|
sb.WriteString(b.x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(b.rt)
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data rows
|
||||||
|
for _, row := range t.Rows {
|
||||||
|
sb.WriteString(b.v)
|
||||||
|
for i := range cols {
|
||||||
|
val := ""
|
||||||
|
if i < len(row) {
|
||||||
|
val = row[i]
|
||||||
|
}
|
||||||
|
cell := Pad(Truncate(val, widths[i]), widths[i])
|
||||||
|
if style := t.resolveStyle(i, val); style != nil {
|
||||||
|
cell = style.Render(cell)
|
||||||
|
}
|
||||||
|
sb.WriteByte(' ')
|
||||||
|
sb.WriteString(cell)
|
||||||
|
sb.WriteByte(' ')
|
||||||
|
sb.WriteString(b.v)
|
||||||
|
}
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom border: ╰──────┴──────╯
|
||||||
|
sb.WriteString(b.bl)
|
||||||
|
for i := range cols {
|
||||||
|
sb.WriteString(strings.Repeat(b.h, widths[i]+2))
|
||||||
|
if i < cols-1 {
|
||||||
|
sb.WriteString(b.bt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(b.br)
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
206
pkg/cli/styles_test.go
Normal file
206
pkg/cli/styles_test.go
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTable_Good(t *testing.T) {
|
||||||
|
t.Run("plain table unchanged", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
tbl := NewTable("NAME", "AGE")
|
||||||
|
tbl.AddRow("Alice", "30")
|
||||||
|
tbl.AddRow("Bob", "25")
|
||||||
|
|
||||||
|
out := tbl.String()
|
||||||
|
assert.Contains(t, out, "NAME")
|
||||||
|
assert.Contains(t, out, "Alice")
|
||||||
|
assert.Contains(t, out, "Bob")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bordered normal", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
tbl := NewTable("A", "B").WithBorders(BorderNormal)
|
||||||
|
tbl.AddRow("x", "y")
|
||||||
|
|
||||||
|
out := tbl.String()
|
||||||
|
assert.True(t, strings.HasPrefix(out, "┌"))
|
||||||
|
assert.Contains(t, out, "┐")
|
||||||
|
assert.Contains(t, out, "│")
|
||||||
|
assert.Contains(t, out, "├")
|
||||||
|
assert.Contains(t, out, "┤")
|
||||||
|
assert.Contains(t, out, "└")
|
||||||
|
assert.Contains(t, out, "┘")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bordered rounded", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
tbl := NewTable("REPO", "STATUS").WithBorders(BorderRounded)
|
||||||
|
tbl.AddRow("core", "clean")
|
||||||
|
|
||||||
|
out := tbl.String()
|
||||||
|
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||||
|
assert.True(t, strings.HasPrefix(lines[0], "╭"))
|
||||||
|
assert.True(t, strings.HasSuffix(lines[0], "╮"))
|
||||||
|
assert.True(t, strings.HasPrefix(lines[len(lines)-1], "╰"))
|
||||||
|
assert.True(t, strings.HasSuffix(lines[len(lines)-1], "╯"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bordered heavy", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
tbl := NewTable("X").WithBorders(BorderHeavy)
|
||||||
|
tbl.AddRow("v")
|
||||||
|
|
||||||
|
out := tbl.String()
|
||||||
|
assert.Contains(t, out, "┏")
|
||||||
|
assert.Contains(t, out, "┓")
|
||||||
|
assert.Contains(t, out, "┃")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bordered double", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
tbl := NewTable("X").WithBorders(BorderDouble)
|
||||||
|
tbl.AddRow("v")
|
||||||
|
|
||||||
|
out := tbl.String()
|
||||||
|
assert.Contains(t, out, "╔")
|
||||||
|
assert.Contains(t, out, "╗")
|
||||||
|
assert.Contains(t, out, "║")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bordered structure", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
tbl := NewTable("A", "B").WithBorders(BorderRounded)
|
||||||
|
tbl.AddRow("x", "y")
|
||||||
|
tbl.AddRow("1", "2")
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimRight(tbl.String(), "\n"), "\n")
|
||||||
|
// Top border, header, separator, 2 data rows, bottom border = 6 lines
|
||||||
|
assert.Equal(t, 6, len(lines), "expected 6 lines: border, header, sep, 2 rows, border")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cell style function", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
called := false
|
||||||
|
tbl := NewTable("STATUS").
|
||||||
|
WithCellStyle(0, func(val string) *AnsiStyle {
|
||||||
|
called = true
|
||||||
|
if val == "ok" {
|
||||||
|
return SuccessStyle
|
||||||
|
}
|
||||||
|
return ErrorStyle
|
||||||
|
})
|
||||||
|
tbl.AddRow("ok")
|
||||||
|
tbl.AddRow("fail")
|
||||||
|
|
||||||
|
_ = tbl.String()
|
||||||
|
assert.True(t, called, "cell style function should be called")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cell style with borders", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
tbl := NewTable("NAME", "STATUS").
|
||||||
|
WithBorders(BorderRounded).
|
||||||
|
WithCellStyle(1, func(val string) *AnsiStyle {
|
||||||
|
return nil // fallback to default
|
||||||
|
})
|
||||||
|
tbl.AddRow("core", "ok")
|
||||||
|
|
||||||
|
out := tbl.String()
|
||||||
|
assert.Contains(t, out, "core")
|
||||||
|
assert.Contains(t, out, "ok")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("max width truncates", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
tbl := NewTable("LONG_HEADER", "SHORT").WithMaxWidth(25)
|
||||||
|
tbl.AddRow("very_long_value_here", "x")
|
||||||
|
|
||||||
|
out := tbl.String()
|
||||||
|
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
w := utf8.RuneCountInString(line)
|
||||||
|
assert.LessOrEqual(t, w, 25, "line should not exceed max width: %q", line)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("max width with borders", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
tbl := NewTable("A", "B").WithBorders(BorderNormal).WithMaxWidth(20)
|
||||||
|
tbl.AddRow("hello", "world")
|
||||||
|
|
||||||
|
out := tbl.String()
|
||||||
|
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
w := utf8.RuneCountInString(line)
|
||||||
|
assert.LessOrEqual(t, w, 20, "bordered line should not exceed max width: %q", line)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty table returns empty", func(t *testing.T) {
|
||||||
|
tbl := NewTable()
|
||||||
|
assert.Equal(t, "", tbl.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no headers with borders", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
tbl := NewTable().WithBorders(BorderNormal)
|
||||||
|
tbl.Rows = [][]string{{"a", "b"}, {"c", "d"}}
|
||||||
|
|
||||||
|
out := tbl.String()
|
||||||
|
assert.Contains(t, out, "┌")
|
||||||
|
// No header separator since no headers
|
||||||
|
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||||
|
// Top border, 2 data rows, bottom border = 4 lines (no header separator)
|
||||||
|
assert.Equal(t, 4, len(lines))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTable_Bad(t *testing.T) {
|
||||||
|
t.Run("short rows padded", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
tbl := NewTable("A", "B", "C")
|
||||||
|
tbl.AddRow("x") // only 1 cell, 3 columns
|
||||||
|
|
||||||
|
out := tbl.String()
|
||||||
|
assert.Contains(t, out, "x")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncate_Good(t *testing.T) {
|
||||||
|
assert.Equal(t, "hel...", Truncate("hello world", 6))
|
||||||
|
assert.Equal(t, "hi", Truncate("hi", 6))
|
||||||
|
assert.Equal(t, "he", Truncate("hello", 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPad_Good(t *testing.T) {
|
||||||
|
assert.Equal(t, "hi ", Pad("hi", 5))
|
||||||
|
assert.Equal(t, "hello", Pad("hello", 3))
|
||||||
|
}
|
||||||
291
pkg/cli/tracker.go
Normal file
291
pkg/cli/tracker.go
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Spinner frames (braille pattern).
|
||||||
|
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||||
|
|
||||||
|
// taskState tracks the lifecycle of a tracked task.
|
||||||
|
type taskState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
taskPending taskState = iota
|
||||||
|
taskRunning
|
||||||
|
taskDone
|
||||||
|
taskFailed
|
||||||
|
)
|
||||||
|
|
||||||
|
// TrackedTask represents a single task in a TaskTracker.
|
||||||
|
// Safe for concurrent use — call Update, Done, or Fail from any goroutine.
|
||||||
|
type TrackedTask struct {
|
||||||
|
name string
|
||||||
|
status string
|
||||||
|
state taskState
|
||||||
|
tracker *TaskTracker
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sets the task status message and marks it as running.
|
||||||
|
func (t *TrackedTask) Update(status string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.status = status
|
||||||
|
t.state = taskRunning
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done marks the task as successfully completed with a final message.
|
||||||
|
func (t *TrackedTask) Done(message string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.status = message
|
||||||
|
t.state = taskDone
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail marks the task as failed with an error message.
|
||||||
|
func (t *TrackedTask) Fail(message string) {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.status = message
|
||||||
|
t.state = taskFailed
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TrackedTask) snapshot() (string, string, taskState) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
return t.name, t.status, t.state
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskTracker displays multiple concurrent tasks with individual spinners.
|
||||||
|
//
|
||||||
|
// tracker := cli.NewTaskTracker()
|
||||||
|
// for _, repo := range repos {
|
||||||
|
// t := tracker.Add(repo.Name)
|
||||||
|
// go func(t *cli.TrackedTask) {
|
||||||
|
// t.Update("pulling...")
|
||||||
|
// // ...
|
||||||
|
// t.Done("up to date")
|
||||||
|
// }(t)
|
||||||
|
// }
|
||||||
|
// tracker.Wait()
|
||||||
|
type TaskTracker struct {
|
||||||
|
tasks []*TrackedTask
|
||||||
|
out io.Writer
|
||||||
|
mu sync.Mutex
|
||||||
|
started bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTaskTracker creates a new parallel task tracker.
|
||||||
|
func NewTaskTracker() *TaskTracker {
|
||||||
|
return &TaskTracker{out: os.Stdout}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add registers a task and returns it for goroutine use.
|
||||||
|
func (tr *TaskTracker) Add(name string) *TrackedTask {
|
||||||
|
t := &TrackedTask{
|
||||||
|
name: name,
|
||||||
|
status: "waiting",
|
||||||
|
state: taskPending,
|
||||||
|
tracker: tr,
|
||||||
|
}
|
||||||
|
tr.mu.Lock()
|
||||||
|
tr.tasks = append(tr.tasks, t)
|
||||||
|
tr.mu.Unlock()
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait renders the task display and blocks until all tasks complete.
|
||||||
|
// Uses ANSI cursor manipulation for live updates when connected to a terminal.
|
||||||
|
// Falls back to line-by-line output for non-TTY.
|
||||||
|
func (tr *TaskTracker) Wait() {
|
||||||
|
if !tr.isTTY() {
|
||||||
|
tr.waitStatic()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tr.waitLive()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TaskTracker) isTTY() bool {
|
||||||
|
if f, ok := tr.out.(*os.File); ok {
|
||||||
|
return term.IsTerminal(int(f.Fd()))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TaskTracker) waitStatic() {
|
||||||
|
// Non-TTY: print final state of each task when it completes.
|
||||||
|
reported := make(map[int]bool)
|
||||||
|
for {
|
||||||
|
tr.mu.Lock()
|
||||||
|
tasks := tr.tasks
|
||||||
|
tr.mu.Unlock()
|
||||||
|
|
||||||
|
allDone := true
|
||||||
|
for i, t := range tasks {
|
||||||
|
name, status, state := t.snapshot()
|
||||||
|
if state != taskDone && state != taskFailed {
|
||||||
|
allDone = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if reported[i] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reported[i] = true
|
||||||
|
icon := Glyph(":check:")
|
||||||
|
if state == taskFailed {
|
||||||
|
icon = Glyph(":cross:")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(tr.out, "%s %-20s %s\n", icon, name, status)
|
||||||
|
}
|
||||||
|
if allDone {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TaskTracker) waitLive() {
|
||||||
|
tr.mu.Lock()
|
||||||
|
n := len(tr.tasks)
|
||||||
|
tr.mu.Unlock()
|
||||||
|
|
||||||
|
// Print initial lines.
|
||||||
|
frame := 0
|
||||||
|
for i := range n {
|
||||||
|
tr.renderLine(i, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(80 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
<-ticker.C
|
||||||
|
frame++
|
||||||
|
|
||||||
|
tr.mu.Lock()
|
||||||
|
count := len(tr.tasks)
|
||||||
|
tr.mu.Unlock()
|
||||||
|
|
||||||
|
// Move cursor up to redraw all lines.
|
||||||
|
fmt.Fprintf(tr.out, "\033[%dA", count)
|
||||||
|
for i := range count {
|
||||||
|
tr.renderLine(i, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tr.allDone() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TaskTracker) renderLine(idx, frame int) {
|
||||||
|
tr.mu.Lock()
|
||||||
|
t := tr.tasks[idx]
|
||||||
|
tr.mu.Unlock()
|
||||||
|
|
||||||
|
name, status, state := t.snapshot()
|
||||||
|
nameW := tr.nameWidth()
|
||||||
|
|
||||||
|
var icon string
|
||||||
|
switch state {
|
||||||
|
case taskPending:
|
||||||
|
icon = DimStyle.Render(Glyph(":pending:"))
|
||||||
|
case taskRunning:
|
||||||
|
icon = InfoStyle.Render(spinnerFrames[frame%len(spinnerFrames)])
|
||||||
|
case taskDone:
|
||||||
|
icon = SuccessStyle.Render(Glyph(":check:"))
|
||||||
|
case taskFailed:
|
||||||
|
icon = ErrorStyle.Render(Glyph(":cross:"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var styledStatus string
|
||||||
|
switch state {
|
||||||
|
case taskDone:
|
||||||
|
styledStatus = SuccessStyle.Render(status)
|
||||||
|
case taskFailed:
|
||||||
|
styledStatus = ErrorStyle.Render(status)
|
||||||
|
default:
|
||||||
|
styledStatus = DimStyle.Render(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(tr.out, "\033[2K%s %-*s %s\n", icon, nameW, name, styledStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TaskTracker) nameWidth() int {
|
||||||
|
tr.mu.Lock()
|
||||||
|
defer tr.mu.Unlock()
|
||||||
|
w := 0
|
||||||
|
for _, t := range tr.tasks {
|
||||||
|
if len(t.name) > w {
|
||||||
|
w = len(t.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TaskTracker) allDone() bool {
|
||||||
|
tr.mu.Lock()
|
||||||
|
defer tr.mu.Unlock()
|
||||||
|
for _, t := range tr.tasks {
|
||||||
|
_, _, state := t.snapshot()
|
||||||
|
if state != taskDone && state != taskFailed {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary returns a one-line summary of task results.
|
||||||
|
func (tr *TaskTracker) Summary() string {
|
||||||
|
tr.mu.Lock()
|
||||||
|
defer tr.mu.Unlock()
|
||||||
|
|
||||||
|
var passed, failed int
|
||||||
|
for _, t := range tr.tasks {
|
||||||
|
_, _, state := t.snapshot()
|
||||||
|
switch state {
|
||||||
|
case taskDone:
|
||||||
|
passed++
|
||||||
|
case taskFailed:
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total := len(tr.tasks)
|
||||||
|
if failed > 0 {
|
||||||
|
return fmt.Sprintf("%d/%d passed, %d failed", passed, total, failed)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d/%d passed", passed, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the current state of all tasks as plain text (no ANSI).
|
||||||
|
func (tr *TaskTracker) String() string {
|
||||||
|
tr.mu.Lock()
|
||||||
|
tasks := tr.tasks
|
||||||
|
tr.mu.Unlock()
|
||||||
|
|
||||||
|
nameW := tr.nameWidth()
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, t := range tasks {
|
||||||
|
name, status, state := t.snapshot()
|
||||||
|
icon := "…"
|
||||||
|
switch state {
|
||||||
|
case taskDone:
|
||||||
|
icon = "✓"
|
||||||
|
case taskFailed:
|
||||||
|
icon = "✗"
|
||||||
|
case taskRunning:
|
||||||
|
icon = "⠋"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&sb, "%s %-*s %s\n", icon, nameW, name, status)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
188
pkg/cli/tracker_test.go
Normal file
188
pkg/cli/tracker_test.go
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTaskTracker_Good(t *testing.T) {
|
||||||
|
t.Run("add and complete tasks", func(t *testing.T) {
|
||||||
|
tr := NewTaskTracker()
|
||||||
|
tr.out = &bytes.Buffer{} // non-TTY
|
||||||
|
|
||||||
|
t1 := tr.Add("repo-a")
|
||||||
|
t2 := tr.Add("repo-b")
|
||||||
|
|
||||||
|
t1.Update("pulling...")
|
||||||
|
t2.Update("pulling...")
|
||||||
|
|
||||||
|
t1.Done("up to date")
|
||||||
|
t2.Done("3 commits behind")
|
||||||
|
|
||||||
|
out := tr.String()
|
||||||
|
assert.Contains(t, out, "repo-a")
|
||||||
|
assert.Contains(t, out, "repo-b")
|
||||||
|
assert.Contains(t, out, "up to date")
|
||||||
|
assert.Contains(t, out, "3 commits behind")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("task states", func(t *testing.T) {
|
||||||
|
tr := NewTaskTracker()
|
||||||
|
tr.out = &bytes.Buffer{}
|
||||||
|
|
||||||
|
task := tr.Add("test")
|
||||||
|
|
||||||
|
// Pending
|
||||||
|
_, _, state := task.snapshot()
|
||||||
|
assert.Equal(t, taskPending, state)
|
||||||
|
|
||||||
|
// Running
|
||||||
|
task.Update("working")
|
||||||
|
_, status, state := task.snapshot()
|
||||||
|
assert.Equal(t, taskRunning, state)
|
||||||
|
assert.Equal(t, "working", status)
|
||||||
|
|
||||||
|
// Done
|
||||||
|
task.Done("finished")
|
||||||
|
_, status, state = task.snapshot()
|
||||||
|
assert.Equal(t, taskDone, state)
|
||||||
|
assert.Equal(t, "finished", status)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("task fail", func(t *testing.T) {
|
||||||
|
tr := NewTaskTracker()
|
||||||
|
tr.out = &bytes.Buffer{}
|
||||||
|
|
||||||
|
task := tr.Add("bad-repo")
|
||||||
|
task.Fail("connection refused")
|
||||||
|
|
||||||
|
_, status, state := task.snapshot()
|
||||||
|
assert.Equal(t, taskFailed, state)
|
||||||
|
assert.Equal(t, "connection refused", status)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("concurrent updates", func(t *testing.T) {
|
||||||
|
tr := NewTaskTracker()
|
||||||
|
tr.out = &bytes.Buffer{}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range 10 {
|
||||||
|
task := tr.Add("task-" + string(rune('a'+i)))
|
||||||
|
wg.Add(1)
|
||||||
|
go func(t *TrackedTask) {
|
||||||
|
defer wg.Done()
|
||||||
|
t.Update("running")
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
t.Done("ok")
|
||||||
|
}(task)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
assert.True(t, tr.allDone())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("summary all passed", func(t *testing.T) {
|
||||||
|
tr := NewTaskTracker()
|
||||||
|
tr.out = &bytes.Buffer{}
|
||||||
|
|
||||||
|
tr.Add("a").Done("ok")
|
||||||
|
tr.Add("b").Done("ok")
|
||||||
|
tr.Add("c").Done("ok")
|
||||||
|
|
||||||
|
assert.Equal(t, "3/3 passed", tr.Summary())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("summary with failures", func(t *testing.T) {
|
||||||
|
tr := NewTaskTracker()
|
||||||
|
tr.out = &bytes.Buffer{}
|
||||||
|
|
||||||
|
tr.Add("a").Done("ok")
|
||||||
|
tr.Add("b").Fail("error")
|
||||||
|
tr.Add("c").Done("ok")
|
||||||
|
|
||||||
|
assert.Equal(t, "2/3 passed, 1 failed", tr.Summary())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wait completes for non-TTY", func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
tr := NewTaskTracker()
|
||||||
|
tr.out = &buf
|
||||||
|
|
||||||
|
task := tr.Add("quick")
|
||||||
|
go func() {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
task.Done("done")
|
||||||
|
}()
|
||||||
|
|
||||||
|
tr.Wait()
|
||||||
|
assert.Contains(t, buf.String(), "quick")
|
||||||
|
assert.Contains(t, buf.String(), "done")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("name width alignment", func(t *testing.T) {
|
||||||
|
tr := NewTaskTracker()
|
||||||
|
tr.out = &bytes.Buffer{}
|
||||||
|
|
||||||
|
tr.Add("short")
|
||||||
|
tr.Add("very-long-repo-name")
|
||||||
|
|
||||||
|
w := tr.nameWidth()
|
||||||
|
assert.Equal(t, 19, w)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("String output format", func(t *testing.T) {
|
||||||
|
tr := NewTaskTracker()
|
||||||
|
tr.out = &bytes.Buffer{}
|
||||||
|
|
||||||
|
tr.Add("repo-a").Done("clean")
|
||||||
|
tr.Add("repo-b").Fail("dirty")
|
||||||
|
tr.Add("repo-c").Update("pulling")
|
||||||
|
|
||||||
|
out := tr.String()
|
||||||
|
assert.Contains(t, out, "✓")
|
||||||
|
assert.Contains(t, out, "✗")
|
||||||
|
assert.Contains(t, out, "⠋")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskTracker_Bad(t *testing.T) {
|
||||||
|
t.Run("allDone with no tasks", func(t *testing.T) {
|
||||||
|
tr := NewTaskTracker()
|
||||||
|
tr.out = &bytes.Buffer{}
|
||||||
|
assert.True(t, tr.allDone())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("allDone incomplete", func(t *testing.T) {
|
||||||
|
tr := NewTaskTracker()
|
||||||
|
tr.out = &bytes.Buffer{}
|
||||||
|
tr.Add("pending")
|
||||||
|
assert.False(t, tr.allDone())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrackedTask_Good(t *testing.T) {
|
||||||
|
t.Run("thread safety", func(t *testing.T) {
|
||||||
|
tr := NewTaskTracker()
|
||||||
|
tr.out = &bytes.Buffer{}
|
||||||
|
task := tr.Add("concurrent")
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range 100 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
task.Update("running")
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
_, status, state := task.snapshot()
|
||||||
|
require.Equal(t, taskRunning, state)
|
||||||
|
require.Equal(t, "running", status)
|
||||||
|
})
|
||||||
|
}
|
||||||
98
pkg/cli/tree.go
Normal file
98
pkg/cli/tree.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TreeNode represents a node in a displayable tree structure.
|
||||||
|
// Use NewTree to create a root, then Add children.
|
||||||
|
//
|
||||||
|
// tree := cli.NewTree("core-php")
|
||||||
|
// tree.Add("core-tenant").Add("core-bio")
|
||||||
|
// tree.Add("core-admin")
|
||||||
|
// tree.Add("core-api")
|
||||||
|
// fmt.Print(tree)
|
||||||
|
// // core-php
|
||||||
|
// // ├── core-tenant
|
||||||
|
// // │ └── core-bio
|
||||||
|
// // ├── core-admin
|
||||||
|
// // └── core-api
|
||||||
|
type TreeNode struct {
|
||||||
|
label string
|
||||||
|
style *AnsiStyle
|
||||||
|
children []*TreeNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTree creates a new tree with the given root label.
|
||||||
|
func NewTree(label string) *TreeNode {
|
||||||
|
return &TreeNode{label: label}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add appends a child node and returns the child for chaining.
|
||||||
|
func (n *TreeNode) Add(label string) *TreeNode {
|
||||||
|
child := &TreeNode{label: label}
|
||||||
|
n.children = append(n.children, child)
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddStyled appends a styled child node and returns the child for chaining.
|
||||||
|
func (n *TreeNode) AddStyled(label string, style *AnsiStyle) *TreeNode {
|
||||||
|
child := &TreeNode{label: label, style: style}
|
||||||
|
n.children = append(n.children, child)
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTree appends an existing tree as a child and returns the parent for chaining.
|
||||||
|
func (n *TreeNode) AddTree(child *TreeNode) *TreeNode {
|
||||||
|
n.children = append(n.children, child)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithStyle sets the style on this node and returns it for chaining.
|
||||||
|
func (n *TreeNode) WithStyle(style *AnsiStyle) *TreeNode {
|
||||||
|
n.style = style
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// String renders the tree with box-drawing characters.
|
||||||
|
// Implements fmt.Stringer.
|
||||||
|
func (n *TreeNode) String() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(n.renderLabel())
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
n.writeChildren(&sb, "")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render prints the tree to stdout.
|
||||||
|
func (n *TreeNode) Render() {
|
||||||
|
fmt.Print(n.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *TreeNode) renderLabel() string {
|
||||||
|
if n.style != nil {
|
||||||
|
return n.style.Render(n.label)
|
||||||
|
}
|
||||||
|
return n.label
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *TreeNode) writeChildren(sb *strings.Builder, prefix string) {
|
||||||
|
for i, child := range n.children {
|
||||||
|
last := i == len(n.children)-1
|
||||||
|
|
||||||
|
connector := "├── "
|
||||||
|
next := "│ "
|
||||||
|
if last {
|
||||||
|
connector = "└── "
|
||||||
|
next = " "
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(prefix)
|
||||||
|
sb.WriteString(connector)
|
||||||
|
sb.WriteString(child.renderLabel())
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
|
||||||
|
child.writeChildren(sb, prefix+next)
|
||||||
|
}
|
||||||
|
}
|
||||||
113
pkg/cli/tree_test.go
Normal file
113
pkg/cli/tree_test.go
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTree_Good(t *testing.T) {
|
||||||
|
t.Run("single root", func(t *testing.T) {
|
||||||
|
tree := NewTree("root")
|
||||||
|
assert.Equal(t, "root\n", tree.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("flat children", func(t *testing.T) {
|
||||||
|
tree := NewTree("root")
|
||||||
|
tree.Add("alpha")
|
||||||
|
tree.Add("beta")
|
||||||
|
tree.Add("gamma")
|
||||||
|
|
||||||
|
expected := "root\n" +
|
||||||
|
"├── alpha\n" +
|
||||||
|
"├── beta\n" +
|
||||||
|
"└── gamma\n"
|
||||||
|
assert.Equal(t, expected, tree.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nested children", func(t *testing.T) {
|
||||||
|
tree := NewTree("core-php")
|
||||||
|
tree.Add("core-tenant").Add("core-bio")
|
||||||
|
tree.Add("core-admin")
|
||||||
|
tree.Add("core-api")
|
||||||
|
|
||||||
|
expected := "core-php\n" +
|
||||||
|
"├── core-tenant\n" +
|
||||||
|
"│ └── core-bio\n" +
|
||||||
|
"├── core-admin\n" +
|
||||||
|
"└── core-api\n"
|
||||||
|
assert.Equal(t, expected, tree.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("deep nesting", func(t *testing.T) {
|
||||||
|
tree := NewTree("a")
|
||||||
|
tree.Add("b").Add("c").Add("d")
|
||||||
|
|
||||||
|
expected := "a\n" +
|
||||||
|
"└── b\n" +
|
||||||
|
" └── c\n" +
|
||||||
|
" └── d\n"
|
||||||
|
assert.Equal(t, expected, tree.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("mixed depth", func(t *testing.T) {
|
||||||
|
tree := NewTree("root")
|
||||||
|
a := tree.Add("a")
|
||||||
|
a.Add("a1")
|
||||||
|
a.Add("a2")
|
||||||
|
tree.Add("b")
|
||||||
|
|
||||||
|
expected := "root\n" +
|
||||||
|
"├── a\n" +
|
||||||
|
"│ ├── a1\n" +
|
||||||
|
"│ └── a2\n" +
|
||||||
|
"└── b\n"
|
||||||
|
assert.Equal(t, expected, tree.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddTree composes subtrees", func(t *testing.T) {
|
||||||
|
sub := NewTree("sub-root")
|
||||||
|
sub.Add("child")
|
||||||
|
|
||||||
|
tree := NewTree("main")
|
||||||
|
tree.AddTree(sub)
|
||||||
|
|
||||||
|
expected := "main\n" +
|
||||||
|
"└── sub-root\n" +
|
||||||
|
" └── child\n"
|
||||||
|
assert.Equal(t, expected, tree.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("styled nodes", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
tree := NewTree("root")
|
||||||
|
tree.AddStyled("green", SuccessStyle)
|
||||||
|
tree.Add("plain")
|
||||||
|
|
||||||
|
expected := "root\n" +
|
||||||
|
"├── green\n" +
|
||||||
|
"└── plain\n"
|
||||||
|
assert.Equal(t, expected, tree.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("WithStyle on root", func(t *testing.T) {
|
||||||
|
SetColorEnabled(false)
|
||||||
|
defer SetColorEnabled(true)
|
||||||
|
|
||||||
|
tree := NewTree("root").WithStyle(ErrorStyle)
|
||||||
|
tree.Add("child")
|
||||||
|
|
||||||
|
expected := "root\n" +
|
||||||
|
"└── child\n"
|
||||||
|
assert.Equal(t, expected, tree.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTree_Bad(t *testing.T) {
|
||||||
|
t.Run("empty label", func(t *testing.T) {
|
||||||
|
tree := NewTree("")
|
||||||
|
assert.Equal(t, "\n", tree.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue