Skip to content

Commit 182defa

Browse files
gloursclaude
andcommitted
feat: add Docker Desktop Logs view hints and navigation shortcut
Add CLI hooks handler to show "What's next:" hints pointing to the Docker Desktop Logs view after `docker logs`, `docker compose logs`, and `docker compose up -d`. Add `l` keyboard shortcut in the `compose up` navigation menu to open the Logs view, gated on Docker Desktop feature flag and settings. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> Signed-off-by: Guillaume Lours <[email protected]>
1 parent ae92bef commit 182defa

File tree

10 files changed

+452
-16
lines changed

10 files changed

+452
-16
lines changed

cmd/compose/compose.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ const PluginName = "compose"
409409

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

415415
type BackendOptions struct {

cmd/compose/hooks.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
Copyright 2020 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package compose
18+
19+
import (
20+
"encoding/json"
21+
"io"
22+
23+
"github.com/docker/cli/cli-plugins/hooks"
24+
"github.com/docker/cli/cli-plugins/metadata"
25+
"github.com/spf13/cobra"
26+
)
27+
28+
const deepLink = "docker-desktop://dashboard/logs"
29+
30+
const composeLogsHint = "Filter, search, and stream logs from all your Compose services\nin one place with Docker Desktop's Logs view. " + deepLink
31+
32+
const dockerLogsHint = "View and search logs for all containers in one place\nwith Docker Desktop's Logs view. " + deepLink
33+
34+
// hookHint defines a hint that can be returned by the hooks handler.
35+
// When checkFlags is nil, the hint is always returned for the matching command.
36+
// When checkFlags is set, the hint is only returned if the check passes.
37+
type hookHint struct {
38+
template string
39+
checkFlags func(flags map[string]string) bool
40+
}
41+
42+
// hooksHints maps hook root commands to their hint definitions.
43+
var hooksHints = map[string]hookHint{
44+
// standalone "docker logs" (not a compose subcommand)
45+
"logs": {template: dockerLogsHint},
46+
"compose logs": {template: composeLogsHint},
47+
"compose up": {
48+
template: composeLogsHint,
49+
checkFlags: func(flags map[string]string) bool {
50+
// Only show the hint when running in detached mode
51+
_, hasDetach := flags["detach"]
52+
_, hasD := flags["d"]
53+
return hasDetach || hasD
54+
},
55+
},
56+
}
57+
58+
// HooksCommand returns the hidden subcommand that the Docker CLI invokes
59+
// after command execution when the compose plugin has hooks configured.
60+
// Docker Desktop is responsible for registering which commands trigger hooks
61+
// and for gating on feature flags/settings — the hook handler simply
62+
// responds with the appropriate hint message.
63+
func HooksCommand() *cobra.Command {
64+
return &cobra.Command{
65+
Use: metadata.HookSubcommandName,
66+
Hidden: true,
67+
// Override PersistentPreRunE to prevent the parent's PersistentPreRunE
68+
// (plugin initialization) from running for hook invocations.
69+
PersistentPreRunE: func(*cobra.Command, []string) error { return nil },
70+
RunE: func(cmd *cobra.Command, args []string) error {
71+
return handleHook(args, cmd.OutOrStdout())
72+
},
73+
}
74+
}
75+
76+
func handleHook(args []string, w io.Writer) error {
77+
if len(args) == 0 {
78+
return nil
79+
}
80+
81+
var hookData hooks.Request
82+
if err := json.Unmarshal([]byte(args[0]), &hookData); err != nil {
83+
return nil
84+
}
85+
86+
hint, ok := hooksHints[hookData.RootCmd]
87+
if !ok {
88+
return nil
89+
}
90+
91+
if hint.checkFlags != nil && !hint.checkFlags(hookData.Flags) {
92+
return nil
93+
}
94+
95+
enc := json.NewEncoder(w)
96+
enc.SetEscapeHTML(false)
97+
return enc.Encode(hooks.Response{
98+
Type: hooks.NextSteps,
99+
Template: hint.template,
100+
})
101+
}

cmd/compose/hooks_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
Copyright 2020 Docker Compose CLI authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package compose
18+
19+
import (
20+
"bytes"
21+
"encoding/json"
22+
"testing"
23+
24+
"github.com/docker/cli/cli-plugins/hooks"
25+
"gotest.tools/v3/assert"
26+
)
27+
28+
func TestHandleHook_NoArgs(t *testing.T) {
29+
var buf bytes.Buffer
30+
err := handleHook(nil, &buf)
31+
assert.NilError(t, err)
32+
assert.Equal(t, buf.String(), "")
33+
}
34+
35+
func TestHandleHook_InvalidJSON(t *testing.T) {
36+
var buf bytes.Buffer
37+
err := handleHook([]string{"not json"}, &buf)
38+
assert.NilError(t, err)
39+
assert.Equal(t, buf.String(), "")
40+
}
41+
42+
func TestHandleHook_UnknownCommand(t *testing.T) {
43+
data := marshalHookData(t, hooks.Request{
44+
RootCmd: "compose push",
45+
})
46+
var buf bytes.Buffer
47+
err := handleHook([]string{data}, &buf)
48+
assert.NilError(t, err)
49+
assert.Equal(t, buf.String(), "")
50+
}
51+
52+
func TestHandleHook_LogsCommand(t *testing.T) {
53+
tests := []struct {
54+
rootCmd string
55+
wantHint string
56+
}{
57+
{rootCmd: "compose logs", wantHint: composeLogsHint},
58+
{rootCmd: "logs", wantHint: dockerLogsHint},
59+
}
60+
for _, tt := range tests {
61+
t.Run(tt.rootCmd, func(t *testing.T) {
62+
data := marshalHookData(t, hooks.Request{
63+
RootCmd: tt.rootCmd,
64+
})
65+
var buf bytes.Buffer
66+
err := handleHook([]string{data}, &buf)
67+
assert.NilError(t, err)
68+
69+
msg := unmarshalResponse(t, buf.Bytes())
70+
assert.Equal(t, msg.Type, hooks.NextSteps)
71+
assert.Equal(t, msg.Template, tt.wantHint)
72+
})
73+
}
74+
}
75+
76+
func TestHandleHook_ComposeUpDetached(t *testing.T) {
77+
tests := []struct {
78+
name string
79+
flags map[string]string
80+
wantHint bool
81+
}{
82+
{
83+
name: "with --detach flag",
84+
flags: map[string]string{"detach": ""},
85+
wantHint: true,
86+
},
87+
{
88+
name: "with -d flag",
89+
flags: map[string]string{"d": ""},
90+
wantHint: true,
91+
},
92+
{
93+
name: "without detach flag",
94+
flags: map[string]string{"build": ""},
95+
wantHint: false,
96+
},
97+
{
98+
name: "no flags",
99+
flags: map[string]string{},
100+
wantHint: false,
101+
},
102+
}
103+
for _, tt := range tests {
104+
t.Run(tt.name, func(t *testing.T) {
105+
data := marshalHookData(t, hooks.Request{
106+
RootCmd: "compose up",
107+
Flags: tt.flags,
108+
})
109+
var buf bytes.Buffer
110+
err := handleHook([]string{data}, &buf)
111+
assert.NilError(t, err)
112+
113+
if tt.wantHint {
114+
msg := unmarshalResponse(t, buf.Bytes())
115+
assert.Equal(t, msg.Template, composeLogsHint)
116+
} else {
117+
assert.Equal(t, buf.String(), "")
118+
}
119+
})
120+
}
121+
}
122+
123+
func marshalHookData(t *testing.T, data hooks.Request) string {
124+
t.Helper()
125+
b, err := json.Marshal(data)
126+
assert.NilError(t, err)
127+
return string(b)
128+
}
129+
130+
func unmarshalResponse(t *testing.T, data []byte) hooks.Response {
131+
t.Helper()
132+
var msg hooks.Response
133+
err := json.Unmarshal(data, &msg)
134+
assert.NilError(t, err)
135+
return msg
136+
}

cmd/formatter/shortcut.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,15 @@ type LogKeyboard struct {
9494
Watch *KeyboardWatch
9595
Detach func()
9696
IsDockerDesktopActive bool
97+
IsLogsViewEnabled bool
9798
logLevel KEYBOARD_LOG_LEVEL
9899
signalChannel chan<- os.Signal
99100
}
100101

101-
func NewKeyboardManager(isDockerDesktopActive bool, sc chan<- os.Signal) *LogKeyboard {
102+
func NewKeyboardManager(isDockerDesktopActive, isLogsViewEnabled bool, sc chan<- os.Signal) *LogKeyboard {
102103
return &LogKeyboard{
103104
IsDockerDesktopActive: isDockerDesktopActive,
105+
IsLogsViewEnabled: isLogsViewEnabled,
104106
logLevel: INFO,
105107
signalChannel: sc,
106108
}
@@ -173,6 +175,10 @@ func (lk *LogKeyboard) navigationMenu() string {
173175
items = append(items, shortcutKeyColor("o")+navColor(" View Config"))
174176
}
175177

178+
if lk.IsLogsViewEnabled {
179+
items = append(items, shortcutKeyColor("l")+navColor(" View Logs"))
180+
}
181+
176182
isEnabled := " Enable"
177183
if lk.Watch != nil && lk.Watch.Watching {
178184
isEnabled = " Disable"
@@ -232,6 +238,24 @@ func (lk *LogKeyboard) openDDComposeUI(ctx context.Context, project *types.Proje
232238
}()
233239
}
234240

241+
func (lk *LogKeyboard) openDDLogsView(ctx context.Context) {
242+
if !lk.IsLogsViewEnabled {
243+
return
244+
}
245+
go func() {
246+
_ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui/logsview", tracing.SpanOptions{},
247+
func(ctx context.Context) error {
248+
link := "docker-desktop://dashboard/logs"
249+
err := open.Run(link)
250+
if err != nil {
251+
err = fmt.Errorf("could not open Docker Desktop Logs view: %w", err)
252+
lk.keyboardError("View Logs", err)
253+
}
254+
return err
255+
})()
256+
}()
257+
}
258+
235259
func (lk *LogKeyboard) openDDWatchDocs(ctx context.Context, project *types.Project) {
236260
go func() {
237261
_ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui/watch", tracing.SpanOptions{},
@@ -311,6 +335,8 @@ func (lk *LogKeyboard) HandleKeyEvents(ctx context.Context, event keyboard.KeyEv
311335
lk.ToggleWatch(ctx, options)
312336
case 'o':
313337
lk.openDDComposeUI(ctx, project)
338+
case 'l':
339+
lk.openDDLogsView(ctx)
314340
}
315341
switch key := event.Key; key {
316342
case keyboard.KeyCtrlC:

cmd/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ func pluginMain() {
4444
}
4545

4646
cmd := commands.RootCommand(cli, backendOptions)
47+
cmd.AddCommand(commands.HooksCommand())
4748
originalPreRunE := cmd.PersistentPreRunE
4849
cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
4950
// initialize the cli instance

0 commit comments

Comments
 (0)