github.com/DaAlbrecht/cf-cli@v0.0.0-20231128151943-1fe19bb400b9/util/ui/ui.go (about) 1 // Package ui will provide hooks into STDOUT, STDERR and STDIN. It will also 2 // handle translation as necessary. 3 // 4 // This package is explicitly designed for the CF CLI and is *not* to be used 5 // by any package outside of the commands package. 6 package ui 7 8 import ( 9 "bytes" 10 "encoding/json" 11 "fmt" 12 "io" 13 "os" 14 "strings" 15 "sync" 16 "time" 17 18 "code.cloudfoundry.org/cli/command/translatableerror" 19 "code.cloudfoundry.org/cli/util/configv3" 20 "github.com/fatih/color" 21 runewidth "github.com/mattn/go-runewidth" 22 "github.com/vito/go-interact/interact" 23 ) 24 25 var realExiter exiterFunc = os.Exit 26 27 var realInteract interactorFunc = func(prompt string, choices ...interact.Choice) Resolver { 28 return &interactionWrapper{ 29 interact.NewInteraction(prompt, choices...), 30 } 31 } 32 33 //go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 . Interactor 34 35 // Interactor hides interact.NewInteraction for testing purposes 36 type Interactor interface { 37 NewInteraction(prompt string, choices ...interact.Choice) Resolver 38 } 39 40 type interactorFunc func(prompt string, choices ...interact.Choice) Resolver 41 42 func (f interactorFunc) NewInteraction(prompt string, choices ...interact.Choice) Resolver { 43 return f(prompt, choices...) 44 } 45 46 type interactionWrapper struct { 47 interact.Interaction 48 } 49 50 func (w *interactionWrapper) SetIn(in io.Reader) { 51 w.Input = in 52 } 53 54 func (w *interactionWrapper) SetOut(o io.Writer) { 55 w.Output = o 56 } 57 58 //go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 . Exiter 59 60 // Exiter hides os.Exit for testing purposes 61 type Exiter interface { 62 Exit(code int) 63 } 64 65 type exiterFunc func(int) 66 67 func (f exiterFunc) Exit(code int) { 68 f(code) 69 } 70 71 // UI is interface to interact with the user 72 type UI struct { 73 // In is the input buffer 74 In io.Reader 75 // Out is the output buffer 76 Out io.Writer 77 // OutForInteraction is the output buffer when working with go-interact. When 78 // working with Windows, color.Output does not work with TTY detection. So 79 // real STDOUT is required or go-interact will not properly work. 80 OutForInteraction io.Writer 81 // Err is the error buffer 82 Err io.Writer 83 84 colorEnabled configv3.ColorSetting 85 translate TranslateFunc 86 Exiter Exiter 87 88 terminalLock *sync.Mutex 89 fileLock *sync.Mutex 90 91 Interactor Interactor 92 93 IsTTY bool 94 TerminalWidth int 95 96 TimezoneLocation *time.Location 97 98 deferred []string 99 } 100 101 // NewUI will return a UI object where Out is set to STDOUT, In is set to 102 // STDIN, and Err is set to STDERR 103 func NewUI(config Config) (*UI, error) { 104 translateFunc, err := GetTranslationFunc(config) 105 if err != nil { 106 return nil, err 107 } 108 109 location := time.Now().Location() 110 111 return &UI{ 112 In: os.Stdin, 113 Out: color.Output, 114 OutForInteraction: os.Stdout, 115 Err: os.Stderr, 116 colorEnabled: config.ColorEnabled(), 117 translate: translateFunc, 118 terminalLock: &sync.Mutex{}, 119 Exiter: realExiter, 120 fileLock: &sync.Mutex{}, 121 Interactor: realInteract, 122 IsTTY: config.IsTTY(), 123 TerminalWidth: config.TerminalWidth(), 124 TimezoneLocation: location, 125 }, nil 126 } 127 128 // NewPluginUI will return a UI object where OUT and ERR are customizable. 129 func NewPluginUI(config Config, outBuffer io.Writer, errBuffer io.Writer) (*UI, error) { 130 translateFunc, translationError := GetTranslationFunc(config) 131 if translationError != nil { 132 return nil, translationError 133 } 134 135 location := time.Now().Location() 136 137 return &UI{ 138 In: nil, 139 Out: outBuffer, 140 OutForInteraction: outBuffer, 141 Err: errBuffer, 142 colorEnabled: configv3.ColorDisabled, 143 translate: translateFunc, 144 terminalLock: &sync.Mutex{}, 145 Exiter: realExiter, 146 fileLock: &sync.Mutex{}, 147 Interactor: realInteract, 148 IsTTY: config.IsTTY(), 149 TerminalWidth: config.TerminalWidth(), 150 TimezoneLocation: location, 151 }, nil 152 } 153 154 // NewTestUI will return a UI object where Out, In, and Err are customizable, 155 // and colors are disabled 156 func NewTestUI(in io.Reader, out io.Writer, err io.Writer) *UI { 157 translationFunc, translateErr := generateTranslationFunc([]byte("[]")) 158 if translateErr != nil { 159 panic(translateErr) 160 } 161 162 return &UI{ 163 In: in, 164 Out: out, 165 OutForInteraction: out, 166 Err: err, 167 Exiter: realExiter, 168 colorEnabled: configv3.ColorDisabled, 169 translate: translationFunc, 170 Interactor: realInteract, 171 terminalLock: &sync.Mutex{}, 172 fileLock: &sync.Mutex{}, 173 TimezoneLocation: time.UTC, 174 } 175 } 176 177 // DeferText translates the template, substitutes in templateValues, and 178 // Enqueues the output to be presented later via FlushDeferred. Only the first 179 // map in templateValues is used. 180 func (ui *UI) DeferText(template string, templateValues ...map[string]interface{}) { 181 s := fmt.Sprintf("%s\n", ui.TranslateText(template, templateValues...)) 182 ui.deferred = append(ui.deferred, s) 183 } 184 185 func (ui *UI) DisplayDeprecationWarning() { 186 ui.terminalLock.Lock() 187 defer ui.terminalLock.Unlock() 188 189 fmt.Fprintf(ui.Err, "Deprecation warning: This command has been deprecated. This feature will be removed in the future.\n") 190 } 191 192 // DisplayError outputs the translated error message to ui.Err if the error 193 // satisfies TranslatableError, otherwise it outputs the original error message 194 // to ui.Err. It also outputs "FAILED" in bold red to ui.Out. 195 func (ui *UI) DisplayError(err error) { 196 var errMsg string 197 if translatableError, ok := err.(translatableerror.TranslatableError); ok { 198 errMsg = translatableError.Translate(ui.translate) 199 } else { 200 errMsg = err.Error() 201 } 202 fmt.Fprintf(ui.Err, "%s\n", errMsg) 203 204 ui.terminalLock.Lock() 205 defer ui.terminalLock.Unlock() 206 207 fmt.Fprintf(ui.Out, "%s\n", ui.modifyColor(ui.TranslateText("FAILED"), color.New(color.FgRed, color.Bold))) 208 } 209 210 func (ui *UI) DisplayFileDeprecationWarning() { 211 ui.terminalLock.Lock() 212 defer ui.terminalLock.Unlock() 213 214 fmt.Fprintf(ui.Err, "Deprecation warning: This command has been deprecated and will be removed in the future. For similar functionality, please use the `cf ssh` command instead.\n") 215 } 216 217 // DisplayHeader translates the header, bolds and adds the default color to the 218 // header, and outputs the result to ui.Out. 219 func (ui *UI) DisplayHeader(text string) { 220 ui.terminalLock.Lock() 221 defer ui.terminalLock.Unlock() 222 223 fmt.Fprintf(ui.Out, "%s\n", ui.modifyColor(ui.TranslateText(text), color.New(color.Bold))) 224 } 225 226 // DisplayNewline outputs a newline to UI.Out. 227 func (ui *UI) DisplayNewline() { 228 ui.terminalLock.Lock() 229 defer ui.terminalLock.Unlock() 230 231 fmt.Fprintf(ui.Out, "\n") 232 } 233 234 // DisplayOK outputs a bold green translated "OK" to UI.Out. 235 func (ui *UI) DisplayOK() { 236 ui.terminalLock.Lock() 237 defer ui.terminalLock.Unlock() 238 239 fmt.Fprintf(ui.Out, "%s\n\n", ui.modifyColor(ui.TranslateText("OK"), color.New(color.FgGreen, color.Bold))) 240 } 241 242 // DisplayText translates the template, substitutes in templateValues, and 243 // outputs the result to ui.Out. Only the first map in templateValues is used. 244 func (ui *UI) DisplayText(template string, templateValues ...map[string]interface{}) { 245 ui.terminalLock.Lock() 246 defer ui.terminalLock.Unlock() 247 248 fmt.Fprintf(ui.Out, "%s\n", ui.TranslateText(template, templateValues...)) 249 } 250 251 // DisplayTextWithBold translates the template, bolds the templateValues, 252 // substitutes templateValues into the template, and outputs 253 // the result to ui.Out. Only the first map in templateValues is used. 254 func (ui *UI) DisplayTextWithBold(template string, templateValues ...map[string]interface{}) { 255 ui.terminalLock.Lock() 256 defer ui.terminalLock.Unlock() 257 258 firstTemplateValues := getFirstSet(templateValues) 259 for key, value := range firstTemplateValues { 260 firstTemplateValues[key] = ui.modifyColor(fmt.Sprint(value), color.New(color.Bold)) 261 } 262 fmt.Fprintf(ui.Out, "%s\n", ui.TranslateText(template, firstTemplateValues)) 263 } 264 265 // DisplayTextWithFlavor translates the template, bolds and adds cyan color to 266 // templateValues, substitutes templateValues into the template, and outputs 267 // the result to ui.Out. Only the first map in templateValues is used. 268 func (ui *UI) DisplayTextWithFlavor(template string, templateValues ...map[string]interface{}) { 269 ui.terminalLock.Lock() 270 defer ui.terminalLock.Unlock() 271 272 firstTemplateValues := getFirstSet(templateValues) 273 for key, value := range firstTemplateValues { 274 firstTemplateValues[key] = ui.modifyColor(fmt.Sprint(value), color.New(color.FgCyan, color.Bold)) 275 } 276 fmt.Fprintf(ui.Out, "%s\n", ui.TranslateText(template, firstTemplateValues)) 277 } 278 279 // DisplayDiffAddition displays added lines in a diff, colored green and prefixed with '+' 280 func (ui *UI) DisplayDiffAddition(lines string, depth int, addHyphen bool) { 281 ui.terminalLock.Lock() 282 defer ui.terminalLock.Unlock() 283 284 indent := getIndent(depth, addHyphen) 285 286 for i, line := range strings.Split(lines, "\n") { 287 if line == "" { 288 continue 289 } 290 if i > 0 { 291 indent = getIndent(depth, false) 292 } 293 template := "+ " + indent + line 294 formatted := ui.modifyColor(template, color.New(color.FgGreen)) 295 296 fmt.Fprintf(ui.Out, "%s\n", formatted) 297 } 298 } 299 300 // DisplayDiffRemoval displays removed lines in a diff, colored red and prefixed with '-' 301 func (ui *UI) DisplayDiffRemoval(lines string, depth int, addHyphen bool) { 302 ui.terminalLock.Lock() 303 defer ui.terminalLock.Unlock() 304 305 indent := getIndent(depth, addHyphen) 306 307 for i, line := range strings.Split(lines, "\n") { 308 if line == "" { 309 continue 310 } 311 if i > 0 { 312 indent = getIndent(depth, false) 313 } 314 template := "- " + indent + line 315 formatted := ui.modifyColor(template, color.New(color.FgRed)) 316 317 fmt.Fprintf(ui.Out, "%s\n", formatted) 318 } 319 } 320 321 // DisplayDiffUnchanged displays unchanged lines in a diff, with no color or prefix 322 func (ui *UI) DisplayDiffUnchanged(lines string, depth int, addHyphen bool) { 323 ui.terminalLock.Lock() 324 defer ui.terminalLock.Unlock() 325 326 indent := getIndent(depth, addHyphen) 327 328 for i, line := range strings.Split(lines, "\n") { 329 if line == "" { 330 continue 331 } 332 if i > 0 { 333 indent = getIndent(depth, false) 334 } 335 template := " " + indent + line 336 337 fmt.Fprintf(ui.Out, "%s\n", template) 338 } 339 } 340 341 // DisplayJSON encodes and indents the input 342 // and outputs the result to ui.Out. 343 func (ui *UI) DisplayJSON(name string, jsonData interface{}) error { 344 ui.terminalLock.Lock() 345 defer ui.terminalLock.Unlock() 346 347 buff := new(bytes.Buffer) 348 encoder := json.NewEncoder(buff) 349 encoder.SetEscapeHTML(false) 350 encoder.SetIndent("", " ") 351 352 err := encoder.Encode(jsonData) 353 if err != nil { 354 return err 355 } 356 357 if name != "" { 358 fmt.Fprintf(ui.Out, "%s\n", fmt.Sprintf("%s: %s", name, buff)) 359 } else { 360 fmt.Fprintf(ui.Out, "%s\n", buff) 361 } 362 363 return nil 364 } 365 366 // FlushDeferred displays text previously deferred (using DeferText) to the UI's 367 // `Out`. 368 func (ui *UI) FlushDeferred() { 369 ui.terminalLock.Lock() 370 defer ui.terminalLock.Unlock() 371 372 for _, s := range ui.deferred { 373 fmt.Fprint(ui.Out, s) 374 } 375 ui.deferred = []string{} 376 } 377 378 // GetErr returns the error writer. 379 func (ui *UI) GetErr() io.Writer { 380 return ui.Err 381 } 382 383 // GetIn returns the input reader. 384 func (ui *UI) GetIn() io.Reader { 385 return ui.In 386 } 387 388 // GetOut returns the output writer. Same as `Writer`. 389 func (ui *UI) GetOut() io.Writer { 390 return ui.Out 391 } 392 393 // TranslateText passes the template through an internationalization function 394 // to translate it to a pre-configured language, and returns the template with 395 // templateValues substituted in. Only the first map in templateValues is used. 396 func (ui *UI) TranslateText(template string, templateValues ...map[string]interface{}) string { 397 return ui.translate(template, getFirstSet(templateValues)) 398 } 399 400 // UserFriendlyDate converts the time to UTC and then formats it to ISO8601. 401 func (ui *UI) UserFriendlyDate(input time.Time) string { 402 return input.Local().Format("Mon 02 Jan 15:04:05 MST 2006") 403 } 404 405 // Writer returns the output writer. Same as `GetOut`. 406 func (ui *UI) Writer() io.Writer { 407 return ui.Out 408 } 409 410 func (ui *UI) displayWrappingTableWithWidth(prefix string, table [][]string, padding int) { 411 ui.terminalLock.Lock() 412 defer ui.terminalLock.Unlock() 413 414 var columnPadding []int 415 416 rows := len(table) 417 columns := len(table[0]) 418 419 for col := 0; col < columns-1; col++ { 420 var max int 421 for row := 0; row < rows; row++ { 422 if strLen := runewidth.StringWidth(table[row][col]); max < strLen { 423 max = strLen 424 } 425 } 426 columnPadding = append(columnPadding, max+padding) 427 } 428 429 spilloverPadding := len(prefix) + sum(columnPadding) 430 lastColumnWidth := ui.TerminalWidth - spilloverPadding 431 432 for row := 0; row < rows; row++ { 433 fmt.Fprint(ui.Out, prefix) 434 435 // for all columns except last, add cell value and padding 436 for col := 0; col < columns-1; col++ { 437 var addedPadding int 438 if col+1 != columns { 439 addedPadding = columnPadding[col] - runewidth.StringWidth(table[row][col]) 440 } 441 fmt.Fprintf(ui.Out, "%s%s", table[row][col], strings.Repeat(" ", addedPadding)) 442 } 443 444 // for last column, add each word individually. If the added word would make the column exceed terminal width, create a new line and add padding 445 words := strings.Split(table[row][columns-1], " ") 446 currentWidth := 0 447 448 for _, word := range words { 449 wordWidth := runewidth.StringWidth(word) 450 switch { 451 case currentWidth == 0: 452 currentWidth = wordWidth 453 fmt.Fprintf(ui.Out, "%s", word) 454 case (wordWidth + 1 + currentWidth) > lastColumnWidth: 455 fmt.Fprintf(ui.Out, "\n%s%s", strings.Repeat(" ", spilloverPadding), word) 456 currentWidth = wordWidth 457 default: 458 fmt.Fprintf(ui.Out, " %s", word) 459 currentWidth += wordWidth + 1 460 } 461 } 462 463 fmt.Fprintf(ui.Out, "\n") 464 } 465 } 466 467 func (ui *UI) modifyColor(text string, colorPrinter *color.Color) string { 468 if len(text) == 0 { 469 return text 470 } 471 472 switch ui.colorEnabled { 473 case configv3.ColorEnabled: 474 colorPrinter.EnableColor() 475 case configv3.ColorDisabled: 476 colorPrinter.DisableColor() 477 } 478 479 return colorPrinter.SprintFunc()(text) 480 } 481 482 // getFirstSet returns the first map if 1 or more maps are provided. Otherwise 483 // it returns the empty map. 484 func getFirstSet(list []map[string]interface{}) map[string]interface{} { 485 if len(list) == 0 { 486 return map[string]interface{}{} 487 } 488 return list[0] 489 } 490 491 func sum(intSlice []int) int { 492 sum := 0 493 494 for _, i := range intSlice { 495 sum += i 496 } 497 498 return sum 499 } 500 501 func getIndent(depth int, addHyphen bool) string { 502 if depth == 0 { 503 return "" 504 } 505 indent := strings.Repeat(" ", depth-1) 506 if addHyphen { 507 return indent + "- " 508 } else { 509 return indent + " " 510 } 511 }