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
1 change: 1 addition & 0 deletions internal/ui/log_buffer.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func (b *logBuffer) clear() {
}

func (b *logBuffer) append(raw string) bool {
raw = sanitizeLogLine(raw)
entry := logEntry{
sequence: b.nextSequence,
raw: raw,
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/log_buffer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func TestLogBufferMatchesCaseInsensitivelyWithoutANSI(t *testing.T) {

buffer.setQuery("error")

if got, want := buffer.visibleLines(), []string{"\x1b[31mERROR\x1b[0m database unavailable"}; !slices.Equal(got, want) {
if got, want := buffer.visibleLines(), []string{"\x1b[31mERROR\x1b[0m database unavailable\x1b[0m"}; !slices.Equal(got, want) {
t.Fatalf("visible lines = %q, want %q", got, want)
}
}
Expand Down
10 changes: 6 additions & 4 deletions internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ func New(definitions []procfile.Process, source processSource, path, workingDire
filterInput.CharLimit = 256
views := make([]processView, len(definitions))
for index, definition := range definitions {
definition.Name = sanitizeDisplayText(definition.Name)
definition.Command = sanitizeDisplayText(definition.Command)
view := viewport.New(0, 0)
view.MouseWheelDelta = 3
views[index] = processView{
Expand All @@ -122,10 +124,10 @@ func New(definitions []procfile.Process, source processSource, path, workingDire
return Model{
processes: views,
source: source,
path: path,
workingDirectory: abbreviateHomeDirectory(workingDirectory, homeDirectory),
branch: branch,
version: version,
path: sanitizeDisplayText(path),
workingDirectory: sanitizeDisplayText(abbreviateHomeDirectory(workingDirectory, homeDirectory)),
branch: sanitizeDisplayText(branch),
version: sanitizeDisplayText(version),
startupSpinner: startupSpinner,
filterInput: filterInput,
filterProcess: -1,
Expand Down
147 changes: 147 additions & 0 deletions internal/ui/terminal_text.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package ui

import (
"strings"
"unicode"
"unicode/utf8"
)

const (
escapeByte = '\x1b'
deleteByte = '\x7f'
csiByte = '\x9b'
dcsByte = '\x90'
oscByte = '\x9d'
sosByte = '\x98'
pmByte = '\x9e'
apcByte = '\x9f'
stByte = '\x9c'
belByte = '\x07'

maxSGRSequenceBytes = 128
)

// sanitizeLogLine retains printable text, tabs, and conventional SGR styling.
// All other terminal controls are discarded before the line reaches a renderer.
func sanitizeLogLine(value string) string {
return sanitizeTerminalText(value, true, true)
}

// sanitizeDisplayText produces plain, single-line text for UI labels.
func sanitizeDisplayText(value string) string {
return sanitizeTerminalText(value, false, false)
}

func sanitizeTerminalText(value string, allowSGR, allowTab bool) string {
var output strings.Builder
output.Grow(len(value))
retainedSGR := false

for offset := 0; offset < len(value); {
current := value[offset]
switch {
case current == escapeByte:
end := consumeEscapeSequence(value, offset)
sequence := value[offset:end]
if allowSGR && validSGRSequence(sequence) {
output.WriteString(sequence)
retainedSGR = true
}
offset = end
case current == csiByte:
offset = consumeCSISequence(value, offset+1)
case current == dcsByte || current == sosByte || current == oscByte || current == pmByte || current == apcByte:
offset = consumeControlString(value, offset+1, current == oscByte)
case current < utf8.RuneSelf:
if current == '\t' && allowTab {
output.WriteByte(current)
} else if current >= ' ' && current != deleteByte {
output.WriteByte(current)
}
offset++
default:
r, size := utf8.DecodeRuneInString(value[offset:])
if r == utf8.RuneError && size == 1 {
output.WriteRune(utf8.RuneError)
offset++
continue
}
if !unicode.IsControl(r) {
output.WriteString(value[offset : offset+size])
}
offset += size
}
}

if retainedSGR && !strings.HasSuffix(output.String(), "\x1b[0m") && !strings.HasSuffix(output.String(), "\x1b[m") {
output.WriteString("\x1b[0m")
}
return output.String()
}

func consumeEscapeSequence(value string, start int) int {
if start+1 >= len(value) {
return len(value)
}

switch value[start+1] {
case '[':
return consumeCSISequence(value, start+2)
case 'P', 'X', '^', '_':
return consumeControlString(value, start+2, false)
case ']':
return consumeControlString(value, start+2, true)
}

offset := start + 1
for offset < len(value) && value[offset] >= 0x20 && value[offset] <= 0x2f {
offset++
}
if offset < len(value) && value[offset] >= 0x30 && value[offset] <= 0x7e {
return offset + 1
}
return max(start+1, offset)
}

func consumeCSISequence(value string, offset int) int {
for offset < len(value) {
current := value[offset]
offset++
if current >= 0x40 && current <= 0x7e {
return offset
}
}
return len(value)
}

func consumeControlString(value string, offset int, osc bool) int {
for offset < len(value) {
switch value[offset] {
case belByte:
if osc {
return offset + 1
}
case stByte:
return offset + 1
case escapeByte:
if offset+1 < len(value) && value[offset+1] == '\\' {
return offset + 2
}
}
offset++
}
return len(value)
}

func validSGRSequence(sequence string) bool {
if len(sequence) < 3 || len(sequence) > maxSGRSequenceBytes ||
!strings.HasPrefix(sequence, "\x1b[") || sequence[len(sequence)-1] != 'm' {
return false
}
for _, current := range sequence[2 : len(sequence)-1] {
if (current < '0' || current > '9') && current != ';' && current != ':' {
return false
}
}
return true
}
154 changes: 154 additions & 0 deletions internal/ui/terminal_text_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package ui

import (
"strings"
"testing"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"

"github.com/maful/inline/internal/process"
"github.com/maful/inline/internal/procfile"
)

func TestSanitizeLogLinePreservesTextAndSGR(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "printable Unicode and tab", input: "ready\tcafé 👩‍💻", want: "ready\tcafé 👩‍💻"},
{name: "basic color and reset", input: "\x1b[1;31merror\x1b[0m", want: "\x1b[1;31merror\x1b[0m"},
{name: "256 color", input: "\x1b[38;5;214mwarning", want: "\x1b[38;5;214mwarning\x1b[0m"},
{name: "true color with colon syntax", input: "\x1b[38:2::12:34:56mvalue", want: "\x1b[38:2::12:34:56mvalue\x1b[0m"},
{name: "invalid UTF-8", input: string([]byte{'a', 0xff, 'b'}), want: "a�b"},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := sanitizeLogLine(test.input); got != test.want {
t.Fatalf("sanitizeLogLine() = %q, want %q", got, test.want)
}
})
}
}

func TestSanitizeLogLineDiscardsTerminalControls(t *testing.T) {
tests := []struct {
name string
control string
}{
{name: "OSC 52 with BEL", control: "\x1b]52;c;dGVzdA==\x07"},
{name: "window title with ST", control: "\x1b]0;inline-test\x1b\\"},
{name: "hyperlink", control: "\x1b]8;;https://example.com\x1b\\"},
{name: "screen erase", control: "\x1b[2J"},
{name: "cursor movement", control: "\x1b[4A"},
{name: "terminal mode", control: "\x1b[?25l"},
{name: "device control string", control: "\x1bP1;2|payload\x1b\\"},
{name: "application program command", control: "\x1b_payload\x1b\\"},
{name: "privacy message", control: "\x1b^payload\x1b\\"},
{name: "start of string", control: "\x1bXpayload\x1b\\"},
{name: "C0 controls", control: "\a\b\r\n"},
{name: "C1 OSC", control: string([]byte{oscByte}) + "0;title" + string([]byte{stByte})},
{name: "C1 CSI", control: string([]byte{csiByte}) + "2J"},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
input := "before" + test.control + "after"
if got, want := sanitizeLogLine(input), "beforeafter"; got != want {
t.Fatalf("sanitizeLogLine() = %q, want %q", got, want)
}
})
}
}

func TestSanitizeLogLineDiscardsIncompleteSequences(t *testing.T) {
for _, input := range []string{
"prefix\x1b",
"prefix\x1b[31",
"prefix\x1b]52;c;payload",
"prefix\x1bPpayload",
} {
if got, want := sanitizeLogLine(input), "prefix"; got != want {
t.Errorf("sanitizeLogLine(%q) = %q, want %q", input, got, want)
}
}
}

func TestSanitizeDisplayTextRemovesStylesAndWhitespaceControls(t *testing.T) {
input := "web\t\x1b[31mred\x1b[0m\n"
if got, want := sanitizeDisplayText(input), "webred"; got != want {
t.Fatalf("sanitizeDisplayText() = %q, want %q", got, want)
}
}

func TestValidSGRSequenceRejectsOtherCSIAndOversizedParameters(t *testing.T) {
if validSGRSequence("\x1b[2J") || validSGRSequence("\x1b[?25m") {
t.Fatal("accepted a non-SGR control sequence")
}
if validSGRSequence("\x1b[" + strings.Repeat("1", maxSGRSequenceBytes) + "m") {
t.Fatal("accepted an oversized SGR sequence")
}
}

func TestModelSanitizesProcessOutputBeforeStorageAndRendering(t *testing.T) {
payloads := map[string]string{
"OSC 52": "\x1b]52;c;dGVzdA==\x07",
"screen erase": "\x1b[2J",
"window title": "\x1b]0;inline-test\x07",
}

for name, payload := range payloads {
t.Run(name, func(t *testing.T) {
model := newTestModel()
updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
model = updated.(Model)
updated, _ = model.Update(process.Event{Index: 0, Line: "before" + payload + "after"})
model = updated.(Model)

if got, want := model.processes[0].logs.visibleLines(), []string{"beforeafter"}; len(got) != 1 || got[0] != want[0] {
t.Fatalf("stored logs = %q, want %q", got, want)
}
if strings.Contains(model.View(), payload) {
t.Fatal("control sequence survived the unfiltered view")
}

model.processes[0].logs.setQuery("beforeafter")
model.processes[0].dirty = true
model.refreshSelected()
if strings.Contains(model.View(), payload) {
t.Fatal("control sequence survived the filtered view")
}
})
}
}

func TestModelSanitizesDisplayMetadataWithoutChangingSourceDefinitions(t *testing.T) {
payload := "\x1b]0;inline-test\x07"
definitions := []procfile.Process{{Name: "web" + payload, Command: "echo" + payload + " done"}}
original := definitions[0]
model := New(
definitions,
&fakeSource{events: make(chan process.Event)},
"Procfile"+payload,
"/tmp/project"+payload,
"main"+payload,
"v1"+payload,
)
updated, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
view := updated.(Model).View()

if strings.Contains(view, payload) {
t.Fatal("control sequence survived metadata rendering")
}
plain := ansi.Strip(view)
for _, want := range []string{"web", "$ echo done", "Procfile", "/tmp/project", "main", "v1"} {
if !strings.Contains(plain, want) {
t.Errorf("rendered metadata does not contain %q", want)
}
}
if definitions[0] != original {
t.Fatalf("source definition changed from %#v to %#v", original, definitions[0])
}
}