shortcut.go 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. /*
  2. Copyright 2024 Docker Compose CLI authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package formatter
  14. import (
  15. "context"
  16. "errors"
  17. "fmt"
  18. "math"
  19. "os"
  20. "strings"
  21. "syscall"
  22. "time"
  23. "github.com/buger/goterm"
  24. "github.com/compose-spec/compose-go/v2/types"
  25. "github.com/eiannone/keyboard"
  26. "github.com/skratchdot/open-golang/open"
  27. "github.com/docker/compose/v5/internal/tracing"
  28. "github.com/docker/compose/v5/pkg/api"
  29. )
  30. const DISPLAY_ERROR_TIME = 10
  31. type KeyboardError struct {
  32. err error
  33. timeStart time.Time
  34. }
  35. func (ke *KeyboardError) shouldDisplay() bool {
  36. return ke.err != nil && int(time.Since(ke.timeStart).Seconds()) < DISPLAY_ERROR_TIME
  37. }
  38. func (ke *KeyboardError) printError(height int, info string) {
  39. if ke.shouldDisplay() {
  40. errMessage := ke.err.Error()
  41. moveCursor(height-1-extraLines(info)-extraLines(errMessage), 0)
  42. clearLine()
  43. fmt.Print(errMessage)
  44. }
  45. }
  46. func (ke *KeyboardError) addError(prefix string, err error) {
  47. ke.timeStart = time.Now()
  48. prefix = ansiColor(CYAN, fmt.Sprintf("%s →", prefix), BOLD)
  49. errorString := fmt.Sprintf("%s %s", prefix, err.Error())
  50. ke.err = errors.New(errorString)
  51. }
  52. func (ke *KeyboardError) error() string {
  53. return ke.err.Error()
  54. }
  55. type KeyboardWatch struct {
  56. Watching bool
  57. Watcher Feature
  58. }
  59. // Feature is an compose feature that can be started/stopped by a menu command
  60. type Feature interface {
  61. Start(context.Context) error
  62. Stop() error
  63. }
  64. type KEYBOARD_LOG_LEVEL int
  65. const (
  66. NONE KEYBOARD_LOG_LEVEL = 0
  67. INFO KEYBOARD_LOG_LEVEL = 1
  68. DEBUG KEYBOARD_LOG_LEVEL = 2
  69. )
  70. type LogKeyboard struct {
  71. kError KeyboardError
  72. Watch *KeyboardWatch
  73. Detach func()
  74. IsDockerDesktopActive bool
  75. logLevel KEYBOARD_LOG_LEVEL
  76. signalChannel chan<- os.Signal
  77. }
  78. func NewKeyboardManager(isDockerDesktopActive bool, sc chan<- os.Signal) *LogKeyboard {
  79. return &LogKeyboard{
  80. IsDockerDesktopActive: isDockerDesktopActive,
  81. logLevel: INFO,
  82. signalChannel: sc,
  83. }
  84. }
  85. func (lk *LogKeyboard) Decorate(l api.LogConsumer) api.LogConsumer {
  86. return logDecorator{
  87. decorated: l,
  88. Before: lk.clearNavigationMenu,
  89. After: lk.PrintKeyboardInfo,
  90. }
  91. }
  92. func (lk *LogKeyboard) PrintKeyboardInfo() {
  93. if lk.logLevel == INFO {
  94. lk.printNavigationMenu()
  95. }
  96. }
  97. // Creates space to print error and menu string
  98. func (lk *LogKeyboard) createBuffer(lines int) {
  99. if lk.kError.shouldDisplay() {
  100. extraLines := extraLines(lk.kError.error()) + 1
  101. lines += extraLines
  102. }
  103. // get the string
  104. infoMessage := lk.navigationMenu()
  105. // calculate how many lines we need to display the menu info
  106. // might be needed a line break
  107. extraLines := extraLines(infoMessage) + 1
  108. lines += extraLines
  109. if lines > 0 {
  110. allocateSpace(lines)
  111. moveCursorUp(lines)
  112. }
  113. }
  114. func (lk *LogKeyboard) printNavigationMenu() {
  115. offset := 1
  116. lk.clearNavigationMenu()
  117. lk.createBuffer(offset)
  118. if lk.logLevel == INFO {
  119. height := goterm.Height()
  120. menu := lk.navigationMenu()
  121. carriageReturn()
  122. saveCursor()
  123. lk.kError.printError(height, menu)
  124. moveCursor(height-extraLines(menu), 0)
  125. clearLine()
  126. fmt.Print(menu)
  127. carriageReturn()
  128. restoreCursor()
  129. }
  130. }
  131. func (lk *LogKeyboard) navigationMenu() string {
  132. var items []string
  133. if lk.IsDockerDesktopActive {
  134. items = append(items, shortcutKeyColor("v")+navColor(" View in Docker Desktop"))
  135. }
  136. if lk.IsDockerDesktopActive {
  137. items = append(items, shortcutKeyColor("o")+navColor(" View Config"))
  138. }
  139. isEnabled := " Enable"
  140. if lk.Watch != nil && lk.Watch.Watching {
  141. isEnabled = " Disable"
  142. }
  143. items = append(items, shortcutKeyColor("w")+navColor(isEnabled+" Watch"))
  144. items = append(items, shortcutKeyColor("d")+navColor(" Detach"))
  145. return strings.Join(items, " ")
  146. }
  147. func (lk *LogKeyboard) clearNavigationMenu() {
  148. height := goterm.Height()
  149. carriageReturn()
  150. saveCursor()
  151. // clearLine()
  152. for i := 0; i < height; i++ {
  153. moveCursorDown(1)
  154. clearLine()
  155. }
  156. restoreCursor()
  157. }
  158. func (lk *LogKeyboard) openDockerDesktop(ctx context.Context, project *types.Project) {
  159. if !lk.IsDockerDesktopActive {
  160. return
  161. }
  162. go func() {
  163. _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui", tracing.SpanOptions{},
  164. func(ctx context.Context) error {
  165. link := fmt.Sprintf("docker-desktop://dashboard/apps/%s", project.Name)
  166. err := open.Run(link)
  167. if err != nil {
  168. err = fmt.Errorf("could not open Docker Desktop")
  169. lk.keyboardError("View", err)
  170. }
  171. return err
  172. })()
  173. }()
  174. }
  175. func (lk *LogKeyboard) openDDComposeUI(ctx context.Context, project *types.Project) {
  176. if !lk.IsDockerDesktopActive {
  177. return
  178. }
  179. go func() {
  180. _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui/composeview", tracing.SpanOptions{},
  181. func(ctx context.Context) error {
  182. link := fmt.Sprintf("docker-desktop://dashboard/docker-compose/%s", project.Name)
  183. err := open.Run(link)
  184. if err != nil {
  185. err = fmt.Errorf("could not open Docker Desktop Compose UI")
  186. lk.keyboardError("View Config", err)
  187. }
  188. return err
  189. })()
  190. }()
  191. }
  192. func (lk *LogKeyboard) openDDWatchDocs(ctx context.Context, project *types.Project) {
  193. go func() {
  194. _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui/watch", tracing.SpanOptions{},
  195. func(ctx context.Context) error {
  196. link := fmt.Sprintf("docker-desktop://dashboard/docker-compose/%s/watch", project.Name)
  197. err := open.Run(link)
  198. if err != nil {
  199. err = fmt.Errorf("could not open Docker Desktop Compose UI")
  200. lk.keyboardError("Watch Docs", err)
  201. }
  202. return err
  203. })()
  204. }()
  205. }
  206. func (lk *LogKeyboard) keyboardError(prefix string, err error) {
  207. lk.kError.addError(prefix, err)
  208. lk.printNavigationMenu()
  209. timer1 := time.NewTimer((DISPLAY_ERROR_TIME + 1) * time.Second)
  210. go func() {
  211. <-timer1.C
  212. lk.printNavigationMenu()
  213. }()
  214. }
  215. func (lk *LogKeyboard) ToggleWatch(ctx context.Context, options api.UpOptions) {
  216. if lk.Watch == nil {
  217. return
  218. }
  219. if lk.Watch.Watching {
  220. err := lk.Watch.Watcher.Stop()
  221. if err != nil {
  222. options.Start.Attach.Err(api.WatchLogger, err.Error())
  223. } else {
  224. lk.Watch.Watching = false
  225. }
  226. } else {
  227. go func() {
  228. _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{},
  229. func(ctx context.Context) error {
  230. err := lk.Watch.Watcher.Start(ctx)
  231. if err != nil {
  232. options.Start.Attach.Err(api.WatchLogger, err.Error())
  233. } else {
  234. lk.Watch.Watching = true
  235. }
  236. return err
  237. })()
  238. }()
  239. }
  240. }
  241. func (lk *LogKeyboard) HandleKeyEvents(ctx context.Context, event keyboard.KeyEvent, project *types.Project, options api.UpOptions) {
  242. switch kRune := event.Rune; kRune {
  243. case 'd':
  244. lk.clearNavigationMenu()
  245. lk.Detach()
  246. case 'v':
  247. lk.openDockerDesktop(ctx, project)
  248. case 'w':
  249. if lk.Watch == nil {
  250. // we try to open watch docs if DD is installed
  251. if lk.IsDockerDesktopActive {
  252. lk.openDDWatchDocs(ctx, project)
  253. }
  254. // either way we mark menu/watch as an error
  255. go func() {
  256. _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{},
  257. func(ctx context.Context) error {
  258. err := fmt.Errorf("watch is not yet configured. Learn more: %s", ansiColor(CYAN, "https://docs.docker.com/compose/file-watch/"))
  259. lk.keyboardError("Watch", err)
  260. return err
  261. })()
  262. }()
  263. }
  264. lk.ToggleWatch(ctx, options)
  265. case 'o':
  266. lk.openDDComposeUI(ctx, project)
  267. }
  268. switch key := event.Key; key {
  269. case keyboard.KeyCtrlC:
  270. _ = keyboard.Close()
  271. lk.clearNavigationMenu()
  272. showCursor()
  273. lk.logLevel = NONE
  274. // will notify main thread to kill and will handle gracefully
  275. lk.signalChannel <- syscall.SIGINT
  276. case keyboard.KeyCtrlZ:
  277. handleCtrlZ()
  278. case keyboard.KeyEnter:
  279. newLine()
  280. lk.printNavigationMenu()
  281. }
  282. }
  283. func (lk *LogKeyboard) EnableWatch(enabled bool, watcher Feature) {
  284. lk.Watch = &KeyboardWatch{
  285. Watching: enabled,
  286. Watcher: watcher,
  287. }
  288. }
  289. func (lk *LogKeyboard) EnableDetach(detach func()) {
  290. lk.Detach = detach
  291. }
  292. func allocateSpace(lines int) {
  293. for i := 0; i < lines; i++ {
  294. clearLine()
  295. newLine()
  296. carriageReturn()
  297. }
  298. }
  299. func extraLines(s string) int {
  300. return int(math.Floor(float64(lenAnsi(s)) / float64(goterm.Width())))
  301. }
  302. func shortcutKeyColor(key string) string {
  303. foreground := "38;2"
  304. black := "0;0;0"
  305. background := "48;2"
  306. white := "255;255;255"
  307. return ansiColor(foreground+";"+black+";"+background+";"+white, key, BOLD)
  308. }
  309. func navColor(key string) string {
  310. return ansiColor(FAINT, key)
  311. }