Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions cli/internal/adapters/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ type Model struct {
ViewMode ViewMode
TickInterval time.Duration
DisableTick bool // Disable tick loop for testing
BuildFailed bool // Tracks if any task has failed
}

// Init initializes the model.
Expand Down Expand Up @@ -311,6 +312,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
node.Cached = msg.Cached
if msg.Err != nil {
node.Status = StatusError
m.BuildFailed = true
// Move cursor to the failed task
for i, t := range m.FlatList {
if t.Name == node.Name {
m.SelectedIdx = i
m.FollowMode = false
m.ensureVisible()
m.updateActiveView()
break
}
}
} else {
node.Status = StatusDone
}
Expand Down
6 changes: 6 additions & 0 deletions cli/internal/adapters/tui/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,10 @@ var (
Padding(0, 1).
Background(colorIris).
Foreground(colorWhite)

failureTitleStyle = lipgloss.NewStyle().
Bold(true).
Padding(0, 1).
Background(colorRed).
Foreground(colorWhite)
)
9 changes: 8 additions & 1 deletion cli/internal/adapters/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,14 @@ func (m *Model) View() string {
func (m *Model) treeView() string {
var s strings.Builder

s.WriteString(titleStyle.Render("BUILD PLAN") + "\n\n")
// Header: change title and style based on build state
header := "BUILD PLAN"
headerStyle := titleStyle
if m.BuildFailed {
header = "BUILD FAILED - Press 'q' to exit"
headerStyle = failureTitleStyle
}
s.WriteString(headerStyle.Render(header) + "\n\n")

// Handle empty plan
if len(m.FlatList) == 0 {
Expand Down
130 changes: 130 additions & 0 deletions cli/internal/adapters/tui/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,133 @@ func TestView_FullScreenLogView_WithDuration(t *testing.T) {
assert.Contains(t, output, "Completed")
assert.Contains(t, output, "[2.0s]")
}

func TestView_BuildFailed_Header(t *testing.T) {
task := &tui.TaskNode{Name: "task1", Status: tui.StatusError, Term: tui.NewVterm()}

m := tui.Model{
FlatList: []*tui.TaskNode{task},
TreeRoots: []*tui.TaskNode{task},
ListHeight: 10,
ViewMode: tui.ViewModeTree,
BuildFailed: true,
TaskMap: map[string]*tui.TaskNode{"task1": task},
}

output := m.View()

assert.Contains(t, output, "BUILD FAILED")
assert.Contains(t, output, "Press 'q' to exit")
}

func TestView_BuildSuccess_Header(t *testing.T) {
task := &tui.TaskNode{Name: "task1", Status: tui.StatusDone, Term: tui.NewVterm()}

m := tui.Model{
FlatList: []*tui.TaskNode{task},
TreeRoots: []*tui.TaskNode{task},
ListHeight: 10,
ViewMode: tui.ViewModeTree,
BuildFailed: false,
TaskMap: map[string]*tui.TaskNode{"task1": task},
}

output := m.View()

assert.Contains(t, output, "BUILD PLAN")
assert.NotContains(t, output, "BUILD FAILED")
}

func TestView_TreeConnectors(t *testing.T) {
child1 := &tui.TaskNode{Name: "child1", Status: tui.StatusDone, Term: tui.NewVterm(), Depth: 1}
child2 := &tui.TaskNode{Name: "child2", Status: tui.StatusDone, Term: tui.NewVterm(), Depth: 1}
child3 := &tui.TaskNode{Name: "child3", Status: tui.StatusDone, Term: tui.NewVterm(), Depth: 1}
parent := &tui.TaskNode{
Name: "parent",
Status: tui.StatusDone,
Term: tui.NewVterm(),
Depth: 0,
Children: []*tui.TaskNode{child1, child2, child3},
IsExpanded: true,
}
child1.Parent = parent
child2.Parent = parent
child3.Parent = parent

m := tui.Model{
FlatList: []*tui.TaskNode{parent, child1, child2, child3},
TreeRoots: []*tui.TaskNode{parent},
ListHeight: 10,
SelectedIdx: 0,
TaskMap: map[string]*tui.TaskNode{
"parent": parent,
"child1": child1,
"child2": child2,
"child3": child3,
},
ViewMode: tui.ViewModeTree,
}

output := m.View()

assert.Contains(t, output, "├──")
assert.Contains(t, output, "└──")
}

func TestView_RootNodeNoConnector(t *testing.T) {
root := &tui.TaskNode{
Name: "root",
Status: tui.StatusDone,
Term: tui.NewVterm(),
Depth: 0,
Parent: nil,
Children: []*tui.TaskNode{},
}

m := tui.Model{
FlatList: []*tui.TaskNode{root},
TreeRoots: []*tui.TaskNode{root},
ListHeight: 10,
SelectedIdx: 0,
TaskMap: map[string]*tui.TaskNode{"root": root},
ViewMode: tui.ViewModeTree,
}

output := m.View()

assert.Contains(t, output, "root")
assert.NotContains(t, output, "├──")
assert.NotContains(t, output, "└──")
}

func TestView_EmptyParentChildren(t *testing.T) {
parent := &tui.TaskNode{
Name: "parent",
Status: tui.StatusDone,
Term: tui.NewVterm(),
Depth: 0,
Children: []*tui.TaskNode{},
}
orphan := &tui.TaskNode{
Name: "orphan",
Status: tui.StatusDone,
Term: tui.NewVterm(),
Depth: 1,
Parent: parent,
}

m := tui.Model{
FlatList: []*tui.TaskNode{parent, orphan},
TreeRoots: []*tui.TaskNode{parent},
ListHeight: 10,
SelectedIdx: 0,
TaskMap: map[string]*tui.TaskNode{
"parent": parent,
"orphan": orphan,
},
ViewMode: tui.ViewModeTree,
}

output := m.View()
assert.Contains(t, output, "orphan")
}
8 changes: 6 additions & 2 deletions cli/internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,19 +149,23 @@ func (a *App) Run(ctx context.Context, targetNames []string, opts RunOptions) er

// Scheduler Routine
g.Go(func() error {
var schedErr error
defer func() {
// Handle panic recovery for the scheduler goroutine
if r := recover(); r != nil {
// Print panic info before renderer shutdown
fmt.Fprintf(os.Stderr, "Scheduler panic: %v\n", r)
}
// Ensure renderer stops when scheduler finishes, UNLESS inspection mode is on.
if !opts.Inspect {
// Stop renderer ONLY if:
// 1. Inspect mode is not enabled AND
// 2. No build failure occurred
if !opts.Inspect && schedErr == nil {
_ = renderer.Stop()
}
}()

if err := sched.Run(ctx, graph, targetNames, runtime.NumCPU(), opts.NoCache); err != nil {
schedErr = err
return errors.Join(domain.ErrBuildExecutionFailed, err)
}
return nil
Expand Down