Skip to content

Commit

Permalink
interactive task picker when none specified (#109)
Browse files Browse the repository at this point in the history
* WIP: interactive

* print tasks if short is enabled

* lints

* chore:  update docs
  • Loading branch information
joerdav authored Dec 21, 2023
1 parent 09e7548 commit c7f36da
Show file tree
Hide file tree
Showing 8 changed files with 223 additions and 12 deletions.
141 changes: 141 additions & 0 deletions cmd/xc/interactive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package main

import (
"context"
"fmt"
"io"
"strings"

"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/joerdav/xc/models"
"github.com/joerdav/xc/run"
)

var (
titleStyle = lipgloss.NewStyle().MarginLeft(titleMargin)
itemStyle = lipgloss.NewStyle().PaddingLeft(itemPadding)
selectedItemStyle = lipgloss.NewStyle().PaddingLeft(selectedItemPadding).Foreground(lipgloss.Color("170"))
paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(paginationPadding)
helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(helpPadding).PaddingBottom(1)
)

const (
titleMargin = 2
itemPadding = 4
selectedItemPadding = 2
paginationPadding = 4
helpPadding = 4
listItemWidth = 20
listItemHeight = 6
)

type taskItem struct {
models.Task
}

func (ti taskItem) FilterValue() string {
return ti.Name
}

type itemDelegate struct{}

func (d itemDelegate) Height() int { return 1 }
func (d itemDelegate) Spacing() int { return 0 }
func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
i, ok := listItem.(taskItem)
if !ok {
return
}

str := i.Name

fn := itemStyle.Render
if index == m.Index() {
fn = func(s ...string) string {
return selectedItemStyle.Render("> " + strings.Join(s, " "))
}
}

fmt.Fprint(w, fn(str))
}

type model struct {
list list.Model
choice *models.Task
quitting bool
}

func (m model) Init() tea.Cmd {
return nil
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.list.SetWidth(msg.Width)
return m, nil

case tea.KeyMsg:
switch keypress := msg.String(); keypress {
case "ctrl+c", "q":
m.quitting = true
return m, tea.Quit

case "enter":
i, ok := m.list.SelectedItem().(taskItem)
if ok {
m.choice = &i.Task
}
m.quitting = true
return m, tea.Quit
}
}

var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}

func (m model) View() string {
if m.quitting {
return ""
}
return "\n" + m.list.View()
}

func interactivePicker(ctx context.Context, tasks []models.Task, dir string) error {
var items []list.Item
for _, t := range tasks {
items = append(items, taskItem{t})
}
l := list.New(items, itemDelegate{}, listItemWidth, listItemHeight+len(tasks))
l.Title = "xc: Choose a task"
l.SetShowStatusBar(false)
l.DisableQuitKeybindings()
l.SetFilteringEnabled(true)
l.Styles.Title = titleStyle
l.Styles.PaginationStyle = paginationStyle
l.Styles.HelpStyle = helpStyle

m := model{list: l}
tm, err := tea.NewProgram(m).Run()
if err != nil {
return err
}
task := tm.(model).choice
if task == nil {
return nil
}
runner, err := run.NewRunner(tasks, dir)
if err != nil {
return fmt.Errorf("xc parse error: %w", err)
}
err = runner.Run(ctx, task.Name, nil)
if err != nil {
return fmt.Errorf("xc: %w", err)
}
return nil
}
18 changes: 14 additions & 4 deletions cmd/xc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ var usage string
var ErrNoMarkdownFile = errors.New("no xc compatible markdown file found")

type config struct {
version, help, short, display, complete, uncomplete bool
filename, heading string
version, help, short, display, noTTY, complete, uncomplete bool
filename, heading string
}

var version = ""
Expand Down Expand Up @@ -70,6 +70,9 @@ func flags() config {

flag.BoolVar(&cfg.complete, "complete", false, "install shell completion for xc")
flag.BoolVar(&cfg.uncomplete, "uncomplete", false, "uninstall shell completion for xc")

flag.BoolVar(&cfg.noTTY, "no-tty", false, "disable interactive picker")

flag.Parse()
return cfg
}
Expand Down Expand Up @@ -139,6 +142,14 @@ func printTasks(tasks models.Tasks, short bool) {
}
}

func displayAndRunTasks(ctx context.Context, tasks models.Tasks, dir string, cfg config) error {
if cfg.noTTY || cfg.short {
printTasks(tasks, cfg.short)
return nil
}
return interactivePicker(ctx, tasks, dir)
}

func printTask(task models.Task, maxLen int) {
padLen := maxLen - len(task.Name)
pad := strings.Repeat(" ", padLen)
Expand Down Expand Up @@ -189,8 +200,7 @@ func runMain() error {
tav := flag.Args()
// xc
if len(tav) == 0 {
printTasks(tasks, cfg.short)
return nil
return displayAndRunTasks(ctx, tasks, dir, cfg)
}
ta, ok := tasks.Get(tav[0])
if !ok {
Expand Down
4 changes: 3 additions & 1 deletion cmd/xc/usage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ xc <task> [inputs...]
Specify the heading for xc tasks (default: "Tasks").

xc
List tasks from an xc-compatible markdown file.
Interactive picker for xc tasks.
If -file is not specified and no README.md is found in the current directory,
xc will search in parent directories for convenience.
-s -short
List task names in a short format.
-no-tty
Disable interactive mode.
-h -help
Print this help text.
-f -file <string>
Expand Down
21 changes: 19 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,33 @@ module github.com/joerdav/xc
go 1.20

require (
github.com/charmbracelet/bubbles v0.16.1
github.com/charmbracelet/bubbletea v0.24.2
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/posener/complete/v2 v2.0.1-alpha.13
mvdan.cc/sh/v3 v3.6.0
)

require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/lipgloss v0.7.1 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.1 // indirect
github.com/posener/script v1.1.5 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/sahilm/fuzzy v0.1.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.3.0 // indirect
golang.org/x/term v0.3.0 // indirect
golang.org/x/sys v0.6.0 // indirect
golang.org/x/term v0.6.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
41 changes: 41 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY=
github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc=
github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY=
github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg=
github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand All @@ -13,6 +25,23 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs=
github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
Expand All @@ -21,16 +50,28 @@ github.com/posener/complete/v2 v2.0.1-alpha.13 h1:xmeDzuCWM0E+hSq6hv4gZQBmiw+uz0
github.com/posener/complete/v2 v2.0.1-alpha.13/go.mod h1:+ndzg+QjkR+oKXdpgsPCdZTg67phWqV1atTotlxuyDg=
github.com/posener/script v1.1.5 h1:su+9YHNlevT+Hlq2Xul5skh5kYDIBE+x4xu+5mLDT9o=
github.com/posener/script v1.1.5/go.mod h1:Rg3ijooqulo05aGLyGsHoLmIOUzHUVK19WVgrYBPU/E=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
Expand Down
4 changes: 2 additions & 2 deletions run/interpreter.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func newInterpreter() interpreter {
}

func (i interpreter) Execute(
ctx context.Context, script string, env []string, args []string, dir, logPrefix string,
ctx context.Context, script string, env, args []string, dir, logPrefix string,
) error {
interpreterCmd, interpreterArgs, text, ok := parseShebang(script)
if !ok {
Expand Down Expand Up @@ -83,7 +83,7 @@ func (i interpreter) executeShebang(
}

func (i interpreter) executeShell(
ctx context.Context, text string, env []string, args []string, dir, logPrefix string,
ctx context.Context, text string, env, args []string, dir, logPrefix string,
) error {
if shellShebangRe.MatchString(text) {
text = strings.Join(strings.Split(text, "\n")[1:], "\n")
Expand Down
4 changes: 2 additions & 2 deletions run/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
const maxDeps = 50

type ScriptRunner interface {
Execute(ctx context.Context, text string, env []string, args []string, dir, logPrefix string) error
Execute(ctx context.Context, text string, env, args []string, dir, logPrefix string) error
}

// Runner is responsible for running Tasks.
Expand Down Expand Up @@ -78,7 +78,7 @@ func environmentContainsInput(env []string, input string) bool {
return false
}

func getInputs(task models.Task, inputs []string, env []string) ([]string, error) {
func getInputs(task models.Task, inputs, env []string) ([]string, error) {
result := []string{}
for i, n := range task.Inputs {
// Do the command args contain the input?
Expand Down
2 changes: 1 addition & 1 deletion run/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type mockScriptRunner struct {
}

func (r *mockScriptRunner) Execute(
ctx context.Context, text string, env []string, args []string, dir, logPrefix string,
ctx context.Context, text string, env, args []string, dir, logPrefix string,
) error {
r.calls++
return r.returns
Expand Down

0 comments on commit c7f36da

Please sign in to comment.