fix: expand grouped log lines when copying to clipboard (#4771)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Amir Raminfar
2026-06-01 07:30:34 -07:00
committed by GitHub
parent b37de0a16d
commit 51e13d5f97
3 changed files with 53 additions and 24 deletions
+15 -13
View File
@@ -2,18 +2,20 @@ package container
import "strings"
// stripControlBytes removes C0 control characters except tab (\x09), newline
// (\x0a) and carriage return (\x0d). A NUL byte terminates the clipboard string
// on Windows, dropping everything after it.
func stripControlBytes(r rune) rune {
if (r >= 0x00 && r <= 0x08) || r == 0x0b || r == 0x0c || (r >= 0x0e && r <= 0x1f) {
return -1
// PlainText renders a log event as plain text for clipboard copy, stripping
// ANSI escape sequences. Grouped events carry their lines in fragments and
// have an empty RawMessage, so they are expanded one line per fragment;
// otherwise every grouped multi-line entry collapses to a single blank line on
// copy.
func (e *LogEvent) PlainText() string {
if e.Type == LogTypeGroup {
if fragments, ok := e.Message.([]LogFragment); ok {
lines := make([]string, len(fragments))
for i, fragment := range fragments {
lines[i] = StripANSI(fragment.Message)
}
return strings.Join(lines, "\n")
}
}
return r
}
// SanitizeForPlainText strips ANSI escape sequences and control bytes so log
// text is safe to copy to the clipboard or open in a text editor.
func SanitizeForPlainText(str string) string {
return strings.Map(stripControlBytes, StripANSI(str))
return StripANSI(e.RawMessage)
}
+34 -9
View File
@@ -2,23 +2,48 @@ package container
import "testing"
func TestSanitizeForPlainText(t *testing.T) {
func TestLogEventPlainText(t *testing.T) {
tests := []struct {
name string
input string
event *LogEvent
expected string
}{
{"strips NUL bytes that truncate clipboard text on Windows", "before\x00after", "beforeafter"},
{"strips other C0 control bytes", "a\x01b\x07c\x1fd", "abcd"},
{"preserves tab, newline and carriage return", "a\tb\nc\r\nd", "a\tb\nc\r\nd"},
{"removes ANSI escape sequences", "\x1b[31mred\x1b[0m text", "red text"},
{"leaves plain text untouched", "plain log line", "plain log line"},
{
name: "single event returns ANSI-stripped raw message",
event: &LogEvent{Type: LogTypeSingle, RawMessage: "\x1b[31mred\x1b[0m text"},
expected: "red text",
},
{
name: "complex event returns raw message",
event: &LogEvent{Type: LogTypeComplex, RawMessage: `{"level":"info"}`},
expected: `{"level":"info"}`,
},
{
name: "grouped event expands every fragment line (otherwise lost on copy)",
event: &LogEvent{
Type: LogTypeGroup,
Message: []LogFragment{
{Message: "Job (17) starting"},
{Message: "Job (17) done in 0.006s"},
{Message: "Job (17) completed"},
},
},
expected: "Job (17) starting\nJob (17) done in 0.006s\nJob (17) completed",
},
{
name: "grouped fragments are ANSI-stripped",
event: &LogEvent{
Type: LogTypeGroup,
Message: []LogFragment{{Message: "\x1b[31mred\x1b[0m"}, {Message: "plain"}},
},
expected: "red\nplain",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := SanitizeForPlainText(tt.input); got != tt.expected {
t.Errorf("SanitizeForPlainText(%q) = %q, want %q", tt.input, got, tt.expected)
if got := tt.event.PlainText(); got != tt.expected {
t.Errorf("PlainText() = %q, want %q", got, tt.expected)
}
})
}
+4 -2
View File
@@ -197,8 +197,10 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
}
}
if plainText {
// Strip ANSI and control bytes; a NUL byte truncates clipboard text on Windows
fmt.Fprintf(writer, "%s\n", container.SanitizeForPlainText(event.RawMessage))
// Expand grouped events into their fragment lines; grouped
// events store their lines in Message and have an empty
// RawMessage, so writing RawMessage alone drops every group.
fmt.Fprintf(writer, "%s\n", event.PlainText())
} else if err := encoder.Encode(event); err != nil {
log.Error().Err(err).Msg("error encoding log event")
}