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
2 changes: 1 addition & 1 deletion cmd/compose/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ const PluginName = "compose"

// RunningAsStandalone detects when running as a standalone program
func RunningAsStandalone() bool {
return len(os.Args) < 2 || os.Args[1] != metadata.MetadataSubcommandName && os.Args[1] != PluginName
return len(os.Args) < 2 || os.Args[1] != metadata.MetadataSubcommandName && os.Args[1] != metadata.HookSubcommandName && os.Args[1] != PluginName
}

type BackendOptions struct {
Expand Down
101 changes: 101 additions & 0 deletions cmd/compose/hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
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 (
"encoding/json"
"io"

"github.com/docker/cli/cli-plugins/hooks"
"github.com/docker/cli/cli-plugins/metadata"
"github.com/spf13/cobra"
)

const deepLink = "docker-desktop://dashboard/logs"

const composeLogsHint = "Filter, search, and stream logs from all your Compose services\nin one place with Docker Desktop's Logs view. " + deepLink

const dockerLogsHint = "View and search logs for all containers in one place\nwith Docker Desktop's Logs view. " + deepLink

// hookHint defines a hint that can be returned by the hooks handler.
// When checkFlags is nil, the hint is always returned for the matching command.
// When checkFlags is set, the hint is only returned if the check passes.
type hookHint struct {
template string
checkFlags func(flags map[string]string) bool
}

// hooksHints maps hook root commands to their hint definitions.
var hooksHints = map[string]hookHint{
// standalone "docker logs" (not a compose subcommand)
"logs": {template: dockerLogsHint},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unclear to me why we provide docker logs hint inside compose. Maybe just because we have no place in docker/cli for this and compose used to be installed by 99% users?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's because Compose is in charge of this hint globally, it could trigger the hint display to any other cli plugins too.

"compose logs": {template: composeLogsHint},
"compose up": {
template: composeLogsHint,
checkFlags: func(flags map[string]string) bool {
// Only show the hint when running in detached mode
_, hasDetach := flags["detach"]
_, hasD := flags["d"]
return hasDetach || hasD
},
},
}

// HooksCommand returns the hidden subcommand that the Docker CLI invokes
// after command execution when the compose plugin has hooks configured.
// Docker Desktop is responsible for registering which commands trigger hooks
// and for gating on feature flags/settings — the hook handler simply
// responds with the appropriate hint message.
func HooksCommand() *cobra.Command {
return &cobra.Command{
Use: metadata.HookSubcommandName,
Hidden: true,
// Override PersistentPreRunE to prevent the parent's PersistentPreRunE
// (plugin initialization) from running for hook invocations.
PersistentPreRunE: func(*cobra.Command, []string) error { return nil },
Comment on lines +63 to +69
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment and the no-op PersistentPreRunE won’t actually prevent the parent command’s PersistentPreRunE from running (Cobra runs parent persistent pre-runs before the subcommand’s). To avoid plugin initialization for hooks, add the gating in the parent PersistentPreRunE (e.g., in cmd/main.go wrapper) or restructure command setup; otherwise hooks will still pay the full init cost.

Suggested change
func HooksCommand() *cobra.Command {
return &cobra.Command{
Use: metadata.HookSubcommandName,
Hidden: true,
// Override PersistentPreRunE to prevent the parent's PersistentPreRunE
// (plugin initialization) from running for hook invocations.
PersistentPreRunE: func(*cobra.Command, []string) error { return nil },
//
// Note: a child command's PersistentPreRunE does not prevent a parent's
// PersistentPreRunE from running in Cobra. Any optimization to skip parent
// plugin initialization for hook invocations must be implemented where the
// parent command is configured.
func HooksCommand() *cobra.Command {
return &cobra.Command{
Use: metadata.HookSubcommandName,
Hidden: true,

Copilot uses AI. Check for mistakes.
RunE: func(cmd *cobra.Command, args []string) error {
return handleHook(args, cmd.OutOrStdout())
},
}
}

func handleHook(args []string, w io.Writer) error {
if len(args) == 0 {
return nil
}

var hookData hooks.Request
if err := json.Unmarshal([]byte(args[0]), &hookData); err != nil {
return nil
}

hint, ok := hooksHints[hookData.RootCmd]
if !ok {
return nil
}

if hint.checkFlags != nil && !hint.checkFlags(hookData.Flags) {
return nil
}

enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
return enc.Encode(hooks.Response{
Type: hooks.NextSteps,
Template: hint.template,
})
}
136 changes: 136 additions & 0 deletions cmd/compose/hooks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
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 (
"bytes"
"encoding/json"
"testing"

"github.com/docker/cli/cli-plugins/hooks"
"gotest.tools/v3/assert"
)

func TestHandleHook_NoArgs(t *testing.T) {
var buf bytes.Buffer
err := handleHook(nil, &buf)
assert.NilError(t, err)
assert.Equal(t, buf.String(), "")
}

func TestHandleHook_InvalidJSON(t *testing.T) {
var buf bytes.Buffer
err := handleHook([]string{"not json"}, &buf)
assert.NilError(t, err)
assert.Equal(t, buf.String(), "")
}

func TestHandleHook_UnknownCommand(t *testing.T) {
data := marshalHookData(t, hooks.Request{
RootCmd: "compose push",
})
var buf bytes.Buffer
err := handleHook([]string{data}, &buf)
assert.NilError(t, err)
assert.Equal(t, buf.String(), "")
}

func TestHandleHook_LogsCommand(t *testing.T) {
tests := []struct {
rootCmd string
wantHint string
}{
{rootCmd: "compose logs", wantHint: composeLogsHint},
{rootCmd: "logs", wantHint: dockerLogsHint},
}
for _, tt := range tests {
t.Run(tt.rootCmd, func(t *testing.T) {
data := marshalHookData(t, hooks.Request{
RootCmd: tt.rootCmd,
})
var buf bytes.Buffer
err := handleHook([]string{data}, &buf)
assert.NilError(t, err)

msg := unmarshalResponse(t, buf.Bytes())
assert.Equal(t, msg.Type, hooks.NextSteps)
assert.Equal(t, msg.Template, tt.wantHint)
})
}
}

func TestHandleHook_ComposeUpDetached(t *testing.T) {
tests := []struct {
name string
flags map[string]string
wantHint bool
}{
{
name: "with --detach flag",
flags: map[string]string{"detach": ""},
wantHint: true,
},
{
name: "with -d flag",
flags: map[string]string{"d": ""},
wantHint: true,
},
{
name: "without detach flag",
flags: map[string]string{"build": ""},
wantHint: false,
},
{
name: "no flags",
flags: map[string]string{},
wantHint: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := marshalHookData(t, hooks.Request{
RootCmd: "compose up",
Flags: tt.flags,
})
var buf bytes.Buffer
err := handleHook([]string{data}, &buf)
assert.NilError(t, err)

if tt.wantHint {
msg := unmarshalResponse(t, buf.Bytes())
assert.Equal(t, msg.Template, composeLogsHint)
} else {
assert.Equal(t, buf.String(), "")
}
})
}
}

func marshalHookData(t *testing.T, data hooks.Request) string {
t.Helper()
b, err := json.Marshal(data)
assert.NilError(t, err)
return string(b)
}

func unmarshalResponse(t *testing.T, data []byte) hooks.Response {
t.Helper()
var msg hooks.Response
err := json.Unmarshal(data, &msg)
assert.NilError(t, err)
return msg
}
28 changes: 27 additions & 1 deletion cmd/formatter/shortcut.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,15 @@ type LogKeyboard struct {
Watch *KeyboardWatch
Detach func()
IsDockerDesktopActive bool
IsLogsViewEnabled bool
logLevel KEYBOARD_LOG_LEVEL
signalChannel chan<- os.Signal
}

func NewKeyboardManager(isDockerDesktopActive bool, sc chan<- os.Signal) *LogKeyboard {
func NewKeyboardManager(isDockerDesktopActive, isLogsViewEnabled bool, sc chan<- os.Signal) *LogKeyboard {
return &LogKeyboard{
IsDockerDesktopActive: isDockerDesktopActive,
IsLogsViewEnabled: isLogsViewEnabled,
logLevel: INFO,
signalChannel: sc,
}
Expand Down Expand Up @@ -173,6 +175,10 @@ func (lk *LogKeyboard) navigationMenu() string {
items = append(items, shortcutKeyColor("o")+navColor(" View Config"))
}

if lk.IsLogsViewEnabled {
items = append(items, shortcutKeyColor("l")+navColor(" View Logs"))
}

isEnabled := " Enable"
if lk.Watch != nil && lk.Watch.Watching {
isEnabled = " Disable"
Expand Down Expand Up @@ -232,6 +238,24 @@ func (lk *LogKeyboard) openDDComposeUI(ctx context.Context, project *types.Proje
}()
}

func (lk *LogKeyboard) openDDLogsView(ctx context.Context) {
if !lk.IsLogsViewEnabled {
return
}
go func() {
_ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui/logsview", tracing.SpanOptions{},
func(ctx context.Context) error {
link := "docker-desktop://dashboard/logs"
err := open.Run(link)
if err != nil {
err = fmt.Errorf("could not open Docker Desktop Logs view: %w", err)
lk.keyboardError("View Logs", err)
}
return err
})()
}()
}

func (lk *LogKeyboard) openDDWatchDocs(ctx context.Context, project *types.Project) {
go func() {
_ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui/watch", tracing.SpanOptions{},
Expand Down Expand Up @@ -311,6 +335,8 @@ func (lk *LogKeyboard) HandleKeyEvents(ctx context.Context, event keyboard.KeyEv
lk.ToggleWatch(ctx, options)
case 'o':
lk.openDDComposeUI(ctx, project)
case 'l':
lk.openDDLogsView(ctx)
}
switch key := event.Key; key {
case keyboard.KeyCtrlC:
Expand Down
1 change: 1 addition & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func pluginMain() {
}

cmd := commands.RootCommand(cli, backendOptions)
cmd.AddCommand(commands.HooksCommand())
originalPreRunE := cmd.PersistentPreRunE
cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
// initialize the cli instance
Expand Down
Loading
Loading