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
2 changes: 2 additions & 0 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ uppercase belongs to Labels:
| Ctrl+A | switch linked account |
| Ctrl+V | choose an Imbox cover |

While reading a thread, links can be selected without a mouse. Tab selects the next link in document order and Shift+Tab selects the previous one; both wrap at the ends. A fixed row above the shortcut bar shows the complete selected destination without moving the thread. Press Enter to open it; opening stays unavailable until the terminal is wide enough to show the complete destination. Press Escape once to clear the selection, and Escape again to leave the thread. A thread with no selectable links keeps the normal global Tab focus behavior. Existing OSC 8 mouse links remain available.

Most of those keep working while you are reading a thread, the way the web app's topic
toolbar stays live: `r`, `f`, `v`, `b`, `u`, `i`, `l`, `a`, `d`, `p` and `t` all act on
the thread on screen rather than on the list behind it. Filing a thread leaves it open in
Expand Down
5 changes: 0 additions & 5 deletions internal/markdown/contain.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,6 @@ func hyperlinkEnd(s string) (end, terminator int) {
return -1, 0
}

func allowedHyperlink(uri string) bool {
lower := strings.ToLower(uri)
return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "mailto:")
}

// stripAll removes every escape sequence and control character, newlines and tabs
// excepted, from text that contain could not vouch for.
func stripAll(out string) string {
Expand Down
184 changes: 184 additions & 0 deletions internal/markdown/linked.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package markdown

import (
"net/url"
"strings"

"github.com/charmbracelet/x/ansi"

"github.com/basecamp/hey-cli/internal/htmlutil"
)

// LinkOccurrence describes one selectable hyperlink in rendered document order.
// Lines are zero-based and inclusive. A wrapped link therefore has different
// StartLine and EndLine values.
type LinkOccurrence struct {
Destination string
StartLine int
EndLine int
}

// LinkedRender is the contained terminal rendering of one Markdown body and
// the safe hyperlink occurrences it contains.
type LinkedRender struct {
Text string
Links []LinkOccurrence
}

// RenderLinked renders one sealed Markdown body and records each selectable
// hyperlink in the order it appears. selected is a zero-based occurrence index;
// a negative value leaves all links unselected.
func RenderLinked(md htmlutil.Markdown, width, selected int) LinkedRender {
out := render(md.String(), width)
return linkedRender(out, selected)
}

func linkedRender(out string, selected int) LinkedRender {
var b strings.Builder
b.Grow(len(out))
links := make([]LinkOccurrence, 0)
line := 0
currentDestination := ""
currentShownDestination := false
currentShownText := ""
currentComplete := false
for i := 0; i < len(out); {
if strings.HasPrefix(out[i:], "\x1b]8;") {
openEnd, _, ok := hyperlink(out[i:])
if ok {
_, destination, found := hyperlinkDestination(openEnd)
if found && destination != "" {
contentStart := i + len(openEnd)
closeRel := strings.Index(out[contentStart:], "\x1b]8;")
if closeRel >= 0 {
closeStart := contentStart + closeRel
closeEnd, _, closeOK := hyperlink(out[closeStart:])
if closeOK {
if allowedHyperlink(destination) {
startLine := line
endLine := line + strings.Count(out[contentStart:closeStart], "\n")
content := out[contentStart:closeStart]
if destination != currentDestination || currentComplete {
links = append(links, LinkOccurrence{Destination: destination, StartLine: startLine, EndLine: endLine})
currentDestination = destination
currentShownDestination = false
currentShownText = ""
} else {
links[len(links)-1].EndLine = endLine
}
// Glamour renders a named anchor as one OSC 8 span for
// its label and one or more underlined spans for the shown
// destination. Only those destination spans complete the
// occurrence. A plain fallback URL has no style prefix.
if !currentShownDestination && (precededByUnderline(out, i) || content == destination) {
currentShownDestination = true
}
if currentShownDestination {
currentShownText += withoutWhitespace(ansi.Strip(content))
currentComplete = currentShownText == withoutWhitespace(destination)
} else {
currentComplete = false
}

b.WriteString(openEnd)
if len(links)-1 == selected {
b.WriteString("\x1b[7m")
b.WriteString(selectedContent(content))
b.WriteString("\x1b[27m")
} else {
b.WriteString(content)
}
b.WriteString(closeEnd)
line = endLine
i = closeStart + len(closeEnd)
continue
}
}
}
}
}
}
if out[i] == '\n' {
line++
}
b.WriteByte(out[i])
i++
}
return LinkedRender{Text: contain(b.String()), Links: links}
}

func hyperlinkDestination(sequence string) (params, destination string, found bool) {
body := sequence[len("\x1b]8;"):]
body = strings.TrimSuffix(body, "\a")
body = strings.TrimSuffix(body, "\x1b\\")
params, destination, found = strings.Cut(body, ";")
return params, destination, found
}

func allowedHyperlink(uri string) bool {
if strings.ContainsFunc(uri, isControl) {
return false
}
parsed, err := url.Parse(uri)
if err != nil || parsed.Scheme == "" {
return false
}
switch strings.ToLower(parsed.Scheme) {
case "http", "https":
return parsed.User == nil && parsed.Host != "" && parsed.Hostname() != ""
case "mailto":
return parsed.Opaque != ""
default:
return false
}
}

func precededByUnderline(out string, position int) bool {
start := strings.LastIndex(out[:position], "\x1b[")
if start < 0 {
return false
}
sequence, _, ok := sgr(out[start:position])
if !ok || start+len(sequence) != position {
return false
}
parameters := strings.FieldsFunc(sequence[2:len(sequence)-1], func(r rune) bool {
return r == ';' || r == ':'
})
for _, parameter := range parameters {
if parameter == "4" {
return true
}
}
return false
}

func withoutWhitespace(s string) string {
return strings.Map(func(r rune) rune {
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
return -1
}
return r
}, s)
}

func selectedContent(content string) string {
var b strings.Builder
b.Grow(len(content) + 16)
for i := 0; i < len(content); {
if content[i] == '\x1b' && strings.HasPrefix(content[i:], "\x1b[") {
sequence, _, ok := sgr(content[i:])
if ok {
b.WriteString(sequence)
// A nested reset can turn reverse video off. Reapply it after
// every SGR while the selected label is still visible.
b.WriteString("\x1b[7m")
i += len(sequence)
continue
}
}
b.WriteByte(content[i])
i++
}
return b.String()
}
154 changes: 154 additions & 0 deletions internal/markdown/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package markdown
import (
"strings"
"testing"

"github.com/basecamp/hey-cli/internal/htmlutil"
)

func TestRenderEmpty(t *testing.T) {
Expand Down Expand Up @@ -76,3 +78,155 @@ func TestRenderFallsBackToDefaultWidth(t *testing.T) {
t.Error("Render with no width returned nothing")
}
}

func TestRenderLinkedReturnsOneOccurrence(t *testing.T) {
linked := RenderLinked(htmlutil.ToMarkdown(`<p>Read <a href="https://example.com/report">the report</a>.</p>`), 80, -1)
if len(linked.Links) != 1 {
t.Fatalf("RenderLinked returned %d links, want 1: %#v", len(linked.Links), linked.Links)
}
if linked.Links[0].Destination != "https://example.com/report" {
t.Errorf("destination = %q", linked.Links[0].Destination)
}
if linked.Links[0].StartLine != 0 || linked.Links[0].EndLine != 0 {
t.Errorf("line range = %d-%d, want 0-0", linked.Links[0].StartLine, linked.Links[0].EndLine)
}
if !strings.Contains(linked.Text, "\x1b]8;") {
t.Errorf("linked text = %q, want OSC 8", linked.Text)
}
}

func TestRenderLinkedPreservesOrderDuplicatesAndSchemes(t *testing.T) {
linked := RenderLinked(htmlutil.ToMarkdown(`<p><a href="https://example.com/one">one</a> <a href="https://example.com/one">again</a> <a href="mailto:reader@example.com">mail</a> https://example.org/two</p>`), 80, -1)
want := []string{"https://example.com/one", "https://example.com/one", "mailto:reader@example.com", "https://example.org/two"}
if len(linked.Links) != len(want) {
t.Fatalf("links = %#v, want %d occurrences", linked.Links, len(want))
}
for i, destination := range want {
if linked.Links[i].Destination != destination {
t.Errorf("links[%d].Destination = %q, want %q", i, linked.Links[i].Destination, destination)
}
}
}

func TestRenderLinkedRejectsUnsafeAndRelativeDestinations(t *testing.T) {
linked := RenderLinked(htmlutil.ToMarkdown(`<p><a href="/relative">relative</a> <a href="ftp://example.com">ftp</a> <a href="javascript:alert(1)">script</a> <a href="https://">bad</a> <a href="https://trusted.example@evil.example/path">userinfo</a></p>`), 80, -1)
if len(linked.Links) != 0 {
t.Fatalf("links = %#v, want no selectable links", linked.Links)
}
}

func TestRenderLinkedStylesOnlySelectedOccurrence(t *testing.T) {
linked := RenderLinked(htmlutil.ToMarkdown(`<p><a href="https://example.com/one">one</a> <a href="https://example.com/two">two</a></p>`), 80, 1)
if len(linked.Links) != 2 {
t.Fatalf("links = %#v, want 2 occurrences", linked.Links)
}
if strings.Count(linked.Text, "\x1b[7m") < 2 || strings.Count(linked.Text, "\x1b[27m") == 0 {
t.Errorf("selected styling missing from %q", linked.Text)
}
if !sgrOnly(linked.Text) {
t.Errorf("selected output escaped containment: %q", linked.Text)
}
}

func TestRenderLinkedReportsWrappedLinkRange(t *testing.T) {
linked := RenderLinked(htmlutil.ToMarkdown(`<p><a href="https://example.com/long">this is a link label that must wrap over several lines</a></p>`), 12, -1)
if len(linked.Links) != 1 {
t.Fatalf("links = %#v, want one occurrence", linked.Links)
}
if linked.Links[0].EndLine <= linked.Links[0].StartLine {
t.Errorf("line range = %d-%d, want a wrapped range", linked.Links[0].StartLine, linked.Links[0].EndLine)
}
}

func TestRenderLinkedKeepsOneAnchorWhoseLabelContainsItsDestinationTogether(t *testing.T) {
for _, tt := range []struct {
name string
label string
selectedSpans int
}{
{name: "ends with destination", label: "Read https://example.com", selectedSpans: 2},
{name: "equals destination", label: "https://example.com", selectedSpans: 1},
{name: "equals after whitespace is removed", label: "https://example. com", selectedSpans: 2},
} {
t.Run(tt.name, func(t *testing.T) {
source := `<p><a href="https://example.com">` + tt.label + `</a></p>`
linked := RenderLinked(htmlutil.ToMarkdown(source), 80, 0)
if len(linked.Links) != 1 {
t.Fatalf("links = %#v, want one occurrence; rendered text = %q", linked.Links, linked.Text)
}
if got := strings.Count(linked.Text, "\x1b[27m"); got != tt.selectedSpans {
t.Errorf("selected spans = %d, want %d: %q", got, tt.selectedSpans, linked.Text)
}
})
}
}

func TestRenderLinkedKeepsWrappedDuplicateLinksSeparate(t *testing.T) {
linked := RenderLinked(htmlutil.ToMarkdown(`<p><a href="https://example.com/very/long/path">one</a> <a href="https://example.com/very/long/path">again</a></p>`), 12, -1)
if len(linked.Links) != 2 {
t.Fatalf("links = %#v, want two occurrences; rendered text = %q", linked.Links, linked.Text)
}
}

func TestRenderLinkedKeepsDuplicateLinksSeparate(t *testing.T) {
for name, source := range map[string]string{
"labels": `<p><a href="https://example.com/one">one</a><a href="https://example.com/one">again</a></p>`,
"autolinks": `<p>https://example.com/one https://example.com/one</p>`,
"line break": `<p>https://example.com/one<br>https://example.com/one</p>`,
} {
t.Run(name, func(t *testing.T) {
linked := RenderLinked(htmlutil.ToMarkdown(source), 80, -1)
if len(linked.Links) != 2 {
t.Fatalf("links = %#v, want two occurrences", linked.Links)
}
})
}
}

func TestRenderLinkedSelectedNestedLabelStaysSelected(t *testing.T) {
linked := RenderLinked(htmlutil.ToMarkdown(`<p><a href="https://example.com/one"><strong>bold</strong> and <em>italic</em></a></p>`), 80, 0)
if len(linked.Links) != 1 {
t.Fatalf("links = %#v, want one occurrence", linked.Links)
}
if !strings.Contains(linked.Text, "\x1b[7m\x1b[94;1m\x1b[7mbold") || !strings.Contains(linked.Text, "\x1b[7m\x1b[94;3m\x1b[7mitalic") || !strings.Contains(linked.Text, "\x1b[m\x1b[7m") {
t.Errorf("nested SGR styling lost selection: %q", linked.Text)
}
if !sgrOnly(linked.Text) {
t.Errorf("selected output escaped containment: %q", linked.Text)
}
}

func TestRenderLinkedSelectedWrappedLabelStaysSelected(t *testing.T) {
linked := RenderLinked(htmlutil.ToMarkdown(`<p><a href="https://example.com/long">this is a link label that must wrap over several lines</a></p>`), 12, 0)
if len(linked.Links) != 1 {
t.Fatalf("links = %#v, want one occurrence", linked.Links)
}
if strings.Count(linked.Text, "\x1b[7m") < 6 {
t.Errorf("selected wrapped label lost selection: %q", linked.Text)
}
if !sgrOnly(linked.Text) {
t.Errorf("selected output escaped containment: %q", linked.Text)
}
}

func TestRenderLinkedSanitizesLinkTextAndDestination(t *testing.T) {
linked := RenderLinked(htmlutil.ToMarkdown("<p><a href=\"https://example.com/a\u200bb\x01\">shown\u200b\x01\u202ename</a></p>"), 80, -1)
if len(linked.Links) != 1 || linked.Links[0].Destination != "https://example.com/ab" {
t.Fatalf("links = %#v, want one sanitized link", linked.Links)
}
if strings.Contains(linked.Text, "\u200b") || strings.Contains(linked.Text, "\u202e") {
t.Errorf("sanitized link output = %q", linked.Text)
}
}

func TestRenderLinkedRejectsMalformedDestinationAndHandlesNoLinks(t *testing.T) {
for _, source := range []string{
`<p><a href="https://[">malformed</a></p>`,
`<p><a href="mailto://example.com">malformed mail address</a></p>`,
`<p>There is no link here.</p>`,
} {
if linked := RenderLinked(htmlutil.ToMarkdown(source), 80, -1); len(linked.Links) != 0 {
t.Errorf("source %q produced links %#v", source, linked.Links)
}
}
}
Loading
Loading