diff --git a/Dockerfile b/Dockerfile index a6f74d07d2b..4488f740a06 100644 --- a/Dockerfile +++ b/Dockerfile @@ -101,6 +101,7 @@ RUN --mount=type=bind,target=. \ FROM build-base AS test ARG CGO_ENABLED=0 ARG BUILD_TAGS +ENV COMPOSE_MENU=FALSE RUN --mount=type=bind,target=. \ --mount=type=cache,target=/root/.cache \ --mount=type=cache,target=/go/pkg/mod \ diff --git a/Makefile b/Makefile index df30dc86009..60bbae549ba 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,7 @@ # limitations under the License. PKG := github.com/docker/compose/v2 +export COMPOSE_MENU = FALSE VERSION ?= $(shell git describe --match 'v[0-9]*' --dirty='.m' --always --tags) GO_LDFLAGS ?= -w -X ${PKG}/internal.Version=${VERSION} diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 0ecb19b60e7..42b7b178d7b 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -65,6 +65,8 @@ const ( ComposeIgnoreOrphans = "COMPOSE_IGNORE_ORPHANS" // ComposeEnvFiles defines the env files to use if --env-file isn't used ComposeEnvFiles = "COMPOSE_ENV_FILES" + // ComposeMenu defines if the navigation menu should be rendered. Can be also set via --menu + ComposeMenu = "COMPOSE_MENU" ) type Backend interface { @@ -620,3 +622,15 @@ var printerModes = []string{ ui.ModePlain, ui.ModeQuiet, } + +func SetUnchangedOption(name string, experimentalFlag bool) bool { + var value bool + // If the var is defined we use that value first + if envVar, ok := os.LookupEnv(name); ok { + value = utils.StringToBool(envVar) + } else { + // if not, we try to get it from experimental feature flag + value = experimentalFlag + } + return value +} diff --git a/cmd/compose/up.go b/cmd/compose/up.go index 25382f9b742..1b458e3a0d1 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -42,20 +42,22 @@ type composeOptions struct { type upOptions struct { *composeOptions - Detach bool - noStart bool - noDeps bool - cascadeStop bool - exitCodeFrom string - noColor bool - noPrefix bool - attachDependencies bool - attach []string - noAttach []string - timestamp bool - wait bool - waitTimeout int - watch bool + Detach bool + noStart bool + noDeps bool + cascadeStop bool + exitCodeFrom string + noColor bool + noPrefix bool + attachDependencies bool + attach []string + noAttach []string + timestamp bool + wait bool + waitTimeout int + watch bool + navigationMenu bool + navigationMenuChanged bool } func (opts upOptions) apply(project *types.Project, services []string) (*types.Project, error) { @@ -87,6 +89,7 @@ func upCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service, ex PreRunE: AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error { create.pullChanged = cmd.Flags().Changed("pull") create.timeChanged = cmd.Flags().Changed("timeout") + up.navigationMenuChanged = cmd.Flags().Changed("menu") return validateFlags(&up, &create) }), RunE: p.WithServices(dockerCli, func(ctx context.Context, project *types.Project, services []string) error { @@ -128,6 +131,8 @@ func upCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service, ex flags.BoolVar(&up.wait, "wait", false, "Wait for services to be running|healthy. Implies detached mode.") flags.IntVar(&up.waitTimeout, "wait-timeout", 0, "Maximum duration to wait for the project to be running|healthy") flags.BoolVarP(&up.watch, "watch", "w", false, "Watch source code and rebuild/refresh containers when files are updated.") + flags.BoolVar(&up.navigationMenu, "menu", false, "Enable interactive shortcuts when running attached (Experimental). Incompatible with --detach.") + flags.MarkHidden("menu") //nolint:errcheck return upCmd } @@ -161,7 +166,7 @@ func runUp( ctx context.Context, dockerCli command.Cli, backend api.Service, - _ *experimental.State, + experimentals *experimental.State, createOptions createOptions, upOptions upOptions, buildOptions buildOptions, @@ -181,6 +186,9 @@ func runUp( if err != nil { return err } + if !upOptions.navigationMenuChanged { + upOptions.navigationMenu = SetUnchangedOption(ComposeMenu, experimentals.NavBar()) + } var build *api.BuildOptions if !createOptions.noBuild { @@ -253,15 +261,16 @@ func runUp( return backend.Up(ctx, project, api.UpOptions{ Create: create, Start: api.StartOptions{ - Project: project, - Attach: consumer, - AttachTo: attach, - ExitCodeFrom: upOptions.exitCodeFrom, - CascadeStop: upOptions.cascadeStop, - Wait: upOptions.wait, - WaitTimeout: timeout, - Watch: upOptions.watch, - Services: services, + Project: project, + Attach: consumer, + AttachTo: attach, + ExitCodeFrom: upOptions.exitCodeFrom, + CascadeStop: upOptions.cascadeStop, + Wait: upOptions.wait, + WaitTimeout: timeout, + Watch: upOptions.watch, + Services: services, + NavigationMenu: upOptions.navigationMenu, }, }) } diff --git a/cmd/formatter/ansi.go b/cmd/formatter/ansi.go new file mode 100644 index 00000000000..bcb03173cfa --- /dev/null +++ b/cmd/formatter/ansi.go @@ -0,0 +1,67 @@ +/* + Copyright 2024 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package formatter + +import ( + "fmt" + + "github.com/acarl005/stripansi" +) + +func ansi(code string) string { + return fmt.Sprintf("\033%s", code) +} +func SaveCursor() { + fmt.Print(ansi("7")) +} +func RestoreCursor() { + fmt.Print(ansi("8")) +} +func HideCursor() { + fmt.Print(ansi("[?25l")) +} +func ShowCursor() { + fmt.Print(ansi("[?25h")) +} +func MoveCursor(y, x int) { + fmt.Print(ansi(fmt.Sprintf("[%d;%dH", y, x))) +} +func MoveCursorX(pos int) { + fmt.Print(ansi(fmt.Sprintf("[%dG", pos))) +} +func ClearLine() { + // Does not move cursor from its current position + fmt.Print(ansi("[2K")) +} +func MoveCursorUp(lines int) { + // Does not add new lines + fmt.Print(ansi(fmt.Sprintf("[%dA", lines))) +} +func MoveCursorDown(lines int) { + // Does not add new lines + fmt.Print(ansi(fmt.Sprintf("[%dB", lines))) +} +func NewLine() { + // Like \n + fmt.Print("\012") +} +func lenAnsi(s string) int { + // len has into consideration ansi codes, if we want + // the len of the actual len(string) we need to strip + // all ansi codes + return len(stripansi.Strip(s)) +} diff --git a/cmd/formatter/colors.go b/cmd/formatter/colors.go index e050a6aefcf..cf6a4b56c37 100644 --- a/cmd/formatter/colors.go +++ b/cmd/formatter/colors.go @@ -35,6 +35,18 @@ var names = []string{ "white", } +const ( + BOLD = "1" + FAINT = "2" + ITALIC = "3" + UNDERLINE = "4" +) + +const ( + RESET = "0" + CYAN = "36" +) + const ( // Never use ANSI codes Never = "never" @@ -72,12 +84,17 @@ var monochrome = func(s string) string { return s } -func ansiColor(code, s string) string { - return fmt.Sprintf("%s%s%s", ansi(code), s, ansi("0")) +func ansiColor(code, s string, formatOpts ...string) string { + return fmt.Sprintf("%s%s%s", ansiColorCode(code, formatOpts...), s, ansiColorCode("0")) } -func ansi(code string) string { - return fmt.Sprintf("\033[%sm", code) +// Everything about ansiColorCode color https://hyperskill.org/learn/step/18193 +func ansiColorCode(code string, formatOpts ...string) string { + res := "\033[" + for _, c := range formatOpts { + res = fmt.Sprintf("%s%s;", res, c) + } + return fmt.Sprintf("%s%sm", res, code) } func makeColorFunc(code string) colorFunc { diff --git a/cmd/formatter/logs.go b/cmd/formatter/logs.go index 465aa229a91..7d2c5451296 100644 --- a/cmd/formatter/logs.go +++ b/cmd/formatter/logs.go @@ -102,19 +102,29 @@ func (l *logConsumer) Err(container, message string) { l.write(l.stderr, container, message) } +var navColor = makeColorFunc("90") + func (l *logConsumer) write(w io.Writer, container, message string) { if l.ctx.Err() != nil { return } - p := l.getPresenter(container) - timestamp := time.Now().Format(jsonmessage.RFC3339NanoFixed) - for _, line := range strings.Split(message, "\n") { - if l.timestamp { - fmt.Fprintf(w, "%s%s%s\n", p.prefix, timestamp, line) - } else { - fmt.Fprintf(w, "%s%s\n", p.prefix, line) + printFn := func() { + p := l.getPresenter(container) + timestamp := time.Now().Format(jsonmessage.RFC3339NanoFixed) + for _, line := range strings.Split(message, "\n") { + ClearLine() + if l.timestamp { + fmt.Fprintf(w, "%s%s%s\n", p.prefix, timestamp, line) + } else { + fmt.Fprintf(w, "%s%s\n", p.prefix, line) + } } } + if KeyboardManager != nil { + KeyboardManager.PrintKeyboardInfo(printFn) + } else { + printFn() + } } func (l *logConsumer) Status(container, msg string) { diff --git a/cmd/formatter/shortcut.go b/cmd/formatter/shortcut.go new file mode 100644 index 00000000000..feb3eb95fce --- /dev/null +++ b/cmd/formatter/shortcut.go @@ -0,0 +1,321 @@ +/* + Copyright 2024 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package formatter + +import ( + "context" + "errors" + "fmt" + "math" + "os" + "syscall" + "time" + + "github.com/buger/goterm" + "github.com/compose-spec/compose-go/v2/types" + "github.com/docker/compose/v2/internal/tracing" + "github.com/docker/compose/v2/pkg/api" + "github.com/docker/compose/v2/pkg/watch" + "github.com/eiannone/keyboard" + "github.com/hashicorp/go-multierror" + "github.com/skratchdot/open-golang/open" +) + +const DISPLAY_ERROR_TIME = 10 + +type KeyboardError struct { + err error + timeStart time.Time +} + +func (ke *KeyboardError) shouldDisplay() bool { + return ke.err != nil && int(time.Since(ke.timeStart).Seconds()) < DISPLAY_ERROR_TIME +} + +func (ke *KeyboardError) printError(height int, info string) { + if ke.shouldDisplay() { + errMessage := ke.err.Error() + + MoveCursor(height-linesOffset(info)-linesOffset(errMessage)-1, 0) + ClearLine() + + fmt.Print(errMessage) + } +} + +func (ke *KeyboardError) addError(prefix string, err error) { + ke.timeStart = time.Now() + + prefix = ansiColor(CYAN, fmt.Sprintf("%s →", prefix), BOLD) + errorString := fmt.Sprintf("%s %s", prefix, err.Error()) + + ke.err = errors.New(errorString) +} + +func (ke *KeyboardError) error() string { + return ke.err.Error() +} + +type KeyboardWatch struct { + Watcher watch.Notify + Watching bool + WatchFn func(ctx context.Context, project *types.Project, services []string, options api.WatchOptions) error + Ctx context.Context + Cancel context.CancelFunc +} + +func (kw *KeyboardWatch) isWatching() bool { + return kw.Watching +} + +func (kw *KeyboardWatch) switchWatching() { + kw.Watching = !kw.Watching +} + +func (kw *KeyboardWatch) newContext(ctx context.Context) context.CancelFunc { + ctx, cancel := context.WithCancel(ctx) + kw.Ctx = ctx + kw.Cancel = cancel + return cancel +} + +type KEYBOARD_LOG_LEVEL int + +const ( + NONE KEYBOARD_LOG_LEVEL = 0 + INFO KEYBOARD_LOG_LEVEL = 1 + DEBUG KEYBOARD_LOG_LEVEL = 2 +) + +type LogKeyboard struct { + kError KeyboardError + Watch KeyboardWatch + IsDockerDesktopActive bool + IsWatchConfigured bool + logLevel KEYBOARD_LOG_LEVEL + signalChannel chan<- os.Signal +} + +var KeyboardManager *LogKeyboard +var eg multierror.Group + +func NewKeyboardManager(ctx context.Context, isDockerDesktopActive, isWatchConfigured bool, + sc chan<- os.Signal, + watchFn func(ctx context.Context, + project *types.Project, + services []string, + options api.WatchOptions, + ) error, +) { + km := LogKeyboard{} + km.IsDockerDesktopActive = isDockerDesktopActive + km.IsWatchConfigured = isWatchConfigured + km.logLevel = INFO + + km.Watch.Watching = false + km.Watch.WatchFn = watchFn + + km.signalChannel = sc + + KeyboardManager = &km + + HideCursor() +} + +func (lk *LogKeyboard) PrintKeyboardInfo(printFn func()) { + printFn() + + if lk.logLevel == INFO { + lk.printNavigationMenu() + } +} + +// Creates space to print error and menu string +func (lk *LogKeyboard) createBuffer(lines int) { + allocateSpace(lines) + + if lk.kError.shouldDisplay() { + extraLines := linesOffset(lk.kError.error()) + 1 + allocateSpace(extraLines) + lines += extraLines + } + + infoMessage := lk.navigationMenu() + extraLines := linesOffset(infoMessage) + 1 + allocateSpace(extraLines) + lines += extraLines + + if lines > 0 { + MoveCursorUp(lines) + } +} + +func (lk *LogKeyboard) printNavigationMenu() { + lk.clearNavigationMenu() + lk.createBuffer(0) + + if lk.logLevel == INFO { + height := goterm.Height() + menu := lk.navigationMenu() + + MoveCursorX(0) + SaveCursor() + + lk.kError.printError(height, menu) + + MoveCursor(height-linesOffset(menu), 0) + ClearLine() + fmt.Print(menu) + + MoveCursorX(0) + RestoreCursor() + } +} + +func (lk *LogKeyboard) navigationMenu() string { + var options string + var openDDInfo string + if lk.IsDockerDesktopActive { + openDDInfo = shortcutKeyColor("v") + navColor(" View in Docker Desktop") + } + var watchInfo string + if openDDInfo != "" { + watchInfo = navColor(" ") + } + var isEnabled = " Enable" + if lk.Watch.Watching { + isEnabled = " Disable" + } + watchInfo = watchInfo + shortcutKeyColor("w") + navColor(isEnabled+" Watch") + return options + openDDInfo + watchInfo +} + +func (lk *LogKeyboard) clearNavigationMenu() { + height := goterm.Height() + MoveCursorX(0) + SaveCursor() + for i := 0; i < height; i++ { + MoveCursorDown(1) + ClearLine() + } + RestoreCursor() +} + +func (lk *LogKeyboard) openDockerDesktop(ctx context.Context, project *types.Project) { + if !lk.IsDockerDesktopActive { + return + } + eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "menu/gui", tracing.SpanOptions{}, + func(ctx context.Context) error { + link := fmt.Sprintf("docker-desktop://dashboard/apps/%s", project.Name) + err := open.Run(link) + if err != nil { + err = fmt.Errorf("Could not open Docker Desktop") + lk.keyboardError("View", err) + } + return err + }), + ) +} + +func (lk *LogKeyboard) keyboardError(prefix string, err error) { + lk.kError.addError(prefix, err) + + lk.printNavigationMenu() + timer1 := time.NewTimer((DISPLAY_ERROR_TIME + 1) * time.Second) + go func() { + <-timer1.C + lk.printNavigationMenu() + }() +} + +func (lk *LogKeyboard) StartWatch(ctx context.Context, project *types.Project, options api.UpOptions) { + if !lk.IsWatchConfigured { + eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{}, + func(ctx context.Context) error { + err := fmt.Errorf("Watch is not yet configured. Learn more: %s", ansiColor(CYAN, "https://docs.docker.com/compose/file-watch/")) + lk.keyboardError("Watch", err) + return err + })) + return + } + lk.Watch.switchWatching() + if !lk.Watch.isWatching() { + lk.Watch.Cancel() + } else { + eg.Go(tracing.EventWrapFuncForErrGroup(ctx, "menu/watch", tracing.SpanOptions{}, + func(ctx context.Context) error { + lk.Watch.newContext(ctx) + buildOpts := *options.Create.Build + buildOpts.Quiet = true + return lk.Watch.WatchFn(lk.Watch.Ctx, project, options.Start.Services, api.WatchOptions{ + Build: &buildOpts, + LogTo: options.Start.Attach, + }) + })) + } +} + +func (lk *LogKeyboard) KeyboardClose() { + _ = keyboard.Close() +} + +func (lk *LogKeyboard) HandleKeyEvents(event keyboard.KeyEvent, ctx context.Context, project *types.Project, options api.UpOptions) { + switch kRune := event.Rune; kRune { + case 'v': + lk.openDockerDesktop(ctx, project) + case 'w': + lk.StartWatch(ctx, project, options) + } + switch key := event.Key; key { + case keyboard.KeyCtrlC: + lk.KeyboardClose() + + lk.clearNavigationMenu() + ShowCursor() + + lk.logLevel = NONE + if lk.Watch.Watching && lk.Watch.Cancel != nil { + lk.Watch.Cancel() + _ = eg.Wait().ErrorOrNil() // Need to print this ? + } + // will notify main thread to kill and will handle gracefully + lk.signalChannel <- syscall.SIGINT + case keyboard.KeyEnter: + lk.printNavigationMenu() + } +} + +func allocateSpace(lines int) { + for i := 0; i < lines; i++ { + ClearLine() + NewLine() + MoveCursorX(0) + } +} + +func linesOffset(s string) int { + return int(math.Floor(float64(lenAnsi(s)) / float64(goterm.Width()))) +} + +func shortcutKeyColor(key string) string { + foreground := "38;2" + black := "0;0;0" + background := "48;2" + white := "255;255;255" + return ansiColor(foreground+";"+black+";"+background+";"+white, key, BOLD) +} diff --git a/docs/reference/docker_compose_up.yaml b/docs/reference/docker_compose_up.yaml index ec269c8b85b..967a16379f9 100644 --- a/docs/reference/docker_compose_up.yaml +++ b/docs/reference/docker_compose_up.yaml @@ -108,6 +108,17 @@ options: experimentalcli: false kubernetes: false swarm: false + - option: menu + value_type: bool + default_value: "false" + description: | + Enable interactive shortcuts when running attached (Experimental). Incompatible with --detach. + deprecated: false + hidden: true + experimental: false + experimentalcli: false + kubernetes: false + swarm: false - option: no-attach value_type: stringArray default_value: '[]' diff --git a/go.mod b/go.mod index 50b3dccf6e0..74fa634f962 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.21 require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/Microsoft/go-winio v0.6.1 + github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d github.com/buger/goterm v1.0.4 github.com/compose-spec/compose-go/v2 v2.0.2 github.com/containerd/console v1.0.4 @@ -34,6 +35,7 @@ require ( github.com/otiai10/copy v1.14.0 github.com/r3labs/sse v0.0.0-20210224172625-26fe804710bc github.com/sirupsen/logrus v1.9.3 + github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.4 @@ -85,6 +87,7 @@ require ( github.com/docker/docker-credential-helpers v0.8.0 // indirect github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect github.com/docker/go-metrics v0.0.1 // indirect + github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fvbommel/sortorder v1.0.2 // indirect diff --git a/go.sum b/go.sum index 379671ad689..aa184e37d2d 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDe github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= +github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= +github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 h1:aM1rlcoLz8y5B2r4tTLMiVTrMtpfY0O8EScKJxaSaEc= @@ -150,6 +152,8 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= github.com/dvsekhvalnov/jose2go v0.0.0-20170216131308-f21a8cedbbae/go.mod h1:7BvyPhdbLxMXIYTFPLsyJRFMsKmOZnQmzh6Gb+uquuM= +github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg= +github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -440,6 +444,8 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/spdx/tools-golang v0.5.3 h1:ialnHeEYUC4+hkm5vJm4qz2x+oEJbS0mAMFrNXdQraY= github.com/spdx/tools-golang v0.5.3/go.mod h1:/ETOahiAo96Ob0/RAIBmFZw6XN0yTnyr/uFZm2NTMhI= github.com/spf13/cast v0.0.0-20150508191742-4d07383ffe94 h1:JmfC365KywYwHB946TTiQWEb8kqPY+pybPLoGE9GgVk= diff --git a/internal/tracing/keyboard_metrics.go b/internal/tracing/keyboard_metrics.go new file mode 100644 index 00000000000..3317879dda1 --- /dev/null +++ b/internal/tracing/keyboard_metrics.go @@ -0,0 +1,36 @@ +/* + Copyright 2024 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package tracing + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" +) + +func KeyboardMetrics(ctx context.Context, enabled, isDockerDesktopActive, isWatchConfigured bool) { + commandAvailable := []string{} + if isDockerDesktopActive { + commandAvailable = append(commandAvailable, "gui") + } + if isWatchConfigured { + commandAvailable = append(commandAvailable, "watch") + } + AddAttributeToSpan(ctx, + attribute.Bool("navmenu.enabled", enabled), + attribute.StringSlice("navmenu.command_available", commandAvailable)) +} diff --git a/internal/tracing/wrap.go b/internal/tracing/wrap.go index 812ad22f689..d78b53191b1 100644 --- a/internal/tracing/wrap.go +++ b/internal/tracing/wrap.go @@ -19,6 +19,8 @@ package tracing import ( "context" + "github.com/acarl005/stripansi" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" semconv "go.opentelemetry.io/otel/semconv/v1.19.0" "go.opentelemetry.io/otel/trace" @@ -80,12 +82,16 @@ func EventWrapFuncForErrGroup(ctx context.Context, eventName string, opts SpanOp eventOpts := opts.EventOptions() err := fn(ctx) - if err != nil { - eventOpts = append(eventOpts, trace.WithAttributes(semconv.ExceptionMessage(err.Error()))) + eventOpts = append(eventOpts, trace.WithAttributes(semconv.ExceptionMessage(stripansi.Strip(err.Error())))) } span.AddEvent(eventName, eventOpts...) return err } } + +func AddAttributeToSpan(ctx context.Context, attr ...attribute.KeyValue) { + span := trace.SpanFromContext(ctx) + span.SetAttributes(attr...) +} diff --git a/pkg/api/api.go b/pkg/api/api.go index 53fce924e80..ac6a2e0db6d 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -217,8 +217,9 @@ type StartOptions struct { Wait bool WaitTimeout time.Duration // Services passed in the command line to be started - Services []string - Watch bool + Services []string + Watch bool + NavigationMenu bool } // RestartOptions group options of the Restart API diff --git a/pkg/compose/compose.go b/pkg/compose/compose.go index e01ab931e47..29c5f26d617 100644 --- a/pkg/compose/compose.go +++ b/pkg/compose/compose.go @@ -81,7 +81,7 @@ func (s *composeService) Close() error { if s.dockerCli != nil { errs = append(errs, s.dockerCli.Client().Close()) } - if s.desktopCli != nil { + if s.isDesktopIntegrationActive() { errs = append(errs, s.desktopCli.Close()) } return errors.Join(errs...) @@ -320,3 +320,7 @@ func (s *composeService) RuntimeVersion(ctx context.Context) (string, error) { return runtimeVersion.val, runtimeVersion.err } + +func (s *composeService) isDesktopIntegrationActive() bool { + return s.desktopCli != nil +} diff --git a/pkg/compose/create.go b/pkg/compose/create.go index d6cf1890846..b35c9814f33 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -152,7 +152,7 @@ func (s *composeService) ensureProjectVolumes(ctx context.Context, project *type } err := func() error { - if s.experiments.AutoFileShares() && s.desktopCli != nil { + if s.experiments.AutoFileShares() && s.isDesktopIntegrationActive() { // collect all the bind mount paths and try to set up file shares in // Docker Desktop for them var paths []string diff --git a/pkg/compose/down.go b/pkg/compose/down.go index d11bfabeeac..ca1f58fe9c9 100644 --- a/pkg/compose/down.go +++ b/pkg/compose/down.go @@ -145,7 +145,7 @@ func (s *composeService) ensureVolumesDown(ctx context.Context, project *types.P }) } - if s.experiments.AutoFileShares() && s.desktopCli != nil { + if s.experiments.AutoFileShares() && s.isDesktopIntegrationActive() { ops = append(ops, func() error { desktop.RemoveFileSharesForProject(ctx, s.desktopCli, project.Name) return nil diff --git a/pkg/compose/up.go b/pkg/compose/up.go index 94b37a97687..4d69ee5ec47 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -25,9 +25,11 @@ import ( "github.com/compose-spec/compose-go/v2/types" "github.com/docker/cli/cli" + "github.com/docker/compose/v2/cmd/formatter" "github.com/docker/compose/v2/internal/tracing" "github.com/docker/compose/v2/pkg/api" "github.com/docker/compose/v2/pkg/progress" + "github.com/eiannone/keyboard" "github.com/hashicorp/go-multierror" ) @@ -73,6 +75,7 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options first := true gracefulTeardown := func() { printer.Cancel() + formatter.ClearLine() fmt.Fprintln(s.stdinfo(), "Gracefully stopping... (press Ctrl+C again to force)") eg.Go(func() error { err := s.Stop(context.Background(), project.Name, api.StopOptions{ @@ -85,6 +88,23 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options }) first = false } + + var kEvents <-chan keyboard.KeyEvent + isWatchConfigured := s.shouldWatch(project) + isDockerDesktopActive := s.isDesktopIntegrationActive() + + tracing.KeyboardMetrics(ctx, options.Start.NavigationMenu, isDockerDesktopActive, isWatchConfigured) + if options.Start.NavigationMenu { + kEvents, err = keyboard.GetKeys(100) + if err != nil { + panic(err) + } + formatter.NewKeyboardManager(ctx, isDockerDesktopActive, isWatchConfigured, signalChan, s.Watch) + if options.Start.Watch { + formatter.KeyboardManager.StartWatch(ctx, project, options) + } + defer formatter.KeyboardManager.KeyboardClose() + } for { select { case <-doneCh: @@ -105,6 +125,8 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options }) return nil } + case event := <-kEvents: + formatter.KeyboardManager.HandleKeyEvents(event, ctx, project, options) } } }) @@ -124,7 +146,7 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options return err }) - if options.Start.Watch { + if options.Start.Watch && !options.Start.NavigationMenu { eg.Go(func() error { buildOpts := *options.Create.Build buildOpts.Quiet = true diff --git a/pkg/compose/watch.go b/pkg/compose/watch.go index bc5469f880c..1cf61157e8c 100644 --- a/pkg/compose/watch.go +++ b/pkg/compose/watch.go @@ -65,6 +65,17 @@ func (s *composeService) getSyncImplementation(project *types.Project) (sync.Syn return sync.NewTar(project.Name, tarDockerClient{s: s}), nil } +func (s *composeService) shouldWatch(project *types.Project) bool { + var shouldWatch bool + for i := range project.Services { + service := project.Services[i] + + if service.Develop != nil && service.Develop.Watch != nil { + shouldWatch = true + } + } + return shouldWatch +} func (s *composeService) Watch(ctx context.Context, project *types.Project, services []string, options api.WatchOptions) error { //nolint: gocyclo var err error @@ -159,17 +170,15 @@ func (s *composeService) Watch(ctx context.Context, project *types.Project, serv return err } watching = true - eg.Go(func() error { defer watcher.Close() //nolint:errcheck return s.watch(ctx, project, service.Name, options, watcher, syncer, config.Watch) }) } - if !watching { return fmt.Errorf("none of the selected services is configured for watch, consider setting an 'develop' section") } - options.LogTo.Log(api.WatchLogger, "watch enabled") + options.LogTo.Log(api.WatchLogger, "Watch enabled") return eg.Wait() } @@ -189,10 +198,12 @@ func (s *composeService) watch(ctx context.Context, project *types.Project, name events := make(chan fileEvent) batchEvents := batchDebounceEvents(ctx, s.clock, quietPeriod, events) + quit := make(chan bool) go func() { for { select { case <-ctx.Done(): + quit <- true return case batch := <-batchEvents: start := time.Now() @@ -208,9 +219,11 @@ func (s *composeService) watch(ctx context.Context, project *types.Project, name for { select { - case <-ctx.Done(): + case <-quit: + options.LogTo.Log(api.WatchLogger, "Watch disabled") return nil case err := <-watcher.Errors(): + options.LogTo.Err(api.WatchLogger, "Watch disabled with errors") return err case event := <-watcher.Events(): hostPath := event.Path()