Skip to content

Commit a5f4fc7

Browse files
authored
Merge pull request #5256 from gui-cs/release/v2.1.0-rc.3
Release v2.1.0-rc.3
2 parents 47e6cc0 + 3cd3c0f commit a5f4fc7

5 files changed

Lines changed: 156 additions & 21 deletions

File tree

.github/workflows/build-validation.yml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
name: Build All Configurations
1616
runs-on: ubuntu-latest
1717

18-
timeout-minutes: 15
18+
timeout-minutes: 20
1919
steps:
2020

2121
- name: Checkout code
@@ -57,7 +57,14 @@ jobs:
5757
dotnet build ./Examples/SelfContained/SelfContained.csproj --configuration Release -property:NoWarn=0618%3B0612
5858
5959
- name: AOT Publish (catches trimming and reflection errors)
60-
run: dotnet publish ./Examples/NativeAot/NativeAot.csproj --configuration Release -property:NoWarn=0618%3B0612
60+
run: dotnet publish ./Examples/NativeAot/NativeAot.csproj --configuration Release --output ./aot-publish -property:NoWarn=0618%3B0612
61+
62+
- name: AOT Smoke Test (run the published AOT binary)
63+
env:
64+
DisableRealDriverIO: "1"
65+
run: |
66+
chmod +x ./aot-publish/NativeAot
67+
./aot-publish/NativeAot --smoke-test
6168
6269
- name: Build Release Solution
6370
run: dotnet build --configuration Release --no-restore -property:NoWarn=0618%3B0612

Examples/NativeAot/Program.cs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,19 @@ namespace NativeAot;
1818

1919
public static class Program
2020
{
21-
private static void Main ()
21+
private static async Task Main (string [] args)
2222
{
2323
#pragma warning disable IL2026, IL3050
24-
Run ();
24+
await RunAsync (args);
2525
#pragma warning restore IL2026, IL3050
2626
}
2727

2828
[RequiresUnreferencedCode ("Calls Terminal.Gui.Application.Init(IDriver, String)")]
2929
[RequiresDynamicCode ("Calls Terminal.Gui.Application.Init(IDriver, String)")]
30-
private static void Run ()
30+
private static async Task RunAsync (string [] args)
3131
{
32+
bool smokeTest = args.Length > 0 && args [0] == "--smoke-test";
33+
3234
ConfigurationManager.Enable (ConfigLocations.All);
3335

3436
IApplication app = Application.Create ().Init ();
@@ -47,6 +49,17 @@ private static void Run ()
4749

4850
#endregion
4951

52+
if (smokeTest)
53+
{
54+
// CI smoke test: run the full app lifecycle with a timeout
55+
using CancellationTokenSource cts = new (TimeSpan.FromSeconds (5));
56+
await app.RunAsync<AotAllViewsWindow> (cts.Token);
57+
app.Dispose ();
58+
Console.WriteLine ("AOT smoke test passed: full app lifecycle completed successfully.");
59+
60+
return;
61+
}
62+
5063
app.Run<AotAllViewsWindow> ();
5164
app.Dispose ();
5265
}

Terminal.Gui/Drivers/Output/OutputBase.cs

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -170,31 +170,36 @@ public virtual void Write (IOutputBuffer buffer)
170170
}
171171
}
172172

173-
// Flush buffered output for row
174-
if (outputStringBuilder.Length <= 0)
173+
// Flush buffered output for row. Even when nothing remains buffered, an OSC 8 hyperlink
174+
// may still be open in the terminal because it was started in a prior batch flushed by
175+
// WriteToConsole and the row ended (or only clean cells followed) before any cell with
176+
// a different URL closed it. Emit the close so the link does not bleed into later rows.
177+
if (outputStringBuilder.Length <= 0 && _lastUrl is null)
175178
{
176179
continue;
177180
}
178181

179182
if (IsLegacyConsole)
180183
{
181-
Write (outputStringBuilder);
182-
}
183-
else
184-
{
185-
SetCursorPositionImpl (lastCol, row);
186-
187-
// Close any open hyperlink before processing URLs
188-
if (_lastUrl is { })
184+
if (outputStringBuilder.Length > 0)
189185
{
190-
outputStringBuilder.Append (EscSeqUtils.OSC_EndHyperlink ());
191-
_lastUrl = null;
186+
Write (outputStringBuilder);
192187
}
193188

194-
// Wrap URLs with OSC 8 hyperlink sequences
195-
StringBuilder processed = Osc8UrlLinker.WrapOsc8 (outputStringBuilder);
196-
Write (processed);
189+
continue;
197190
}
191+
192+
if (_lastUrl is { })
193+
{
194+
outputStringBuilder.Append (EscSeqUtils.OSC_EndHyperlink ());
195+
_lastUrl = null;
196+
}
197+
198+
SetCursorPositionImpl (lastCol, row);
199+
200+
// Wrap URLs with OSC 8 hyperlink sequences
201+
StringBuilder processed = Osc8UrlLinker.WrapOsc8 (outputStringBuilder);
202+
Write (processed);
198203
}
199204

200205
if (IsLegacyConsole)

Terminal.Gui/Drivers/Output/OutputBufferImpl.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,11 +409,16 @@ private void SetAttributeAndDirty (int col, int row)
409409
Contents! [row, col].Attribute = CurrentAttribute;
410410
Contents [row, col].IsDirty = true;
411411

412-
// If CurrentUrl is set, store it in the URL map
412+
// Update the URL map: store CurrentUrl, or clear any stale entry so cells
413+
// overdrawn by non-link content are not wrapped in OSC 8 sequences.
413414
if (!string.IsNullOrEmpty (CurrentUrl))
414415
{
415416
SetCellUrl (col, row, CurrentUrl);
416417
}
418+
else
419+
{
420+
_urlMap?.Remove (new Point (col, row));
421+
}
417422
}
418423

419424
/// <summary>

Tests/UnitTestsParallelizable/Drivers/Output/OutputBaseTests.cs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,4 +444,109 @@ public void Write_EmitsSixelDataAndPositionsCursor (bool isLegacyConsole)
444444

445445
app.Dispose ();
446446
}
447+
448+
// Claude - Opus 4.7
449+
// Regression test for https://github.com/gui-cs/Terminal.Gui/issues/4892
450+
// When dirty cells with a URL are flushed mid-row because a clean cell follows,
451+
// the OSC 8 hyperlink remains open in the terminal. If no more dirty cells appear
452+
// on the row, the end-of-row code must still emit the OSC 8 close sequence so the
453+
// hyperlink does not bleed into the next row.
454+
[Fact]
455+
public void Write_UrlFollowedByCleanCells_ClosesHyperlinkAtRowEnd ()
456+
{
457+
// Arrange: 5-col row. URL at cols 0-1, clean cells at cols 2-4.
458+
AnsiOutput output = new ();
459+
IOutputBuffer buffer = output.GetLastBuffer ()!;
460+
buffer.SetSize (5, 1);
461+
462+
// First frame: write URL cells then clear dirty by flushing
463+
buffer.Move (0, 0);
464+
buffer.CurrentUrl = "https://example.com";
465+
buffer.AddStr ("AB");
466+
buffer.CurrentUrl = null;
467+
output.Write (buffer);
468+
469+
// Second frame: only re-mark the URL cells dirty so cols 2-4 stay clean.
470+
buffer.Contents! [0, 0].IsDirty = true;
471+
buffer.Contents! [0, 1].IsDirty = true;
472+
buffer.DirtyLines [0] = true;
473+
474+
// Act
475+
output.Write (buffer);
476+
string result = output.GetLastOutput ();
477+
478+
// Assert: every OSC 8 start sequence is followed by an OSC 8 close before the row ends.
479+
string start = EscSeqUtils.OSC_StartHyperlink ("https://example.com");
480+
string end = EscSeqUtils.OSC_EndHyperlink ();
481+
int startIdx = result.IndexOf (start, StringComparison.Ordinal);
482+
Assert.True (startIdx >= 0, "OSC 8 start sequence not emitted");
483+
484+
int endIdx = result.IndexOf (end, startIdx + start.Length, StringComparison.Ordinal);
485+
Assert.True (endIdx > startIdx, "OSC 8 hyperlink was not closed before the row ended");
486+
}
487+
488+
// Claude - Opus 4.7
489+
// Regression test for https://github.com/gui-cs/Terminal.Gui/issues/4892
490+
// When a Link's display area shrinks (or a Link is replaced), the cells previously
491+
// associated with a URL may be overdrawn by content that has no URL. Those cells
492+
// must be removed from the URL map so OSC 8 sequences are not re-emitted for them.
493+
[Fact]
494+
public void AddStr_NoCurrentUrl_ClearsStaleUrlMapping ()
495+
{
496+
// Arrange
497+
AnsiOutput output = new ();
498+
IOutputBuffer buffer = output.GetLastBuffer ()!;
499+
buffer.SetSize (5, 1);
500+
501+
// First write: cells get associated with a URL
502+
buffer.Move (0, 0);
503+
buffer.CurrentUrl = "https://example.com";
504+
buffer.AddStr ("HELLO");
505+
buffer.CurrentUrl = null;
506+
507+
Assert.Equal ("https://example.com", buffer.GetCellUrl (0, 0));
508+
Assert.Equal ("https://example.com", buffer.GetCellUrl (4, 0));
509+
510+
// Act: overwrite cells with no CurrentUrl set (simulates a non-link view redrawing)
511+
buffer.Move (0, 0);
512+
buffer.AddStr ("WORLD");
513+
514+
// Assert: stale URL associations are cleared
515+
Assert.Null (buffer.GetCellUrl (0, 0));
516+
Assert.Null (buffer.GetCellUrl (1, 0));
517+
Assert.Null (buffer.GetCellUrl (2, 0));
518+
Assert.Null (buffer.GetCellUrl (3, 0));
519+
Assert.Null (buffer.GetCellUrl (4, 0));
520+
521+
// And the rendered output for the second frame contains no OSC 8 sequences
522+
string result = output.ToAnsi (buffer);
523+
Assert.DoesNotContain (EscSeqUtils.OSC_StartHyperlink ("https://example.com"), result);
524+
}
525+
526+
// Claude - Opus 4.7
527+
// Regression test for https://github.com/gui-cs/Terminal.Gui/issues/4892
528+
// When CurrentUrl changes from one URL to another for the same cell, the URL map
529+
// should reflect the new URL (verifying the URL clear path does not break re-assignment).
530+
[Fact]
531+
public void AddStr_DifferentUrl_OverwritesUrlMapping ()
532+
{
533+
// Arrange
534+
AnsiOutput output = new ();
535+
IOutputBuffer buffer = output.GetLastBuffer ()!;
536+
buffer.SetSize (3, 1);
537+
538+
buffer.Move (0, 0);
539+
buffer.CurrentUrl = "https://one.com";
540+
buffer.AddStr ("ABC");
541+
542+
// Act: rewrite same cells with a different URL
543+
buffer.Move (0, 0);
544+
buffer.CurrentUrl = "https://two.com";
545+
buffer.AddStr ("ABC");
546+
547+
// Assert: cells now report the new URL
548+
Assert.Equal ("https://two.com", buffer.GetCellUrl (0, 0));
549+
Assert.Equal ("https://two.com", buffer.GetCellUrl (1, 0));
550+
Assert.Equal ("https://two.com", buffer.GetCellUrl (2, 0));
551+
}
447552
}

0 commit comments

Comments
 (0)