Skip to content

Commit a7d9937

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) <noreply@anthropic.com> Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
1 parent ae92bef commit a7d9937

10 files changed

Lines changed: 441 additions & 16 deletions

File tree

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: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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+
"os"
23+
24+
"github.com/docker/cli/cli-plugins/hooks"
25+
"github.com/docker/cli/cli-plugins/metadata"
26+
"github.com/spf13/cobra"
27+
)
28+
29+
const logsViewHint = "View logs in Docker Desktop: docker-desktop://dashboard/logs"
30+
31+
// hookHint defines a hint that can be returned by the hooks handler.
32+
// When checkFlags is nil, the hint is always returned for the matching command.
33+
// When checkFlags is set, the hint is only returned if the check passes.
34+
type hookHint struct {
35+
template string
36+
checkFlags func(flags map[string]string) bool
37+
}
38+
39+
// hooksHints maps hook root commands to their hint definitions.
40+
var hooksHints = map[string]hookHint{
41+
"logs": {template: logsViewHint},
42+
"compose logs": {template: logsViewHint},
43+
"compose up": {
44+
template: logsViewHint,
45+
checkFlags: func(flags map[string]string) bool {
46+
// Only show the hint when running in detached mode
47+
_, hasDetach := flags["detach"]
48+
_, hasD := flags["d"]
49+
return hasDetach || hasD
50+
},
51+
},
52+
}
53+
54+
// HooksCommand returns the hidden subcommand that the Docker CLI invokes
55+
// after command execution when the compose plugin has hooks configured.
56+
// Docker Desktop is responsible for registering which commands trigger hooks
57+
// and for gating on feature flags/settings — the hook handler simply
58+
// responds with the appropriate hint message.
59+
func HooksCommand() *cobra.Command {
60+
return &cobra.Command{
61+
Use: metadata.HookSubcommandName,
62+
Hidden: true,
63+
// Override PersistentPreRunE to prevent the parent's PersistentPreRunE
64+
// (plugin initialization) from running for hook invocations.
65+
PersistentPreRunE: func(*cobra.Command, []string) error { return nil },
66+
RunE: func(cmd *cobra.Command, args []string) error {
67+
return handleHook(args, os.Stdout)
68+
},
69+
}
70+
}
71+
72+
func handleHook(args []string, w io.Writer) error {
73+
if len(args) == 0 {
74+
return nil
75+
}
76+
77+
var hookData hooks.Request
78+
if err := json.Unmarshal([]byte(args[0]), &hookData); err != nil {
79+
return nil
80+
}
81+
82+
hint, ok := hooksHints[hookData.RootCmd]
83+
if !ok {
84+
return nil
85+
}
86+
87+
if hint.checkFlags != nil && !hint.checkFlags(hookData.Flags) {
88+
return nil
89+
}
90+
91+
enc := json.NewEncoder(w)
92+
enc.SetEscapeHTML(false)
93+
return enc.Encode(hooks.Response{
94+
Type: hooks.NextSteps,
95+
Template: hint.template,
96+
})
97+
}

cmd/compose/hooks_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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+
for _, cmd := range []string{"logs", "compose logs"} {
54+
t.Run(cmd, func(t *testing.T) {
55+
data := marshalHookData(t, hooks.Request{
56+
RootCmd: cmd,
57+
})
58+
var buf bytes.Buffer
59+
err := handleHook([]string{data}, &buf)
60+
assert.NilError(t, err)
61+
62+
msg := unmarshalResponse(t, buf.Bytes())
63+
assert.Equal(t, msg.Type, hooks.NextSteps)
64+
assert.Equal(t, msg.Template, logsViewHint)
65+
})
66+
}
67+
}
68+
69+
func TestHandleHook_ComposeUpDetached(t *testing.T) {
70+
tests := []struct {
71+
name string
72+
flags map[string]string
73+
wantHint bool
74+
}{
75+
{
76+
name: "with --detach flag",
77+
flags: map[string]string{"detach": ""},
78+
wantHint: true,
79+
},
80+
{
81+
name: "with -d flag",
82+
flags: map[string]string{"d": ""},
83+
wantHint: true,
84+
},
85+
{
86+
name: "without detach flag",
87+
flags: map[string]string{"build": ""},
88+
wantHint: false,
89+
},
90+
{
91+
name: "no flags",
92+
flags: map[string]string{},
93+
wantHint: false,
94+
},
95+
}
96+
for _, tt := range tests {
97+
t.Run(tt.name, func(t *testing.T) {
98+
data := marshalHookData(t, hooks.Request{
99+
RootCmd: "compose up",
100+
Flags: tt.flags,
101+
})
102+
var buf bytes.Buffer
103+
err := handleHook([]string{data}, &buf)
104+
assert.NilError(t, err)
105+
106+
if tt.wantHint {
107+
msg := unmarshalResponse(t, buf.Bytes())
108+
assert.Equal(t, msg.Template, logsViewHint)
109+
} else {
110+
assert.Equal(t, buf.String(), "")
111+
}
112+
})
113+
}
114+
}
115+
116+
func marshalHookData(t *testing.T, data hooks.Request) string {
117+
t.Helper()
118+
b, err := json.Marshal(data)
119+
assert.NilError(t, err)
120+
return string(b)
121+
}
122+
123+
func unmarshalResponse(t *testing.T, data []byte) hooks.Response {
124+
t.Helper()
125+
var msg hooks.Response
126+
err := json.Unmarshal(data, &msg)
127+
assert.NilError(t, err)
128+
return msg
129+
}

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")
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)