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
43 changes: 39 additions & 4 deletions cmd/compose/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,58 @@ package compose
import (
"encoding/json"
"io"
"os"

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

"github.com/docker/compose/v5/cmd/formatter"
)

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
func composeLogsHint() string {
return "Filter, search, and stream logs from all your Compose services\nin one place with Docker Desktop's Logs view. " + hintLink(deepLink)
}

func dockerLogsHint() string {
return "View and search logs for all containers in one place\nwith Docker Desktop's Logs view. " + hintLink(deepLink)
}

// hintLink returns a clickable OSC 8 terminal hyperlink when ANSI is allowed,
// or the plain URL when ANSI output is suppressed via NO_COLOR or COMPOSE_ANSI.
func hintLink(url string) string {
if shouldDisableAnsi() {
return url
}
return formatter.OSC8Link(url, url)
}

const dockerLogsHint = "View and search logs for all containers in one place\nwith Docker Desktop's Logs view. " + deepLink
// shouldDisableAnsi checks whether ANSI escape sequences should be explicitly
// suppressed via environment variables. The hook runs as a separate subprocess
// where the normal PersistentPreRunE (which calls formatter.SetANSIMode) is
// skipped, so we check NO_COLOR and COMPOSE_ANSI directly.
//
// TTY detection is intentionally omitted: the hook produces a JSON response
// whose template is rendered by the parent Docker CLI process via
// PrintNextSteps (which itself emits bold ANSI unconditionally). The hook
// subprocess cannot reliably detect whether the parent's output is a terminal.
func shouldDisableAnsi() bool {
if noColor, ok := os.LookupEnv("NO_COLOR"); ok && noColor != "" {
return true
}
if v, ok := os.LookupEnv("COMPOSE_ANSI"); ok && v == formatter.Never {
return true
}
return false
}

// 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
template func() string
checkFlags func(flags map[string]string) bool
}

Expand Down Expand Up @@ -96,6 +131,6 @@ func handleHook(args []string, w io.Writer) error {
enc.SetEscapeHTML(false)
return enc.Encode(hooks.Response{
Type: hooks.NextSteps,
Template: hint.template,
Template: hint.template(),
})
}
56 changes: 53 additions & 3 deletions cmd/compose/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ package compose
import (
"bytes"
"encoding/json"
"strings"
"testing"

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

"github.com/docker/compose/v5/cmd/formatter"
)

func TestHandleHook_NoArgs(t *testing.T) {
Expand Down Expand Up @@ -52,7 +55,7 @@ func TestHandleHook_UnknownCommand(t *testing.T) {
func TestHandleHook_LogsCommand(t *testing.T) {
tests := []struct {
rootCmd string
wantHint string
wantHint func() string
}{
{rootCmd: "compose logs", wantHint: composeLogsHint},
{rootCmd: "logs", wantHint: dockerLogsHint},
Expand All @@ -68,7 +71,7 @@ func TestHandleHook_LogsCommand(t *testing.T) {

msg := unmarshalResponse(t, buf.Bytes())
assert.Equal(t, msg.Type, hooks.NextSteps)
assert.Equal(t, msg.Template, tt.wantHint)
assert.Equal(t, msg.Template, tt.wantHint())
})
}
}
Expand Down Expand Up @@ -112,14 +115,61 @@ func TestHandleHook_ComposeUpDetached(t *testing.T) {

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

func TestHandleHook_HintContainsOSC8Link(t *testing.T) {
// Ensure ANSI is not suppressed by the test runner environment
t.Setenv("NO_COLOR", "")
t.Setenv("COMPOSE_ANSI", "")
data := marshalHookData(t, hooks.Request{
RootCmd: "compose logs",
})
var buf bytes.Buffer
err := handleHook([]string{data}, &buf)
assert.NilError(t, err)

msg := unmarshalResponse(t, buf.Bytes())
// Verify the template contains the OSC 8 hyperlink sequence
wantLink := formatter.OSC8Link(deepLink, deepLink)
assert.Assert(t, len(wantLink) > len(deepLink), "OSC8Link should wrap the URL with escape sequences")
assert.Assert(t, strings.Contains(msg.Template, wantLink), "hint should contain OSC 8 hyperlink")
}

func TestHandleHook_NoColorDisablesOsc8(t *testing.T) {
t.Setenv("NO_COLOR", "1")
data := marshalHookData(t, hooks.Request{
RootCmd: "compose logs",
})
var buf bytes.Buffer
err := handleHook([]string{data}, &buf)
assert.NilError(t, err)

msg := unmarshalResponse(t, buf.Bytes())
// With NO_COLOR set, the hint should contain the plain URL without escape sequences
assert.Assert(t, strings.Contains(msg.Template, deepLink), "hint should contain the deep link URL")
assert.Assert(t, !strings.Contains(msg.Template, "\033"), "hint should not contain ANSI escape sequences")
}

func TestHandleHook_ComposeAnsiNeverDisablesOsc8(t *testing.T) {
t.Setenv("COMPOSE_ANSI", "never")
data := marshalHookData(t, hooks.Request{
RootCmd: "compose logs",
})
var buf bytes.Buffer
err := handleHook([]string{data}, &buf)
assert.NilError(t, err)

msg := unmarshalResponse(t, buf.Bytes())
assert.Assert(t, strings.Contains(msg.Template, deepLink), "hint should contain the deep link URL")
assert.Assert(t, !strings.Contains(msg.Template, "\033"), "hint should not contain ANSI escape sequences")
}

func marshalHookData(t *testing.T, data hooks.Request) string {
t.Helper()
b, err := json.Marshal(data)
Expand Down
15 changes: 11 additions & 4 deletions cmd/formatter/ansi.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,20 @@ func moveCursorDown(lines int) {
}

func newLine() {
// Like \n
fmt.Print("\012")
}

// lenAnsi returns the visible length of s after stripping ANSI escape codes.
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))
}

// OSC8Link wraps text in an OSC 8 terminal hyperlink escape sequence with
// underline styling, making it clickable in supported terminal emulators.
// When ANSI output is disabled, returns the plain text without escape sequences.
func OSC8Link(url, text string) string {
if disableAnsi {
return text
}
return "\033]8;;" + url + "\033\\\033[4m" + text + "\033[24m\033]8;;\033\\"
}
50 changes: 50 additions & 0 deletions cmd/formatter/ansi_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
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 (
"testing"

"gotest.tools/v3/assert"
)

func TestOSC8Link(t *testing.T) {
disableAnsi = false
t.Cleanup(func() { disableAnsi = false })

got := OSC8Link("http://example.com", "click here")
want := "\x1b]8;;http://example.com\x1b\\\x1b[4mclick here\x1b[24m\x1b]8;;\x1b\\"
assert.Equal(t, got, want)
}

func TestOSC8Link_AnsiDisabled(t *testing.T) {
disableAnsi = true
t.Cleanup(func() { disableAnsi = false })

got := OSC8Link("http://example.com", "click here")
assert.Equal(t, got, "click here")
}

func TestOSC8Link_URLAsDisplayText(t *testing.T) {
disableAnsi = false
t.Cleanup(func() { disableAnsi = false })

url := "docker-desktop://dashboard/logs"
got := OSC8Link(url, url)
want := "\x1b]8;;docker-desktop://dashboard/logs\x1b\\\x1b[4mdocker-desktop://dashboard/logs\x1b[24m\x1b]8;;\x1b\\"
assert.Equal(t, got, want)
}
Loading