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