Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ linters:
desc: Use "gotest.tools/v3/assert" instead
- pkg: github.com/stretchr/testify/suite
desc: Do not use
library-not-cli:
# pkg/ is the SDK and internal/ its support code: neither may depend
# on the CLI. A CLI-only component the library needs goes behind an
# interface that cmd/ implements and injects (e.g. WithNavigationMenu).
files:
- "**/pkg/**"
- "**/internal/**"
- "!**/pkg/e2e/**"
deny:
- pkg: github.com/docker/compose/v5/cmd
desc: the library must not depend on the CLI, inject an implementation instead.
forbidigo:
analyze-types: true
forbid:
Expand Down
53 changes: 43 additions & 10 deletions cmd/formatter/shortcut.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"math"
"os"
"strings"
"sync"
"syscall"
"time"

Expand Down Expand Up @@ -73,13 +74,7 @@ func (ke *KeyboardError) error() string {

type KeyboardWatch struct {
Watching bool
Watcher Feature
}

// Feature is an compose feature that can be started/stopped by a menu command
type Feature interface {
Start(context.Context) error
Stop() error
Watcher api.Feature
}

type KEYBOARD_LOG_LEVEL int
Expand All @@ -98,6 +93,9 @@ type LogKeyboard struct {
IsLogsViewEnabled bool
logLevel KEYBOARD_LOG_LEVEL
signalChannel chan<- os.Signal
keys <-chan keyboard.KeyEvent
closeOnce sync.Once
closeErr error
}

func NewKeyboardManager(isDockerDesktopActive, isLogsViewEnabled bool, sc chan<- os.Signal) *LogKeyboard {
Expand Down Expand Up @@ -310,7 +308,42 @@ func (lk *LogKeyboard) ToggleWatch(ctx context.Context, options api.UpOptions) {
}
}

func (lk *LogKeyboard) HandleKeyEvents(ctx context.Context, event keyboard.KeyEvent, project *types.Project, options api.UpOptions) {
// Open puts the terminal in raw mode; keys are buffered until Run reads them.
func (lk *LogKeyboard) Open() error {
keys, err := keyboard.GetKeys(100)
if err != nil {
return err
}
lk.keys = keys
return nil
}

// Run handles the keys typed by the user until ctx is done.
func (lk *LogKeyboard) Run(ctx context.Context, project *types.Project, options api.UpOptions) {
if lk.keys == nil { // Open was not called, or failed
return
}
for {
select {
case <-ctx.Done():
return
case event, ok := <-lk.keys:
if !ok { // the keyboard was closed
return
}
lk.handleKeyEvent(ctx, event, project, options)
}
}
}

// Close restores the terminal. It can be called several times, from several
// goroutines (Ctrl+C is seen both as a key and as a signal).
func (lk *LogKeyboard) Close() error {
lk.closeOnce.Do(func() { lk.closeErr = keyboard.Close() })
Comment thread
ndeloof marked this conversation as resolved.
return lk.closeErr
}

func (lk *LogKeyboard) handleKeyEvent(ctx context.Context, event keyboard.KeyEvent, project *types.Project, options api.UpOptions) {
switch kRune := event.Rune; kRune {
case 'd':
lk.clearNavigationMenu()
Expand Down Expand Up @@ -341,7 +374,7 @@ func (lk *LogKeyboard) HandleKeyEvents(ctx context.Context, event keyboard.KeyEv
}
switch key := event.Key; key {
case keyboard.KeyCtrlC:
_ = keyboard.Close()
_ = lk.Close()
lk.clearNavigationMenu()
showCursor()

Expand All @@ -356,7 +389,7 @@ func (lk *LogKeyboard) HandleKeyEvents(ctx context.Context, event keyboard.KeyEv
}
}

func (lk *LogKeyboard) EnableWatch(enabled bool, watcher Feature) {
func (lk *LogKeyboard) EnableWatch(enabled bool, watcher api.Feature) {
lk.Watch = &KeyboardWatch{
Watching: enabled,
Watcher: watcher,
Expand Down
94 changes: 94 additions & 0 deletions cmd/formatter/shortcut_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
Copyright 2020 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"
"os"
"testing"
"time"

"github.com/compose-spec/compose-go/v2/types"
"github.com/eiannone/keyboard"
"gotest.tools/v3/assert"

"github.com/docker/compose/v5/pkg/api"
)

func TestRunHandlesKeysUntilContextIsDone(t *testing.T) {
detached := make(chan struct{})
keys := make(chan keyboard.KeyEvent, 1)
lk := NewKeyboardManager(false, false, make(chan os.Signal, 1))
lk.keys = keys
lk.EnableDetach(func() { close(detached) })

ctx, cancel := context.WithCancel(t.Context())
done := make(chan struct{})
go func() {
lk.Run(ctx, &types.Project{}, api.UpOptions{})
close(done)
}()

keys <- keyboard.KeyEvent{Rune: 'd'}
select {
case <-detached:
case <-time.After(5 * time.Second):
t.Fatal("the detach key was not handled")
}

cancel()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("Run must return once its context is done")
}
assert.NilError(t, lk.Close(), "Close must be harmless when the keyboard was never opened")
assert.NilError(t, lk.Close(), "Close must be idempotent")
}

func TestRunReturnsWhenKeyboardIsClosed(t *testing.T) {
keys := make(chan keyboard.KeyEvent)
lk := NewKeyboardManager(false, false, make(chan os.Signal, 1))
lk.keys = keys

done := make(chan struct{})
go func() {
lk.Run(t.Context(), &types.Project{}, api.UpOptions{})
close(done)
}()

close(keys) // what keyboard.Close does to the channel it handed out
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("Run must return once the keyboard channel is closed")
}
}

func TestRunWithoutOpenReturnsImmediately(t *testing.T) {
lk := NewKeyboardManager(false, false, make(chan os.Signal, 1))
done := make(chan struct{})
go func() {
lk.Run(t.Context(), &types.Project{}, api.UpOptions{})
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("Run must not wait for keys that can't come")
}
}
4 changes: 4 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/docker/compose/v5/cmd/cmdtrace"
"github.com/docker/compose/v5/cmd/compatibility"
commands "github.com/docker/compose/v5/cmd/compose"
"github.com/docker/compose/v5/cmd/formatter"
"github.com/docker/compose/v5/cmd/prompt"
"github.com/docker/compose/v5/internal"
"github.com/docker/compose/v5/pkg/compose"
Expand All @@ -40,6 +41,9 @@ func pluginMain() {
backendOptions := &commands.BackendOptions{
Options: []compose.Option{
compose.WithPrompt(prompt.NewPrompt(cli.In(), cli.Out()).Confirm),
compose.WithNavigationMenu(func(isDockerDesktopActive, isLogsViewEnabled bool, signals chan<- os.Signal) compose.NavigationMenu {
return formatter.NewKeyboardManager(isDockerDesktopActive, isLogsViewEnabled, signals)
}),
},
}

Expand Down
1 change: 1 addition & 0 deletions docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ options allow you to configure I/O streams, concurrency limits, dry-run mode, an
- `WithStreams(out, err, in)` - Set all I/O streams at once
- `WithMaxConcurrency(int)` - Limit the number of concurrent operations against the Docker API
- `WithPrompt(Prompt)` - Customize user confirmation behavior (use `AlwaysOkPrompt()` for non-interactive mode)
- `WithNavigationMenu(NavigationMenuFactory)` - Provide the interactive keyboard menu of a foreground `up` (no menu is displayed without it)
- `WithDryRun` - Run operations in dry-run mode without actually applying changes
- `WithContextInfo(api.ContextInfo)` - Set custom Docker context information
- `WithProxyConfig(map[string]string)` - Configure HTTP proxy settings for builds
Expand Down
26 changes: 26 additions & 0 deletions pkg/api/feature.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
Copyright 2020 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 api

import "context"

// Feature is a compose feature that can be started and stopped on demand,
// e.g. by a command of the interactive menu of `compose up`.
type Feature interface {
Start(context.Context) error
Stop() error
}
2 changes: 2 additions & 0 deletions pkg/compose/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,8 @@ type composeService struct {
dockerCli command.Cli
// prompt is used to interact with user and confirm actions
prompt Prompt
// navigationMenu creates the interactive menu of a foreground `up`, if any
navigationMenu NavigationMenuFactory
// eventBus collects tasks execution events
events api.EventProcessor

Expand Down
65 changes: 65 additions & 0 deletions pkg/compose/navigation_menu.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
Copyright 2020 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 compose

import (
"context"
"os"

"github.com/compose-spec/compose-go/v2/types"

"github.com/docker/compose/v5/pkg/api"
)

// NavigationMenu is the interactive keyboard menu of a foreground `compose up`.
// The terminal rendering is up to the implementation, which the CLI provides
// (see WithNavigationMenu): the library only drives it.
type NavigationMenu interface {
// Decorate returns a LogConsumer that keeps the menu displayed below the logs
Decorate(api.LogConsumer) api.LogConsumer
// EnableWatch makes the menu able to toggle watch, which is initially enabled or not
EnableWatch(enabled bool, watcher api.Feature)
// EnableDetach sets the function run when the user asks to detach
EnableDetach(detach func())
// Open takes control of the keyboard. Keys typed from then on are kept until
// Run consumes them. An error means the menu can't be used (no terminal, …)
// and the session carries on without it.
Open() error
// Run reacts to the keys typed by the user until ctx is done. It is called
// once, after Open and after the menu has been fully configured.
Run(ctx context.Context, project *types.Project, options api.UpOptions)
// Close gives the keyboard back. It can be called several times, and also
// when Open failed.
Close() error
}

// NavigationMenuFactory creates the NavigationMenu of an `up` session. It must
// return a nil interface, not a typed nil pointer, when there is no menu.
// isDockerDesktopActive and isLogsViewEnabled tell which Docker Desktop
// integrations the menu can offer, and signals is the channel the menu notifies
// with an interruption when the user asks to stop the application.
type NavigationMenuFactory func(isDockerDesktopActive, isLogsViewEnabled bool, signals chan<- os.Signal) NavigationMenu

// WithNavigationMenu configures the interactive menu displayed by a foreground
// `up` when api.StartOptions.NavigationMenu is set. Without it, no menu is
// displayed.
func WithNavigationMenu(factory NavigationMenuFactory) Option {
return func(s *composeService) error {
s.navigationMenu = factory
return nil
}
}
Loading
Loading