diff --git a/internal/integration/module_test.go b/internal/integration/module_test.go index 07c20d0..961dfa4 100644 --- a/internal/integration/module_test.go +++ b/internal/integration/module_test.go @@ -147,6 +147,24 @@ func TestExplorer_SingleInitUpgrade(t *testing.T) { }) } +func TestExplorer_SingleFormat(t *testing.T) { + t.Parallel() + + tm := setup(t, "./testdata/single_module") + + // Expect one module in tree + waitFor(t, tm, func(s string) bool { + return strings.Contains(s, "└ 󰠱 a") + }) + + // Format module + tm.Type("f") + // Expect short message in footer. + waitFor(t, tm, func(s string) bool { + return strings.Contains(s, "fmt: finished successfully…(Press 'o' for full output)") + }) +} + func TestExplorer_MultipleFormat(t *testing.T) { t.Parallel() @@ -170,6 +188,24 @@ func TestExplorer_MultipleFormat(t *testing.T) { }) } +func TestExplorer_SingleValidate(t *testing.T) { + t.Parallel() + + tm := setupAndInitModule_Explorer(t) + + // Expect one module in tree + waitFor(t, tm, func(s string) bool { + return strings.Contains(s, "└ 󰠱 a") + }) + + // Format module + tm.Type("v") + // Expect short message in footer. + waitFor(t, tm, func(s string) bool { + return strings.Contains(s, "validate: finished successfully…(Press 'o' for full output)") + }) +} + func TestExplorer_MultipleValidate(t *testing.T) { t.Parallel() diff --git a/internal/integration/state_test.go b/internal/integration/state_test.go index 34037d3..135df30 100644 --- a/internal/integration/state_test.go +++ b/internal/integration/state_test.go @@ -19,25 +19,19 @@ func TestState_SingleTaint_Untaint(t *testing.T) { // Taint first resource, which should be random_pet.pet[0] tm.Send(tea.KeyMsg{Type: tea.KeyCtrlT}) - // Expect to be taken to task page for taint + // Expect short message in footer. waitFor(t, tm, func(s string) bool { - return strings.Contains(s, "Resource instance random_pet.pet[0] has been marked as tainted.") - }) - - // Go back to state page - tm.Type("s") - - // Expect resource to be marked as tainted - waitFor(t, tm, func(s string) bool { - return strings.Contains(s, "random_pet.pet[0] (tainted)") + return strings.Contains(s, "taint: finished successfully…(Press 'o' for full output)") && + strings.Contains(s, "random_pet.pet[0] (tainted)") }) // Untaint resource tm.Type("U") - // Expect to be taken to task page for untaint + // Expect short message in footer. waitFor(t, tm, func(s string) bool { - return strings.Contains(s, "Resource instance random_pet.pet[0] has been successfully untainted.") + return strings.Contains(s, "untaint: finished successfully…(Press 'o' for full output)") && + strings.Contains(s, "random_pet.pet[0]") }) } @@ -114,11 +108,10 @@ func TestState_Move(t *testing.T) { tm.Type("giraffe[99]") tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) - // Expect to be taken to task page for move + // Expect short message in footer. waitFor(t, tm, func(s string) bool { - return strings.Contains(s, "state mv 󰠱 modules/a  default") && - strings.Contains(s, `Move "random_pet.pet[0]" to "random_pet.giraffe[99]"`) && - strings.Contains(s, `Successfully moved 1 object(s).`) + return strings.Contains(s, "random_pet.giraffe[99]") && + strings.Contains(s, "state mv: finished successfully…(Press 'o' for full output)") }) } @@ -136,20 +129,11 @@ func TestState_SingleDelete(t *testing.T) { }) tm.Type("y") - // User is taken to its task page, which should provide the output from the - // command. + // Expect short message in footer. waitFor(t, tm, func(s string) bool { - return strings.Contains(s, "state rm 󰠱 modules/a  default") && - strings.Contains(s, "Removed random_pet.pet[0]") && - strings.Contains(s, "Successfully removed 1 resource instance(s).") - }) - - // Go back to state page - tm.Type("s") - - // Expect only 9 resources. - waitFor(t, tm, func(s string) bool { - return strings.Contains(s, "1-9 of 9") + return strings.Contains(s, "state rm: finished successfully…(Press 'o' for full output)") && + // Expect only 9 resources now. + strings.Contains(s, "1-9 of 9") }) } @@ -171,25 +155,10 @@ func TestState_MultipleDelete(t *testing.T) { // User is taken to its task page, which should provide the output from the // command. waitFor(t, tm, func(s string) bool { - return strings.Contains(s, "state rm 󰠱 modules/a  default") && - strings.Contains(s, "Removed random_pet.pet[0]") && - strings.Contains(s, "Removed random_pet.pet[1]") && - strings.Contains(s, "Removed random_pet.pet[2]") && - strings.Contains(s, "Removed random_pet.pet[3]") && - strings.Contains(s, "Removed random_pet.pet[4]") && - strings.Contains(s, "Removed random_pet.pet[5]") && - strings.Contains(s, "Removed random_pet.pet[6]") && - strings.Contains(s, "Removed random_pet.pet[7]") && - strings.Contains(s, "Removed random_pet.pet[8]") && - strings.Contains(s, "Removed random_pet.pet[9]") && - strings.Contains(s, "Successfully removed 10 resource instance(s).") + return strings.Contains(s, "state rm: finished successfully…(Press 'o' for full output)") && + // Expect only 0 resources now. + strings.Contains(s, "1-0 of 0") }) - - // Go back to state page - tm.Send(tea.KeyMsg{Type: tea.KeyEsc}) - - // TODO: test that there are zero resources in state. There is currently - // scant information to test for. } func TestState_TargetedPlan_SingleResource(t *testing.T) { diff --git a/internal/module/service.go b/internal/module/service.go index 0c9a2ff..01d4090 100644 --- a/internal/module/service.go +++ b/internal/module/service.go @@ -241,6 +241,8 @@ func (s *Service) Format(moduleID resource.ID) (task.Spec, error) { Execution: task.Execution{ TerraformCommand: []string{"fmt"}, }, + Immediate: true, + Short: true, } return spec, nil } @@ -256,6 +258,8 @@ func (s *Service) Validate(moduleID resource.ID) (task.Spec, error) { Execution: task.Execution{ TerraformCommand: []string{"validate"}, }, + Immediate: true, + Short: true, } return spec, nil } diff --git a/internal/state/service.go b/internal/state/service.go index fad2c54..27b5457 100644 --- a/internal/state/service.go +++ b/internal/state/service.go @@ -79,6 +79,7 @@ func (s *Service) Delete(workspaceID resource.ID, addrs ...ResourceAddress) (tas AfterExited: func(t *task.Task) { s.CreateReloadTask(workspaceID) }, + Short: true, }) } @@ -95,6 +96,7 @@ func (s *Service) Taint(workspaceID resource.ID, addr ResourceAddress) (task.Spe AfterExited: func(t *task.Task) { s.CreateReloadTask(workspaceID) }, + Short: true, }) } @@ -111,6 +113,7 @@ func (s *Service) Untaint(workspaceID resource.ID, addr ResourceAddress) (task.S AfterExited: func(t *task.Task) { s.CreateReloadTask(workspaceID) }, + Short: true, }) } @@ -127,6 +130,7 @@ func (s *Service) Move(workspaceID resource.ID, src, dest ResourceAddress) (task AfterExited: func(t *task.Task) { s.CreateReloadTask(workspaceID) }, + Short: true, }) } diff --git a/internal/task/spec.go b/internal/task/spec.go index eca9ba1..8e5bd69 100644 --- a/internal/task/spec.go +++ b/internal/task/spec.go @@ -10,6 +10,9 @@ type Spec struct { // WorkspaceID is the ID of the workspace the task belongs to. If nil, the // task does not belong to a workspace. WorkspaceID *resource.ID + // TaskGroupID specifies the ID of the task group this task is to belong to. + // Nil means the task does not belong to a group. + TaskGroupID *resource.ID // Execution specifies the execution of a program. Execution Execution // AdditionalExecution specifies the execution of another program. The @@ -30,6 +33,9 @@ type Spec struct { JSON bool // Skip queue and immediately start task Immediate bool + // Short if true indicates that the task runtime is short and the output is + // minimal. + Short bool // Wait blocks until the task has finished Wait bool // Description assigns an optional description to the task to display to the diff --git a/internal/task/task.go b/internal/task/task.go index c9fa26a..82b9e66 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -12,6 +12,7 @@ import ( "strings" "sync" "time" + "unicode" "github.com/leg100/pug/internal" "github.com/leg100/pug/internal/resource" @@ -26,6 +27,7 @@ type Task struct { ModuleID *resource.ID WorkspaceID *resource.ID + TaskGroupID *resource.ID Identifier Identifier Program string Args []string @@ -35,6 +37,7 @@ type Task struct { State Status JSON bool Immediate bool + Short bool AdditionalEnv []string DependsOn []resource.ID // Summary summarises the outcome of a task to the end-user. @@ -109,10 +112,14 @@ func (f *factory) newTask(spec Spec) (*Task, error) { if spec.WorkspaceID != nil && spec.ModuleID == nil { return nil, errors.New("workspace ID cannot be provided without module ID") } + if spec.Blocking && spec.Immediate { + return nil, errors.New("a task cannot both be blocking and immediately") + } task := &Task{ ID: resource.NewID(resource.Task), ModuleID: spec.ModuleID, WorkspaceID: spec.WorkspaceID, + TaskGroupID: spec.TaskGroupID, Identifier: spec.Identifier, State: Pending, Created: time.Now(), @@ -128,6 +135,7 @@ func (f *factory) newTask(spec Spec) (*Task, error) { Blocking: spec.Blocking, DependsOn: spec.dependsOn, Immediate: spec.Immediate, + Short: spec.Short, exclusive: spec.Exclusive, Description: spec.Description, Spec: spec, @@ -414,3 +422,20 @@ func (t *Task) updateState(state Status) { } } } + +func StripError(s string) string { + // Strip ANSI escape codes + s = internal.StripAnsi(s) + // Strip non-ascii chars + s = strings.Map(func(r rune) rune { + if r > unicode.MaxASCII { + return -1 + } + return r + }, s) + // Strip leading and trailing whitespace + s = strings.TrimSpace(s) + // Compact whitespace + s = strings.Join(strings.Fields(s), " ") + return s +} diff --git a/internal/task/task_test.go b/internal/task/task_test.go index fc686ae..1bd6b2a 100644 --- a/internal/task/task_test.go +++ b/internal/task/task_test.go @@ -3,6 +3,8 @@ package task import ( "context" "io" + "os" + "strings" "testing" "github.com/leg100/pug/internal" @@ -73,36 +75,10 @@ func TestTask_cancel(t *testing.T) { assert.Equal(t, Exited, task.State) } -// func TestTask_WaitFor_immediateExit(t *testing.T) { -// f := factory{program: "../testdata/task"} -// task, err := f.newTask(".") -// require.NoError(t, err) -// task.run()() -// -// require.True(t, task.WaitFor(Exited)) -// } -// -// func TestTask_WaitFor(t *testing.T) { -// f := factory{program: "../testdata/killme"} -// task, err := f.newTask(".") -// require.NoError(t, err) -// -// // wait for task to exit in background -// got := make(chan bool) -// go func() { -// got <- task.WaitFor(Exited) -// }() -// -// // start task in background -// go func() { -// task.run()() -// }() -// -// // wait for task to start -// assert.Equal(t, "ok, you can kill me now\n", <-iochan.DelimReader(task.NewReader(), '\n')) -// // then cancel -// task.cancel() -// -// // verify task exits -// require.True(t, <-got) -// } +func TestStripError(t *testing.T) { + b, err := os.ReadFile("./testdata/validate.out") + require.NoError(t, err) + got := StripError(string(b)) + want := "Error: Could not load plugin Plugin reinitialization required. Please run \"terraform init\"." + assert.True(t, strings.HasPrefix(got, want), got) +} diff --git a/internal/tui/color.go b/internal/tui/color.go index 3aa65bc..3bd83d3 100644 --- a/internal/tui/color.go +++ b/internal/tui/color.go @@ -11,8 +11,9 @@ const ( BurntOrange = lipgloss.Color("214") Yellow = lipgloss.Color("#DBBD70") Green = lipgloss.Color("34") - LightGreen = lipgloss.Color("86") + Turquoise = lipgloss.Color("86") DarkGreen = lipgloss.Color("#325451") + LightGreen = lipgloss.Color("47") GreenBlue = lipgloss.Color("#00A095") DeepBlue = lipgloss.Color("39") LightBlue = lipgloss.Color("81") @@ -26,11 +27,12 @@ const ( White = lipgloss.Color("#ffffff") OffWhite = lipgloss.Color("#a8a7a5") Pink = lipgloss.Color("30") + HotPink = lipgloss.Color("200") ) var ( DebugLogLevel = Blue - InfoLogLevel = lipgloss.AdaptiveColor{Dark: string(LightGreen), Light: string(Green)} + InfoLogLevel = lipgloss.AdaptiveColor{Dark: string(Turquoise), Light: string(Green)} ErrorLogLevel = Red WarnLogLevel = Yellow diff --git a/internal/tui/helpers.go b/internal/tui/helpers.go index be22c71..572f276 100644 --- a/internal/tui/helpers.go +++ b/internal/tui/helpers.go @@ -316,6 +316,10 @@ func (h *Helpers) CreateTasks(fn task.SpecFunc, ids ...resource.ID) tea.Cmd { if err != nil { return ErrorMsg(fmt.Errorf("creating task: %w", err)) } + if task.Short { + // Don't navigate the user to the task page for short tasks. + return nil + } return NewNavigationMsg(TaskKind, WithParent(task.ID)) default: specs := make([]task.Spec, 0, len(ids)) diff --git a/internal/tui/keys/common.go b/internal/tui/keys/common.go index 6cc1848..948b0df 100644 --- a/internal/tui/keys/common.go +++ b/internal/tui/keys/common.go @@ -19,6 +19,7 @@ type common struct { Validate key.Binding Format key.Binding Cost key.Binding + LastTask key.Binding } // Keys shared by several models. @@ -87,4 +88,8 @@ var Common = common{ key.WithKeys("$"), key.WithHelp("$", "cost"), ), + LastTask: key.NewBinding( + key.WithKeys("o"), + key.WithHelp("o", "last task output"), + ), } diff --git a/internal/tui/style.go b/internal/tui/style.go index eaea948..088b6ce 100644 --- a/internal/tui/style.go +++ b/internal/tui/style.go @@ -7,9 +7,6 @@ var ( Bold = Regular.Bold(true) Padded = Regular.Padding(0, 1) - Width = lipgloss.Width - Height = lipgloss.Height - Border = Regular.Border(lipgloss.NormalBorder()) ThickBorder = Regular.Border(lipgloss.ThickBorder()).BorderForeground(Violet) diff --git a/internal/tui/top/model.go b/internal/tui/top/model.go index c0ac9c4..3311a09 100644 --- a/internal/tui/top/model.go +++ b/internal/tui/top/model.go @@ -1,6 +1,9 @@ package top import ( + "errors" + "fmt" + "io" "os" "strings" @@ -31,21 +34,21 @@ const ( type model struct { *tui.PaneManager - makers map[tui.Kind]tui.Maker - modules *module.Service - width int - height int - mode mode - showHelp bool - prompt *tui.Prompt - dump *os.File - workdir string - err error - info string - tasks *task.Service - spinner *spinner.Model - spinning bool - maxTasks int + makers map[tui.Kind]tui.Maker + modules *module.Service + width int + height int + mode mode + showHelp bool + prompt *tui.Prompt + dump *os.File + workdir string + tasks *task.Service + spinner *spinner.Model + spinning bool + lastTaskID *resource.ID + err error + info string } func newModel(cfg app.Config, app *app.App) (model, error) { @@ -79,7 +82,6 @@ func newModel(cfg app.Config, app *app.App) (model, error) { modules: app.Modules, spinner: &spinner, tasks: app.Tasks, - maxTasks: cfg.MaxTasks, dump: dump, workdir: cfg.Workdir.PrettyString(), } @@ -126,6 +128,52 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + // Watch tasks as they finish and announce success/failure in the + // footer. This is only done for short tasks because these tasks do + // not redirect the user to the task output therefore the user needs to be + // notified of its success/failure. + switch msg := msg.(type) { + case resource.Event[*task.Task]: + if msg.Type == resource.UpdatedEvent { + t := msg.Payload + if t.TaskGroupID != nil { + break + } + if !t.Short { + break + } + if t.State != task.Exited && t.State != task.Errored { + break + } + b, err := io.ReadAll(t.NewReader(true)) + if err != nil { + err = fmt.Errorf("unable to report task completion: %w", err) + cmds = append(cmds, tui.ReportError(err)) + break + } + m.lastTaskID = &msg.Payload.ID + report := fmt.Sprintf("%s: ", t.String()) + errored := t.State == task.Errored + if errored { + report += task.StripError(string(b)) + } else { + report += "finished successfully" + } + // Ensure that the suggestion fits within visible width of message. + suggestion := "…(Press 'o' for full output)" + if len(report)+len(suggestion) > m.availableFooterMsgWidth() { + report = report[:m.availableFooterMsgWidth()-len(suggestion)] + } + report += suggestion + if errored { + err = errors.New(report) + cmds = append(cmds, tui.ReportError(err)) + } else { + cmds = append(cmds, tui.ReportInfo(report)) + } + } + } + switch msg := msg.(type) { case tui.PromptMsg: // Enable prompt widget @@ -217,6 +265,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tui.NavigateTo(tui.LogListKind) case key.Matches(msg, keys.Global.Tasks): return m, tui.NavigateTo(tui.TaskListKind) + case key.Matches(msg, keys.Common.LastTask): + if m.lastTaskID != nil { + return m, tui.NavigateTo(tui.TaskKind, tui.WithParent(*m.lastTaskID)) + } default: // Send all other keys to panes. if cmd := m.PaneManager.Update(msg); cmd != nil { @@ -280,30 +332,28 @@ func (m model) View() string { components = append(components, m.help()) } // Compose footer - footer := tui.Padded.Background(tui.Grey).Foreground(tui.White).Render("? help") + footer := helpWidget if m.err != nil { - footer += tui.Padded. - Bold(true). - Background(tui.Red). - Foreground(tui.White). - Render("Error:") - footer += tui.Regular.Padding(0, 1, 0, 0). + footer += tui.Regular.Padding(0, 1). Background(tui.Red). Foreground(tui.White). + Width(m.availableFooterMsgWidth()). Render(m.err.Error()) } else if m.info != "" { + footer += tui.Padded. + Foreground(tui.Black). + Background(tui.LightGreen). + Width(m.availableFooterMsgWidth()). + Render(m.info) + } else { footer += tui.Padded. Foreground(tui.Black). Background(tui.EvenLighterGrey). + Width(m.availableFooterMsgWidth()). Render(m.info) } - pug := tui.Padded.Background(tui.LightGrey).Foreground(tui.White).Render("PUG") - version := tui.Padded.Background(tui.DarkGrey).Foreground(tui.White).Render(version.Version) - // Fill in left over space with background color - leftover := m.width - tui.Width(footer) - tui.Width(pug) - tui.Width(version) - footer += tui.Regular.Width(leftover).Background(tui.EvenLighterGrey).Render() - footer += version - footer += pug + footer += versionWidget + footer += pugIconWidget // Add footer components = append(components, tui.Regular. Inline(true). @@ -314,6 +364,20 @@ func (m model) View() string { return lipgloss.JoinVertical(lipgloss.Top, components...) } +var ( + helpWidget = tui.Padded.Background(tui.Grey).Foreground(tui.White).Render("? help") + pugIconWidget = tui.Padded.Background(tui.HotPink).Foreground(tui.EvenLighterGrey).Render("󰩃 ") + versionWidget = tui.Padded.Background(tui.DarkGrey).Foreground(tui.White).Render(version.Version) +) + +func (m model) availableFooterMsgWidth() int { + // -2 to accommodate padding + return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(pugIconWidget)-lipgloss.Width(versionWidget)) +} + +// type taskCompletionMsg struct { +// error + // viewHeight returns the height available to the panes // // TODO: rename contentHeight