shortcut.go 9.4 KB

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