diff --git a/Dotty.Terminal.Tests/HyperlinkTests.cs b/Dotty.Terminal.Tests/HyperlinkTests.cs new file mode 100644 index 0000000..a85b6c6 --- /dev/null +++ b/Dotty.Terminal.Tests/HyperlinkTests.cs @@ -0,0 +1,130 @@ +using System.Text; +using Dotty.Terminal; +using Dotty.Terminal.Rendering; +using Xunit; + +namespace Dotty.Terminal.Tests; + +public class HyperlinkTests +{ + private static Terminal Feed(params string[] chunks) + { + var term = new Terminal(new GridSize(80, 24)); + foreach (var chunk in chunks) + term.ProcessPtyOutput(Encoding.UTF8.GetBytes(chunk)); + return term; + } + + // OSC 8 hyperlink: ESC ] 8 ; params ; URI ST (ST = ESC \). An empty URI closes. + private static string Open(string uri, string? id = null) => + $"\x1b]8;{(id is null ? "" : $"id={id}")};{uri}\x1b\\"; + + private static readonly string Close = "\x1b]8;;\x1b\\"; + + [Fact] + public void LinkedCellsShareOneIdAndResolveToUri() + { + var term = Feed(Open("https://example.com"), "link", Close, "x"); + + var row = term.RowCells(0); + var id = row[0].HyperlinkId; + + Assert.NotEqual(0, id); + Assert.Equal(id, row[1].HyperlinkId); + Assert.Equal(id, row[2].HyperlinkId); + Assert.Equal(id, row[3].HyperlinkId); + Assert.Equal("https://example.com", term.Hyperlinks[id]); + + // Text after the close carries no link. + Assert.Equal(0, row[4].HyperlinkId); + } + + [Fact] + public void PlainTextHasNoLink() + { + var term = Feed("plain text"); + + Assert.Equal(0, term.RowCells(0)[0].HyperlinkId); + Assert.Empty(term.Hyperlinks); + } + + [Fact] + public void EmptyUriClosesTheCurrentLink() + { + var term = Feed(Open("https://a.test"), "A", Close, "B"); + + var row = term.RowCells(0); + Assert.NotEqual(0, row[0].HyperlinkId); // A + Assert.Equal(0, row[1].HyperlinkId); // B + } + + [Fact] + public void ExplicitIdGroupsSpansAcrossRows() + { + // Same id= on two spans (a link split across a newline) => one link id. + var term = Feed( + Open("https://wrapped.test", id: "L1"), "AA", Close, + "\r\n", + Open("https://wrapped.test", id: "L1"), "BB", Close); + + var first = term.RowCells(0)[0].HyperlinkId; + var second = term.RowCells(1)[0].HyperlinkId; + + Assert.NotEqual(0, first); + Assert.Equal(first, second); + Assert.Single(term.Hyperlinks); + } + + [Fact] + public void SameUriWithoutIdIsDeduplicated() + { + var term = Feed( + Open("https://dup.test"), "A", Close, + Open("https://dup.test"), "B", Close); + + Assert.Single(term.Hyperlinks); + } + + [Fact] + public void LongUriWithinBufferCapIsStored() + { + var uri = "https://example.com/" + new string('a', 2000); + var term = Feed(Open(uri), "x", Close); + + var id = term.RowCells(0)[0].HyperlinkId; + Assert.Equal(uri, term.Hyperlinks[id]); + } + + [Fact] + public void MalformedOsc8WithoutSeparatorIsIgnored() + { + // No second ';' — nothing to open, and it must not throw. + var term = Feed("\x1b]8\x1b\\", "text"); + + Assert.Equal(0, term.RowCells(0)[0].HyperlinkId); + Assert.Empty(term.Hyperlinks); + } + + [Fact] + public void ResetClearsHyperlinkTable() + { + var term = Feed(Open("https://a.test"), "A", Close); + Assert.NotEmpty(term.Hyperlinks); + + // RIS (ESC c) — full reset. Explicit bytes avoid the \x1b greedy-hex + // hazard where 'c' would be read as a hex digit. + term.ProcessPtyOutput(new byte[] { 0x1b, (byte)'c' }); + Assert.Empty(term.Hyperlinks); + } + + [Fact] + public void SnapshotExposesLinkUriPerCell() + { + var term = Feed(Open("https://snap.test"), "hi", Close, "!"); + var snapshot = TerminalScreenSnapshot.FromTerminal(term); + + Assert.Equal("https://snap.test", snapshot.HyperlinkAt(0, 0)); + Assert.Equal("https://snap.test", snapshot.HyperlinkAt(1, 0)); + Assert.Null(snapshot.HyperlinkAt(2, 0)); // the '!' after close + } +} diff --git a/Dotty.Terminal/Cell.cs b/Dotty.Terminal/Cell.cs index 3b8000d..393cad8 100644 --- a/Dotty.Terminal/Cell.cs +++ b/Dotty.Terminal/Cell.cs @@ -23,12 +23,21 @@ public struct Cell public Color Bg; public CellAttributes Attrs; + /// + /// OSC 8 hyperlink reference: an id into the terminal's link table (0 = no + /// link). Cells printed between an OSC 8 open and close share the same id, so + /// a link that wraps across rows is one logical link. The id, not the URI + /// string, keeps a cheap value type to copy and reflow. + /// + public ushort HyperlinkId; + public static Cell Default => new() { Codepoint = ' ', Fg = Color.DefaultColor, Bg = Color.DefaultColor, Attrs = CellAttributes.None, + HyperlinkId = 0, }; public void Reset() @@ -37,6 +46,7 @@ public void Reset() Fg = Color.DefaultColor; Bg = Color.DefaultColor; Attrs = CellAttributes.None; + HyperlinkId = 0; } public bool IsEmpty => diff --git a/Dotty.Terminal/Hosting/ZshPromptShim.cs b/Dotty.Terminal/Hosting/ZshPromptShim.cs index 49e2cb1..c48f068 100644 --- a/Dotty.Terminal/Hosting/ZshPromptShim.cs +++ b/Dotty.Terminal/Hosting/ZshPromptShim.cs @@ -95,5 +95,5 @@ internal static void Cleanup(string? shimDirectory) } catch (IOException) { } catch (UnauthorizedAccessException) { } - catch (DirectoryNotFoundException) { } + } } diff --git a/Dotty.Terminal/Parser/VtHandler.cs b/Dotty.Terminal/Parser/VtHandler.cs index 8b2fe8d..162001b 100644 --- a/Dotty.Terminal/Parser/VtHandler.cs +++ b/Dotty.Terminal/Parser/VtHandler.cs @@ -397,12 +397,48 @@ public void OscDispatch(ReadOnlySpan payload) case 7: // Current Working Directory _terminal.SetWorkingDirectory(Encoding.UTF8.GetString(data)); break; + case 8: // Hyperlink: OSC 8 ; params ; URI ST + HandleOsc8(data); + break; case 52: // Clipboard access HandleOsc52(data); break; } } + /// + /// Handles an OSC 8 hyperlink. The payload after the command number is + /// params ; URI; an empty URI closes the current link. params is + /// a colon-separated key=value list whose only standard key is id, + /// which groups spans (e.g. a link wrapped across rows) under one link. + /// + private void HandleOsc8(ReadOnlySpan data) + { + int sep = data.IndexOf((byte)';'); + if (sep < 0) + return; // Malformed: no params/URI separator. + + var id = ExtractHyperlinkId(data[..sep]); + var uri = Encoding.UTF8.GetString(data[(sep + 1)..]); + _terminal.SetHyperlink(id, uri); + } + + /// Pulls the id= value out of an OSC 8 params section + /// (key=value:key=value), or null when absent. + private static string? ExtractHyperlinkId(ReadOnlySpan paramsPart) + { + if (paramsPart.Length == 0) + return null; + + foreach (var kv in Encoding.UTF8.GetString(paramsPart).Split(':')) + { + if (kv.StartsWith("id=", StringComparison.Ordinal)) + return kv["id=".Length..]; + } + + return null; + } + private void ParseSgr(ReadOnlySpan parameters) { if (parameters.Length == 0) diff --git a/Dotty.Terminal/Parser/VtStateMachine.cs b/Dotty.Terminal/Parser/VtStateMachine.cs index e0cd24a..752e978 100644 --- a/Dotty.Terminal/Parser/VtStateMachine.cs +++ b/Dotty.Terminal/Parser/VtStateMachine.cs @@ -35,8 +35,9 @@ private enum State private readonly byte[] _intermediates = new byte[4]; private int _intermediateCount; - // OSC payload - private readonly byte[] _oscPayload = new byte[512]; + // OSC payload. Sized for OSC 8 hyperlink URIs (the realistic long-OSC case); + // bytes past the cap are dropped rather than growing the buffer. + private readonly byte[] _oscPayload = new byte[4096]; private int _oscPayloadLen; // UTF-8 decoding @@ -78,6 +79,13 @@ private void ProcessByte(IVtHandler handler, byte b) _state = State.Ground; return; case 0x1B: // ESC + // ESC inside an OSC string is the start of a String Terminator + // (ST = ESC \): finalize the OSC now, then let the trailing '\' + // be consumed in the Escape state. Without this, ST-terminated + // OSC (titles, cwd, OSC 8 hyperlinks) would never dispatch — only + // the BEL terminator would. + if (_state == State.OscString) + handler.OscDispatch(OscPayload); TransitionTo(State.Escape); return; } @@ -352,12 +360,9 @@ private void OscStringState(IVtHandler handler, byte b) handler.OscDispatch(OscPayload); _state = State.Ground; break; - case 0x1B: - // ESC could be start of ST (ESC \) - // We'll handle this by checking next byte — for simplicity, dispatch now - handler.OscDispatch(OscPayload); - TransitionTo(State.Escape); - break; + // NB: ESC (start of a 7-bit ST) is intercepted by the "anywhere" + // transition in ProcessByte, which dispatches the OSC before this + // state ever sees it. default: if (_oscPayloadLen < _oscPayload.Length) _oscPayload[_oscPayloadLen++] = b; diff --git a/Dotty.Terminal/Rendering/TerminalScreenSnapshot.cs b/Dotty.Terminal/Rendering/TerminalScreenSnapshot.cs index b322d87..de9cb35 100644 --- a/Dotty.Terminal/Rendering/TerminalScreenSnapshot.cs +++ b/Dotty.Terminal/Rendering/TerminalScreenSnapshot.cs @@ -5,10 +5,18 @@ public sealed record TerminalScreenSnapshot( IReadOnlyList Cells, TerminalRenderCursor Cursor, bool IsScrolledBack, - string? ExitMessage) + string? ExitMessage, + IReadOnlyDictionary Hyperlinks) { public TerminalRenderCell CellAt(ushort col, ushort row) => Cells[row * Size.Cols + col]; + /// The OSC 8 URI a cell links to, or null when it has no link. + public string? HyperlinkAt(ushort col, ushort row) + { + var id = CellAt(col, row).HyperlinkId; + return id != 0 && Hyperlinks.TryGetValue(id, out var uri) ? uri : null; + } + public static TerminalScreenSnapshot FromTerminal(Terminal terminal) { var size = terminal.GridSize; @@ -28,7 +36,8 @@ public static TerminalScreenSnapshot FromTerminal(Terminal terminal) cell.Fg, cell.Bg, cell.Attrs, - selection?.Contains(pos) ?? false); + selection?.Contains(pos) ?? false, + cell.HyperlinkId); } } @@ -45,8 +54,21 @@ public static TerminalScreenSnapshot FromTerminal(Terminal terminal) cells, new TerminalRenderCursor(cursor.Position, cursor.Shape, cursor.Visible, cursor.Blinking), terminal.IsScrolledBack, - exitMessage); + exitMessage, + SnapshotHyperlinks(terminal.Hyperlinks)); } + + // Copy the live link table so the snapshot stays a stable value even as the + // terminal keeps mutating. Empty tables share one instance (no per-frame + // allocation for the common no-links case). + private static IReadOnlyDictionary SnapshotHyperlinks( + IReadOnlyDictionary live) => + live.Count == 0 + ? EmptyHyperlinks + : new Dictionary(live); + + private static readonly IReadOnlyDictionary EmptyHyperlinks = + new Dictionary(); } public readonly record struct TerminalRenderCell( @@ -55,7 +77,8 @@ public readonly record struct TerminalRenderCell( Color Foreground, Color Background, CellAttributes Attributes, - bool IsSelected); + bool IsSelected, + ushort HyperlinkId); public readonly record struct TerminalRenderCursor( GridPosition Position, diff --git a/Dotty.Terminal/Terminal.cs b/Dotty.Terminal/Terminal.cs index 8a1de08..73f06a7 100644 --- a/Dotty.Terminal/Terminal.cs +++ b/Dotty.Terminal/Terminal.cs @@ -23,6 +23,15 @@ public class Terminal private Color _penBg; private CellAttributes _penAttrs; + // OSC 8 hyperlinks. Cells store a small id; these map it to the URI. The pen + // holds the id of the currently-open link (0 = none). Links are deduplicated + // by their explicit `id=` param, or by URI when none is given, so a link that + // wraps across rows — or repeats — reuses one id and the table stays bounded. + private ushort _penHyperlinkId; + private ushort _nextHyperlinkId = 1; // 0 is reserved for "no link" + private readonly Dictionary _hyperlinks = new(); + private readonly Dictionary _hyperlinkKeys = new(); + // Selection state private SelectionRange? _selection; @@ -417,6 +426,7 @@ internal void PutChar(char c) cell.Fg = _penFg; cell.Bg = _penBg; cell.Attrs = wide ? _penAttrs | CellAttributes.Wide : _penAttrs; + cell.HyperlinkId = _penHyperlinkId; if (wide) { @@ -426,6 +436,7 @@ internal void PutChar(char c) spacer.Fg = _penFg; spacer.Bg = _penBg; spacer.Attrs = _penAttrs | CellAttributes.WideSpacer; + spacer.HyperlinkId = _penHyperlinkId; } _damage.MarkRow(_cursor.Position.Row); @@ -684,6 +695,10 @@ internal void Reset() _penFg = Color.DefaultColor; _penBg = Color.DefaultColor; _penAttrs = CellAttributes.None; + _penHyperlinkId = 0; + _nextHyperlinkId = 1; + _hyperlinks.Clear(); + _hyperlinkKeys.Clear(); _selection = null; _title = ""; _workingDirectory = null; @@ -720,6 +735,43 @@ internal void SetCursorVisible(bool visible) internal void SetWorkingDirectory(string dir) => _workingDirectory = dir; + /// + /// Opens or closes an OSC 8 hyperlink. An empty closes + /// the current link; a non-empty one becomes the pen link for subsequent + /// output. is the sequence's explicit id= param + /// (or null); it groups spans — e.g. the two rows of a wrapped link — under + /// one link id. The URI is stored verbatim; callers that act on it (open a + /// browser) are responsible for restricting schemes. + /// + internal void SetHyperlink(string? id, string uri) + { + if (string.IsNullOrEmpty(uri)) + { + _penHyperlinkId = 0; + return; + } + + // Group by explicit id when present so a wrapped/multi-span link is one + // entry; otherwise group by URI so repeats of the same link don't grow + // the table without bound. + var key = string.IsNullOrEmpty(id) ? "u" + uri : "i" + id; + if (!_hyperlinkKeys.TryGetValue(key, out var linkId)) + { + linkId = _nextHyperlinkId; + // ushort ids top out at 65535 — vanishingly unlikely in a session, but + // stop allocating rather than wrap to 0 ("no link") or collide. + if (_nextHyperlinkId != ushort.MaxValue) + _nextHyperlinkId++; + _hyperlinkKeys[key] = linkId; + _hyperlinks[linkId] = uri; + } + + _penHyperlinkId = linkId; + } + + /// OSC 8 hyperlink table: cell → URI. + public IReadOnlyDictionary Hyperlinks => _hyperlinks; + internal void PushResponse(ReadOnlySpan data) => _responseBuffer.AddRange(data); // Bell event — UI layer can subscribe to trigger visual bell