refactor(cli): replace stdlib imports with core/go primitives
Replace fmt, errors, path/filepath, strings, and strconv usages with core/go equivalents where available. Fully removes "errors" and "path/filepath" imports. Retains fmt (Fprint/Fprintf/Fscanln/Sscanf/Stringer), strings (Builder/Repeat/LastIndex/NewReplacer/FieldsSeq/SplitSeq), and strconv (Atoi/ParseUint) where core/go has no equivalent. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
437fa93918
commit
bf53270631
14 changed files with 160 additions and 557 deletions
|
|
@ -1,11 +1,11 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// ANSI escape codes
|
||||
|
|
@ -147,24 +147,24 @@ func (s *AnsiStyle) Render(text string) string {
|
|||
return text
|
||||
}
|
||||
|
||||
return strings.Join(codes, "") + text + ansiReset
|
||||
return core.Join("", codes...) + text + ansiReset
|
||||
}
|
||||
|
||||
// fgColorHex converts a hex string to an ANSI foreground color code.
|
||||
func fgColorHex(hex string) string {
|
||||
r, g, b := hexToRGB(hex)
|
||||
return fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b)
|
||||
return core.Sprintf("\033[38;2;%d;%d;%dm", r, g, b)
|
||||
}
|
||||
|
||||
// bgColorHex converts a hex string to an ANSI background color code.
|
||||
func bgColorHex(hex string) string {
|
||||
r, g, b := hexToRGB(hex)
|
||||
return fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)
|
||||
return core.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)
|
||||
}
|
||||
|
||||
// hexToRGB converts a hex string to RGB values.
|
||||
func hexToRGB(hex string) (int, int, int) {
|
||||
hex = strings.TrimPrefix(hex, "#")
|
||||
hex = core.TrimPrefix(hex, "#")
|
||||
if len(hex) != 6 {
|
||||
return 255, 255, 255
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,16 @@ package cli
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// DaemonOptions configures a background process helper.
|
||||
|
|
@ -68,7 +66,7 @@ var (
|
|||
return false
|
||||
}
|
||||
err = proc.Signal(syscall.Signal(0))
|
||||
return err == nil || errors.Is(err, syscall.EPERM)
|
||||
return err == nil || core.Is(err, syscall.EPERM)
|
||||
}
|
||||
processSignal = func(pid int, sig syscall.Signal) error {
|
||||
proc, err := os.FindProcess(pid)
|
||||
|
|
@ -188,9 +186,9 @@ func StopPIDFile(pidFile string, timeout time.Duration) error {
|
|||
return err
|
||||
}
|
||||
|
||||
pid, err := parsePID(strings.TrimSpace(string(rawPID)))
|
||||
pid, err := parsePID(core.Trim(string(rawPID)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse pid file %q: %w", pidFile, err)
|
||||
return core.E("StopPIDFile", core.Sprintf("parse pid file %q", pidFile), err)
|
||||
}
|
||||
|
||||
if err := processSignal(pid, syscall.SIGTERM); err != nil && !isProcessGone(err) {
|
||||
|
|
@ -213,7 +211,7 @@ func StopPIDFile(pidFile string, timeout time.Duration) error {
|
|||
}
|
||||
|
||||
if processAlive(pid) {
|
||||
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
|
||||
return core.E("StopPIDFile", core.Sprintf("process %d did not exit after SIGKILL", pid), nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -222,20 +220,20 @@ func StopPIDFile(pidFile string, timeout time.Duration) error {
|
|||
|
||||
func parsePID(raw string) (int, error) {
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("empty pid")
|
||||
return 0, core.NewError("empty pid")
|
||||
}
|
||||
pid, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if pid <= 0 {
|
||||
return 0, fmt.Errorf("invalid pid %d", pid)
|
||||
return 0, core.E("parsePID", core.Sprintf("invalid pid %d", pid), nil)
|
||||
}
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
func isProcessGone(err error) bool {
|
||||
return errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH)
|
||||
return core.Is(err, os.ErrProcessDone) || core.Is(err, syscall.ESRCH)
|
||||
}
|
||||
|
||||
func (d *Daemon) writePIDFile() error {
|
||||
|
|
@ -243,10 +241,10 @@ func (d *Daemon) writePIDFile() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(d.opts.PIDFile), 0o755); err != nil {
|
||||
if err := os.MkdirAll(core.PathDir(d.opts.PIDFile), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(d.opts.PIDFile, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644)
|
||||
return os.WriteFile(d.opts.PIDFile, []byte(core.Sprintf("%d", os.Getpid())+"\n"), 0o644)
|
||||
}
|
||||
|
||||
func (d *Daemon) removePIDFile() error {
|
||||
|
|
@ -318,5 +316,5 @@ func isClosedServerError(err error) bool {
|
|||
}
|
||||
|
||||
func isListenerClosedError(err error) bool {
|
||||
return err == nil || errors.Is(err, net.ErrClosed)
|
||||
return err == nil || core.Is(err, net.ErrClosed)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
|
|
@ -15,7 +14,7 @@ import (
|
|||
// Err creates a new error from a format string.
|
||||
// This is a direct replacement for fmt.Errorf.
|
||||
func Err(format string, args ...any) error {
|
||||
return fmt.Errorf(format, args...)
|
||||
return core.E("cli", core.Sprintf(format, args...), nil)
|
||||
}
|
||||
|
||||
// Wrap wraps an error with a message.
|
||||
|
|
@ -26,7 +25,7 @@ func Wrap(err error, msg string) error {
|
|||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s: %w", msg, err)
|
||||
return core.E("cli", msg, err)
|
||||
}
|
||||
|
||||
// WrapVerb wraps an error using i18n grammar for "Failed to verb subject".
|
||||
|
|
@ -39,7 +38,7 @@ func WrapVerb(err error, verb, subject string) error {
|
|||
return nil
|
||||
}
|
||||
msg := i18n.ActionFailed(verb, subject)
|
||||
return fmt.Errorf("%s: %w", msg, err)
|
||||
return core.E("cli", msg, err)
|
||||
}
|
||||
|
||||
// WrapAction wraps an error using i18n grammar for "Failed to verb".
|
||||
|
|
@ -52,7 +51,7 @@ func WrapAction(err error, verb string) error {
|
|||
return nil
|
||||
}
|
||||
msg := i18n.ActionFailed(verb, "")
|
||||
return fmt.Errorf("%s: %w", msg, err)
|
||||
return core.E("cli", msg, err)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -62,19 +61,19 @@ func WrapAction(err error, verb string) error {
|
|||
// Is reports whether any error in err's tree matches target.
|
||||
// This is a re-export of errors.Is for convenience.
|
||||
func Is(err, target error) bool {
|
||||
return errors.Is(err, target)
|
||||
return core.Is(err, target)
|
||||
}
|
||||
|
||||
// As finds the first error in err's tree that matches target.
|
||||
// This is a re-export of errors.As for convenience.
|
||||
func As(err error, target any) bool {
|
||||
return errors.As(err, target)
|
||||
return core.As(err, target)
|
||||
}
|
||||
|
||||
// Join returns an error that wraps the given errors.
|
||||
// This is a re-export of errors.Join for convenience.
|
||||
func Join(errs ...error) error {
|
||||
return errors.Join(errs...)
|
||||
return core.ErrorJoin(errs...)
|
||||
}
|
||||
|
||||
// ExitError represents an error that should cause the CLI to exit with a specific code.
|
||||
|
|
@ -120,7 +119,7 @@ func Exit(code int, err error) error {
|
|||
func Fatal(err error) {
|
||||
if err != nil {
|
||||
LogError("Fatal error", "err", err)
|
||||
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
|
||||
core.Print(stderrWriter(), "%s", ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -129,9 +128,9 @@ func Fatal(err error) {
|
|||
//
|
||||
// Deprecated: return an error from the command instead.
|
||||
func Fatalf(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
msg := core.Sprintf(format, args...)
|
||||
LogError("Fatal error", "msg", msg)
|
||||
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+msg))
|
||||
core.Print(stderrWriter(), "%s", ErrorStyle.Render(Glyph(":cross:")+" "+msg))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -146,8 +145,8 @@ func FatalWrap(err error, msg string) {
|
|||
return
|
||||
}
|
||||
LogError("Fatal error", "msg", msg, "err", err)
|
||||
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
||||
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
fullMsg := core.Sprintf("%s: %v", msg, err)
|
||||
core.Print(stderrWriter(), "%s", ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -163,7 +162,7 @@ func FatalWrapVerb(err error, verb, subject string) {
|
|||
}
|
||||
msg := i18n.ActionFailed(verb, subject)
|
||||
LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject)
|
||||
fullMsg := fmt.Sprintf("%s: %v", msg, err)
|
||||
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
fullMsg := core.Sprintf("%s: %v", msg, err)
|
||||
core.Print(stderrWriter(), "%s", ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
|
@ -27,24 +27,15 @@ type ModelFunc func(width, height int) string
|
|||
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
|
||||
history []Model
|
||||
out io.Writer
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
|
||||
// Focus management (bubbletea upgrade)
|
||||
focused Region
|
||||
keyMap KeyMap
|
||||
width int
|
||||
|
|
@ -53,9 +44,6 @@ type Frame struct {
|
|||
}
|
||||
|
||||
// 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,
|
||||
|
|
@ -70,8 +58,6 @@ func NewFrame(variant string) *Frame {
|
|||
}
|
||||
}
|
||||
|
||||
// WithOutput sets the destination writer for rendered output.
|
||||
// Pass nil to keep the current writer unchanged.
|
||||
func (f *Frame) WithOutput(out io.Writer) *Frame {
|
||||
if out != nil {
|
||||
f.out = out
|
||||
|
|
@ -79,20 +65,11 @@ func (f *Frame) WithOutput(out io.Writer) *Frame {
|
|||
return f
|
||||
}
|
||||
|
||||
// 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) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f }
|
||||
func (f *Frame) Left(m Model) *Frame { f.setModel(RegionLeft, m); return f }
|
||||
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) Right(m Model) *Frame { f.setModel(RegionRight, m); return f }
|
||||
func (f *Frame) Footer(m Model) *Frame { f.setModel(RegionFooter, m); return f }
|
||||
|
||||
func (f *Frame) setModel(r Region, m Model) {
|
||||
f.mu.Lock()
|
||||
|
|
@ -100,8 +77,6 @@ func (f *Frame) setModel(r Region, m Model) {
|
|||
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()
|
||||
|
|
@ -111,8 +86,6 @@ func (f *Frame) Navigate(m Model) {
|
|||
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()
|
||||
|
|
@ -124,7 +97,6 @@ func (f *Frame) Back() bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// Stop signals the Frame to exit its Run loop.
|
||||
func (f *Frame) Stop() {
|
||||
if f.program != nil {
|
||||
f.program.Quit()
|
||||
|
|
@ -137,29 +109,23 @@ func (f *Frame) Stop() {
|
|||
}
|
||||
}
|
||||
|
||||
// Send injects a message into the Frame's tea.Program.
|
||||
// Safe to call before Run() (message is discarded).
|
||||
func (f *Frame) Send(msg tea.Msg) {
|
||||
if f.program != nil {
|
||||
f.program.Send(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// WithKeyMap sets custom key bindings for Frame navigation.
|
||||
func (f *Frame) WithKeyMap(km KeyMap) *Frame {
|
||||
f.keyMap = km
|
||||
return f
|
||||
}
|
||||
|
||||
// Focused returns the currently focused region.
|
||||
func (f *Frame) Focused() Region {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.focused
|
||||
}
|
||||
|
||||
// Focus sets focus to a specific region.
|
||||
// Ignores the request if the region is not in this Frame's variant.
|
||||
func (f *Frame) Focus(r Region) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
|
@ -168,8 +134,6 @@ func (f *Frame) Focus(r Region) {
|
|||
}
|
||||
}
|
||||
|
||||
// buildFocusRing returns the ordered list of regions in this Frame's variant.
|
||||
// Order follows HLCRF convention.
|
||||
func (f *Frame) buildFocusRing() []Region {
|
||||
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
|
||||
var ring []Region
|
||||
|
|
@ -181,11 +145,9 @@ func (f *Frame) buildFocusRing() []Region {
|
|||
return ring
|
||||
}
|
||||
|
||||
// Init implements tea.Model. Collects Init() from all FrameModel regions.
|
||||
func (f *Frame) Init() tea.Cmd {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
var cmds []tea.Cmd
|
||||
for _, m := range f.models {
|
||||
fm := adaptModel(m)
|
||||
|
|
@ -196,7 +158,6 @@ func (f *Frame) Init() tea.Cmd {
|
|||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// Update implements tea.Model. Routes messages based on type and focus.
|
||||
func (f *Frame) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
|
@ -206,52 +167,39 @@ func (f *Frame) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
f.width = msg.Width
|
||||
f.height = msg.Height
|
||||
return f, f.broadcastLocked(msg)
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case f.keyMap.Quit:
|
||||
return f, tea.Quit
|
||||
|
||||
case f.keyMap.Back:
|
||||
f.backLocked()
|
||||
return f, nil
|
||||
|
||||
case f.keyMap.FocusNext:
|
||||
f.cycleFocusLocked(1)
|
||||
return f, nil
|
||||
|
||||
case f.keyMap.FocusPrev:
|
||||
f.cycleFocusLocked(-1)
|
||||
return f, nil
|
||||
|
||||
case f.keyMap.FocusUp:
|
||||
f.spatialFocusLocked(RegionHeader)
|
||||
return f, nil
|
||||
|
||||
case f.keyMap.FocusDown:
|
||||
f.spatialFocusLocked(RegionFooter)
|
||||
return f, nil
|
||||
|
||||
case f.keyMap.FocusLeft:
|
||||
f.spatialFocusLocked(RegionLeft)
|
||||
return f, nil
|
||||
|
||||
case f.keyMap.FocusRight:
|
||||
f.spatialFocusLocked(RegionRight)
|
||||
return f, nil
|
||||
|
||||
default:
|
||||
// Forward to focused region
|
||||
return f, f.updateFocusedLocked(msg)
|
||||
}
|
||||
|
||||
default:
|
||||
// Broadcast non-key messages to all regions
|
||||
return f, f.broadcastLocked(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// View implements tea.Model. Composes region views using lipgloss.
|
||||
func (f *Frame) View() string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
|
@ -264,7 +212,6 @@ func (f *Frame) viewLocked() string {
|
|||
w, h = f.termSize()
|
||||
}
|
||||
|
||||
// Calculate region dimensions
|
||||
headerH, footerH := 0, 0
|
||||
if _, ok := f.layout.regions[RegionHeader]; ok {
|
||||
if _, ok := f.models[RegionHeader]; ok {
|
||||
|
|
@ -278,11 +225,9 @@ func (f *Frame) viewLocked() string {
|
|||
}
|
||||
middleH := max(h-headerH-footerH, 1)
|
||||
|
||||
// Render each region
|
||||
header := f.renderRegionLocked(RegionHeader, w, headerH)
|
||||
footer := f.renderRegionLocked(RegionFooter, w, footerH)
|
||||
|
||||
// Calculate sidebar widths
|
||||
leftW, rightW := 0, 0
|
||||
if _, ok := f.layout.regions[RegionLeft]; ok {
|
||||
if _, ok := f.models[RegionLeft]; ok {
|
||||
|
|
@ -300,7 +245,6 @@ func (f *Frame) viewLocked() string {
|
|||
right := f.renderRegionLocked(RegionRight, rightW, middleH)
|
||||
content := f.renderRegionLocked(RegionContent, contentW, middleH)
|
||||
|
||||
// Compose middle row
|
||||
var middleParts []string
|
||||
if leftW > 0 {
|
||||
middleParts = append(middleParts, left)
|
||||
|
|
@ -315,7 +259,6 @@ func (f *Frame) viewLocked() string {
|
|||
middle = lipgloss.JoinHorizontal(lipgloss.Top, middleParts...)
|
||||
}
|
||||
|
||||
// Compose full layout
|
||||
var verticalParts []string
|
||||
if headerH > 0 {
|
||||
verticalParts = append(verticalParts, header)
|
||||
|
|
@ -340,8 +283,6 @@ func (f *Frame) renderRegionLocked(r Region, w, h int) string {
|
|||
return fm.View(w, h)
|
||||
}
|
||||
|
||||
// cycleFocusLocked moves focus forward (+1) or backward (-1) in the focus ring.
|
||||
// Must be called with f.mu held.
|
||||
func (f *Frame) cycleFocusLocked(dir int) {
|
||||
ring := f.buildFocusRing()
|
||||
if len(ring) == 0 {
|
||||
|
|
@ -358,15 +299,12 @@ func (f *Frame) cycleFocusLocked(dir int) {
|
|||
f.focused = ring[idx]
|
||||
}
|
||||
|
||||
// spatialFocusLocked moves focus to a specific region if it exists in the layout.
|
||||
// Must be called with f.mu held.
|
||||
func (f *Frame) spatialFocusLocked(target Region) {
|
||||
if _, exists := f.layout.regions[target]; exists {
|
||||
f.focused = target
|
||||
}
|
||||
}
|
||||
|
||||
// backLocked pops the content history. Must be called with f.mu held.
|
||||
func (f *Frame) backLocked() {
|
||||
if len(f.history) == 0 {
|
||||
return
|
||||
|
|
@ -375,8 +313,6 @@ func (f *Frame) backLocked() {
|
|||
f.history = f.history[:len(f.history)-1]
|
||||
}
|
||||
|
||||
// broadcastLocked sends a message to all FrameModel regions.
|
||||
// Must be called with f.mu held.
|
||||
func (f *Frame) broadcastLocked(msg tea.Msg) tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
for r, m := range f.models {
|
||||
|
|
@ -390,8 +326,6 @@ func (f *Frame) broadcastLocked(msg tea.Msg) tea.Cmd {
|
|||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// updateFocusedLocked sends a message to only the focused region.
|
||||
// Must be called with f.mu held.
|
||||
func (f *Frame) updateFocusedLocked(msg tea.Msg) tea.Cmd {
|
||||
m, ok := f.models[f.focused]
|
||||
if !ok {
|
||||
|
|
@ -403,8 +337,7 @@ func (f *Frame) updateFocusedLocked(msg tea.Msg) tea.Cmd {
|
|||
return cmd
|
||||
}
|
||||
|
||||
// Run renders the frame and blocks. In TTY mode, it live-refreshes at ~12fps.
|
||||
// In non-TTY mode, it renders once and returns immediately.
|
||||
// Run renders the frame and blocks.
|
||||
func (f *Frame) Run() {
|
||||
if !f.isTTY() {
|
||||
fmt.Fprint(f.out, f.String())
|
||||
|
|
@ -414,7 +347,6 @@ func (f *Frame) Run() {
|
|||
}
|
||||
|
||||
// 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)
|
||||
|
|
@ -429,7 +361,6 @@ func (f *Frame) RunFor(d time.Duration) {
|
|||
}
|
||||
|
||||
// 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()
|
||||
|
|
@ -439,8 +370,7 @@ func (f *Frame) String() string {
|
|||
return ""
|
||||
}
|
||||
view = ansi.Strip(view)
|
||||
// Ensure trailing newline for non-TTY consistency
|
||||
if !strings.HasSuffix(view, "\n") {
|
||||
if !core.HasSuffix(view, "\n") {
|
||||
view += "\n"
|
||||
}
|
||||
return view
|
||||
|
|
@ -460,7 +390,7 @@ func (f *Frame) termSize() (int, int) {
|
|||
return w, h
|
||||
}
|
||||
}
|
||||
return 80, 24 // sensible default
|
||||
return 80, 24
|
||||
}
|
||||
|
||||
func (f *Frame) runLive() {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"iter"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// Region represents one of the 5 HLCRF regions.
|
||||
|
|
@ -91,7 +92,7 @@ func ParseVariant(variant string) (*Composite, error) {
|
|||
for i < len(variant) {
|
||||
r := Region(variant[i])
|
||||
if !isValidRegion(r) {
|
||||
return nil, fmt.Errorf("invalid region: %c", r)
|
||||
return nil, core.E("ParseVariant", core.Sprintf("invalid region: %c", r), nil)
|
||||
}
|
||||
|
||||
slot := &Slot{region: r, path: string(r)}
|
||||
|
|
@ -101,7 +102,7 @@ func ParseVariant(variant string) (*Composite, error) {
|
|||
if i < len(variant) && variant[i] == '[' {
|
||||
end := findMatchingBracket(variant, i)
|
||||
if end == -1 {
|
||||
return nil, fmt.Errorf("unmatched bracket at %d", i)
|
||||
return nil, core.E("ParseVariant", core.Sprintf("unmatched bracket at %d", i), nil)
|
||||
}
|
||||
nested, err := ParseVariant(variant[i+1 : end])
|
||||
if err != nil {
|
||||
|
|
@ -168,6 +169,6 @@ func toRenderable(item any) Renderable {
|
|||
case string:
|
||||
return StringBlock(v)
|
||||
default:
|
||||
return StringBlock(fmt.Sprint(v))
|
||||
return StringBlock(core.Sprint(v))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
|
|
@ -46,5 +45,5 @@ func LogSecurity(msg string, keyvals ...any) { log.Security(msg, keyvals...) }
|
|||
//
|
||||
// cli.LogSecurityf("login attempt from %s", username)
|
||||
func LogSecurityf(format string, args ...any) {
|
||||
log.Security(fmt.Sprintf(format, args...))
|
||||
log.Security(core.Sprintf(format, args...))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,58 +2,58 @@ package cli
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"dappco.re/go/core/i18n"
|
||||
)
|
||||
|
||||
// Blank prints an empty line.
|
||||
func Blank() {
|
||||
fmt.Fprintln(stdoutWriter())
|
||||
core.Print(stdoutWriter(), "")
|
||||
}
|
||||
|
||||
// Echo translates a key via i18n.T and prints with newline.
|
||||
// No automatic styling - use Success/Error/Warn/Info for styled output.
|
||||
func Echo(key string, args ...any) {
|
||||
fmt.Fprintln(stdoutWriter(), compileGlyphs(i18n.T(key, args...)))
|
||||
core.Print(stdoutWriter(), "%s", compileGlyphs(i18n.T(key, args...)))
|
||||
}
|
||||
|
||||
// Print outputs formatted text (no newline).
|
||||
// Glyph shortcodes like :check: are converted.
|
||||
func Print(format string, args ...any) {
|
||||
fmt.Fprint(stdoutWriter(), compileGlyphs(fmt.Sprintf(format, args...)))
|
||||
fmt.Fprint(stdoutWriter(), compileGlyphs(core.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
// Println outputs formatted text with newline.
|
||||
// Glyph shortcodes like :check: are converted.
|
||||
func Println(format string, args ...any) {
|
||||
fmt.Fprintln(stdoutWriter(), compileGlyphs(fmt.Sprintf(format, args...)))
|
||||
core.Print(stdoutWriter(), "%s", compileGlyphs(core.Sprintf(format, args...)))
|
||||
}
|
||||
|
||||
// Text prints arguments like fmt.Println, but handling glyphs.
|
||||
func Text(args ...any) {
|
||||
fmt.Fprintln(stdoutWriter(), compileGlyphs(fmt.Sprint(args...)))
|
||||
core.Print(stdoutWriter(), "%s", compileGlyphs(core.Sprint(args...)))
|
||||
}
|
||||
|
||||
// Success prints a success message with checkmark (green).
|
||||
func Success(msg string) {
|
||||
fmt.Fprintln(stdoutWriter(), SuccessStyle.Render(Glyph(":check:")+" "+compileGlyphs(msg)))
|
||||
core.Print(stdoutWriter(), "%s", SuccessStyle.Render(Glyph(":check:")+" "+compileGlyphs(msg)))
|
||||
}
|
||||
|
||||
// Successf prints a formatted success message.
|
||||
func Successf(format string, args ...any) {
|
||||
Success(fmt.Sprintf(format, args...))
|
||||
Success(core.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Error prints an error message with cross (red) to stderr and logs it.
|
||||
func Error(msg string) {
|
||||
LogError(msg)
|
||||
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+compileGlyphs(msg)))
|
||||
core.Print(stderrWriter(), "%s", ErrorStyle.Render(Glyph(":cross:")+" "+compileGlyphs(msg)))
|
||||
}
|
||||
|
||||
// Errorf prints a formatted error message to stderr and logs it.
|
||||
func Errorf(format string, args ...any) {
|
||||
Error(fmt.Sprintf(format, args...))
|
||||
Error(core.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// ErrorWrap prints a wrapped error message to stderr and logs it.
|
||||
|
|
@ -61,7 +61,7 @@ func ErrorWrap(err error, msg string) {
|
|||
if err == nil {
|
||||
return
|
||||
}
|
||||
Error(fmt.Sprintf("%s: %v", msg, err))
|
||||
Error(core.Sprintf("%s: %v", msg, err))
|
||||
}
|
||||
|
||||
// ErrorWrapVerb prints a wrapped error using i18n grammar to stderr and logs it.
|
||||
|
|
@ -70,7 +70,7 @@ func ErrorWrapVerb(err error, verb, subject string) {
|
|||
return
|
||||
}
|
||||
msg := i18n.ActionFailed(verb, subject)
|
||||
Error(fmt.Sprintf("%s: %v", msg, err))
|
||||
Error(core.Sprintf("%s: %v", msg, err))
|
||||
}
|
||||
|
||||
// ErrorWrapAction prints a wrapped error using i18n grammar to stderr and logs it.
|
||||
|
|
@ -79,33 +79,33 @@ func ErrorWrapAction(err error, verb string) {
|
|||
return
|
||||
}
|
||||
msg := i18n.ActionFailed(verb, "")
|
||||
Error(fmt.Sprintf("%s: %v", msg, err))
|
||||
Error(core.Sprintf("%s: %v", msg, err))
|
||||
}
|
||||
|
||||
// Warn prints a warning message with warning symbol (amber) to stderr and logs it.
|
||||
func Warn(msg string) {
|
||||
LogWarn(msg)
|
||||
fmt.Fprintln(stderrWriter(), WarningStyle.Render(Glyph(":warn:")+" "+compileGlyphs(msg)))
|
||||
core.Print(stderrWriter(), "%s", WarningStyle.Render(Glyph(":warn:")+" "+compileGlyphs(msg)))
|
||||
}
|
||||
|
||||
// Warnf prints a formatted warning message to stderr and logs it.
|
||||
func Warnf(format string, args ...any) {
|
||||
Warn(fmt.Sprintf(format, args...))
|
||||
Warn(core.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Info prints an info message with info symbol (blue).
|
||||
func Info(msg string) {
|
||||
fmt.Fprintln(stdoutWriter(), InfoStyle.Render(Glyph(":info:")+" "+compileGlyphs(msg)))
|
||||
core.Print(stdoutWriter(), "%s", InfoStyle.Render(Glyph(":info:")+" "+compileGlyphs(msg)))
|
||||
}
|
||||
|
||||
// Infof prints a formatted info message.
|
||||
func Infof(format string, args ...any) {
|
||||
Info(fmt.Sprintf(format, args...))
|
||||
Info(core.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Dim prints dimmed text.
|
||||
func Dim(msg string) {
|
||||
fmt.Fprintln(stdoutWriter(), DimStyle.Render(compileGlyphs(msg)))
|
||||
core.Print(stdoutWriter(), "%s", DimStyle.Render(compileGlyphs(msg)))
|
||||
}
|
||||
|
||||
// Progress prints a progress indicator that overwrites the current line.
|
||||
|
|
@ -126,7 +126,7 @@ func ProgressDone() {
|
|||
|
||||
// Label prints a "Label: value" line.
|
||||
func Label(word, value string) {
|
||||
fmt.Fprintf(stdoutWriter(), "%s %s\n", KeyStyle.Render(compileGlyphs(i18n.Label(word))), compileGlyphs(value))
|
||||
core.Print(stdoutWriter(), "%s %s", KeyStyle.Render(compileGlyphs(i18n.Label(word))), compileGlyphs(value))
|
||||
}
|
||||
|
||||
// Scanln reads from stdin.
|
||||
|
|
@ -139,7 +139,7 @@ func Scanln(a ...any) (int, error) {
|
|||
// cli.Task("php", "Running tests...") // [php] Running tests...
|
||||
// cli.Task("go", i18n.Progress("build")) // [go] Building...
|
||||
func Task(label, message string) {
|
||||
fmt.Fprintf(stdoutWriter(), "%s %s\n\n", DimStyle.Render("["+compileGlyphs(label)+"]"), compileGlyphs(message))
|
||||
core.Print(stdoutWriter(), "%s %s\n", DimStyle.Render("["+compileGlyphs(label)+"]"), compileGlyphs(message))
|
||||
}
|
||||
|
||||
// Section prints a section header: "── SECTION ──"
|
||||
|
|
@ -147,8 +147,8 @@ func Task(label, message string) {
|
|||
// cli.Section("audit") // ── AUDIT ──
|
||||
func Section(name string) {
|
||||
dash := Glyph(":dash:")
|
||||
header := dash + dash + " " + strings.ToUpper(compileGlyphs(name)) + " " + dash + dash
|
||||
fmt.Fprintln(stdoutWriter(), AccentStyle.Render(header))
|
||||
header := dash + dash + " " + core.Upper(compileGlyphs(name)) + " " + dash + dash
|
||||
core.Print(stdoutWriter(), "%s", AccentStyle.Render(header))
|
||||
}
|
||||
|
||||
// Hint prints a labelled hint: "label: message"
|
||||
|
|
@ -156,7 +156,7 @@ func Section(name string) {
|
|||
// cli.Hint("install", "composer require vimeo/psalm")
|
||||
// cli.Hint("fix", "core php fmt --fix")
|
||||
func Hint(label, message string) {
|
||||
fmt.Fprintf(stdoutWriter(), " %s %s\n", DimStyle.Render(compileGlyphs(label)+":"), compileGlyphs(message))
|
||||
core.Print(stdoutWriter(), " %s %s", DimStyle.Render(compileGlyphs(label)+":"), compileGlyphs(message))
|
||||
}
|
||||
|
||||
// Severity prints a severity-styled message.
|
||||
|
|
@ -167,7 +167,7 @@ func Hint(label, message string) {
|
|||
// cli.Severity("low", "Debug enabled") // gray
|
||||
func Severity(level, message string) {
|
||||
var style *AnsiStyle
|
||||
switch strings.ToLower(level) {
|
||||
switch core.Lower(level) {
|
||||
case "critical":
|
||||
style = NewStyle().Bold().Foreground(ColourRed500)
|
||||
case "high":
|
||||
|
|
@ -179,7 +179,7 @@ func Severity(level, message string) {
|
|||
default:
|
||||
style = DimStyle
|
||||
}
|
||||
fmt.Fprintf(stdoutWriter(), " %s %s\n", style.Render("["+compileGlyphs(level)+"]"), compileGlyphs(message))
|
||||
core.Print(stdoutWriter(), " %s %s", style.Render("["+compileGlyphs(level)+"]"), compileGlyphs(message))
|
||||
}
|
||||
|
||||
// Result prints a result line: "✓ message" or "✗ message"
|
||||
|
|
|
|||
|
|
@ -2,14 +2,13 @@ package cli
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// newReader wraps stdin in a bufio.Reader if it isn't one already.
|
||||
func newReader() *bufio.Reader {
|
||||
if br, ok := stdinReader().(*bufio.Reader); ok {
|
||||
return br
|
||||
|
|
@ -29,9 +28,9 @@ func Prompt(label, defaultVal string) (string, error) {
|
|||
|
||||
r := newReader()
|
||||
input, err := r.ReadString('\n')
|
||||
input = strings.TrimSpace(input)
|
||||
input = core.Trim(input)
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
if !core.Is(err, io.EOF) {
|
||||
return "", err
|
||||
}
|
||||
if input == "" {
|
||||
|
|
@ -53,7 +52,7 @@ func Select(label string, options []string) (string, error) {
|
|||
return "", nil
|
||||
}
|
||||
|
||||
fmt.Fprintln(stderrWriter(), compileGlyphs(label))
|
||||
core.Print(stderrWriter(), "%s", compileGlyphs(label))
|
||||
for i, opt := range options {
|
||||
fmt.Fprintf(stderrWriter(), " %d. %s\n", i+1, compileGlyphs(opt))
|
||||
}
|
||||
|
|
@ -61,15 +60,15 @@ func Select(label string, options []string) (string, error) {
|
|||
|
||||
r := newReader()
|
||||
input, err := r.ReadString('\n')
|
||||
if err != nil && strings.TrimSpace(input) == "" {
|
||||
if err != nil && core.Trim(input) == "" {
|
||||
promptHint("No input received. Selection cancelled.")
|
||||
return "", Wrap(err, "selection cancelled")
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(input)
|
||||
trimmed := core.Trim(input)
|
||||
n, err := strconv.Atoi(trimmed)
|
||||
if err != nil || n < 1 || n > len(options) {
|
||||
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(options)))
|
||||
promptHint(core.Sprintf("Please enter a number between 1 and %d.", len(options)))
|
||||
return "", Err("invalid selection %q: choose a number between 1 and %d", trimmed, len(options))
|
||||
}
|
||||
return options[n-1], nil
|
||||
|
|
@ -81,7 +80,7 @@ func MultiSelect(label string, options []string) ([]string, error) {
|
|||
return []string{}, nil
|
||||
}
|
||||
|
||||
fmt.Fprintln(stderrWriter(), compileGlyphs(label))
|
||||
core.Print(stderrWriter(), "%s", compileGlyphs(label))
|
||||
for i, opt := range options {
|
||||
fmt.Fprintf(stderrWriter(), " %d. %s\n", i+1, compileGlyphs(opt))
|
||||
}
|
||||
|
|
@ -89,17 +88,17 @@ func MultiSelect(label string, options []string) ([]string, error) {
|
|||
|
||||
r := newReader()
|
||||
input, err := r.ReadString('\n')
|
||||
trimmed := strings.TrimSpace(input)
|
||||
trimmed := core.Trim(input)
|
||||
if err != nil && trimmed == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
if err != nil && !core.Is(err, io.EOF) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
selected, parseErr := parseMultiSelection(trimmed, len(options))
|
||||
if parseErr != nil {
|
||||
return nil, Wrap(parseErr, fmt.Sprintf("invalid selection %q", trimmed))
|
||||
return nil, Wrap(parseErr, core.Sprintf("invalid selection %q", trimmed))
|
||||
}
|
||||
|
||||
selectedOptions := make([]string, 0, len(selected))
|
||||
|
|
|
|||
|
|
@ -3,41 +3,24 @@ package cli
|
|||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"dappco.re/go/core"
|
||||
)
|
||||
|
||||
// RenderStyle controls how layouts are rendered.
|
||||
//
|
||||
// cli.UseRenderBoxed()
|
||||
// frame := cli.NewFrame("HCF")
|
||||
// fmt.Print(frame.String())
|
||||
type RenderStyle int
|
||||
|
||||
// Render style constants for layout output.
|
||||
const (
|
||||
// RenderFlat uses no borders or decorations.
|
||||
RenderFlat RenderStyle = iota
|
||||
// RenderSimple uses --- separators between sections.
|
||||
RenderSimple
|
||||
// RenderBoxed uses Unicode box drawing characters.
|
||||
RenderBoxed
|
||||
)
|
||||
|
||||
var currentRenderStyle = RenderFlat
|
||||
|
||||
// UseRenderFlat sets the render style to flat (no borders).
|
||||
//
|
||||
// cli.UseRenderFlat()
|
||||
func UseRenderFlat() { currentRenderStyle = RenderFlat }
|
||||
|
||||
// UseRenderSimple sets the render style to simple (--- separators).
|
||||
//
|
||||
// cli.UseRenderSimple()
|
||||
func UseRenderFlat() { currentRenderStyle = RenderFlat }
|
||||
func UseRenderSimple() { currentRenderStyle = RenderSimple }
|
||||
|
||||
// UseRenderBoxed sets the render style to boxed (Unicode box drawing).
|
||||
//
|
||||
// cli.UseRenderBoxed()
|
||||
func UseRenderBoxed() { currentRenderStyle = RenderBoxed }
|
||||
func UseRenderBoxed() { currentRenderStyle = RenderBoxed }
|
||||
|
||||
// Render outputs the layout to terminal.
|
||||
func (c *Composite) Render() {
|
||||
|
|
@ -85,7 +68,7 @@ func (c *Composite) renderSeparator(sb *strings.Builder, depth int) {
|
|||
func (c *Composite) renderSlot(sb *strings.Builder, slot *Slot, depth int) {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
for _, block := range slot.blocks {
|
||||
for _, line := range strings.Split(block.Render(), "\n") {
|
||||
for _, line := range core.Split(block.Render(), "\n") {
|
||||
if line != "" {
|
||||
sb.WriteString(indent + line + "\n")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
|
|
@ -75,14 +76,14 @@ func (s *Stream) Write(text string) {
|
|||
|
||||
for _, r := range text {
|
||||
if r == '\n' {
|
||||
fmt.Fprintln(s.out)
|
||||
core.Print(s.out, "")
|
||||
s.col = 0
|
||||
continue
|
||||
}
|
||||
|
||||
rw := runewidth.RuneWidth(r)
|
||||
if rw > 0 && s.col > 0 && s.col+rw > s.wrap {
|
||||
fmt.Fprintln(s.out)
|
||||
core.Print(s.out, "")
|
||||
s.col = 0
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +114,7 @@ func (s *Stream) Done() {
|
|||
s.once.Do(func() {
|
||||
s.mu.Lock()
|
||||
if s.col > 0 {
|
||||
fmt.Fprintln(s.out) // ensure trailing newline
|
||||
core.Print(s.out, "") // ensure trailing newline
|
||||
}
|
||||
s.mu.Unlock()
|
||||
close(s.done)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
|
@ -49,7 +50,6 @@ const (
|
|||
ColourGray900 = "#111827"
|
||||
)
|
||||
|
||||
// Core styles
|
||||
var (
|
||||
SuccessStyle = NewStyle().Bold().Foreground(ColourGreen500)
|
||||
ErrorStyle = NewStyle().Bold().Foreground(ColourRed500)
|
||||
|
|
@ -70,7 +70,6 @@ var (
|
|||
RepoStyle = NewStyle().Bold().Foreground(ColourBlue500)
|
||||
)
|
||||
|
||||
// Truncate shortens a string to max length with ellipsis.
|
||||
func Truncate(s string, max int) string {
|
||||
if max <= 0 || s == "" {
|
||||
return ""
|
||||
|
|
@ -84,7 +83,6 @@ func Truncate(s string, max int) string {
|
|||
return truncateByWidth(s, max-3) + "..."
|
||||
}
|
||||
|
||||
// Pad right-pads a string to width.
|
||||
func Pad(s string, width int) string {
|
||||
if displayWidth(s) >= width {
|
||||
return s
|
||||
|
|
@ -100,12 +98,10 @@ func truncateByWidth(s string, max int) string {
|
|||
if max <= 0 || s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
plain := ansi.Strip(s)
|
||||
if displayWidth(plain) <= max {
|
||||
return plain
|
||||
}
|
||||
|
||||
var (
|
||||
width int
|
||||
out strings.Builder
|
||||
|
|
@ -121,50 +117,39 @@ func truncateByWidth(s string, max int) string {
|
|||
return out.String()
|
||||
}
|
||||
|
||||
// FormatAge formats a time as human-readable age (e.g., "2h ago", "3d ago").
|
||||
func FormatAge(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
return core.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
return core.Sprintf("%dh ago", int(d.Hours()))
|
||||
case d < 7*24*time.Hour:
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||
return core.Sprintf("%dd ago", int(d.Hours()/24))
|
||||
case d < 30*24*time.Hour:
|
||||
return fmt.Sprintf("%dw ago", int(d.Hours()/(24*7)))
|
||||
return core.Sprintf("%dw ago", int(d.Hours()/(24*7)))
|
||||
default:
|
||||
return fmt.Sprintf("%dmo ago", int(d.Hours()/(24*30)))
|
||||
return core.Sprintf("%dmo ago", int(d.Hours()/(24*30)))
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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
|
||||
tl, tr, bl, br string
|
||||
h, v string
|
||||
tt, bt, lt, rt string
|
||||
x string
|
||||
}
|
||||
|
||||
var borderSets = map[BorderStyle]borderSet{
|
||||
|
|
@ -181,25 +166,8 @@ var borderSetsASCII = map[BorderStyle]borderSet{
|
|||
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.
|
||||
// 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 {
|
||||
Headers []string
|
||||
Rows [][]string
|
||||
|
|
@ -209,44 +177,24 @@ type Table struct {
|
|||
maxWidth int
|
||||
}
|
||||
|
||||
// TableStyle configures the appearance of table output.
|
||||
type TableStyle struct {
|
||||
HeaderStyle *AnsiStyle
|
||||
CellStyle *AnsiStyle
|
||||
Separator string
|
||||
}
|
||||
|
||||
// DefaultTableStyle returns sensible defaults.
|
||||
func DefaultTableStyle() TableStyle {
|
||||
return TableStyle{
|
||||
HeaderStyle: HeaderStyle,
|
||||
CellStyle: nil,
|
||||
Separator: " ",
|
||||
}
|
||||
return TableStyle{HeaderStyle: HeaderStyle, CellStyle: nil, Separator: " "}
|
||||
}
|
||||
|
||||
// NewTable creates a table with headers.
|
||||
func NewTable(headers ...string) *Table {
|
||||
return &Table{
|
||||
Headers: headers,
|
||||
Style: DefaultTableStyle(),
|
||||
}
|
||||
return &Table{Headers: headers, Style: DefaultTableStyle()}
|
||||
}
|
||||
|
||||
// AddRow adds a row to the table.
|
||||
func (t *Table) AddRow(cells ...string) *Table {
|
||||
t.Rows = append(t.Rows, cells)
|
||||
return t
|
||||
}
|
||||
func (t *Table) AddRow(cells ...string) *Table { t.Rows = append(t.Rows, cells); return t }
|
||||
|
||||
// WithBorders enables box-drawing borders on the table.
|
||||
func (t *Table) WithBorders(style BorderStyle) *Table {
|
||||
t.borders = style
|
||||
return t
|
||||
}
|
||||
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)
|
||||
|
|
@ -255,25 +203,18 @@ func (t *Table) WithCellStyle(col int, fn CellStyleFn) *Table {
|
|||
return t
|
||||
}
|
||||
|
||||
// WithMaxWidth sets the maximum table width, truncating columns to fit.
|
||||
func (t *Table) WithMaxWidth(w int) *Table {
|
||||
t.maxWidth = w
|
||||
return t
|
||||
}
|
||||
func (t *Table) WithMaxWidth(w int) *Table { t.maxWidth = w; return t }
|
||||
|
||||
// String renders the table.
|
||||
func (t *Table) String() string {
|
||||
if len(t.Headers) == 0 && len(t.Rows) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if t.borders != BorderNone {
|
||||
return t.renderBordered()
|
||||
}
|
||||
return t.renderPlain()
|
||||
}
|
||||
|
||||
// Render prints the table to stdout.
|
||||
func (t *Table) Render() {
|
||||
fmt.Fprint(stdoutWriter(), t.String())
|
||||
}
|
||||
|
|
@ -289,7 +230,6 @@ func (t *Table) colCount() int {
|
|||
func (t *Table) columnWidths() []int {
|
||||
cols := t.colCount()
|
||||
widths := make([]int, cols)
|
||||
|
||||
for i, h := range t.Headers {
|
||||
if w := displayWidth(compileGlyphs(h)); w > widths[i] {
|
||||
widths[i] = w
|
||||
|
|
@ -304,7 +244,6 @@ func (t *Table) columnWidths() []int {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if t.maxWidth > 0 {
|
||||
t.constrainWidths(widths)
|
||||
}
|
||||
|
|
@ -315,23 +254,17 @@ 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 := max(t.maxWidth-overhead, cols)
|
||||
for total-overhead > budget {
|
||||
maxIdx, maxW := 0, 0
|
||||
|
|
@ -358,10 +291,8 @@ func (t *Table) resolveStyle(col int, value string) *AnsiStyle {
|
|||
|
||||
func (t *Table) renderPlain() string {
|
||||
widths := t.columnWidths()
|
||||
|
||||
var sb strings.Builder
|
||||
sep := t.Style.Separator
|
||||
|
||||
if len(t.Headers) > 0 {
|
||||
for i, h := range t.Headers {
|
||||
if i > 0 {
|
||||
|
|
@ -375,7 +306,6 @@ func (t *Table) renderPlain() string {
|
|||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
|
||||
for _, row := range t.Rows {
|
||||
for i := range t.colCount() {
|
||||
if i > 0 {
|
||||
|
|
@ -393,7 +323,6 @@ func (t *Table) renderPlain() string {
|
|||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
|
|
@ -401,10 +330,7 @@ func (t *Table) renderBordered() string {
|
|||
b := tableBorderSet(t.borders)
|
||||
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))
|
||||
|
|
@ -414,8 +340,6 @@ func (t *Table) renderBordered() string {
|
|||
}
|
||||
sb.WriteString(b.tr)
|
||||
sb.WriteByte('\n')
|
||||
|
||||
// Header row
|
||||
if len(t.Headers) > 0 {
|
||||
sb.WriteString(b.v)
|
||||
for i := range cols {
|
||||
|
|
@ -433,8 +357,6 @@ func (t *Table) renderBordered() string {
|
|||
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))
|
||||
|
|
@ -445,8 +367,6 @@ func (t *Table) renderBordered() string {
|
|||
sb.WriteString(b.rt)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
|
||||
// Data rows
|
||||
for _, row := range t.Rows {
|
||||
sb.WriteString(b.v)
|
||||
for i := range cols {
|
||||
|
|
@ -465,8 +385,6 @@ func (t *Table) renderBordered() string {
|
|||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
|
||||
// Bottom border: ╰──────┴──────╯
|
||||
sb.WriteString(b.bl)
|
||||
for i := range cols {
|
||||
sb.WriteString(strings.Repeat(b.h, widths[i]+2))
|
||||
|
|
@ -476,7 +394,6 @@ func (t *Table) renderBordered() string {
|
|||
}
|
||||
sb.WriteString(b.br)
|
||||
sb.WriteByte('\n')
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,14 +9,13 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Spinner frames for the live tracker.
|
||||
var spinnerFramesUnicode = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
var spinnerFramesASCII = []string{"-", "\\", "|", "/"}
|
||||
|
||||
// taskState tracks the lifecycle of a tracked task.
|
||||
type taskState int
|
||||
|
||||
const (
|
||||
|
|
@ -26,8 +25,6 @@ const (
|
|||
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
|
||||
|
|
@ -36,7 +33,6 @@ type TrackedTask struct {
|
|||
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
|
||||
|
|
@ -44,7 +40,6 @@ func (t *TrackedTask) Update(status string) {
|
|||
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
|
||||
|
|
@ -52,7 +47,6 @@ func (t *TrackedTask) Done(message string) {
|
|||
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
|
||||
|
|
@ -66,18 +60,6 @@ func (t *TrackedTask) snapshot() (string, string, taskState) {
|
|||
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
|
||||
|
|
@ -85,14 +67,12 @@ type TaskTracker struct {
|
|||
started bool
|
||||
}
|
||||
|
||||
// Tasks returns an iterator over the tasks in the tracker.
|
||||
func (tr *TaskTracker) Tasks() iter.Seq[*TrackedTask] {
|
||||
return func(yield func(*TrackedTask) bool) {
|
||||
tr.mu.Lock()
|
||||
tasks := make([]*TrackedTask, len(tr.tasks))
|
||||
copy(tasks, tr.tasks)
|
||||
tr.mu.Unlock()
|
||||
|
||||
for _, t := range tasks {
|
||||
if !yield(t) {
|
||||
return
|
||||
|
|
@ -101,14 +81,12 @@ func (tr *TaskTracker) Tasks() iter.Seq[*TrackedTask] {
|
|||
}
|
||||
}
|
||||
|
||||
// Snapshots returns an iterator over snapshots of tasks in the tracker.
|
||||
func (tr *TaskTracker) Snapshots() iter.Seq2[string, string] {
|
||||
return func(yield func(string, string) bool) {
|
||||
tr.mu.Lock()
|
||||
tasks := make([]*TrackedTask, len(tr.tasks))
|
||||
copy(tasks, tr.tasks)
|
||||
tr.mu.Unlock()
|
||||
|
||||
for _, t := range tasks {
|
||||
name, status, _ := t.snapshot()
|
||||
if !yield(name, status) {
|
||||
|
|
@ -118,13 +96,10 @@ func (tr *TaskTracker) Snapshots() iter.Seq2[string, string] {
|
|||
}
|
||||
}
|
||||
|
||||
// NewTaskTracker creates a new parallel task tracker.
|
||||
func NewTaskTracker() *TaskTracker {
|
||||
return &TaskTracker{out: stderrWriter()}
|
||||
}
|
||||
|
||||
// WithOutput sets the destination writer for tracker output.
|
||||
// Pass nil to keep the current writer unchanged.
|
||||
func (tr *TaskTracker) WithOutput(out io.Writer) *TaskTracker {
|
||||
if out != nil {
|
||||
tr.out = out
|
||||
|
|
@ -132,23 +107,14 @@ func (tr *TaskTracker) WithOutput(out io.Writer) *TaskTracker {
|
|||
return tr
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
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()
|
||||
|
|
@ -165,7 +131,6 @@ func (tr *TaskTracker) isTTY() bool {
|
|||
}
|
||||
|
||||
func (tr *TaskTracker) waitStatic() {
|
||||
// Non-TTY: print final state of each task when it completes.
|
||||
reported := make(map[int]bool)
|
||||
for {
|
||||
tr.mu.Lock()
|
||||
|
|
@ -203,7 +168,6 @@ func (tr *TaskTracker) waitLive() {
|
|||
n := len(tr.tasks)
|
||||
tr.mu.Unlock()
|
||||
|
||||
// Print initial lines.
|
||||
frame := 0
|
||||
for i := range n {
|
||||
tr.renderLine(i, frame)
|
||||
|
|
@ -223,7 +187,6 @@ func (tr *TaskTracker) waitLive() {
|
|||
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)
|
||||
|
|
@ -294,7 +257,6 @@ func (tr *TaskTracker) allDone() bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// Summary returns a one-line summary of task results.
|
||||
func (tr *TaskTracker) Summary() string {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
|
|
@ -312,12 +274,11 @@ func (tr *TaskTracker) Summary() string {
|
|||
|
||||
total := len(tr.tasks)
|
||||
if failed > 0 {
|
||||
return fmt.Sprintf("%d/%d passed, %d failed", passed, total, failed)
|
||||
return core.Sprintf("%d/%d passed, %d failed", passed, total, failed)
|
||||
}
|
||||
return fmt.Sprintf("%d/%d passed", passed, total)
|
||||
return core.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
|
||||
|
|
|
|||
|
|
@ -7,56 +7,38 @@ import (
|
|||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Children returns an iterator over the node's children.
|
||||
func (n *TreeNode) Children() iter.Seq[*TreeNode] {
|
||||
return func(yield func(*TreeNode) bool) {
|
||||
for _, child := range n.children {
|
||||
|
|
@ -67,8 +49,6 @@ func (n *TreeNode) Children() iter.Seq[*TreeNode] {
|
|||
}
|
||||
}
|
||||
|
||||
// String renders the tree with box-drawing characters.
|
||||
// Implements fmt.Stringer.
|
||||
func (n *TreeNode) String() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(n.renderLabel())
|
||||
|
|
@ -77,7 +57,6 @@ func (n *TreeNode) String() string {
|
|||
return sb.String()
|
||||
}
|
||||
|
||||
// Render prints the tree to stdout.
|
||||
func (n *TreeNode) Render() {
|
||||
fmt.Fprint(stdoutWriter(), n.String())
|
||||
}
|
||||
|
|
@ -97,19 +76,16 @@ func (n *TreeNode) writeChildren(sb *strings.Builder, prefix string) {
|
|||
|
||||
for i, child := range n.children {
|
||||
last := i == len(n.children)-1
|
||||
|
||||
connector := tee
|
||||
next := pipe
|
||||
if last {
|
||||
connector = corner
|
||||
next = " "
|
||||
}
|
||||
|
||||
sb.WriteString(prefix)
|
||||
sb.WriteString(connector)
|
||||
sb.WriteString(child.renderLabel())
|
||||
sb.WriteByte('\n')
|
||||
|
||||
child.writeChildren(sb, prefix+next)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
253
pkg/cli/utils.go
253
pkg/cli/utils.go
|
|
@ -2,38 +2,29 @@ package cli
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"dappco.re/go/core"
|
||||
"dappco.re/go/core/i18n"
|
||||
"dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// GhAuthenticated checks if the GitHub CLI is authenticated.
|
||||
// Returns true if 'gh auth status' indicates a logged-in user.
|
||||
func GhAuthenticated() bool {
|
||||
cmd := exec.Command("gh", "auth", "status")
|
||||
output, _ := cmd.CombinedOutput()
|
||||
authenticated := strings.Contains(string(output), "Logged in")
|
||||
|
||||
authenticated := core.Contains(string(output), "Logged in")
|
||||
if authenticated {
|
||||
LogWarn("GitHub CLI authenticated", "user", log.Username())
|
||||
} else {
|
||||
LogWarn("GitHub CLI not authenticated", "user", log.Username())
|
||||
}
|
||||
|
||||
return authenticated
|
||||
}
|
||||
|
||||
// ConfirmOption configures Confirm behaviour.
|
||||
//
|
||||
// if cli.Confirm("Proceed?", cli.DefaultYes()) {
|
||||
// cli.Success("continuing")
|
||||
// }
|
||||
type ConfirmOption func(*confirmConfig)
|
||||
|
||||
type confirmConfig struct {
|
||||
|
|
@ -43,50 +34,25 @@ type confirmConfig struct {
|
|||
}
|
||||
|
||||
func promptHint(msg string) {
|
||||
fmt.Fprintln(stderrWriter(), DimStyle.Render(compileGlyphs(msg)))
|
||||
core.Print(stderrWriter(), "%s", DimStyle.Render(compileGlyphs(msg)))
|
||||
}
|
||||
|
||||
func promptWarning(msg string) {
|
||||
fmt.Fprintln(stderrWriter(), WarningStyle.Render(compileGlyphs(msg)))
|
||||
core.Print(stderrWriter(), "%s", WarningStyle.Render(compileGlyphs(msg)))
|
||||
}
|
||||
|
||||
// DefaultYes sets the default response to "yes" (pressing Enter confirms).
|
||||
func DefaultYes() ConfirmOption {
|
||||
return func(c *confirmConfig) {
|
||||
c.defaultYes = true
|
||||
}
|
||||
return func(c *confirmConfig) { c.defaultYes = true }
|
||||
}
|
||||
|
||||
// Required prevents empty responses; user must explicitly type y/n.
|
||||
func Required() ConfirmOption {
|
||||
return func(c *confirmConfig) {
|
||||
c.required = true
|
||||
}
|
||||
return func(c *confirmConfig) { c.required = true }
|
||||
}
|
||||
|
||||
// Timeout sets a timeout after which the default response is auto-selected.
|
||||
// If no default is set (not Required and not DefaultYes), defaults to "no".
|
||||
//
|
||||
// Confirm("Continue?", Timeout(30*time.Second)) // Auto-no after 30s
|
||||
// Confirm("Continue?", DefaultYes(), Timeout(10*time.Second)) // Auto-yes after 10s
|
||||
func Timeout(d time.Duration) ConfirmOption {
|
||||
return func(c *confirmConfig) {
|
||||
c.timeout = d
|
||||
}
|
||||
return func(c *confirmConfig) { c.timeout = d }
|
||||
}
|
||||
|
||||
// Confirm prompts the user for yes/no confirmation.
|
||||
// Returns true if the user enters "y" or "yes" (case-insensitive).
|
||||
//
|
||||
// Basic usage:
|
||||
//
|
||||
// if Confirm("Delete file?") { ... }
|
||||
//
|
||||
// With options:
|
||||
//
|
||||
// if Confirm("Save changes?", DefaultYes()) { ... }
|
||||
// if Confirm("Dangerous!", Required()) { ... }
|
||||
// if Confirm("Auto-continue?", Timeout(30*time.Second)) { ... }
|
||||
func Confirm(prompt string, opts ...ConfirmOption) bool {
|
||||
cfg := &confirmConfig{}
|
||||
for _, opt := range opts {
|
||||
|
|
@ -95,7 +61,6 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
|
|||
|
||||
prompt = compileGlyphs(prompt)
|
||||
|
||||
// Build the prompt suffix
|
||||
var suffix string
|
||||
if cfg.required {
|
||||
suffix = "[y/n] "
|
||||
|
|
@ -105,9 +70,8 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
|
|||
suffix = "[y/N] "
|
||||
}
|
||||
|
||||
// Add timeout indicator if set
|
||||
if cfg.timeout > 0 {
|
||||
suffix = fmt.Sprintf("%s(auto in %s) ", suffix, cfg.timeout.Round(time.Second))
|
||||
suffix = core.Sprintf("%s(auto in %s) ", suffix, cfg.timeout.Round(time.Second))
|
||||
}
|
||||
|
||||
reader := newReader()
|
||||
|
|
@ -119,7 +83,6 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
|
|||
var readErr error
|
||||
|
||||
if cfg.timeout > 0 {
|
||||
// Use timeout-based reading
|
||||
resultChan := make(chan string, 1)
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
|
|
@ -131,9 +94,9 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
|
|||
select {
|
||||
case response = <-resultChan:
|
||||
readErr = <-errChan
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
response = core.Lower(core.Trim(response))
|
||||
case <-time.After(cfg.timeout):
|
||||
fmt.Fprintln(stderrWriter()) // New line after timeout
|
||||
core.Print(stderrWriter(), "")
|
||||
return cfg.defaultYes
|
||||
}
|
||||
} else {
|
||||
|
|
@ -143,10 +106,9 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
|
|||
return cfg.defaultYes
|
||||
}
|
||||
response = line
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
response = core.Lower(core.Trim(response))
|
||||
}
|
||||
|
||||
// Handle empty response
|
||||
if response == "" {
|
||||
if readErr == nil && cfg.required {
|
||||
promptHint("Please enter y or n, then press Enter.")
|
||||
|
|
@ -158,7 +120,6 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
|
|||
return cfg.defaultYes
|
||||
}
|
||||
|
||||
// Check for yes/no responses
|
||||
if response == "y" || response == "yes" {
|
||||
return true
|
||||
}
|
||||
|
|
@ -166,43 +127,28 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// Invalid response
|
||||
if cfg.required {
|
||||
promptHint("Please enter y or n, then press Enter.")
|
||||
continue
|
||||
}
|
||||
|
||||
// Non-required: treat invalid as default
|
||||
return cfg.defaultYes
|
||||
}
|
||||
}
|
||||
|
||||
// ConfirmAction prompts for confirmation of an action using grammar composition.
|
||||
//
|
||||
// if ConfirmAction("delete", "config.yaml") { ... }
|
||||
// if ConfirmAction("save", "changes", DefaultYes()) { ... }
|
||||
func ConfirmAction(verb, subject string, opts ...ConfirmOption) bool {
|
||||
question := i18n.Title(verb) + " " + subject + "?"
|
||||
return Confirm(question, opts...)
|
||||
}
|
||||
|
||||
// ConfirmDangerousAction prompts for double confirmation of a dangerous action.
|
||||
// Shows initial question, then a "Really verb subject?" confirmation.
|
||||
//
|
||||
// if ConfirmDangerousAction("delete", "config.yaml") { ... }
|
||||
func ConfirmDangerousAction(verb, subject string) bool {
|
||||
question := i18n.Title(verb) + " " + subject + "?"
|
||||
if !Confirm(question, Required()) {
|
||||
return false
|
||||
}
|
||||
|
||||
confirm := "Really " + verb + " " + subject + "?"
|
||||
return Confirm(confirm, Required())
|
||||
}
|
||||
|
||||
// QuestionOption configures Question behaviour.
|
||||
//
|
||||
// name := cli.Question("Project name:", cli.WithDefault("my-app"))
|
||||
type QuestionOption func(*questionConfig)
|
||||
|
||||
type questionConfig struct {
|
||||
|
|
@ -211,57 +157,36 @@ type questionConfig struct {
|
|||
validator func(string) error
|
||||
}
|
||||
|
||||
// WithDefault sets the default value shown in brackets.
|
||||
func WithDefault(value string) QuestionOption {
|
||||
return func(c *questionConfig) {
|
||||
c.defaultValue = value
|
||||
}
|
||||
return func(c *questionConfig) { c.defaultValue = value }
|
||||
}
|
||||
|
||||
// WithValidator adds a validation function for the response.
|
||||
func WithValidator(fn func(string) error) QuestionOption {
|
||||
return func(c *questionConfig) {
|
||||
c.validator = fn
|
||||
}
|
||||
return func(c *questionConfig) { c.validator = fn }
|
||||
}
|
||||
|
||||
// RequiredInput prevents empty responses.
|
||||
func RequiredInput() QuestionOption {
|
||||
return func(c *questionConfig) {
|
||||
c.required = true
|
||||
}
|
||||
return func(c *questionConfig) { c.required = true }
|
||||
}
|
||||
|
||||
// Question prompts the user for text input.
|
||||
//
|
||||
// name := Question("Enter your name:")
|
||||
// name := Question("Enter your name:", WithDefault("Anonymous"))
|
||||
// name := Question("Enter your name:", RequiredInput())
|
||||
func Question(prompt string, opts ...QuestionOption) string {
|
||||
cfg := &questionConfig{}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
prompt = compileGlyphs(prompt)
|
||||
|
||||
reader := newReader()
|
||||
|
||||
for {
|
||||
// Build prompt with default
|
||||
if cfg.defaultValue != "" {
|
||||
fmt.Fprintf(stderrWriter(), "%s [%s] ", prompt, compileGlyphs(cfg.defaultValue))
|
||||
} else {
|
||||
fmt.Fprintf(stderrWriter(), "%s ", prompt)
|
||||
}
|
||||
|
||||
response, err := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(response)
|
||||
response = core.Trim(response)
|
||||
if err != nil && response == "" {
|
||||
return cfg.defaultValue
|
||||
}
|
||||
|
||||
// Handle empty response
|
||||
if response == "" {
|
||||
if cfg.required {
|
||||
promptHint("Please enter a value, then press Enter.")
|
||||
|
|
@ -269,100 +194,63 @@ func Question(prompt string, opts ...QuestionOption) string {
|
|||
}
|
||||
response = cfg.defaultValue
|
||||
}
|
||||
|
||||
// Validate if validator provided
|
||||
if cfg.validator != nil {
|
||||
if err := cfg.validator(response); err != nil {
|
||||
promptWarning(fmt.Sprintf("Invalid: %v", err))
|
||||
promptWarning(core.Sprintf("Invalid: %v", err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
// QuestionAction prompts for text input using grammar composition.
|
||||
//
|
||||
// name := QuestionAction("rename", "old.txt")
|
||||
func QuestionAction(verb, subject string, opts ...QuestionOption) string {
|
||||
question := i18n.Title(verb) + " " + subject + "?"
|
||||
return Question(question, opts...)
|
||||
}
|
||||
|
||||
// ChooseOption configures Choose behaviour.
|
||||
//
|
||||
// choice := cli.Choose("Pick one:", items, cli.Display(func(v Item) string {
|
||||
// return v.Name
|
||||
// }))
|
||||
type ChooseOption[T any] func(*chooseConfig[T])
|
||||
|
||||
type chooseConfig[T any] struct {
|
||||
displayFn func(T) string
|
||||
defaultN int // 0-based index of default selection
|
||||
filter bool // Enable type-to-filter selection
|
||||
multi bool // Allow multiple selection
|
||||
defaultN int
|
||||
filter bool
|
||||
multi bool
|
||||
}
|
||||
|
||||
// WithDisplay sets a custom display function for items.
|
||||
func WithDisplay[T any](fn func(T) string) ChooseOption[T] {
|
||||
return func(c *chooseConfig[T]) {
|
||||
c.displayFn = fn
|
||||
}
|
||||
return func(c *chooseConfig[T]) { c.displayFn = fn }
|
||||
}
|
||||
|
||||
// WithDefaultIndex sets the default selection index (0-based).
|
||||
func WithDefaultIndex[T any](idx int) ChooseOption[T] {
|
||||
return func(c *chooseConfig[T]) {
|
||||
c.defaultN = idx
|
||||
}
|
||||
return func(c *chooseConfig[T]) { c.defaultN = idx }
|
||||
}
|
||||
|
||||
// Filter enables type-to-filter functionality.
|
||||
// When enabled, typed text narrows the visible options before selection.
|
||||
func Filter[T any]() ChooseOption[T] {
|
||||
return func(c *chooseConfig[T]) {
|
||||
c.filter = true
|
||||
}
|
||||
return func(c *chooseConfig[T]) { c.filter = true }
|
||||
}
|
||||
|
||||
// Multi allows multiple selections.
|
||||
// Use ChooseMulti instead of Choose when this option is needed.
|
||||
func Multi[T any]() ChooseOption[T] {
|
||||
return func(c *chooseConfig[T]) {
|
||||
c.multi = true
|
||||
}
|
||||
return func(c *chooseConfig[T]) { c.multi = true }
|
||||
}
|
||||
|
||||
// Display sets a custom display function for items.
|
||||
// Alias for WithDisplay for shorter syntax.
|
||||
//
|
||||
// Choose("Select:", items, Display(func(f File) string { return f.Name }))
|
||||
func Display[T any](fn func(T) string) ChooseOption[T] {
|
||||
return WithDisplay[T](fn)
|
||||
}
|
||||
|
||||
// Choose prompts the user to select from a list of items.
|
||||
// Returns the selected item. Uses simple numbered selection for terminal compatibility.
|
||||
//
|
||||
// choice := Choose("Select a file:", files)
|
||||
// choice := Choose("Select a file:", files, WithDisplay(func(f File) string { return f.Name }))
|
||||
func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
|
||||
var zero T
|
||||
if len(items) == 0 {
|
||||
return zero
|
||||
}
|
||||
|
||||
cfg := &chooseConfig[T]{
|
||||
displayFn: func(item T) string { return fmt.Sprint(item) },
|
||||
displayFn: func(item T) string { return core.Sprint(item) },
|
||||
defaultN: -1,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
prompt = compileGlyphs(prompt)
|
||||
|
||||
reader := newReader()
|
||||
visible := make([]int, len(items))
|
||||
for i := range items {
|
||||
|
|
@ -372,15 +260,13 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
|
|||
|
||||
for {
|
||||
renderChoices(prompt, items, visible, cfg.displayFn, cfg.defaultN, cfg.filter)
|
||||
|
||||
if cfg.filter {
|
||||
fmt.Fprintf(stderrWriter(), "Enter number [1-%d] or filter: ", len(visible))
|
||||
} else {
|
||||
fmt.Fprintf(stderrWriter(), "Enter number [1-%d]: ", len(visible))
|
||||
}
|
||||
response, err := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
response = core.Trim(response)
|
||||
if err != nil && response == "" {
|
||||
if idx, ok := defaultVisibleIndex(visible, cfg.defaultN); ok {
|
||||
return items[idx]
|
||||
|
|
@ -388,7 +274,6 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
|
|||
var zero T
|
||||
return zero
|
||||
}
|
||||
|
||||
if response == "" {
|
||||
if cfg.filter && len(visible) != len(allVisible) {
|
||||
visible = append([]int(nil), allVisible...)
|
||||
|
|
@ -402,106 +287,78 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
|
|||
promptHint("Default selection is not available in the current list. Narrow the list or choose another number.")
|
||||
continue
|
||||
}
|
||||
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible)))
|
||||
promptHint(core.Sprintf("Please enter a number between 1 and %d.", len(visible)))
|
||||
continue
|
||||
}
|
||||
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(response, "%d", &n); err == nil {
|
||||
if n >= 1 && n <= len(visible) {
|
||||
return items[visible[n-1]]
|
||||
}
|
||||
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible)))
|
||||
promptHint(core.Sprintf("Please enter a number between 1 and %d.", len(visible)))
|
||||
continue
|
||||
}
|
||||
|
||||
if cfg.filter {
|
||||
nextVisible := filterVisible(items, visible, response, cfg.displayFn)
|
||||
if len(nextVisible) == 0 {
|
||||
promptHint(fmt.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response))
|
||||
promptHint(core.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response))
|
||||
continue
|
||||
}
|
||||
visible = nextVisible
|
||||
continue
|
||||
}
|
||||
|
||||
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible)))
|
||||
promptHint(core.Sprintf("Please enter a number between 1 and %d.", len(visible)))
|
||||
}
|
||||
}
|
||||
|
||||
// ChooseAction prompts for selection using grammar composition.
|
||||
//
|
||||
// file := ChooseAction("select", "file", files)
|
||||
func ChooseAction[T any](verb, subject string, items []T, opts ...ChooseOption[T]) T {
|
||||
question := i18n.Title(verb) + " " + subject + ":"
|
||||
return Choose(question, items, opts...)
|
||||
}
|
||||
|
||||
// ChooseMulti prompts the user to select multiple items from a list.
|
||||
// Returns the selected items. Uses space-separated numbers or ranges.
|
||||
//
|
||||
// choices := ChooseMulti("Select files:", files)
|
||||
// choices := ChooseMulti("Select files:", files, WithDisplay(func(f File) string { return f.Name }))
|
||||
//
|
||||
// Input format:
|
||||
// - "1 3 5" - select items 1, 3, and 5
|
||||
// - "1-3" - select items 1, 2, and 3
|
||||
// - "1 3-5" - select items 1, 3, 4, and 5
|
||||
// - "" (empty) - select none
|
||||
func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := &chooseConfig[T]{
|
||||
displayFn: func(item T) string { return fmt.Sprint(item) },
|
||||
displayFn: func(item T) string { return core.Sprint(item) },
|
||||
defaultN: -1,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
prompt = compileGlyphs(prompt)
|
||||
|
||||
reader := newReader()
|
||||
visible := make([]int, len(items))
|
||||
for i := range items {
|
||||
visible[i] = i
|
||||
}
|
||||
|
||||
for {
|
||||
renderChoices(prompt, items, visible, cfg.displayFn, -1, cfg.filter)
|
||||
|
||||
if cfg.filter {
|
||||
fmt.Fprint(stderrWriter(), "Enter numbers (e.g., 1 3 5 or 1-3), or filter text, or empty for none: ")
|
||||
} else {
|
||||
fmt.Fprint(stderrWriter(), "Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
|
||||
}
|
||||
response, _ := reader.ReadString('\n')
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
// Empty response returns no selections.
|
||||
response = core.Trim(response)
|
||||
if response == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the selection.
|
||||
selected, err := parseMultiSelection(response, len(visible))
|
||||
if err != nil {
|
||||
if cfg.filter && !looksLikeMultiSelectionInput(response) {
|
||||
nextVisible := filterVisible(items, visible, response, cfg.displayFn)
|
||||
if len(nextVisible) == 0 {
|
||||
promptHint(fmt.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response))
|
||||
promptHint(core.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response))
|
||||
continue
|
||||
}
|
||||
visible = nextVisible
|
||||
continue
|
||||
}
|
||||
promptWarning(fmt.Sprintf("Invalid selection %q: enter numbers like 1 3 or 1-3.", response))
|
||||
promptWarning(core.Sprintf("Invalid selection %q: enter numbers like 1 3 or 1-3.", response))
|
||||
continue
|
||||
}
|
||||
|
||||
// Build result
|
||||
result := make([]T, 0, len(selected))
|
||||
for _, idx := range selected {
|
||||
result = append(result, items[visible[idx]])
|
||||
|
|
@ -511,7 +368,7 @@ func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
|
|||
}
|
||||
|
||||
func renderChoices[T any](prompt string, items []T, visible []int, displayFn func(T) string, defaultN int, filter bool) {
|
||||
fmt.Fprintln(stderrWriter(), prompt)
|
||||
core.Print(stderrWriter(), "%s", prompt)
|
||||
for i, idx := range visible {
|
||||
marker := " "
|
||||
if defaultN >= 0 && idx == defaultN {
|
||||
|
|
@ -520,7 +377,7 @@ func renderChoices[T any](prompt string, items []T, visible []int, displayFn fun
|
|||
fmt.Fprintf(stderrWriter(), " %s%d. %s\n", marker, i+1, compileGlyphs(displayFn(items[idx])))
|
||||
}
|
||||
if filter {
|
||||
fmt.Fprintln(stderrWriter(), " (type to filter the list)")
|
||||
core.Print(stderrWriter(), " (type to filter the list)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -537,14 +394,13 @@ func defaultVisibleIndex(visible []int, defaultN int) (int, bool) {
|
|||
}
|
||||
|
||||
func filterVisible[T any](items []T, visible []int, query string, displayFn func(T) string) []int {
|
||||
q := strings.ToLower(strings.TrimSpace(query))
|
||||
q := core.Lower(core.Trim(query))
|
||||
if q == "" {
|
||||
return visible
|
||||
}
|
||||
|
||||
filtered := make([]int, 0, len(visible))
|
||||
for _, idx := range visible {
|
||||
if strings.Contains(strings.ToLower(displayFn(items[idx])), q) {
|
||||
if core.Contains(core.Lower(displayFn(items[idx])), q) {
|
||||
filtered = append(filtered, idx)
|
||||
}
|
||||
}
|
||||
|
|
@ -566,17 +422,11 @@ func looksLikeMultiSelectionInput(input string) bool {
|
|||
return hasDigit
|
||||
}
|
||||
|
||||
// parseMultiSelection parses a multi-selection string like "1 3 5", "1,3,5",
|
||||
// or "1-3 5".
|
||||
// Returns 0-based indices.
|
||||
func parseMultiSelection(input string, maxItems int) ([]int, error) {
|
||||
selected := make(map[int]bool)
|
||||
|
||||
normalized := strings.NewReplacer(",", " ").Replace(input)
|
||||
|
||||
for part := range strings.FieldsSeq(normalized) {
|
||||
// Check for range (e.g., "1-3")
|
||||
if strings.Contains(part, "-") {
|
||||
if core.Contains(part, "-") {
|
||||
var rangeParts []string
|
||||
for p := range strings.SplitSeq(part, "-") {
|
||||
rangeParts = append(rangeParts, p)
|
||||
|
|
@ -595,10 +445,9 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) {
|
|||
return nil, Err("range out of bounds: %s", part)
|
||||
}
|
||||
for i := start; i <= end; i++ {
|
||||
selected[i-1] = true // Convert to 0-based
|
||||
selected[i-1] = true
|
||||
}
|
||||
} else {
|
||||
// Single number
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(part, "%d", &n); err != nil {
|
||||
return nil, Err("invalid number: %s", part)
|
||||
|
|
@ -606,11 +455,9 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) {
|
|||
if n < 1 || n > maxItems {
|
||||
return nil, Err("number out of range: %d", n)
|
||||
}
|
||||
selected[n-1] = true // Convert to 0-based
|
||||
selected[n-1] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to sorted slice
|
||||
result := make([]int, 0, len(selected))
|
||||
for i := range maxItems {
|
||||
if selected[i] {
|
||||
|
|
@ -620,25 +467,18 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) {
|
|||
return result, nil
|
||||
}
|
||||
|
||||
// ChooseMultiAction prompts for multiple selections using grammar composition.
|
||||
//
|
||||
// files := ChooseMultiAction("select", "files", files)
|
||||
func ChooseMultiAction[T any](verb, subject string, items []T, opts ...ChooseOption[T]) []T {
|
||||
question := i18n.Title(verb) + " " + subject + ":"
|
||||
return ChooseMulti(question, items, opts...)
|
||||
}
|
||||
|
||||
// GitClone clones a GitHub repository to the specified path.
|
||||
// Prefers 'gh repo clone' if authenticated, falls back to SSH.
|
||||
func GitClone(ctx context.Context, org, repo, path string) error {
|
||||
return GitCloneRef(ctx, org, repo, path, "")
|
||||
}
|
||||
|
||||
// GitCloneRef clones a GitHub repository at a specific ref to the specified path.
|
||||
// Prefers 'gh repo clone' if authenticated, falls back to SSH.
|
||||
func GitCloneRef(ctx context.Context, org, repo, path, ref string) error {
|
||||
if GhAuthenticated() {
|
||||
httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo)
|
||||
httpsURL := core.Sprintf("https://github.com/%s/%s.git", org, repo)
|
||||
args := []string{"repo", "clone", httpsURL, path}
|
||||
if ref != "" {
|
||||
args = append(args, "--", "--branch", ref, "--single-branch")
|
||||
|
|
@ -648,21 +488,20 @@ func GitCloneRef(ctx context.Context, org, repo, path, ref string) error {
|
|||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
errStr := strings.TrimSpace(string(output))
|
||||
if strings.Contains(errStr, "already exists") {
|
||||
return errors.New(errStr)
|
||||
errStr := core.Trim(string(output))
|
||||
if core.Contains(errStr, "already exists") {
|
||||
return core.NewError(errStr)
|
||||
}
|
||||
}
|
||||
// Fall back to SSH clone
|
||||
args := []string{"clone"}
|
||||
if ref != "" {
|
||||
args = append(args, "--branch", ref, "--single-branch")
|
||||
}
|
||||
args = append(args, fmt.Sprintf("git@github.com:%s/%s.git", org, repo), path)
|
||||
args = append(args, core.Sprintf("git@github.com:%s/%s.git", org, repo), path)
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return errors.New(strings.TrimSpace(string(output)))
|
||||
return core.NewError(core.Trim(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue