shortcut.go 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  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. "syscall"
  21. "time"
  22. "github.com/buger/goterm"
  23. "github.com/compose-spec/compose-go/v2/types"
  24. "github.com/docker/compose/v2/internal/tracing"
  25. "github.com/docker/compose/v2/pkg/api"
  26. "github.com/docker/compose/v2/pkg/watch"
  27. "github.com/eiannone/keyboard"
  28. "github.com/hashicorp/go-multierror"
  29. "github.com/skratchdot/open-golang/open"
  30. )
  31. const DISPLAY_ERROR_TIME = 10
  32. type KeyboardError struct {
  33. err error
  34. timeStart time.Time
  35. }
  36. func (ke *KeyboardError) shouldDisplay() bool {
  37. return ke.err != nil && int(time.Since(ke.timeStart).Seconds()) < DISPLAY_ERROR_TIME
  38. }
  39. func (ke *KeyboardError) printError(height int, info string) {
  40. if ke.shouldDisplay() {
  41. errMessage := ke.err.Error()
  42. MoveCursor(height-linesOffset(info)-linesOffset(errMessage)-1, 0)
  43. ClearLine()
  44. fmt.Print(errMessage)
  45. }
  46. }
  47. func (ke *KeyboardError) addError(prefix string, err error) {
  48. ke.timeStart = time.Now()
  49. prefix = ansiColor(CYAN, fmt.Sprintf("%s →", prefix), BOLD)
  50. errorString := fmt.Sprintf("%s %s", prefix, err.Error())
  51. ke.err = errors.New(errorString)
  52. }
  53. func (ke *KeyboardError) error() string {
  54. return ke.err.Error()
  55. }
  56. type KeyboardWatch struct {
  57. Watcher watch.Notify
  58. Watching bool
  59. WatchFn func(ctx context.Context, project *types.Project, services []string, options api.WatchOptions) error
  60. Ctx context.Context
  61. Cancel context.CancelFunc
  62. }
  63. func (kw *KeyboardWatch) isWatching() bool {
  64. return kw.Watching
  65. }
  66. func (kw *KeyboardWatch) switchWatching() {
  67. kw.Watching = !kw.Watching
  68. }
  69. func (kw *KeyboardWatch) newContext(ctx context.Context) context.CancelFunc {
  70. ctx, cancel := context.WithCancel(ctx)
  71. kw.Ctx = ctx
  72. kw.Cancel = cancel
  73. return cancel
  74. }
  75. type KEYBOARD_LOG_LEVEL int
  76. const (
  77. NONE KEYBOARD_LOG_LEVEL = 0
  78. INFO KEYBOARD_LOG_LEVEL = 1
  79. DEBUG KEYBOARD_LOG_LEVEL = 2
  80. )
  81. type LogKeyboard struct {
  82. kError KeyboardError
  83. Watch KeyboardWatch
  84. IsDockerDesktopActive bool
  85. IsWatchConfigured bool
  86. logLevel KEYBOARD_LOG_LEVEL
  87. signalChannel chan<- os.Signal
  88. }
  89. var KeyboardManager *LogKeyboard
  90. var eg multierror.Group
  91. func NewKeyboardManager(ctx context.Context, isDockerDesktopActive, isWatchConfigured bool,
  92. sc chan<- os.Signal,
  93. watchFn func(ctx context.Context,
  94. project *types.Project,
  95. services []string,
  96. options api.WatchOptions,
  97. ) error,
  98. ) {
  99. km := LogKeyboard{}
  100. km.IsDockerDesktopActive = isDockerDesktopActive
  101. km.IsWatchConfigured = isWatchConfigured
  102. km.logLevel = INFO
  103. km.Watch.Watching = false
  104. km.Watch.WatchFn = watchFn
  105. km.signalChannel = sc
  106. KeyboardManager = &km
  107. HideCursor()
  108. }
  109. func (lk *LogKeyboard) PrintKeyboardInfo(printFn func()) {
  110. printFn()
  111. if lk.logLevel == INFO {
  112. lk.printNavigationMenu()
  113. }
  114. }
  115. // Creates space to print error and menu string
  116. func (lk *LogKeyboard) createBuffer(lines int) {
  117. allocateSpace(lines)
  118. if lk.kError.shouldDisplay() {
  119. extraLines := linesOffset(lk.kError.error()) + 1
  120. allocateSpace(extraLines)
  121. lines += extraLines
  122. }
  123. infoMessage := lk.navigationMenu()
  124. extraLines := linesOffset(infoMessage) + 1
  125. allocateSpace(extraLines)
  126. lines += extraLines
  127. if lines > 0 {
  128. MoveCursorUp(lines)
  129. }
  130. }
  131. func (lk *LogKeyboard) printNavigationMenu() {
  132. lk.clearNavigationMenu()
  133. lk.createBuffer(0)
  134. if lk.logLevel == INFO {
  135. height := goterm.Height()
  136. menu := lk.navigationMenu()
  137. MoveCursorX(0)
  138. SaveCursor()
  139. lk.kError.printError(height, menu)
  140. MoveCursor(height-linesOffset(menu), 0)
  141. ClearLine()
  142. fmt.Print(menu)
  143. MoveCursorX(0)
  144. RestoreCursor()
  145. }
  146. }
  147. func (lk *LogKeyboard) navigationMenu() string {
  148. var options string
  149. var openDDInfo string
  150. if lk.IsDockerDesktopActive {
  151. openDDInfo = shortcutKeyColor("v") + navColor(" View in Docker Desktop")
  152. }
  153. var watchInfo string
  154. if openDDInfo != "" {
  155. watchInfo = navColor(" ")
  156. }
  157. var isEnabled = " Enable"
  158. if lk.Watch.Watching {
  159. isEnabled = " Disable"
  160. }
  161. watchInfo = watchInfo + shortcutKeyColor("w") + navColor(isEnabled+" Watch")
  162. return options + openDDInfo + watchInfo
  163. }
  164. func (lk *LogKeyboard) clearNavigationMenu() {
  165. height := goterm.Height()
  166. MoveCursorX(0)
  167. SaveCursor()
  168. for i := 0; i < height; i++ {
  169. MoveCursorDown(1)
  170. ClearLine()
  171. }
  172. RestoreCursor()
  173. }
  174. func (lk *LogKeyboard) openDockerDesktop(ctx context.Context, project *types.Project) {
  175. if !lk.IsDockerDesktopActive {
  176. return
  177. }
  178. eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "menu/gui", tracing.SpanOptions{},
  179. func(ctx context.Context) error {
  180. link := fmt.Sprintf("docker-desktop://dashboard/apps/%s", project.Name)
  181. err := open.Run(link)
  182. if err != nil {
  183. err = fmt.Errorf("Could not open Docker Desktop")
  184. lk.keyboardError("View", err)
  185. }
  186. return err
  187. }),
  188. )
  189. }
  190. func (lk *LogKeyboard) keyboardError(prefix string, err error) {
  191. lk.kError.addError(prefix, err)
  192. lk.printNavigationMenu()
  193. timer1 := time.NewTimer((DISPLAY_ERROR_TIME + 1) * time.Second)
  194. go func() {
  195. <-timer1.C
  196. lk.printNavigationMenu()
  197. }()
  198. }
  199. func (lk *LogKeyboard) StartWatch(ctx context.Context, project *types.Project, options api.UpOptions) {
  200. if !lk.IsWatchConfigured {
  201. eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{},
  202. func(ctx context.Context) error {
  203. err := fmt.Errorf("Watch is not yet configured. Learn more: %s", ansiColor(CYAN, "https://docs.docker.com/compose/file-watch/"))
  204. lk.keyboardError("Watch", err)
  205. return err
  206. }))
  207. return
  208. }
  209. lk.Watch.switchWatching()
  210. if !lk.Watch.isWatching() {
  211. lk.Watch.Cancel()
  212. } else {
  213. eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{},
  214. func(ctx context.Context) error {
  215. if options.Create.Build == nil {
  216. err := fmt.Errorf("Cannot run watch mode with flag --no-build")
  217. lk.keyboardError("Watch", err)
  218. return err
  219. }
  220. lk.Watch.newContext(ctx)
  221. buildOpts := *options.Create.Build
  222. buildOpts.Quiet = true
  223. return lk.Watch.WatchFn(lk.Watch.Ctx, project, options.Start.Services, api.WatchOptions{
  224. Build: &buildOpts,
  225. LogTo: options.Start.Attach,
  226. })
  227. }))
  228. }
  229. }
  230. func (lk *LogKeyboard) KeyboardClose() {
  231. _ = keyboard.Close()
  232. }
  233. func (lk *LogKeyboard) HandleKeyEvents(event keyboard.KeyEvent, ctx context.Context, project *types.Project, options api.UpOptions) {
  234. switch kRune := event.Rune; kRune {
  235. case 'v':
  236. lk.openDockerDesktop(ctx, project)
  237. case 'w':
  238. lk.StartWatch(ctx, project, options)
  239. }
  240. switch key := event.Key; key {
  241. case keyboard.KeyCtrlC:
  242. lk.KeyboardClose()
  243. lk.clearNavigationMenu()
  244. ShowCursor()
  245. lk.logLevel = NONE
  246. if lk.Watch.Watching && lk.Watch.Cancel != nil {
  247. lk.Watch.Cancel()
  248. _ = eg.Wait().ErrorOrNil() // Need to print this ?
  249. }
  250. // will notify main thread to kill and will handle gracefully
  251. lk.signalChannel <- syscall.SIGINT
  252. case keyboard.KeyEnter:
  253. lk.printNavigationMenu()
  254. }
  255. }
  256. func allocateSpace(lines int) {
  257. for i := 0; i < lines; i++ {
  258. ClearLine()
  259. NewLine()
  260. MoveCursorX(0)
  261. }
  262. }
  263. func linesOffset(s string) int {
  264. return int(math.Floor(float64(lenAnsi(s)) / float64(goterm.Width())))
  265. }
  266. func shortcutKeyColor(key string) string {
  267. foreground := "38;2"
  268. black := "0;0;0"
  269. background := "48;2"
  270. white := "255;255;255"
  271. return ansiColor(foreground+";"+black+";"+background+";"+white, key, BOLD)
  272. }