From 7d7b23cd9fc77a2beda5114e16466986600febde Mon Sep 17 00:00:00 2001 From: YourRobotOverlord <47068588+YourRobotOverlord@users.noreply.github.com> Date: Fri, 8 May 2026 18:34:45 -0600 Subject: [PATCH 1/2] Fixes #5273. Code block partial selection should not include fence delimiters When copying a partial selection that starts or ends inside a fenced code block, the copied text should not include the fence delimiters unless the selection actually crosses from non-code content into the code block. Root cause: GetSelectedText() unconditionally injected the opening fence whenever it first encountered a code-block line, and unconditionally injected the closing fence at the end of the loop if still inside a code block. This meant any selection touching a code block line would include fences, even if the selection was entirely within the code block. Fix: Replace the unconditional fence injection with two tracking flags: - selectionHasNonCodeContent: set true when any non-code line is processed. Opening fence is only emitted when transitioning from non-code -> code. - codeOpenFenceEmitted: tracks whether an opening fence was actually emitted for the current code block. Closing fence is only emitted when the matching opening fence was emitted. This ensures: - Selection entirely within a code block -> no fences (regardless of position) - Selection starting before a code block -> opening fence included - Selection ending after a code block -> closing fence included Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Views/Markdown/MarkdownView.Selection.cs | 54 ++++++-- .../Markdown/MarkdownViewSelectionTests.cs | 127 +++++++++++++++++- 2 files changed, 165 insertions(+), 16 deletions(-) diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs index 12b89a31f9..9812023220 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs @@ -131,9 +131,20 @@ private string GetSelectedText () (Point start, Point end) = GetNormalizedSelection (); List outputLines = []; var inCodeBlock = false; - string? currentCodeLanguage = null; + // Fences are injected only when the selection crosses a code-block boundary: + // • Opening fence: only when the selection already contains non-code content + // before the code block (the selection crosses from outside into the block). + // • Closing fence: always when the selection crosses out of the code block into + // non-code content — regardless of whether an opening fence was emitted. + // • No trailing fence: when the selection ends inside a code block, no closing + // fence is added; the selection ends mid-block. + // This produces no fences for a selection entirely within a code block, matching + // the behaviour of the copy-button on MarkdownCodeBlock. + var selectionHasNonCodeContent = false; + var codeOpenFenceEmitted = false; + // Track the last table instance that was output. All placeholder rows for the // same table share the same TableData reference, so we use ReferenceEquals to // emit the reconstructed table markdown exactly once even when the selection @@ -150,26 +161,50 @@ private string GetSelectedText () if (!inCodeBlock) { - // Entering a code block: inject the opening fence with optional language tag - outputLines.Add ($"```{nextCodeLanguage ?? string.Empty}"); inCodeBlock = true; currentCodeLanguage = nextCodeLanguage; + + // Only inject the opening fence when non-code content has already been + // output — that is, the selection crosses from outside into this code block. + if (selectionHasNonCodeContent) + { + outputLines.Add ($"```{nextCodeLanguage ?? string.Empty}"); + codeOpenFenceEmitted = true; + } + else + { + codeOpenFenceEmitted = false; + } } else if (!string.Equals (currentCodeLanguage, nextCodeLanguage, StringComparison.Ordinal)) { - // Transitioning directly between two code blocks: close the current fence - // and open the next one so adjacent fenced blocks are preserved. - outputLines.Add ("```"); + // Transitioning directly between two adjacent code blocks of different + // languages: close the current fence (if opened) and open the next one. + if (codeOpenFenceEmitted) + { + outputLines.Add ("```"); + } + outputLines.Add ($"```{nextCodeLanguage ?? string.Empty}"); + codeOpenFenceEmitted = true; currentCodeLanguage = nextCodeLanguage; } } else if (inCodeBlock) { - // Leaving a code block: inject the closing fence + // Leaving a code block into non-code content: always inject the closing fence. + // The selection crosses the block's end boundary regardless of whether the + // opening fence was emitted (e.g., when the selection started inside the block). outputLines.Add ("```"); + inCodeBlock = false; + codeOpenFenceEmitted = false; currentCodeLanguage = null; + selectionHasNonCodeContent = true; + } + else + { + selectionHasNonCodeContent = true; } if (line is { IsTable: true, TableData: { } tableData }) @@ -197,11 +232,6 @@ private string GetSelectedText () outputLines.Add (lineSb.ToString ()); } - if (inCodeBlock) - { - outputLines.Add ("```"); - } - return string.Join ("\n", outputLines); } diff --git a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs index 156bba53c3..4484b70c2c 100644 --- a/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs +++ b/Tests/UnitTestsParallelizable/Views/Markdown/MarkdownViewSelectionTests.cs @@ -462,20 +462,23 @@ public void PartialSelection_TaskList_SelectedText_Preserves_Markdown_Markers () } // Copilot - partial drag selection spanning lines inside a csharp code block (with 🌍) + // The selection covers all code lines but no non-code content, so no fence delimiters + // should appear in the output (mirrors the copy-button behaviour on MarkdownCodeBlock). [Fact] - public void PartialSelection_FencedCodeBlock_SelectedText_Preserves_Fence_Context () + public void PartialSelection_FencedCodeBlock_SelectedText_DoesNotIncludeFence () { string md = "```csharp\nConsole.WriteLine (\"Hello, Terminal.Gui! 🌍\");\nvar x = 42;\n```"; (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); - // Select both code lines (rendered as lines 0 and 1 — fence lines are not in _renderedLines) + // Select both code lines (rendered as lines 0 and 1 — fence lines are not in _renderedLines). + // End at x=10 (one short of "var x = 42;" width=11) so IsFullDocumentSelected() returns false. mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); - mv.NewMouseEvent (new Mouse { Position = new Point (12, 1), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + mv.NewMouseEvent (new Mouse { Position = new Point (10, 1), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); string? selected = mv.SelectedText; Assert.NotNull (selected); - Assert.Contains ("```csharp", selected); + Assert.DoesNotContain ("```", selected); Assert.Contains ("Console.WriteLine", selected); Assert.Contains ("🌍", selected); @@ -764,6 +767,122 @@ public void PartialSelection_DocEndingWithTable_NotTreatedAsFullDocument () app.Dispose (); } + // --- Issue #5273: partial selection inside a code block must not include fence delimiters --- + + // Copilot - Claude Sonnet 4.6 + // Selecting only middle lines of a multi-line code block should not produce fence delimiters. + [Fact] + public void PartialSelection_InsideCodeBlock_DoesNotIncludeFenceDelimiters () + { + // 4 code lines; select only the middle two (lines 1 and 2) + string md = "```csharp\nline A\nline B\nline C\nline D\n```"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); + + // Rendered lines 0-3 are the four code lines (fence lines are stripped during parse). + // Press on line 1, drag to end of line 2. + mv.NewMouseEvent (new Mouse { Position = new Point (0, 1), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (60, 2), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Contains ("line B", selected); + Assert.Contains ("line C", selected); + Assert.DoesNotContain ("```", selected); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - Claude Sonnet 4.6 + // Selection starts before the code block (on a paragraph line) and ends inside it. + // Only the opening fence should be present; the closing fence must be omitted. + [Fact] + public void PartialSelection_StartBeforeCodeBlock_EndInside_HasOpeningFenceOnly () + { + string md = "Before\n```csharp\nline A\nline B\nline C\n```"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); + + // Rendered line 0 = "Before", lines 1-3 = code lines. + // Press on line 0, drag to line 2 (mid-block). + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (60, 2), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Contains ("Before", selected); + Assert.Contains ("```csharp", selected); + Assert.Contains ("line A", selected); + Assert.Contains ("line B", selected); + Assert.DoesNotContain ("line C", selected); + + // Opening fence present; closing fence absent because selection ends mid-block. + int fenceCount = CountOccurrences (selected, "```"); + Assert.Equal (1, fenceCount); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - Claude Sonnet 4.6 + // Selection starts inside a code block and ends after it (on a paragraph line). + // A closing fence is expected because the selection crosses the block's end, even + // though no opening fence was emitted (the selection began inside the block). + [Fact] + public void PartialSelection_StartInsideCodeBlock_EndAfter_HasClosingFenceOnly () + { + string md = "```csharp\nline A\nline B\nline C\n```\nAfter"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); + + // Rendered lines 0-2 = code lines, line 3 = "After". + // Press on line 1 (mid-block), drag to line 3 (after block). + mv.NewMouseEvent (new Mouse { Position = new Point (0, 1), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (60, 3), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Contains ("line B", selected); + Assert.Contains ("line C", selected); + Assert.Contains ("After", selected); + Assert.DoesNotContain ("line A", selected); + + // Closing fence present because selection crosses out of the code block; no opening fence. + int fenceCount = CountOccurrences (selected, "```"); + Assert.Equal (1, fenceCount); + + window.Dispose (); + app.Dispose (); + } + + // Copilot - Claude Sonnet 4.6 + // Regression guard: selecting all lines of a code block starting from its first line should + // produce NO fence delimiters — the selection is entirely within the fenced region. + [Fact] + public void PartialSelection_AllLinesOfCodeBlock_FromFirstLine_NoFences () + { + // Three code lines; select only the first two to avoid triggering IsFullDocumentSelected(). + string md = "```csharp\nline A\nline B\nline C\n```"; + (IApplication app, Runnable window, Terminal.Gui.Views.Markdown mv) = CreateMv (md, width: 60, height: 10); + + // Press on line 0 (first code line), drag to end of line 1. + // end.Y=1 < lastLine=2, so IsFullDocumentSelected() returns false and partial-selection runs. + mv.NewMouseEvent (new Mouse { Position = new Point (0, 0), Flags = MouseFlags.LeftButtonPressed }); + mv.NewMouseEvent (new Mouse { Position = new Point (6, 1), Flags = MouseFlags.LeftButtonPressed | MouseFlags.PositionReport }); + + string? selected = mv.SelectedText; + + Assert.NotNull (selected); + Assert.Contains ("line A", selected); + Assert.Contains ("line B", selected); + Assert.DoesNotContain ("line C", selected); + Assert.DoesNotContain ("```", selected); + + window.Dispose (); + app.Dispose (); + } + private static int CountOccurrences (string text, string pattern) { int count = 0; From 30358f638ed9930d810b5c3636a6fb8e3f350e03 Mon Sep 17 00:00:00 2001 From: YourRobotOverlord <47068588+YourRobotOverlord@users.noreply.github.com> Date: Fri, 8 May 2026 19:17:14 -0600 Subject: [PATCH 2/2] Clarify the comment to reflect the adjacent-code-block case Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs index 9812023220..e8c8c7e10d 100644 --- a/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs +++ b/Terminal.Gui/Views/Markdown/MarkdownView.Selection.cs @@ -134,14 +134,16 @@ private string GetSelectedText () string? currentCodeLanguage = null; // Fences are injected only when the selection crosses a code-block boundary: - // • Opening fence: only when the selection already contains non-code content - // before the code block (the selection crosses from outside into the block). + // • Opening fence: emitted when entering a code block after selected non-code + // content, and also when transitioning directly to an adjacent selected code + // block whose opening fence has not yet been written. // • Closing fence: always when the selection crosses out of the code block into // non-code content — regardless of whether an opening fence was emitted. // • No trailing fence: when the selection ends inside a code block, no closing // fence is added; the selection ends mid-block. // This produces no fences for a selection entirely within a code block, matching - // the behaviour of the copy-button on MarkdownCodeBlock. + // the behaviour of the copy-button on MarkdownCodeBlock. codeOpenFenceEmitted + // tracks whether the current selected code block already has its opening fence. var selectionHasNonCodeContent = false; var codeOpenFenceEmitted = false;