diff --git a/cli/internal/adapters/tui/export_test.go b/cli/internal/adapters/tui/export_test.go index 7e6f84a..c8f0fff 100644 --- a/cli/internal/adapters/tui/export_test.go +++ b/cli/internal/adapters/tui/export_test.go @@ -20,3 +20,11 @@ func (m *Model) UpdateActiveView() { func (m *Model) EnsureVisible() { m.ensureVisible() } + +func CalculateRowNameWidth(node *TaskNode) int { + return calculateRowNameWidth(node) +} + +func (m *Model) CalculateMaxNameWidth(start, end int) int { + return m.calculateMaxNameWidth(start, end) +} diff --git a/cli/internal/adapters/tui/view.go b/cli/internal/adapters/tui/view.go index d86acfd..4d039fc 100644 --- a/cli/internal/adapters/tui/view.go +++ b/cli/internal/adapters/tui/view.go @@ -47,15 +47,18 @@ func (m *Model) treeView() string { start = end } + // Calculate maximum name width for alignment + maxNameWidth := m.calculateMaxNameWidth(start, end) + for i := start; i < end; i++ { node := m.FlatList[i] - s.WriteString(m.renderTreeRow(i, node) + "\n") + s.WriteString(m.renderTreeRow(i, node, maxNameWidth) + "\n") } return s.String() } -func (m *Model) renderTreeRow(index int, node *TaskNode) string { +func (m *Model) renderTreeRow(index int, node *TaskNode, maxNameWidth int) string { // Get live status from canonical node canonical := node.CanonicalNode if canonical == nil { @@ -88,8 +91,12 @@ func (m *Model) renderTreeRow(index int, node *TaskNode) string { expander = " " } - // Duration display (use canonical node for times) - duration := m.formatDuration(canonical) + // Status display (use canonical node for times) + status := m.formatStatus(canonical) + + // Calculate padding for alignment + nameWidth := calculateRowNameWidth(node) + padding := strings.Repeat(" ", maxNameWidth-nameWidth) // Selection cursor var cursor string @@ -102,8 +109,8 @@ func (m *Model) renderTreeRow(index int, node *TaskNode) string { cursor = " " } - content := fmt.Sprintf("%s%s%s%s %s %s", - indent, connector, expander, icon, node.Name, duration) + content := fmt.Sprintf("%s%s%s%s %s%s %s", + indent, connector, expander, icon, node.Name, padding, status) return cursor + style.Render(content) } @@ -116,27 +123,95 @@ func isLastChild(node *TaskNode) bool { return len(children) > 0 && children[len(children)-1] == node } -func (m *Model) formatDuration(node *TaskNode) string { - if node.Status == StatusPending { +func (m *Model) formatStatus(node *TaskNode) string { + switch node.Status { + case StatusPending: + return "[Pending]" + + case StatusRunning: + var duration time.Duration + startTime := node.StartTime + if !node.ExecStartTime.IsZero() { + startTime = node.ExecStartTime + } + duration = time.Since(startTime) + return fmt.Sprintf("[Running %s]", formatDuration(duration)) + + case StatusDone: + var duration time.Duration + startTime := node.StartTime + if !node.ExecStartTime.IsZero() { + startTime = node.ExecStartTime + } + duration = node.EndTime.Sub(startTime) + + if node.Cached { + return fmt.Sprintf("[Cached %s]", formatDuration(duration)) + } + return fmt.Sprintf("[Took %s]", formatDuration(duration)) + + case StatusError: + var duration time.Duration + startTime := node.StartTime + if !node.ExecStartTime.IsZero() { + startTime = node.ExecStartTime + } + duration = node.EndTime.Sub(startTime) + return fmt.Sprintf("[Failed %s]", formatDuration(duration)) + + default: return "" } +} - var duration time.Duration - startTime := node.StartTime - if !node.ExecStartTime.IsZero() { - startTime = node.ExecStartTime +func formatDuration(duration time.Duration) string { + if duration < time.Second { + return fmt.Sprintf("%dms", duration.Milliseconds()) } + return fmt.Sprintf("%.1fs", duration.Seconds()) +} - if node.Status == StatusRunning { - duration = time.Since(startTime) - } else { - duration = node.EndTime.Sub(startTime) +func calculateRowNameWidth(node *TaskNode) int { + width := 0 + + // Indent: 2 chars per depth level + width += node.Depth * 2 + + // Connector: 4 chars if depth > 0 + if node.Depth > 0 { + width += 4 } - if duration < time.Second { - return fmt.Sprintf("[%dms]", duration.Milliseconds()) + // Expander: 2 chars always + width += 2 + + // Icon: 1 char + width++ + + // Space separator before name + width++ + + // Name width (Unicode-safe) + width += lipgloss.Width(node.Name) + + return width +} + +func (m *Model) calculateMaxNameWidth(start, end int) int { + maxWidth := 0 + + for i := start; i < end; i++ { + if i >= len(m.FlatList) { + break + } + node := m.FlatList[i] + width := calculateRowNameWidth(node) + if width > maxWidth { + maxWidth = width + } } - return fmt.Sprintf("[%.1fs]", duration.Seconds()) + + return maxWidth } //nolint:gocritic // hugeParam ignored @@ -153,21 +228,9 @@ func (m *Model) fullScreenLogView() string { return "Task not found" } - status := "" - switch node.Status { - case StatusRunning: - status = " (Running)" - case StatusDone: - status = " (Completed)" - case StatusError: - status = " (Failed)" - default: - status = " (Pending)" - } - - duration := m.formatDuration(node) - header = titleStyle.Render(fmt.Sprintf("LOGS: %s%s %s | Press ESC to return", - node.Name, status, duration)) + status := m.formatStatus(node) + header = titleStyle.Render(fmt.Sprintf("LOGS: %s %s | Press ESC to return", + node.Name, status)) content = node.Term.View() diff --git a/cli/internal/adapters/tui/view_test.go b/cli/internal/adapters/tui/view_test.go index 79308c8..b8da33e 100644 --- a/cli/internal/adapters/tui/view_test.go +++ b/cli/internal/adapters/tui/view_test.go @@ -77,13 +77,13 @@ func TestView_LogPane(t *testing.T) { task.Status = tui.StatusRunning output = m.View() assert.Contains(t, output, "LOGS: task1") - assert.Contains(t, output, "Running") + assert.Contains(t, output, "[Running") // Case 3: Active task completed task.Status = tui.StatusDone output = m.View() assert.Contains(t, output, "LOGS: task1") - assert.Contains(t, output, "Completed") + assert.Contains(t, output, "[Took") } func TestView_LipglossIntegration(t *testing.T) { @@ -163,12 +163,12 @@ func TestView_DurationFormat(t *testing.T) { } output := m.View() - assert.NotContains(t, output, "ms") - assert.NotContains(t, output, "s]") + assert.Contains(t, output, "[Pending]") task.Status = tui.StatusDone task.StartTime = task.StartTime.Add(-500 * time.Millisecond) output = m.View() + assert.Contains(t, output, "[Took") assert.Contains(t, output, "ms") } @@ -177,8 +177,8 @@ func TestView_LogViewStatuses(t *testing.T) { status tui.TaskStatus expected string }{ - {tui.StatusPending, "Pending"}, - {tui.StatusError, "Failed"}, + {tui.StatusPending, "[Pending]"}, + {tui.StatusError, "[Failed"}, } for _, tt := range tests { @@ -245,7 +245,7 @@ func TestView_FormatDuration_WithExecStartTime(t *testing.T) { output := m.View() - assert.Contains(t, output, "[1.0s]") + assert.Contains(t, output, "[Took 1.0s]") } func TestView_FormatDuration_RunningTask(t *testing.T) { @@ -266,6 +266,7 @@ func TestView_FormatDuration_RunningTask(t *testing.T) { output := m.View() + assert.Contains(t, output, "[Running") assert.Contains(t, output, "ms") } @@ -290,6 +291,154 @@ func TestView_FullScreenLogView_WithDuration(t *testing.T) { output := m.View() assert.Contains(t, output, "LOGS: task1") - assert.Contains(t, output, "Completed") - assert.Contains(t, output, "[2.0s]") + assert.Contains(t, output, "[Took 2.0s]") +} + +func TestFormatStatus_AllStates(t *testing.T) { + now := time.Now() + tests := []struct { + name string + task *tui.TaskNode + expected string + }{ + { + name: "Pending", + task: &tui.TaskNode{ + Name: "task1", + Status: tui.StatusPending, + Term: tui.NewVterm(), + }, + expected: "[Pending]", + }, + { + name: "Running", + task: &tui.TaskNode{ + Name: "task2", + Status: tui.StatusRunning, + Term: tui.NewVterm(), + StartTime: now.Add(-1 * time.Second), + }, + expected: "[Running", + }, + { + name: "Done", + task: &tui.TaskNode{ + Name: "task3", + Status: tui.StatusDone, + Term: tui.NewVterm(), + StartTime: now.Add(-1 * time.Second), + EndTime: now, + }, + expected: "[Took 1.0s]", + }, + { + name: "Cached", + task: &tui.TaskNode{ + Name: "task4", + Status: tui.StatusDone, + Term: tui.NewVterm(), + StartTime: now.Add(-500 * time.Millisecond), + EndTime: now, + Cached: true, + }, + expected: "[Cached", + }, + { + name: "Failed", + task: &tui.TaskNode{ + Name: "task5", + Status: tui.StatusError, + Term: tui.NewVterm(), + StartTime: now.Add(-2 * time.Second), + EndTime: now, + }, + expected: "[Failed 2.0s]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := tui.Model{ + FlatList: []*tui.TaskNode{tt.task}, + TreeRoots: []*tui.TaskNode{tt.task}, + ListHeight: 10, + ViewMode: tui.ViewModeTree, + TaskMap: map[string]*tui.TaskNode{tt.task.Name: tt.task}, + } + + output := m.View() + assert.Contains(t, output, tt.expected) + }) + } +} + +func TestCalculateRowNameWidth(t *testing.T) { + tests := []struct { + name string + task *tui.TaskNode + expected int + }{ + { + name: "Root level task", + task: &tui.TaskNode{ + Name: "root-task", + Depth: 0, + }, + expected: 2 + 1 + 1 + 9, + }, + { + name: "Depth 1 task", + task: &tui.TaskNode{ + Name: "child-task", + Depth: 1, + }, + expected: 2 + 4 + 2 + 1 + 1 + 10, + }, + { + name: "Depth 2 task", + task: &tui.TaskNode{ + Name: "grandchild", + Depth: 2, + }, + expected: 4 + 4 + 2 + 1 + 1 + 10, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + width := tui.CalculateRowNameWidth(tt.task) + assert.Equal(t, tt.expected, width) + }) + } +} + +func TestCalculateMaxNameWidth(t *testing.T) { + tasks := []*tui.TaskNode{ + {Name: "short", Depth: 0, Term: tui.NewVterm()}, + {Name: "very-long-task-name", Depth: 0, Term: tui.NewVterm()}, + {Name: "child", Depth: 1, Term: tui.NewVterm()}, + } + + m := tui.Model{ + FlatList: tasks, + TreeRoots: tasks, + ListHeight: 10, + ViewMode: tui.ViewModeTree, + } + + maxWidth := m.CalculateMaxNameWidth(0, len(tasks)) + + shortWidth := tui.CalculateRowNameWidth(tasks[0]) + longWidth := tui.CalculateRowNameWidth(tasks[1]) + childWidth := tui.CalculateRowNameWidth(tasks[2]) + + expectedMax := longWidth + if childWidth > expectedMax { + expectedMax = childWidth + } + if shortWidth > expectedMax { + expectedMax = shortWidth + } + + assert.Equal(t, expectedMax, maxWidth) }