Merge "Support an action table that shows longest running actions"
diff --git a/ui/terminal/smart_status.go b/ui/terminal/smart_status.go
index 82c04d4..9638cdf 100644
--- a/ui/terminal/smart_status.go
+++ b/ui/terminal/smart_status.go
@@ -19,13 +19,22 @@
 	"io"
 	"os"
 	"os/signal"
+	"strconv"
 	"strings"
 	"sync"
 	"syscall"
+	"time"
 
 	"android/soong/ui/status"
 )
 
+const tableHeightEnVar = "SOONG_UI_TABLE_HEIGHT"
+
+type actionTableEntry struct {
+	action    *status.Action
+	startTime time.Time
+}
+
 type smartStatusOutput struct {
 	writer    io.Writer
 	formatter formatter
@@ -34,7 +43,14 @@
 
 	haveBlankLine bool
 
-	termWidth       int
+	tableMode             bool
+	tableHeight           int
+	requestedTableHeight  int
+	termWidth, termHeight int
+
+	runningActions  []actionTableEntry
+	ticker          *time.Ticker
+	done            chan bool
 	sigwinch        chan os.Signal
 	sigwinchHandled chan bool
 }
@@ -43,17 +59,41 @@
 // current build status similarly to Ninja's built-in terminal
 // output.
 func NewSmartStatusOutput(w io.Writer, formatter formatter) status.StatusOutput {
+	tableHeight, _ := strconv.Atoi(os.Getenv(tableHeightEnVar))
+
 	s := &smartStatusOutput{
 		writer:    w,
 		formatter: formatter,
 
 		haveBlankLine: true,
 
+		tableMode:            tableHeight > 0,
+		requestedTableHeight: tableHeight,
+
+		done:     make(chan bool),
 		sigwinch: make(chan os.Signal),
 	}
 
 	s.updateTermSize()
 
+	if s.tableMode {
+		// Add empty lines at the bottom of the screen to scroll back the existing history
+		// and make room for the action table.
+		// TODO: read the cursor position to see if the empty lines are necessary?
+		for i := 0; i < s.tableHeight; i++ {
+			fmt.Fprintln(w)
+		}
+
+		// Hide the cursor to prevent seeing it bouncing around
+		fmt.Fprintf(s.writer, ansi.hideCursor())
+
+		// Configure the empty action table
+		s.actionTable()
+
+		// Start a tick to update the action table periodically
+		s.startActionTableTick()
+	}
+
 	s.startSigwinch()
 
 	return s
@@ -77,6 +117,8 @@
 }
 
 func (s *smartStatusOutput) StartAction(action *status.Action, counts status.Counts) {
+	startTime := time.Now()
+
 	str := action.Description
 	if str == "" {
 		str = action.Command
@@ -87,6 +129,11 @@
 	s.lock.Lock()
 	defer s.lock.Unlock()
 
+	s.runningActions = append(s.runningActions, actionTableEntry{
+		action:    action,
+		startTime: startTime,
+	})
+
 	s.statusLine(progress + str)
 }
 
@@ -103,6 +150,13 @@
 	s.lock.Lock()
 	defer s.lock.Unlock()
 
+	for i, runningAction := range s.runningActions {
+		if runningAction.action == result.Action {
+			s.runningActions = append(s.runningActions[:i], s.runningActions[i+1:]...)
+			break
+		}
+	}
+
 	if output != "" {
 		s.statusLine(progress)
 		s.requestLine()
@@ -119,6 +173,23 @@
 	s.stopSigwinch()
 
 	s.requestLine()
+
+	s.runningActions = nil
+
+	if s.tableMode {
+		s.stopActionTableTick()
+
+		// Update the table after clearing runningActions to clear it
+		s.actionTable()
+
+		// Reset the scrolling region to the whole terminal
+		fmt.Fprintf(s.writer, ansi.resetScrollingMargins())
+		_, height, _ := termSize(s.writer)
+		// Move the cursor to the top of the now-blank, previously non-scrolling region
+		fmt.Fprintf(s.writer, ansi.setCursor(height-s.tableHeight, 0))
+		// Turn the cursor back on
+		fmt.Fprintf(s.writer, ansi.showCursor())
+	}
 }
 
 func (s *smartStatusOutput) Write(p []byte) (int, error) {
@@ -137,7 +208,7 @@
 
 func (s *smartStatusOutput) print(str string) {
 	if !s.haveBlankLine {
-		fmt.Fprint(s.writer, "\r", "\x1b[K")
+		fmt.Fprint(s.writer, "\r", ansi.clearToEndOfLine())
 		s.haveBlankLine = true
 	}
 	fmt.Fprint(s.writer, str)
@@ -160,8 +231,8 @@
 
 	// Move to the beginning on the line, turn on bold, print the output,
 	// turn off bold, then clear the rest of the line.
-	start := "\r\x1b[1m"
-	end := "\x1b[0m\x1b[K"
+	start := "\r" + ansi.bold()
+	end := ansi.regular() + ansi.clearToEndOfLine()
 	fmt.Fprint(s.writer, start, str, end)
 	s.haveBlankLine = false
 }
@@ -176,12 +247,36 @@
 	return str
 }
 
+func (s *smartStatusOutput) startActionTableTick() {
+	s.ticker = time.NewTicker(time.Second)
+	go func() {
+		for {
+			select {
+			case <-s.ticker.C:
+				s.lock.Lock()
+				s.actionTable()
+				s.lock.Unlock()
+			case <-s.done:
+				return
+			}
+		}
+	}()
+}
+
+func (s *smartStatusOutput) stopActionTableTick() {
+	s.ticker.Stop()
+	s.done <- true
+}
+
 func (s *smartStatusOutput) startSigwinch() {
 	signal.Notify(s.sigwinch, syscall.SIGWINCH)
 	go func() {
 		for _ = range s.sigwinch {
 			s.lock.Lock()
 			s.updateTermSize()
+			if s.tableMode {
+				s.actionTable()
+			}
 			s.lock.Unlock()
 			if s.sigwinchHandled != nil {
 				s.sigwinchHandled <- true
@@ -196,7 +291,122 @@
 }
 
 func (s *smartStatusOutput) updateTermSize() {
-	if w, ok := termWidth(s.writer); ok {
-		s.termWidth = w
+	if w, h, ok := termSize(s.writer); ok {
+		firstUpdate := s.termHeight == 0 && s.termWidth == 0
+		oldScrollingHeight := s.termHeight - s.tableHeight
+
+		s.termWidth, s.termHeight = w, h
+
+		if s.tableMode {
+			tableHeight := s.requestedTableHeight
+			if tableHeight > s.termHeight-1 {
+				tableHeight = s.termHeight - 1
+			}
+			s.tableHeight = tableHeight
+
+			scrollingHeight := s.termHeight - s.tableHeight
+
+			if !firstUpdate {
+				// If the scrolling region has changed, attempt to pan the existing text so that it is
+				// not overwritten by the table.
+				if scrollingHeight < oldScrollingHeight {
+					pan := oldScrollingHeight - scrollingHeight
+					if pan > s.tableHeight {
+						pan = s.tableHeight
+					}
+					fmt.Fprint(s.writer, ansi.panDown(pan))
+				}
+			}
+		}
 	}
 }
+
+func (s *smartStatusOutput) actionTable() {
+	scrollingHeight := s.termHeight - s.tableHeight
+
+	// Update the scrolling region in case the height of the terminal changed
+	fmt.Fprint(s.writer, ansi.setScrollingMargins(0, scrollingHeight))
+	// Move the cursor to the first line of the non-scrolling region
+	fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight+1, 0))
+
+	// Write as many status lines as fit in the table
+	var tableLine int
+	var runningAction actionTableEntry
+	for tableLine, runningAction = range s.runningActions {
+		if tableLine >= s.tableHeight {
+			break
+		}
+
+		seconds := int(time.Since(runningAction.startTime).Round(time.Second).Seconds())
+
+		desc := runningAction.action.Description
+		if desc == "" {
+			desc = runningAction.action.Command
+		}
+
+		str := fmt.Sprintf("   %2d:%02d %s", seconds/60, seconds%60, desc)
+		str = s.elide(str)
+		fmt.Fprint(s.writer, str, ansi.clearToEndOfLine())
+		if tableLine < s.tableHeight-1 {
+			fmt.Fprint(s.writer, "\n")
+		}
+	}
+
+	// Clear any remaining lines in the table
+	for ; tableLine < s.tableHeight; tableLine++ {
+		fmt.Fprint(s.writer, ansi.clearToEndOfLine())
+		if tableLine < s.tableHeight-1 {
+			fmt.Fprint(s.writer, "\n")
+		}
+	}
+
+	// Move the cursor back to the last line of the scrolling region
+	fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight, 0))
+}
+
+var ansi = ansiImpl{}
+
+type ansiImpl struct{}
+
+func (ansiImpl) clearToEndOfLine() string {
+	return "\x1b[K"
+}
+
+func (ansiImpl) setCursor(row, column int) string {
+	// Direct cursor address
+	return fmt.Sprintf("\x1b[%d;%dH", row, column)
+}
+
+func (ansiImpl) setScrollingMargins(top, bottom int) string {
+	// Set Top and Bottom Margins DECSTBM
+	return fmt.Sprintf("\x1b[%d;%dr", top, bottom)
+}
+
+func (ansiImpl) resetScrollingMargins() string {
+	// Set Top and Bottom Margins DECSTBM
+	return fmt.Sprintf("\x1b[r")
+}
+
+func (ansiImpl) bold() string {
+	return "\x1b[1m"
+}
+
+func (ansiImpl) regular() string {
+	return "\x1b[0m"
+}
+
+func (ansiImpl) showCursor() string {
+	return "\x1b[?25h"
+}
+
+func (ansiImpl) hideCursor() string {
+	return "\x1b[?25l"
+}
+
+func (ansiImpl) panDown(lines int) string {
+	return fmt.Sprintf("\x1b[%dS", lines)
+}
+
+func (ansiImpl) panUp(lines int) string {
+	return fmt.Sprintf("\x1b[%dT", lines)
+}
diff --git a/ui/terminal/status_test.go b/ui/terminal/status_test.go
index 106d651..81aa238 100644
--- a/ui/terminal/status_test.go
+++ b/ui/terminal/status_test.go
@@ -17,6 +17,7 @@
 import (
 	"bytes"
 	"fmt"
+	"os"
 	"syscall"
 	"testing"
 
@@ -86,8 +87,11 @@
 		},
 	}
 
+	os.Setenv(tableHeightEnVar, "")
+
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
+
 			t.Run("smart", func(t *testing.T) {
 				smart := &fakeSmartTerminal{termWidth: 40}
 				stat := NewStatusOutput(smart, "", false)
@@ -251,6 +255,8 @@
 }
 
 func TestSmartStatusOutputWidthChange(t *testing.T) {
+	os.Setenv(tableHeightEnVar, "")
+
 	smart := &fakeSmartTerminal{termWidth: 40}
 	stat := NewStatusOutput(smart, "", false)
 	smartStat := stat.(*smartStatusOutput)
diff --git a/ui/terminal/util.go b/ui/terminal/util.go
index 3a11b79..c9377f1 100644
--- a/ui/terminal/util.go
+++ b/ui/terminal/util.go
@@ -35,7 +35,7 @@
 	return false
 }
 
-func termWidth(w io.Writer) (int, bool) {
+func termSize(w io.Writer) (width int, height int, ok bool) {
 	if f, ok := w.(*os.File); ok {
 		var winsize struct {
 			ws_row, ws_column    uint16
@@ -44,11 +44,11 @@
 		_, _, err := syscall.Syscall6(syscall.SYS_IOCTL, f.Fd(),
 			syscall.TIOCGWINSZ, uintptr(unsafe.Pointer(&winsize)),
 			0, 0, 0)
-		return int(winsize.ws_column), err == 0
+		return int(winsize.ws_column), int(winsize.ws_row), err == 0
 	} else if f, ok := w.(*fakeSmartTerminal); ok {
-		return f.termWidth, true
+		return f.termWidth, f.termHeight, true
 	}
-	return 0, false
+	return 0, 0, false
 }
 
 // stripAnsiEscapes strips ANSI control codes from a byte array in place.
@@ -106,5 +106,5 @@
 
 type fakeSmartTerminal struct {
 	bytes.Buffer
-	termWidth int
+	termWidth, termHeight int
 }