shortcut.go 9.3 KB

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