From 27fb276f642717635980310cbd01f53a209e7897 Mon Sep 17 00:00:00 2001 From: Pat Gavlin Date: Mon, 31 Oct 2022 07:59:14 -0700 Subject: [PATCH 1/4] [cli] Reimplement the interactive renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The display pipleline looks like this: ╭──────╮ │Engine│ ╰──────╯ ⬇ engine events ╭────────────────╮ │Progress Display│ ╰────────────────╯ ⬇ display events: ticks, resource updates, system messages ╭─────────────────╮ │Progress Renderer│ ╰─────────────────╯ ⬇ text ╭────────╮ │Terminal│ ╰────────╯ The existing implementation of the interactive Progress Renderer is broken into two parts, the display renderer and the message renderer. The display renderer converts display events into progress messages, each of which generally represents a single line of text at a particular position in the output. The message renderer converts progress messages into screen updates by identifying whether or not the contents of a particular message have changed and if so, re-rendering its output line. In somewhat greater detail: ╭────────────────╮ │Display Renderer│ ╰────────────────╯ ⬇ convert resource rows into a tree table ⬇ convert the tree table and system messages into lines ⬇ convert each line into a progress message with an index ╭────────────────╮ │Message Renderer│ ╰────────────────╯ ⬇ if the line identified in a progress message has changed, ⬇ go to that line on the terminal, clear it, and update it ╭────────╮ │Terminal│ ╰────────╯ This separation of concerns is unnecessary and makes it difficult to understand where and when the terminal is updated. This approach also makes it somewhat challenging to change the way in which the display interacts with the terminal, as both the display renderer and the message renderer need to e.g. understand terminal dimensions, movement, etc. These changes reimplement the interactive Progress Renderer using a frame-oriented approach. The display is updated at 60 frame per second. If nothing has happened to invalidate the display's contents (i.e. no changes to the terminal geometry or the displayable contents have occurred), then the frame is not redrawn. Otherwise, the contents of the display are re-rendered and redrawn. An advantage of this approach is that it made it relatively simple to fix a long-standing issue with the interactive display: when the number of rows in the output exceed the height of the terminal, the new renderer clamps the output and allows the user to scroll the tree table using the up and down arrow keys. --- ...ay-by-making-the-treetable-scrollable.yaml | 4 + pkg/backend/display/jsonmessage.go | 38 +- pkg/backend/display/options.go | 1 + pkg/backend/display/progress.go | 78 +--- pkg/backend/display/sigint_unix.go | 28 ++ pkg/backend/display/sigint_windows.go | 44 ++ pkg/backend/display/tree.go | 415 ++++++++++++++++++ pkg/go.mod | 3 +- tests/go.mod | 3 +- tests/go.sum | 6 +- 10 files changed, 545 insertions(+), 75 deletions(-) create mode 100644 changelog/pending/20221101--cli-display--improve-the-usability-of-the-interactive-dipslay-by-making-the-treetable-scrollable.yaml create mode 100644 pkg/backend/display/sigint_unix.go create mode 100644 pkg/backend/display/sigint_windows.go create mode 100644 pkg/backend/display/tree.go diff --git a/changelog/pending/20221101--cli-display--improve-the-usability-of-the-interactive-dipslay-by-making-the-treetable-scrollable.yaml b/changelog/pending/20221101--cli-display--improve-the-usability-of-the-interactive-dipslay-by-making-the-treetable-scrollable.yaml new file mode 100644 index 000000000000..39a750741c28 --- /dev/null +++ b/changelog/pending/20221101--cli-display--improve-the-usability-of-the-interactive-dipslay-by-making-the-treetable-scrollable.yaml @@ -0,0 +1,4 @@ +changes: +- type: feat + scope: cli/display + description: Improve the usability of the interactive dipslay by making the treetable scrollable diff --git a/pkg/backend/display/jsonmessage.go b/pkg/backend/display/jsonmessage.go index b351c907a641..09f80480976b 100644 --- a/pkg/backend/display/jsonmessage.go +++ b/pkg/backend/display/jsonmessage.go @@ -140,14 +140,37 @@ type messageRenderer struct { nonInteractiveSpinner cmdutil.Spinner progressOutput chan<- Progress + closed <-chan bool // Cache of lines we've already printed. We don't print a progress message again if it hasn't // changed between the last time we printed and now. printedProgressCache map[string]Progress } +func newMessageRenderer(stdout io.Writer, op string, opts Options) progressRenderer { + progressOutput, closed := make(chan Progress), make(chan bool) + go func() { + ShowProgressOutput(progressOutput, stdout, false) + close(closed) + }() + + spinner, ticker := cmdutil.NewSpinnerAndTicker( + fmt.Sprintf("%s%s...", cmdutil.EmojiOr("✨ ", "@ "), op), + nil, opts.Color, 1 /*timesPerSecond*/) + ticker.Stop() + + return &messageRenderer{ + opts: opts, + progressOutput: progressOutput, + closed: closed, + printedProgressCache: make(map[string]Progress), + nonInteractiveSpinner: spinner, + } +} + func (r *messageRenderer) Close() error { close(r.progressOutput) + <-r.closed return nil } @@ -196,7 +219,7 @@ func (r *messageRenderer) println(display *ProgressDisplay, line string) { func (r *messageRenderer) tick(display *ProgressDisplay) { if r.isTerminal { - r.render(display) + r.render(display, false) } else { // Update the spinner to let the user know that that work is still happening. r.nonInteractiveSpinner.Tick() @@ -233,7 +256,7 @@ func (r *messageRenderer) renderRow(display *ProgressDisplay, func (r *messageRenderer) rowUpdated(display *ProgressDisplay, row Row) { if r.isTerminal { // if we're in a terminal, then refresh everything so that all our columns line up - r.render(display) + r.render(display, false) } else { // otherwise, just print out this single row. colorizedColumns := row.ColorizedColumns() @@ -246,7 +269,7 @@ func (r *messageRenderer) systemMessage(display *ProgressDisplay, payload engine if r.isTerminal { // if we're in a terminal, then refresh everything. The system events will come after // all the normal rows - r.render(display) + r.render(display, false) } else { // otherwise, in a non-terminal, just print out the actual event. r.writeSimpleMessage(renderStdoutColorEvent(payload, display.opts)) @@ -254,9 +277,12 @@ func (r *messageRenderer) systemMessage(display *ProgressDisplay, payload engine } func (r *messageRenderer) done(display *ProgressDisplay) { + if r.isTerminal { + r.render(display, false) + } } -func (r *messageRenderer) render(display *ProgressDisplay) { +func (r *messageRenderer) render(display *ProgressDisplay, done bool) { if !r.isTerminal || display.headerRow == nil { return } @@ -316,6 +342,10 @@ func (r *messageRenderer) render(display *ProgressDisplay) { systemID++ } } + + if done { + r.println(display, "") + } } // Ensure our stored dimension info is up to date. diff --git a/pkg/backend/display/options.go b/pkg/backend/display/options.go index bea8c466eade..2221675d8c1e 100644 --- a/pkg/backend/display/options.go +++ b/pkg/backend/display/options.go @@ -50,6 +50,7 @@ type Options struct { JSONDisplay bool // true if we should emit the entire diff as JSON. EventLogPath string // the path to the file to use for logging events, if any. Debug bool // true to enable debug output. + Stdin io.Reader // the reader to use for stdin. Defaults to os.Stdin if unset. Stdout io.Writer // the writer to use for stdout. Defaults to os.Stdout if unset. Stderr io.Writer // the writer to use for stderr. Defaults to os.Stderr if unset. SuppressTimings bool // true to suppress displaying timings of resource actions diff --git a/pkg/backend/display/progress.go b/pkg/backend/display/progress.go index 0c5d22d1d661..c3f452e7cb9d 100644 --- a/pkg/backend/display/progress.go +++ b/pkg/backend/display/progress.go @@ -19,7 +19,6 @@ import ( "bytes" "fmt" "io" - "math" "os" "sort" "strings" @@ -27,9 +26,6 @@ import ( "unicode" "unicode/utf8" - "github.com/moby/term" - "golang.org/x/crypto/ssh/terminal" - "github.com/pulumi/pulumi/pkg/v3/engine" "github.com/pulumi/pulumi/pkg/v3/resource/deploy" "github.com/pulumi/pulumi/sdk/v3/go/common/apitype" @@ -228,45 +224,26 @@ func getEventUrnAndMetadata(event engine.Event) (resource.URN, *engine.StepEvent func ShowProgressEvents(op string, action apitype.UpdateKind, stack tokens.Name, proj tokens.PackageName, events <-chan engine.Event, done chan<- bool, opts Options, isPreview bool) { + stdin := opts.Stdin + if stdin == nil { + stdin = os.Stdin + } stdout := opts.Stdout if stdout == nil { stdout = os.Stdout } - stderr := opts.Stderr - if stderr == nil { - stderr = os.Stderr - } - - // Create a ticker that will update all our status messages once a second. Any - // in-flight resources will get a varying . .. ... ticker appended to them to - // let the user know what is still being worked on. - var spinner cmdutil.Spinner - var ticker *time.Ticker - if stdout == os.Stdout && stderr == os.Stderr { - spinner, ticker = cmdutil.NewSpinnerAndTicker( - fmt.Sprintf("%s%s...", cmdutil.EmojiOr("✨ ", "@ "), op), - nil, opts.Color, 1 /*timesPerSecond*/) - } else { - spinner = &nopSpinner{} - ticker = time.NewTicker(math.MaxInt64) - } - - // The channel we push progress messages into, and which ShowProgressOutput pulls - // from to display to the console. - progressOutput := make(chan Progress) - - opStopwatch := newOpStopwatch() - renderer := &messageRenderer{ - opts: opts, - progressOutput: progressOutput, - printedProgressCache: make(map[string]Progress), - nonInteractiveSpinner: spinner, + isTerminal := true + renderer, err := newTreeRenderer(stdin, stdout, opts) + if err != nil { + fmt.Println(err) + isTerminal, renderer = false, newMessageRenderer(stdout, op, opts) } display := &ProgressDisplay{ action: action, isPreview: isPreview, + isTerminal: isTerminal, opts: opts, renderer: renderer, stack: stack, @@ -277,39 +254,12 @@ func ShowProgressEvents(op string, action apitype.UpdateKind, stack tokens.Name, urnToID: make(map[resource.URN]string), colorizedToUncolorized: make(map[string]string), displayOrderCounter: 1, - opStopwatch: opStopwatch, + opStopwatch: newOpStopwatch(), } - // Assume we are not displaying in a terminal by default. - renderer.isTerminal = false - if stdout == os.Stdout { - terminalWidth, terminalHeight, err := terminal.GetSize(int(os.Stdout.Fd())) - if err == nil { - // If the terminal has a size, use it. - renderer.isTerminal = opts.IsInteractive - renderer.terminalWidth = terminalWidth - renderer.terminalHeight = terminalHeight - - // Don't bother attempting to treat this display as a terminal if it has no width/height. - if renderer.isTerminal && (renderer.terminalWidth == 0 || renderer.terminalHeight == 0) { - renderer.isTerminal = false - _, err = fmt.Fprintln(stderr, "Treating display as non-terminal due to 0 width/height.") - contract.IgnoreError(err) - } - - // Fetch the canonical stdout stream, configured appropriately. - _, stdout, _ = term.StdStreams() - } - } - display.isTerminal = renderer.isTerminal - - go func() { - display.processEvents(ticker, events) - contract.IgnoreClose(display.renderer) - }() - - ShowProgressOutput(progressOutput, stdout, display.isTerminal) - + ticker := time.NewTicker(1 * time.Second) + display.processEvents(ticker, events) + contract.IgnoreClose(display.renderer) ticker.Stop() // let our caller know we're done. diff --git a/pkg/backend/display/sigint_unix.go b/pkg/backend/display/sigint_unix.go new file mode 100644 index 000000000000..ab005d7557a3 --- /dev/null +++ b/pkg/backend/display/sigint_unix.go @@ -0,0 +1,28 @@ +// Copyright 2016-2022, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows + +package display + +import ( + "syscall" + + "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" +) + +func sigint() { + err := syscall.Kill(syscall.Getpid(), syscall.SIGINT) + contract.IgnoreError(err) +} diff --git a/pkg/backend/display/sigint_windows.go b/pkg/backend/display/sigint_windows.go new file mode 100644 index 000000000000..e3cb92eb3bbb --- /dev/null +++ b/pkg/backend/display/sigint_windows.go @@ -0,0 +1,44 @@ +// Copyright 2016-2022, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package display + +import ( + "fmt" + "os" + "syscall" + + "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" +) + +var generateConsoleCtrlEvent *syscall.Proc + +func init() { + dll, err := syscall.LoadDLL("kernel32.dll") + if err != nil { + panic(fmt.Errorf("loading kernel32.dll: %w", err)) + } + proc, err := dll.FindProc("GenerateConsoleCtrlEvent") + if err != nil { + panic(fmt.Errorf("finding GenerateConsoleCtrlEvent: %w", err)) + } + generateConsoleCtrlEvent = proc +} + +func sigint() { + _, _, err := generateConsoleCtrlEvent.Call(syscall.CTRL_BREAK_EVENT, uintptr(os.Getpid())) + contract.IgnoreError(err) +} diff --git a/pkg/backend/display/tree.go b/pkg/backend/display/tree.go new file mode 100644 index 000000000000..eb81b91fd02b --- /dev/null +++ b/pkg/backend/display/tree.go @@ -0,0 +1,415 @@ +// Copyright 2016-2022, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// nolint: goconst +package display + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + "sync" + "time" + "unicode/utf8" + + terminal "golang.org/x/term" + + gotty "github.com/ijc/Gotty" + "github.com/muesli/cancelreader" + "github.com/pulumi/pulumi/pkg/v3/engine" + "github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors" + "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" +) + +type treeRenderer struct { + m sync.Mutex + + opts Options + + // The file descriptor, width, and height of the terminal. Used so we can trim resource messages that are too long. + termFD int + termInfo termInfo + termWidth int + termHeight int + termState *terminal.State + term io.Writer + + inFile cancelreader.CancelReader + + dirty bool // True if the display has changed since the last redraw. + rewind int // The number of lines we need to rewind to redraw the entire screen. + + treeTableRows []string + systemMessages []string + + ticker *time.Ticker + keys chan string + closed chan bool + + treeTableOffset int // The scroll offset into the tree table. + maxTreeTableOffset int // The maximum scroll offset. +} + +type fileLike interface { + Fd() uintptr +} + +func newTreeRenderer(in io.Reader, out io.Writer, opts Options) (progressRenderer, error) { + if !opts.IsInteractive { + return nil, fmt.Errorf("the tree display can only be used in interactive mode") + } + + outFile, ok := out.(fileLike) + if !ok { + return nil, fmt.Errorf("stdout must be a terminal") + } + outFD := int(outFile.Fd()) + + inFile, err := cancelreader.NewReader(in) + if err != nil { + return nil, fmt.Errorf("preparing stdin: %w", err) + } + + width, height, err := terminal.GetSize(outFD) + if err != nil { + return nil, fmt.Errorf("getting terminal dimensions: %w", err) + } + if width == 0 || height == 0 { + return nil, fmt.Errorf("terminal has unusable dimensions %v x %v", width, height) + } + + termType := os.Getenv("TERM") + if termType == "" { + termType = "vt102" + } + var info termInfo + if info, err = gotty.OpenTermInfo(termType); err != nil { + info = &noTermInfo{} + } + + state, err := terminal.MakeRaw(outFD) + if err != nil { + return nil, fmt.Errorf("enabling raw terminal: %w", err) + } + + r := &treeRenderer{ + opts: opts, + termFD: outFD, + termInfo: info, + termWidth: width, + termHeight: height, + termState: state, + term: out, + inFile: inFile, + ticker: time.NewTicker(16 * time.Millisecond), + keys: make(chan string), + closed: make(chan bool), + } + go r.handleEvents() + go r.pollInput() + return r, nil +} + +func (r *treeRenderer) Close() error { + r.inFile.Cancel() + return terminal.Restore(r.termFD, r.termState) +} + +func (r *treeRenderer) tick(display *ProgressDisplay) { + r.render(display) +} + +func (r *treeRenderer) rowUpdated(display *ProgressDisplay, _ Row) { + r.render(display) +} + +func (r *treeRenderer) systemMessage(display *ProgressDisplay, _ engine.StdoutEventPayload) { + r.render(display) +} + +func (r *treeRenderer) done(display *ProgressDisplay) { + r.render(display) + + r.ticker.Stop() + r.closed <- true + close(r.closed) + + r.frame(true) +} + +func (r *treeRenderer) println(display *ProgressDisplay, text string) { + _, err := fmt.Fprint(r.term, r.opts.Color.Colorize(strings.ReplaceAll(text, "\n", "\r\n"))) + contract.IgnoreError(err) + _, err = fmt.Fprint(r.term, "\r\n") + contract.IgnoreError(err) +} + +func (r *treeRenderer) render(display *ProgressDisplay) { + r.m.Lock() + defer r.m.Unlock() + + if display.headerRow == nil { + return + } + + // Render the resource tree table into rows. + rootNodes := display.generateTreeNodes() + rootNodes = display.filterOutUnnecessaryNodesAndSetDisplayTimes(rootNodes) + sortNodes(rootNodes) + display.addIndentations(rootNodes, true /*isRoot*/, "") + + maxSuffixLength := 0 + for _, v := range display.suffixesArray { + runeCount := utf8.RuneCountInString(v) + if runeCount > maxSuffixLength { + maxSuffixLength = runeCount + } + } + + var treeTableRows [][]string + var maxColumnLengths []int + display.convertNodesToRows(rootNodes, maxSuffixLength, &treeTableRows, &maxColumnLengths) + removeInfoColumnIfUnneeded(treeTableRows) + + r.treeTableRows = r.treeTableRows[:0] + for _, row := range treeTableRows { + r.treeTableRows = append(r.treeTableRows, r.renderRow(display, row, maxColumnLengths)) + } + + // Convert system events into lines. + r.systemMessages = r.systemMessages[:0] + for _, payload := range display.systemEventPayloads { + msg := payload.Color.Colorize(payload.Message) + r.systemMessages = append(r.systemMessages, splitIntoDisplayableLines(msg)...) + } + + r.dirty = true +} + +func (r *treeRenderer) markDirty() { + r.m.Lock() + defer r.m.Unlock() + + r.dirty = true +} + +// +--------------------------------------------+ +// | treetable header | +// | treetable contents... | +// +--------------------------------------------+ +func (r *treeRenderer) frame(done bool) { + r.m.Lock() + defer r.m.Unlock() + + if !done && !r.dirty { + return + } + r.dirty = false + + // Make sure our stored dimension info is up to date + r.updateTerminalDimensions() + + treeTableRows := r.treeTableRows + systemMessages := r.systemMessages + + var treeTableHeight int + var treeTableHeader string + if len(r.treeTableRows) > 0 { + treeTableHeader, treeTableRows = treeTableRows[0], treeTableRows[1:] + treeTableHeight = 1 + len(treeTableRows) + } + + systemMessagesHeight := len(systemMessages) + if len(systemMessages) > 0 { + systemMessagesHeight += 3 // Account for padding + title + } + + // Layout the display. The extra '1' accounts for the fact that we terminate each line with a newline. + totalHeight := treeTableHeight + systemMessagesHeight + 1 + r.maxTreeTableOffset = 0 + + // If this is not the final frame and the terminal is not large enough to show the entire display: + // - If there are no system messages, devote the entire display to the tree table + // - If there are system messages, devote the first two thirds of the display to the tree table and the + // last third to the system messages + if !done && totalHeight >= r.termHeight { + if systemMessagesHeight > 0 { + systemMessagesHeight = r.termHeight / 3 + if systemMessagesHeight <= 3 { + systemMessagesHeight = 0 + } else { + systemMessagesContentHeight := systemMessagesHeight - 3 + if len(systemMessages) > systemMessagesContentHeight { + systemMessages = systemMessages[len(systemMessages)-systemMessagesContentHeight:] + } + } + } + + treeTableHeight = r.termHeight - systemMessagesHeight - 1 + r.maxTreeTableOffset = len(treeTableRows) - treeTableHeight - 1 + + treeTableRows = treeTableRows[r.treeTableOffset : r.treeTableOffset+treeTableHeight-1] + + totalHeight = treeTableHeight + systemMessagesHeight + 1 + } + + // Re-home the cursor. + for ; r.rewind > 0; r.rewind-- { + cursorUp(r.term, r.termInfo, 1) + clearLine(r.term, r.termInfo) + } + r.rewind = totalHeight - 1 + + // Render the tree table. + r.println(nil, treeTableHeader) + for _, row := range treeTableRows { + r.println(nil, row) + } + + // Render the system messages. + if systemMessagesHeight > 0 { + r.println(nil, "") + r.println(nil, colors.Yellow+"System Messages"+colors.Reset) + + for _, line := range systemMessages { + r.println(nil, " "+line) + } + } + + if done && totalHeight > 0 { + r.println(nil, "") + } +} + +func (r *treeRenderer) renderRow(display *ProgressDisplay, colorizedColumns []string, maxColumnLengths []int) string { + uncolorizedColumns := display.uncolorizeColumns(colorizedColumns) + row := renderRow(colorizedColumns, uncolorizedColumns, maxColumnLengths) + + // Ensure we don't go past the end of the terminal. Note: this is made complex due to + // msgWithColors having the color code information embedded with it. So we need to get + // the right substring of it, assuming that embedded colors are just markup and do not + // actually contribute to the length + maxRowLength := r.termWidth - 1 + if maxRowLength < 0 { + maxRowLength = 0 + } + return colors.TrimColorizedString(row, maxRowLength) +} + +// Ensure our stored dimension info is up to date. +func (r *treeRenderer) updateTerminalDimensions() { + currentTermWidth, currentTermHeight, err := terminal.GetSize(r.termFD) + contract.IgnoreError(err) + + if currentTermWidth != r.termWidth || + currentTermHeight != r.termHeight { + r.termWidth = currentTermWidth + r.termHeight = currentTermHeight + } +} + +func (r *treeRenderer) handleEvents() { + for { + select { + case <-r.ticker.C: + r.frame(false) + case key := <-r.keys: + switch key { + case "ctrl+c": + sigint() + case "up": + if r.treeTableOffset > 0 { + r.treeTableOffset-- + } + r.markDirty() + case "down": + if r.treeTableOffset < r.maxTreeTableOffset { + r.treeTableOffset++ + } + r.markDirty() + } + case <-r.closed: + return + } + } +} + +func (r *treeRenderer) pollInput() { + for { + key, err := readKey(r.inFile) + if err == nil { + r.keys <- key + } else if errors.Is(err, cancelreader.ErrCanceled) || errors.Is(err, io.EOF) { + close(r.keys) + return + } + } +} + +func readKey(r io.Reader) (string, error) { + type stateFunc func(b byte) (stateFunc, string) + + var stateIntermediate stateFunc + stateIntermediate = func(b byte) (stateFunc, string) { + if b >= 0x20 && b < 0x30 { + return stateIntermediate, "" + } + switch b { + case 'A': + return nil, "up" + case 'B': + return nil, "down" + default: + return nil, "" + } + } + var stateParameter stateFunc + stateParameter = func(b byte) (stateFunc, string) { + if b >= 0x30 && b < 0x40 { + return stateParameter, "" + } + return stateIntermediate(b) + } + stateBracket := func(b byte) (stateFunc, string) { + if b == '[' { + return stateParameter, "" + } + return nil, "" + } + stateEscape := func(b byte) (stateFunc, string) { + if b == 0x1b { + return stateBracket, "" + } + if b == 3 { + return nil, "ctrl+c" + } + return nil, string([]byte{b}) + } + + state := stateEscape + for { + var b [1]byte + if _, err := r.Read(b[:]); err != nil { + return "", err + } + + next, key := state(b[0]) + if next == nil { + return key, nil + } + state = next + } +} diff --git a/pkg/go.mod b/pkg/go.mod index e2e947c00b2c..0406bd626a8d 100644 --- a/pkg/go.mod +++ b/pkg/go.mod @@ -65,11 +65,13 @@ require ( github.com/edsrzf/mmap-go v1.1.0 github.com/go-git/go-git/v5 v5.4.2 github.com/hexops/gotextdiff v1.0.3 + github.com/muesli/cancelreader v0.2.2 github.com/natefinch/atomic v1.0.1 github.com/pulumi/pulumi-java/pkg v0.6.0 github.com/pulumi/pulumi-yaml v1.0.1 github.com/segmentio/encoding v0.3.5 github.com/shirou/gopsutil/v3 v3.22.3 + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 ) require ( @@ -195,7 +197,6 @@ require ( go.opencensus.io v0.23.0 // indirect go.uber.org/atomic v1.9.0 // indirect golang.org/x/sys v0.0.0-20220823224334-20c2bfdbfe24 // indirect - golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect diff --git a/tests/go.mod b/tests/go.mod index 23132e87f484..6be8a717beba 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -30,7 +30,6 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.0.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v0.4.1 // indirect - github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest v0.11.28 // indirect github.com/Azure/go-autorest/autorest/adal v0.9.21 // indirect @@ -124,7 +123,7 @@ require ( github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/mapstructure v1.4.3 // indirect github.com/mitchellh/reflectwalk v1.0.0 // indirect - github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/natefinch/atomic v1.0.1 // indirect github.com/opentracing/basictracer-go v1.1.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect diff --git a/tests/go.sum b/tests/go.sum index c0f9760bf747..cc6b1e6ad7be 100644 --- a/tests/go.sum +++ b/tests/go.sum @@ -134,7 +134,6 @@ github.com/Azure/go-amqp v0.17.0/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fw github.com/Azure/go-amqp v0.17.5/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fwmw9Zlg= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/Azure/go-ansiterm v0.0.0-20210608223527-2377c96fe795/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= -github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= @@ -1252,7 +1251,6 @@ github.com/moby/sys/symlink v0.1.0/go.mod h1:GGDODQmbFOjFsXvfLVn3+ZRxkch54RkSiGq github.com/moby/sys/symlink v0.2.0/go.mod h1:7uZVF2dqJjG/NsClqul95CqKOBRQyYSNnJ6BMgR/gFs= github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo= github.com/moby/term v0.0.0-20210610120745-9d4ed1856297/go.mod h1:vgPCkQMyxTZ7IDy8SXRufE172gr8+K/JE/7hHFxHW3A= -github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc= github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -1264,6 +1262,8 @@ github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJ github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -2489,10 +2489,8 @@ gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= -gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= From ecb88dae147501343798099cc1c1f3a85aefef72 Mon Sep 17 00:00:00 2001 From: Pat Gavlin Date: Mon, 7 Nov 2022 18:50:08 -0800 Subject: [PATCH 2/4] fall back to the message renderer on Windows --- pkg/backend/display/jsonmessage.go | 41 +++++++++++++++++------------- pkg/backend/display/progress.go | 5 ++-- pkg/backend/display/tree.go | 19 +++++++++----- 3 files changed, 38 insertions(+), 27 deletions(-) diff --git a/pkg/backend/display/jsonmessage.go b/pkg/backend/display/jsonmessage.go index 09f80480976b..d917edcdea51 100644 --- a/pkg/backend/display/jsonmessage.go +++ b/pkg/backend/display/jsonmessage.go @@ -23,7 +23,6 @@ import ( "os" "unicode/utf8" - gotty "github.com/ijc/Gotty" "golang.org/x/crypto/ssh/terminal" "github.com/pulumi/pulumi/pkg/v3/engine" @@ -147,10 +146,30 @@ type messageRenderer struct { printedProgressCache map[string]Progress } -func newMessageRenderer(stdout io.Writer, op string, opts Options) progressRenderer { +func newInteractiveMessageRenderer(stdout io.Writer, opts Options, + terminalWidth, terminalHeight int, termInfo termInfo) progressRenderer { + + progressOutput, closed := make(chan Progress), make(chan bool) + go func() { + ShowProgressOutput(progressOutput, stdout, termInfo) + close(closed) + }() + + return &messageRenderer{ + opts: opts, + isTerminal: true, + terminalWidth: terminalWidth, + terminalHeight: terminalHeight, + progressOutput: progressOutput, + closed: closed, + printedProgressCache: make(map[string]Progress), + } +} + +func newNonInteractiveRenderer(stdout io.Writer, op string, opts Options) progressRenderer { progressOutput, closed := make(chan Progress), make(chan bool) go func() { - ShowProgressOutput(progressOutput, stdout, false) + ShowProgressOutput(progressOutput, stdout, nil) close(closed) }() @@ -366,25 +385,11 @@ func (r *messageRenderer) updateTerminalDimensions() { // ShowProgressOutput displays a progress stream from `in` to `out`, `isTerminal` describes if // `out` is a terminal. If this is the case, it will print `\n` at the end of each line and move the // cursor while displaying. -func ShowProgressOutput(in <-chan Progress, out io.Writer, isTerminal bool) { +func ShowProgressOutput(in <-chan Progress, out io.Writer, info termInfo) { var ( ids = make(map[string]int) ) - var info termInfo - - if isTerminal { - term := os.Getenv("TERM") - if term == "" { - term = "vt102" - } - - var err error - if info, err = gotty.OpenTermInfo(term); err != nil { - info = &noTermInfo{} - } - } - for jm := range in { diff := 0 diff --git a/pkg/backend/display/progress.go b/pkg/backend/display/progress.go index c3f452e7cb9d..580025ddb614 100644 --- a/pkg/backend/display/progress.go +++ b/pkg/backend/display/progress.go @@ -234,10 +234,10 @@ func ShowProgressEvents(op string, action apitype.UpdateKind, stack tokens.Name, } isTerminal := true - renderer, err := newTreeRenderer(stdin, stdout, opts) + renderer, err := newInteractiveRenderer(stdin, stdout, opts) if err != nil { fmt.Println(err) - isTerminal, renderer = false, newMessageRenderer(stdout, op, opts) + isTerminal, renderer = false, newNonInteractiveRenderer(stdout, op, opts) } display := &ProgressDisplay{ @@ -360,7 +360,6 @@ func (display *ProgressDisplay) generateTreeNodes() []*treeNode { } func (display *ProgressDisplay) addIndentations(treeNodes []*treeNode, isRoot bool, indentation string) { - childIndentation := indentation + "│ " lastChildIndentation := indentation + " " diff --git a/pkg/backend/display/tree.go b/pkg/backend/display/tree.go index eb81b91fd02b..b78ca88272a4 100644 --- a/pkg/backend/display/tree.go +++ b/pkg/backend/display/tree.go @@ -20,6 +20,7 @@ import ( "fmt" "io" "os" + "runtime" "strings" "sync" "time" @@ -67,7 +68,7 @@ type fileLike interface { Fd() uintptr } -func newTreeRenderer(in io.Reader, out io.Writer, opts Options) (progressRenderer, error) { +func newInteractiveRenderer(in io.Reader, out io.Writer, opts Options) (progressRenderer, error) { if !opts.IsInteractive { return nil, fmt.Errorf("the tree display can only be used in interactive mode") } @@ -78,11 +79,6 @@ func newTreeRenderer(in io.Reader, out io.Writer, opts Options) (progressRendere } outFD := int(outFile.Fd()) - inFile, err := cancelreader.NewReader(in) - if err != nil { - return nil, fmt.Errorf("preparing stdin: %w", err) - } - width, height, err := terminal.GetSize(outFD) if err != nil { return nil, fmt.Errorf("getting terminal dimensions: %w", err) @@ -100,6 +96,17 @@ func newTreeRenderer(in io.Reader, out io.Writer, opts Options) (progressRendere info = &noTermInfo{} } + // Something about the tree renderer--possibly the raw terminal--does not yet play well with Windows, so for now + // we fall back to the legacy renderer on that platform. + if runtime.GOOS == "windows" { + return newInteractiveMessageRenderer(out, opts, width, height, info), nil + } + + inFile, err := cancelreader.NewReader(in) + if err != nil { + return nil, fmt.Errorf("preparing stdin: %w", err) + } + state, err := terminal.MakeRaw(outFD) if err != nil { return nil, fmt.Errorf("enabling raw terminal: %w", err) From 7d17bbaadabd43310163053277c7f7068b3ef13d Mon Sep 17 00:00:00 2001 From: Pat Gavlin Date: Mon, 7 Nov 2022 22:02:19 -0800 Subject: [PATCH 3/4] make tidy --- pkg/go.sum | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/go.sum b/pkg/go.sum index d3954a4807e9..f83c5765f1c4 100644 --- a/pkg/go.sum +++ b/pkg/go.sum @@ -1279,6 +1279,8 @@ github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJ github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= From 0e8d3e4577425cea02107614f40053b4ad2dc081 Mon Sep 17 00:00:00 2001 From: Pat Gavlin Date: Tue, 8 Nov 2022 08:56:01 -0800 Subject: [PATCH 4/4] write a status line for scrollable content --- pkg/backend/display/tree.go | 30 +++++++++++++++++++++++++++--- pkg/go.mod | 2 +- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/pkg/backend/display/tree.go b/pkg/backend/display/tree.go index b78ca88272a4..599bae4dd10a 100644 --- a/pkg/backend/display/tree.go +++ b/pkg/backend/display/tree.go @@ -33,6 +33,7 @@ import ( "github.com/pulumi/pulumi/pkg/v3/engine" "github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors" "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" + "github.com/rivo/uniseg" ) type treeRenderer struct { @@ -157,11 +158,14 @@ func (r *treeRenderer) done(display *ProgressDisplay) { r.frame(true) } -func (r *treeRenderer) println(display *ProgressDisplay, text string) { +func (r *treeRenderer) print(text string) { _, err := fmt.Fprint(r.term, r.opts.Color.Colorize(strings.ReplaceAll(text, "\n", "\r\n"))) contract.IgnoreError(err) - _, err = fmt.Fprint(r.term, "\r\n") - contract.IgnoreError(err) +} + +func (r *treeRenderer) println(display *ProgressDisplay, text string) { + r.print(text) + r.print("\n") } func (r *treeRenderer) render(display *ProgressDisplay) { @@ -216,6 +220,9 @@ func (r *treeRenderer) markDirty() { // +--------------------------------------------+ // | treetable header | // | treetable contents... | +// | treetable footer | +// | system messages header | +// | system messages contents... | // +--------------------------------------------+ func (r *treeRenderer) frame(done bool) { r.m.Lock() @@ -252,6 +259,7 @@ func (r *treeRenderer) frame(done bool) { // - If there are no system messages, devote the entire display to the tree table // - If there are system messages, devote the first two thirds of the display to the tree table and the // last third to the system messages + var treeTableFooter string if !done && totalHeight >= r.termHeight { if systemMessagesHeight > 0 { systemMessagesHeight = r.termHeight / 3 @@ -271,9 +279,22 @@ func (r *treeRenderer) frame(done bool) { treeTableRows = treeTableRows[r.treeTableOffset : r.treeTableOffset+treeTableHeight-1] totalHeight = treeTableHeight + systemMessagesHeight + 1 + + upArrow := " " + if r.treeTableOffset != 0 { + upArrow = "⬆ " + } + downArrow := " " + if r.treeTableOffset != r.maxTreeTableOffset { + downArrow = "⬇ " + } + footer := fmt.Sprintf("%smore%s", upArrow, downArrow) + padding := r.termWidth - uniseg.GraphemeClusterCount(footer) + treeTableFooter = strings.Repeat(" ", padding) + footer } // Re-home the cursor. + clearLine(r.term, r.termInfo) for ; r.rewind > 0; r.rewind-- { cursorUp(r.term, r.termInfo, 1) clearLine(r.term, r.termInfo) @@ -285,6 +306,9 @@ func (r *treeRenderer) frame(done bool) { for _, row := range treeTableRows { r.println(nil, row) } + if treeTableFooter != "" { + r.print(treeTableFooter) + } // Render the system messages. if systemMessagesHeight > 0 { diff --git a/pkg/go.mod b/pkg/go.mod index 0406bd626a8d..f456324450a9 100644 --- a/pkg/go.mod +++ b/pkg/go.mod @@ -69,6 +69,7 @@ require ( github.com/natefinch/atomic v1.0.1 github.com/pulumi/pulumi-java/pkg v0.6.0 github.com/pulumi/pulumi-yaml v1.0.1 + github.com/rivo/uniseg v0.2.0 github.com/segmentio/encoding v0.3.5 github.com/shirou/gopsutil/v3 v3.22.3 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 @@ -176,7 +177,6 @@ require ( github.com/pkg/term v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect - github.com/rivo/uniseg v0.2.0 // indirect github.com/rogpeppe/go-internal v1.8.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect