shortcut.go 8.6 KB

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