diff --git a/internal/container/sanitize.go b/internal/container/sanitize.go index d708017f..a9f2b062 100644 --- a/internal/container/sanitize.go +++ b/internal/container/sanitize.go @@ -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) } diff --git a/internal/container/sanitize_test.go b/internal/container/sanitize_test.go index 3ac3b3b3..f5e442f1 100644 --- a/internal/container/sanitize_test.go +++ b/internal/container/sanitize_test.go @@ -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) } }) } diff --git a/internal/web/logs.go b/internal/web/logs.go index dfc847b3..77aa0a39 100644 --- a/internal/web/logs.go +++ b/internal/web/logs.go @@ -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") }