mirror of
https://github.com/docker/compose.git
synced 2026-05-13 13:58:02 +00:00
feat: make hook hint deep links clickable using OSC 8 terminal hyperlinks
Wrap the docker-desktop://dashboard/logs URL in OSC 8 escape sequences with underline styling so it appears as a clickable link in supported terminals. Respects NO_COLOR and COMPOSE_ANSI=never to suppress escapes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
This commit is contained in:
parent
6ed7625d43
commit
efb090183f
4 changed files with 153 additions and 11 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
const dockerLogsHint = "View and search logs for all containers in one place\nwith Docker Desktop's Logs view. " + 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -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},
|
||||
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -112,7 +115,7 @@ 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(), "")
|
||||
}
|
||||
|
|
@ -120,6 +123,53 @@ func TestHandleHook_ComposeUpDetached(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
cmd/formatter/ansi_test.go
Normal file
50
cmd/formatter/ansi_test.go
Normal file
|
|
@ -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…
Add table
Add a link
Reference in a new issue