diff --git a/Taskfile.tui-tests.yml b/Taskfile.tui-tests.yml new file mode 100644 index 0000000000..a827717df7 --- /dev/null +++ b/Taskfile.tui-tests.yml @@ -0,0 +1,142 @@ +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.%3N)" + sleep 2 + echo "shared-always END $(date +%H:%M:%S.%3N)" + + 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.%3N)" + sleep 2 + echo "shared-once END $(date +%H:%M:%S.%3N)" + + 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" 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..2d5298bb61 100644 --- a/executor.go +++ b/executor.go @@ -68,6 +68,7 @@ type ( Compiler *Compiler Output output.Output OutputStyle ast.Output + Listener Listener // Optional; observes execution and may own the terminal TaskSorter sort.Sorter UserWorkingDir string EnableVersionCheck bool @@ -81,6 +82,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..23ea03cdea 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", "", "Sets TUI task status style: [icons|labels].") + pflag.StringVar(&TUITaskNavigator, "tui-task-navigator", "", "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,14 @@ 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, TUIStatus, TUITaskNavigator); err != nil { + return err + } + if TUI && (List || ListAll || ListJson || Status || Summary || Watch || Interactive) { + return errors.New("task: --tui cannot be combined with task listing, status, summary, watch, or interactive modes") } if List && ListAll { @@ -249,6 +253,34 @@ 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 +} + +func validateTUIOptions(enabled bool, status, navigator string) error { + if enabled { + return nil + } + if status != "" { + return errors.New("task: You can't set --tui-status without --tui") + } + if navigator != "" { + 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..cda9cf64ed --- /dev/null +++ b/internal/flags/flags_test.go @@ -0,0 +1,75 @@ +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 + status string + navigator string + wantError string + }{ + {name: "TUI without options", enabled: true}, + {name: "TUI with options", enabled: true, status: "labels", navigator: "tree"}, + {name: "status without TUI", status: "labels", wantError: "--tui-status without --tui"}, + {name: "navigator without TUI", navigator: "tree", wantError: "--tui-task-navigator without --tui"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := validateTUIOptions(test.enabled, test.status, test.navigator) + 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..77db8b67d2 --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,203 @@ +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 + Snapshot 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")), + Snapshot: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "print output to terminal")), + 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.Snapshot, "t", "to terminal"), + terse(k.Move, "↑/↓", move), + terse(k.Pane, "←/→", "pane"), + } +} + +func (k dashboardKeys) FullHelp() [][]key.Binding { + return fullHelpColumns(k.allBindings(), 3) +} + +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.Snapshot, k.Launcher, k.Quit, k.Help, + } +} + +// fullscreenKeys are the bindings of the single-pane output view. +type fullscreenKeys struct { + Move key.Binding + Page key.Binding + Top key.Binding + Bottom key.Binding + Copy key.Binding + CopyRaw key.Binding + Snapshot key.Binding + Return key.Binding + Quit key.Binding + Help key.Binding +} + +func newFullscreenKeys() fullscreenKeys { + return fullscreenKeys{ + Move: key.NewBinding(key.WithKeys("up", "down", "k", "j"), key.WithHelp("↑/↓", "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")), + 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")), + Snapshot: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "print output to terminal")), + 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 { + return []key.Binding{ + terse(k.Help, "?", "help"), + terse(k.Quit, "q", "quit"), + terse(k.Return, "f/esc", "back"), + terse(k.Copy, "y", "copy"), + terse(k.Snapshot, "t", "to terminal"), + terse(k.Move, "↑/↓", "scroll"), + } +} + +func (k fullscreenKeys) FullHelp() [][]key.Binding { + return fullHelpColumns(k.allBindings(), 3) +} + +func (k fullscreenKeys) allBindings() []key.Binding { + return []key.Binding{ + k.Move, k.Page, k.Top, k.Bottom, + k.Copy, k.CopyRaw, k.Snapshot, + 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..f931cb49e9 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,451 @@ +package tui + +import ( + "context" + "strings" + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" +) + +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 + + 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 } + taskFinishedMsg struct { + id uint64 + result taskResult + err error + } +) + +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 + showHelp bool + help help.Model + 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 + task.startedAt = time.Now() + 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 = time.Now() + switch msg.result { + case resultSkipped: + task.state = taskSkipped + case resultCanceled: + task.state = taskCanceled + case resultFailed: + task.state = taskFailed + 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 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 t" + } + 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 + 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 +} + +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.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() + switch { + 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.Copy): + return *m, m.copyOutput(false) + case key.Matches(msg, keys.CopyRaw): + return *m, m.copyOutput(true) + case key.Matches(msg, keys.Snapshot): + return *m, m.snapshotSelectedOutput() + case key.Matches(msg, keys.Move): + if msg.String() == "up" || msg.String() == "k" { + m.fullscreenViewport.ScrollUp(1) + } else { + m.fullscreenViewport.ScrollDown(1) + } + case key.Matches(msg, keys.Page): + if msg.String() == "pgup" { + m.fullscreenViewport.PageUp() + } else { + m.fullscreenViewport.PageDown() + } + case key.Matches(msg, keys.Top): + m.fullscreenViewport.GotoTop() + case key.Matches(msg, keys.Bottom): + m.fullscreenViewport.GotoBottom() + } + return *m, nil +} + +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.Snapshot): + return *m, m.snapshotSelectedOutput() + 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() + } + case msg.String() == "enter": + if m.done { + return *m, tea.Quit + } + } + 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) +} + +func returnToLauncher() tea.Msg { + return returnToLauncherMsg{} +} diff --git a/internal/tui/snapshot.go b/internal/tui/snapshot.go new file mode 100644 index 0000000000..5ed87e7bc1 --- /dev/null +++ b/internal/tui/snapshot.go @@ -0,0 +1,123 @@ +package tui + +import ( + "bufio" + "fmt" + "io" + "strings" + + tea "charm.land/bubbletea/v2" +) + +// snapshotOutput prints a task's output to the terminal and waits, so that the +// terminal's own scrollback and text selection apply to it. +// +// Selecting text inside the dashboard does not work: the terminal drops a +// selection whenever the screen is repainted, and scrolling a viewport is a +// repaint. Handing the text to the terminal sidesteps that entirely, at the +// cost of the view being a snapshot rather than a live one. +type snapshotOutput struct { + name string + text string + running bool + width int + height int + + stdin io.Reader + stdout io.Writer +} + +func (s *snapshotOutput) SetStdin(r io.Reader) { s.stdin = r } +func (s *snapshotOutput) SetStdout(w io.Writer) { s.stdout = w } +func (*snapshotOutput) SetStderr(io.Writer) {} + +func (s *snapshotOutput) Run() error { + if _, err := io.WriteString(s.stdout, s.blankScreen()+s.body()); err != nil { + return err + } + if s.stdin == nil { + return nil + } + // The terminal is back in its normal line-buffered mode here, so a plain + // read blocks until the user presses Enter. + _, err := bufio.NewReader(s.stdin).ReadString('\n') + if err != nil && err != io.EOF { + return err + } + return nil +} + +// blankScreen scrolls whatever the terminal was showing up into its scrollback +// and puts the cursor back at the top, so the snapshot starts on a clean screen +// rather than underneath the shell session. +// +// Erasing the screen instead would also blank it, but terminals disagree on +// whether the erased lines are kept in scrollback or discarded, and discarding +// them would throw away what the user had on screen before Task ran. Scrolling +// destroys nothing on any terminal. +func (s *snapshotOutput) blankScreen() string { + if s.height <= 0 { + return "" + } + const cursorHome = "\x1b[H" + return strings.Repeat("\n", s.height) + cursorHome +} + +func (s *snapshotOutput) body() string { + var b strings.Builder + b.WriteString(s.rule(fmt.Sprintf("snapshot: %s", s.name))) + b.WriteString("\n") + + if s.text == "" { + b.WriteString("(no output)\n") + } else { + b.WriteString(s.text) + if !strings.HasSuffix(s.text, "\n") { + b.WriteString("\n") + } + } + + b.WriteString(s.rule("end of snapshot")) + b.WriteString("\n") + if s.running { + // Reuse the colour the navigator gives a running task, which doubles as + // the usual warning colour: the output above is incomplete. + b.WriteString(tuiRunningStyle.Render( + "This task was still running. The output above is what it had produced so far.", + )) + b.WriteString("\n") + } + b.WriteString("Press Enter to return.\n") + return b.String() +} + +// rule draws a labelled horizontal separator, so the snapshot is visibly +// bounded rather than blending into the surrounding terminal output. +func (s *snapshotOutput) rule(label string) string { + line := "── " + label + " " + if width := s.width - len([]rune(line)); width > 0 { + return line + strings.Repeat("─", width) + } + return line +} + +// snapshotSelectedOutput hands the selected task's output to the terminal. +func (m *tuiModel) snapshotSelectedOutput() tea.Cmd { + task := m.selectedTask() + if task == nil { + return nil + } + snapshot := &snapshotOutput{ + name: m.taskName(task), + text: task.output, + running: m.taskState(task) == taskRunning, + width: m.width, + height: m.height, + } + return tea.Exec(snapshot, func(err error) tea.Msg { + if err != nil { + return noticeRequestedMsg{text: fmt.Sprintf("snapshot failed: %v", err)} + } + return nil + }) +} diff --git a/internal/tui/tasks.go b/internal/tui/tasks.go new file mode 100644 index 0000000000..6f490a258a --- /dev/null +++ b/internal/tui/tasks.go @@ -0,0 +1,518 @@ +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, so the snapshot view already gives plain text. 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 { + if task.ownerID != 0 { + if owner := m.byID[task.ownerID]; owner != nil { + return owner.state + } + } + return task.state +} + +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..191f9f2a00 --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,302 @@ +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.TaskResult + +const ( + resultSucceeded = task.TaskSucceeded + resultFailed = task.TaskFailed + resultCanceled = task.TaskCanceled + resultSkipped = task.TaskSkipped +) + +// 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 + + 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), + }, nil +} + +// WriterFor routes a task's command output into that task's own pane. +func (t *UI) WriterFor(invocation task.Invocation) (io.Writer, io.Writer) { + w := &tuiWriter{ui: t, id: invocation.ID, name: invocation.Name} + return w, w +} + +func (t *UI) TaskScheduled(invocation task.Invocation) { + t.send(taskScheduledMsg{task: invocation}) +} + +func (t *UI) TaskStarted(invocation task.Invocation) { + t.send(taskStartedMsg{task: invocation}) +} + +func (t *UI) TaskFinished(id uint64, result taskResult, err error) { + t.send(taskFinishedMsg{id: id, result: result, err: err}) +} + +func (t *UI) TaskJoined(id, ownerID uint64) { + t.send(taskJoinedMsg{id: id, ownerID: ownerID}) +} + +func (*UI) OwnsTerminal() bool { return true } + +// 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 + + 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 + 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() + 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 +} + +func (t *UI) send(msg tea.Msg) { + t.mutex.RLock() + program := t.program + t.mutex.RUnlock() + if program != nil { + program.Send(msg) + } +} + +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..bcabc68ee3 --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,1248 @@ +package tui + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "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" + + "github.com/go-task/task/v3" + "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, m.View().Content, "scroll") + + 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 { + return taskStartedMsg{task: taskInvocation{ID: id, ParentID: parentID, RootID: rootID, Name: name}} +} + +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, 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 TestSnapshotOutputBody(t *testing.T) { + t.Parallel() + + finished := (&snapshotOutput{name: "build", text: "compiling\n", width: 40}).body() + assert.Contains(t, finished, "compiling\n") + // The dump carries no other context, so it always says what it is. + assert.Contains(t, finished, "snapshot: build") + assert.Contains(t, finished, "end of snapshot") + assert.Contains(t, finished, "Press Enter to return") + assert.NotContains(t, finished, "still running") + + running := (&snapshotOutput{name: "build", text: "compiling", running: true, width: 40}).body() + assert.Contains(t, ansi.Strip(running), "still running") + // Output that did not end in a newline must not run into the footer. + assert.Contains(t, running, "compiling\n") + // The warning is coloured; the rest of the footer is not. + assert.NotEqual(t, ansi.Strip(running), running, "the warning must stand out") + assert.NotContains(t, finished, "\x1b", "a finished snapshot needs no colour") + + empty := (&snapshotOutput{name: "build", width: 40}).body() + assert.Contains(t, empty, "(no output)") +} + +func TestSnapshotOutputStartsOnABlankScreen(t *testing.T) { + t.Parallel() + + snapshot := &snapshotOutput{name: "build", text: "hi\n", width: 40, height: 24} + blank := snapshot.blankScreen() + + // Scrolling the old screen away keeps it in scrollback; erasing it might + // not, depending on the terminal. + assert.Equal(t, 24, strings.Count(blank, "\n")) + assert.True(t, strings.HasSuffix(blank, "\x1b[H"), "cursor must return to the top") + assert.NotContains(t, blank, "2J", "the screen must not be erased") + + // A zero height means we do not know the terminal size; print nothing. + assert.Empty(t, (&snapshotOutput{}).blankScreen()) +} + +func TestSnapshotOutputWaitsForEnter(t *testing.T) { + t.Parallel() + + var screen bytes.Buffer + snapshot := &snapshotOutput{name: "build", text: "hello\n", width: 40} + snapshot.SetStdout(&screen) + snapshot.SetStdin(strings.NewReader("\n")) + + require.NoError(t, snapshot.Run()) + assert.Contains(t, screen.String(), "hello") + assert.Contains(t, screen.String(), "Press Enter to return") +} + +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 t") +} + +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", "t to terminal"} { + assert.Contains(t, dashboard, expected, "footer at 80 columns: %s", dashboard) + } + + full := ansi.Strip(shortHelp(m.help, newFullscreenKeys().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) + } + } +} + +func TestPrintToTerminalIsBoundToT(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.NotNil(t, cmd, "t prints the output to the terminal") + + _, cmd = m.Update(tea.KeyPressMsg{Code: 's', Text: "s"}) + assert.Nil(t, cmd, "s no longer does anything") +} + +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. + 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") +} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000000..515b6c2a63 --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,583 @@ +package tui + +import ( + "fmt" + "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() + 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 { + 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.notice != "": + footer = renderStatus(layout.width, m.notice, tuiTitleStyle) + } + + 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)), + ) + view.SoftWrap = true + m.fullscreenViewport = view + m.fullscreenViewport.SetContent(m.fullscreenOutputContent()) + 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()))) + } +} + +func (m *tuiModel) leaveFullscreenOutput() { + atTop := m.fullscreenViewport.AtTop() + atBottom := m.fullscreenViewport.AtBottom() + position := m.fullscreenViewport.ScrollPercent() + m.fullscreenOutput = false + m.fullscreenViewport = viewport.Model{} + 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() { + atBottom := m.fullscreenViewport.AtBottom() + offset := m.fullscreenViewport.YOffset() + m.fullscreenViewport.SetContent(m.fullscreenOutputContent()) + if atBottom { + m.fullscreenViewport.GotoBottom() + } else { + m.fullscreenViewport.SetYOffset(offset) + } +} + +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().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().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, right +} + +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) + } + position := "" + if !m.viewport.AtTop() || !m.viewport.AtBottom() { + position = fmt.Sprintf("%3.0f%%", m.viewport.ScrollPercent()*100) + } + return paneTitle(title, tuiHelpStyle.Render(position), 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 +} + +// 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")}) + 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..3c6bbae31d --- /dev/null +++ b/listener.go @@ -0,0 +1,122 @@ +package task + +import "io" + +// 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 // ID of the task call that scheduled this call; zero for roots + RootID uint64 // ID of the root call requested by the user + Name string +} + +// TaskResult is how a call ended. +type TaskResult uint8 + +const ( + // TaskSucceeded is the zero value so that the outcome of a call that + // reported no error needs no further interpretation. + TaskSucceeded TaskResult = iota + TaskFailed + // TaskCanceled is a call interrupted before it could finish, by a failing + // sibling under fail-fast or by the caller cancelling the context. + TaskCanceled + // TaskSkipped 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. + TaskSkipped +) + +func (r TaskResult) String() string { + switch r { + case TaskSucceeded: + return "succeeded" + case TaskFailed: + return "failed" + case TaskCanceled: + return "canceled" + case TaskSkipped: + return "skipped" + default: + return "unknown" + } +} + +// Listener observes task execution and may take over the terminal while it +// runs. It is optional: an Executor without one behaves exactly as before. +// +// Lifecycle methods are called from the goroutines that run the tasks, so +// implementations must be safe for concurrent use. +type Listener interface { + // TaskScheduled reports a call that Task intends to run. A call that is + // attempted is always reported to TaskFinished, but a scheduled call may + // never be attempted -- when an earlier requested root fails, say -- and + // then no further event arrives for it. + TaskScheduled(Invocation) + // TaskStarted reports that a call began executing its deps and commands. + TaskStarted(Invocation) + // TaskFinished reports how a call ended. err carries the detail behind a + // TaskFailed or TaskCanceled result and is nil otherwise; classify on the + // result rather than on whether err is nil, because what a killed process + // reports differs between platforms. + TaskFinished(id uint64, result TaskResult, err error) + // TaskJoined reports a call that waits on the execution owned by ownerID + // instead of running its own. It produces no output of its own. + TaskJoined(id, ownerID uint64) + + // WriterFor returns the destination streams for a call's command output. + // Returning a nil writer leaves that stream on the Executor's own, which is + // what a listener that only wants lifecycle events should do. + WriterFor(Invocation) (stdOut, stdErr io.Writer) + + // OwnsTerminal reports that the listener is drawing to the terminal, so Task + // must not write to its own streams or run anything interactive. + OwnsTerminal() bool +} + +func (e *Executor) notifyScheduled(invocation Invocation) { + if e.Listener != nil { + e.Listener.TaskScheduled(invocation) + } +} + +func (e *Executor) notifyStarted(invocation Invocation) { + if e.Listener != nil { + e.Listener.TaskStarted(invocation) + } +} + +func (e *Executor) notifyFinished(id uint64, result TaskResult, err error) { + if e.Listener != nil { + e.Listener.TaskFinished(id, result, err) + } +} + +func (e *Executor) notifyJoined(id, ownerID uint64) { + if e.Listener != nil { + e.Listener.TaskJoined(id, ownerID) + } +} + +// ownsTerminal reports whether a listener is drawing to the terminal. +func (e *Executor) ownsTerminal() bool { + return e.Listener != nil && e.Listener.OwnsTerminal() +} + +// 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 { + return stdOut, stdErr + } + listenerOut, listenerErr := e.Listener.WriterFor(invocation) + if listenerOut != nil { + stdOut = listenerOut + } + if listenerErr != nil { + stdErr = listenerErr + } + return stdOut, stdErr +} 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..60e0ac3a04 100644 --- a/task.go +++ b/task.go @@ -39,6 +39,10 @@ type MatchingTask struct { // Run runs Task func (e *Executor) Run(ctx context.Context, calls ...*Call) error { + if e.ownsTerminal() && e.Interactive { + return errors.New("task: interactive variable prompting is not supported while a terminal UI is active") + } + // check if given tasks exist for _, call := range calls { task, err := e.GetTask(call) @@ -82,6 +86,18 @@ func (e *Executor) Run(ctx context.Context, calls ...*Call) error { if err != nil { return err } + if e.ownsTerminal() && len(watchCalls) > 0 { + return errors.New("task: watch mode is not supported while a terminal UI is active") + } + // 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 +140,17 @@ 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. + e.taskInvocation(call, call.Task) + skipped := false + defer func() { + e.notifyFinished(call.invocationID, taskResult(ctx, skipped, runErr), runErr) + }() + // Inject prompted vars into call if available if e.promptedVars != nil { if call.Vars == nil { @@ -144,6 +170,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 +186,18 @@ 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.ownsTerminal() { + if t.Interactive { + return fmt.Errorf("task: task %q is interactive and cannot run while a terminal UI is active", t.Name()) + } + if len(t.Prompt) > 0 && !e.AssumeYes { + return fmt.Errorf("task: task %q requires confirmation; run with --yes", t.Name()) + } + } // Check if condition after CompiledTask so dynamic variables are resolved if strings.TrimSpace(t.If) != "" { @@ -168,6 +207,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 +243,11 @@ 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, call.invocationID, func(ctx context.Context) (runErr error) { + e.notifyStarted(invocation) + 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 } @@ -284,13 +326,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) TaskResult { + switch { + case skipped: + return TaskSkipped + case err == nil: + return TaskSucceeded + case ctx.Err() != nil: + return TaskCanceled + default: + return TaskFailed + } +} + +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 := Invocation{ + ID: call.invocationID, + ParentID: call.parentInvocationID, + RootID: call.rootInvocationID, + Name: name, + } + e.notifyScheduled(invocation) + return invocation + } + return Invocation{ + ID: call.invocationID, + ParentID: call.parentInvocationID, + RootID: call.rootInvocationID, + Name: name, + } +} + func (e *Executor) mkdir(t *ast.Task) error { if t.Dir == "" { return nil @@ -308,7 +394,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 +414,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 +488,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 +510,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.ownsTerminal() || e.Dry) { e.Logger.Errf(logger.Green, "task: [%s] %s\n", t.Name(), cmd.LogCmd) } @@ -422,12 +523,26 @@ func (e *Executor) runCommand(ctx context.Context, t *ast.Task, call *Call, i in if t.Interactive { outputWrapper = output.Interleaved{} } + stdOutBase, stdErrBase := e.listenerWriters(Invocation{ + ID: call.invocationID, + ParentID: call.parentInvocationID, + RootID: call.rootInvocationID, + Name: t.Name(), + }) + if e.ownsTerminal() { + // 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.ownsTerminal() { + 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 +589,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, invocationID uint64, execute func(ctx context.Context) error) error { h, err := e.GetHash(t) if err != nil { return err @@ -493,6 +609,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(invocationID, other.ownerID) // Release our execution slot to avoid blocking other tasks while we wait reacquire := e.releaseConcurrencyLimit() @@ -518,7 +635,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: invocationID} 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..46d485edf4 --- /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 TaskResult + }{ + {"no error", live, false, nil, TaskSucceeded}, + {"failed", live, false, failure, TaskFailed}, + {"skipped", live, true, nil, TaskSkipped}, + // 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), TaskCanceled}, + {"killed, reported as an exit status", canceled, false, failure, TaskCanceled}, + // Succeeding in a context that is already done is still success. + {"finished before cancellation landed", canceled, false, nil, TaskSucceeded}, + // Skipping wins: Task chose not to run it, so there is nothing to cancel. + {"skipped in a cancelled context", canceled, true, nil, TaskSkipped}, + } + 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 TestTaskResultString(t *testing.T) { + t.Parallel() + + assert.Equal(t, "succeeded", TaskSucceeded.String()) + assert.Equal(t, "failed", TaskFailed.String()) + assert.Equal(t, "canceled", TaskCanceled.String()) + assert.Equal(t, "skipped", TaskSkipped.String()) +} diff --git a/task_lifecycle_test.go b/task_lifecycle_test.go new file mode 100644 index 0000000000..b1d4308d4b --- /dev/null +++ b/task_lifecycle_test.go @@ -0,0 +1,427 @@ +package task_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + + "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 + + 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 + + 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.TaskCanceled, 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 + + 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 + + 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.TaskFailed, 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.TaskResult + + scheduledAtFirstStart int +} + +func (*lifecycleRecorder) OwnsTerminal() bool { return false } + +func (r *lifecycleRecorder) WriterFor(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 (r *lifecycleRecorder) TaskStarted(invocation task.Invocation) { + r.mutex.Lock() + defer r.mutex.Unlock() + if len(r.started) == 0 { + r.scheduledAtFirstStart = len(r.scheduled) + } + r.started = append(r.started, invocation) +} + +func (r *lifecycleRecorder) TaskScheduled(invocation task.Invocation) { + r.mutex.Lock() + defer r.mutex.Unlock() + r.scheduled = append(r.scheduled, invocation) +} + +func (r *lifecycleRecorder) TaskFinished(id uint64, result task.TaskResult, err error) { + r.mutex.Lock() + defer r.mutex.Unlock() + r.finished = append(r.finished, id) + if r.finishErrors == nil { + r.finishErrors = make(map[uint64]error) + r.finishResults = make(map[uint64]task.TaskResult) + } + r.finishErrors[id] = err + r.finishResults[id] = result +} + +func (r *lifecycleRecorder) TaskJoined(id, ownerID uint64) { + r.mutex.Lock() + defer r.mutex.Unlock() + if r.joined == nil { + r.joined = make(map[uint64]uint64) + } + r.joined[id] = ownerID +} + +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 + + 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 + + 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.TaskFailed, 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 + + 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.TaskSkipped, 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.TaskSucceeded, recorder.finishResults[byName["always"].ID]) +} diff --git a/website/src/next/docs/guide.md b/website/src/next/docs/guide.md index f9243013d7..b145a06ae9 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,109 @@ $ 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. + +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. + +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 + +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. The keyboard scrolling controls above remain +available. Press `f` again or Escape to return to the two-pane view. + +### 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. Three 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. +- `t` prints the output to the terminal and waits for Enter. The text lands in + your terminal's normal scrollback, where its own scrolling and selection apply + as they would to any other command output. + +All three work whether or not the task has finished. A snapshot of a running +task says so, and shows the output as it stood at that moment. + +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. When no helper confirmed the copy, the message says +so and points at `t`. + +```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 Enter or `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. Watch mode, +interactive commands, and interactive variable prompting are not currently +supported. Task confirmation prompts can be accepted up front with `--yes`. ## CI Integration diff --git a/website/src/next/docs/reference/cli.md b/website/src/next/docs/reference/cli.md index 0051797355..549023c110 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,35 @@ 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. + +#### `--tui-status