diff --git a/Taskfile.yml b/Taskfile.yml index 88434a535b..4fd166b5d3 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -144,13 +144,13 @@ tasks: desc: Runs test suite with watch tests included deps: [sleepit:build, gotestsum:install] cmds: - - gotestsum -f '{{.GOTESTSUM_FORMAT}}' ./... -tags 'watch' + - gotestsum -f '{{.GOTESTSUM_FORMAT}}' -- -tags 'watch' ./... test:all: desc: Runs test suite with signals and watch tests included deps: [sleepit:build, gotestsum:install] cmds: - - gotestsum -f '{{.GOTESTSUM_FORMAT}}' -tags 'signals watch' ./... + - gotestsum -f '{{.GOTESTSUM_FORMAT}}' -- -tags 'signals watch' ./... bench:checksum: desc: Runs checksum benchmarks diff --git a/call.go b/call.go index a0b357185c..0c077ee2ec 100644 --- a/call.go +++ b/call.go @@ -8,4 +8,8 @@ type Call struct { Vars *ast.Vars Silent bool Indirect bool // True if the task was called by another task + + invocationID uint64 + parentInvocationID uint64 + rootInvocationID uint64 } diff --git a/cmd/task/task.go b/cmd/task/task.go index b81e23dd5f..47c49f6771 100644 --- a/cmd/task/task.go +++ b/cmd/task/task.go @@ -16,6 +16,7 @@ import ( "github.com/go-task/task/v3/internal/filepathext" "github.com/go-task/task/v3/internal/flags" "github.com/go-task/task/v3/internal/logger" + "github.com/go-task/task/v3/internal/tui" "github.com/go-task/task/v3/internal/version" "github.com/go-task/task/v3/taskfile/ast" ) @@ -168,7 +169,7 @@ func run() error { calls, globals := args.Parse(cliArgsPreDash...) // If there are no calls, run the default task instead - if len(calls) == 0 { + if len(calls) == 0 && !flags.TUI { calls = append(calls, &task.Call{Task: "default"}) } @@ -198,6 +199,16 @@ func run() error { if flags.Status { return e.Status(ctx, calls...) } + if flags.TUI { + ui, err := tui.New(e.Logger, tui.Options{ + Status: flags.TUIStatus, + TaskNavigator: flags.TUITaskNavigator, + }) + if err != nil { + return err + } + return ui.Run(ctx, e, calls) + } return e.Run(ctx, calls...) } diff --git a/executor.go b/executor.go index 2ed4463beb..a522d485d6 100644 --- a/executor.go +++ b/executor.go @@ -68,6 +68,8 @@ type ( Compiler *Compiler Output output.Output OutputStyle ast.Output + Listener *Listener // Optional; observes execution and may draw the display + Prompter Prompter // Optional; answers the questions Task asks TaskSorter sort.Sorter UserWorkingDir string EnableVersionCheck bool @@ -81,6 +83,7 @@ type ( mkdirMutexMap map[string]*sync.Mutex executionHashes map[string]*executionState executionHashesMutex sync.Mutex + taskInvocationID uint64 watchedDirs *xsync.Map[string, bool] } TempDir struct { diff --git a/go.mod b/go.mod index c6fd21f5bb..82996a832b 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/Masterminds/semver/v3 v3.5.0 github.com/alecthomas/chroma/v2 v2.27.0 github.com/chainguard-dev/git-urls v1.0.2 + github.com/charmbracelet/x/ansi v0.11.8 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/dominikbraun/graph v0.23.0 github.com/elliotchance/orderedmap/v3 v3.1.1 @@ -69,7 +70,6 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect - github.com/charmbracelet/x/ansi v0.11.8 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 9e43d4a943..80eb215e80 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -70,6 +70,9 @@ var ( Concurrency int Dir string Entrypoint string + TUI bool + TUIStatus string + TUITaskNavigator string Output ast.Output Color bool Interval time.Duration @@ -144,6 +147,9 @@ func init() { pflag.BoolVarP(&ExitCode, "exit-code", "x", false, "Pass-through the exit code of the task command.") pflag.StringVarP(&Dir, "dir", "d", "", "Sets the directory in which Task will execute and look for a Taskfile.") pflag.StringVarP(&Entrypoint, "taskfile", "t", "", `Choose which Taskfile to run. Defaults to "Taskfile.yml".`) + pflag.BoolVarP(&TUI, "tui", "T", false, "Runs Task in an interactive terminal interface.") + pflag.StringVar(&TUIStatus, "tui-status", getConfig(config, "TUI_STATUS", func() *string { return config.TUI.Status }, ""), "Sets TUI task status style: [icons|labels].") + pflag.StringVar(&TUITaskNavigator, "tui-task-navigator", getConfig(config, "TUI_TASK_NAVIGATOR", func() *string { return config.TUI.TaskNavigator }, ""), "Sets TUI task navigator: [list|tree]. Defaults to tree.") pflag.StringVar(&TempDir, "temp-dir", getConfig(config, "TEMP_DIR", func() *string { return config.TempDir }, ""), "Sets the directory used to store Task temporary files, such as checksums. Relative paths are relative to the root Taskfile.") pflag.StringVarP(&Output.Name, "output", "o", getConfig(config, "OUTPUT", func() *string { return nil }, ""), "Sets output style: [interleaved|group|prefixed].") pflag.StringVar(&Output.Group.Begin, "output-group-begin", getConfig(config, "OUTPUT_GROUP_BEGIN", func() *string { return nil }, ""), "Message template to print before a task's grouped output.") @@ -213,16 +219,17 @@ func Validate() error { return errors.New("task: You can't set both --global and --dir") } - if Output.Name != "group" { - if Output.Group.Begin != "" { - return errors.New("task: You can't set --output-group-begin without --output=group") - } - if Output.Group.End != "" { - return errors.New("task: You can't set --output-group-end without --output=group") - } - if Output.Group.ErrorOnly { - return errors.New("task: You can't set --output-group-error-only without --output=group") - } + if err := validateOutputOptions(Output); err != nil { + return err + } + if err := validateTUIOptions(TUI, pflag.Lookup("tui-status").Changed, pflag.Lookup("tui-task-navigator").Changed); err != nil { + return err + } + if TUI && (List || ListAll || ListJson || Status || Summary || Watch) { + return errors.New("task: --tui cannot be combined with task listing, status, summary, or watch modes") + } + if err := validateTUIPrompting(TUI, Interactive, pflag.Lookup("interactive").Changed); err != nil { + return err } if List && ListAll { @@ -249,6 +256,51 @@ func Validate() error { return nil } +func validateOutputOptions(output ast.Output) error { + if output.Name != "group" { + if output.Group.Begin != "" { + return errors.New("task: You can't set --output-group-begin without --output=group") + } + if output.Group.End != "" { + return errors.New("task: You can't set --output-group-end without --output=group") + } + if output.Group.ErrorOnly { + return errors.New("task: You can't set --output-group-error-only without --output=group") + } + } + return nil +} + +// validateTUIPrompting rejects turning prompting off while the TUI is on. +// +// The interface asks in its own dialog, so it does not consult --interactive, +// and passing it would be silently ignored. It cannot honour the flag either: +// a task requiring a variable would then be unrunnable from the launcher, +// which has nowhere to pass one. +func validateTUIPrompting(tui, interactive, interactiveSet bool) error { + if tui && interactiveSet && !interactive { + return errors.New( + "task: You can't set --interactive=false with --tui: the interface asks in its own dialog") + } + return nil +} + +// validateTUIOptions rejects the interface's display options without the +// interface. Only the flags are rejected: the same settings in .taskrc.yml are +// defaults for the runs that do use it, and must not fail the ones that don't. +func validateTUIOptions(enabled, statusSet, navigatorSet bool) error { + if enabled { + return nil + } + if statusSet { + return errors.New("task: You can't set --tui-status without --tui") + } + if navigatorSet { + return errors.New("task: You can't set --tui-task-navigator without --tui") + } + return nil +} + // WithFlags is a special internal functional option that is used to pass flags // from the CLI into any constructor that accepts functional options. func WithFlags() task.ExecutorOption { diff --git a/internal/flags/flags_test.go b/internal/flags/flags_test.go new file mode 100644 index 0000000000..7c23d0f22f --- /dev/null +++ b/internal/flags/flags_test.go @@ -0,0 +1,115 @@ +package flags + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/taskfile/ast" +) + +func TestValidateOutputOptions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + output ast.Output + wantError string + }{ + { + name: "group options with group output", + output: ast.Output{Name: "group", Group: ast.OutputGroup{Begin: "begin", End: "end", ErrorOnly: true}}, + }, + { + name: "group option without group output", + output: ast.Output{Name: "interleaved", Group: ast.OutputGroup{Begin: "begin"}}, + wantError: "--output-group-begin without --output=group", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := validateOutputOptions(test.output) + if test.wantError == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantError) + }) + } +} + +func TestValidateTUIOptions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + enabled bool + statusSet bool + navigatorSet bool + wantError string + }{ + {name: "TUI without options", enabled: true}, + {name: "TUI with options", enabled: true, statusSet: true, navigatorSet: true}, + {name: "status flag without TUI", statusSet: true, wantError: "--tui-status without --tui"}, + {name: "navigator flag without TUI", navigatorSet: true, wantError: "--tui-task-navigator without --tui"}, + // A .taskrc.yml default leaves the flags unchanged, so an ordinary run + // is not failed by settings that only apply to the interface. + {name: "options configured, TUI off"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := validateTUIOptions(test.enabled, test.statusSet, test.navigatorSet) + if test.wantError == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantError) + }) + } +} + +func TestValidateTUIPrompting(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tui bool + interactive bool + interactiveSet bool + wantError string + }{ + {name: "TUI alone"}, + {name: "TUI without the flag", tui: true}, + {name: "TUI with prompting asked for", tui: true, interactive: true, interactiveSet: true}, + { + name: "TUI with prompting turned off", tui: true, interactiveSet: true, + wantError: "--interactive=false with --tui", + }, + // The flag defaults to false, so an unset flag must not be mistaken for + // a request to turn prompting off. + {name: "TUI with the flag left alone", tui: true, interactive: false}, + // Without the TUI the flag means what it always has. + {name: "prompting turned off on its own", interactiveSet: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + err := validateTUIPrompting(test.tui, test.interactive, test.interactiveSet) + if test.wantError == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), test.wantError) + }) + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 0000000000..01c0f49969 --- /dev/null +++ b/internal/tui/app.go @@ -0,0 +1,116 @@ +package tui + +import ( + "context" + + tea "charm.land/bubbletea/v2" +) + +type appPage uint8 + +const ( + launcherPage appPage = iota + executionPage +) + +type appModel struct { + page appPage + launcher launcherModel + launcherLoaded bool + execution tuiModel + loadLauncher func() (launcherModel, error) + startTUI func([]string) context.CancelFunc + runNormal func(string) + err error + width int + height int +} + +func newAppModel( + launcher launcherModel, + execution tuiModel, + showLauncher bool, + loadLauncher func() (launcherModel, error), + startTUI func([]string) context.CancelFunc, + runNormal func(string), +) appModel { + page := executionPage + if showLauncher { + page = launcherPage + } + return appModel{ + page: page, + launcher: launcher, + launcherLoaded: showLauncher, + execution: execution, + loadLauncher: loadLauncher, + startTUI: startTUI, + runNormal: runNormal, + } +} + +func (m appModel) Init() tea.Cmd { + if m.page == launcherPage { + return m.launcher.Init() + } + return m.execution.Init() +} + +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if size, ok := msg.(tea.WindowSizeMsg); ok { + m.width, m.height = size.Width, size.Height + } + + if m.page == launcherPage { + if _, ok := msg.(interruptRequestedMsg); ok { + return m, tea.Quit + } + launcher, cmd, request := m.launcher.Update(msg) + m.launcher = launcher + if request == nil { + return m, cmd + } + if request.mode == launchNormally { + m.runNormal(request.name) + return m, tea.Quit + } + + m.page = executionPage + statusLabels, taskNavigator := m.execution.statusLabels, m.execution.taskNavigator + canReturnToLauncher := m.execution.canReturnToLauncher + cancel := m.startTUI([]string{request.name}) + m.execution = newTUIModel(cancel) + m.execution.statusLabels = statusLabels + m.execution.taskNavigator = taskNavigator + m.execution.canReturnToLauncher = canReturnToLauncher + execution, resizeCmd := m.execution.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.execution = execution.(tuiModel) + return m, tea.Batch(cmd, resizeCmd, tea.ClearScreen) + } + if _, ok := msg.(returnToLauncherMsg); ok { + if !m.launcherLoaded { + launcher, err := m.loadLauncher() + if err != nil { + m.err = err + return m, tea.Quit + } + m.launcher = launcher + m.launcherLoaded = true + } + m.page = launcherPage + launcher, _, _ := m.launcher.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.launcher = launcher + return m, tea.Batch(tea.ClearScreen, m.launcher.Init()) + } + + execution, cmd := m.execution.Update(msg) + m.execution = execution.(tuiModel) + return m, cmd +} + +func (m appModel) View() tea.View { + if m.page == launcherPage { + return m.launcher.View() + } + return m.execution.View() +} diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go new file mode 100644 index 0000000000..605f21d2e7 --- /dev/null +++ b/internal/tui/clipboard.go @@ -0,0 +1,80 @@ +package tui + +import ( + "context" + "os" + "os/exec" + "runtime" + "strings" + "time" + + tea "charm.land/bubbletea/v2" +) + +// clipboardTimeout bounds a clipboard helper that misbehaves. Copying is not +// worth hanging the interface over. +const clipboardTimeout = 3 * time.Second + +type clipboardCopiedMsg struct { + size int + confirmed bool // a system clipboard tool accepted the text + colours bool // escape sequences were kept +} + +// systemClipboardArgs returns the command that puts stdin on the clipboard, or +// false when this machine has none. +// +// OSC 52 alone is not enough. VTE, which backs GNOME Terminal and the other +// Ubuntu terminals, does not implement it and swallows the sequence without +// error, so a helper is the only thing that works there. +func systemClipboardArgs() ([]string, bool) { + candidates := [][]string{} + if os.Getenv("WAYLAND_DISPLAY") != "" { + candidates = append(candidates, []string{"wl-copy"}) + } + if runtime.GOOS == "darwin" { + candidates = append(candidates, []string{"pbcopy"}) + } + if os.Getenv("DISPLAY") != "" { + candidates = append(candidates, + []string{"xclip", "-selection", "clipboard"}, + []string{"xsel", "--clipboard", "--input"}, + ) + } + // Also covers WSL, where the Windows helper is on PATH. + candidates = append(candidates, []string{"clip.exe"}) + + for _, candidate := range candidates { + if _, err := exec.LookPath(candidate[0]); err == nil { + return candidate, true + } + } + return nil, false +} + +// copyToSystemClipboard runs the clipboard helper, if there is one. It reports +// whether the text was definitely copied, which OSC 52 can never tell us. +func copyToSystemClipboard(text string, keepColours bool) tea.Cmd { + return func() tea.Msg { + args, ok := systemClipboardArgs() + if !ok { + return clipboardCopiedMsg{size: len(text), colours: keepColours} + } + + ctx, cancel := context.WithTimeout(context.Background(), clipboardTimeout) + defer cancel() + // args comes from the fixed candidate list above, never from the + // Taskfile or the user, and the text goes in over stdin. + cmd := exec.CommandContext(ctx, args[0], args[1:]...) //nolint:gosec + cmd.Stdin = strings.NewReader(text) + // Leave the helper's output attached to nothing. Helpers such as xclip + // and wl-copy fork to hold the selection, and inheriting a pipe would + // keep us waiting for that background process to exit. + cmd.Stdout, cmd.Stderr = nil, nil + + if err := cmd.Run(); err != nil { + return clipboardCopiedMsg{size: len(text), colours: keepColours} + } + return clipboardCopiedMsg{size: len(text), confirmed: true, colours: keepColours} + } +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000000..ffd125f983 --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,225 @@ +package tui + +import "charm.land/bubbles/v2/key" + +// mouseHelpKey marks a help entry that documents a mouse action rather than a +// key. bubbles/key only renders a binding that has at least one key, so these +// carry a sentinel no terminal can produce. +const mouseHelpKey = "\x00mouse" + +// terse returns a copy of a binding with shorter help text. key.Binding is a +// value, so the original is untouched. +// +// A binding carries the description the full key list shows. The footer has +// room for a word at most, so ShortHelp restates the entries it shows. Keeping +// both wordings in one place is what stops them drifting apart. +func terse(b key.Binding, name, desc string) key.Binding { + b.SetHelp(name, desc) + return b +} + +// fullHelpColumns lays bindings out column by column, keeping related entries +// together in reading order. +func fullHelpColumns(bindings []key.Binding, columns int) [][]key.Binding { + columns = max(columns, 1) + perColumn := (len(bindings) + columns - 1) / columns + groups := make([][]key.Binding, 0, columns) + for start := 0; start < len(bindings); start += perColumn { + groups = append(groups, bindings[start:min(start+perColumn, len(bindings))]) + } + return groups +} + +// dashboardKeys are the two-pane view's bindings. The arrow keys mean different +// things depending on which pane has focus, so a keymap is built per render +// rather than kept as a package-level value. +type dashboardKeys struct { + Move key.Binding + Pane key.Binding + Click key.Binding + Wheel key.Binding + Page key.Binding + Top key.Binding + Bottom key.Binding + Fullscreen key.Binding + Copy key.Binding + CopyRaw key.Binding + Save key.Binding + SaveAll key.Binding + Navigator key.Binding + Launcher key.Binding + Quit key.Binding + Help key.Binding +} + +func newDashboardKeys(outputFocused, canReturnToLauncher bool) dashboardKeys { + move := key.NewBinding(key.WithKeys("up", "down", "k", "j"), key.WithHelp("↑/↓", "select a task")) + click := key.NewBinding(key.WithKeys(mouseHelpKey), key.WithHelp("click", "select a task")) + if outputFocused { + move.SetHelp("↑/↓", "scroll the output") + click.SetHelp("click", "focus a pane") + } + keys := dashboardKeys{ + Move: move, + Pane: key.NewBinding(key.WithKeys("tab", "shift+tab", "left", "right", "h", "l"), key.WithHelp("←/→/tab", "switch pane")), + Click: click, + Wheel: key.NewBinding(key.WithKeys(mouseHelpKey), key.WithHelp("wheel", "scroll the output")), + Page: key.NewBinding(key.WithKeys("pgup", "pgdown"), key.WithHelp("pgup/pgdn", "scroll a page")), + Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("g", "jump to start")), + Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("G", "jump to end")), + Fullscreen: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "output fullscreen")), + Copy: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy output without ANSI codes")), + CopyRaw: key.NewBinding(key.WithKeys("Y"), key.WithHelp("Y", "copy output with ANSI codes")), + Save: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "save output to a file")), + SaveAll: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "save every output to a folder")), + Navigator: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "switch task view: tree or list")), + Launcher: key.NewBinding(key.WithKeys("esc", "b"), key.WithHelp("esc/b", "stop, open launcher")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "stop and quit")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "show this list")), + } + if !canReturnToLauncher { + keys.Launcher.SetEnabled(false) + } + return keys +} + +// ShortHelp lists the footer keys in order of importance, because the help +// bubble drops from the end when the line does not fit. Leaving a view comes +// first: help, quit, and the way back to the launcher. Then the actions, which +// are the part nobody can guess. Moving around comes last, and the two arrow +// entries sit together: vertical moves the selection, horizontal moves panes. +// +// The whole line is not expected to fit eighty columns. It does not have to: +// what matters is that the entries a reader needs to get somewhere else survive, +// and "?" lists the rest. +func (k dashboardKeys) ShortHelp() []key.Binding { + move := "select" + if k.Move.Help().Desc == "scroll the output" { + move = "scroll" + } + return []key.Binding{ + terse(k.Help, "?", "help"), + terse(k.Quit, "q", "quit"), + terse(k.Launcher, "esc/b", "launcher"), + terse(k.Copy, "y", "copy"), + terse(k.Fullscreen, "f", "fullscreen"), + terse(k.Save, "s", "save"), + terse(k.Move, "↑/↓", move), + terse(k.Pane, "←/→", "pane"), + } +} + +func (k dashboardKeys) allBindings() []key.Binding { + return []key.Binding{ + k.Move, k.Pane, k.Click, k.Wheel, k.Page, + k.Top, k.Bottom, k.Fullscreen, k.Copy, k.CopyRaw, + k.Save, k.SaveAll, k.Navigator, k.Launcher, k.Quit, k.Help, + } +} + +// fullscreenKeys are the bindings of the single-pane output view. +type fullscreenKeys struct { + Move key.Binding + Select key.Binding + Cancel key.Binding + Page key.Binding + Top key.Binding + Bottom key.Binding + Copy key.Binding + CopyRaw key.Binding + Save key.Binding + SaveAll key.Binding + Return key.Binding + Quit key.Binding + Help key.Binding +} + +// newFullscreenKeys describes the single-pane output view. What the copy keys +// do depends on whether lines are selected, so the help says which. +func newFullscreenKeys(selecting bool) fullscreenKeys { + // v is vim's visual mode, V its line-wise form, which is what this is; tmux + // copy-mode uses V for a line too. Both are bound so that neither habit + // meets a key that does nothing. + selectHelp := key.NewBinding(key.WithKeys("v", "V"), key.WithHelp("v", "select lines")) + copyHelp := key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy output without ANSI codes")) + copyRawHelp := key.NewBinding(key.WithKeys("Y"), key.WithHelp("Y", "copy output with ANSI codes")) + cancel := key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear the selection")) + cancel.SetEnabled(false) + if selecting { + selectHelp.SetHelp("v", "stop extending the selection") + copyHelp.SetHelp("y", "copy the selected lines") + copyRawHelp.SetHelp("Y", "copy them with ANSI codes") + cancel.SetEnabled(true) + } + return fullscreenKeys{ + Move: key.NewBinding(key.WithKeys("up", "down", "k", "j"), key.WithHelp("↑/↓", "move the line cursor")), + Select: selectHelp, + Cancel: cancel, + Page: key.NewBinding(key.WithKeys("pgup", "pgdown"), key.WithHelp("pgup/pgdn", "move a page")), + Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("g", "jump to start")), + Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("G", "jump to end")), + Copy: copyHelp, + CopyRaw: copyRawHelp, + Save: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "save output to a file")), + SaveAll: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "save every output to a folder")), + Return: key.NewBinding(key.WithKeys("f", "esc"), key.WithHelp("f/esc", "back to panes")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "stop and quit")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "show this list")), + } +} + +func (k fullscreenKeys) ShortHelp() []key.Binding { + selectLabel, copyLabel := "select", "copy all" + if k.Cancel.Enabled() { + selectLabel, copyLabel = "stop", "copy lines" + } + return []key.Binding{ + terse(k.Help, "?", "help"), + terse(k.Quit, "q", "quit"), + terse(k.Return, "f/esc", "back"), + terse(k.Select, "v", selectLabel), + terse(k.Copy, "y", copyLabel), + terse(k.Move, "↑/↓", "cursor"), + } +} + +func (k fullscreenKeys) allBindings() []key.Binding { + return []key.Binding{ + k.Move, k.Page, k.Top, k.Bottom, + k.Select, k.Cancel, k.Copy, k.CopyRaw, k.Save, k.SaveAll, + k.Return, k.Quit, k.Help, + } +} + +// launcherKeys are the bindings of the task launcher. +// +// The launcher filters as you type, so every printable character belongs to the +// filter and cannot be a command. That rules out "?" for help here, which is why +// the launcher keeps to a single line rather than offering a full list. +type launcherKeys struct { + Move key.Binding + Boundary key.Binding + RunInTUI key.Binding + RunNormally key.Binding + ClearFilter key.Binding + Quit key.Binding +} + +func newLauncherKeys() launcherKeys { + return launcherKeys{ + Move: key.NewBinding(key.WithKeys("up", "down", "tab"), key.WithHelp("↑/↓", "navigate")), + Boundary: key.NewBinding(key.WithKeys("home", "end"), key.WithHelp("home/end", "first/last")), + RunInTUI: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "run in TUI")), + RunNormally: key.NewBinding(key.WithKeys("ctrl+r", "alt+enter"), key.WithHelp("ctrl+r", "run normally")), + ClearFilter: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear")), + Quit: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit")), + } +} + +func (k launcherKeys) ShortHelp() []key.Binding { + return []key.Binding{k.Quit, k.Move, k.RunInTUI, k.RunNormally, k.ClearFilter} +} + +func (k launcherKeys) FullHelp() [][]key.Binding { + return [][]key.Binding{k.ShortHelp()} +} diff --git a/internal/tui/launcher.go b/internal/tui/launcher.go new file mode 100644 index 0000000000..e5c1e34f4b --- /dev/null +++ b/internal/tui/launcher.go @@ -0,0 +1,319 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/go-task/task/v3/taskfile/ast" +) + +type launchMode uint8 + +const ( + launchNormally launchMode = iota + launchInTUI +) + +type launchRequest struct { + name string + mode launchMode +} + +type launcherItem struct { + name string + description string +} + +func (i launcherItem) matches(filter string) bool { + text := strings.ToLower(i.name + " " + i.description) + return strings.Contains(text, strings.ToLower(filter)) +} + +type launcherModel struct { + help help.Model + items []launcherItem + filtered []int + filterInput textinput.Model + selected int + top int + width int + height int +} + +func newLauncherModel(tasks []*ast.Task) launcherModel { + m := launcherModel{ + help: newHelpModel(), + filterInput: newLauncherFilterInput(), + width: 100, + height: 30, + } + m.items = make([]launcherItem, 0, len(tasks)) + for _, task := range tasks { + m.items = append(m.items, launcherItem{ + name: task.Task, + description: strings.Join(strings.Fields(task.Desc), " "), + }) + } + m.applyFilter("") + return m +} + +func newLauncherFilterInput() textinput.Model { + input := textinput.New() + input.Prompt = "" + input.Placeholder = "type to search" + styles := input.Styles() + styles.Focused.Text = lipgloss.NewStyle() + styles.Focused.Placeholder = tuiHelpStyle + styles.Cursor.Color = tuiHelpColor + input.SetStyles(styles) + wordDeleteKeys := input.KeyMap.DeleteWordBackward.Keys() + input.KeyMap.DeleteWordBackward.SetKeys(append(wordDeleteKeys, "ctrl+backspace")...) + input.Focus() + return input +} + +func (m launcherModel) Init() tea.Cmd { return textinput.Blink } + +func (m launcherModel) Update(msg tea.Msg) (launcherModel, tea.Cmd, *launchRequest) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.keepSelectionVisible() + case tea.MouseClickMsg: + if index := m.itemAtY(msg.Y); index >= 0 { + m.selected = index + m.keepSelectionVisible() + } + case tea.MouseWheelMsg: + switch msg.Button { + case tea.MouseWheelUp: + m.moveSelection(-1) + case tea.MouseWheelDown: + m.moveSelection(1) + } + case tea.KeyPressMsg: + switch msg.String() { + case "enter": + return m, nil, m.request(launchInTUI) + case "ctrl+r", "alt+enter": + return m, nil, m.request(launchNormally) + case "ctrl+c": + return m, tea.Quit, nil + case "up": + m.moveSelection(-1) + return m, nil, nil + case "down", "tab": + m.moveSelection(1) + return m, nil, nil + case "home": + m.selectBoundary(false) + return m, nil, nil + case "end": + m.selectBoundary(true) + return m, nil, nil + case "esc": + m.applyFilter("") + return m, nil, nil + } + } + + previousFilter := m.filterInput.Value() + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + if filter := m.filterInput.Value(); filter != previousFilter { + m.applyFilter(filter) + } + return m, cmd, nil +} + +func (m launcherModel) request(mode launchMode) *launchRequest { + if m.selected < 0 || m.selected >= len(m.filtered) { + return nil + } + return &launchRequest{name: m.items[m.filtered[m.selected]].name, mode: mode} +} + +func (m *launcherModel) applyFilter(filter string) { + selectedName := "" + if request := m.request(launchNormally); request != nil { + selectedName = request.name + } + m.filterInput.SetValue(filter) + m.filtered = m.filtered[:0] + for index, item := range m.items { + if item.matches(filter) { + m.filtered = append(m.filtered, index) + } + } + m.selected = 0 + for index, itemIndex := range m.filtered { + if m.items[itemIndex].name == selectedName { + m.selected = index + break + } + } + m.top = min(m.top, max(len(m.filtered)-1, 0)) + m.keepSelectionVisible() +} + +func (m *launcherModel) moveSelection(delta int) { + if len(m.filtered) == 0 { + return + } + m.selected = min(max(m.selected+delta, 0), len(m.filtered)-1) + m.keepSelectionVisible() +} + +func (m *launcherModel) selectBoundary(last bool) { + if len(m.filtered) == 0 { + return + } + m.selected = 0 + if last { + m.selected = len(m.filtered) - 1 + } + m.keepSelectionVisible() +} + +func (m *launcherModel) keepSelectionVisible() { + if len(m.filtered) == 0 { + m.selected, m.top = 0, 0 + return + } + m.selected = min(max(m.selected, 0), len(m.filtered)-1) + m.top = min(max(m.top, 0), m.selected) + available := m.taskViewportHeight() + if m.selected >= m.top+available { + m.top = m.selected - available + 1 + } +} + +func (m launcherModel) taskViewportHeight() int { + layout := newLauncherLayout(m.width, m.height) + return max(layout.innerHeight-2, 1) +} + +func (m launcherModel) itemAtY(y int) int { + // The outer border occupies row zero; title and filter occupy rows one and + // two, so cards begin at row three. + row := y - 3 + if row < 0 || row >= m.taskViewportHeight() { + return -1 + } + index := m.top + row + if index >= len(m.filtered) { + return -1 + } + return index +} + +func (m launcherModel) View() tea.View { + layout := newLauncherLayout(m.width, m.height) + content := m.renderContent(layout) + panel := tuiPanelStyle.BorderForeground(tuiAccentColor). + Width(layout.width). + Height(layout.bodyHeight). + Render(content) + help := shortHelp(m.help, newLauncherKeys().ShortHelp(), layout.width) + + view := tea.NewView(panel + "\n" + help) + view.AltScreen = true + view.MouseMode = tea.MouseModeCellMotion + view.WindowTitle = "Task" + return view +} + +func (m launcherModel) renderContent(layout launcherLayout) string { + count := fmt.Sprintf("%d/%d", len(m.filtered), len(m.items)) + lines := []string{paneTitle("TASKS", count, layout.innerWidth)} + lines = append(lines, m.renderFilter(layout.innerWidth)) + + available := max(layout.innerHeight-len(lines), 0) + nameWidth := m.nameColumnWidth(layout.innerWidth) + for index := m.top; index < len(m.filtered) && index-m.top < available; index++ { + item := m.items[m.filtered[index]] + lines = append(lines, launcherRow(item, layout.innerWidth, nameWidth, index == m.selected)) + } + if len(m.filtered) == 0 && available > 0 { + lines = append(lines, tuiHelpStyle.Render("No matching tasks")) + } + return strings.Join(lines, "\n") +} + +func (m launcherModel) renderFilter(width int) string { + labelStyle := tuiHelpStyle + cursorColor := tuiHelpColor + if m.filterInput.Value() != "" { + labelStyle = tuiFilterActiveStyle + cursorColor = tuiFilterActiveColor + } + + input := m.filterInput + styles := input.Styles() + styles.Cursor.Color = cursorColor + input.SetStyles(styles) + label := labelStyle.Render("Filter: ") + // Textinput renders the cursor as one cell beyond its configured content + // width when it is positioned at the end of the value. + input.SetWidth(max(width-lipgloss.Width(label)-1, 1)) + return truncateText(label+input.View(), width) +} + +func (m launcherModel) nameColumnWidth(width int) int { + longest := 1 + for _, itemIndex := range m.filtered { + longest = max(longest, lipgloss.Width(m.items[itemIndex].name)) + } + // Prefer complete task names while preserving useful room for descriptions + // on ordinary terminal widths. + maxNameWidth := max(width*3/5, 1) + if width > 3 { + maxNameWidth = min(maxNameWidth, width-3) + } + return min(longest, maxNameWidth) +} + +func launcherRow(item launcherItem, width, nameWidth int, selected bool) string { + width = max(width, 1) + nameWidth = min(max(nameWidth, 1), width) + name := truncateMiddle(item.name, nameWidth) + name += strings.Repeat(" ", max(nameWidth-lipgloss.Width(name), 0)) + + gapWidth := min(2, max(width-nameWidth, 0)) + descriptionWidth := max(width-nameWidth-gapWidth, 0) + description := truncateText(item.description, descriptionWidth) + description += strings.Repeat(" ", max(descriptionWidth-lipgloss.Width(description), 0)) + content := name + strings.Repeat(" ", gapWidth) + description + content += strings.Repeat(" ", max(width-lipgloss.Width(content), 0)) + + if selected { + return tuiSelectedStyle.Width(width).Render(content) + } + name = lipgloss.NewStyle().Bold(true).Render(name) + description = tuiHelpStyle.Render(description) + return name + strings.Repeat(" ", gapWidth) + description +} + +type launcherLayout struct { + width int + bodyHeight int + innerWidth int + innerHeight int +} + +func newLauncherLayout(width, height int) launcherLayout { + width, height = max(width, 1), max(height, 1) + bodyHeight := max(height-1, 3) + return launcherLayout{ + width: width, + bodyHeight: bodyHeight, + innerWidth: max(width-tuiPanelStyle.GetHorizontalFrameSize(), 1), + innerHeight: max(bodyHeight-tuiPanelStyle.GetVerticalFrameSize(), 1), + } +} diff --git a/internal/tui/launcher_test.go b/internal/tui/launcher_test.go new file mode 100644 index 0000000000..691cbd01e1 --- /dev/null +++ b/internal/tui/launcher_test.go @@ -0,0 +1,273 @@ +package tui + +import ( + "context" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3/taskfile/ast" +) + +func TestLauncherFiltersAsTheUserTypes(t *testing.T) { + t.Parallel() + + m := testLauncher() + m, _, request := m.Update(tea.KeyPressMsg{Code: 'p', Text: "p"}) + require.Nil(t, request) + assert.Equal(t, "p", m.filterInput.Value()) + assert.Equal(t, []int{0, 2}, m.filtered) + + m, _, _ = m.Update(tea.KeyPressMsg{Code: 'u', Text: "u"}) + assert.Equal(t, "pu", m.filterInput.Value()) + assert.Equal(t, []int{2}, m.filtered) + assert.Contains(t, ansi.Strip(m.View().Content), "publish") + assert.NotContains(t, ansi.Strip(m.View().Content), "build") + + m, _, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace}) + assert.Equal(t, "p", m.filterInput.Value()) + m, _, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + assert.Empty(t, m.filterInput.Value()) + assert.Len(t, m.filtered, 3) +} + +func TestLauncherFilterLooksInactiveUntilTheUserTypes(t *testing.T) { + t.Parallel() + + m := testLauncher() + assert.True(t, m.filterInput.Focused()) + inactive := m.renderFilter(40) + assert.Equal(t, 40, lipgloss.Width(inactive)) + assert.Contains(t, inactive, tuiHelpStyle.Render("Filter: ")) + assert.Contains(t, ansi.Strip(inactive), "Filter: type to search") + + m, _, _ = m.Update(tea.KeyPressMsg{Code: 'b', Text: "b"}) + active := m.renderFilter(40) + assert.Equal(t, 40, lipgloss.Width(active)) + assert.Contains(t, active, tuiFilterActiveStyle.Render("Filter: ")) + assert.Contains(t, ansi.Strip(active), "Filter: b") + assert.NotContains(t, active, tuiHelpStyle.Render("b")) +} + +func TestLauncherFilterDeletesWords(t *testing.T) { + t.Parallel() + + m := testLauncher() + m, _, _ = m.Update(tea.KeyPressMsg{Text: "build docs"}) + m, _, _ = m.Update(tea.KeyPressMsg{Code: 'w', Mod: tea.ModCtrl}) + assert.Equal(t, "build ", m.filterInput.Value()) + + m, _, _ = m.Update(tea.KeyPressMsg{Text: "tests"}) + m, _, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace, Mod: tea.ModCtrl}) + assert.Equal(t, "build ", m.filterInput.Value()) +} + +func TestLauncherUsesSeparateNormalAndTUIActions(t *testing.T) { + t.Parallel() + + m := testLauncher() + m, _, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + _, _, request := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + require.NotNil(t, request) + assert.Equal(t, "lint", request.name) + assert.Equal(t, launchInTUI, request.mode) + + _, _, request = m.Update(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl}) + require.NotNil(t, request) + assert.Equal(t, "lint", request.name) + assert.Equal(t, launchNormally, request.mode) + + _, _, request = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModAlt}) + require.NotNil(t, request) + assert.Equal(t, launchNormally, request.mode) +} + +func TestLauncherEscapeOnlyClearsTheFilter(t *testing.T) { + t.Parallel() + + m := testLauncher() + m.applyFilter("build") + m, cmd, request := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + require.Nil(t, cmd) + require.Nil(t, request) + assert.Empty(t, m.filterInput.Value()) + + m, cmd, request = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + require.Nil(t, cmd) + require.Nil(t, request) + assert.Empty(t, m.filterInput.Value()) +} + +func TestLauncherControlCQuits(t *testing.T) { + t.Parallel() + + m := testLauncher() + _, cmd, request := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + require.Nil(t, request) + require.NotNil(t, cmd) + assert.IsType(t, tea.QuitMsg{}, cmd()) +} + +func TestLauncherRowsUseNameAndDescriptionColumns(t *testing.T) { + t.Parallel() + + withoutDescription := launcherRow(launcherItem{name: "lint"}, 40, 10, false) + withDescription := launcherRow(launcherItem{name: "build", description: "Compile the project"}, 40, 10, true) + + for _, line := range []string{withoutDescription, withDescription} { + assert.Equal(t, 40, lipgloss.Width(line)) + assert.NotContains(t, ansi.Strip(line), "│") + } + assert.Contains(t, ansi.Strip(withDescription), "build Compile the project") +} + +func TestLauncherViewFitsTheTerminalAndScrollsSelection(t *testing.T) { + t.Parallel() + + m := testLauncher() + m, _, _ = m.Update(tea.WindowSizeMsg{Width: 60, Height: 7}) + for range 2 { + m, _, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + } + + assert.Equal(t, 2, m.selected) + assert.Greater(t, m.top, 0) + assert.Equal(t, 60, lipgloss.Width(m.View().Content)) + assert.Equal(t, 7, lipgloss.Height(m.View().Content)) +} + +func TestAppRunsNormalLauncherSelectionOutsideDashboard(t *testing.T) { + t.Parallel() + + var normalTask string + m := newAppModel( + testLauncher(), + newTUIModel(func() {}), + true, + func() (launcherModel, error) { + t.Fatal("launcher loader should not run") + return launcherModel{}, nil + }, + func([]string) context.CancelFunc { + t.Fatal("dashboard callback should not run") + return func() {} + }, + func(name string) { normalTask = name }, + ) + + next, cmd := m.Update(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl}) + require.NotNil(t, cmd) + assert.IsType(t, tea.QuitMsg{}, cmd()) + assert.Equal(t, "build", normalTask) + assert.Equal(t, launcherPage, next.(appModel).page) +} + +func TestAppCanReturnToLauncherAfterDashboardExecution(t *testing.T) { + t.Parallel() + + execution := newTUIModel(func() {}) + execution.canReturnToLauncher = true + var dashboardTasks []string + m := newAppModel( + testLauncher(), + execution, + true, + func() (launcherModel, error) { + t.Fatal("launcher loader should not run") + return launcherModel{}, nil + }, + func(names []string) context.CancelFunc { + dashboardTasks = append(dashboardTasks, names[0]) + return func() {} + }, + func(string) { t.Fatal("normal callback should not run") }, + ) + + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(appModel) + assert.Equal(t, executionPage, m.page) + assert.Equal(t, []string{"build"}, dashboardTasks) + assert.True(t, m.execution.canReturnToLauncher) + + next, _ = m.Update(executionDoneMsg{}) + m = next.(appModel) + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + m = next.(appModel) + require.NotNil(t, cmd) + + next, _ = m.Update(cmd()) + m = next.(appModel) + assert.Equal(t, launcherPage, m.page) + + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(appModel) + assert.Equal(t, executionPage, m.page) + assert.Equal(t, []string{"build", "build"}, dashboardTasks) + assert.False(t, m.execution.done) +} + +func TestAppLoadsLauncherAfterDirectExecution(t *testing.T) { + t.Parallel() + + execution := newTUIModel(func() {}) + execution.done = true + execution.canReturnToLauncher = true + loaded := false + m := newAppModel( + launcherModel{}, + execution, + false, + func() (launcherModel, error) { + loaded = true + return testLauncher(), nil + }, + func([]string) context.CancelFunc { + t.Fatal("dashboard callback should not run") + return func() {} + }, + func(string) { t.Fatal("normal callback should not run") }, + ) + + next, cmd := m.Update(returnToLauncherMsg{}) + m = next.(appModel) + assert.True(t, loaded) + assert.True(t, m.launcherLoaded) + assert.Equal(t, launcherPage, m.page) + require.NotNil(t, cmd) + require.NotEmpty(t, m.launcher.items) + assert.Equal(t, "build", m.launcher.items[0].name) +} + +func TestLauncherHelpFitsANarrowTerminal(t *testing.T) { + t.Parallel() + + m := testLauncher() + m.width, m.height = 80, 24 + for line := range strings.SplitSeq(m.View().Content, "\n") { + assert.LessOrEqual(t, lipgloss.Width(line), 80) + } +} + +func TestLauncherHelpDescribesLaunchActions(t *testing.T) { + t.Parallel() + + m := testLauncher() + m.width = 160 + help := ansi.Strip(m.View().Content) + assert.Contains(t, help, "↑/↓ navigate") + assert.Contains(t, help, "enter run in TUI") + assert.Contains(t, help, "ctrl+r run normally") +} + +func testLauncher() launcherModel { + return newLauncherModel([]*ast.Task{ + {Task: "build", Desc: "Compile the project"}, + {Task: "lint"}, + {Task: "publish", Desc: "Upload release artifacts"}, + }) +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000000..14da7aa02e --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,642 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + + "github.com/go-task/task/v3/errors" +) + +type taskState uint8 + +const ( + taskPending taskState = iota + taskRunning + taskSucceeded + taskFailed + taskCanceled + taskSkipped +) + +type paneFocus uint8 + +const ( + taskPane paneFocus = iota + outputPane +) + +type tuiTaskNavigator uint8 + +const ( + taskNavigatorList tuiTaskNavigator = iota + taskNavigatorTree +) + +type tuiTask struct { + id uint64 + parentID uint64 + rootID uint64 + name string + isRoot bool + shared bool + ownerID uint64 + output string + state taskState + truncated bool + + // exitCode is what this task's own command exited with, when it failed and + // reported one. + exitCode *int + + startedAt time.Time + finishedAt time.Time + + scrollOffset int + followOutput bool + + // pendingRedraw records a carriage return whose line has not been redrawn + // yet, so the redraw can span separate writes. + pendingRedraw bool +} + +type ( + taskScheduledMsg struct{ task taskInvocation } + taskStartedMsg struct { + task taskInvocation + at time.Time + } + taskFinishedMsg struct { + id uint64 + result taskResult + err error + at time.Time + duration time.Duration + } +) + +type taskJoinedMsg struct { + id uint64 + ownerID uint64 +} +type taskOutputMsg struct { + id uint64 + name, data string +} +type ( + noticeExpiredMsg struct{ id int } + // elapsedTickMsg redraws running durations. It is only scheduled while a + // task is running, so a finished dashboard is completely static. + elapsedTickMsg struct{} + noticeRequestedMsg struct{ text string } +) + +type ( + outputReadyMsg struct{ ui *UI } + executionDoneMsg struct { + ui *UI + err error + } + interruptRequestedMsg struct{} + returnToLauncherMsg struct{} +) + +type tuiModel struct { + tasks []*tuiTask + byID map[uint64]*tuiTask + selectedID uint64 + hasSelect bool + listTop int + focus paneFocus + width int + height int + viewport viewport.Model + done bool + quitting bool + returning bool + err error + cancel context.CancelFunc + statusLabels bool + taskNavigator tuiTaskNavigator + canReturnToLauncher bool + + fullscreenOutput bool + fullscreenViewport viewport.Model + + // Fullscreen output is browsed a line at a time. The output is wrapped + // once into fullscreenRows and handed to the viewport pre-wrapped, so that + // a viewport row and a screen row are the same thing; fullscreenRowOf maps + // a logical line to its first row. The cursor and the anchor a selection + // grows from are logical line indices into fullscreenLines, which is what + // a copy takes its text from. + fullscreenLines []string + fullscreenRows []string + fullscreenShown []string + fullscreenRowOf []int + fullscreenCursor int + fullscreenAnchor int + fullscreenSelecting bool + fullscreenPainted [2]int + // fullscreenPaintedSelecting is which of the two highlights the painted + // span is currently drawn in. + fullscreenPaintedSelecting bool + showHelp bool + help help.Model + prompt *promptState + save *saveState + ticking bool + + // notice is transient feedback shown in place of the controls, such as the + // result of a copy. noticeID lets a later notice cancel an earlier timer. + notice string + noticeID int +} + +type tuiTaskKey struct { + groupID uint64 + name string + isRoot bool +} + +func newTUIModel(cancel context.CancelFunc) tuiModel { + view := viewport.New() + view.SoftWrap = true + view.MouseWheelDelta = 3 + return tuiModel{ + help: newHelpModel(), + byID: make(map[uint64]*tuiTask), + width: 100, + height: 30, + viewport: view, + cancel: cancel, + taskNavigator: taskNavigatorTree, + } +} + +func (m tuiModel) Init() tea.Cmd { return nil } + +func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + if m.fullscreenOutput { + m.leaveFullscreenOutput() + } + m.saveViewport() + m.width, m.height = msg.Width, msg.Height + m.resizeViewport() + m.loadViewport() + m.keepSelectionVisible() + return m, nil + case taskScheduledMsg: + m.scheduleTask(msg.task) + m.keepSelectionVisible() + return m, nil + case taskStartedMsg: + task := m.scheduleTask(msg.task) + task.state = taskRunning + // The executor timestamps the event. Stamping it here would measure + // when this loop got round to it, which for a burst of events is well + // after the task started. + task.startedAt = msg.at + m.keepSelectionVisible() + return m, m.startElapsedTicker() + case elapsedTickMsg: + m.ticking = false + return m, m.startElapsedTicker() + case taskFinishedMsg: + task := m.byID[msg.id] + if task == nil { + return m, nil + } + task.finishedAt = msg.at + if msg.duration > 0 { + // Prefer the executor's measurement over the gap between the two + // events reaching us. + task.startedAt = msg.at.Add(-msg.duration) + } + switch msg.result { + case resultSkipped: + task.state = taskSkipped + case resultCanceled: + task.state = taskCanceled + case resultFailed: + task.state = taskFailed + task.exitCode = taskExitCode(task.name, msg.err) + m.appendFailure(task, msg.err) + case resultSucceeded: + task.state = taskSucceeded + } + return m, nil + case noticeExpiredMsg: + if msg.id == m.noticeID { + m.notice = "" + } + return m, nil + case noticeRequestedMsg: + return m, m.showNotice(msg.text) + case promptRequestedMsg: + return m, m.beginPrompt(msg.state) + case savedMsg: + if msg.err != nil { + return m, m.showNotice("save failed: " + saveError(msg.err)) + } + if msg.count == 1 { + return m, m.showNotice("saved " + msg.path) + } + return m, m.showNotice(fmt.Sprintf("saved %d outputs to %s", msg.count, msg.path)) + case clipboardCopiedMsg: + notice := "copied " + humanizeBytes(msg.size) + if msg.colours { + notice += " with colours" + } + if !msg.confirmed { + // Only OSC 52 was sent, and it has no reply, so we cannot know + // whether the terminal honoured it. Say so rather than claim + // success: VTE-based terminals silently discard it. + notice += " — if nothing was copied, press s to save it" + } + return m, m.showNotice(notice) + case taskJoinedMsg: + m.joinTask(msg.id, msg.ownerID) + return m, nil + case taskOutputMsg: + m.appendOutput(msg.id, msg.name, msg.data) + return m, nil + case outputReadyMsg: + for id, pending := range msg.ui.drainOutput() { + m.appendOutput(id, pending.name, pending.data) + } + return m, nil + case executionDoneMsg: + if msg.ui != nil { + for id, pending := range msg.ui.drainOutput() { + m.appendOutput(id, pending.name, pending.data) + } + } + for _, task := range m.tasks { + if task.id == 0 { + continue + } + switch task.state { + case taskPending: + task.state = taskSkipped + case taskRunning: + task.state = taskCanceled + task.finishedAt = time.Now() + } + } + m.done, m.err = true, msg.err + m.reportUnattributedFailure(msg.err) + if m.quitting { + return m, tea.Quit + } + if m.returning { + return m, returnToLauncher + } + return m, nil + case interruptRequestedMsg: + if m.done { + return m, tea.Quit + } + m.quitting = true + m.cancel() + return m, nil + case tea.MouseClickMsg: + if m.fullscreenOutput { + return m, nil + } + m.handleMouseClick(tea.Mouse(msg)) + return m, nil + case tea.MouseWheelMsg: + if m.fullscreenOutput { + return m, nil + } + return m, m.handleMouseWheel(msg) + case tea.KeyPressMsg: + return m.handleKey(msg) + } + + return m, nil +} + +// reportUnattributedFailure shows a failure that belongs to no task. +// +// A run can fail before anything is scheduled: a declined prompt, a Taskfile +// that will not load. The error is then attached to nothing, and the dashboard +// would say the run failed while showing an empty task list and no reason. +func (m *tuiModel) reportUnattributedFailure(err error) { + if err == nil { + return + } + for _, task := range m.tasks { + if task.state == taskFailed { + // Already shown against the task it belongs to. + return + } + } + // appendOutput creates the pane and selects it when nothing else is. + m.appendOutput(0, systemTaskName, err.Error()+"\n") +} + +func (m *tuiModel) appendFailure(task *tuiTask, err error) { + if task.output != "" && !strings.HasSuffix(task.output, "\n") { + task.output += "\n" + } + message := err.Error() + "\n" + if !strings.HasSuffix(task.output, message) { + task.output += message + } + if selected := m.selectedTask(); selected != nil && selected.id == task.id { + m.refreshOutputView() + } +} + +func (m *tuiModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if m.prompt != nil { + // A task is blocked waiting for this answer, so nothing else can act. + return m.handlePromptKey(msg) + } + if m.save != nil { + // The footer is a path field; every key belongs to it. + return m.handleSaveKey(msg) + } + if m.showHelp { + // Any key leaves the key list; it is a reference, not a mode. + m.showHelp = false + return *m, nil + } + if m.fullscreenOutput { + return m.handleFullscreenKey(msg) + } + return m.handleDashboardKey(msg) +} + +func (m *tuiModel) handleFullscreenKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + keys := newFullscreenKeys(m.fullscreenSelecting) + switch { + case key.Matches(msg, keys.Cancel): + m.clearFullscreenSelection() + case key.Matches(msg, keys.Return): + m.leaveFullscreenOutput() + case key.Matches(msg, keys.Quit): + return *m, m.requestQuit() + case key.Matches(msg, keys.Help): + m.showHelp = true + case key.Matches(msg, keys.Select): + m.toggleFullscreenSelection() + case key.Matches(msg, keys.Copy): + return *m, m.copyFullscreenLines(false) + case key.Matches(msg, keys.CopyRaw): + return *m, m.copyFullscreenLines(true) + case key.Matches(msg, keys.Save): + return *m, m.askWhereToSave(false) + case key.Matches(msg, keys.SaveAll): + return *m, m.askWhereToSave(true) + case key.Matches(msg, keys.Move): + if msg.String() == "up" || msg.String() == "k" { + m.moveFullscreenCursor(-1) + } else { + m.moveFullscreenCursor(1) + } + case key.Matches(msg, keys.Page): + page := max(m.fullscreenViewport.Height(), 1) + if msg.String() == "pgup" { + m.moveFullscreenCursor(-page) + } else { + m.moveFullscreenCursor(page) + } + case key.Matches(msg, keys.Top): + m.moveFullscreenCursor(-len(m.fullscreenLines)) + case key.Matches(msg, keys.Bottom): + m.moveFullscreenCursor(len(m.fullscreenLines)) + } + return *m, nil +} + +// moveFullscreenCursor moves the line cursor and brings it into view, taking +// the selection with it when one is being extended. +func (m *tuiModel) moveFullscreenCursor(delta int) { + if len(m.fullscreenLines) == 0 { + return + } + m.fullscreenCursor = min(max(m.fullscreenCursor+delta, 0), len(m.fullscreenLines)-1) + m.keepFullscreenCursorVisible() + m.paintFullscreenSelection() +} + +// keepFullscreenCursorVisible scrolls by as little as it takes to show every +// row of the cursor's line, or its first rows when the line is taller than the +// screen. +func (m *tuiModel) keepFullscreenCursorVisible() { + first := m.fullscreenRowOf[m.fullscreenCursor] + last := m.fullscreenRowOf[m.fullscreenCursor+1] - 1 + height := max(m.fullscreenViewport.Height(), 1) + offset := m.fullscreenViewport.YOffset() + switch { + case first < offset: + offset = first + case last >= offset+height: + offset = max(last-height+1, first) + } + m.fullscreenViewport.SetYOffset(offset) +} + +// toggleFullscreenSelection starts a selection at the cursor, or cancels one +// that is growing, as leaving Vim's visual mode does. The two states are drawn +// differently, so that pressing this twice by mistake is visible rather than +// silently leaving a copy about to take the whole output. +func (m *tuiModel) toggleFullscreenSelection() { + if len(m.fullscreenLines) == 0 { + return + } + if m.fullscreenSelecting { + m.fullscreenSelecting = false + } else { + m.fullscreenAnchor = m.fullscreenCursor + m.fullscreenSelecting = true + } + m.paintFullscreenSelection() +} + +func (m *tuiModel) clearFullscreenSelection() { + m.fullscreenSelecting = false + m.fullscreenAnchor = m.fullscreenCursor + m.paintFullscreenSelection() +} + +// fullscreenCopyText is what a copy puts on the clipboard: the selected lines +// as they were written rather than as they were folded to the screen, or the +// whole output when nothing is selected, so that the key keeps the meaning it +// has on the dashboard. +func (m tuiModel) fullscreenCopyText(keepColours bool) string { + if !m.fullscreenSelecting { + task := m.selectedTask() + if task == nil { + return "" + } + return copyText(task.output, keepColours) + } + if len(m.fullscreenLines) == 0 { + return "" + } + first, last := m.fullscreenSelectedLines() + return copyText(strings.Join(m.fullscreenLines[first:last+1], "\n"), keepColours) +} + +func (m *tuiModel) copyFullscreenLines(keepColours bool) tea.Cmd { + text := m.fullscreenCopyText(keepColours) + if text == "" { + return m.showNotice("nothing to copy") + } + return tea.Batch( + tea.SetClipboard(text), + copyToSystemClipboard(text, keepColours), + ) +} + +func (m *tuiModel) handleDashboardKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + keys := newDashboardKeys(m.focus == outputPane, m.canReturnToLauncher) + switch { + case key.Matches(msg, keys.Quit): + return *m, m.requestQuit() + case key.Matches(msg, keys.Launcher): + if m.done { + return *m, returnToLauncher + } + m.returning = true + m.cancel() + return *m, nil + case key.Matches(msg, keys.Help): + m.showHelp = true + case key.Matches(msg, keys.Pane): + m.togglePane(msg) + case key.Matches(msg, keys.Fullscreen): + m.enterFullscreenOutput() + case key.Matches(msg, keys.Copy): + return *m, m.copyOutput(false) + case key.Matches(msg, keys.CopyRaw): + return *m, m.copyOutput(true) + case key.Matches(msg, keys.Save): + return *m, m.askWhereToSave(false) + case key.Matches(msg, keys.SaveAll): + return *m, m.askWhereToSave(true) + case key.Matches(msg, keys.Navigator): + m.toggleTaskNavigator() + case key.Matches(msg, keys.Page): + m.focus = outputPane + return *m, m.updateViewport(msg) + case key.Matches(msg, keys.Move): + if m.focus == taskPane { + if msg.String() == "up" || msg.String() == "k" { + m.moveSelection(-1) + } else { + m.moveSelection(1) + } + return *m, nil + } + return *m, m.updateViewport(msg) + case key.Matches(msg, keys.Top): + if m.focus == taskPane { + m.selectBoundary(false) + } else { + m.viewport.GotoTop() + m.saveViewport() + } + case key.Matches(msg, keys.Bottom): + if m.focus == taskPane { + m.selectBoundary(true) + } else { + m.viewport.GotoBottom() + m.saveViewport() + } + } + return *m, nil +} + +// togglePane moves focus. The arrow and vi keys name a direction, so they pick +// a pane outright; tab cycles. +func (m *tuiModel) togglePane(msg tea.KeyPressMsg) { + switch msg.String() { + case "left", "h": + m.focus = taskPane + case "right", "l": + m.focus = outputPane + default: + m.toggleFocus() + } +} + +// requestQuit closes the TUI, cancelling execution first if it is still running. +func (m *tuiModel) requestQuit() tea.Cmd { + if m.done { + return tea.Quit + } + m.quitting = true + m.cancel() + return nil +} + +// startElapsedTicker schedules a redraw a second from now, but only while a +// task is running and only if one is not already pending. A dashboard whose +// tasks have all finished draws nothing and costs nothing. +func (m *tuiModel) startElapsedTicker() tea.Cmd { + if m.ticking || !m.anyRunning() { + return nil + } + m.ticking = true + return tea.Tick(time.Second, func(time.Time) tea.Msg { return elapsedTickMsg{} }) +} + +func (m tuiModel) anyRunning() bool { + for _, task := range m.tasks { + if task.state == taskRunning { + return true + } + } + return false +} + +// elapsed is how long a task ran, or has been running so far. +func (m tuiModel) elapsed(task *tuiTask) time.Duration { + if task.startedAt.IsZero() { + return 0 + } + if task.finishedAt.IsZero() { + return time.Since(task.startedAt) + } + return task.finishedAt.Sub(task.startedAt) +} + +// taskExitCode is the status the task's own command exited with. A task that +// failed because a dependency did carries that dependency's error, so the code +// is reported only when the error names this task. +func taskExitCode(name string, err error) *int { + runErr, ok := errors.AsType[*errors.TaskRunError](err) + if !ok || runErr.TaskName != name { + return nil + } + code := runErr.TaskExitCode() + return &code +} + +// toggleTaskNavigator switches the task pane between the tree and the flat +// list. A deep tree is sometimes easier to read flattened, and the choice is +// cheap enough to make while a run is going. +func (m *tuiModel) toggleTaskNavigator() { + if m.taskNavigator == taskNavigatorTree { + m.taskNavigator = taskNavigatorList + } else { + m.taskNavigator = taskNavigatorTree + } + m.keepSelectionVisible() +} + +func returnToLauncher() tea.Msg { + return returnToLauncherMsg{} +} diff --git a/internal/tui/prompt.go b/internal/tui/prompt.go new file mode 100644 index 0000000000..0410c22672 --- /dev/null +++ b/internal/tui/prompt.go @@ -0,0 +1,315 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/go-task/task/v3" +) + +// promptKind is which question is being asked. +type promptKind uint8 + +const ( + promptConfirm promptKind = iota + promptText + promptChoice +) + +// promptAnswer travels back to the goroutine that asked. +type promptAnswer struct { + confirmed bool + value any + err error +} + +// promptState is a question waiting on screen. Only one exists at a time: the +// task that asked is blocked until it is answered. +type promptState struct { + kind promptKind + task string + message string + name string + options []string + cursor int + input textinput.Model + done chan promptAnswer +} + +type promptRequestedMsg struct{ state *promptState } + +// Confirm asks whether to run a task that declares "prompt". +func (t *UI) Confirm(taskName, message string) (bool, error) { + answer := t.ask(&promptState{kind: promptConfirm, task: taskName, message: message}) + return answer.confirmed, answer.err +} + +// Ask asks for a required variable that was not supplied. +func (t *UI) Ask(request task.VarRequest) (any, error) { + state := &promptState{kind: promptText, task: request.Task, name: request.Name} + switch varType := request.Type.(type) { + case task.EnumVar: + state.kind = promptChoice + state.options = varType.Options + case task.StringVar: + default: + // Guessing would produce a value the task then acts on. + return nil, fmt.Errorf("task: the TUI cannot ask for a %T variable", varType) + } + answer := t.ask(state) + return answer.value, answer.err +} + +// ask puts a question on screen and waits for it to be answered. Serialised, +// because the screen holds one question at a time and tasks may ask at once. +func (t *UI) ask(state *promptState) promptAnswer { + t.promptMutex.Lock() + defer t.promptMutex.Unlock() + + state.done = make(chan promptAnswer, 1) + if !t.send(promptRequestedMsg{state: state}) { + // Nothing is drawing, so there is nobody to ask. + return promptAnswer{err: task.ErrPromptCancelled} + } + return awaitAnswer(state.done, t.programDone) +} + +// awaitAnswer waits for the user's answer, giving up if the interface stops +// first. +// +// Without the second case a task would wait for an answer that can no longer +// come, and Task would hang rather than exit. +func awaitAnswer(done <-chan promptAnswer, programDone <-chan struct{}) promptAnswer { + // An answer already given wins even if the interface has since stopped: + // the user answered, and select would otherwise pick at random. + select { + case answer := <-done: + return answer + default: + } + + select { + case answer := <-done: + return answer + case <-programDone: + return promptAnswer{err: task.ErrPromptCancelled} + } +} + +// beginPrompt puts a question on screen. +// confirmOptions are the answers to a confirmation. No is the default, which is +// what Task offers on the terminal when it renders "[y/N]". +var confirmOptions = []string{"yes", "no"} + +const confirmDefault = 1 // "no" + +func (m *tuiModel) beginPrompt(state *promptState) tea.Cmd { + if state.kind == promptConfirm { + state.options = confirmOptions + state.cursor = confirmDefault + } + if state.kind == promptText { + input := textinput.New() + input.Prompt = "" + input.Placeholder = "type a value" + input.Focus() + state.input = input + } + m.prompt = state + if state.kind == promptText { + return textinput.Blink + } + return nil +} + +// answerPrompt hands the answer back and takes the question off screen. +func (m *tuiModel) answerPrompt(answer promptAnswer) { + if m.prompt == nil { + return + } + m.prompt.done <- answer + m.prompt = nil +} + +func (m *tuiModel) handlePromptKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + state := m.prompt + if msg.String() == "ctrl+c" { + // Ctrl+C closes the interface, as it does everywhere else. Answer the + // question first, or the task waiting on it never returns. + m.answerPrompt(promptAnswer{err: task.ErrPromptCancelled}) + return *m, m.requestQuit() + } + switch state.kind { + case promptConfirm: + switch msg.String() { + case "up", "k": + state.cursor = max(state.cursor-1, 0) + case "down", "j": + state.cursor = min(state.cursor+1, len(state.options)-1) + case "enter": + m.answerPrompt(promptAnswer{confirmed: state.options[state.cursor] == "yes"}) + case "y", "Y": + m.answerPrompt(promptAnswer{confirmed: true}) + case "n", "N", "esc": + m.answerPrompt(promptAnswer{}) + } + case promptChoice: + switch msg.String() { + case "up", "k": + state.cursor = max(state.cursor-1, 0) + case "down", "j": + state.cursor = min(state.cursor+1, len(state.options)-1) + case "enter": + if len(state.options) > 0 { + m.answerPrompt(promptAnswer{value: state.options[state.cursor]}) + } + case "esc": + m.answerPrompt(promptAnswer{err: task.ErrPromptCancelled}) + } + case promptText: + switch msg.String() { + case "enter": + m.answerPrompt(promptAnswer{value: state.input.Value()}) + case "esc": + m.answerPrompt(promptAnswer{err: task.ErrPromptCancelled}) + default: + var cmd tea.Cmd + state.input, cmd = state.input.Update(msg) + return *m, cmd + } + } + return *m, nil +} + +// promptView draws the question as a dialog over the dashboard. +// +// A blocking question is an interruption, not a place you navigated to, and a +// box over the interface reads that way. It also tells it apart from the key +// list, which fills the screen because it is a reference you asked for. +func (m tuiModel) promptView() string { + width, height := max(m.width, 1), max(m.height, 1) + box := m.promptBox(width, height) + x := max((width-lipgloss.Width(box))/2, 0) + y := max((height-lipgloss.Height(box))/2, 0) + + // A Compositor is what applies a layer's position; Canvas.Compose draws + // into the whole canvas and ignores it. + return lipgloss.NewCanvas(width, height). + Compose(lipgloss.NewCompositor( + lipgloss.NewLayer(m.renderContent()), + lipgloss.NewLayer(box).X(x).Y(y).Z(1), + )). + Render() +} + +// promptBox is the dialog itself, sized to its content within the screen. +func (m tuiModel) promptBox(screenWidth, screenHeight int) string { + state := m.prompt + outer := min(max(screenWidth-8, 24), 72) + inner := max(outer-tuiPanelStyle.GetHorizontalFrameSize(), 1) + // A long list of options must not grow the box past the screen. + maxHeight := max(screenHeight-2, 3) + + var body strings.Builder + body.WriteString(tuiTitleStyle.Render(truncateText( + fmt.Sprintf("Task %q is asking", state.task), inner))) + body.WriteString("\n\n") + + switch state.kind { + case promptConfirm: + body.WriteString(wrapText(state.message, inner)) + body.WriteString("\n") + body.WriteString(promptOptions(state, inner)) + case promptText: + body.WriteString(wrapText(state.name, inner)) + body.WriteString("\n\n") + state.input.SetWidth(max(inner-1, 1)) + body.WriteString(state.input.View()) + case promptChoice: + body.WriteString(wrapText(state.name, inner)) + body.WriteString("\n") + body.WriteString(promptOptions(state, inner)) + } + + // The keys belong to the dialog, not to the interface behind it, which + // cannot be acted on while a question is up. + body.WriteString("\n\n") + body.WriteString(renderPromptKeys(m, inner, m.promptKeys())) + + return tuiPanelStyle. + BorderForeground(tuiAccentColor). + Width(outer). + MaxWidth(outer). + MaxHeight(maxHeight). + Render(body.String()) +} + +// promptOptions lists the answers, marking the one Enter would pick. Showing +// the default rather than encoding it, as "[y/N]" does, means it cannot be +// misread. +func promptOptions(state *promptState, width int) string { + var out strings.Builder + for i, option := range state.options { + line := truncateText(" "+option, width) + if i == state.cursor { + // Highlight the whole row, as the launcher does. + line = tuiSelectedStyle.Width(width).Render(line) + } + out.WriteString("\n" + line) + } + return out.String() +} + +// promptKeys are the footer hints while a question is on screen. +func (m tuiModel) promptKeys() []helpBinding { + switch m.prompt.kind { + case promptConfirm: + return []helpBinding{ + {"↑/↓", "choose"}, {"enter", "confirm"}, {"y/n", "answer"}, {"esc", "no"}, + } + case promptChoice: + return []helpBinding{{"↑/↓", "choose"}, {"enter", "confirm"}, {"esc", "cancel"}} + default: + return []helpBinding{{"enter", "confirm"}, {"esc", "cancel"}} + } +} + +// wrapText breaks text to width without splitting words, for a message written +// by a Taskfile author who did not know how wide the pane would be. +func wrapText(text string, width int) string { + width = max(width, 1) + var out strings.Builder + line := "" + for word := range strings.FieldsSeq(text) { + switch { + case line == "": + line = word + case lipgloss.Width(line)+1+lipgloss.Width(word) <= width: + line += " " + word + default: + out.WriteString(truncateText(line, width) + "\n") + line = word + } + } + out.WriteString(truncateText(line, width)) + return out.String() +} + +type helpBinding struct{ key, desc string } + +func renderPromptKeys(m tuiModel, width int, keys []helpBinding) string { + var line strings.Builder + for i, k := range keys { + if i > 0 { + line.WriteString(m.help.Styles.ShortSeparator.Inline(true).Render(m.help.ShortSeparator)) + } + line.WriteString(m.help.Styles.ShortKey.Inline(true).Render(k.key)) + line.WriteString(" ") + line.WriteString(m.help.Styles.ShortDesc.Inline(true).Render(k.desc)) + } + return truncateText(line.String(), max(width, 1)) +} diff --git a/internal/tui/save.go b/internal/tui/save.go new file mode 100644 index 0000000000..3795b92116 --- /dev/null +++ b/internal/tui/save.go @@ -0,0 +1,299 @@ +package tui + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/go-task/task/v3/errors" +) + +// savedMsg reports the outcome of writing output to disk. +type savedMsg struct { + path string + count int + err error +} + +// savedOutput is one task's output, copied out of the model so the writing can +// happen off the update loop. +type savedOutput struct { + name string + content string +} + +// saveState is a pending save, waiting for the user to say where. +type saveState struct { + all bool + outputs []savedOutput + stamp string // shared by every file of one save, so a run stays together + input textinput.Model +} + +// savedAtLayout stamps a file name with when it was saved. Colons are not +// usable in a file name on Windows, so the ISO form is spelled with dashes. +const savedAtLayout = "2006-01-02T15-04-05" + +// generatedFileName is what a single saved output is called. +func generatedFileName(stamp, taskName string) string { + return generatedName(stamp, taskName) + ".log" +} + +// generatedName is the name of one run: which task, then when. Used for a +// single file and for the folder a whole run is saved into. +// +// The task leads because time ordering is already free from ls -t, while +// nothing but the name groups a task's logs together. It also lets shell +// completion narrow on a task without having to know the date. +func generatedName(stamp, taskName string) string { + return fileNameFor(taskName) + "." + stamp +} + +// defaultSaveDir is where logs go unless the user says otherwise. +// +// A logs directory beside the project, rather than one shared by every project +// in the home directory, where a "build" log from four repositories would be +// indistinguishable. Visible rather than inside .task, which is Task's own and +// gets deleted when checksums go stale. +func defaultSaveDir() string { + return "logs" +} + +// askWhereToSave puts a path field in the footer, filled in with a default so +// that Enter alone is enough. +func (m *tuiModel) askWhereToSave(all bool) tea.Cmd { + var outputs []savedOutput + if all { + for _, task := range m.tasks { + if task.output != "" { + outputs = append(outputs, savedOutput{name: m.taskName(task), content: task.output}) + } + } + } else if task := m.selectedTask(); task != nil && task.output != "" { + outputs = append(outputs, savedOutput{name: m.taskName(task), content: task.output}) + } + if len(outputs) == 0 { + return m.showNotice("nothing to save") + } + + stamp := time.Now().Format(savedAtLayout) + name := outputs[0].name + if all { + name = m.runName() + } + suggestion := filepath.Join(defaultSaveDir(), generatedFileName(stamp, name)) + if all { + // A folder, not a file: the run is the folder, and the tasks are the + // files inside it. + suggestion = filepath.Join(defaultSaveDir(), generatedName(stamp, name)) + } + input := textinput.New() + input.Prompt = "" + input.SetValue(suggestion) + input.Focus() + + m.save = &saveState{all: all, outputs: outputs, stamp: stamp, input: input} + return textinput.Blink +} + +func (m *tuiModel) handleSaveKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + state := m.save + m.save = nil + return *m, saveOutputs(state, strings.TrimSpace(state.input.Value())) + case "esc", "ctrl+c": + m.save = nil + return *m, nil + } + var cmd tea.Cmd + m.save.input, cmd = m.save.input.Update(msg) + return *m, cmd +} + +// saveOutputs writes to the path the user gave, creating any directories it +// needs. The path was typed deliberately, so an existing file is replaced, as +// a shell redirect would. +func saveOutputs(state *saveState, target string) tea.Cmd { + return func() tea.Msg { + if target == "" { + return savedMsg{err: errors.New("no path given")} + } + path, err := expandHome(target) + if err != nil { + return savedMsg{err: err} + } + + if !state.all { + if err := makeSaveDir(filepath.Dir(path)); err != nil { + return savedMsg{err: err} + } + if err := os.WriteFile(path, []byte(state.outputs[0].content), 0o600); err != nil { + return savedMsg{err: err} + } + return savedMsg{path: path, count: 1} + } + + if err := makeSaveDir(path); err != nil { + return savedMsg{err: err} + } + used := make(map[string]bool, len(state.outputs)) + for _, output := range state.outputs { + // The folder already says which run this was and when, so a file + // only has to say which task it came from. + name := unusedName(fileNameFor(output.name)+".log", used) + if err := os.WriteFile(filepath.Join(path, name), []byte(output.content), 0o600); err != nil { + return savedMsg{err: err} + } + } + return savedMsg{path: path, count: len(state.outputs)} + } +} + +// unusedName keeps two tasks whose names clean up to the same thing from +// writing over each other. +func unusedName(name string, used map[string]bool) string { + candidate := name + base, extension := strings.TrimSuffix(name, ".log"), ".log" + for attempt := 1; used[candidate]; attempt++ { + candidate = fmt.Sprintf("%s-%d%s", base, attempt, extension) + } + used[candidate] = true + return candidate +} + +// expandHome resolves a leading ~, which a user typing a path will expect. +func expandHome(path string) (string, error) { + if path != "~" && !strings.HasPrefix(path, "~/") { + return path, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, strings.TrimPrefix(path, "~")), nil +} + +// fileNameFor makes a task name safe to use as a file name. Task names carry +// namespace colons and wildcards, and a label can be any text at all. +func fileNameFor(name string) string { + var out strings.Builder + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-', r == '_': + out.WriteRune(r) + default: + out.WriteRune('-') + } + } + cleaned := strings.Trim(collapseDashes(out.String()), "-.") + if cleaned == "" { + return "task" + } + return cleaned +} + +func collapseDashes(s string) string { + for strings.Contains(s, "--") { + s = strings.ReplaceAll(s, "--", "-") + } + return s +} + +// saveFooter renders the path field in place of the key hints. +func (m tuiModel) saveFooter(width int) string { + label := "Save to: " + if m.save.all { + label = "Save all to folder: " + } + keys := renderPromptKeys(m, width, []helpBinding{{"enter", "save"}, {"esc", "cancel"}}) + + room := max(width-lipgloss.Width(label)-lipgloss.Width(keys)-3, 8) + input := m.save.input + input.SetWidth(room) + line := tuiTitleStyle.Render(label) + input.View() + " " + keys + return truncateText(line, max(width, 1)) +} + +// saveError is what to tell the user when a save fails. +// +// A filesystem error repeats the path, which the user typed a moment ago and +// can still see. Keeping it pushes the reason, the only part they do not know, +// off the end of the footer. +func saveError(err error) string { + var pathErr *fs.PathError + if errors.As(err, &pathErr) { + return pathErr.Err.Error() + } + return err.Error() +} + +// runName names the run, for the folder a whole run is saved into. That is the +// task the user asked for, rather than whichever one happens to be selected. +func (m tuiModel) runName() string { + var root *tuiTask + for _, task := range m.tasks { + if task.isRoot && (root == nil || task.id < root.id) { + root = task + } + } + if root != nil { + return m.taskName(root) + } + if selected := m.selectedTask(); selected != nil { + return m.taskName(selected) + } + return "task" +} + +// ignoreMarker is what Task writes into a logs directory it created, so a +// directory the user did not make does not turn up in git status, or get +// committed by a stray git add. +const ignoreMarker = "# Created by Task. Delete this file to track saved output.\n*\n" + +// makeSaveDir creates dir and its parents. +// +// When it is Task that creates the default logs directory, it leaves a +// .gitignore behind. A directory the user typed themselves is theirs, and one +// that already exists is left exactly as it is. +func makeSaveDir(dir string) error { + head := firstPathElement(dir) + ours := head == defaultSaveDir() && !pathExists(head) + + if err := os.MkdirAll(dir, 0o750); err != nil { + return err + } + if !ours { + return nil + } + marker := filepath.Join(head, ".gitignore") + if pathExists(marker) { + return nil + } + return os.WriteFile(marker, []byte(ignoreMarker), 0o600) +} + +// firstPathElement is the leading directory of a relative path, and empty for +// an absolute one, which the user asked for by name. +func firstPathElement(path string) string { + cleaned := filepath.Clean(path) + if filepath.IsAbs(cleaned) { + return "" + } + if index := strings.IndexRune(cleaned, filepath.Separator); index >= 0 { + return cleaned[:index] + } + return cleaned +} + +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/internal/tui/tasks.go b/internal/tui/tasks.go new file mode 100644 index 0000000000..ff79452296 --- /dev/null +++ b/internal/tui/tasks.go @@ -0,0 +1,523 @@ +package tui + +import ( + "cmp" + "fmt" + "slices" + "strings" + "time" + "unicode/utf8" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) + +func (m *tuiModel) scheduleTask(invocation taskInvocation) *tuiTask { + if task := m.byID[invocation.ID]; task != nil { + // A call is announced under the name written in the Taskfile, before + // compilation resolves labels and included-taskfile prefixes. + if invocation.Name != "" { + task.name = invocation.Name + } + return task + } + isRoot := invocation.ID == invocation.RootID + task := &tuiTask{ + id: invocation.ID, + parentID: invocation.ParentID, + rootID: invocation.RootID, + name: invocation.Name, + isRoot: isRoot, + state: taskPending, + followOutput: true, + } + for _, candidate := range m.tasks { + if candidate.ownerID == task.id { + task.shared = true + break + } + } + m.byID[invocation.ID] = task + m.tasks = append(m.tasks, task) + if !m.hasSelect { + m.selectedID = task.id + m.hasSelect = true + m.refreshOutputView() + } else if !task.isRoot { + // Prefer the first child for orchestration roots, while keeping a root + // selected if it has already produced output of its own. + selected := m.selectedRowTask() + if selected != nil && selected.isRoot && selected.output == "" { + m.selectedID = task.id + m.refreshOutputView() + } + } else if m.selectedID == task.id { + m.refreshOutputView() + } + return task +} + +func (m *tuiModel) joinTask(id, ownerID uint64) { + task := m.byID[id] + if task == nil { + return + } + task.shared = true + task.ownerID = ownerID + if owner := m.byID[ownerID]; owner != nil { + owner.shared = true + } + if m.taskNavigator == taskNavigatorList && !task.isRoot && m.hasSelect && m.selectedID == id { + m.saveViewport() + m.selectedID = ownerID + m.hasSelect = ownerID != 0 + m.refreshOutputView() + } else if m.hasSelect && m.selectedID == id { + m.refreshOutputView() + } + m.keepSelectionVisible() +} + +func (m *tuiModel) ensureOutputTask(id uint64, name string) *tuiTask { + if task := m.byID[id]; task != nil { + return task + } + if name == "" { + name = fmt.Sprintf("task %d", id) + } + task := &tuiTask{id: id, name: name, state: taskPending, followOutput: true} + m.byID[id] = task + m.tasks = append(m.tasks, task) + if !m.hasSelect { + m.selectedID = task.id + m.hasSelect = true + m.refreshOutputView() + } + return task +} + +func (m *tuiModel) appendOutput(id uint64, name, data string) { + task := m.ensureOutputTask(id, name) + if task.state == taskPending && id != 0 { + task.state = taskRunning + } + task.output, task.pendingRedraw = appendOutputText(task.output, data, task.pendingRedraw) + if len(task.output) > maxTaskOutputLen { + task.output = trimPartialRune(task.output[len(task.output)-maxTaskOutputLen:]) + task.truncated = true + } + if selected := m.selectedTask(); selected != nil && selected.id == task.id { + m.refreshOutputView() + } +} + +// trimPartialRune drops the leading bytes of the rune that slicing the output +// buffer at a fixed byte length may have cut in half. +func trimPartialRune(s string) string { + for len(s) > 0 && !utf8.RuneStart(s[0]) { + s = s[1:] + } + return s +} + +// copyOutput puts the selected task's output on the system clipboard. +// +// Colours are stripped unless keepColours is set. Plain text is the default +// because copied output usually lands somewhere that cannot render escape +// sequences, such as an issue or a chat message, and because selecting text in +// a terminal yields the characters rather than the sequences that coloured +// them. Keeping them is worth a key of its own for pasting into something that +// does render them, such as an editor with an ANSI extension. +func (m *tuiModel) copyOutput(keepColours bool) tea.Cmd { + task := m.selectedTask() + if task == nil || task.output == "" { + return m.showNotice("nothing to copy") + } + text := copyText(task.output, keepColours) + // Send both. OSC 52 reaches a terminal we are talking to over SSH; the + // helper reaches terminals that ignore OSC 52. Whichever lands, lands. + return tea.Batch( + tea.SetClipboard(text), + copyToSystemClipboard(text, keepColours), + ) +} + +// copyText is what a copy puts on the clipboard for the given task output. +func copyText(output string, keepColours bool) string { + if keepColours { + return output + } + return ansi.Strip(output) +} + +// showNotice replaces the controls with a short message that clears itself. +func (m *tuiModel) showNotice(text string) tea.Cmd { + m.noticeID++ + m.notice = text + id := m.noticeID + return tea.Tick(noticeDuration, func(time.Time) tea.Msg { + return noticeExpiredMsg{id: id} + }) +} + +func humanizeBytes(n int) string { + switch { + case n >= 1<<20: + return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%.1f KB", float64(n)/(1<<10)) + default: + return fmt.Sprintf("%d B", n) + } +} + +func (m *tuiModel) refreshOutputView() { + if m.fullscreenOutput { + m.syncFullscreenOutput() + } else { + m.loadViewport() + } +} + +func (m tuiModel) taskName(task *tuiTask) string { + key := m.taskNameKey(task) + count, occurrence := 0, 0 + for _, candidate := range m.tasks { + if !m.taskVisible(candidate) || m.taskNameKey(candidate) != key { + continue + } + count++ + if candidate.id <= task.id { + occurrence++ + } + } + if count > 1 { + return fmt.Sprintf("#%d %s", occurrence, task.name) + } + return task.name +} + +func (m tuiModel) taskNameKey(task *tuiTask) tuiTaskKey { + groupID := uint64(0) + if !task.isRoot { + groupID = task.rootID + } + if !task.isRoot && m.taskNavigator == taskNavigatorTree { + groupID = task.parentID + } + return tuiTaskKey{groupID: groupID, name: task.name, isRoot: task.isRoot} +} + +func (m tuiModel) taskVisible(task *tuiTask) bool { + return m.taskNavigator == taskNavigatorTree || task.isRoot || task.ownerID == 0 +} + +func (m tuiModel) taskState(task *tuiTask) taskState { + return m.taskOwner(task).state +} + +// taskOwner is the invocation that actually ran, which for a joined task is +// the one it waited on rather than the task itself. +func (m tuiModel) taskOwner(task *tuiTask) *tuiTask { + if task.ownerID != 0 { + if owner := m.byID[task.ownerID]; owner != nil { + return owner + } + } + return task +} + +type tuiTaskRow struct { + task *tuiTask + treePrefix string +} + +func (m tuiModel) taskRows() []tuiTaskRow { + if m.taskNavigator == taskNavigatorTree { + return m.treeTaskRows() + } + return m.listTaskRows() +} + +func (m tuiModel) listTaskRows() []tuiTaskRow { + childrenByRoot := make(map[uint64][]*tuiTask) + var roots, standalone []*tuiTask + for _, task := range m.tasks { + if !m.taskVisible(task) { + continue + } + if task.isRoot { + roots = append(roots, task) + continue + } + if task.rootID == 0 { + standalone = append(standalone, task) + } else { + childrenByRoot[task.rootID] = append(childrenByRoot[task.rootID], task) + } + } + sortTasksByID(roots) + sortTasksByID(standalone) + for _, children := range childrenByRoot { + sortTasksByID(children) + } + + rows := make([]tuiTaskRow, 0, len(m.tasks)) + for _, root := range roots { + rows = append(rows, tuiTaskRow{task: root}) + children := childrenByRoot[root.id] + for i, child := range children { + prefix := "├─ " + if i == len(children)-1 { + prefix = "└─ " + } + rows = append(rows, tuiTaskRow{task: child, treePrefix: prefix}) + } + } + for _, task := range standalone { + rows = append(rows, tuiTaskRow{task: task}) + } + return rows +} + +func (m tuiModel) treeTaskRows() []tuiTaskRow { + childrenByParent := make(map[uint64][]*tuiTask) + var roots, standalone []*tuiTask + for _, task := range m.tasks { + if !m.taskVisible(task) { + continue + } + if task.isRoot { + roots = append(roots, task) + continue + } + parentID := task.parentID + if m.byID[parentID] == nil { + parentID = 0 + } + if parentID == 0 { + standalone = append(standalone, task) + } else { + childrenByParent[parentID] = append(childrenByParent[parentID], task) + } + } + sortTasksByID(roots) + sortTasksByID(standalone) + for _, children := range childrenByParent { + sortTasksByID(children) + } + + rows := make([]tuiTaskRow, 0, len(m.tasks)) + for _, root := range roots { + rows = append(rows, tuiTaskRow{task: root}) + rows = appendTaskRows(rows, root.id, nil, childrenByParent) + } + for _, task := range standalone { + rows = append(rows, tuiTaskRow{task: task}) + } + return rows +} + +func sortTasksByID(tasks []*tuiTask) { + slices.SortFunc(tasks, func(a, b *tuiTask) int { + return cmp.Compare(a.id, b.id) + }) +} + +func appendTaskRows(rows []tuiTaskRow, parentID uint64, ancestorLast []bool, childrenByParent map[uint64][]*tuiTask) []tuiTaskRow { + children := childrenByParent[parentID] + for i, child := range children { + last := i == len(children)-1 + var prefix strings.Builder + prefix.Grow((len(ancestorLast) + 1) * 3) + for _, wasLast := range ancestorLast { + if wasLast { + prefix.WriteString(" ") + } else { + prefix.WriteString("│ ") + } + } + if last { + prefix.WriteString("└─ ") + } else { + prefix.WriteString("├─ ") + } + rows = append(rows, tuiTaskRow{task: child, treePrefix: prefix.String()}) + rows = appendTaskRows(rows, child.id, append(ancestorLast, last), childrenByParent) + } + return rows +} + +func (m *tuiModel) selectedRowTask() *tuiTask { + if !m.hasSelect { + return nil + } + return m.byID[m.selectedID] +} + +func (m *tuiModel) selectedTask() *tuiTask { + task := m.selectedRowTask() + if task != nil && task.ownerID != 0 { + if owner := m.byID[task.ownerID]; owner != nil { + return owner + } + } + return task +} + +func (m *tuiModel) selectedIndex() int { + for i, row := range m.taskRows() { + if row.task.id == m.selectedID { + return i + } + } + return -1 +} + +func (m *tuiModel) moveSelection(delta int) { + rows := m.taskRows() + if len(rows) == 0 { + return + } + index := m.selectedIndex() + if index < 0 { + if delta < 0 { + m.selectBoundary(true) + } else { + m.selectBoundary(false) + } + return + } + index += delta + if index >= 0 && index < len(rows) { + m.selectTask(index) + } +} + +func (m *tuiModel) selectTask(index int) { + rows := m.taskRows() + if index < 0 || index >= len(rows) { + return + } + m.saveViewport() + m.selectedID = rows[index].task.id + m.hasSelect = true + m.keepSelectionVisible() + m.loadViewport() +} + +func (m *tuiModel) selectBoundary(last bool) { + rows := m.taskRows() + if len(rows) == 0 { + return + } + if last { + m.selectTask(len(rows) - 1) + return + } + m.selectTask(0) +} + +func (m *tuiModel) keepSelectionVisible() { + index := m.selectedIndex() + if index < 0 { + return + } + visible := max(newTUILayout(m.width, m.height).innerHeight-1, 1) + if index < m.listTop { + m.listTop = index + } else if index >= m.listTop+visible { + m.listTop = index - visible + 1 + } + maxTop := max(len(m.taskRows())-visible, 0) + m.listTop = min(max(m.listTop, 0), maxTop) +} + +func (m *tuiModel) resizeViewport() { + layout := newTUILayout(m.width, m.height) + m.viewport.SetWidth(layout.rightInnerWidth) + m.viewport.SetHeight(max(layout.innerHeight-1, 1)) +} + +func (m *tuiModel) loadViewport() { + task := m.selectedTask() + if task == nil { + m.viewport.SetContent("") + return + } + content := task.output + if task.truncated { + content = tuiHelpStyle.Render("… earlier output was discarded …") + "\n" + content + } + m.viewport.SetContent(content) + if task.followOutput { + m.viewport.GotoBottom() + } else { + m.viewport.SetYOffset(task.scrollOffset) + } +} + +func (m *tuiModel) saveViewport() { + task := m.selectedTask() + if task == nil { + return + } + task.scrollOffset = m.viewport.YOffset() + task.followOutput = m.viewport.AtBottom() +} + +func (m *tuiModel) updateViewport(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + m.saveViewport() + return cmd +} + +func (m *tuiModel) toggleFocus() { + if m.focus == taskPane { + m.focus = outputPane + } else { + m.focus = taskPane + } +} + +func (m *tuiModel) handleMouseClick(mouse tea.Mouse) { + layout := newTUILayout(m.width, m.height) + if mouse.Y < 0 || mouse.Y >= layout.bodyHeight { + return + } + if mouse.X >= 0 && mouse.X < layout.leftOuterWidth { + m.focus = taskPane + // Border is row 0 and the title is row 1, so tasks begin at row 2. + row := mouse.Y - 2 + if row >= 0 { + m.selectTask(m.listTop + row) + } + return + } + if mouse.X >= layout.leftOuterWidth+layout.gap { + m.focus = outputPane + } +} + +func (m *tuiModel) handleMouseWheel(msg tea.MouseWheelMsg) tea.Cmd { + layout := newTUILayout(m.width, m.height) + if msg.Y < 0 || msg.Y >= layout.bodyHeight { + return nil + } + if msg.X < layout.leftOuterWidth { + m.focus = taskPane + switch msg.Button { + case tea.MouseWheelUp: + m.moveSelection(-1) + case tea.MouseWheelDown: + m.moveSelection(1) + } + return nil + } + if msg.X >= layout.leftOuterWidth+layout.gap { + m.focus = outputPane + return m.updateViewport(msg) + } + return nil +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000000..7ae1d9c39c --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,322 @@ +package tui + +import ( + "context" + "fmt" + "io" + "sync" + "sync/atomic" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/go-task/task/v3" + "github.com/go-task/task/v3/internal/logger" + "github.com/go-task/task/v3/internal/term" +) + +const ( + systemTaskName = "Task messages" + maxTaskOutputLen = 10 << 20 + noticeDuration = 2 * time.Second +) + +// taskInvocation aliases the executor type so the rest of this package can keep +// using "task" as a local variable name without shadowing the package. +type taskInvocation = task.Invocation + +// taskResult and its values are aliased for the same reason. +type taskResult = task.Result + +const ( + resultSucceeded = task.ResultSucceeded + resultFailed = task.ResultFailed + resultCanceled = task.ResultCanceled + resultSkipped = task.ResultSkipped +) + +// UI captures task lifecycle and output events for the interactive interface. +type UI struct { + logger *logger.Logger + input io.Reader + output io.Writer + statusLabels bool + taskNavigator tuiTaskNavigator + + mutex sync.RWMutex + program *tea.Program + // programDone is closed when the program stops, so a task waiting on an + // answer is not left waiting for one that cannot come. + programDone chan struct{} + // promptMutex serialises questions: the screen holds one at a time. + promptMutex sync.Mutex + + outputMutex sync.Mutex + pending map[uint64]pendingOutput + outputQueued bool +} + +type pendingOutput struct { + name string + data string +} + +// Options configures the execution dashboard. +type Options struct { + Status string + TaskNavigator string +} + +// New creates a terminal interface using the logger's input and output streams. +func New(log *logger.Logger, options Options) (*UI, error) { + if !log.AssumeTerm && !term.IsTerminal() { + return nil, fmt.Errorf("task: --tui requires an interactive terminal") + } + statusLabels := false + switch options.Status { + case "", "icons": + case "labels": + statusLabels = true + default: + return nil, fmt.Errorf(`task: invalid TUI status style %q: expected "icons" or "labels"`, options.Status) + } + taskNavigator := taskNavigatorTree + switch options.TaskNavigator { + case "", "tree": + case "list": + taskNavigator = taskNavigatorList + default: + return nil, fmt.Errorf(`task: invalid TUI task navigator %q: expected "list" or "tree"`, options.TaskNavigator) + } + return &UI{ + logger: log, + input: log.Stdin, + output: log.Stdout, + statusLabels: statusLabels, + taskNavigator: taskNavigator, + pending: make(map[uint64]pendingOutput), + programDone: make(chan struct{}), + }, nil +} + +// listener turns execution events into messages for the Bubble Tea program. +func (t *UI) listener() *task.Listener { + return &task.Listener{ + OwnsScreen: true, + Scheduled: func(invocation task.Invocation) { + t.send(taskScheduledMsg{task: invocation}) + }, + Started: func(started task.Started) { + t.send(taskStartedMsg{task: started.Invocation, at: started.At}) + }, + Finished: func(finished task.Finished) { + t.send(taskFinishedMsg{ + id: finished.ID, + result: finished.Result, + err: finished.Err, + at: finished.At, + duration: finished.Duration, + }) + }, + Joined: func(joined task.Joined) { + t.send(taskJoinedMsg{id: joined.ID, ownerID: joined.OwnerID}) + }, + // Route each task's command output into its own pane. + OutputFor: func(invocation task.Invocation) (io.Writer, io.Writer) { + w := &tuiWriter{ui: t, id: invocation.ID, name: invocation.Name} + return w, w + }, + } +} + +// Run opens the launcher when calls is empty, or starts the calls immediately. +func (t *UI) Run(ctx context.Context, executor *task.Executor, calls []*task.Call) error { + sessionCtx, cancelSession := context.WithCancel(ctx) + defer cancelSession() + + loadLauncher := func() (launcherModel, error) { + tasks, err := executor.GetTaskList(task.FilterOutInternal) + if err != nil { + return launcherModel{}, err + } + return newLauncherModel(tasks), nil + } + var launcher launcherModel + if len(calls) == 0 { + var err error + launcher, err = loadLauncher() + if err != nil { + return err + } + } else { + // Resolve the requested tasks before the alt screen opens, so an unknown + // task name reports itself on the terminal instead of inside a dashboard + // the user then has to quit. + for _, call := range calls { + if _, err := executor.GetTask(call); err != nil { + return err + } + } + } + + execution := newTUIModel(func() {}) + execution.statusLabels = t.statusLabels + execution.taskNavigator = t.taskNavigator + execution.canReturnToLauncher = true + + var runs sync.WaitGroup + var started atomic.Bool + var resultMutex sync.Mutex + var lastRunErr error + programReady := make(chan struct{}) + start := func(selectedCalls []*task.Call) context.CancelFunc { + runCtx, cancelRun := context.WithCancel(sessionCtx) + started.Store(true) + // Each launcher selection is an independent run. Without this, the + // second run of a "run: once" task joins the first run's finished + // execution and returns its result without executing anything. + executor.ResetRunState() + runs.Go(func() { + <-programReady + err := executor.Run(runCtx, selectedCalls...) + resultMutex.Lock() + lastRunErr = err + resultMutex.Unlock() + t.send(executionDoneMsg{ui: t, err: err}) + }) + return cancelRun + } + + var normalTask string + model := newAppModel( + launcher, + execution, + len(calls) == 0, + loadLauncher, + func(names []string) context.CancelFunc { + selectedCalls := make([]*task.Call, len(names)) + for i, name := range names { + selectedCalls[i] = &task.Call{Task: name} + } + return start(selectedCalls) + }, + func(name string) { normalTask = name }, + ) + if len(calls) > 0 { + model.execution.cancel = start(calls) + } + program := tea.NewProgram( + model, + tea.WithInput(t.input), + tea.WithOutput(t.output), + tea.WithFilter(func(_ tea.Model, msg tea.Msg) tea.Msg { + if _, ok := msg.(tea.InterruptMsg); ok { + return interruptRequestedMsg{} + } + return msg + }), + ) + + t.mutex.Lock() + t.program = program + t.mutex.Unlock() + + executor.Listener = t.listener() + executor.Prompter = t + + oldStdout, oldStderr := t.logger.Stdout, t.logger.Stderr + systemWriter := &tuiWriter{ui: t, name: systemTaskName} + t.logger.Stdout, t.logger.Stderr = systemWriter, systemWriter + var restoreOnce sync.Once + restore := func() { + restoreOnce.Do(func() { + executor.Listener = nil + executor.Prompter = nil + t.logger.Stdout, t.logger.Stderr = oldStdout, oldStderr + t.mutex.Lock() + t.program = nil + t.mutex.Unlock() + }) + } + defer restore() + close(programReady) + + go func() { + <-sessionCtx.Done() + program.Send(interruptRequestedMsg{}) + }() + finalModel, uiErr := program.Run() + close(t.programDone) + cancelSession() + runs.Wait() + if uiErr != nil { + return fmt.Errorf("task: TUI failed: %w", uiErr) + } + finalApp := finalModel.(appModel) + if finalApp.err != nil { + return finalApp.err + } + if normalTask != "" { + restore() + return executor.Run(ctx, &task.Call{Task: normalTask}) + } + if !started.Load() || finalApp.page == launcherPage { + return nil + } + resultMutex.Lock() + defer resultMutex.Unlock() + return lastRunErr +} + +// send delivers a message to the running program, reporting whether there was +// one to receive it. Everything the executor reports arrives this way, and it +// may arrive after the interface has closed. +func (t *UI) send(msg tea.Msg) bool { + t.mutex.RLock() + program := t.program + t.mutex.RUnlock() + if program == nil { + return false + } + program.Send(msg) + return true +} + +func (t *UI) enqueueOutput(id uint64, name, data string) { + t.outputMutex.Lock() + pending := t.pending[id] + pending.name = name + pending.data += data + t.pending[id] = pending + if t.outputQueued { + t.outputMutex.Unlock() + return + } + t.outputQueued = true + t.outputMutex.Unlock() + + // Sending asynchronously lets bursts of command output collapse into one + // model update instead of rebuilding the viewport for every pipe write. + go t.send(outputReadyMsg{ui: t}) +} + +func (t *UI) drainOutput() map[uint64]pendingOutput { + t.outputMutex.Lock() + defer t.outputMutex.Unlock() + output := t.pending + t.pending = make(map[uint64]pendingOutput) + t.outputQueued = false + return output +} + +type tuiWriter struct { + ui *UI + id uint64 + name string +} + +func (w *tuiWriter) Write(p []byte) (int, error) { + data := string(append([]byte(nil), p...)) + w.ui.enqueueOutput(w.id, w.name, data) + return len(p), nil +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 0000000000..8dc7d6581f --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,2160 @@ +package tui + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "sync" + "testing" + "time" + "unicode/utf8" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "mvdan.cc/sh/v3/interp" + + "github.com/go-task/task/v3" + taskerrors "github.com/go-task/task/v3/errors" + "github.com/go-task/task/v3/internal/logger" +) + +func TestNew(t *testing.T) { + t.Parallel() + + got, err := New(&logger.Logger{AssumeTerm: true}, Options{}) + require.NoError(t, err) + assert.Equal(t, taskNavigatorTree, got.taskNavigator) + + got, err = New(&logger.Logger{AssumeTerm: true}, Options{Status: "labels", TaskNavigator: "list"}) + require.NoError(t, err) + assert.True(t, got.statusLabels) + assert.Equal(t, taskNavigatorList, got.taskNavigator) + + _, err = New(&logger.Logger{AssumeTerm: true}, Options{Status: "unknown"}) + require.Error(t, err) + assert.Contains(t, err.Error(), `expected "icons" or "labels"`) + + _, err = New(&logger.Logger{AssumeTerm: true}, Options{TaskNavigator: "unknown"}) + require.Error(t, err) + assert.Contains(t, err.Error(), `expected "list" or "tree"`) +} + +func TestTUIModelTracksTasksAndOutput(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "build")) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "build", data: "compiling\r\ndone\r"}) + m = updateTUIModel(t, m, started(3, 1, "test")) + m = updateTUIModel(t, m, taskFinishedMsg{id: 2}) + m = updateTUIModel(t, m, taskFinishedMsg{id: 3, result: resultFailed, err: errors.New("failed")}) + + require.Len(t, m.tasks, 3) + assert.Equal(t, taskSucceeded, m.byID[2].state) + // The trailing carriage return returns the cursor to the start of "done" + // without erasing it, so the line stays visible until something redraws it. + assert.Equal(t, "compiling\ndone", m.byID[2].output) + assert.Equal(t, taskFailed, m.byID[3].state) + assert.Equal(t, m.width, lipgloss.Width(m.View().Content)) + assert.Equal(t, m.height, lipgloss.Height(m.View().Content)) + assert.LessOrEqual(t, lipgloss.Width(m.View().Content), m.width) + assert.LessOrEqual(t, lipgloss.Height(m.View().Content), m.height) + assert.Equal(t, tea.MouseModeCellMotion, m.View().MouseMode) + left, right := m.renderPanes(newTUILayout(m.width, m.height)) + assert.Equal(t, lipgloss.Height(left), lipgloss.Height(right)) + + m.moveSelection(1) + assert.Equal(t, uint64(3), m.selectedID) + assert.Contains(t, m.View().Content, "test") +} + +func TestTUIModelDistinguishesCanceledTasksAndShowsStatusWords(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m.statusLabels = true + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, scheduled(2, 1, "pending-task")) + m = updateTUIModel(t, m, started(3, 1, "running-task")) + m = updateTUIModel(t, m, started(4, 1, "successful-task")) + m = updateTUIModel(t, m, taskFinishedMsg{id: 4}) + m = updateTUIModel(t, m, started(5, 1, "failed-task")) + m = updateTUIModel(t, m, taskFinishedMsg{id: 5, result: resultFailed, err: errors.New("failed")}) + m = updateTUIModel(t, m, started(6, 1, "canceled-task")) + m = updateTUIModel(t, m, taskFinishedMsg{id: 6, result: resultCanceled}) + + assert.Equal(t, taskCanceled, m.byID[6].state) + assert.Equal(t, "failed\n", m.byID[5].output) + assert.Equal(t, "■", taskIconText(taskCanceled)) + list := m.taskList(50, 20) + for _, status := range []string{"pending", "running", "success", "failed", "canceled"} { + assert.Contains(t, list, status) + } + name, status := taskNameStatus("build", taskRunning, 20, true) + assert.Equal(t, "build running", name+" "+status) + name, status = taskNameStatus("fail-fast-success-1s", taskSucceeded, 13, true) + assert.Equal(t, "fa…1s success", name+" "+status) +} + +func TestTUIStatusLabelsAreOptionalAndDisabledByDefault(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "worker")) + + // Skip the pane header, which carries the state of the run as a whole. + taskRows := func(m tuiModel) string { + lines := strings.SplitN(ansi.Strip(m.taskList(30, 10)), "\n", 2) + require.Len(t, lines, 2) + return lines[1] + } + + icons := taskRows(m) + assert.Contains(t, icons, "└─ ● worker") + assert.NotContains(t, icons, "running") + m.statusLabels = true + labels := taskRows(m) + assert.Contains(t, labels, "└─ worker running") + assert.NotContains(t, labels, "●") +} + +func TestTUIModelFitsMinimumTerminalSize(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 40, Height: 8}) + m = updateTUIModel(t, m, started(1, 0, "a-task-with-a-fairly-long-name")) + + view := m.View().Content + assert.Equal(t, 40, lipgloss.Width(view)) + assert.Equal(t, 8, lipgloss.Height(view)) + assert.LessOrEqual(t, lipgloss.Width(view), 40) + assert.LessOrEqual(t, lipgloss.Height(view), 8) + left, right := m.renderPanes(newTUILayout(40, 8)) + assert.Equal(t, lipgloss.Height(left), lipgloss.Height(right)) +} + +func TestTUILayoutGivesWideTerminalsMoreTaskSpace(t *testing.T) { + t.Parallel() + + compact := newTUILayout(80, 24) + wide := newTUILayout(240, 24) + + assert.Zero(t, compact.gap) + assert.Greater(t, wide.leftOuterWidth, compact.leftOuterWidth) + assert.Equal(t, 72, wide.leftOuterWidth) + assert.Equal(t, compact.leftOuterWidth-tuiPanelStyle.GetHorizontalFrameSize(), compact.leftInnerWidth) + assert.Equal(t, compact.rightOuterWidth-tuiPanelStyle.GetHorizontalFrameSize(), compact.rightInnerWidth) + assert.Equal(t, compact.bodyHeight-tuiPanelStyle.GetVerticalFrameSize(), compact.innerHeight) +} + +func TestTUITextTruncationUsesTerminalCellWidth(t *testing.T) { + t.Parallel() + + assert.LessOrEqual(t, ansi.StringWidth(truncateText("界界界", 4)), 4) + middle := truncateMiddle("build-界界-target", 10) + assert.LessOrEqual(t, ansi.StringWidth(middle), 10) + assert.Contains(t, middle, "…") + assert.True(t, strings.HasSuffix(middle, "arget"), middle) +} + +func TestTUIModelKeepsRepeatedTaskCallsSeparate(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 20}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "worker")) + m = updateTUIModel(t, m, started(3, 1, "worker")) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: "first"}) + m = updateTUIModel(t, m, taskOutputMsg{id: 3, name: "worker", data: " second"}) + m = updateTUIModel(t, m, taskFinishedMsg{id: 2, result: resultFailed, err: errors.New("failed")}) + m = updateTUIModel(t, m, taskFinishedMsg{id: 3}) + + require.Len(t, m.tasks, 3) + assert.NotSame(t, m.byID[2], m.byID[3]) + assert.Equal(t, "first\nfailed\n", m.byID[2].output) + assert.Equal(t, " second", m.byID[3].output) + assert.Equal(t, taskFailed, m.byID[2].state) + assert.Equal(t, taskSucceeded, m.byID[3].state) + assert.Equal(t, []string{"root", "worker", "worker"}, rowNames(m.taskRows())) + assert.Contains(t, m.taskList(30, 10), "#1 worker") + assert.Contains(t, m.taskList(30, 10), "#2 worker") + + m.selectTask(2) + assert.Equal(t, " second", m.selectedTask().output) + assert.Contains(t, m.viewport.View(), " second") + assert.Contains(t, m.outputPanel(30), "#2 worker") +} + +func TestTUIModelSharesJoinedExecutionStatusAndOutput(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m.taskNavigator = taskNavigatorTree + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 20}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "worker")) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: "shared output"}) + m = updateTUIModel(t, m, scheduled(3, 1, "worker")) + require.Len(t, m.tasks, 3) + assert.Contains(t, m.taskList(30, 10), "#1 worker") + assert.Contains(t, m.taskList(30, 10), "#2 worker") + + m.selectTask(2) + m = updateTUIModel(t, m, taskJoinedMsg{id: 3, ownerID: 2}) + + require.Len(t, m.tasks, 3) + assert.Equal(t, uint64(2), m.byID[3].ownerID) + assert.True(t, m.byID[2].shared) + assert.True(t, m.byID[3].shared) + assert.Equal(t, []uint64{1, 2, 3}, rowIDs(m.taskRows())) + assert.Equal(t, "#1 worker", m.taskName(m.byID[2])) + assert.Equal(t, "#2 worker", m.taskName(m.byID[3])) + assert.Equal(t, uint64(3), m.selectedID) + assert.Equal(t, uint64(2), m.selectedTask().id) + assert.Equal(t, "shared output", m.selectedTask().output) + assert.Contains(t, m.outputPanel(30), "#2 worker") + assert.Equal(t, taskRunning, m.taskState(m.byID[3])) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: " continued"}) + assert.Contains(t, m.viewport.View(), "shared output continued") + m = updateTUIModel(t, m, taskFinishedMsg{id: 2}) + assert.Equal(t, taskSucceeded, m.taskState(m.byID[3])) + assert.Equal(t, []string{"root", "worker", "worker"}, rowNames(m.taskRows())) + assert.Equal(t, 2, strings.Count(ansi.Strip(m.taskList(30, 10)), "↳")) +} + +func TestTUIModelShowsSharedExecutionInEachTreeLocation(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m.taskNavigator = taskNavigatorTree + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "parent-a")) + m = updateTUIModel(t, m, started(3, 1, "parent-b")) + m = updateTUIModel(t, m, startedUnder(4, 2, 1, "shared")) + m = updateTUIModel(t, m, scheduledUnder(5, 3, 1, "shared")) + m = updateTUIModel(t, m, taskJoinedMsg{id: 5, ownerID: 4}) + + assert.Equal(t, []string{"root", "parent-a", "shared", "parent-b", "shared"}, rowNames(m.taskRows())) + list := ansi.Strip(m.taskList(40, 10)) + assert.Contains(t, list, "│ └─ ● ↳ shared") + assert.Contains(t, list, " └─ ● ↳ shared") + assert.NotContains(t, list, "#1 shared") + assert.NotContains(t, list, "#2 shared") +} + +func TestTUIModelMarksOwnerSharedWhenJoinEventArrivesFirst(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m.taskNavigator = taskNavigatorTree + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, scheduled(3, 1, "shared")) + m = updateTUIModel(t, m, taskJoinedMsg{id: 3, ownerID: 2}) + m = updateTUIModel(t, m, started(2, 1, "shared")) + + assert.True(t, m.byID[2].shared) + assert.True(t, m.byID[3].shared) + assert.Equal(t, []uint64{1, 2, 3}, rowIDs(m.taskRows())) + assert.Equal(t, "#1 shared", m.taskName(m.byID[2])) + assert.Equal(t, "#2 shared", m.taskName(m.byID[3])) +} + +func TestTUIModelNestsExecutionsUnderTheirParent(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m.taskNavigator = taskNavigatorTree + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(5, 0, "other-root")) + m = updateTUIModel(t, m, started(2, 1, "child")) + m = updateTUIModel(t, m, startedUnder(3, 2, 1, "grandchild")) + m = updateTUIModel(t, m, started(4, 1, "second-child")) + + rows := m.taskRows() + assert.Equal(t, []string{"root", "child", "grandchild", "second-child", "other-root"}, rowNames(rows)) + list := ansi.Strip(m.taskList(30, 10)) + lines := strings.Split(list, "\n") + require.GreaterOrEqual(t, len(lines), 5) + assert.True(t, strings.HasPrefix(lines[1], "● root"), lines[1]) + assert.True(t, strings.HasPrefix(lines[2], "├─ ● child"), lines[2]) + assert.True(t, strings.HasPrefix(lines[3], "│ └─ ● grandchild"), lines[3]) + assert.True(t, strings.HasPrefix(lines[4], "└─ ● second-child"), lines[4]) +} + +func TestTUIModelShowsMultipleIndependentRoots(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + m = updateTUIModel(t, m, started(2, 1, "compile")) + m = updateTUIModel(t, m, started(3, 0, "test")) + m = updateTUIModel(t, m, started(4, 3, "unit")) + + assert.Equal(t, []string{"build", "compile", "test", "unit"}, rowNames(m.taskRows())) + list := taskListWithoutDurations(t, m, 40, 10) + assert.Contains(t, list, "● build\n└─ ● compile") + assert.Contains(t, list, "● test\n└─ ● unit") +} + +func TestTUIModelNumbersRepeatedRootCalls(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + m = updateTUIModel(t, m, started(2, 0, "build")) + + assert.Equal(t, "#1 build", m.taskName(m.byID[1])) + assert.Equal(t, "#2 build", m.taskName(m.byID[2])) + m.selectTask(1) + m = updateTUIModel(t, m, taskJoinedMsg{id: 2, ownerID: 1}) + assert.Equal(t, []uint64{1, 2}, rowIDs(m.taskRows())) + assert.Equal(t, uint64(2), m.selectedID) + assert.Equal(t, uint64(1), m.selectedTask().id) +} + +func TestTUIModelSkipsTasksNotAttemptedWhenExecutionEnds(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, scheduled(1, 1, "first")) + m = updateTUIModel(t, m, started(2, 0, "second")) + m = updateTUIModel(t, m, taskFinishedMsg{id: 2}) + m = updateTUIModel(t, m, executionDoneMsg{}) + + assert.Equal(t, taskSkipped, m.byID[1].state) + assert.Equal(t, "○", taskIconText(taskSkipped)) + assert.Equal(t, "skipped", taskStateText(taskSkipped)) + assert.Equal(t, taskSucceeded, m.byID[2].state) +} + +func TestTUIModelPrefersFirstChildButAllowsSelectingRoot(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, scheduled(1, 1, "root")) + assert.True(t, m.hasSelect) + assert.Equal(t, uint64(1), m.selectedID) + m = updateTUIModel(t, m, scheduled(2, 1, "child")) + assert.Equal(t, taskPending, m.byID[2].state) + assert.Equal(t, uint64(2), m.selectedID) + + m.selectTask(0) + assert.Equal(t, uint64(1), m.selectedID) + m = updateTUIModel(t, m, taskFinishedMsg{id: 2}) + assert.Equal(t, taskSucceeded, m.byID[2].state) +} + +func TestTUIModelMakesRootOutputAccessible(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 20}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, taskOutputMsg{id: 1, name: "root", data: "root output\n"}) + + assert.Equal(t, uint64(1), m.selectedID) + assert.Contains(t, m.outputPanel(40), "OUTPUT · root") + assert.Contains(t, m.viewport.View(), "root output") + assert.Contains(t, ansi.Strip(m.taskList(30, 10)), "● root") +} + +func TestTUIModelKeepsRootSelectedWhenItProducedOutputBeforeChild(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 20}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, taskOutputMsg{id: 1, name: "root", data: "root output\n"}) + m = updateTUIModel(t, m, started(2, 1, "child")) + + assert.Equal(t, uint64(1), m.selectedID) + m.moveSelection(1) + assert.Equal(t, uint64(2), m.selectedID) + m.moveSelection(-1) + assert.Equal(t, uint64(1), m.selectedID) + assert.Contains(t, m.viewport.View(), "root output") +} + +func TestTUIModelListNavigatorFlattensTasksAndCollapsesSharedCalls(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m.taskNavigator = taskNavigatorList + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "parent-a")) + m = updateTUIModel(t, m, started(3, 1, "parent-b")) + m = updateTUIModel(t, m, startedUnder(4, 2, 1, "shared")) + m = updateTUIModel(t, m, scheduledUnder(5, 3, 1, "shared")) + m.selectTask(4) + assert.Equal(t, uint64(5), m.selectedID) + m = updateTUIModel(t, m, taskJoinedMsg{id: 5, ownerID: 4}) + + assert.Equal(t, []uint64{1, 2, 3, 4}, rowIDs(m.taskRows())) + assert.Equal(t, uint64(4), m.selectedID) + list := ansi.Strip(m.taskList(40, 10)) + assert.Contains(t, list, "├─ ● parent-a") + assert.Contains(t, list, "├─ ● parent-b") + assert.Contains(t, list, "└─ ● shared") + assert.NotContains(t, list, "↳") + assert.NotContains(t, list, "#1 shared") +} + +func TestTUIModelMouseSelectsTasksAndFocusesPanes(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 100, Height: 30}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "first")) + m = updateTUIModel(t, m, started(3, 1, "second")) + + // The title occupies row 1; the root is row 2 and the second child is row 4. + m = updateTUIModel(t, m, tea.MouseClickMsg{X: 5, Y: 4, Button: tea.MouseLeft}) + assert.Equal(t, uint64(3), m.selectedID) + assert.Equal(t, taskPane, m.focus) + + layout := newTUILayout(m.width, m.height) + m = updateTUIModel(t, m, tea.MouseClickMsg{X: layout.leftOuterWidth + layout.gap + 2, Y: 3, Button: tea.MouseLeft}) + assert.Equal(t, outputPane, m.focus) +} + +func TestTUIModelFullscreenOutputDisablesMouseAndShowsLiveOutput(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 12}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "worker")) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: "first\n"}) + assert.Contains(t, ansi.Strip(m.View().Content), "f fullscreen") + + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 'f', Text: "f"}) + selectionView := m.View() + assert.True(t, m.fullscreenOutput) + assert.Equal(t, tea.MouseModeNone, selectionView.MouseMode) + assert.Contains(t, ansi.Strip(selectionView.Content), "? help") + assert.NotContains(t, ansi.Strip(selectionView.Content), "drag") + assert.Contains(t, selectionView.Content, "first") + assert.NotContains(t, selectionView.Content, "TASKS") + assert.NotContains(t, selectionView.Content, "╭") + + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: "second\n"}) + assert.NotEqual(t, selectionView.Content, m.View().Content) + assert.Contains(t, m.View().Content, "second") + assert.True(t, m.fullscreenViewport.AtBottom()) + assert.Equal(t, "first\nsecond\n", m.byID[2].output) + + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: tea.KeyEscape}) + assert.False(t, m.fullscreenOutput) + assert.Equal(t, tea.MouseModeCellMotion, m.View().MouseMode) + assert.Contains(t, m.View().Content, "second") +} + +func TestTUIModelFullscreenOutputScrollsWithKeyboard(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 10}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "worker")) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: numberedLines(60)}) + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 'f', Text: "f"}) + + require.True(t, m.fullscreenViewport.AtBottom()) + bottomOffset := m.fullscreenViewport.YOffset() + require.Greater(t, bottomOffset, 0) + + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: tea.KeyPgUp}) + scrolledOffset := m.fullscreenViewport.YOffset() + assert.Less(t, scrolledOffset, bottomOffset) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "worker", data: "new output\n"}) + assert.Equal(t, scrolledOffset, m.fullscreenViewport.YOffset()) + assert.Contains(t, ansi.Strip(m.View().Content), "v select") + + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 'g', Text: "g"}) + assert.True(t, m.fullscreenViewport.AtTop()) + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 'G', Text: "G"}) + assert.True(t, m.fullscreenViewport.AtBottom()) +} + +func TestTUIModelScrollsAndRemembersEachTaskOutput(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 10}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "first")) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "first", data: numberedLines(30)}) + m = updateTUIModel(t, m, started(3, 1, "second")) + m.focus = outputPane + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: tea.KeyPgUp}) + + firstOffset := m.viewport.YOffset() + assert.Greater(t, firstOffset, 0) + assert.False(t, m.byID[2].followOutput) + + m.selectTask(2) + m.selectTask(1) + assert.Equal(t, firstOffset, m.viewport.YOffset()) + + layout := newTUILayout(m.width, m.height) + m = updateTUIModel(t, m, tea.MouseWheelMsg{ + X: layout.leftOuterWidth + layout.gap + 2, + Y: 4, + Button: tea.MouseWheelUp, + }) + assert.Less(t, m.viewport.YOffset(), firstOffset) +} + +func TestTUIModelQuitCancelsExecution(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + m := newTUIModel(cancel) + key := tea.KeyPressMsg{Code: 'q', Text: "q"} + next, cmd := m.Update(key) + require.Nil(t, cmd) + assert.ErrorIs(t, ctx.Err(), context.Canceled) + m = next.(tuiModel) + assert.True(t, m.quitting) + assert.Contains(t, m.View().Content, "waiting for processes to exit") + + next, cmd = m.Update(executionDoneMsg{}) + require.NotNil(t, cmd) + assert.IsType(t, tea.QuitMsg{}, cmd()) + assert.True(t, next.(tuiModel).done) +} + +func TestTUIModelBackCancelsBeforeReturningToLauncher(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + m := newTUIModel(cancel) + m.canReturnToLauncher = true + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + require.Nil(t, cmd) + assert.ErrorIs(t, ctx.Err(), context.Canceled) + m = next.(tuiModel) + assert.True(t, m.returning) + assert.Contains(t, m.View().Content, "returning to launcher") + + next, cmd = m.Update(executionDoneMsg{}) + require.NotNil(t, cmd) + assert.IsType(t, returnToLauncherMsg{}, cmd()) + assert.True(t, next.(tuiModel).done) +} + +func TestTUIOutputQueueCoalescesWrites(t *testing.T) { + t.Parallel() + + tui := &UI{pending: make(map[uint64]pendingOutput)} + tui.enqueueOutput(7, "build", "one") + tui.enqueueOutput(7, "build", " two") + + assert.Equal(t, map[uint64]pendingOutput{7: {name: "build", data: "one two"}}, tui.drainOutput()) + assert.False(t, tui.outputQueued) +} + +func started(id, rootID uint64, name string) taskStartedMsg { + if rootID == 0 { + rootID = id + } + parentID := rootID + if id == rootID { + parentID = 0 + } + return startedUnder(id, parentID, rootID, name) +} + +func startedUnder(id, parentID, rootID uint64, name string) taskStartedMsg { + // The executor timestamps every event, so a hand-built one does too. + return taskStartedMsg{ + task: taskInvocation{ID: id, ParentID: parentID, RootID: rootID, Task: name, Name: name}, + at: time.Now(), + } +} + +func scheduled(id, rootID uint64, name string) taskScheduledMsg { + parentID := rootID + if id == rootID { + parentID = 0 + } + return scheduledUnder(id, parentID, rootID, name) +} + +func scheduledUnder(id, parentID, rootID uint64, name string) taskScheduledMsg { + return taskScheduledMsg{task: taskInvocation{ID: id, ParentID: parentID, RootID: rootID, Task: name, Name: name}} +} + +func updateTUIModel(t *testing.T, m tuiModel, msg tea.Msg) tuiModel { + t.Helper() + next, _ := m.Update(msg) + result, ok := next.(tuiModel) + require.True(t, ok) + return result +} + +func rowNames(rows []tuiTaskRow) []string { + names := make([]string, len(rows)) + for i, row := range rows { + names[i] = row.task.name + } + return names +} + +func rowIDs(rows []tuiTaskRow) []uint64 { + ids := make([]uint64, len(rows)) + for i, row := range rows { + ids[i] = row.task.id + } + return ids +} + +func numberedLines(count int) string { + var output string + for i := range count { + output += fmt.Sprintf("line %02d\n", i) + } + return output +} + +func TestRunRejectsUnknownTasksWithoutOpeningTheTUI(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + taskfile := "version: '3'\ntasks:\n build: echo built\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o600)) + + var screen bytes.Buffer + log := &logger.Logger{ + AssumeTerm: true, + Stdin: strings.NewReader(""), + Stdout: &screen, + Stderr: &screen, + } + ui, err := New(log, Options{}) + require.NoError(t, err) + + e := task.NewExecutor(task.WithDir(dir), task.WithStdout(io.Discard), task.WithStderr(io.Discard)) + require.NoError(t, e.Setup()) + + err = ui.Run(t.Context(), e, []*task.Call{{Task: "nope"}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "nope") + assert.Empty(t, screen.String(), "the terminal must be untouched when the task cannot be resolved") + assert.Nil(t, e.Listener, "the executor must not be left with a listener attached") +} + +func TestTUIModelShowsSkippedCallsAsSkipped(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + m = updateTUIModel(t, m, scheduledUnder(2, 1, 1, "other-platform")) + m = updateTUIModel(t, m, taskFinishedMsg{id: 2, result: resultSkipped}) + + assert.Equal(t, taskSkipped, m.byID[2].state) + // A skipped call is not a failure, so its error is not written to its output. + assert.Empty(t, m.byID[2].output) +} + +func TestTUIModelShowsCallsThatNeverCompiled(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + m = updateTUIModel(t, m, scheduledUnder(2, 1, 1, "typoo")) + m = updateTUIModel(t, m, taskFinishedMsg{id: 2, result: resultFailed, err: errors.New(`task: Task "typoo" does not exist`)}) + + assert.Equal(t, taskFailed, m.byID[2].state) + assert.Contains(t, rowNames(m.taskRows()), "typoo") + assert.Contains(t, m.byID[2].output, `Task "typoo" does not exist`) +} + +func TestTUIModelRenamesCallsOnceCompilationResolvesTheName(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + // Announced under the raw Taskfile name, then started under its label. + m = updateTUIModel(t, m, scheduledUnder(2, 1, 1, "docs")) + assert.Contains(t, rowNames(m.taskRows()), "docs") + + m = updateTUIModel(t, m, startedUnder(2, 1, 1, "Build the docs")) + assert.Contains(t, rowNames(m.taskRows()), "Build the docs") + assert.NotContains(t, rowNames(m.taskRows()), "docs") +} + +func TestTUIOutputRedrawsLinesOnCarriageReturn(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + parts []string + want string + }{ + { + name: "a progress bar collapses to its last frame", + parts: []string{"Downloading 0%\rDownloading 50%\rDownloading 100%\nDone\n"}, + want: "Downloading 100%\nDone\n", + }, + { + name: "a redraw arriving in a later write replaces the line", + parts: []string{"Downloading 0%", "\rDownloading 50%", "\rDownloading 100%\n"}, + want: "Downloading 100%\n", + }, + { + name: "earlier complete lines survive a redraw", + parts: []string{"building\nDownloading 0%\rDownloading 100%\n"}, + want: "building\nDownloading 100%\n", + }, + { + name: "a trailing carriage return leaves the line visible", + parts: []string{"partial\r"}, + want: "partial", + }, + { + name: "a carriage return followed by a newline keeps the line", + parts: []string{"kept\r", "\nnext\n"}, + want: "kept\nnext\n", + }, + { + name: "a redraw spanning two writes replaces only the current line", + parts: []string{"first\nsecond\r", "third\n"}, + want: "first\nthird\n", + }, + { + name: "windows line endings stay line breaks", + parts: []string{"first\r\nsecond\r\n"}, + want: "first\nsecond\n", + }, + { + name: "output without carriage returns is untouched", + parts: []string{"one\n", "two\n"}, + want: "one\ntwo\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + for _, part := range test.parts { + m = updateTUIModel(t, m, taskOutputMsg{id: 1, name: "build", data: part}) + } + assert.Equal(t, test.want, m.byID[1].output) + }) + } +} + +func TestTrimPartialRuneKeepsOutputValid(t *testing.T) { + t.Parallel() + + // Slicing the output buffer at a fixed byte length can land inside a rune. + const text = "héllo" + for cut := range len(text) + 1 { + got := trimPartialRune(text[cut:]) + assert.True(t, utf8.ValidString(got), "cut at %d produced %q", cut, got) + assert.True(t, strings.HasSuffix(text, got), "cut at %d dropped too much: %q", cut, got) + } + assert.Equal(t, "llo", trimPartialRune(text[3:]), "the half of é must be dropped") +} + +func TestTUIModelCopiesSelectedOutputToClipboard(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + m = updateTUIModel(t, m, taskOutputMsg{id: 1, name: "build", data: "compiling\n"}) + + next, cmd := m.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + m = next.(tuiModel) + require.NotNil(t, cmd) + + // The notice reports the outcome of the copy, not the attempt. + m = updateTUIModel(t, m, clipboardCopiedMsg{size: 10, confirmed: true}) + assert.Contains(t, m.View().Content, "copied 10 B") + assert.NotContains(t, m.View().Content, "press s") + + // The notice clears itself, and a stale timer must not clear a newer one. + m.noticeID++ + m = updateTUIModel(t, m, noticeExpiredMsg{id: m.noticeID - 1}) + assert.NotEmpty(t, m.notice, "an outdated timer must not clear the current notice") + m = updateTUIModel(t, m, noticeExpiredMsg{id: m.noticeID}) + assert.Empty(t, m.notice) +} + +func TestTUIModelReportsWhenThereIsNothingToCopy(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + + next, cmd := m.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + m = next.(tuiModel) + require.NotNil(t, cmd) + assert.Contains(t, m.View().Content, "nothing to copy") +} + +func TestHumanizeBytes(t *testing.T) { + t.Parallel() + + assert.Equal(t, "12 B", humanizeBytes(12)) + assert.Equal(t, "1.0 KB", humanizeBytes(1024)) + assert.Equal(t, "1.5 MB", humanizeBytes(1024*1024*3/2)) +} + +func TestTUIModelAdmitsWhenAClipboardCopyCannotBeConfirmed(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + m = updateTUIModel(t, m, taskOutputMsg{id: 1, name: "build", data: "compiling\n"}) + + // No clipboard helper ran, so only OSC 52 was sent. It has no reply, and + // VTE-based terminals discard it, so the notice must not claim success. + m = updateTUIModel(t, m, clipboardCopiedMsg{size: 10}) + assert.Contains(t, m.View().Content, "press s to save it") +} + +func TestSystemClipboardArgsPrefersTheSessionsTool(t *testing.T) { + // Not parallel: it sets environment variables. + dir := t.TempDir() + for _, name := range []string{"wl-copy", "xclip"} { + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"), 0o700)) + } + t.Setenv("PATH", dir) + + t.Setenv("WAYLAND_DISPLAY", "wayland-0") + t.Setenv("DISPLAY", "") + args, ok := systemClipboardArgs() + require.True(t, ok) + assert.Equal(t, "wl-copy", args[0]) + + t.Setenv("WAYLAND_DISPLAY", "") + t.Setenv("DISPLAY", ":0") + args, ok = systemClipboardArgs() + require.True(t, ok) + assert.Equal(t, []string{"xclip", "-selection", "clipboard"}, args) +} + +func TestCopyToSystemClipboardReportsWhenNoHelperExists(t *testing.T) { + // Not parallel: it sets environment variables. + t.Setenv("PATH", t.TempDir()) + t.Setenv("WAYLAND_DISPLAY", "") + t.Setenv("DISPLAY", "") + + msg, ok := copyToSystemClipboard("hello", false)().(clipboardCopiedMsg) + require.True(t, ok) + assert.Equal(t, 5, msg.size) + assert.False(t, msg.confirmed, "no helper ran, so the copy cannot be confirmed") +} + +func TestTUIModelCopiesWithoutColourCodes(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + m = updateTUIModel(t, m, taskOutputMsg{ + id: 1, + name: "build", + data: "\x1b[31mFAILED\x1b[0m: two tests\n", + }) + + // The pane keeps the colours; the clipboard gets the characters, which is + // what selecting the same text in a terminal would give. + assert.Contains(t, m.byID[1].output, "\x1b[31m") + + _, cmd := m.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + require.NotNil(t, cmd) + + copied := copyText(m.byID[1].output, false) + assert.Equal(t, "FAILED: two tests\n", copied) + assert.NotContains(t, copied, "\x1b") +} + +func TestTUIModelCopiesWithColoursOnShiftY(t *testing.T) { + t.Parallel() + + const coloured = "\x1b[31mFAILED\x1b[0m\n" + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + m = updateTUIModel(t, m, taskOutputMsg{id: 1, name: "build", data: coloured}) + + assert.Equal(t, "FAILED\n", copyText(m.byID[1].output, false)) + assert.Equal(t, coloured, copyText(m.byID[1].output, true), "Y must keep the escape sequences") + + _, cmd := m.Update(tea.KeyPressMsg{Code: 'Y', Text: "Y"}) + require.NotNil(t, cmd) + + // The notice distinguishes the two, so the key teaches itself on use. + m = updateTUIModel(t, m, clipboardCopiedMsg{size: 7, confirmed: true, colours: true}) + assert.Contains(t, m.View().Content, "with colours") + m = updateTUIModel(t, m, clipboardCopiedMsg{size: 7, confirmed: true}) + assert.NotContains(t, m.View().Content, "with colours") +} + +func TestTUIModelShowsRunStateInTheTaskPaneHeader(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + header := func(m tuiModel) string { + return strings.SplitN(ansi.Strip(m.taskList(40, 10)), "\n", 2)[0] + } + assert.Contains(t, header(m), "running") + + done := updateTUIModel(t, m, executionDoneMsg{}) + assert.Contains(t, header(done), "complete") + + failed := updateTUIModel(t, m, executionDoneMsg{err: errors.New("boom")}) + assert.Contains(t, header(failed), "failed") + + // The footer stays dedicated to keys. + assert.NotContains(t, ansi.Strip(done.View().Content), "execution complete") +} + +func TestTUIModelOpensAndClosesTheKeyList(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m.canReturnToLauncher = true + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 100, Height: 24}) + m = updateTUIModel(t, m, started(1, 0, "build")) + + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: '?', Text: "?"}) + require.True(t, m.showHelp) + page := ansi.Strip(m.View().Content) + // Everything the short footer had no room for must be listed here. + for _, expected := range []string{"Y", "copy output with ANSI codes", "wheel", "click", "launcher", "quit"} { + assert.Contains(t, page, expected) + } + assert.Equal(t, tea.MouseModeNone, m.View().MouseMode) + + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 'x', Text: "x"}) + assert.False(t, m.showHelp, "any key returns from the key list") +} + +func TestTUIViewsFitTheTerminal(t *testing.T) { + t.Parallel() + + for _, size := range []struct{ width, height int }{{40, 10}, {80, 24}, {200, 60}} { + t.Run(fmt.Sprintf("%dx%d", size.width, size.height), func(t *testing.T) { + t.Parallel() + base := newTUIModel(func() {}) + base = updateTUIModel(t, base, tea.WindowSizeMsg{Width: size.width, Height: size.height}) + base = updateTUIModel(t, base, started(1, 0, "a-task-with-a-fairly-long-name")) + + views := map[string]tuiModel{ + "dashboard": base, + "fullscreen": updateTUIModel(t, base, tea.KeyPressMsg{Code: 'f', Text: "f"}), + "keys": updateTUIModel(t, base, tea.KeyPressMsg{Code: '?', Text: "?"}), + } + for name, m := range views { + content := m.View().Content + assert.LessOrEqual(t, lipgloss.Width(content), size.width, "%s is too wide", name) + assert.LessOrEqual(t, lipgloss.Height(content), size.height, "%s is too tall", name) + } + }) + } +} + +func TestDashboardKeysHideTheLauncherWhenThereIsNoneToReturnTo(t *testing.T) { + t.Parallel() + + withLauncher := newDashboardKeys(false, true) + assert.True(t, withLauncher.Launcher.Enabled()) + + direct := newDashboardKeys(false, false) + assert.False(t, direct.Launcher.Enabled(), "a disabled binding is left out of the help") +} + +func TestDashboardKeysDescribeArrowsByFocus(t *testing.T) { + t.Parallel() + + assert.Equal(t, "select a task", newDashboardKeys(false, true).Move.Help().Desc) + assert.Equal(t, "scroll the output", newDashboardKeys(true, true).Move.Help().Desc) + + // The footer restates them in a word. + shortDesc := func(outputFocused bool) string { + for _, binding := range newDashboardKeys(outputFocused, true).ShortHelp() { + if binding.Help().Key == "↑/↓" { + return binding.Help().Desc + } + } + return "" + } + assert.Equal(t, "select", shortDesc(false)) + assert.Equal(t, "scroll", shortDesc(true)) +} + +func TestShortHelpKeepsTheWayOutOnANarrowTerminal(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + bindings := newDashboardKeys(false, true).ShortHelp() + for _, width := range []int{20, 40, 60, 80, 200} { + line := shortHelp(m.help, bindings, width) + assert.LessOrEqual(t, lipgloss.Width(line), width, "footer overflows at %d", width) + if width >= 20 { + // Help and quit lead, so truncation eats the tail rather than the + // way out of the view. + assert.Contains(t, ansi.Strip(line), "? help", "at %d columns", width) + assert.Contains(t, ansi.Strip(line), "q quit", "at %d columns", width) + } + } +} + +func TestFormatDuration(t *testing.T) { + t.Parallel() + + // A quick task reports milliseconds rather than nothing, so every row that + // ran carries a number. + assert.Equal(t, "0ms", formatDuration(0)) + assert.Equal(t, "3ms", formatDuration(3*time.Millisecond)) + assert.Equal(t, "400ms", formatDuration(400*time.Millisecond)) + assert.Equal(t, "3.4s", formatDuration(3400*time.Millisecond)) + assert.Equal(t, "12s", formatDuration(12*time.Second)) + assert.Equal(t, "1m35s", formatDuration(95*time.Second)) + assert.Equal(t, "2h05m", formatDuration(125*time.Minute)) +} + +func TestTUIModelShowsHowLongTasksTook(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + m = updateTUIModel(t, m, startedUnder(2, 1, 1, "compile")) + + now := time.Now() + m.byID[1].startedAt = now.Add(-95 * time.Second) + m.byID[2].startedAt = now.Add(-3400 * time.Millisecond) + m.byID[2].finishedAt = now + + pane := ansi.Strip(m.taskList(34, 8)) + assert.Contains(t, pane, "1m35s", "a running task counts up") + assert.Contains(t, pane, "3.4s", "a finished task keeps its final duration") + + // A narrow pane keeps the names and drops the durations. + assert.NotContains(t, ansi.Strip(m.taskList(18, 8)), "1m35s") +} + +func TestTUIModelTicksOnlyWhileTasksRun(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + next, cmd := m.Update(started(1, 0, "build")) + m = next.(tuiModel) + require.NotNil(t, cmd, "a running task schedules a redraw") + assert.True(t, m.ticking) + + // A second start must not stack a second ticker. + next, cmd = m.Update(startedUnder(2, 1, 1, "compile")) + m = next.(tuiModel) + assert.Nil(t, cmd, "only one ticker at a time") + + // Once everything has finished the ticker stops, so an idle dashboard is + // completely static. + m = updateTUIModel(t, m, taskFinishedMsg{id: 1}) + m = updateTUIModel(t, m, taskFinishedMsg{id: 2}) + next, cmd = m.Update(elapsedTickMsg{}) + m = next.(tuiModel) + assert.Nil(t, cmd) + assert.False(t, m.ticking) +} + +func TestTUIModelStopsTheClockOnCancelledTasks(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "slow")) + m = updateTUIModel(t, m, executionDoneMsg{}) + + require.Equal(t, taskCanceled, m.byID[1].state) + assert.False(t, m.byID[1].finishedAt.IsZero(), "a cancelled task must stop counting up") +} + +func TestFullHelpUsesTheColumnsThatFit(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + bindings := newDashboardKeys(false, true).allBindings() + + countColumns := func(width int) int { + view := fullHelp(m.help, bindings, width) + require.LessOrEqual(t, lipgloss.Width(view), width, "key list overflows at %d", width) + // Every binding is listed whatever the layout. + for _, binding := range bindings { + assert.Contains(t, ansi.Strip(view), binding.Help().Desc) + } + return len(strings.Split(strings.TrimRight(ansi.Strip(view), "\n"), "\n")) + } + + // Narrower means taller: the descriptions are written to be read, so the + // layout gives way rather than the wording. + wide, narrow := countColumns(140), countColumns(50) + assert.Less(t, wide, narrow, "a narrow terminal should stack into more rows") +} + +func TestFooterKeepsTheWayOutAtEightyColumns(t *testing.T) { + t.Parallel() + + // The whole line is not expected to fit eighty columns; it is ordered so + // that what does fit is what a reader needs to get somewhere else. + m := newTUIModel(func() {}) + // The arrow keys are deliberately last: they are the part of a TUI a reader + // can guess, so they are what an eighty column terminal gives up. + dashboard := ansi.Strip(shortHelp(m.help, newDashboardKeys(false, true).ShortHelp(), 80)) + for _, expected := range []string{"? help", "q quit", "esc/b launcher", "y copy", "s save"} { + assert.Contains(t, dashboard, expected, "footer at 80 columns: %s", dashboard) + } + + full := ansi.Strip(shortHelp(m.help, newFullscreenKeys(false).ShortHelp(), 80)) + for _, expected := range []string{"? help", "q quit", "f/esc back"} { + assert.Contains(t, full, expected, "fullscreen footer at 80 columns: %s", full) + } + + // Truncation drops whole entries: a line that was cut ends at an entry + // boundary followed by the ellipsis, never part-way through a word. + for _, width := range []int{40, 60, 80, 100} { + line := ansi.Strip(shortHelp(m.help, newDashboardKeys(false, true).ShortHelp(), width)) + assert.LessOrEqual(t, lipgloss.Width(line), width) + if strings.HasSuffix(line, "…") { + // A trimmed line ends at an entry boundary, never part-way through a + // word and never on a dangling separator. + assert.True(t, strings.HasSuffix(line, " …"), + "at %d columns the line was cut mid-entry: %s", width, line) + assert.NotContains(t, line, "• …", + "at %d columns the line ends on a separator: %s", width, line) + } + } +} + +// t used to print the output to the terminal, for its own scrollback and the +// native selection there. Selecting lines with the keyboard covers that, and +// works over a connection where the terminal owns no scrollback of ours. +func TestPrintToTerminalIsGone(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + m = updateTUIModel(t, m, taskOutputMsg{id: 1, name: "build", data: "hi\n"}) + + _, cmd := m.Update(tea.KeyPressMsg{Code: 't', Text: "t"}) + assert.Nil(t, cmd, "t is not bound to anything") + + for _, binding := range newDashboardKeys(false, true).allBindings() { + assert.NotContains(t, binding.Keys(), "t") + } + for _, binding := range newFullscreenKeys(false).allBindings() { + assert.NotContains(t, binding.Keys(), "t") + } +} + +func TestFooterPairsTheArrowKeys(t *testing.T) { + t.Parallel() + + // Vertical arrows move the selection and horizontal arrows move panes, so + // the two entries sit next to each other rather than either being described + // as tab. + bindings := newDashboardKeys(false, true).ShortHelp() + var keys []string + for _, binding := range bindings { + keys = append(keys, binding.Help().Key) + } + require.Len(t, keys, 8) + assert.Equal(t, []string{"↑/↓", "←/→"}, keys[len(keys)-2:], "the arrows are adjacent and last") + assert.Equal(t, "pane", bindings[len(bindings)-1].Help().Desc) +} + +// taskListWithoutDurations renders the task pane with the right-aligned +// duration column trimmed, for assertions about names and tree structure. +func taskListWithoutDurations(t *testing.T, m tuiModel, width, height int) string { + t.Helper() + var lines []string + for line := range strings.SplitSeq(ansi.Strip(m.taskList(width, height)), "\n") { + lines = append(lines, strings.TrimRight(regexp. + MustCompile(`\s+\d[\dhms.]*$`). + ReplaceAllString(line, ""), " ")) + } + return strings.Join(lines, "\n") +} + +func TestTUIModelReportsNoDurationForTasksThatNeverRan(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + m = updateTUIModel(t, m, scheduledUnder(2, 1, 1, "never-attempted")) + + // A task that ran has a duration even if it was instant; one that never + // started has none, which is different from a duration of zero. Pin the + // start so the assertion does not depend on how long the test itself took. + m.byID[1].startedAt = time.Now() + m.byID[1].finishedAt = m.byID[1].startedAt + assert.Equal(t, "0ms", m.durationLabel(m.byID[1])) + assert.Empty(t, m.durationLabel(m.byID[2])) + assert.NotContains(t, ansi.Strip(m.taskList(40, 10)), "never-attempted 0ms") +} + +func TestPromptAsksTheUserAndReturnsTheAnswer(t *testing.T) { + t.Parallel() + + press := func(m tuiModel, keys ...string) tuiModel { + for _, key := range keys { + next, _ := m.Update(tea.KeyPressMsg{Code: rune(key[0]), Text: key}) + m = next.(tuiModel) + } + return m + } + + t.Run("confirm", func(t *testing.T) { + t.Parallel() + m := newTUIModel(func() {}) + state := &promptState{kind: promptConfirm, task: "deploy", message: "Really?", done: make(chan promptAnswer, 1)} + m.beginPrompt(state) + assert.Contains(t, ansi.Strip(m.View().Content), "Really?") + + m = press(m, "y") + assert.Equal(t, promptAnswer{confirmed: true}, <-state.done) + assert.Nil(t, m.prompt, "the question leaves the screen once answered") + }) + + t.Run("declining is not an error", func(t *testing.T) { + t.Parallel() + m := newTUIModel(func() {}) + state := &promptState{kind: promptConfirm, done: make(chan promptAnswer, 1)} + m.beginPrompt(state) + + m = press(m, "n") + answer := <-state.done + assert.False(t, answer.confirmed) + assert.NoError(t, answer.err, "declining stops the task without being an error") + }) + + t.Run("free text", func(t *testing.T) { + t.Parallel() + m := newTUIModel(func() {}) + state := &promptState{kind: promptText, name: "RELEASE_NAME", done: make(chan promptAnswer, 1)} + m.beginPrompt(state) + + m = press(m, "v", "1") + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter, Text: "enter"}) + m = next.(tuiModel) + assert.Equal(t, "v1", (<-state.done).value) + }) + + t.Run("choice", func(t *testing.T) { + t.Parallel() + m := newTUIModel(func() {}) + state := &promptState{ + kind: promptChoice, + name: "ENVIRONMENT", + options: []string{"development", "staging", "production"}, + done: make(chan promptAnswer, 1), + } + m.beginPrompt(state) + assert.Contains(t, ansi.Strip(m.View().Content), "staging") + + m = press(m, "j") + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter, Text: "enter"}) + m = next.(tuiModel) + assert.Equal(t, "staging", (<-state.done).value) + }) + + t.Run("cancelling a value stops the run", func(t *testing.T) { + t.Parallel() + m := newTUIModel(func() {}) + state := &promptState{kind: promptText, done: make(chan promptAnswer, 1)} + m.beginPrompt(state) + + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc, Text: "esc"}) + m = next.(tuiModel) + assert.ErrorIs(t, (<-state.done).err, task.ErrPromptCancelled) + }) +} + +func TestPromptRefusesAVariableTypeItCannotRender(t *testing.T) { + t.Parallel() + + // Task can add variable types; guessing would produce a value the task acts + // on, so an unknown one is refused. + ui := &UI{} + _, err := ui.Ask(task.VarRequest{Task: "deploy", Name: "COUNT", Type: nil}) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot ask") +} + +func TestTUIModelShowsAFailureThatBelongsToNoTask(t *testing.T) { + t.Parallel() + + // Declining a prompt fails the run before anything is scheduled. Without + // somewhere to put it, the dashboard would say the run failed while showing + // an empty task list and no reason. + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 20}) + m = updateTUIModel(t, m, executionDoneMsg{err: errors.New(`task: task "deploy" cancelled by user`)}) + + pane := ansi.Strip(m.View().Content) + assert.Contains(t, pane, systemTaskName) + assert.Contains(t, pane, "cancelled by user") + assert.NotContains(t, pane, "Waiting for tasks") +} + +func TestTUIModelDoesNotRepeatAFailureAlreadyShownAgainstATask(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + m = updateTUIModel(t, m, taskFinishedMsg{id: 1, result: resultFailed, err: errors.New("exit status 1")}) + m = updateTUIModel(t, m, executionDoneMsg{err: errors.New(`task: Failed to run task "build": exit status 1`)}) + + // The task carries its own failure, so the run's error is not repeated. + assert.NotContains(t, rowNames(m.taskRows()), systemTaskName) +} + +func TestPromptControlCQuitsTheInterface(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + state := &promptState{kind: promptText, name: "RELEASE_NAME", done: make(chan promptAnswer, 1)} + m.beginPrompt(state) + + next, _ := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl, Text: ""}) + m = next.(tuiModel) + + // The waiting task is released first, or it would never return. + assert.ErrorIs(t, (<-state.done).err, task.ErrPromptCancelled) + assert.True(t, m.quitting, "ctrl+c closes the interface, as it does elsewhere") + assert.Nil(t, m.prompt) +} + +func TestPromptIsADialogOverTheDashboard(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 90, Height: 14}) + m = updateTUIModel(t, m, started(1, 0, "release")) + + m.beginPrompt(&promptState{ + kind: promptChoice, task: "release", name: "ENVIRONMENT", + options: []string{"staging", "production"}, done: make(chan promptAnswer, 1), + }) + view := ansi.Strip(m.View().Content) + + // A question is an interruption, not a place you navigated to, so it is + // drawn over the interface rather than replacing it. + assert.Contains(t, view, `Task "release" is asking`) + assert.Contains(t, view, "staging") + assert.Contains(t, view, "TASKS", "the dashboard stays behind the dialog") + // The dialog carries its own keys, and the interface behind it offers + // none, since none of them would do anything. + assert.Contains(t, view, "enter confirm") + assert.NotContains(t, view, "y copy") + assert.NotContains(t, view, "? help") +} + +func TestPromptDialogFitsASmallTerminal(t *testing.T) { + t.Parallel() + + options := make([]string, 30) + for i := range options { + options[i] = fmt.Sprintf("option-%02d", i) + } + for _, size := range []struct{ width, height int }{{40, 10}, {80, 24}, {200, 60}} { + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: size.width, Height: size.height}) + m.beginPrompt(&promptState{ + kind: promptChoice, task: "release", name: "ENVIRONMENT", + options: options, done: make(chan promptAnswer, 1), + }) + content := m.View().Content + assert.LessOrEqual(t, lipgloss.Width(content), size.width, "%dx%d", size.width, size.height) + assert.LessOrEqual(t, lipgloss.Height(content), size.height, "%dx%d", size.width, size.height) + } +} + +func TestPromptWrapsALongMessage(t *testing.T) { + t.Parallel() + + long := "This will delete every artifact in the production bucket and cannot be undone" + wrapped := wrapText(long, 30) + + require.Greater(t, len(strings.Split(wrapped, "\n")), 1, "a long message wraps") + for line := range strings.SplitSeq(wrapped, "\n") { + assert.LessOrEqual(t, lipgloss.Width(line), 30) + } + // Wrapping breaks between words, not through them. + assert.Equal(t, strings.Fields(long), strings.Fields(wrapped)) +} + +func TestConfirmationShowsAndUsesItsDefault(t *testing.T) { + t.Parallel() + + newConfirm := func() (tuiModel, *promptState) { + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 14}) + state := &promptState{kind: promptConfirm, task: "release", message: "Continue?", done: make(chan promptAnswer, 1)} + m.beginPrompt(state) + return m, state + } + press := func(m tuiModel, code rune, text string) tuiModel { + next, _ := m.Update(tea.KeyPressMsg{Code: code, Text: text}) + return next.(tuiModel) + } + + t.Run("the default is marked rather than encoded", func(t *testing.T) { + t.Parallel() + _, state := newConfirm() + + lines := strings.Split(strings.TrimPrefix(promptOptions(state, 20), "\n"), "\n") + require.Len(t, lines, 2) + assert.Equal(t, []string{"yes", "no"}, []string{ + strings.TrimSpace(ansi.Strip(lines[0])), + strings.TrimSpace(ansi.Strip(lines[1])), + }) + + // "no" is highlighted, so the answer Enter would give can be seen, + // rather than hidden in the capitalisation of "[y/N]". + assert.Equal(t, " yes", lines[0], "the answer that is not the default is plain") + assert.NotEqual(t, ansi.Strip(lines[1]), lines[1], "the default is styled") + }) + + t.Run("enter takes the default", func(t *testing.T) { + t.Parallel() + m, state := newConfirm() + press(m, tea.KeyEnter, "enter") + answer := <-state.done + assert.False(t, answer.confirmed, "the default is no, as it is on the terminal") + assert.NoError(t, answer.err) + }) + + t.Run("enter takes yes once it is chosen", func(t *testing.T) { + t.Parallel() + m, state := newConfirm() + m = press(m, 'k', "k") + press(m, tea.KeyEnter, "enter") + assert.True(t, (<-state.done).confirmed) + }) + + t.Run("y and n still answer directly", func(t *testing.T) { + t.Parallel() + m, state := newConfirm() + press(m, 'y', "y") + assert.True(t, (<-state.done).confirmed) + + m, state = newConfirm() + press(m, 'n', "n") + assert.False(t, (<-state.done).confirmed) + }) + + t.Run("every key that answers is listed", func(t *testing.T) { + t.Parallel() + m, _ := newConfirm() + view := ansi.Strip(m.View().Content) + for _, expected := range []string{"enter confirm", "y/n answer", "esc no"} { + assert.Contains(t, view, expected) + } + }) +} + +func TestFinishedDashboardIsOnlyClosedByADocumentedKey(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 80, Height: 20}) + m = updateTUIModel(t, m, started(1, 0, "build")) + m = updateTUIModel(t, m, taskFinishedMsg{id: 1}) + m = updateTUIModel(t, m, executionDoneMsg{}) + require.True(t, m.done) + + // Enter used to close the finished dashboard without appearing among the + // keys. It is a confirm key in the dialogs, so pressing it out of habit + // threw away output the run had left to read. + _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter, Text: "enter"}) + assert.Nil(t, cmd, "enter does not close the interface") + + _, cmd = m.Update(tea.KeyPressMsg{Code: 'q', Text: "q"}) + assert.NotNil(t, cmd, "q, which the footer lists, does") +} + +// typePath enters a path into the footer field and presses Enter. +func typePath(t *testing.T, m tuiModel, path string) (tuiModel, savedMsg) { + t.Helper() + for _, r := range path { + next, _ := m.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) + m = next.(tuiModel) + } + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter, Text: "enter"}) + m = next.(tuiModel) + require.Nil(t, m.save, "the field closes once a path is given") + require.NotNil(t, cmd) + saved, ok := cmd().(savedMsg) + require.True(t, ok) + return m, saved +} + +func withOutput(t *testing.T) tuiModel { + t.Helper() + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 90, Height: 20}) + m = updateTUIModel(t, m, started(1, 0, "build")) + m = updateTUIModel(t, m, taskOutputMsg{id: 1, name: "build", data: "\x1b[31mFAILED\x1b[0m\n"}) + return m +} + +// clearField empties the pre-filled suggestion so a test can type its own path. +func clearField(t *testing.T, m tuiModel) tuiModel { + t.Helper() + m.save.input.SetValue("") + return m +} + +func TestSaveAsksWhereToPutTheOutput(t *testing.T) { // nolint:paralleltest // t.Chdir cannot be used in a parallel test + t.Chdir(t.TempDir()) + + m := withOutput(t) + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 's', Text: "s"}) + require.NotNil(t, m.save, "s asks where rather than choosing for the user") + + // The field is filled in, so Enter alone is enough. + view := ansi.Strip(m.View().Content) + assert.Contains(t, view, "Save to:") + assert.Contains(t, view, "build.", "the suggestion leads with the task") + assert.Contains(t, view, ".log") + assert.Contains(t, view, "logs", "the default is a logs folder beside the project") + assert.NotContains(t, view, "~", "not a folder shared by every project") + assert.Contains(t, view, "enter save") + // The dashboard stays visible: this is a footer field, not a dialog. + assert.Contains(t, view, "TASKS") + + m, saved := typePath(t, m, "") + require.NoError(t, saved.err) + content, err := os.ReadFile(saved.path) + require.NoError(t, err) + assert.Equal(t, "\x1b[31mFAILED\x1b[0m\n", string(content)) +} + +func TestSaveCreatesMissingDirectories(t *testing.T) { // nolint:paralleltest // t.Chdir cannot be used in a parallel test + dir := t.TempDir() + t.Chdir(dir) + + m := withOutput(t) + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 's', Text: "s"}) + m = clearField(t, m) + + _, saved := typePath(t, m, "logs/today/build.log") + require.NoError(t, saved.err) + assert.FileExists(t, filepath.Join(dir, "logs", "today", "build.log")) +} + +func TestSaveReportsAPathItCannotWrite(t *testing.T) { // nolint:paralleltest // t.Chdir cannot be used in a parallel test + dir := t.TempDir() + t.Chdir(dir) + readOnly := filepath.Join(dir, "read-only") + require.NoError(t, os.Mkdir(readOnly, 0o500)) + + m := withOutput(t) + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 's', Text: "s"}) + m = clearField(t, m) + + m, saved := typePath(t, m, filepath.Join(readOnly, "nested", "build.log")) + require.Error(t, saved.err, "a directory that cannot be written is reported, not ignored") + + m = updateTUIModel(t, m, saved) + assert.Contains(t, ansi.Strip(m.View().Content), "save failed") + assert.Contains(t, ansi.Strip(m.View().Content), "permission denied") +} + +func TestSaveCanBeCancelled(t *testing.T) { + t.Parallel() + + m := withOutput(t) + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 's', Text: "s"}) + require.NotNil(t, m.save) + + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: tea.KeyEsc, Text: "esc"}) + assert.Nil(t, m.save) + assert.Contains(t, ansi.Strip(m.View().Content), "? help", "the keys come back") +} + +func TestSaveAllWritesOneFilePerTask(t *testing.T) { // nolint:paralleltest // t.Chdir cannot be used in a parallel test + dir := t.TempDir() + t.Chdir(dir) + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 90, Height: 20}) + m = updateTUIModel(t, m, started(1, 0, "build")) + m = updateTUIModel(t, m, startedUnder(2, 1, 1, "test:unit")) + m = updateTUIModel(t, m, startedUnder(3, 1, 1, "silent")) + m = updateTUIModel(t, m, taskOutputMsg{id: 1, name: "build", data: "building\n"}) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "test:unit", data: "testing\n"}) + + // Select a task other than the root, to show the folder is named for the + // run rather than for whatever happens to be selected. + m.selectedID, m.hasSelect = 2, true + + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 'S', Text: "S"}) + require.NotNil(t, m.save) + suggestion := ansi.Strip(m.View().Content) + assert.Contains(t, suggestion, "Save all to folder:") + assert.NotContains(t, suggestion, ".log", "saving all asks for a folder, not a file") + assert.Contains(t, suggestion, "build.", "the folder is named for the task that was run") + assert.NotContains(t, suggestion, "test-unit") + m = clearField(t, m) + + m, saved := typePath(t, m, "logs/run-1") + require.NoError(t, saved.err) + assert.Equal(t, 2, saved.count, "a task with no output is not written") + + entries, err := os.ReadDir(filepath.Join(dir, "logs", "run-1")) + require.NoError(t, err) + var names []string + for _, entry := range entries { + names = append(names, entry.Name()) + } + // The folder already says which run this was and when, so a file inside it + // only says which task it came from. A namespaced task name is not a usable + // file name. + assert.ElementsMatch(t, []string{"build.log", "test-unit.log"}, names) +} + +func TestSaveReportsWhenThereIsNothingToSave(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "build")) + + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 's', Text: "s"}) + assert.Nil(t, m.save, "nothing to save means nothing to ask about") + assert.Contains(t, ansi.Strip(m.View().Content), "nothing to save") +} + +func TestFileNameForATaskName(t *testing.T) { + t.Parallel() + + assert.Equal(t, "build", fileNameFor("build")) + assert.Equal(t, "test-unit", fileNameFor("test:unit")) + assert.Equal(t, "Build-the-docs", fileNameFor("Build the docs")) + assert.Equal(t, "build-foo", fileNameFor("build:*:foo")) + // A label can be anything, including nothing usable. + assert.Equal(t, "task", fileNameFor("///")) +} + +func TestGeneratedFileNameGroupsByTask(t *testing.T) { + t.Parallel() + + name := generatedFileName("2026-09-05T14-30-22", "test:unit") + assert.Equal(t, "test-unit.2026-09-05T14-30-22.log", name) + + // The task leads, so a folder of logs groups by task and a shell can + // complete on one without knowing the date. Time ordering is free from + // ls -t either way. + older := generatedFileName("2026-09-05T09-00-00", "test:unit") + other := generatedFileName("2026-09-05T10-00-00", "build") + sorted := []string{name, older, other} + slices.Sort(sorted) + assert.Equal(t, []string{other, older, name}, sorted, + "a task's logs sort together, oldest first") + + // A timestamp carries no colons, which a file name cannot hold on Windows. + assert.NotContains(t, name, ":") + + // Two tasks whose names clean up the same way keep separate files. + used := map[string]bool{} + first := unusedName(generatedFileName("t", "a:b"), used) + second := unusedName(generatedFileName("t", "a/b"), used) + assert.NotEqual(t, first, second) + assert.True(t, strings.HasSuffix(second, ".log"), second) +} + +func TestSaveLeavesAGitignoreInADirectoryItCreated(t *testing.T) { // nolint:paralleltest // t.Chdir cannot be used in a parallel test + dir := t.TempDir() + t.Chdir(dir) + + m := withOutput(t) + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 's', Text: "s"}) + _, saved := typePath(t, m, "") + require.NoError(t, saved.err) + + // Task made the directory, so it ignores itself rather than turning up in + // git status or being swept in by git add. + marker, err := os.ReadFile(filepath.Join(dir, "logs", ".gitignore")) + require.NoError(t, err) + assert.Contains(t, string(marker), "*") + assert.Contains(t, string(marker), "Created by Task") +} + +func TestSaveLeavesAnExistingDirectoryAlone(t *testing.T) { // nolint:paralleltest // t.Chdir cannot be used in a parallel test + dir := t.TempDir() + t.Chdir(dir) + require.NoError(t, os.Mkdir("logs", 0o750)) + + m := withOutput(t) + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 's', Text: "s"}) + _, saved := typePath(t, m, "") + require.NoError(t, saved.err) + + // The directory was already there, so it is the user's to manage. + assert.NoFileExists(t, filepath.Join(dir, "logs", ".gitignore")) +} + +func TestSaveDoesNotWriteAGitignoreIntoAPathTheUserChose(t *testing.T) { // nolint:paralleltest // t.Chdir cannot be used in a parallel test + dir := t.TempDir() + t.Chdir(dir) + + m := withOutput(t) + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 's', Text: "s"}) + m = clearField(t, m) + _, saved := typePath(t, m, "build-output/today/build.log") + require.NoError(t, saved.err) + + assert.NoFileExists(t, filepath.Join(dir, "build-output", ".gitignore")) + assert.NoFileExists(t, filepath.Join(dir, "build-output", "today", ".gitignore")) +} + +func TestFirstPathElement(t *testing.T) { + t.Parallel() + + assert.Equal(t, "logs", firstPathElement("logs")) + assert.Equal(t, "logs", firstPathElement("logs/run-1")) + assert.Equal(t, "logs", firstPathElement("./logs/run-1")) + assert.Equal(t, ".", firstPathElement(".")) + // An absolute path was asked for by name, so nothing is added to it. + assert.Empty(t, firstPathElement(filepath.Join(string(filepath.Separator), "var", "logs"))) +} + +func TestAwaitAnswerGivesUpWhenTheInterfaceStops(t *testing.T) { + t.Parallel() + + t.Run("an answer is returned", func(t *testing.T) { + t.Parallel() + done := make(chan promptAnswer, 1) + done <- promptAnswer{confirmed: true} + assert.Equal(t, promptAnswer{confirmed: true}, awaitAnswer(done, make(chan struct{}))) + }) + + t.Run("a stopped interface releases the waiting task", func(t *testing.T) { + t.Parallel() + // Without this the task waits for an answer that can no longer come, + // and Task hangs instead of exiting. + programDone := make(chan struct{}) + close(programDone) + assert.ErrorIs(t, awaitAnswer(make(chan promptAnswer), programDone).err, task.ErrPromptCancelled) + }) + + t.Run("an answer already given wins a stopped interface", func(t *testing.T) { + t.Parallel() + programDone := make(chan struct{}) + close(programDone) + + // The user answered, so their answer is used rather than discarded + // because the interface happened to stop at the same moment. Repeated + // because a plain select over two ready cases picks at random. + for range 100 { + done := make(chan promptAnswer, 1) + done <- promptAnswer{value: "dev"} + answer := awaitAnswer(done, programDone) + require.NoError(t, answer.err) + require.Equal(t, "dev", answer.value) + } + }) +} + +func TestPromptingWithoutAnInterfaceDoesNotBlock(t *testing.T) { + t.Parallel() + + // A task can reach a question after the interface has closed, during a + // cancelled run. It must be told so rather than waiting for a dialog that + // will never be drawn. + ui := &UI{programDone: make(chan struct{})} + + done := make(chan struct{}) + go func() { + defer close(done) + confirmed, err := ui.Confirm("deploy", "Really?") + assert.False(t, confirmed) + assert.ErrorIs(t, err, task.ErrPromptCancelled) + + value, err := ui.Ask(task.VarRequest{Task: "deploy", Name: "ENV", Type: task.StringVar{}}) + assert.Nil(t, value) + assert.ErrorIs(t, err, task.ErrPromptCancelled) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("asking without an interface blocked") + } +} + +func TestQuestionsAreAskedOneAtATime(t *testing.T) { + t.Parallel() + + // Tasks running in parallel can reach questions at once, and the screen + // holds one. Serialising them is what stops two dialogs racing for it. + ui := &UI{programDone: make(chan struct{})} + + var wg sync.WaitGroup + for i := range 20 { + wg.Go(func() { + _, err := ui.Ask(task.VarRequest{ + Task: "deploy", Name: fmt.Sprintf("VAR_%d", i), Type: task.StringVar{}, + }) + assert.ErrorIs(t, err, task.ErrPromptCancelled) + }) + } + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("concurrent questions deadlocked") + } +} + +func TestSendReportsWhetherAnythingReceivedIt(t *testing.T) { + t.Parallel() + + // Events keep arriving from the executor after the interface has closed; + // they are dropped rather than panicking on a program that is gone. + ui := &UI{pending: make(map[uint64]pendingOutput)} + assert.False(t, ui.send(taskScheduledMsg{}), "nothing is running to receive it") +} + +func TestOutputHeaderShowsTheStatusAndExitCode(t *testing.T) { + t.Parallel() + + exitError := func(name string, status uint8) error { + return &taskerrors.TaskRunError{TaskName: name, Err: interp.ExitStatus(status)} + } + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 100, Height: 30}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "build")) + m = updateTUIModel(t, m, started(3, 1, "test")) + m = updateTUIModel(t, m, started(4, 1, "lint")) + + selectTaskByID(t, &m, 2) + assert.Contains(t, ansi.Strip(m.outputStatus()), "running", "a task still running says so") + + m = updateTUIModel(t, m, taskFinishedMsg{id: 2}) + assert.Contains(t, ansi.Strip(m.outputStatus()), "success") + assert.NotContains(t, m.outputStatus(), "(", "a task that succeeded has no code to report") + + // A task that ran its own failing command reports what it exited with. + m = updateTUIModel(t, m, taskFinishedMsg{id: 3, result: resultFailed, err: exitError("test", 127)}) + selectTaskByID(t, &m, 3) + assert.Contains(t, ansi.Strip(m.outputStatus()), "failed (127)") + assert.Contains(t, ansi.Strip(m.View().Content), "failed (127)") + + // A task that failed because a dependency did carries the dependency's + // error, which is not this task's exit code. + m = updateTUIModel(t, m, taskFinishedMsg{id: 4, result: resultFailed, err: exitError("test", 127)}) + selectTaskByID(t, &m, 4) + assert.Equal(t, "failed", ansi.Strip(m.outputStatus())) +} + +func TestOutputHeaderFollowsTheJoinedOwner(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "build")) + m = updateTUIModel(t, m, scheduled(3, 1, "build")) + m = updateTUIModel(t, m, taskJoinedMsg{id: 3, ownerID: 2}) + m = updateTUIModel(t, m, taskFinishedMsg{ + id: 2, + result: resultFailed, + err: &taskerrors.TaskRunError{TaskName: "build", Err: interp.ExitStatus(2)}, + }) + + selectTaskByID(t, &m, 3) + assert.Contains(t, ansi.Strip(m.outputStatus()), "failed (2)", + "a joined invocation reports how the run it waited on ended") +} + +func selectTaskByID(t *testing.T, m *tuiModel, id uint64) { + t.Helper() + for index, row := range m.taskRows() { + if row.task.id == id { + m.selectTask(index) + require.Equal(t, id, m.selectedID) + return + } + } + t.Fatalf("no row for task %d", id) +} + +func TestScrollbarSitsOnTheOutputPaneBorder(t *testing.T) { + t.Parallel() + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 70, Height: 14}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "build")) + selectTaskByID(t, &m, 2) + + assert.NotContains(t, ansi.Strip(m.View().Content), "█", + "output that fits on screen keeps a plain border") + + lines := make([]string, 0, 40) + for i := range 40 { + lines = append(lines, fmt.Sprintf("line %d", i)) + } + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "build", data: strings.Join(lines, "\n")}) + track := m.viewport.Height() + + m.viewport.GotoTop() + start, size := scrollbarThumb(t, m) + assert.Equal(t, 0, start, "at the top the thumb starts at the top of the track") + // Ten lines of forty are on screen, so the thumb covers a quarter of the + // track. + assert.Equal(t, track*m.viewport.Height()/40, size) + + m.viewport.GotoBottom() + start, bottomSize := scrollbarThumb(t, m) + assert.Equal(t, track-size, start, "at the end the thumb reaches the bottom of the track") + assert.Equal(t, size, bottomSize, "the thumb keeps its length") + + m.viewport.SetYOffset(15) + middle, _ := scrollbarThumb(t, m) + assert.Greater(t, middle, 0) + assert.Less(t, middle, track-size) +} + +// scrollbarThumb reads the thumb's position and length out of the rendered +// output pane, from the border cell at the end of each of its rows. +func scrollbarThumb(t *testing.T, m tuiModel) (start, size int) { + t.Helper() + lines := strings.Split(ansi.Strip(m.View().Content), "\n") + // The panel opens with its top border and the pane title, and closes with + // its bottom border; the footer follows it. + rows := lines[2 : len(lines)-2] + var thumb []int + for i, line := range rows { + runes := []rune(line) + if runes[len(runes)-1] == '█' { + thumb = append(thumb, i) + } + } + require.NotEmpty(t, thumb, "no thumb in %q", rows) + for i, row := range thumb { + require.Equal(t, thumb[0]+i, row, "the thumb is one unbroken run: %v", thumb) + } + return thumb[0], len(thumb) +} + +func TestNavigatorKeySwitchesTheTaskView(t *testing.T) { + t.Parallel() + + prefixFor := func(m tuiModel, id uint64) string { + for _, row := range m.taskRows() { + if row.task.id == id { + return row.treePrefix + } + } + t.Fatalf("no row for task %d", id) + return "" + } + + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: 90, Height: 14}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, startedUnder(2, 1, 1, "build")) + m = updateTUIModel(t, m, startedUnder(3, 2, 1, "compile")) + + require.Equal(t, taskNavigatorTree, m.taskNavigator) + assert.Greater(t, lipgloss.Width(prefixFor(m, 3)), 3, "the tree nests a grandchild under its parent") + + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 'n', Text: "n"}) + assert.Equal(t, taskNavigatorList, m.taskNavigator) + assert.Equal(t, 3, lipgloss.Width(prefixFor(m, 3)), "the list puts every task under its root") + + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 'n', Text: "n"}) + assert.Equal(t, taskNavigatorTree, m.taskNavigator, "the key toggles back") +} + +// fullscreenWith opens the fullscreen output view on a task holding the given +// output, with the cursor on its first line. +func fullscreenWith(t *testing.T, output string, width, height int) tuiModel { + t.Helper() + m := newTUIModel(func() {}) + m = updateTUIModel(t, m, tea.WindowSizeMsg{Width: width, Height: height}) + m = updateTUIModel(t, m, started(1, 0, "root")) + m = updateTUIModel(t, m, started(2, 1, "build")) + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "build", data: output}) + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 'f', Text: "f"}) + require.True(t, m.fullscreenOutput) + m.moveFullscreenCursor(-len(m.fullscreenLines)) + return m +} + +func press(t *testing.T, m tuiModel, key rune) tuiModel { + t.Helper() + return updateTUIModel(t, m, tea.KeyPressMsg{Code: key, Text: string(key)}) +} + +func TestFullscreenSelectionCopiesUnwrappedLines(t *testing.T) { + t.Parallel() + + long := strings.Repeat("x", 95) + m := fullscreenWith(t, "first\n"+long+"\nthird\nfourth\n", 60, 12) + + // The long line is folded across two rows, so a line and a row are not the + // same thing and the cursor has to count lines. + require.Equal(t, []int{0, 1, 3, 4, 5, 6}, m.fullscreenRowOf) + + m = press(t, m, 'v') + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: tea.KeyDown}) + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: tea.KeyDown}) + require.True(t, m.fullscreenSelecting) + + first, last := m.fullscreenSelectedLines() + assert.Equal(t, 0, first) + assert.Equal(t, 2, last, "three lines are selected, not three rows") + assert.Equal(t, "first\n"+long+"\nthird", m.fullscreenCopyText(false), + "the copy has the line as it was written, not as it was folded") + + // Every row of every selected line is highlighted, and no row beyond them. + assert.Equal(t, [2]int{0, 4}, m.fullscreenPainted) +} + +func TestFullscreenSelectionGrowsBothWays(t *testing.T) { + t.Parallel() + + m := fullscreenWith(t, numberedLines(20), 80, 12) + for range 5 { + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: tea.KeyDown}) + } + m = press(t, m, 'v') + for range 2 { + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: tea.KeyUp}) + } + + first, last := m.fullscreenSelectedLines() + assert.Equal(t, 3, first, "moving up from the anchor selects the lines above it") + assert.Equal(t, 5, last) + + // v again cancels, as leaving Vim's visual mode does: a later copy takes + // the whole output again, which is why the two states have to look + // different. + m = press(t, m, 'v') + assert.False(t, m.fullscreenSelecting, "v again cancels the selection") + first, last = m.fullscreenSelectedLines() + assert.Equal(t, first, last, "only the cursor's own line is left marked") +} + +func TestFullscreenCopyWithoutSelectionTakesEverything(t *testing.T) { + t.Parallel() + + coloured := "\x1b[31mred\x1b[0m\nplain\n" + m := fullscreenWith(t, coloured, 80, 12) + + require.False(t, m.fullscreenSelecting) + assert.Equal(t, "red\nplain\n", m.fullscreenCopyText(false), + "with nothing selected the key still takes the whole output") + assert.Equal(t, coloured, m.fullscreenCopyText(true)) + + m = press(t, m, 'v') + assert.Equal(t, "red", m.fullscreenCopyText(false), "y drops the escape sequences") + assert.Equal(t, "\x1b[31mred\x1b[0m", m.fullscreenCopyText(true), "Y keeps them") +} + +func TestFullscreenEscapeClearsTheSelectionBeforeLeaving(t *testing.T) { + t.Parallel() + + m := fullscreenWith(t, numberedLines(20), 80, 12) + m = press(t, m, 'v') + require.True(t, m.fullscreenSelecting) + + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: tea.KeyEscape}) + assert.False(t, m.fullscreenSelecting) + assert.True(t, m.fullscreenOutput, "the first escape clears, it does not leave") + + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: tea.KeyEscape}) + assert.False(t, m.fullscreenOutput, "the second escape leaves") +} + +func TestFullscreenSelectionPinsTheView(t *testing.T) { + t.Parallel() + + m := fullscreenWith(t, numberedLines(60), 80, 12) + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: 'G', Text: "G"}) + require.True(t, m.fullscreenViewport.AtBottom()) + + m = press(t, m, 'v') + offset := m.fullscreenViewport.YOffset() + m = updateTUIModel(t, m, taskOutputMsg{id: 2, name: "build", data: "later\n"}) + assert.Equal(t, offset, m.fullscreenViewport.YOffset(), + "new output must not drag the view away from lines being picked out") +} + +func TestFullscreenSelectionClearedWhenOutputIsTrimmed(t *testing.T) { + t.Parallel() + + m := fullscreenWith(t, numberedLines(40), 80, 12) + m = press(t, m, 'v') + m = updateTUIModel(t, m, tea.KeyPressMsg{Code: tea.KeyDown}) + require.True(t, m.fullscreenSelecting) + + // Output is capped, and trimming it from the front renumbers every line the + // cursor and the anchor were holding. + m.byID[2].output = numberedLines(5) + m.syncFullscreenOutput() + + assert.False(t, m.fullscreenSelecting, "a selection cannot survive its lines being renumbered") + assert.Less(t, m.fullscreenCursor, len(m.fullscreenLines)) +} + +func TestFullscreenSelectionAcceptsBothVisualKeys(t *testing.T) { + t.Parallel() + + for _, key := range []rune{'v', 'V'} { + m := fullscreenWith(t, numberedLines(20), 80, 12) + m = press(t, m, key) + assert.True(t, m.fullscreenSelecting, "%q starts a selection", string(key)) + } +} + +func TestFullscreenSelectionLooksDifferentFromTheCursor(t *testing.T) { + t.Parallel() + + m := fullscreenWith(t, numberedLines(20), 40, 12) + width := m.fullscreenViewport.Width() + plain := ansi.Strip(m.fullscreenRows[0]) + cursorRow := func(m tuiModel) string { + return m.fullscreenShown[m.fullscreenRowOf[m.fullscreenCursor]] + } + + assert.Equal(t, tuiSelectedStyle.Width(width).Render(plain), cursorRow(m), + "a resting cursor is drawn quietly") + + // The span does not change when a selection starts on a single line, so + // only the style says that anything happened. It has to. + m = press(t, m, 'v') + assert.Equal(t, tuiSelectionStyle.Width(width).Render(plain), cursorRow(m), + "starting a selection changes how the same line is drawn") + + m = press(t, m, 'v') + assert.Equal(t, tuiSelectedStyle.Width(width).Render(plain), cursorRow(m), + "leaving selection restores the quiet cursor") +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000000..5651ed193c --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,758 @@ +package tui + +import ( + "fmt" + "slices" + "strings" + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/compat" + "github.com/charmbracelet/x/ansi" +) + +func (m tuiModel) View() tea.View { + content := m.renderContent() + if m.prompt != nil { + content = m.promptView() + } + switch { + case m.showHelp: + content = m.helpView() + case m.fullscreenOutput: + content = m.fullscreenOutputView() + } + view := tea.NewView(content) + view.AltScreen = true + if m.fullscreenOutput || m.showHelp || m.prompt != nil { + view.MouseMode = tea.MouseModeNone + } else { + view.MouseMode = tea.MouseModeCellMotion + } + view.WindowTitle = "Task" + return view +} + +func (m tuiModel) renderContent() string { + layout := newTUILayout(m.width, m.height) + left, right := m.renderPanes(layout) + body := lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Repeat(" ", layout.gap), right) + + keys := newDashboardKeys(m.focus == outputPane, m.canReturnToLauncher) + footer := shortHelp(m.help, keys.ShortHelp(), layout.width) + switch { + case m.quitting && !m.done: + footer = renderStatus(layout.width, "stopping tasks… waiting for processes to exit", tuiHelpStyle) + case m.returning && !m.done: + footer = renderStatus(layout.width, "stopping tasks… returning to launcher after processes exit", tuiHelpStyle) + case m.save != nil: + footer = m.saveFooter(layout.width) + case m.notice != "": + footer = renderStatus(layout.width, m.notice, tuiTitleStyle) + } + if m.prompt != nil { + // The dialog carries its own keys. Leaving the dashboard's here would + // offer controls that do nothing while a question is waiting. + footer = "" + } + + return body + "\n" + footer +} + +func (m *tuiModel) enterFullscreenOutput() { + m.fullscreenOutput = true + view := viewport.New( + viewport.WithWidth(max(m.width, 1)), + viewport.WithHeight(max(m.height-1, 1)), + ) + // The output is wrapped before it reaches the viewport, so that one + // viewport row is one screen row and the line cursor can be placed exactly. + view.SoftWrap = false + m.fullscreenViewport = view + m.fullscreenSelecting = false + m.wrapFullscreenOutput() + m.fullscreenCursor = 0 + if m.viewport.AtBottom() { + m.fullscreenViewport.GotoBottom() + } else if !m.viewport.AtTop() { + position := m.viewport.ScrollPercent() + m.fullscreenViewport.GotoBottom() + m.fullscreenViewport.SetYOffset(int(position * float64(m.fullscreenViewport.YOffset()))) + } + // The cursor starts on the first line in view, so that it is where the + // reader is already looking. + m.fullscreenCursor = m.fullscreenLineAtRow(m.fullscreenViewport.YOffset()) + m.paintFullscreenSelection() +} + +// fullscreenLineAtRow is the logical line a viewport row belongs to. +func (m tuiModel) fullscreenLineAtRow(row int) int { + if len(m.fullscreenLines) == 0 { + return 0 + } + line, found := slices.BinarySearch(m.fullscreenRowOf, row) + if !found { + line-- + } + return min(max(line, 0), len(m.fullscreenLines)-1) +} + +func (m *tuiModel) leaveFullscreenOutput() { + atTop := m.fullscreenViewport.AtTop() + atBottom := m.fullscreenViewport.AtBottom() + position := m.fullscreenViewport.ScrollPercent() + m.fullscreenOutput = false + m.fullscreenViewport = viewport.Model{} + m.fullscreenLines, m.fullscreenRows, m.fullscreenShown, m.fullscreenRowOf = nil, nil, nil, nil + m.fullscreenSelecting = false + m.loadViewport() + if atTop { + m.viewport.GotoTop() + } else if atBottom { + m.viewport.GotoBottom() + } else { + m.viewport.GotoBottom() + m.viewport.SetYOffset(int(position * float64(m.viewport.YOffset()))) + } + m.saveViewport() +} + +func (m *tuiModel) syncFullscreenOutput() { + // Following new output would drag the view away from lines being picked + // out, so a selection pins it. + atBottom := m.fullscreenViewport.AtBottom() && !m.fullscreenSelecting + offset := m.fullscreenViewport.YOffset() + lines := len(m.fullscreenLines) + m.wrapFullscreenOutput() + if len(m.fullscreenLines) < lines { + // Output was trimmed from the front, so every index the cursor and the + // anchor held now names a different line. + m.clearFullscreenSelection() + } + if atBottom { + m.fullscreenViewport.GotoBottom() + } else { + m.fullscreenViewport.SetYOffset(offset) + } +} + +// wrapFullscreenOutput folds the selected task's output to the pane's width and +// gives the result to the viewport, keeping the map from logical lines to rows +// that the cursor is placed with. +func (m *tuiModel) wrapFullscreenOutput() { + width := max(m.fullscreenViewport.Width(), 1) + m.fullscreenLines = strings.Split(m.fullscreenOutputContent(), "\n") + m.fullscreenRows = make([]string, 0, len(m.fullscreenLines)) + m.fullscreenRowOf = make([]int, len(m.fullscreenLines)+1) + for i, line := range m.fullscreenLines { + m.fullscreenRowOf[i] = len(m.fullscreenRows) + m.fullscreenRows = append(m.fullscreenRows, strings.Split(ansi.Hardwrap(line, width, false), "\n")...) + } + m.fullscreenRowOf[len(m.fullscreenLines)] = len(m.fullscreenRows) + m.fullscreenShown = slices.Clone(m.fullscreenRows) + // The clone carries no highlight, so nothing is painted yet. + m.fullscreenPainted, m.fullscreenPaintedSelecting = [2]int{0, 0}, false + m.fullscreenCursor = min(m.fullscreenCursor, max(len(m.fullscreenLines)-1, 0)) + m.fullscreenAnchor = min(m.fullscreenAnchor, max(len(m.fullscreenLines)-1, 0)) + m.paintFullscreenSelection() +} + +// paintFullscreenSelection highlights the rows of the lines under the cursor. +// Only the rows whose highlighting changes are rebuilt, so that moving the +// cursor costs nothing on a large output. +func (m *tuiModel) paintFullscreenSelection() { + first, last := m.fullscreenSelectedLines() + span := [2]int{0, 0} + if len(m.fullscreenRows) > 0 { + span = [2]int{m.fullscreenRowOf[first], m.fullscreenRowOf[last+1]} + } + // The style is part of what is painted, not only the span: pressing v with + // the cursor on a single line leaves the span alone and changes only how + // that line is drawn, which is the whole point of the two styles. + if span == m.fullscreenPainted && m.fullscreenSelecting == m.fullscreenPaintedSelecting { + return + } + for row := m.fullscreenPainted[0]; row < m.fullscreenPainted[1]; row++ { + m.fullscreenShown[row] = m.fullscreenRows[row] + } + width := max(m.fullscreenViewport.Width(), 1) + style := m.fullscreenHighlight() + for row := span[0]; row < span[1]; row++ { + // The highlight is drawn over text that sets colours of its own, and a + // background cannot survive the resets inside it. Highlighted rows show + // their text plainly; a copy still takes the sequences along. + m.fullscreenShown[row] = style.Width(width).Render(ansi.Strip(m.fullscreenRows[row])) + } + m.fullscreenPainted, m.fullscreenPaintedSelecting = span, m.fullscreenSelecting + m.fullscreenViewport.SetContentLines(m.fullscreenShown) +} + +// fullscreenHighlight distinguishes the two things the highlight can mean. The +// cursor marks where you are and is quiet about it; a selection is a mode you +// can leave by mistake, so it says so loudly enough to be noticed away from the +// footer. +func (m tuiModel) fullscreenHighlight() lipgloss.Style { + if m.fullscreenSelecting { + return tuiSelectionStyle + } + return tuiSelectedStyle +} + +// fullscreenSelectedLines is the range of logical lines a copy would take: the +// span between the cursor and the anchor while selecting, and the cursor's own +// line otherwise. +func (m tuiModel) fullscreenSelectedLines() (first, last int) { + if !m.fullscreenSelecting { + return m.fullscreenCursor, m.fullscreenCursor + } + return min(m.fullscreenCursor, m.fullscreenAnchor), max(m.fullscreenCursor, m.fullscreenAnchor) +} + +func (m *tuiModel) fullscreenOutputContent() string { + content := "" + if task := m.selectedTask(); task != nil { + content = task.output + if task.truncated { + content = "… earlier output was discarded …\n" + content + } + } + return content +} + +func (m tuiModel) fullscreenOutputView() string { + footer := renderStatus(m.width, m.notice, tuiTitleStyle) + if m.notice == "" { + footer = shortHelp(m.help, newFullscreenKeys(m.fullscreenSelecting).ShortHelp(), m.width) + } + return m.fullscreenViewport.View() + "\n" + footer +} + +// helpView lists every binding of the view it was opened from. It takes the +// whole screen rather than growing the footer, which would resize the panes. +func (m tuiModel) helpView() string { + bindings := newDashboardKeys(m.focus == outputPane, m.canReturnToLauncher).allBindings() + title := "KEYS" + if m.fullscreenOutput { + bindings = newFullscreenKeys(m.fullscreenSelecting).allBindings() + title = "KEYS · fullscreen" + } + inner := max(m.width-tuiPanelStyle.GetHorizontalFrameSize(), 1) + + body := tuiPanelStyle. + BorderForeground(tuiAccentColor). + Width(max(m.width, 1)). + Height(max(m.height-1, 1)). + MaxWidth(max(m.width, 1)). + MaxHeight(max(m.height-1, 1)). + Render(paneTitle(title, "", inner) + "\n\n" + fullHelp(m.help, bindings, inner)) + return body + "\n" + renderStatus(m.width, "press any key to return", tuiHelpStyle) +} + +func (m tuiModel) renderPanes(layout tuiLayout) (string, string) { + leftStyle, rightStyle := tuiPanelStyle, tuiPanelStyle + if m.focus == taskPane { + leftStyle = leftStyle.BorderForeground(tuiAccentColor) + } else { + rightStyle = rightStyle.BorderForeground(tuiAccentColor) + } + left := leftStyle.Width(layout.leftOuterWidth).Height(layout.bodyHeight). + Render(m.taskList(layout.leftInnerWidth, layout.innerHeight)) + right := rightStyle.Width(layout.rightOuterWidth).Height(layout.bodyHeight). + Render(m.outputPanel(layout.rightInnerWidth)) + return left, withScrollbar(right, m.viewport, rightStyle) +} + +// withScrollbar draws the output's scroll position as a thumb on the panel's +// right border, the way lazygit and gitui do. It replaces the border cell of +// each viewport row, so it costs neither a column of output nor a number the +// reader has to interpret. A panel whose output fits on screen keeps its plain +// border. +func withScrollbar(panel string, view viewport.Model, style lipgloss.Style) string { + total, height := view.TotalLineCount(), view.Height() + if height < 1 || total <= height { + return panel + } + lines := strings.Split(panel, "\n") + // The first line of the panel is its top border and the second is the pane + // title; the last is the bottom border. The viewport's own rows lie + // between, and they are what the thumb is measured against. + const firstRow = 2 + track := len(lines) - 1 - firstRow + if track < 2 { + return panel + } + + // The thumb keeps the border's colour, so that it still follows which pane + // has the focus. A block against a thin line is a difference of shape, and + // so survives a terminal whose palette flattens the two. + thumb := lipgloss.NewStyle().Foreground(style.GetBorderRightForeground()) + + // The thumb is as long a part of the track as the screen is of the output, + // and never shorter than one cell. + size := max(track*height/total, 1) + offset := 0 + if span := track - size; span > 0 { + offset = min(view.YOffset()*span/(total-height), span) + } + for row := firstRow + offset; row < firstRow+offset+size; row++ { + width := lipgloss.Width(lines[row]) + lines[row] = ansi.Truncate(lines[row], width-1, "") + thumb.Render("█") + } + return strings.Join(lines, "\n") +} + +type tuiLayout struct { + width int + bodyHeight int + gap int + leftOuterWidth int + rightOuterWidth int + leftInnerWidth int + rightInnerWidth int + innerHeight int +} + +func newTUILayout(width, height int) tuiLayout { + width, height = max(width, 1), max(height, 1) + bodyHeight := max(height-1, 3) + horizontalFrame := tuiPanelStyle.GetHorizontalFrameSize() + verticalFrame := tuiPanelStyle.GetVerticalFrameSize() + gap := 0 + leftOuterWidth := min(max(width*35/100, 22), 72) + if right := width - gap - leftOuterWidth; right < 16 { + leftOuterWidth = max(width-gap-16, 8) + } + rightOuterWidth := max(width-gap-leftOuterWidth, 8) + return tuiLayout{ + width: width, + bodyHeight: bodyHeight, + gap: gap, + leftOuterWidth: leftOuterWidth, + rightOuterWidth: rightOuterWidth, + leftInnerWidth: max(leftOuterWidth-horizontalFrame, 1), + rightInnerWidth: max(rightOuterWidth-horizontalFrame, 1), + innerHeight: max(bodyHeight-verticalFrame, 1), + } +} + +func (m tuiModel) taskList(width, height int) string { + lines := []string{paneTitle("TASKS", m.runStateLabel(), width)} + rows := m.taskRows() + if len(rows) == 0 { + lines = append(lines, tuiHelpStyle.Render("Waiting for tasks…")) + return strings.Join(lines, "\n") + } + + end := min(len(rows), m.listTop+max(height-1, 1)) + for i := m.listTop; i < end; i++ { + row := rows[i] + state := m.taskState(row.task) + selected := row.task.id == m.selectedID + sharedPrefix := "" + if m.taskNavigator == taskNavigatorTree && row.task.shared { + sharedPrefix = "↳ " + } + plainIcon := "" + if !m.statusLabels { + plainIcon = taskIconText(state) + " " + } + plainPrefix := row.treePrefix + plainIcon + sharedPrefix + + // The duration is right-aligned so durations line up and can be compared + // down the column. It is dropped rather than squeezing the name on a + // narrow pane. + duration := m.durationLabel(row.task) + available := width - lipgloss.Width(plainPrefix) + durationWidth := 0 + if duration != "" && available-lipgloss.Width(duration)-1 >= minTaskNameWidth { + durationWidth = lipgloss.Width(duration) + 1 + } else { + duration = "" + } + withDuration := func(content string, dim bool) string { + if duration == "" { + return content + } + rendered := duration + if dim { + rendered = tuiHelpStyle.Render(duration) + } + pad := max(width-lipgloss.Width(content)-lipgloss.Width(duration), 1) + return content + strings.Repeat(" ", pad) + rendered + } + + name, status := taskNameStatus(m.taskName(row.task), state, available-durationWidth, m.statusLabels) + if selected { + suffix := "" + if status != "" { + suffix = " " + status + } + lines = append(lines, tuiSelectedStyle.Width(width).Render(withDuration(plainPrefix+name+suffix, false))) + continue + } + if row.task.isRoot { + prefix := "" + if !m.statusLabels { + prefix = taskIcon(state) + " " + } + prefix += sharedPrefix + suffix := "" + if status != "" { + suffix = " " + taskStateLabel(state, status) + } + lines = append(lines, withDuration(prefix+tuiRootStyle.Render(name)+suffix, true)) + continue + } + suffix := "" + if status != "" { + suffix = " " + taskStateLabel(state, status) + } + icon := "" + if !m.statusLabels { + icon = taskIcon(state) + " " + } + line := tuiTreeStyle.Render(row.treePrefix) + icon + tuiTreeStyle.Render(sharedPrefix) + name + suffix + lines = append(lines, withDuration(line, true)) + } + return strings.Join(lines, "\n") +} + +func (m tuiModel) outputPanel(width int) string { + title := "OUTPUT" + if task := m.selectedRowTask(); task != nil { + title += " · " + m.taskName(task) + } + return paneTitle(title, m.outputStatus(), width) + "\n" + m.viewport.View() +} + +// paneTitle renders a pane header. right is rendered as given, so a caller can +// style it to carry meaning; the width maths uses its display width. +func paneTitle(left, right string, width int) string { + left = truncateText(left, max(width-lipgloss.Width(right)-1, 1)) + space := max(width-lipgloss.Width(left)-lipgloss.Width(right), 0) + return tuiTitleStyle.Render(left) + strings.Repeat(" ", space) + right +} + +// outputStatus is how the selected task ended, for the output pane header. A +// task that reported an exit code carries it alongside, the way mprocs and +// similar interfaces do, so that "exit 127" can be told from "exit 1" without +// reading the output. +func (m tuiModel) outputStatus() string { + selected := m.selectedRowTask() + if selected == nil { + return "" + } + task := m.taskOwner(selected) + if task.state == taskPending { + return "" + } + label := taskStateText(task.state) + if task.exitCode != nil { + label += fmt.Sprintf(" (%d)", *task.exitCode) + } + return taskStateLabel(task.state, label) +} + +// runStateLabel summarises the whole run for the task pane header, so the +// footer can stay dedicated to keys. +func (m tuiModel) runStateLabel() string { + switch { + case (m.quitting || m.returning) && !m.done: + return tuiHelpStyle.Render("stopping…") + case m.done && m.err != nil: + return tuiFailureStyle.Render("failed") + case m.done: + return tuiSuccessStyle.Render("complete") + case len(m.tasks) == 0: + return "" + default: + return tuiRunningStyle.Render("running") + } +} + +func taskStateStyle(state taskState) lipgloss.Style { + switch state { + case taskRunning: + return tuiRunningStyle + case taskSucceeded: + return tuiSuccessStyle + case taskFailed: + return tuiFailureStyle + case taskCanceled: + return tuiCanceledStyle + case taskSkipped: + return tuiHelpStyle + default: + return tuiHelpStyle + } +} + +func taskIcon(state taskState) string { + return taskStateStyle(state).Render(taskIconText(state)) +} + +func taskStateLabel(state taskState, label string) string { + return taskStateStyle(state).Render(label) +} + +func taskNameStatus(name string, state taskState, width int, showStatus bool) (string, string) { + if !showStatus { + return truncateMiddle(name, max(width, 1)), "" + } + if width <= 1 { + return truncateMiddle(name, max(width, 1)), "" + } + statusWidth := min(lipgloss.Width(taskStateText(state)), max(width-2, 0)) + if statusWidth == 0 { + return truncateMiddle(name, width), "" + } + status := truncateText(taskStateText(state), statusWidth) + name = truncateMiddle(name, max(width-lipgloss.Width(status)-1, 1)) + return name, status +} + +// durationLabel is how long a task ran, or nothing at all for one that never +// started. A pending or skipped task has no duration to report, as opposed to a +// duration of zero. +func (m tuiModel) durationLabel(task *tuiTask) string { + if task.startedAt.IsZero() { + return "" + } + return formatDuration(m.elapsed(task)) +} + +// minTaskNameWidth is the room a name needs before a duration may take space +// from it. Below that, knowing which task a row is matters more than knowing +// how long it took. +const minTaskNameWidth = 12 + +// formatDuration renders how long a task ran, short enough for a narrow pane. +// +// Quick tasks are reported in milliseconds rather than rounded away. A task that +// took three milliseconds spent that time starting a process and doing nothing, +// which is worth seeing, and a column where only the slow rows carry a number +// reads as a fault rather than a decision. +func formatDuration(d time.Duration) string { + switch { + case d < time.Second: + return fmt.Sprintf("%dms", d.Milliseconds()) + case d < 10*time.Second: + return fmt.Sprintf("%.1fs", d.Seconds()) + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) + default: + return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60) + } +} + +func taskIconText(state taskState) string { + switch state { + case taskRunning: + return "●" + case taskSucceeded: + return "✓" + case taskFailed: + return "✗" + case taskCanceled: + return "■" + case taskSkipped: + return "○" + default: + return "·" + } +} + +func taskStateText(state taskState) string { + switch state { + case taskRunning: + return "running" + case taskSucceeded: + return "success" + case taskFailed: + return "failed" + case taskCanceled: + return "canceled" + case taskSkipped: + return "skipped" + default: + return "pending" + } +} + +// appendOutputText appends data to existing, giving a carriage return the +// meaning it has on a terminal: move back to the start of the current line, so +// that what follows redraws it. Progress bars from tools like docker, npm and +// curl repaint themselves that way, and treating every repaint as a new line +// buries the pane in near-identical lines. +// +// A carriage return does not erase anything by itself -- the text stays on +// screen until something overwrites it -- so the pending redraw is carried +// across writes in pendingRedraw and applied only when more output arrives. +// +// The line is replaced rather than overwritten cell by cell, so a repaint +// shorter than what it replaces leaves no remainder behind. That differs from a +// real terminal, but tools that repaint a line pad it to a fixed width, and full +// cursor emulation is well beyond what an output pane needs. +func appendOutputText(existing, data string, pendingRedraw bool) (string, bool) { + data = strings.ReplaceAll(data, "\r\n", "\n") + if !pendingRedraw && !strings.ContainsRune(data, '\r') { + return existing + data, false + } + out := existing + for { + segment, rest, hasCarriageReturn := strings.Cut(data, "\r") + out, pendingRedraw = writeOutputSegment(out, segment, pendingRedraw) + if !hasCarriageReturn { + return out, pendingRedraw + } + pendingRedraw = true + data = rest + } +} + +// writeOutputSegment appends text containing no carriage return, first dropping +// the line the cursor was returned to if anything is about to redraw it. +func writeOutputSegment(out, segment string, pendingRedraw bool) (string, bool) { + if segment == "" { + return out, pendingRedraw + } + if pendingRedraw { + // A newline commits the line the cursor returned to; printable text + // redraws it. + if segment[0] != '\n' { + out = dropCurrentLine(out) + } + } + return out + segment, false +} + +func dropCurrentLine(s string) string { + if newline := strings.LastIndexByte(s, '\n'); newline >= 0 { + return s[:newline+1] + } + return "" +} + +func truncateText(s string, width int) string { + return ansi.Truncate(s, max(width, 0), "…") +} + +func truncateMiddle(s string, width int) string { + stringWidth := ansi.StringWidth(s) + if stringWidth <= width { + return s + } + if width <= 1 { + return ansi.Truncate("…", max(width, 0), "") + } + left := (width - 1) / 2 + right := width - 1 - left + return ansi.Cut(s, 0, left) + "…" + ansi.Cut(s, stringWidth-right, stringWidth) +} + +// shortHelp renders one line of key hints, dropping whole entries from the end +// when they do not fit and marking the cut with an ellipsis. +// +// The help bubble's own ShortHelpView cannot do this. It only drops an entry +// when there is room to place its ellipsis, and otherwise keeps appending, so +// it overflows the width it was given and leaves a word cut in half. Its styles +// and separator are still used, so the line matches the full key list. +func shortHelp(helpModel help.Model, bindings []key.Binding, width int) string { + width = max(width, 1) + styles := helpModel.Styles + separator := styles.ShortSeparator.Inline(true).Render(helpModel.ShortSeparator) + ellipsis := " " + styles.Ellipsis.Inline(true).Render(helpModel.Ellipsis) + + var line strings.Builder + used := 0 + for _, binding := range bindings { + if !binding.Enabled() { + continue + } + entry := styles.ShortKey.Inline(true).Render(binding.Help().Key) + " " + + styles.ShortDesc.Inline(true).Render(binding.Help().Desc) + if used > 0 { + entry = separator + entry + } + if used+lipgloss.Width(entry) > width { + if used+lipgloss.Width(ellipsis) <= width { + line.WriteString(ellipsis) + } + break + } + line.WriteString(entry) + used += lipgloss.Width(entry) + } + return truncateText(line.String(), width) +} + +// fullHelp renders the key list in as many columns as the width allows, down to +// a single column. Descriptions are written to be read, not to fit four columns +// on an 80 column terminal. +func fullHelp(helpModel help.Model, bindings []key.Binding, width int) string { + helpModel.ShowAll = true + for _, columns := range []int{3, 2, 1} { + view := helpModel.FullHelpView(fullHelpColumns(bindings, columns)) + if lipgloss.Width(view) <= width { + return view + } + } + return helpModel.FullHelpView(fullHelpColumns(bindings, 1)) +} + +// newHelpModel styles the help bubble with the palette the rest of the TUI +// uses. Its own defaults are a flat grey that reads as disabled next to the +// panes. +func newHelpModel() help.Model { + model := help.New() + styles := model.Styles + styles.ShortKey, styles.FullKey = tuiKeyStyle, tuiKeyStyle + styles.ShortDesc, styles.FullDesc = tuiHelpStyle, tuiHelpStyle + styles.ShortSeparator, styles.FullSeparator = tuiTreeStyle, tuiTreeStyle + styles.Ellipsis = tuiTreeStyle + model.Styles = styles + return model +} + +func renderStatus(width int, status string, style lipgloss.Style) string { + return truncateText(" "+style.Render(status), max(width, 1)) +} + +var ( + tuiAccentColor = compat.AdaptiveColor{Light: lipgloss.Color("#006A83"), Dark: lipgloss.Color("#5FD7FF")} + tuiFilterActiveColor = compat.AdaptiveColor{Light: lipgloss.Color("#5F3DC4"), Dark: lipgloss.Color("#AF87FF")} + tuiHelpColor = compat.AdaptiveColor{Light: lipgloss.Color("#66717C"), Dark: lipgloss.Color("#89949F")} + tuiPanelStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(compat.AdaptiveColor{Light: lipgloss.Color("#87909A"), Dark: lipgloss.Color("#59636E")}). + PaddingLeft(1). + PaddingRight(1) + + tuiTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(tuiAccentColor) + tuiFilterActiveStyle = lipgloss.NewStyle().Foreground(tuiFilterActiveColor) + tuiKeyStyle = lipgloss.NewStyle().Bold(true).Foreground(tuiAccentColor) + tuiRootStyle = lipgloss.NewStyle().Bold(true) + tuiSelectedStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#10212B"), Dark: lipgloss.Color("#F4F7FA")}). + Background(compat.AdaptiveColor{Light: lipgloss.Color("#D9E8ED"), Dark: lipgloss.Color("#34444D")}) + // tuiSelectionStyle marks a live selection, against tuiSelectedStyle for a + // cursor resting on a line. The accent colour carries it, so the two differ + // in weight rather than in hue. + tuiSelectionStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#F4F7FA"), Dark: lipgloss.Color("#10212B")}). + Background(tuiAccentColor) + tuiTreeStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#77818A"), Dark: lipgloss.Color("#697580")}) + tuiRunningStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#8A6500"), Dark: lipgloss.Color("#FFD75F")}) + tuiSuccessStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#257A3E"), Dark: lipgloss.Color("#5FD787")}) + tuiFailureStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#B42318"), Dark: lipgloss.Color("#FF6B6B")}) + tuiCanceledStyle = lipgloss.NewStyle().Foreground(compat.AdaptiveColor{Light: lipgloss.Color("#5F6670"), Dark: lipgloss.Color("#AAB2BD")}) + tuiHelpStyle = lipgloss.NewStyle().Foreground(tuiHelpColor) +) diff --git a/listener.go b/listener.go new file mode 100644 index 0000000000..850aed18ff --- /dev/null +++ b/listener.go @@ -0,0 +1,161 @@ +package task + +import ( + "io" + "time" +) + +// Invocation identifies one runtime call to a task. IDs are unique within an +// Executor, including repeated calls to the same task. +type Invocation struct { + ID uint64 // Unique call ID + ParentID uint64 // Call that scheduled this one; zero for a requested root + RootID uint64 // Requested root this call descends from + Task string // Name as written in the Taskfile; the key GetTask takes + Name string // Display name: the task's label when it has one +} + +// Result is how a call ended. +type Result uint8 + +const ( + // ResultSucceeded is the zero value, so a call that reported nothing needs + // no further interpretation. + ResultSucceeded Result = iota + ResultFailed + // ResultCanceled is a call interrupted before it could finish, by a failing + // sibling under fail-fast or by the caller cancelling the context. + ResultCanceled + // ResultSkipped is a call Task chose not to run at all: the task is not for + // the current platform, or its "if" condition was not met. Skipping is not a + // failure, and Run returns no error for it. + ResultSkipped +) + +func (r Result) String() string { + switch r { + case ResultSucceeded: + return "succeeded" + case ResultFailed: + return "failed" + case ResultCanceled: + return "canceled" + case ResultSkipped: + return "skipped" + default: + return "unknown" + } +} + +// Started reports a call beginning execution. A call that is scheduled but +// never attempted, because an earlier root failed, is never started. +type Started struct { + Invocation + At time.Time +} + +// Finished reports how a call ended. Every call that started is finished, as is +// every call that failed before it could start. +type Finished struct { + Invocation + Result Result + // Err is the detail behind ResultFailed, and nil otherwise. For a task that + // ran, it is an *errors.TaskRunError, whose TaskExitCode reports the exit + // status of the command that failed. + Err error + At time.Time + // Duration is how long the call ran, and zero if it never started. + Duration time.Duration +} + +// Joined reports a call that waited on another call's execution rather than +// running its own, because the task is "run: once" or "run: when_changed". A +// joined call produces no output and takes its result from the owner. +type Joined struct { + Invocation + OwnerID uint64 // The call whose execution this one waited on +} + +// Listener observes task execution. Assign one to Executor.Listener. +// +// Every field is optional: a zero Listener observes nothing and changes no +// behaviour. New fields may be added, so a client that sets only what it needs +// keeps working. +// +// Callbacks run on the goroutines executing the tasks, so implementations must +// be safe for concurrent use, and must return promptly: a callback that blocks +// holds up the task that reported it. +type Listener struct { + // Scheduled reports a call Task intends to run. A call that is attempted is + // always Finished, but a scheduled call may never be attempted, and then no + // further event arrives for it. + Scheduled func(Invocation) + Started func(Started) + Finished func(Finished) + Joined func(Joined) + + // OutputFor returns where a call's command output goes. A nil writer leaves + // that stream on the Executor's own, which is what a listener that only + // wants events should return. + // + // Output arrives as the command wrote it, escape sequences included, so a + // client can render colour. + OutputFor func(Invocation) (stdOut, stdErr io.Writer) + + // OwnsScreen says the client is drawing the display, so the Executor's own + // Stdout, Stderr and Stdin are not usable. Task routes what it would have + // printed through OutputFor, and refuses to run what needs the terminal + // itself: interactive tasks and watch mode. + // + // Questions are not refused outright. A client that sets a Prompter is + // asked through it, wherever it likes; one that does not gets an error for + // the task that had a question, rather than a prompt to a terminal the + // client is drawing over. + OwnsScreen bool +} + +func (e *Executor) notifyScheduled(invocation Invocation) { + if e.Listener != nil && e.Listener.Scheduled != nil { + e.Listener.Scheduled(invocation) + } +} + +func (e *Executor) notifyStarted(invocation Invocation, at time.Time) { + if e.Listener != nil && e.Listener.Started != nil { + e.Listener.Started(Started{Invocation: invocation, At: at}) + } +} + +func (e *Executor) notifyFinished(finished Finished) { + if e.Listener != nil && e.Listener.Finished != nil { + e.Listener.Finished(finished) + } +} + +func (e *Executor) notifyJoined(invocation Invocation, ownerID uint64) { + if e.Listener != nil && e.Listener.Joined != nil { + e.Listener.Joined(Joined{Invocation: invocation, OwnerID: ownerID}) + } +} + +// ownsScreen reports whether a client is drawing the display. +func (e *Executor) ownsScreen() bool { + return e.Listener != nil && e.Listener.OwnsScreen +} + +// listenerWriters returns where a call's command output should go, falling back +// to the Executor's own streams for whichever the listener declines to take. +func (e *Executor) listenerWriters(invocation Invocation) (io.Writer, io.Writer) { + stdOut, stdErr := e.Stdout, e.Stderr + if e.Listener == nil || e.Listener.OutputFor == nil { + return stdOut, stdErr + } + listenerOut, listenerErr := e.Listener.OutputFor(invocation) + if listenerOut != nil { + stdOut = listenerOut + } + if listenerErr != nil { + stdErr = listenerErr + } + return stdOut, stdErr +} diff --git a/prompter.go b/prompter.go new file mode 100644 index 0000000000..f4a5721964 --- /dev/null +++ b/prompter.go @@ -0,0 +1,68 @@ +package task + +import "github.com/go-task/task/v3/errors" + +// ErrPromptCancelled is returned by a Prompter when the user dismissed the +// question rather than answering it. Task stops the run, as it does when a +// prompt is declined on the terminal. +var ErrPromptCancelled = errors.New("task: prompt cancelled") + +// Prompter answers the questions Task needs to ask while it runs: the +// confirmation a task declares with "prompt", and the value of a required +// variable that was not supplied. Assign one to Executor.Prompter. +// +// A client that draws its own display should set one, or Task has nowhere to +// ask and refuses to run anything that needs an answer. A client that leaves +// Task's own streams alone need not: Task asks on the terminal as it always +// has. +// +// Methods are called from the goroutines running the tasks and must be safe for +// concurrent use. A call blocks the task that asked until it returns. +type Prompter interface { + // Confirm asks whether to run a task that declares "prompt". Returning + // false stops the task, as declining on the terminal does. + Confirm(task, message string) (bool, error) + + // Ask asks for a required variable that was not supplied. The answer + // becomes the variable's value. + // + // Task only asks for strings today, so a client should return one. The + // return is any because a variable's value is any: when Task's schema grows + // to declare types, VarRequest will say which is wanted and this will carry + // it. + Ask(VarRequest) (any, error) +} + +// VarRequest is a required variable Task needs a value for. +type VarRequest struct { + // Task is the name of the task that needs it, as written in the Taskfile, + // which is the key GetTask takes. + Task string + Name string + Type VarType +} + +// VarType is what a variable's value must be. Task always sets one. +// +// The set grows as Task's "requires" schema does. A client should handle an +// unrecognised type by returning an error rather than guessing: a wrong guess +// produces a value the task will act on. Adding a type breaks no client, since +// only Task can add one. +type VarType interface { + isVarType() +} + +// StringVar is free text. +type StringVar struct{} + +// EnumVar is one of a fixed set of values. +// +// A Taskfile can declare an enum by reference, and a reference that does not +// resolve before the run falls back to free text, so Task may ask for a +// StringVar where the Taskfile said "enum". +type EnumVar struct { + Options []string +} + +func (StringVar) isVarType() {} +func (EnumVar) isVarType() {} diff --git a/requires.go b/requires.go index e425f83ce3..029d0e7aee 100644 --- a/requires.go +++ b/requires.go @@ -1,21 +1,76 @@ package task import ( + "fmt" "slices" "github.com/elliotchance/orderedmap/v3" "github.com/go-task/task/v3/errors" "github.com/go-task/task/v3/internal/input" + "github.com/go-task/task/v3/internal/logger" "github.com/go-task/task/v3/internal/templater" "github.com/go-task/task/v3/internal/term" "github.com/go-task/task/v3/taskfile/ast" ) func (e *Executor) canPrompt() bool { + if e.Prompter != nil { + // Providing a Prompter is itself the statement that someone is there to + // answer, so no terminal and no --interactive are needed. A caller that + // wants questions refused can refuse them in its Prompter, which is a + // better place to decide than a flag: it can answer some and not others. + return true + } return e.Interactive && (e.AssumeTerm || term.IsTerminal()) } +// askVar obtains a required variable that was not supplied: from the client +// when one can answer, and otherwise on the terminal as Task always has. +func (e *Executor) askVar(taskName string, v *ast.VarsWithValidation) (any, error) { + if e.Prompter != nil { + return e.Prompter.Ask(VarRequest{ + Task: taskName, + Name: v.Name, + Type: varTypeOf(v), + }) + } + if e.ownsScreen() { + return nil, fmt.Errorf( + "task: task %q needs a value for %q, and the client cannot ask for one", + taskName, v.Name) + } + return e.newPrompter().Prompt(v.Name, getEnumValues(v.Enum)) +} + +func varTypeOf(v *ast.VarsWithValidation) VarType { + if options := getEnumValues(v.Enum); len(options) > 0 { + return EnumVar{Options: options} + } + return StringVar{} +} + +// confirm asks whether to run a task that declares "prompt". +func (e *Executor) confirm(taskName, message string) (bool, error) { + if e.Prompter != nil { + return e.Prompter.Confirm(taskName, message) + } + if e.ownsScreen() { + return false, fmt.Errorf( + "task: task %q needs confirmation, and the client cannot ask for it", taskName) + } + err := e.Logger.Prompt(logger.Yellow, message, "n", "y", "yes") + switch { + case errors.Is(err, logger.ErrNoTerminal): + return false, &errors.TaskCancelledNoTerminalError{TaskName: taskName} + case errors.Is(err, logger.ErrPromptCancelled): + return false, nil + case err != nil: + return false, err + } + return true, nil +} + func (e *Executor) newPrompter() *input.Prompter { return &input.Prompter{ Stdin: e.Stdin, @@ -36,6 +91,7 @@ func (e *Executor) promptDepsVars(calls []*Call) error { // Collect all missing vars from the dependency tree visited := make(map[string]bool) varsMap := orderedmap.NewOrderedMap[string, *ast.VarsWithValidation]() + askedFor := make(map[string]string) var collect func(call *Call) error collect = func(call *Call) error { @@ -47,6 +103,9 @@ func (e *Executor) promptDepsVars(calls []*Call) error { for _, v := range getMissingRequiredVars(compiledTask) { if !varsMap.Has(v.Name) { varsMap.Set(v.Name, resolveEnumRefForPrompt(v, compiledTask.Vars)) + // Remember who needed it first, so a client can say which task + // it is asking on behalf of. + askedFor[v.Name] = call.Task } } @@ -80,14 +139,13 @@ func (e *Executor) promptDepsVars(calls []*Call) error { return nil } - prompter := e.newPrompter() e.promptedVars = ast.NewVars() for v := range varsMap.Values() { - value, err := prompter.Prompt(v.Name, getEnumValues(v.Enum)) + value, err := e.askVar(askedFor[v.Name], v) if err != nil { - if errors.Is(err, input.ErrCancelled) { - return &errors.TaskCancelledByUserError{TaskName: "interactive prompt"} + if errors.Is(err, input.ErrCancelled) || errors.Is(err, ErrPromptCancelled) { + return &errors.TaskCancelledByUserError{TaskName: askedFor[v.Name]} } return err } @@ -120,12 +178,10 @@ func (e *Executor) promptTaskVars(t *ast.Task, call *Call) (bool, error) { return false, nil } - prompter := e.newPrompter() - for _, v := range missing { - value, err := prompter.Prompt(v.Name, getEnumValues(v.Enum)) + value, err := e.askVar(t.Task, v) if err != nil { - if errors.Is(err, input.ErrCancelled) { + if errors.Is(err, input.ErrCancelled) || errors.Is(err, ErrPromptCancelled) { return false, &errors.TaskCancelledByUserError{TaskName: t.Name()} } return false, err diff --git a/setup.go b/setup.go index e92848417a..18126032f3 100644 --- a/setup.go +++ b/setup.go @@ -259,6 +259,21 @@ func (e *Executor) setupDefaults() { } } +// ResetRunState clears the per-run bookkeeping that Task accumulates while +// executing: the map of started executions that "run: once" and +// "run: when_changed" calls join, and the per-task call counter behind +// MaximumTaskCall. Both are meant to span a single Run; a caller that reuses one +// Executor for several independent runs -- an interactive launcher, say -- must +// reset them in between, or the second run will join the first run's finished +// executions and return their results without executing anything. +// +// It must not be called while tasks are running. +func (e *Executor) ResetRunState() { + e.executionHashesMutex.Lock() + defer e.executionHashesMutex.Unlock() + e.setupConcurrencyState() +} + func (e *Executor) setupConcurrencyState() { e.executionHashes = make(map[string]*executionState) diff --git a/task.go b/task.go index 654b397e7c..e7bd63e016 100644 --- a/task.go +++ b/task.go @@ -8,6 +8,7 @@ import ( "slices" "strings" "sync/atomic" + "time" "golang.org/x/sync/errgroup" "mvdan.cc/sh/v3/interp" @@ -82,6 +83,18 @@ func (e *Executor) Run(ctx context.Context, calls ...*Call) error { if err != nil { return err } + if e.ownsScreen() && len(watchCalls) > 0 { + return errors.New("task: watch mode is not supported while a client is drawing the display") + } + // Schedule every requested root before starting execution so lifecycle + // consumers can present the complete set even when roots run sequentially. + for _, call := range regularCalls { + t, err := e.GetTask(call) + if err != nil { + return err + } + e.taskInvocation(call, t.Name()) + } g := &errgroup.Group{} if e.Failfast { @@ -124,7 +137,27 @@ func (e *Executor) splitRegularAndWatchCalls(calls ...*Call) (regularCalls []*Ca } // RunTask runs a task by its name -func (e *Executor) RunTask(ctx context.Context, call *Call) error { +func (e *Executor) RunTask(ctx context.Context, call *Call) (runErr error) { + // Announce the call before Task decides whether to run it, so a listener's + // task list stays complete even when the task cannot be resolved or + // compiled. Requested roots are announced by Run before execution begins; + // this covers every other call. + invocation := e.taskInvocation(call, call.Task) + skipped := false + var startedAt time.Time + defer func() { + finished := Finished{ + Invocation: invocation, + Result: taskResult(ctx, skipped, runErr), + Err: runErr, + At: time.Now(), + } + if !startedAt.IsZero() { + finished.Duration = finished.At.Sub(startedAt) + } + e.notifyFinished(finished) + }() + // Inject prompted vars into call if available if e.promptedVars != nil { if call.Vars == nil { @@ -144,6 +177,7 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { } if !shouldRunOnCurrentPlatform(t.Platforms) { e.Logger.VerboseOutf(logger.Yellow, `task: %q not for current platform - ignored\n`, call.Task) + skipped = true return nil } @@ -159,6 +193,16 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { if err != nil { return err } + // Compilation resolves labels and included-taskfile prefixes, which the raw + // call name does not carry, so re-read the name for the events that follow. + invocation = e.taskInvocation(call, t.Name()) + + if e.ownsScreen() && t.Interactive { + // An interactive task is not a question Task can relay: its command + // takes the terminal and uses it however it likes. Confirmations and + // missing variables are relayed, through the Executor's Prompter. + return fmt.Errorf("task: task %q is interactive and cannot run while a client is drawing the display", t.Name()) + } // Check if condition after CompiledTask so dynamic variables are resolved if strings.TrimSpace(t.If) != "" { @@ -168,6 +212,7 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { Env: env.Get(t), }); err != nil { e.Logger.VerboseOutf(logger.Yellow, "task: if condition not met - skipped: %q\n", call.Task) + skipped = true return nil } } @@ -203,9 +248,12 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { release := e.acquireConcurrencyLimit() defer release() - if err = e.startExecution(ctx, t, func(ctx context.Context) error { + err = e.startExecution(ctx, t, invocation, func(ctx context.Context) (runErr error) { + startedAt = time.Now() + e.notifyStarted(invocation, startedAt) + e.Logger.VerboseErrf(logger.Magenta, "task: %q started\n", call.Task) - if err := e.runDeps(ctx, t); err != nil { + if err := e.runDeps(ctx, t, call.invocationID, call.rootInvocationID); err != nil { return err } @@ -238,14 +286,15 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { } for _, p := range t.Prompt { - if p != "" && !e.Dry { - if err := e.Logger.Prompt(logger.Yellow, p, "n", "y", "yes"); errors.Is(err, logger.ErrNoTerminal) { - return &errors.TaskCancelledNoTerminalError{TaskName: call.Task} - } else if errors.Is(err, logger.ErrPromptCancelled) { - return &errors.TaskCancelledByUserError{TaskName: call.Task} - } else if err != nil { - return err - } + if p == "" || e.Dry { + continue + } + confirmed, err := e.confirm(call.Task, p) + if err != nil { + return err + } + if !confirmed { + return &errors.TaskCancelledByUserError{TaskName: call.Task} } } @@ -284,13 +333,57 @@ func (e *Executor) RunTask(ctx context.Context, call *Call) error { } e.Logger.VerboseErrf(logger.Magenta, "task: %q finished\n", call.Task) return nil - }); err != nil { + }) + if err != nil { return &errors.TaskRunError{TaskName: t.Name(), Err: err} } return nil } +// taskResult classifies how a task attempt ended, for lifecycle consumers. +// +// Cancellation is decided by the context rather than by inspecting the error. A +// killed process does not report itself the same way on every platform: the +// shell interpreter surfaces the context error on Unix, while on Windows the +// same kill arrives as a plain non-zero exit status. The context is the same +// everywhere. +func taskResult(ctx context.Context, skipped bool, err error) Result { + switch { + case skipped: + return ResultSkipped + case err == nil: + return ResultSucceeded + case ctx.Err() != nil: + return ResultCanceled + default: + return ResultFailed + } +} + +func (e *Executor) taskInvocation(call *Call, name string) Invocation { + if call.invocationID == 0 { + call.invocationID = atomic.AddUint64(&e.taskInvocationID, 1) + if !call.Indirect || call.rootInvocationID == 0 { + call.rootInvocationID = call.invocationID + } + invocation := e.invocationOf(call, name) + e.notifyScheduled(invocation) + return invocation + } + return e.invocationOf(call, name) +} + +func (e *Executor) invocationOf(call *Call, name string) Invocation { + return Invocation{ + ID: call.invocationID, + ParentID: call.parentInvocationID, + RootID: call.rootInvocationID, + Task: call.Task, + Name: name, + } +} + func (e *Executor) mkdir(t *ast.Task) error { if t.Dir == "" { return nil @@ -308,7 +401,7 @@ func (e *Executor) mkdir(t *ast.Task) error { return nil } -func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error { +func (e *Executor) runDeps(ctx context.Context, t *ast.Task, parentInvocationID, rootInvocationID uint64) error { g := &errgroup.Group{} if e.Failfast || t.Failfast { g, ctx = errgroup.WithContext(ctx) @@ -328,7 +421,14 @@ func (e *Executor) runDeps(ctx context.Context, t *ast.Task) error { defer cancel() } - err := e.RunTask(depCtx, &Call{Task: d.Task, Vars: d.Vars, Silent: d.Silent, Indirect: true}) + err := e.RunTask(depCtx, &Call{ + Task: d.Task, + Vars: d.Vars, + Silent: d.Silent, + Indirect: true, + parentInvocationID: parentInvocationID, + rootInvocationID: rootInvocationID, + }) if err != nil && timedOut(depCtx, timeout) { return timeout } @@ -395,7 +495,14 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in reacquire := e.releaseConcurrencyLimit() defer reacquire() - err := e.RunTask(ctx, &Call{Task: cmd.Task, Vars: cmd.Vars, Silent: cmd.Silent, Indirect: true}) + err := e.RunTask(ctx, &Call{ + Task: cmd.Task, + Vars: cmd.Vars, + Silent: cmd.Silent, + Indirect: true, + parentInvocationID: call.invocationID, + rootInvocationID: call.rootInvocationID, + }) if err != nil && timedOut(ctx, timeout) { err = timeout } @@ -410,7 +517,8 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in return nil } - if e.Verbose || (!call.Silent && !cmd.Silent && !t.IsSilent() && !e.Taskfile.Silent && !e.Silent) { + logCommand := e.Verbose || (!call.Silent && !cmd.Silent && !t.IsSilent() && !e.Taskfile.Silent && !e.Silent) + if logCommand && (!e.ownsScreen() || e.Dry) { e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmd.LogCmd) } @@ -422,12 +530,21 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in if t.Interactive { outputWrapper = output.Interleaved{} } + stdOutBase, stdErrBase := e.listenerWriters(e.invocationOf(call, t.Name())) + if e.ownsScreen() { + // The listener renders raw command output in its own panes, so + // --output styling applies to runs it does not host instead. + outputWrapper = output.Interleaved{} + } vars, err := e.Compiler.FastGetVariables(t, call) outputTemplater := &templater.Cache{Vars: vars} if err != nil { return fmt.Errorf("task: failed to get variables: %w", err) } - stdOut, stdErr, closer := outputWrapper.WrapWriter(e.Stdout, e.Stderr, t.Prefix, outputTemplater) + stdOut, stdErr, closer := outputWrapper.WrapWriter(stdOutBase, stdErrBase, t.Prefix, outputTemplater) + if logCommand && e.ownsScreen() { + e.Logger.FOutf(stdErr, logger.Green, "task: [%s] %s\n", t.Name(), cmd.LogCmd) + } err = execext.RunCommand(ctx, &execext.RunCommandOptions{ Command: cmd.Cmd, @@ -474,11 +591,12 @@ func timedOut(ctx context.Context, timeout *errors.TaskTimeoutError) bool { // executionState is the outcome of a task execution, shared with the callers // that join it. err is written before done is closed; read it only once closed. type executionState struct { - done chan struct{} - err error + done chan struct{} + err error + ownerID uint64 } -func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func(ctx context.Context) error) error { +func (e *Executor) startExecution(ctx context.Context, t *ast.Task, invocation Invocation, execute func(ctx context.Context) error) error { h, err := e.GetHash(t) if err != nil { return err @@ -493,6 +611,7 @@ func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func if other, ok := e.executionHashes[h]; ok { e.executionHashesMutex.Unlock() e.Logger.VerboseErrf(logger.Magenta, "task: skipping execution of task: %s\n", h) + e.notifyJoined(invocation, other.ownerID) // Release our execution slot to avoid blocking other tasks while we wait reacquire := e.releaseConcurrencyLimit() @@ -518,7 +637,7 @@ func (e *Executor) startExecution(ctx context.Context, t *ast.Task, execute func } } - state := &executionState{done: make(chan struct{})} + state := &executionState{done: make(chan struct{}), ownerID: invocation.ID} e.executionHashes[h] = state e.executionHashesMutex.Unlock() diff --git a/task_internal_test.go b/task_internal_test.go new file mode 100644 index 0000000000..cb8be8a632 --- /dev/null +++ b/task_internal_test.go @@ -0,0 +1,55 @@ +package task + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTaskResult(t *testing.T) { + t.Parallel() + + live := context.Background() + canceled, cancel := context.WithCancel(context.Background()) + cancel() + + failure := errors.New("exit status 1") + + tests := []struct { + name string + ctx context.Context + skipped bool + err error + want Result + }{ + {"no error", live, false, nil, ResultSucceeded}, + {"failed", live, false, failure, ResultFailed}, + {"skipped", live, true, nil, ResultSkipped}, + // A killed process reports the context error on Unix but a plain exit + // status on Windows, so the context decides, not the error. + {"killed, reported as a context error", canceled, false, fmt.Errorf("run: %w", context.Canceled), ResultCanceled}, + {"killed, reported as an exit status", canceled, false, failure, ResultCanceled}, + // Succeeding in a context that is already done is still success. + {"finished before cancellation landed", canceled, false, nil, ResultSucceeded}, + // Skipping wins: Task chose not to run it, so there is nothing to cancel. + {"skipped in a cancelled context", canceled, true, nil, ResultSkipped}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, test.want, taskResult(test.ctx, test.skipped, test.err)) + }) + } +} + +func TestResultString(t *testing.T) { + t.Parallel() + + assert.Equal(t, "succeeded", ResultSucceeded.String()) + assert.Equal(t, "failed", ResultFailed.String()) + assert.Equal(t, "canceled", ResultCanceled.String()) + assert.Equal(t, "skipped", ResultSkipped.String()) +} diff --git a/task_lifecycle_test.go b/task_lifecycle_test.go new file mode 100644 index 0000000000..1ae3d45443 --- /dev/null +++ b/task_lifecycle_test.go @@ -0,0 +1,721 @@ +package task_test + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3" +) + +func TestTaskLifecycleOutput(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + taskfile := `version: '3' +tasks: + default: + deps: [first, second] + cmds: + - task: third + - echo parent + first: + deps: [shared] + cmds: [echo first] + second: + deps: [shared] + cmds: [echo second] + third: echo third + shared: + run: once + cmds: [echo shared] +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o600)) + + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithSilent(true), + task.WithForce(true), + ) + require.NoError(t, e.Setup()) + recorder := &lifecycleRecorder{} + e.Listener = recorder.listener() + + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "default"})) + require.Len(t, recorder.scheduled, 6) + require.Len(t, recorder.started, 5) + byName := make(map[string]task.Invocation) + for _, invocation := range recorder.started { + byName[invocation.Name] = invocation + } + assert.Equal(t, 2, countInvocations(recorder.scheduled, "shared")) + root := byName["default"] + require.Len(t, recorder.joined, 1) + for _, ownerID := range recorder.joined { + assert.Equal(t, byName["shared"].ID, ownerID) + } + assert.Equal(t, root.ID, root.RootID) + assert.Zero(t, root.ParentID) + for _, name := range []string{"first", "second", "third"} { + assert.Equal(t, root.ID, byName[name].ParentID, name) + } + for _, name := range []string{"first", "second", "third", "shared"} { + assert.Equal(t, root.ID, byName[name].RootID, name) + } + var sharedParentIDs []uint64 + for _, invocation := range recorder.scheduled { + if invocation.Name == "shared" { + sharedParentIDs = append(sharedParentIDs, invocation.ParentID) + } + } + assert.ElementsMatch(t, []uint64{byName["first"].ID, byName["second"].ID}, sharedParentIDs) + scheduledIDs := make([]uint64, len(recorder.scheduled)) + for i, invocation := range recorder.scheduled { + scheduledIDs[i] = invocation.ID + } + assert.ElementsMatch(t, scheduledIDs, recorder.finished) + expectedOutput := map[string]string{ + "default": "parent", + "first": "first", + "second": "second", + "third": "third", + "shared": "shared", + } + for name, invocation := range byName { + require.Contains(t, recorder.outputs, invocation.ID) + assert.Contains(t, recorder.outputs[invocation.ID].String(), expectedOutput[name]) + } +} + +func TestTaskLifecycleReportsFailfastCancellation(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + taskfile := `version: '3' +tasks: + default: + failfast: true + deps: [fail, slow] + fail: + cmds: + - sleep 0.1 + - exit 1 + slow: + cmds: + - sleep 5 +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o600)) + + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithSilent(true), + task.WithForce(true), + ) + require.NoError(t, e.Setup()) + recorder := &lifecycleRecorder{} + e.Listener = recorder.listener() + + require.Error(t, e.Run(t.Context(), &task.Call{Task: "default"})) + byName := make(map[string]task.Invocation) + for _, invocation := range recorder.started { + byName[invocation.Name] = invocation + } + require.Contains(t, byName, "slow") + assert.Equal(t, task.ResultCanceled, recorder.finishResults[byName["slow"].ID]) +} + +func TestTaskLifecycleSchedulesAllRequestedRootsBeforeExecution(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + taskfile := `version: '3' +tasks: + build: echo build + test: echo test +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o600)) + + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithSilent(true), + task.WithForce(true), + ) + require.NoError(t, e.Setup()) + recorder := &lifecycleRecorder{} + e.Listener = recorder.listener() + + require.NoError(t, e.Run( + t.Context(), + &task.Call{Task: "build"}, + &task.Call{Task: "test"}, + )) + assert.Equal(t, 2, recorder.scheduledAtFirstStart) + assert.Equal(t, []string{"build", "test"}, invocationNames(recorder.scheduled)) + assert.ElementsMatch(t, []uint64{recorder.scheduled[0].ID, recorder.scheduled[1].ID}, recorder.finished) + for _, invocation := range recorder.scheduled { + assert.Equal(t, invocation.ID, invocation.RootID) + assert.Zero(t, invocation.ParentID) + } +} + +func TestTaskLifecycleFinishesRootThatFailsBeforeStarting(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + taskfile := `version: '3' +tasks: + lint: + requires: + vars: [FIX] + cmds: [echo lint] + typing: echo typing +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o600)) + + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithSilent(true), + ) + require.NoError(t, e.Setup()) + recorder := &lifecycleRecorder{} + e.Listener = recorder.listener() + + err := e.Run( + t.Context(), + &task.Call{Task: "lint"}, + &task.Call{Task: "typing"}, + ) + require.Error(t, err) + require.Len(t, recorder.scheduled, 2) + require.Len(t, recorder.finished, 1) + lint := recorder.scheduled[0] + assert.Equal(t, lint.ID, recorder.finished[0]) + assert.Error(t, recorder.finishErrors[lint.ID]) + assert.Equal(t, task.ResultFailed, recorder.finishResults[lint.ID]) +} + +type lifecycleRecorder struct { + mutex sync.Mutex + scheduled []task.Invocation + started []task.Invocation + finished []uint64 + outputs map[uint64]*bytes.Buffer + joined map[uint64]uint64 + finishErrors map[uint64]error + finishResults map[uint64]task.Result + finishDurations map[uint64]time.Duration + + scheduledAtFirstStart int +} + +// listener is the recorder as an executor listener. Building it from closures +// is what a client does, so the tests exercise the same shape as real callers. +func (r *lifecycleRecorder) listener() *task.Listener { + return &task.Listener{ + Scheduled: func(invocation task.Invocation) { + r.mutex.Lock() + defer r.mutex.Unlock() + r.scheduled = append(r.scheduled, invocation) + }, + Started: func(started task.Started) { + r.mutex.Lock() + defer r.mutex.Unlock() + if len(r.started) == 0 { + r.scheduledAtFirstStart = len(r.scheduled) + } + r.started = append(r.started, started.Invocation) + }, + Finished: func(finished task.Finished) { + r.mutex.Lock() + defer r.mutex.Unlock() + r.finished = append(r.finished, finished.ID) + if r.finishErrors == nil { + r.finishErrors = make(map[uint64]error) + r.finishResults = make(map[uint64]task.Result) + r.finishDurations = make(map[uint64]time.Duration) + } + r.finishErrors[finished.ID] = finished.Err + r.finishResults[finished.ID] = finished.Result + r.finishDurations[finished.ID] = finished.Duration + }, + Joined: func(joined task.Joined) { + r.mutex.Lock() + defer r.mutex.Unlock() + if r.joined == nil { + r.joined = make(map[uint64]uint64) + } + r.joined[joined.ID] = joined.OwnerID + }, + OutputFor: func(invocation task.Invocation) (io.Writer, io.Writer) { + r.mutex.Lock() + defer r.mutex.Unlock() + if r.outputs == nil { + r.outputs = make(map[uint64]*bytes.Buffer) + } + buffer := r.outputs[invocation.ID] + if buffer == nil { + buffer = &bytes.Buffer{} + r.outputs[invocation.ID] = buffer + } + return buffer, buffer + }, + } +} + +func countInvocations(invocations []task.Invocation, name string) int { + count := 0 + for _, invocation := range invocations { + if invocation.Name == name { + count++ + } + } + return count +} + +func invocationNames(invocations []task.Invocation) []string { + names := make([]string, len(invocations)) + for i, invocation := range invocations { + names[i] = invocation.Name + } + return names +} + +func TestResetRunStateLetsRunOnceTasksExecuteAgain(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + taskfile := `version: '3' +tasks: + build: + run: once + cmds: [echo built] +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o600)) + + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithSilent(true), + task.WithForce(true), + ) + require.NoError(t, e.Setup()) + recorder := &lifecycleRecorder{} + e.Listener = recorder.listener() + + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + + // Reusing the Executor without resetting joins the finished execution, so + // the second run produces no output of its own. + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + require.Len(t, recorder.joined, 1) + + e.ResetRunState() + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + require.Len(t, recorder.joined, 1, "reset run should execute rather than join") + + // All three calls were scheduled, but the joined one never started or + // produced output of its own: it adopted the first run's result. + require.Len(t, recorder.scheduled, 3) + require.Len(t, recorder.started, 2) + for _, invocation := range recorder.started { + buffer, ok := recorder.outputs[invocation.ID] + require.True(t, ok, "started call %d produced no output", invocation.ID) + assert.Contains(t, buffer.String(), "built") + } +} + +func TestTaskLifecycleReportsCallsThatCannotBeResolved(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + taskfile := `version: '3' +tasks: + build: + deps: [compile, typoo] + cmds: [echo building] + compile: + cmds: [echo compiling] +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o600)) + + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithSilent(true), + task.WithForce(true), + ) + require.NoError(t, e.Setup()) + recorder := &lifecycleRecorder{} + e.Listener = recorder.listener() + + require.Error(t, e.Run(t.Context(), &task.Call{Task: "build"})) + + // The dep never compiles, but it is still announced under the name written + // in the Taskfile and its own failure is reported against it. + byName := make(map[string]task.Invocation) + for _, invocation := range recorder.scheduled { + byName[invocation.Name] = invocation + } + require.Contains(t, byName, "typoo") + err := recorder.finishErrors[byName["typoo"].ID] + require.Error(t, err) + assert.Equal(t, task.ResultFailed, recorder.finishResults[byName["typoo"].ID]) + assert.Contains(t, err.Error(), `Task "typoo" does not exist`) +} + +func TestTaskLifecycleReportsSkippedCalls(t *testing.T) { + t.Parallel() + + otherPlatform := "windows" + if runtime.GOOS == "windows" { + otherPlatform = "linux" + } + dir := t.TempDir() + taskfile := `version: '3' +tasks: + build: + deps: [other-platform, condition-not-met, always] + other-platform: + platforms: [` + otherPlatform + `] + cmds: [echo nope] + condition-not-met: + if: 'false' + cmds: [echo nope] + always: + cmds: [echo yes] +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o600)) + + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithSilent(true), + task.WithForce(true), + ) + require.NoError(t, e.Setup()) + recorder := &lifecycleRecorder{} + e.Listener = recorder.listener() + + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + + byName := make(map[string]task.Invocation) + for _, invocation := range recorder.scheduled { + byName[invocation.Name] = invocation + } + for _, name := range []string{"other-platform", "condition-not-met"} { + require.Contains(t, byName, name) + assert.Equal(t, task.ResultSkipped, recorder.finishResults[byName[name].ID], name) + assert.NoError(t, recorder.finishErrors[byName[name].ID], "skipping is not a failure") + } + require.Contains(t, byName, "always") + assert.Equal(t, task.ResultSucceeded, recorder.finishResults[byName["always"].ID]) +} + +func TestTaskLifecycleIdentifiesTasksByTheirTaskfileName(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + taskfile := `version: '3' +tasks: + build: + label: Build the docs + cmds: [echo building] +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o600)) + + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithSilent(true), + task.WithForce(true), + ) + require.NoError(t, e.Setup()) + recorder := &lifecycleRecorder{} + e.Listener = recorder.listener() + + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + + require.Len(t, recorder.started, 1) + started := recorder.started[0] + // Name is for display and becomes the label. Task stays the key a client + // needs to look the task back up. + assert.Equal(t, "Build the docs", started.Name) + assert.Equal(t, "build", started.Task) + + found, err := e.GetTask(&task.Call{Task: started.Task}) + require.NoError(t, err) + assert.Equal(t, "Build the docs", found.Name()) +} + +func TestTaskLifecycleTimesTasksItself(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + taskfile := `version: '3' +tasks: + slow: + cmds: [sleep 0.2] + never-runs: + platforms: [plan9] + cmds: [echo nope] +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o600)) + + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithSilent(true), + task.WithForce(true), + ) + require.NoError(t, e.Setup()) + recorder := &lifecycleRecorder{} + e.Listener = recorder.listener() + + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "slow"}, &task.Call{Task: "never-runs"})) + + byName := make(map[string]task.Invocation) + for _, invocation := range recorder.scheduled { + byName[invocation.Task] = invocation + } + + // The duration comes from the executor, so a client need not time the + // events reaching it. + assert.GreaterOrEqual(t, recorder.finishDurations[byName["slow"].ID], 200*time.Millisecond) + // A call that never started has no duration to report. + assert.Zero(t, recorder.finishDurations[byName["never-runs"].ID]) +} + +func TestListenerNeedsNoFields(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), + []byte("version: '3'\ntasks:\n build: echo built\n"), 0o600)) + + var out bytes.Buffer + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(&out), + task.WithStderr(&out), + task.WithSilent(true), + task.WithForce(true), + ) + require.NoError(t, e.Setup()) + + // A listener that sets nothing observes nothing and changes nothing: output + // still goes to the Executor's own streams. + e.Listener = &task.Listener{} + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "build"})) + assert.Contains(t, out.String(), "built") +} + +// recordingPrompter answers Task's questions the way a client would. +type recordingPrompter struct { + mutex sync.Mutex + confirms []string + requests []task.VarRequest + confirmed bool + answer any + err error +} + +func (p *recordingPrompter) Confirm(taskName, message string) (bool, error) { + p.mutex.Lock() + defer p.mutex.Unlock() + p.confirms = append(p.confirms, taskName+": "+message) + return p.confirmed, p.err +} + +func (p *recordingPrompter) Ask(request task.VarRequest) (any, error) { + p.mutex.Lock() + defer p.mutex.Unlock() + p.requests = append(p.requests, request) + return p.answer, p.err +} + +func newPrompterExecutor(t *testing.T, taskfile string) *task.Executor { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o600)) + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithSilent(true), + task.WithForce(true), + task.WithInteractive(true), + ) + require.NoError(t, e.Setup()) + return e +} + +func TestPrompterAnswersForRequiredVariables(t *testing.T) { + t.Parallel() + + e := newPrompterExecutor(t, `version: '3' +tasks: + release: + requires: + vars: + - RELEASE_NAME + - name: ENVIRONMENT + enum: [development, staging, production] + cmds: [echo releasing] +`) + prompter := &recordingPrompter{answer: "staging"} + e.Prompter = prompter + + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "release"})) + + byName := make(map[string]task.VarRequest) + for _, request := range prompter.requests { + byName[request.Name] = request + } + + // A free-text variable and an enum arrive as different types, so a client + // can pick the right dialog without inspecting the Taskfile. + require.Contains(t, byName, "RELEASE_NAME") + assert.Equal(t, task.StringVar{}, byName["RELEASE_NAME"].Type) + assert.Equal(t, "release", byName["RELEASE_NAME"].Task, "the client is told who is asking") + + require.Contains(t, byName, "ENVIRONMENT") + enum, ok := byName["ENVIRONMENT"].Type.(task.EnumVar) + require.True(t, ok, "an enum variable is an EnumVar") + assert.Equal(t, []string{"development", "staging", "production"}, enum.Options) +} + +func TestPrompterConfirmsAndDeclines(t *testing.T) { + t.Parallel() + + taskfile := `version: '3' +tasks: + deploy: + prompt: Really deploy? + cmds: [echo deploying] +` + e := newPrompterExecutor(t, taskfile) + prompter := &recordingPrompter{confirmed: true} + e.Prompter = prompter + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "deploy"})) + assert.Equal(t, []string{"deploy: Really deploy?"}, prompter.confirms) + + // Declining stops the task, and is not an error the client has to invent. + declining := newPrompterExecutor(t, taskfile) + declining.Prompter = &recordingPrompter{confirmed: false} + err := declining.Run(t.Context(), &task.Call{Task: "deploy"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "cancelled") +} + +func TestPrompterCancellationStopsTheRun(t *testing.T) { + t.Parallel() + + e := newPrompterExecutor(t, `version: '3' +tasks: + release: + requires: + vars: [RELEASE_NAME] + cmds: [echo releasing] +`) + e.Prompter = &recordingPrompter{err: task.ErrPromptCancelled} + + err := e.Run(t.Context(), &task.Call{Task: "release"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "cancelled") +} + +func TestPrompterIsUsedWithoutATerminal(t *testing.T) { + t.Parallel() + + // A client answers, so Task does not need a terminal of its own. Without a + // Prompter this same executor would fail on the missing variable. + e := newPrompterExecutor(t, `version: '3' +tasks: + release: + requires: + vars: [RELEASE_NAME] + cmds: [echo releasing] +`) + e.Prompter = &recordingPrompter{answer: "v1"} + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "release"})) +} + +func TestPrompterAsksWithoutTheInteractiveFlag(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(`version: '3' +tasks: + release: + requires: + vars: [RELEASE_NAME] + cmds: [echo releasing] +`), 0o600)) + + // Providing a Prompter is itself the statement that someone can answer, so + // --interactive is not also required. A client from which there is no way + // to pass a variable would otherwise be unable to run the task at all. + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithSilent(true), + task.WithForce(true), + ) + require.NoError(t, e.Setup()) + e.Prompter = &recordingPrompter{answer: "v1"} + + require.NoError(t, e.Run(t.Context(), &task.Call{Task: "release"})) +} + +func TestPrompterCanRefuseToAsk(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(`version: '3' +tasks: + release: + requires: + vars: [RELEASE_NAME] + cmds: [echo releasing] +`), 0o600)) + + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithSilent(true), + task.WithForce(true), + ) + require.NoError(t, e.Setup()) + + // A client that does not want to be asked declines in its Prompter, which + // is a better place to decide than a flag: it can answer some and not + // others. + e.Prompter = &recordingPrompter{err: errors.New("nobody is here to answer")} + err := e.Run(t.Context(), &task.Call{Task: "release"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "nobody is here to answer") +} diff --git a/taskrc/ast/taskrc.go b/taskrc/ast/taskrc.go index 895b8f7ee8..52c73c5cb4 100644 --- a/taskrc/ast/taskrc.go +++ b/taskrc/ast/taskrc.go @@ -18,11 +18,20 @@ type TaskRC struct { Concurrency *int `yaml:"concurrency"` Interactive *bool `yaml:"interactive"` Remote Remote `yaml:"remote"` + TUI TUI `yaml:"tui"` Failfast bool `yaml:"failfast"` TempDir *string `yaml:"temp-dir"` Experiments map[string]int `yaml:"experiments"` } +// TUI holds the display preferences of the terminal interface. Whether to use +// the interface at all stays a flag: it needs a terminal, so a file that turned +// it on by default would break every piped or scripted run. +type TUI struct { + Status *string `yaml:"status"` + TaskNavigator *string `yaml:"task-navigator"` +} + type Remote struct { Insecure *bool `yaml:"insecure"` Offline *bool `yaml:"offline"` @@ -72,4 +81,6 @@ func (t *TaskRC) Merge(other *TaskRC) { t.Interactive = cmp.Or(other.Interactive, t.Interactive) t.Failfast = cmp.Or(other.Failfast, t.Failfast) t.TempDir = cmp.Or(other.TempDir, t.TempDir) + t.TUI.Status = cmp.Or(other.TUI.Status, t.TUI.Status) + t.TUI.TaskNavigator = cmp.Or(other.TUI.TaskNavigator, t.TUI.TaskNavigator) } diff --git a/taskrc/taskrc_test.go b/taskrc/taskrc_test.go index dde9f9c58c..44a8d0010b 100644 --- a/taskrc/taskrc_test.go +++ b/taskrc/taskrc_test.go @@ -341,3 +341,26 @@ remote: assert.Equal(t, []string{"github.com", "gitlab.com"}, base.Remote.TrustedHosts) }) } + +func TestGetConfig_TUI(t *testing.T) { //nolint:paralleltest // cannot run in parallel + _, homeDir, localDir := setupDirs(t) + + writeFile(t, homeDir, ".taskrc.yml", ` +tui: + status: labels + task-navigator: tree +`) + // A project may prefer a different navigator without restating the status. + writeFile(t, localDir, ".taskrc.yml", ` +tui: + task-navigator: list +`) + + cfg, err := GetConfig(localDir) + require.NoError(t, err) + require.NotNil(t, cfg) + require.NotNil(t, cfg.TUI.Status) + require.NotNil(t, cfg.TUI.TaskNavigator) + assert.Equal(t, "labels", *cfg.TUI.Status) + assert.Equal(t, "list", *cfg.TUI.TaskNavigator) +} diff --git a/testdata/tui/Taskfile.yml b/testdata/tui/Taskfile.yml new file mode 100644 index 0000000000..cfd9cf35b2 --- /dev/null +++ b/testdata/tui/Taskfile.yml @@ -0,0 +1,172 @@ +version: '3' + +tasks: + shared-dep-always-running: + desc: Two parents call a shared dependency with the default run behavior + deps: [parent-a-always, parent-b-always] + + parent-a-always: + internal: true + deps: [shared-always] + cmds: + - echo "parent A continued after shared-always" + + parent-b-always: + internal: true + deps: [shared-always] + cmds: + - echo "parent B continued after shared-always" + + shared-always: + internal: true + silent: true + cmds: + - | + echo "shared-always START $(date +%H:%M:%S)" + sleep 2 + echo "shared-always END $(date +%H:%M:%S)" + + shared-dep-running-once: + desc: Two parents call a shared dependency configured with run once + deps: [parent-a-once, parent-b-once] + + parent-a-once: + internal: true + deps: [shared-once] + cmds: + - echo "parent A continued after shared-once" + + parent-b-once: + internal: true + deps: [shared-once] + cmds: + - echo "parent B continued after shared-once" + + shared-once: + internal: true + run: once + silent: true + cmds: + - | + echo "shared-once START $(date +%H:%M:%S)" + sleep 2 + echo "shared-once END $(date +%H:%M:%S)" + + fail-fast-example: + desc: One task succeeds, one fails, and one is canceled by fail-fast + failfast: true + deps: + - success-after-1s + - fail-after-2s + - success-after-3s + + success-after-1s: + internal: true + cmds: + - echo "1-second task START" + - sleep 1 + - echo "1-second task SUCCESS" + + fail-after-2s: + internal: true + cmds: + - echo "2-second task START" + - sleep 2 + - echo "2-second task FAILING" + - exit 1 + + success-after-3s: + internal: true + cmds: + - echo "3-second task START" + - sleep 3 + - echo "3-second task SUCCESS" + + unresolvable-dep-example: + desc: A dep that does not exist still appears, with its own error + deps: + - compiles-fine + - typoo + + compiles-fine: + internal: true + cmds: + - echo "this one resolves and runs" + + skipped-deps-example: + desc: Deps skipped by platform and by an if condition, next to one that runs + deps: + - other-platform-only + - condition-not-met + - condition-met + + other-platform-only: + internal: true + platforms: [plan9] + cmds: + - echo "never runs on a normal machine" + + condition-not-met: + internal: true + if: 'false' + cmds: + - echo "never runs" + + condition-met: + internal: true + if: 'true' + cmds: + - echo "the if condition was met, so this ran" + + labelled-task-example: + desc: A dep announced by its Taskfile name, then renamed to its label + deps: + - docs + + docs: + internal: true + label: Build the docs + cmds: + - echo "renamed once compilation resolved the label" + + progress-bar-example: + desc: A progress bar that redraws one line, next to ordinary line output + cmds: + - | + echo "starting download" + for i in $(seq 0 5 100); do + printf "\rDownloading... %3d%%" "$i" + sleep 0.15 + done + printf "\rDownloading... done \n" + echo "finished" + + prompt-vars-example: + desc: Required variables, asked for up front when run with --interactive + requires: + vars: + - RELEASE_NAME + - name: ENVIRONMENT + enum: [development, staging, production] + cmds: + - echo "releasing {{.RELEASE_NAME}} to {{.ENVIRONMENT}}" + + prompt-vars-midrun-example: + desc: A sub-task whose variables are only asked for once the run reaches it + cmds: + - echo "this runs first, in the dashboard" + - task: needs-a-variable + - echo "and this runs after the prompt" + + needs-a-variable: + internal: true + requires: + vars: [TICKET] + cmds: + - echo "working on {{.TICKET}}" + + confirm-example: + desc: A confirmation Task asks for before running the task + prompt: Really run the example? + cmds: + - echo "confirmed" diff --git a/website/src/next/docs/guide.md b/website/src/next/docs/guide.md index f9243013d7..950d410e0e 100644 --- a/website/src/next/docs/guide.md +++ b/website/src/next/docs/guide.md @@ -2710,6 +2710,14 @@ tasks: # ... ``` +::: tip + +The `output` option can also be specified by the `--output` or `-o` flags. + +::: + +### `group` output + The `group` output will print the entire output of a command once after it finishes, so you will not have live feedback for commands that take a long time to run. @@ -2768,7 +2776,9 @@ output-of-errors task: Failed to run task "errors": exit status 1 ``` -The `prefix` output will prefix every line printed by a command with +### `prefixed` output + +The `prefixed` output will prefix every line printed by a command with `[task-name] ` as the prefix, but you can customize the prefix for a command with the `prefix:` attribute: @@ -2801,11 +2811,158 @@ $ task default [print-baz] baz ``` -::: tip +## Interactive TUI -The `output` option can also be specified by the `--output` or `-o` flags. +Run `task --tui` (or `task -T`) to open an interactive, full-screen Terminal +User Interface (TUI). The launcher lists the available non-internal tasks and +their descriptions. Type to filter by task name or description and use the +up/down arrows to select a task. Press Enter to run it in the execution +dashboard, or press Ctrl+R to leave the TUI and run it with Task's normal +terminal output. Escape clears the current filter and Ctrl+C quits. -::: +You can skip the launcher by providing task names directly: + +```shell +$ task --tui build test lint +$ task --tui --parallel build test lint +``` + +After direct execution completes, press Escape or `b` to open the launcher. + +Each requested task is displayed as an independent root. As with regular Task +invocations, multiple requested tasks run sequentially by default; pass +`--parallel` to run them concurrently. + +During execution, the left pane shows a task navigator and the right pane shows +the output of the currently selected task. + +The requested root task appears at the top and can be selected to inspect output +from commands that it runs directly. When the root only orchestrates other +tasks, the first child is selected automatically. By default, tasks are nested +beneath the task that invoked them. Repeated executions have separate entries, +while calls that join an existing `run: once` or `run: when_changed` execution +remain visible at each location with a `↳` marker and share the owner's status +and output. Pass `--tui-task-navigator list` to show all tasks reached from each +root in a compact, single-level list instead. Press `n` during a run to switch +between the two. + +Each task shows a status icon, including distinct canceled and skipped states. +Canceled tasks were interrupted, while skipped tasks were never attempted after +an earlier sequential task failed. Pass `--tui-status labels` to replace the +icons with text labels. + +Both `--tui-status` and `--tui-task-navigator` can be set as defaults in +[`.taskrc.yml`](./reference/config.md#tui). + +Press `?` at any time to see every key available in the current view. + +Use Tab or the left/right arrow keys to switch between the task navigator and +the output pane. Clicking either pane also focuses it. + +When the navigator is focused, use the up/down arrows or `j`/`k` to select a +task. You can also click a task directly. When the output pane is focused, use +the following controls to scroll: + +- Up/down arrows or `j`/`k` +- Page Up and Page Down +- `g` and `G` to jump to the beginning or end +- Mouse wheel + +Output taller than the pane draws a scrollbar on the pane's right border, +showing both where you are and how much there is. The top right of the pane +shows how the selected task ended, along with the exit code when the task ran a +command that reported one. A task that failed only because one of its +dependencies did shows no code of its own. + +Each task shows how long it ran, counting up while it is running and keeping its +final duration afterwards. Quick tasks are reported in milliseconds. A task that +has not started has no duration, which is not the same as a duration of zero. On +a narrow terminal the durations are dropped so that task names keep their space. + +Press `f` to show the selected task's output fullscreen. Incoming output remains +visible; the view follows it while at the bottom and preserves the current +position after you scroll up. Press `f` again or Escape to return to the +two-pane view. + +Fullscreen is where lines are picked out of the output. A cursor marks one line, +and the controls above move it, scrolling as needed. Press `v` (or `V`, after +Vim's visual mode) to start selecting: the lines between where you pressed it and +where the cursor is now are selected, so moving up from that point selects +upwards. Press `v` again, or Escape, to cancel the selection; a second Escape +leaves fullscreen. + +The two states look different, so that being in one is never a guess. A resting +cursor is marked quietly; a live selection is drawn in the accent colour. + +With lines selected, `y` and `Y` copy those lines instead of the whole output. +They are copied as they were written, so a line too long for the screen arrives +whole rather than in the pieces it was folded into. Highlighted lines are drawn +without their own colours, because a highlight cannot survive the escape +sequences inside them; the copy still carries those sequences for `Y`. + +### Copying task output + +Selecting text with the mouse does not work inside the dashboard. A terminal +discards a selection whenever the screen is repainted, and scrolling either pane +is a repaint. These controls get the text out instead: + +- `y` copies the selected task's output to the system clipboard with its ANSI + escape sequences stripped, which is what a terminal gives you when you select + text by hand. +- `Y` copies it with those sequences intact, for pasting somewhere that renders + them, such as an editor with an ANSI extension. They carry bold, dim and + underline as well as colour. +- `s` saves the selected task's output, and `S` saves every task's output to a + folder, one file per task. Both ask where in the footer: `s` suggests a full + path and `S` only a folder, since the files inside are named for you. The + suggestions are `logs/..log` and `logs/./`, + beside the project and named for the task you ran, so a folder of logs groups + a task's runs together and `ls -t` still orders them by time. A `logs` + directory that Task creates ignores itself, so it does not appear in + `git status`; one that already exists is left alone. Any missing directories are created, and saved output keeps its + escape sequences, so `cat` and `less -R` show the colour. + +To take part of an output rather than all of it, select the lines you want in +the fullscreen view and press `y`. All of these work whether or not the task has +finished. + +Copying uses the OSC 52 escape sequence and, where one is available, a clipboard +helper such as `wl-copy`, `pbcopy`, `xclip`, `xsel` or `clip.exe`. OSC 52 works +over SSH but is not supported everywhere; terminals based on VTE, including +GNOME Terminal, ignore it, which is why the helper is tried as well. When +neither confirmed the copy, the message says so and points at `s`. + +```shell +$ task --tui --tui-task-navigator tree --tui-status labels build +``` + +Pressing `q` while tasks are running requests cancellation and closes the TUI +after Task's execution has returned. After execution finishes normally, the TUI +remains open so its output can be inspected; press Escape or `b` to open the +launcher, or press `q` to close it. Switching to the launcher while +execution is still in progress first cancels the tasks and waits for their +processes to exit. + +The TUI requires an interactive terminal. It is intended for local use; use one +of the stream-based output modes in CI or when redirecting output. + +When Task needs to ask you something, it asks in the interface. A task +declaring `prompt` shows its confirmation, and a missing required variable is +asked for: free text, or a list to choose from when the variable declares an +`enum`. `--interactive` is not needed, since the TUI can always ask; without it +a task requiring a variable could not be run from the launcher at all, as there +is nowhere to pass one. + +A question appears as a dialog over the dashboard, and names the task that is +asking. A confirmation lists its answers with the default marked, so pressing +Enter gives you what you can see rather than what a `[y/N]` would have implied; +`y` and `n` still answer directly. It can arrive partway through a run, because a task reached through +`cmds` is only compiled when the run gets to it. Nothing else can proceed until +you answer. + +Watch mode and tasks marked `interactive: true` are not supported. An +interactive task is not a question Task can relay: its command takes the +terminal and uses it however it likes. ## CI Integration diff --git a/website/src/next/docs/reference/cli.md b/website/src/next/docs/reference/cli.md index 0051797355..3d76f354e5 100644 --- a/website/src/next/docs/reference/cli.md +++ b/website/src/next/docs/reference/cli.md @@ -72,6 +72,20 @@ task --init task -i ``` +### `task --tui [tasks...]` + +Open the interactive task launcher. Type to filter, use the arrow keys to select +a task, then press Enter to run it in the execution dashboard or Ctrl+R to run +it normally. Escape clears the filter. From the dashboard, Escape or `b` returns +to or opens the launcher. When task names are supplied, skip the launcher and +open the execution dashboard directly. + +```bash +task --tui +task --tui build test +task -T --parallel build test +``` + ::: tip Combine `--list` or `--list-all` with `--silent` (`-ls` or `-as` for shortants) @@ -292,6 +306,44 @@ task build --color=false NO_COLOR=1 task build ``` +### TUI + +#### `-T, --tui` + +Open the interactive task launcher, or the execution dashboard when task names +are provided. + +The dashboard gives each task invocation its own output pane, so `--output` has +no effect on tasks run inside it. It still applies to tasks launched with +Ctrl+R, which run with Task's normal terminal output. + +The interface asks for missing required variables in its own dialog, so +[`--interactive`](#--interactive) is not needed and `--interactive=false` is +rejected rather than ignored. + +#### `--tui-status