shortcut.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  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/eiannone/keyboard"
  27. "github.com/skratchdot/open-golang/open"
  28. )
  29. const DISPLAY_ERROR_TIME = 10
  30. type KeyboardError struct {
  31. err error
  32. timeStart time.Time
  33. }
  34. func (ke *KeyboardError) shouldDisplay() bool {
  35. return ke.err != nil && int(time.Since(ke.timeStart).Seconds()) < DISPLAY_ERROR_TIME
  36. }
  37. func (ke *KeyboardError) printError(height int, info string) {
  38. if ke.shouldDisplay() {
  39. errMessage := ke.err.Error()
  40. moveCursor(height-1-extraLines(info)-extraLines(errMessage), 0)
  41. clearLine()
  42. fmt.Print(errMessage)
  43. }
  44. }
  45. func (ke *KeyboardError) addError(prefix string, err error) {
  46. ke.timeStart = time.Now()
  47. prefix = ansiColor(CYAN, fmt.Sprintf("%s →", prefix), BOLD)
  48. errorString := fmt.Sprintf("%s %s", prefix, err.Error())
  49. ke.err = errors.New(errorString)
  50. }
  51. func (ke *KeyboardError) error() string {
  52. return ke.err.Error()
  53. }
  54. type KeyboardWatch struct {
  55. Watching bool
  56. Watcher Feature
  57. }
  58. // Feature is an compose feature that can be started/stopped by a menu command
  59. type Feature interface {
  60. Start(context.Context) error
  61. Stop() error
  62. }
  63. type KEYBOARD_LOG_LEVEL int
  64. const (
  65. NONE KEYBOARD_LOG_LEVEL = 0
  66. INFO KEYBOARD_LOG_LEVEL = 1
  67. DEBUG KEYBOARD_LOG_LEVEL = 2
  68. )
  69. type LogKeyboard struct {
  70. kError KeyboardError
  71. Watch *KeyboardWatch
  72. IsDockerDesktopActive bool
  73. logLevel KEYBOARD_LOG_LEVEL
  74. signalChannel chan<- os.Signal
  75. }
  76. func NewKeyboardManager(isDockerDesktopActive bool, sc chan<- os.Signal) *LogKeyboard {
  77. return &LogKeyboard{
  78. IsDockerDesktopActive: isDockerDesktopActive,
  79. logLevel: INFO,
  80. signalChannel: sc,
  81. }
  82. }
  83. func (lk *LogKeyboard) Decorate(l api.LogConsumer) api.LogConsumer {
  84. return logDecorator{
  85. decorated: l,
  86. Before: lk.clearNavigationMenu,
  87. After: lk.PrintKeyboardInfo,
  88. }
  89. }
  90. func (lk *LogKeyboard) PrintKeyboardInfo() {
  91. if lk.logLevel == INFO {
  92. lk.printNavigationMenu()
  93. }
  94. }
  95. // Creates space to print error and menu string
  96. func (lk *LogKeyboard) createBuffer(lines int) {
  97. if lk.kError.shouldDisplay() {
  98. extraLines := extraLines(lk.kError.error()) + 1
  99. lines += extraLines
  100. }
  101. // get the string
  102. infoMessage := lk.navigationMenu()
  103. // calculate how many lines we need to display the menu info
  104. // might be needed a line break
  105. extraLines := extraLines(infoMessage) + 1
  106. lines += extraLines
  107. if lines > 0 {
  108. allocateSpace(lines)
  109. moveCursorUp(lines)
  110. }
  111. }
  112. func (lk *LogKeyboard) printNavigationMenu() {
  113. offset := 1
  114. lk.clearNavigationMenu()
  115. lk.createBuffer(offset)
  116. if lk.logLevel == INFO {
  117. height := goterm.Height()
  118. menu := lk.navigationMenu()
  119. carriageReturn()
  120. saveCursor()
  121. lk.kError.printError(height, menu)
  122. moveCursor(height-extraLines(menu), 0)
  123. clearLine()
  124. fmt.Print(menu)
  125. carriageReturn()
  126. restoreCursor()
  127. }
  128. }
  129. func (lk *LogKeyboard) navigationMenu() string {
  130. var openDDInfo string
  131. if lk.IsDockerDesktopActive {
  132. openDDInfo = shortcutKeyColor("v") + navColor(" View in Docker Desktop")
  133. }
  134. var openDDUI string
  135. if openDDInfo != "" {
  136. openDDUI = navColor(" ")
  137. }
  138. if lk.IsDockerDesktopActive {
  139. openDDUI = openDDUI + shortcutKeyColor("o") + navColor(" View Config")
  140. }
  141. var watchInfo string
  142. if openDDInfo != "" || openDDUI != "" {
  143. watchInfo = navColor(" ")
  144. }
  145. isEnabled := " Enable"
  146. if lk.Watch != nil && lk.Watch.Watching {
  147. isEnabled = " Disable"
  148. }
  149. watchInfo = watchInfo + shortcutKeyColor("w") + navColor(isEnabled+" Watch")
  150. return openDDInfo + openDDUI + watchInfo
  151. }
  152. func (lk *LogKeyboard) clearNavigationMenu() {
  153. height := goterm.Height()
  154. carriageReturn()
  155. saveCursor()
  156. // clearLine()
  157. for i := 0; i < height; i++ {
  158. moveCursorDown(1)
  159. clearLine()
  160. }
  161. restoreCursor()
  162. }
  163. func (lk *LogKeyboard) openDockerDesktop(ctx context.Context, project *types.Project) {
  164. if !lk.IsDockerDesktopActive {
  165. return
  166. }
  167. go func() {
  168. _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui", tracing.SpanOptions{},
  169. func(ctx context.Context) error {
  170. link := fmt.Sprintf("docker-desktop://dashboard/apps/%s", project.Name)
  171. err := open.Run(link)
  172. if err != nil {
  173. err = fmt.Errorf("could not open Docker Desktop")
  174. lk.keyboardError("View", err)
  175. }
  176. return err
  177. })()
  178. }()
  179. }
  180. func (lk *LogKeyboard) openDDComposeUI(ctx context.Context, project *types.Project) {
  181. if !lk.IsDockerDesktopActive {
  182. return
  183. }
  184. go func() {
  185. _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui/composeview", tracing.SpanOptions{},
  186. func(ctx context.Context) error {
  187. link := fmt.Sprintf("docker-desktop://dashboard/docker-compose/%s", project.Name)
  188. err := open.Run(link)
  189. if err != nil {
  190. err = fmt.Errorf("could not open Docker Desktop Compose UI")
  191. lk.keyboardError("View Config", err)
  192. }
  193. return err
  194. })()
  195. }()
  196. }
  197. func (lk *LogKeyboard) openDDWatchDocs(ctx context.Context, project *types.Project) {
  198. go func() {
  199. _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui/watch", tracing.SpanOptions{},
  200. func(ctx context.Context) error {
  201. link := fmt.Sprintf("docker-desktop://dashboard/docker-compose/%s/watch", project.Name)
  202. err := open.Run(link)
  203. if err != nil {
  204. err = fmt.Errorf("could not open Docker Desktop Compose UI")
  205. lk.keyboardError("Watch Docs", err)
  206. }
  207. return err
  208. })()
  209. }()
  210. }
  211. func (lk *LogKeyboard) keyboardError(prefix string, err error) {
  212. lk.kError.addError(prefix, err)
  213. lk.printNavigationMenu()
  214. timer1 := time.NewTimer((DISPLAY_ERROR_TIME + 1) * time.Second)
  215. go func() {
  216. <-timer1.C
  217. lk.printNavigationMenu()
  218. }()
  219. }
  220. func (lk *LogKeyboard) ToggleWatch(ctx context.Context, options api.UpOptions) {
  221. if lk.Watch == nil {
  222. return
  223. }
  224. if lk.Watch.Watching {
  225. err := lk.Watch.Watcher.Stop()
  226. if err != nil {
  227. options.Start.Attach.Err(api.WatchLogger, err.Error())
  228. } else {
  229. lk.Watch.Watching = false
  230. }
  231. } else {
  232. go func() {
  233. _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{},
  234. func(ctx context.Context) error {
  235. err := lk.Watch.Watcher.Start(ctx)
  236. if err != nil {
  237. options.Start.Attach.Err(api.WatchLogger, err.Error())
  238. } else {
  239. lk.Watch.Watching = true
  240. }
  241. return err
  242. })()
  243. }()
  244. }
  245. }
  246. func (lk *LogKeyboard) HandleKeyEvents(ctx context.Context, event keyboard.KeyEvent, project *types.Project, options api.UpOptions) {
  247. switch kRune := event.Rune; kRune {
  248. case 'v':
  249. lk.openDockerDesktop(ctx, project)
  250. case 'w':
  251. if lk.Watch == nil {
  252. // we try to open watch docs if DD is installed
  253. if lk.IsDockerDesktopActive {
  254. lk.openDDWatchDocs(ctx, project)
  255. }
  256. // either way we mark menu/watch as an error
  257. go func() {
  258. _ = tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{},
  259. func(ctx context.Context) error {
  260. err := fmt.Errorf("watch is not yet configured. Learn more: %s", ansiColor(CYAN, "https://docs.docker.com/compose/file-watch/"))
  261. lk.keyboardError("Watch", err)
  262. return err
  263. })()
  264. }()
  265. }
  266. lk.ToggleWatch(ctx, options)
  267. case 'o':
  268. lk.openDDComposeUI(ctx, project)
  269. }
  270. switch key := event.Key; key {
  271. case keyboard.KeyCtrlC:
  272. _ = keyboard.Close()
  273. lk.clearNavigationMenu()
  274. showCursor()
  275. lk.logLevel = NONE
  276. // will notify main thread to kill and will handle gracefully
  277. lk.signalChannel <- syscall.SIGINT
  278. case keyboard.KeyEnter:
  279. newLine()
  280. lk.printNavigationMenu()
  281. }
  282. }
  283. func (lk *LogKeyboard) EnableWatch(enabled bool, watcher Feature) {
  284. lk.Watch = &KeyboardWatch{
  285. Watching: enabled,
  286. Watcher: watcher,
  287. }
  288. }
  289. func allocateSpace(lines int) {
  290. for i := 0; i < lines; i++ {
  291. clearLine()
  292. newLine()
  293. carriageReturn()
  294. }
  295. }
  296. func extraLines(s string) int {
  297. return int(math.Floor(float64(lenAnsi(s)) / float64(goterm.Width())))
  298. }
  299. func shortcutKeyColor(key string) string {
  300. foreground := "38;2"
  301. black := "0;0;0"
  302. background := "48;2"
  303. white := "255;255;255"
  304. return ansiColor(foreground+";"+black+";"+background+";"+white, key, BOLD)
  305. }
  306. func navColor(key string) string {
  307. return ansiColor(FAINT, key)
  308. }