shortcut.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  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 (
  90. KeyboardManager *LogKeyboard
  91. eg multierror.Group
  92. )
  93. func NewKeyboardManager(ctx context.Context, isDockerDesktopActive, isWatchConfigured bool,
  94. sc chan<- os.Signal,
  95. watchFn func(ctx context.Context,
  96. doneCh chan bool,
  97. project *types.Project,
  98. services []string,
  99. options api.WatchOptions,
  100. ) error,
  101. ) {
  102. km := LogKeyboard{}
  103. km.IsDockerDesktopActive = isDockerDesktopActive
  104. km.IsWatchConfigured = isWatchConfigured
  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.IsDockerDesktopActive {
  163. openDDUI = openDDUI + shortcutKeyColor("o") + navColor(" View Config")
  164. }
  165. var watchInfo string
  166. if openDDInfo != "" || openDDUI != "" {
  167. watchInfo = navColor(" ")
  168. }
  169. 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.IsDockerDesktopActive {
  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. return
  244. }
  245. lk.Watch.switchWatching()
  246. if !lk.Watch.isWatching() {
  247. lk.Watch.Cancel()
  248. } else {
  249. eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{},
  250. func(ctx context.Context) error {
  251. if options.Create.Build == nil {
  252. err := fmt.Errorf("cannot run watch mode with flag --no-build")
  253. lk.keyboardError("Watch", err)
  254. return err
  255. }
  256. lk.Watch.newContext(ctx)
  257. buildOpts := *options.Create.Build
  258. buildOpts.Quiet = true
  259. err := lk.Watch.WatchFn(lk.Watch.Ctx, doneCh, project, options.Start.Services, api.WatchOptions{
  260. Build: &buildOpts,
  261. LogTo: options.Start.Attach,
  262. })
  263. if err != nil {
  264. lk.Watch.switchWatching()
  265. options.Start.Attach.Err(api.WatchLogger, err.Error())
  266. }
  267. return err
  268. }))
  269. }
  270. }
  271. func (lk *LogKeyboard) HandleKeyEvents(event keyboard.KeyEvent, ctx context.Context, doneCh chan bool, project *types.Project, options api.UpOptions) {
  272. switch kRune := event.Rune; kRune {
  273. case 'v':
  274. lk.openDockerDesktop(ctx, project)
  275. case 'w':
  276. if !lk.IsWatchConfigured {
  277. // we try to open watch docs if DD is installed
  278. if lk.IsDockerDesktopActive {
  279. lk.openDDWatchDocs(ctx, project)
  280. }
  281. // either way we mark menu/watch as an error
  282. eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{},
  283. func(ctx context.Context) error {
  284. err := fmt.Errorf("watch is not yet configured. Learn more: %s", ansiColor(CYAN, "https://docs.docker.com/compose/file-watch/"))
  285. lk.keyboardError("Watch", err)
  286. return err
  287. }))
  288. return
  289. }
  290. lk.StartWatch(ctx, doneCh, project, options)
  291. case 'o':
  292. lk.openDDComposeUI(ctx, project)
  293. }
  294. switch key := event.Key; key {
  295. case keyboard.KeyCtrlC:
  296. _ = keyboard.Close()
  297. lk.clearNavigationMenu()
  298. ShowCursor()
  299. lk.logLevel = NONE
  300. if lk.Watch.Watching && lk.Watch.Cancel != nil {
  301. lk.Watch.Cancel()
  302. _ = eg.Wait().ErrorOrNil() // Need to print this ?
  303. }
  304. // will notify main thread to kill and will handle gracefully
  305. lk.signalChannel <- syscall.SIGINT
  306. case keyboard.KeyEnter:
  307. NewLine()
  308. lk.printNavigationMenu()
  309. }
  310. }
  311. func allocateSpace(lines int) {
  312. for i := 0; i < lines; i++ {
  313. ClearLine()
  314. NewLine()
  315. MoveCursorX(0)
  316. }
  317. }
  318. func extraLines(s string) int {
  319. return int(math.Floor(float64(lenAnsi(s)) / float64(goterm.Width())))
  320. }
  321. func shortcutKeyColor(key string) string {
  322. foreground := "38;2"
  323. black := "0;0;0"
  324. background := "48;2"
  325. white := "255;255;255"
  326. return ansiColor(foreground+";"+black+";"+background+";"+white, key, BOLD)
  327. }
  328. func navColor(key string) string {
  329. return ansiColor(FAINT, key)
  330. }