From c4e7946dabab1795d062fc3e2d51db27ec17da14 Mon Sep 17 00:00:00 2001 From: YourRobotOverlord <47068588+YourRobotOverlord@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:27:37 -0600 Subject: [PATCH] Fix relative markdown link handling --- .../Markdown/MarkdownAttributeHelper.cs | 42 ++++++++++- Terminal.Gui/Views/Markdown/Markdown.cs | 3 +- Terminal.Gui/Views/Markdown/MarkdownTable.cs | 2 +- .../Views/Markdown/MarkdownView.Drawing.cs | 7 +- .../Views/Markdown/MarkdownViewTests.cs | 74 ++++++++++++++++++- 5 files changed, 117 insertions(+), 11 deletions(-) diff --git a/Terminal.Gui/Drawing/Markdown/MarkdownAttributeHelper.cs b/Terminal.Gui/Drawing/Markdown/MarkdownAttributeHelper.cs index f3c6715dc6..134bc7b28e 100644 --- a/Terminal.Gui/Drawing/Markdown/MarkdownAttributeHelper.cs +++ b/Terminal.Gui/Drawing/Markdown/MarkdownAttributeHelper.cs @@ -122,11 +122,45 @@ public static List ToStyledSegments (IReadOnlyList run return segments; } - private static Attribute MakeLinkAttribute (Attribute normal, StyledSegment segment) + private static Attribute MakeLinkAttribute (Attribute normal, StyledSegment segment) => + IsMarkdownLinkTarget (segment.Url) ? normal with { Style = normal.Style | TextStyle.Underline } : normal; + + internal static bool IsMarkdownLinkTarget (string? url) + { + if (string.IsNullOrWhiteSpace (url)) + { + return false; + } + + if (url.StartsWith ('#')) + { + return true; + } + + if (TryCreateSafeAbsoluteUri (url, out _)) + { + return true; + } + + return Uri.TryCreate (url, UriKind.Relative, out _); + } + + internal static bool TryCreateSafeAbsoluteUri (string? url, out Uri? absoluteUri) { - bool isClickable = !string.IsNullOrWhiteSpace (segment.Url) - && (Uri.IsWellFormedUriString (segment.Url, UriKind.Absolute) || segment.Url!.StartsWith ('#')); + absoluteUri = null; + + if (!Uri.TryCreate (url, UriKind.Absolute, out Uri? parsed) || parsed is null) + { + return false; + } + + if (!Link.SafeSchemes.Contains (parsed.Scheme)) + { + return false; + } + + absoluteUri = parsed; - return isClickable ? normal with { Style = normal.Style | TextStyle.Underline } : normal; + return true; } } diff --git a/Terminal.Gui/Views/Markdown/Markdown.cs b/Terminal.Gui/Views/Markdown/Markdown.cs index 6560a09f78..bb51c370ae 100644 --- a/Terminal.Gui/Views/Markdown/Markdown.cs +++ b/Terminal.Gui/Views/Markdown/Markdown.cs @@ -528,6 +528,7 @@ bool IDesignable.EnableForDesign () ## Links * [Markdown API docs](https://gui-cs.github.io/Terminal.Gui/api/Terminal.Gui.Views.Markdown.html) for more info. + * [Relative local link](docs/getting-started.md) renders as a link without opening a URI handler by default. ## Checklist @@ -600,4 +601,4 @@ And this text is after. Thematic breaks are rendered as full-width horizontal li That's all folks! 👋 """; -} \ No newline at end of file +} diff --git a/Terminal.Gui/Views/Markdown/MarkdownTable.cs b/Terminal.Gui/Views/Markdown/MarkdownTable.cs index c250ddf38c..9277d992f1 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownTable.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownTable.cs @@ -595,7 +595,7 @@ private void DrawWrappedRow (List [] cellSegments, Alignment [] a } bool hasUrl = !string.IsNullOrWhiteSpace (seg.Url); - bool isAbsoluteUrl = hasUrl && Uri.IsWellFormedUriString (seg.Url, UriKind.Absolute); + bool isAbsoluteUrl = hasUrl && MarkdownAttributeHelper.TryCreateSafeAbsoluteUri (seg.Url, out _); bool isActive = hasUrl && HasFocus && IsActiveLinkAt (rowIndex, col, seg.Url!); if (isActive) diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs index 26b25d38ce..c8bff29e09 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Drawing.cs @@ -229,8 +229,9 @@ private void DrawRenderedLine (RenderedLine line, int contentRow, int drawRow) { Attribute linkAttr = GetAttributeForSegment (segment); Attribute reversed = new (linkAttr.Background, linkAttr.Foreground, linkAttr.Style); + bool isSafeAbsoluteLink = MarkdownAttributeHelper.TryCreateSafeAbsoluteUri (segment.Url, out _); - if (!string.IsNullOrWhiteSpace (segment.Url) && Uri.IsWellFormedUriString (segment.Url, UriKind.Absolute) && Driver is { }) + if (isSafeAbsoluteLink && Driver is { }) { Driver.CurrentUrl = segment.Url; @@ -276,7 +277,7 @@ private void DrawGrapheme (StyledSegment segment, string grapheme, int x, int y) { Attribute attr = GetAttributeForSegment (segment); - if (!string.IsNullOrWhiteSpace (segment.Url) && Uri.IsWellFormedUriString (segment.Url, UriKind.Absolute) && Driver is { }) + if (MarkdownAttributeHelper.TryCreateSafeAbsoluteUri (segment.Url, out _) && Driver is { }) { Driver.CurrentUrl = segment.Url; @@ -337,4 +338,4 @@ private void TryQueueSixel (string imageSource, Point viewPosition) _sixelRenderMap [queueId] = newRender; Driver.GetSixels ().Enqueue (newRender); } -} \ No newline at end of file +} diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewTests.cs index c8ba8f26d2..9459e429df 100644 --- a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewTests.cs @@ -202,6 +202,37 @@ public void Mouse_Click_On_Link_Raises_LinkClicked () window.Dispose (); } + // Codex - GPT-5 + [Fact] + public void Mouse_Click_On_Relative_Link_Raises_LinkClicked () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable window = new () { Width = Dim.Fill (), Height = Dim.Fill (), BorderStyle = LineStyle.None }; + + Terminal.Gui.Views.Markdown markdownView = new () { Text = "[Local](docs/readme.md)", Width = 20, Height = 3 }; + + window.Add (markdownView); + + string clickedUrl = ""; + + markdownView.LinkClicked += (_, e) => + { + clickedUrl = e.Url; + e.Handled = true; + }; + + app.Begin (window); + app.LayoutAndDraw (); + + markdownView.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonClicked }); + + Assert.Equal ("docs/readme.md", clickedUrl); + + window.Dispose (); + } + // Copilot [Fact] public void Mouse_Click_On_Link_In_Table_Cell_Raises_LinkClicked () @@ -256,6 +287,45 @@ public void Mouse_Click_On_Link_In_Table_Cell_Raises_LinkClicked () window.Dispose (); } + // Codex - GPT-5 + [Fact] + public void Mouse_Click_On_Relative_Link_In_Table_Cell_Raises_LinkClicked () + { + using IApplication app = Application.Create (); + app.Init (DriverRegistry.Names.ANSI); + + Runnable window = new () { Width = 80, Height = 20, BorderStyle = LineStyle.None }; + + string markdown = """ + | Name | Description | + |------|-------------| + | [local](docs/readme.md) | Pick one item | + """; + + Terminal.Gui.Views.Markdown markdownView = new () { Text = markdown, Width = 60, Height = 15 }; + window.Add (markdownView); + + string clickedUrl = ""; + + markdownView.LinkClicked += (_, e) => + { + clickedUrl = e.Url; + e.Handled = true; + }; + + app.Begin (window); + app.LayoutAndDraw (); + + MarkdownTable? tableView = markdownView.SubViews.OfType ().FirstOrDefault (); + Assert.NotNull (tableView); + + tableView.NewMouseEvent (new Mouse { Position = new Point (2, 3), Flags = MouseFlags.LeftButtonClicked }); + + Assert.Equal ("docs/readme.md", clickedUrl); + + window.Dispose (); + } + // Copilot [Fact] public void Tab_Navigation_Cycles_Through_Table_Links () @@ -749,11 +819,11 @@ public void Style_Link_Absolute_Underline_With_OSC8 () } [Fact] - public void Style_Link_Relative_No_Underline_No_OSC8 () + public void Style_Link_Relative_Underline_No_OSC8 () { (IApplication app, Runnable window) = SetupStyleTest ("[Go](foo.md)"); - DriverAssert.AssertDriverOutputIs (@"\x1b[30m\x1b[107mGo", output, app.Driver); + DriverAssert.AssertDriverOutputIs (@"\x1b[30m\x1b[107m\x1b[4mGo\x1b[30m\x1b[107m\x1b[24m", output, app.Driver); window.Dispose (); app.Dispose ();