shortcut.go 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  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-1-extraLines(info)-extraLines(errMessage), 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. IsDDComposeUIActive bool
  87. logLevel KEYBOARD_LOG_LEVEL
  88. signalChannel chan<- os.Signal
  89. }
  90. var KeyboardManager *LogKeyboard
  91. var eg multierror.Group
  92. func NewKeyboardManager(ctx context.Context, isDockerDesktopActive, isWatchConfigured, isDockerDesktopConfigActive bool,
  93. sc chan<- os.Signal,
  94. watchFn func(ctx context.Context,
  95. project *types.Project,
  96. services []string,
  97. options api.WatchOptions,
  98. ) error,
  99. ) {
  100. km := LogKeyboard{}
  101. km.IsDockerDesktopActive = isDockerDesktopActive
  102. km.IsWatchConfigured = isWatchConfigured
  103. km.IsDDComposeUIActive = isDockerDesktopConfigActive
  104. km.logLevel = INFO
  105. km.Watch.Watching = false
  106. km.Watch.WatchFn = watchFn
  107. km.signalChannel = sc
  108. KeyboardManager = &km
  109. }
  110. func (lk *LogKeyboard) ClearKeyboardInfo() {
  111. lk.clearNavigationMenu()
  112. }
  113. func (lk *LogKeyboard) PrintKeyboardInfo() {
  114. if lk.logLevel == INFO {
  115. lk.printNavigationMenu()
  116. }
  117. }
  118. // Creates space to print error and menu string
  119. func (lk *LogKeyboard) createBuffer(lines int) {
  120. if lk.kError.shouldDisplay() {
  121. extraLines := extraLines(lk.kError.error()) + 1
  122. lines += extraLines
  123. }
  124. // get the string
  125. infoMessage := lk.navigationMenu()
  126. // calculate how many lines we need to display the menu info
  127. // might be needed a line break
  128. extraLines := extraLines(infoMessage) + 1
  129. lines += extraLines
  130. if lines > 0 {
  131. allocateSpace(lines)
  132. MoveCursorUp(lines)
  133. }
  134. }
  135. func (lk *LogKeyboard) printNavigationMenu() {
  136. offset := 1
  137. lk.clearNavigationMenu()
  138. lk.createBuffer(offset)
  139. if lk.logLevel == INFO {
  140. height := goterm.Height()
  141. menu := lk.navigationMenu()
  142. MoveCursorX(0)
  143. SaveCursor()
  144. lk.kError.printError(height, menu)
  145. MoveCursor(height-extraLines(menu), 0)
  146. ClearLine()
  147. fmt.Print(menu)
  148. MoveCursorX(0)
  149. RestoreCursor()
  150. }
  151. }
  152. func (lk *LogKeyboard) navigationMenu() string {
  153. var openDDInfo string
  154. if lk.IsDockerDesktopActive {
  155. openDDInfo = shortcutKeyColor("v") + navColor(" View in Docker Desktop")
  156. }
  157. var openDDUI string
  158. if openDDInfo != "" {
  159. openDDUI = navColor(" ")
  160. }
  161. if lk.IsDDComposeUIActive {
  162. openDDUI = openDDUI + shortcutKeyColor("o") + navColor(" View Config")
  163. }
  164. var watchInfo string
  165. if openDDInfo != "" || openDDUI != "" {
  166. watchInfo = navColor(" ")
  167. }
  168. var isEnabled = " Enable"
  169. if lk.Watch.Watching {
  170. isEnabled = " Disable"
  171. }
  172. watchInfo = watchInfo + shortcutKeyColor("w") + navColor(isEnabled+" Watch")
  173. return openDDInfo + openDDUI + watchInfo
  174. }
  175. func (lk *LogKeyboard) clearNavigationMenu() {
  176. height := goterm.Height()
  177. MoveCursorX(0)
  178. SaveCursor()
  179. // ClearLine()
  180. for i := 0; i < height; i++ {
  181. MoveCursorDown(1)
  182. ClearLine()
  183. }
  184. RestoreCursor()
  185. }
  186. func (lk *LogKeyboard) openDockerDesktop(ctx context.Context, project *types.Project) {
  187. if !lk.IsDockerDesktopActive {
  188. return
  189. }
  190. eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "menu/gui", tracing.SpanOptions{},
  191. func(ctx context.Context) error {
  192. link := fmt.Sprintf("docker-desktop://dashboard/apps/%s", project.Name)
  193. err := open.Run(link)
  194. if err != nil {
  195. err = fmt.Errorf("Could not open Docker Desktop")
  196. lk.keyboardError("View", err)
  197. }
  198. return err
  199. }),
  200. )
  201. }
  202. func (lk *LogKeyboard) openDDComposeUI(ctx context.Context, project *types.Project) {
  203. if !lk.IsDDComposeUIActive {
  204. return
  205. }
  206. eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "menu/gui/composeview", tracing.SpanOptions{},
  207. func(ctx context.Context) error {
  208. link := fmt.Sprintf("docker-desktop://dashboard/docker-compose/%s", project.Name)
  209. err := open.Run(link)
  210. if err != nil {
  211. err = fmt.Errorf("Could not open Docker Desktop Compose UI")
  212. lk.keyboardError("View Config", err)
  213. }
  214. return err
  215. }),
  216. )
  217. }
  218. func (lk *LogKeyboard) keyboardError(prefix string, err error) {
  219. lk.kError.addError(prefix, err)
  220. lk.printNavigationMenu()
  221. timer1 := time.NewTimer((DISPLAY_ERROR_TIME + 1) * time.Second)
  222. go func() {
  223. <-timer1.C
  224. lk.printNavigationMenu()
  225. }()
  226. }
  227. func (lk *LogKeyboard) StartWatch(ctx context.Context, project *types.Project, options api.UpOptions) {
  228. if !lk.IsWatchConfigured {
  229. eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{},
  230. func(ctx context.Context) error {
  231. err := fmt.Errorf("Watch is not yet configured. Learn more: %s", ansiColor(CYAN, "https://docs.docker.com/compose/file-watch/"))
  232. lk.keyboardError("Watch", err)
  233. return err
  234. }))
  235. return
  236. }
  237. lk.Watch.switchWatching()
  238. if !lk.Watch.isWatching() {
  239. lk.Watch.Cancel()
  240. } else {
  241. eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{},
  242. func(ctx context.Context) error {
  243. if options.Create.Build == nil {
  244. err := fmt.Errorf("Cannot run watch mode with flag --no-build")
  245. lk.keyboardError("Watch", err)
  246. return err
  247. }
  248. lk.Watch.newContext(ctx)
  249. buildOpts := *options.Create.Build
  250. buildOpts.Quiet = true
  251. return lk.Watch.WatchFn(lk.Watch.Ctx, project, options.Start.Services, api.WatchOptions{
  252. Build: &buildOpts,
  253. LogTo: options.Start.Attach,
  254. })
  255. }))
  256. }
  257. }
  258. func (lk *LogKeyboard) HandleKeyEvents(event keyboard.KeyEvent, ctx context.Context, project *types.Project, options api.UpOptions) {
  259. switch kRune := event.Rune; kRune {
  260. case 'v':
  261. lk.openDockerDesktop(ctx, project)
  262. case 'w':
  263. lk.StartWatch(ctx, project, options)
  264. case 'o':
  265. lk.openDDComposeUI(ctx, project)
  266. }
  267. switch key := event.Key; key {
  268. case keyboard.KeyCtrlC:
  269. _ = keyboard.Close()
  270. lk.clearNavigationMenu()
  271. ShowCursor()
  272. lk.logLevel = NONE
  273. if lk.Watch.Watching && lk.Watch.Cancel != nil {
  274. lk.Watch.Cancel()
  275. _ = eg.Wait().ErrorOrNil() // Need to print this ?
  276. }
  277. // will notify main thread to kill and will handle gracefully
  278. lk.signalChannel <- syscall.SIGINT
  279. case keyboard.KeyEnter:
  280. lk.printNavigationMenu()
  281. }
  282. }
  283. func allocateSpace(lines int) {
  284. for i := 0; i < lines; i++ {
  285. ClearLine()
  286. NewLine()
  287. MoveCursorX(0)
  288. }
  289. }
  290. func extraLines(s string) int {
  291. return int(math.Floor(float64(lenAnsi(s)) / float64(goterm.Width())))
  292. }
  293. func shortcutKeyColor(key string) string {
  294. foreground := "38;2"
  295. black := "0;0;0"
  296. background := "48;2"
  297. white := "255;255;255"
  298. return ansiColor(foreground+";"+black+";"+background+";"+white, key, BOLD)
  299. }
  300. func navColor(key string) string {
  301. return ansiColor(FAINT, key)
  302. }