From 1d5071a072f806964b20d35e43263e909650a307 Mon Sep 17 00:00:00 2001 From: Louis Garman Date: Fri, 22 Mar 2024 17:12:55 +0000 Subject: [PATCH] wip --- go.mod | 3 -- go.sum | 2 + internal/app/app.go | 2 +- internal/run/scheduler.go | 7 ++- internal/task/task.go | 5 +++ internal/tui/commands.go | 74 ++++++++++++++++++++++++++++++++ internal/tui/messages.go | 21 +-------- internal/tui/module/list.go | 19 ++++---- internal/tui/module/model.go | 25 ++++++++--- internal/tui/run/list.go | 26 ++++------- internal/tui/run/model.go | 2 +- internal/tui/table/columns.go | 14 +++++- internal/tui/table/table.go | 5 +-- internal/tui/table/truncation.go | 18 ++++++++ internal/tui/tabs.go | 11 +++++ internal/tui/task/list.go | 2 +- internal/tui/task/model.go | 48 +-------------------- internal/tui/top/model.go | 51 +++++++++++++++------- internal/tui/workspace/list.go | 32 ++++++++------ internal/workspace/service.go | 8 ++++ 20 files changed, 237 insertions(+), 138 deletions(-) create mode 100644 internal/tui/commands.go create mode 100644 internal/tui/table/truncation.go diff --git a/go.mod b/go.mod index a3d12032..2e0ab5c5 100644 --- a/go.mod +++ b/go.mod @@ -71,6 +71,3 @@ require ( ) replace github.com/mattn/go-runewidth => github.com/leg100/go-runewidth v0.0.16-0.20240317085039-79cdd3ecf674 - -//replace github.com/charmbracelet/bubbletea => github.com/leg100/bubbletea v0.25.1-0.20240319155826-3bbfacbc5292 -replace github.com/charmbracelet/bubbletea => ../bubbletea diff --git a/go.sum b/go.sum index e074d0d0..179cd548 100644 --- a/go.sum +++ b/go.sum @@ -119,6 +119,8 @@ github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46f github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= diff --git a/internal/app/app.go b/internal/app/app.go index 97d0577a..9aa00889 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -127,7 +127,7 @@ func Start(args []string) error { // Start daemons go task.StartEnqueuer(ctx, tasks) go task.StartRunner(ctx, tasks, cfg.MaxTasks) - go run.StartScheduler(ctx, runs) + go run.StartScheduler(ctx, runs, workspaces) // Search directory for modules if err := modules.Reload(); err != nil { diff --git a/internal/run/scheduler.go b/internal/run/scheduler.go index a44ad1d9..478cf92b 100644 --- a/internal/run/scheduler.go +++ b/internal/run/scheduler.go @@ -5,6 +5,7 @@ import ( "github.com/leg100/pug/internal" "github.com/leg100/pug/internal/resource" + "github.com/leg100/pug/internal/workspace" "golang.org/x/exp/maps" ) @@ -21,13 +22,17 @@ type runLister interface { // runs are not subject this rule). // // The scheduler attempts to schedule runs upon every run event it receives. -func StartScheduler(ctx context.Context, runs *Service) { +func StartScheduler(ctx context.Context, runs *Service, workspaces *workspace.Service) { sub := runs.Broker.Subscribe(ctx) s := &scheduler{runs: runs} for range sub { for _, run := range s.schedule() { // Update status from pending to scheduled run.updateStatus(Scheduled) + // Set run as workspace's current run if not a plan-only run. + if !run.PlanOnly { + workspaces.SetCurrent(run.Workspace().ID(), run.Resource) + } // Trigger a plan task _, _ = runs.plan(run) } diff --git a/internal/task/task.go b/internal/task/task.go index 3ecb0fdd..49b9051a 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -7,6 +7,7 @@ import ( "log/slog" "os" "os/exec" + "strings" "sync" "time" @@ -142,6 +143,10 @@ func (f *factory) newTask(opts CreateOptions) (*Task, error) { func (t *Task) String() string { return t.Resource.String() } +func (t *Task) CommandString() string { + return strings.Join(t.Command, " ") +} + // NewReader provides a reader from which to read the task output from start to // end. func (t *Task) NewReader() io.Reader { diff --git a/internal/tui/commands.go b/internal/tui/commands.go new file mode 100644 index 00000000..1e3c213d --- /dev/null +++ b/internal/tui/commands.go @@ -0,0 +1,74 @@ +package tui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/leg100/pug/internal/resource" + "github.com/leg100/pug/internal/task" +) + +// CreateTasks returns a command that creates one or more tasks using the given +// IDs. If a task fails to be created then no further tasks will be created, and +// an error notification is sent. If all tasks are successfully created then a status +// notification is sent accordingly. +func CreateTasks(fn task.Func, ids ...resource.ID) tea.Cmd { + // Handle the case where a user has pressed a key on an empty table with + // zero rows + //if len(ids) == 0 { + // return nil + //} + + //// If items have been selected then clear the selection + //var deselectCmd tea.Cmd + //if len(ids) > 1 { + // deselectCmd = tui.CmdHandler(table.DeselectMsg{}) + //} + + return func() tea.Msg { + var ( + task *task.Task + err error + ) + for _, id := range ids { + if task, err = fn(id); err != nil { + return NewErrorMsg(err, "creating task") + } + } + return InfoMsg(fmt.Sprintf("Created %d %s tasks", len(ids), task.CommandString())) + + //if len(ids) > 1 { + // // User has selected multiple rows, so send them to the task *list* + // // page + // // + // // TODO: pass in parameter specifying the parent resource for the + // // task listing, i.e. module, workspace, run, etc. + // return navigationMsg{ + // target: page{kind: TaskListKind}, + // } + //} else { + // // User has highlighted a single row, so send them to the task page. + // return navigationMsg{ + // target: page{kind: TaskKind, resource: task.Resource}, + // } + //} + } + + //return tea.Batch(cmd, deselectCmd) +} + +// NavigateTo sends an instruction to navigate to a page with the given model +// kind, and optionally parent resource. +func NavigateTo(kind Kind, parent *resource.Resource) tea.Cmd { + return func() tea.Msg { + page := Page{Kind: kind} + if parent != nil { + page.Parent = *parent + } + return NavigationMsg(page) + } +} + +func ReportError(err error, msg string, args ...any) tea.Cmd { + return CmdHandler(NewErrorMsg(err, msg, args...)) +} diff --git a/internal/tui/messages.go b/internal/tui/messages.go index b1c783ed..b3395315 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -1,24 +1,9 @@ package tui -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/leg100/pug/internal/resource" -) - // NavigationMsg is an instruction to navigate to a page. type NavigationMsg Page -// NavigateTo sends an instruction to navigate to a page with the given model -// kind, and optionally parent resource. -func NavigateTo(kind Kind, parent *resource.Resource) tea.Cmd { - return func() tea.Msg { - page := Page{Kind: kind} - if parent != nil { - page.Parent = *parent - } - return NavigationMsg(page) - } -} +type InfoMsg string type ErrorMsg struct { Error error @@ -34,10 +19,6 @@ func NewErrorMsg(err error, msg string, args ...any) ErrorMsg { } } -func NewErrorCmd(err error, msg string, args ...any) tea.Cmd { - return CmdHandler(NewErrorMsg(err, msg, args...)) -} - // BodyResizeMsg is sent whenever the user resizes the terminal window. The width // and height refer to area available in the main body between the header and // the footer. diff --git a/internal/tui/module/list.go b/internal/tui/module/list.go index 38eee133..2bba3bf5 100644 --- a/internal/tui/module/list.go +++ b/internal/tui/module/list.go @@ -14,7 +14,6 @@ import ( "github.com/leg100/pug/internal/tui" "github.com/leg100/pug/internal/tui/keys" "github.com/leg100/pug/internal/tui/table" - tasktui "github.com/leg100/pug/internal/tui/task" "github.com/leg100/pug/internal/workspace" "golang.org/x/exp/maps" ) @@ -116,12 +115,12 @@ func (m list) Update(msg tea.Msg) (tui.Model, tea.Cmd) { //cmds = append(cmds, m.createRun(run.CreateOptions{})) } case resource.Event[*run.Run]: - switch msg.Type { - case resource.UpdatedEvent: - if msg.Payload.Status == run.Planned { - return m, tui.NavigateTo(tui.RunKind, &msg.Payload.Resource) - } - } + //switch msg.Type { + //case resource.UpdatedEvent: + // if msg.Payload.Status == run.Planned { + // // return m, tui.NavigateTo(tui.RunKind, &msg.Payload.Resource) + // } + //} } switch msg := msg.(type) { @@ -149,11 +148,11 @@ func (m list) Update(msg tea.Msg) (tui.Model, tea.Cmd) { }) } case key.Matches(msg, localKeys.Init): - return m, tasktui.TaskCmd(m.ModuleService.Init, maps.Keys(m.table.HighlightedOrSelected())...) + return m, tui.CreateTasks(m.ModuleService.Init, maps.Keys(m.table.HighlightedOrSelected())...) case key.Matches(msg, localKeys.Validate): - return m, tasktui.TaskCmd(m.ModuleService.Validate, maps.Keys(m.table.HighlightedOrSelected())...) + return m, tui.CreateTasks(m.ModuleService.Validate, maps.Keys(m.table.HighlightedOrSelected())...) case key.Matches(msg, localKeys.Format): - return m, tasktui.TaskCmd(m.ModuleService.Format, maps.Keys(m.table.HighlightedOrSelected())...) + return m, tui.CreateTasks(m.ModuleService.Format, maps.Keys(m.table.HighlightedOrSelected())...) case key.Matches(msg, localKeys.Plan): return m, m.createRun(run.CreateOptions{}) } diff --git a/internal/tui/module/model.go b/internal/tui/module/model.go index 2d72662f..dc90046f 100644 --- a/internal/tui/module/model.go +++ b/internal/tui/module/model.go @@ -16,6 +16,12 @@ import ( "github.com/leg100/pug/internal/workspace" ) +const ( + workspacesTabTitle = "workspaces" + runsTabTitle = "runs" + tasksTabTitle = "tasks" +) + // Maker makes module models. type Maker struct { ModuleService *module.Service @@ -34,24 +40,27 @@ func (mm *Maker) Make(mr resource.Resource, width, height int) (tui.Model, error } tabs := tui.NewTabSet(width, height) - if _, err := tabs.AddTab(mm.WorkspaceListMaker, mr, "workspaces"); err != nil { + if _, err := tabs.AddTab(mm.WorkspaceListMaker, mr, workspacesTabTitle); err != nil { return nil, fmt.Errorf("adding workspaces tab: %w", err) } - if _, err := tabs.AddTab(mm.RunListMaker, mr, "runs"); err != nil { + if _, err := tabs.AddTab(mm.RunListMaker, mr, runsTabTitle); err != nil { return nil, fmt.Errorf("adding runs tab: %w", err) } - if _, err := tabs.AddTab(mm.TaskListMaker, mr, "tasks"); err != nil { + if _, err := tabs.AddTab(mm.TaskListMaker, mr, tasksTabTitle); err != nil { return nil, fmt.Errorf("adding tasks tab: %w", err) } m := model{ - module: mod, - tabs: tabs, + ModuleService: mm.ModuleService, + module: mod, + tabs: tabs, } return m, nil } type model struct { + ModuleService *module.Service + module *module.Module tabs tui.TabSet } @@ -64,6 +73,12 @@ func (m model) Update(msg tea.Msg) (tui.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, localKeys.Init): + m.tabs.SetActiveTabWithTitle(tasksTabTitle) + return m, tui.CreateTasks(m.ModuleService.Init, m.module.ID()) + } case resource.Event[*module.Module]: if msg.Payload.ID() == m.module.ID() { m.module = msg.Payload diff --git a/internal/tui/run/list.go b/internal/tui/run/list.go index 5eaa68e6..6ce75ef5 100644 --- a/internal/tui/run/list.go +++ b/internal/tui/run/list.go @@ -23,16 +23,6 @@ type ListMaker struct { } func (m *ListMaker) Make(parent resource.Resource, width, height int) (tui.Model, error) { - statusColumn := table.Column{ - Key: "run_status", - Title: "STATUS", - Width: run.MaxStatusLen, - } - changesColumn := table.Column{ - Key: "run_changes", - Title: "CHANGES", - Width: 10, - } ageColumn := table.Column{ Key: "age", Title: "AGE", @@ -49,20 +39,20 @@ func (m *ListMaker) Make(parent resource.Resource, width, height int) (tui.Model columns = append(columns, table.WorkspaceColumn) } columns = append(columns, - statusColumn, - changesColumn, + table.RunStatusColumn, + table.RunChangesColumn, ageColumn, table.IDColumn, ) renderer := func(r *run.Run, style lipgloss.Style) table.RenderedRow { row := table.RenderedRow{ - table.ModuleColumn.Key: r.ModulePath(), - table.WorkspaceColumn.Key: r.WorkspaceName(), - statusColumn.Key: string(r.Status), - changesColumn.Key: r.PlanReport.String(), - ageColumn.Key: tui.Ago(time.Now(), r.Updated), - table.IDColumn.Key: r.ID().String(), + table.ModuleColumn.Key: r.ModulePath(), + table.WorkspaceColumn.Key: r.WorkspaceName(), + table.RunStatusColumn.Key: string(r.Status), + table.RunChangesColumn.Key: r.PlanReport.String(), + ageColumn.Key: tui.Ago(time.Now(), r.Updated), + table.IDColumn.Key: r.ID().String(), } // switch r.Status { diff --git a/internal/tui/run/model.go b/internal/tui/run/model.go index 0fea50e6..565ccc58 100644 --- a/internal/tui/run/model.go +++ b/internal/tui/run/model.go @@ -97,7 +97,7 @@ func (m model) Update(msg tea.Msg) (tui.Model, tea.Cmd) { } cmd, err := m.addTab(msg.Payload) if err != nil { - return m, tui.NewErrorCmd(err, "") + return m, tui.ReportError(err, "") } cmds = append(cmds, cmd) } diff --git a/internal/tui/table/columns.go b/internal/tui/table/columns.go index 88349add..baad2f6e 100644 --- a/internal/tui/table/columns.go +++ b/internal/tui/table/columns.go @@ -2,7 +2,7 @@ package table import ( "github.com/leg100/pug/internal/resource" - "github.com/mattn/go-runewidth" + "github.com/leg100/pug/internal/run" ) var ( @@ -14,7 +14,7 @@ var ( ModuleColumn = Column{ Key: "module", Title: "MODULE", - TruncationFunc: runewidth.TruncateLeft, + TruncationFunc: TruncateLeft, FlexFactor: 3, } WorkspaceColumn = Column{ @@ -34,4 +34,14 @@ var ( Width: resource.IDEncodedMaxLen, FlexFactor: 1, } + RunStatusColumn = Column{ + Key: "run_status", + Title: "STATUS", + Width: run.MaxStatusLen, + } + RunChangesColumn = Column{ + Key: "run_changes", + Title: "CHANGES", + Width: 10, + } ) diff --git a/internal/tui/table/table.go b/internal/tui/table/table.go index dfadce9a..a08ceda5 100644 --- a/internal/tui/table/table.go +++ b/internal/tui/table/table.go @@ -11,7 +11,6 @@ import ( "github.com/leg100/pug/internal/tui" "github.com/leg100/pug/internal/tui/keys" "github.com/mattn/go-runewidth" - "github.com/muesli/reflow/truncate" "golang.org/x/exp/maps" ) @@ -115,7 +114,7 @@ func New[T Item](columns []Column, fn RowRenderer[T], width, height int) Model[T for _, col := range columns { // Set default truncation function if unset if col.TruncationFunc == nil { - col.TruncationFunc = runewidth.Truncate + col.TruncationFunc = defaultTruncationFunc } m.cols = append(m.cols, col) } @@ -509,7 +508,7 @@ func (m *Model[T]) renderRow(rowID int) string { for i, col := range m.cols { content := cells[col.Key] // Truncate content if it is wider than column - truncated := truncate.StringWithTail(content, uint(col.Width), "…") + truncated := col.TruncationFunc(content, col.Width, "…") // Ensure content is all on one line. inlined := lipgloss.NewStyle(). Width(col.Width). diff --git a/internal/tui/table/truncation.go b/internal/tui/table/truncation.go new file mode 100644 index 00000000..3b83ec10 --- /dev/null +++ b/internal/tui/table/truncation.go @@ -0,0 +1,18 @@ +package table + +import ( + "github.com/mattn/go-runewidth" + "github.com/muesli/reflow/truncate" +) + +var defaultTruncationFunc = TruncateRight + +type TruncationFunc func(s string, w int, tailOrPrefix string) string + +func TruncateRight(s string, w int, tail string) string { + return truncate.StringWithTail(s, uint(w), tail) +} + +func TruncateLeft(s string, w int, prefix string) string { + return runewidth.TruncateLeft(s, w, prefix) +} diff --git a/internal/tui/tabs.go b/internal/tui/tabs.go index dd1c5a6e..7c853836 100644 --- a/internal/tui/tabs.go +++ b/internal/tui/tabs.go @@ -100,6 +100,17 @@ func (m *TabSet) SetActiveTab(tabIndex int) { } } +// SetActiveTabWithTitle looks up a tab with a title and makes it the active +// tab. If no such tab exists no action is taken. +func (m *TabSet) SetActiveTabWithTitle(title string) { + for i, tab := range m.Tabs { + if tab.title == title { + m.active = i + return + } + } +} + func (m TabSet) Update(msg tea.Msg) (TabSet, tea.Cmd) { var cmds []tea.Cmd diff --git a/internal/tui/task/list.go b/internal/tui/task/list.go index 1208608a..7c5ab8bd 100644 --- a/internal/tui/task/list.go +++ b/internal/tui/task/list.go @@ -119,7 +119,7 @@ func (m list) Update(msg tea.Msg) (tui.Model, tea.Cmd) { return m, tui.NavigateTo(tui.TaskKind, &task.Resource) } case key.Matches(msg, keys.Common.Cancel): - return m, TaskCmd(m.svc.Cancel, maps.Keys(m.table.HighlightedOrSelected())...) + return m, tui.CreateTasks(m.svc.Cancel, maps.Keys(m.table.HighlightedOrSelected())...) } } // Handle keyboard and mouse events in the table widget diff --git a/internal/tui/task/model.go b/internal/tui/task/model.go index 854f56dd..01b09e98 100644 --- a/internal/tui/task/model.go +++ b/internal/tui/task/model.go @@ -14,7 +14,6 @@ import ( "github.com/leg100/pug/internal/task" "github.com/leg100/pug/internal/tui" "github.com/leg100/pug/internal/tui/keys" - "github.com/leg100/pug/internal/tui/table" "github.com/muesli/reflow/wordwrap" ) @@ -81,11 +80,11 @@ func (m model) Update(msg tea.Msg) (tui.Model, tea.Cmd) { case tea.KeyMsg: switch { case key.Matches(msg, keys.Common.Cancel): - return m, TaskCmd(m.svc.Cancel, m.task.ID()) + return m, tui.CreateTasks(m.svc.Cancel, m.task.ID()) // TODO: retry case key.Matches(msg, keys.Common.Apply): - return m, TaskCmd(m.svc.Cancel, m.task.ID()) + return m, tui.CreateTasks(m.svc.Cancel, m.task.ID()) // TODO: retry } case outputMsg: @@ -232,46 +231,3 @@ type outputMsg struct { output string eof bool } - -// TaskCmd returns a command that creates one or more tasks using the given IDs. -func TaskCmd(fn task.Func, ids ...resource.ID) tea.Cmd { - // Handle the case where a user has pressed a key on an empty table with - // zero rows - if len(ids) == 0 { - return nil - } - - // If items have been selected then clear the selection - var deselectCmd tea.Cmd - if len(ids) > 1 { - deselectCmd = tui.CmdHandler(table.DeselectMsg{}) - } - - cmd := func() tea.Msg { - //var task *task.Task - for _, id := range ids { - var err error - if _, err = fn(id); err != nil { - return tui.NewErrorMsg(err, "creating task") - } - } - //if len(ids) > 1 { - // // User has selected multiple rows, so send them to the task *list* - // // page - // // - // // TODO: pass in parameter specifying the parent resource for the - // // task listing, i.e. module, workspace, run, etc. - // return navigationMsg{ - // target: page{kind: TaskListKind}, - // } - //} else { - // // User has highlighted a single row, so send them to the task page. - // return navigationMsg{ - // target: page{kind: TaskKind, resource: task.Resource}, - // } - //} - return nil - } - - return tea.Batch(cmd, deselectCmd) -} diff --git a/internal/tui/top/model.go b/internal/tui/top/model.go index 0fb0f7cb..b21d925e 100644 --- a/internal/tui/top/model.go +++ b/internal/tui/top/model.go @@ -28,7 +28,10 @@ type model struct { height int showHelp bool - err string + + // Either an error or an informational message is rendered in the footer. + err string + info string tasks *task.Service spinner *spinner.Model @@ -105,7 +108,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case resource.Event[*module.Module]: switch msg.Type { case resource.CreatedEvent: - //cmds = append(cmds, tui.NavigateTo(tui.ModuleKind, &msg.Payload.Resource)) + // cmds = append(cmds, tui.NavigateTo(tui.ModuleKind, &msg.Payload.Resource)) } } @@ -123,7 +126,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return tui.BodyResizeMsg{Width: m.viewWidth(), Height: m.viewHeight()} } case tea.KeyMsg: - // Pressing any key makes any error message disappear + // Pressing any key makes any info/error message in the footer disappear + m.info = "" m.err = "" switch { @@ -163,7 +167,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tui.NavigationMsg: created, err := m.setCurrent(tui.Page(msg)) if err != nil { - return m, tui.NewErrorCmd(err, "setting current page") + return m, tui.ReportError(err, "setting current page") } if created { return m, m.currentModel().Init() @@ -177,6 +181,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = fmt.Sprintf("Error: %s: %s", msg, err) slog.Error(msg, "error", err) } + case tui.InfoMsg: + m.info = string(msg) default: // Send remaining msg types to all cached models cmds = append(cmds, m.cache.updateAll(msg)...) @@ -207,9 +213,6 @@ func (m model) View() string { var ( content string shortHelpBindings []key.Binding - pagination = tui.Regular.Padding(0, 1).Render( - fmt.Sprintf("%d/%d tasks", m.tasks.Counter(), 32), - ) ) if m.showHelp { @@ -248,6 +251,25 @@ func (m model) View() string { titleRightRule := strings.Repeat("─", max(0, m.width-tui.Width(titleLeftRuleAndTitle))) renderedTitle := fmt.Sprintf("%s%s", titleLeftRuleAndTitle, titleRightRule) + // Global-level info goes in the bottom right corner in the footer. + metadata := tui.Padded.Copy().Render( + fmt.Sprintf("%d/%d tasks", m.tasks.Counter(), 32), + ) + + // Render any info/error message to be shown in the bottom left corner in + // the footer, using whatever space is remaining to the left of the + // metadata. + var footerMsg string + if m.err != "" { + footerMsg = tui.Padded.Copy(). + Foreground(tui.Red). + Render(m.err) + } else if m.info != "" { + footerMsg = tui.Padded.Copy(). + Foreground(tui.Black). + Render(m.info) + } + return lipgloss.JoinVertical( lipgloss.Top, // header @@ -284,15 +306,14 @@ func (m model) View() string { // footer lipgloss.JoinHorizontal( lipgloss.Top, - // error messages - lipgloss.NewStyle(). - Width(m.width-tui.Width(pagination)). - Padding(0, 1). - Foreground(tui.Red). - // TODO: prefix with Error: - Render(m.err), + // info/error message + tui.Regular. + Inline(true). + MaxWidth(m.width-tui.Width(metadata)). + Width(m.width-tui.Width(metadata)). + Render(footerMsg), // pagination - pagination, + metadata, ), ) } diff --git a/internal/tui/workspace/list.go b/internal/tui/workspace/list.go index de1810ae..db87b6b5 100644 --- a/internal/tui/workspace/list.go +++ b/internal/tui/workspace/list.go @@ -12,7 +12,6 @@ import ( "github.com/leg100/pug/internal/tui" "github.com/leg100/pug/internal/tui/keys" "github.com/leg100/pug/internal/tui/table" - tasktui "github.com/leg100/pug/internal/tui/task" "github.com/leg100/pug/internal/workspace" "golang.org/x/exp/maps" ) @@ -30,17 +29,12 @@ func (m *ListMaker) Make(parent resource.Resource, width, height int) (tui.Model } columns = append(columns, table.WorkspaceColumn, + table.RunStatusColumn, + table.RunChangesColumn, table.IDColumn, ) - renderer := func(ws *workspace.Workspace, inherit lipgloss.Style) table.RenderedRow { - return table.RenderedRow{ - table.ModuleColumn.Key: ws.ModulePath(), - table.WorkspaceColumn.Key: ws.Name(), - table.IDColumn.Key: ws.ID().String(), - } - } - table := table.New(columns, renderer, width, height). + table := table.New(columns, m.renderRow, width, height). WithSortFunc(workspace.Sort). WithParent(parent) @@ -53,6 +47,20 @@ func (m *ListMaker) Make(parent resource.Resource, width, height int) (tui.Model }, nil } +func (m *ListMaker) renderRow(ws *workspace.Workspace, inherit lipgloss.Style) table.RenderedRow { + row := table.RenderedRow{ + table.ModuleColumn.Key: ws.ModulePath(), + table.WorkspaceColumn.Key: ws.Name(), + table.IDColumn.Key: ws.ID().String(), + } + if cr := ws.CurrentRun; cr != nil { + run, _ := m.RunService.Get(cr.ID()) + row[table.RunStatusColumn.Key] = string(run.Status) + row[table.RunChangesColumn.Key] = run.PlanReport.String() + } + return row +} + type list struct { table table.Model[*workspace.Workspace] svc *workspace.Service @@ -85,11 +93,11 @@ func (m list) Update(msg tea.Msg) (tui.Model, tea.Cmd) { return m, tui.NavigateTo(tui.WorkspaceKind, &ws.Resource) } case key.Matches(msg, Keys.Init): - return m, tasktui.TaskCmd(m.modules.Init, m.highlightedOrSelectedModuleIDs()...) + return m, tui.CreateTasks(m.modules.Init, m.highlightedOrSelectedModuleIDs()...) case key.Matches(msg, Keys.Format): - return m, tasktui.TaskCmd(m.modules.Format, m.highlightedOrSelectedModuleIDs()...) + return m, tui.CreateTasks(m.modules.Format, m.highlightedOrSelectedModuleIDs()...) case key.Matches(msg, Keys.Validate): - return m, tasktui.TaskCmd(m.modules.Validate, m.highlightedOrSelectedModuleIDs()...) + return m, tui.CreateTasks(m.modules.Validate, m.highlightedOrSelectedModuleIDs()...) case key.Matches(msg, Keys.Plan): return m, m.createRun(run.CreateOptions{}) } diff --git a/internal/workspace/service.go b/internal/workspace/service.go index 7c99fbdf..312fd0ca 100644 --- a/internal/workspace/service.go +++ b/internal/workspace/service.go @@ -224,6 +224,14 @@ func (s *Service) Subscribe(ctx context.Context) <-chan resource.Event[*Workspac return s.broker.Subscribe(ctx) } +func (s *Service) SetCurrent(workspaceID resource.ID, run resource.Resource) { + ws, err := s.table.Get(workspaceID) + if err != nil { + slog.Error("setting current workspace run", "run", run, "error", err) + } + ws.CurrentRun = &run +} + // Delete a workspace. Asynchronous. func (s *Service) Delete(id resource.ID) (*task.Task, error) { ws, err := s.table.Get(id)