From a8c0e2d9c3f3bf84eb0e3681f7f9dece28f7cdd4 Mon Sep 17 00:00:00 2001 From: Sergey Simonchik Date: Wed, 5 Aug 2026 17:04:32 +0200 Subject: [PATCH] [terminal] IJPL-246212 support OSC8 hyperlinks in `TerminalOutputPattern` (IJ-CR-216855) (cherry picked from commit 28e56b5ead293497f195df33eb2de79eeea140e2) IJ-CR-216855 GitOrigin-RevId: 3005b74c4ea472e2bac0d161818c336afae8b4cf --- .../reworked/util/TerminalOutputPattern.kt | 60 ++++++++- .../TerminalOutputPatternExtensionsTest.kt | 32 +++++ .../util/TerminalOutputPatternTest.kt | 127 ++++++++++++++++++ 3 files changed, 214 insertions(+), 5 deletions(-) diff --git a/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/util/TerminalOutputPattern.kt b/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/util/TerminalOutputPattern.kt index 1f97d280f613..2e60d0f96383 100644 --- a/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/util/TerminalOutputPattern.kt +++ b/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/util/TerminalOutputPattern.kt @@ -5,6 +5,7 @@ import com.intellij.terminal.tests.reworked.util.TerminalOutputPattern.Companion import com.jediterm.terminal.TerminalColor import com.jediterm.terminal.TextStyle import org.assertj.core.api.Assertions.assertThat +import org.jetbrains.plugins.terminal.session.impl.Osc8Hyperlink import org.jetbrains.plugins.terminal.session.impl.StyleRange import org.jetbrains.plugins.terminal.view.TerminalLineIndex import org.jetbrains.plugins.terminal.view.TerminalOffset @@ -19,6 +20,7 @@ internal class TerminalOutputPattern( val text: String, val styles: List, val cursorOffset: Int?, + val osc8Hyperlinks: List, ) { override fun toString(): String { return asString() @@ -31,13 +33,17 @@ internal class TerminalOutputPattern( override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is TerminalOutputPattern) return false - return text == other.text && cursorOffset == other.cursorOffset && mergeAdjacentStyles(styles) == mergeAdjacentStyles(other.styles) + return text == other.text && + cursorOffset == other.cursorOffset && + mergeAdjacentStyles(styles) == mergeAdjacentStyles(other.styles) && + osc8Hyperlinks == other.osc8Hyperlinks } override fun hashCode(): Int { var result = text.hashCode() result = 31 * result + mergeAdjacentStyles(styles).hashCode() result = 31 * result + (cursorOffset ?: 0) + result = 31 * result + osc8Hyperlinks.hashCode() return result } @@ -57,11 +63,14 @@ internal class TerminalOutputPattern( } private val STYLE_TAG_REGEX = Regex("^s([1-9])$") +private const val LINK_TAG_NAME = "a" +private const val LINK_HREF_ATTR = "href" /** * Define the content of the [org.jetbrains.plugins.terminal.view.TerminalOutputModel] using a string with XML markup: * 1. Use `` tag inside string to specify cursor position * 2. Use ``, ``, etc. tags to specify styles, like `hello` + * 3. Use `` tags to specify OSC8 hyperlinks, like `hello` */ internal fun outputPattern(pattern: String): TerminalOutputPattern { // Preprocess: replace with so JSoup XML parser treats it as self-closing @@ -70,9 +79,11 @@ internal fun outputPattern(pattern: String): TerminalOutputPattern { val textBuilder = StringBuilder() val styles = mutableListOf() + val osc8Hyperlinks = mutableListOf() var cursorOffset: Int? = null - fun processNodes(nodes: List, currentStyleIndex: Int?) { + // currentTag is the tag we're nested inside, e.g. "s1" or "a"; styles and links may not nest into each other. + fun processNodes(nodes: List, currentTag: String?) { for (node in nodes) { when (node) { is TextNode -> { @@ -85,17 +96,41 @@ internal fun outputPattern(pattern: String): TerminalOutputPattern { require(node.childNodeSize() == 0) { " tag must be empty" } cursorOffset = textBuilder.length } + else if (tagName == LINK_TAG_NAME) { + require(currentTag == null) { "Nested <$tagName> tags are not allowed: <$tagName> inside <$currentTag>" } + require(node.hasAttr(LINK_HREF_ATTR)) { " tag must have an href attribute" } + val uri = node.attr(LINK_HREF_ATTR) + + val startOffset = textBuilder.length + val cursorBefore = cursorOffset + processNodes(node.childNodes(), tagName) + val endOffset = textBuilder.length + + require(!textBuilder.substring(startOffset, endOffset).contains('\n')) { + " tags cannot span multiple lines" + } + + if (cursorOffset != null && cursorOffset != cursorBefore) { + require(cursorOffset != startOffset && cursorOffset != endOffset) { + " must not be placed at the boundary inside ; place it outside the tag instead" + } + } + + if (startOffset != endOffset) { + osc8Hyperlinks.add(Osc8Hyperlink(startOffset.toLong(), endOffset.toLong(), uri)) + } + } else { val match = STYLE_TAG_REGEX.matchEntire(tagName) requireNotNull(match) { "Unknown tag: <$tagName>" } - require(currentStyleIndex == null) { "Nested style tags are not allowed: <$tagName> inside " } + require(currentTag == null) { "Nested style tags are not allowed: <$tagName> inside <$currentTag>" } val styleNum = match.groupValues[1].toInt() val styleIndex = styleNum - 1 val startOffset = textBuilder.length val cursorBefore = cursorOffset - processNodes(node.childNodes(), styleIndex) + processNodes(node.childNodes(), tagName) val endOffset = textBuilder.length require(!textBuilder.substring(startOffset, endOffset).contains('\n')) { @@ -129,6 +164,7 @@ internal fun outputPattern(pattern: String): TerminalOutputPattern { text = textBuilder.toString(), styles = styles, cursorOffset = cursorOffset, + osc8Hyperlinks = osc8Hyperlinks, ) } @@ -147,6 +183,10 @@ internal fun TerminalOutputPattern.asString(): String { insertions.add(Insertion(style.startOffset.toInt(), 2, "")) insertions.add(Insertion(style.endOffset.toInt(), 0, "")) } + for (link in osc8Hyperlinks) { + insertions.add(Insertion(link.startOffset.toInt(), 2, "")) + insertions.add(Insertion(link.endOffset.toInt(), 0, "")) + } if (cursorOffset != null) { insertions.add(Insertion(cursorOffset, 1, "")) } @@ -178,7 +218,7 @@ private fun mergeAdjacentStyles(styles: List): List { } internal fun MutableTerminalOutputModel.updateContent(absoluteLineIndex: Long, pattern: TerminalOutputPattern) { - updateContent(absoluteLineIndex, pattern.text, pattern.styles) + updateContent(absoluteLineIndex, pattern.text, pattern.styles, pattern.osc8Hyperlinks) if (pattern.cursorOffset != null) { val lineStartOffset = getStartOfLine(TerminalLineIndex.of(absoluteLineIndex)) updateCursorPosition(lineStartOffset + pattern.cursorOffset.toLong()) @@ -190,6 +230,9 @@ internal fun MutableTerminalOutputModel.replaceContent( length: Int, pattern: TerminalOutputPattern, ) { + require(pattern.osc8Hyperlinks.isEmpty()) { + "replaceContent cannot add OSC8 hyperlinks; the model clears links over the replaced range" + } replaceContent(offset, length, pattern.text, pattern.styles) } @@ -201,10 +244,17 @@ internal fun MutableTerminalOutputModel.toPattern(): TerminalOutputPattern { endOffset = it.endOffset - state.trimmedCharsCount, ) } + val relativeLinks = state.osc8Hyperlinks.map { + it.copy( + startOffset = it.startOffset - state.trimmedCharsCount, + endOffset = it.endOffset - state.trimmedCharsCount, + ) + } return TerminalOutputPattern( text = state.text, styles = relativeStyles, cursorOffset = state.cursorOffset, + osc8Hyperlinks = relativeLinks, ) } diff --git a/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/util/TerminalOutputPatternExtensionsTest.kt b/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/util/TerminalOutputPatternExtensionsTest.kt index d872e892dbbe..8043dc4e1a3f 100644 --- a/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/util/TerminalOutputPatternExtensionsTest.kt +++ b/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/util/TerminalOutputPatternExtensionsTest.kt @@ -6,6 +6,7 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 @@ -72,6 +73,37 @@ class TerminalOutputPatternExtensionsTest : BasePlatformTestCase() { assertThat(model.toPattern()).isEqualTo(expected) } + @Test + fun `model matches text with link`() = runOnEdt { + val model = TerminalTestUtil.createOutputModel() + model.updateContent(0, outputPattern("hello world")) + model.assertMatches(outputPattern("hello world")) + } + + @Test + fun `model does not match different link uri`() = runOnEdt { + val model = TerminalTestUtil.createOutputModel() + model.updateContent(0, outputPattern("hello")) + assertThat(model.matches(outputPattern("hello"))).isFalse() + } + + @Test + fun `model toPattern round-trip with link`() = runOnEdt { + val model = TerminalTestUtil.createOutputModel() + val expected = outputPattern("hi there!") + model.updateContent(0, expected) + assertThat(model.toPattern()).isEqualTo(expected) + } + + @Test + fun `replaceContent with a pattern containing a link throws`() = runOnEdt { + val model = TerminalTestUtil.createOutputModel() + model.updateContent(0, outputPattern("hello world")) + assertThatThrownBy { + model.replaceContent(model.startOffset, model.textLength, outputPattern("goodbye")) + }.isInstanceOf(IllegalArgumentException::class.java) + } + @Test fun `model matches empty`() = runOnEdt { val model = TerminalTestUtil.createOutputModel() diff --git a/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/util/TerminalOutputPatternTest.kt b/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/util/TerminalOutputPatternTest.kt index 437fe465a594..e61396e64e3c 100644 --- a/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/util/TerminalOutputPatternTest.kt +++ b/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/util/TerminalOutputPatternTest.kt @@ -4,6 +4,7 @@ package com.intellij.terminal.tests.reworked.util import com.jediterm.terminal.TextStyle import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy +import org.jetbrains.plugins.terminal.session.impl.Osc8Hyperlink import org.jetbrains.plugins.terminal.session.impl.StyleRange import org.junit.Test @@ -208,6 +209,52 @@ class TerminalOutputPatternTest { assertThat(pattern.cursorOffset).isEqualTo(3) } + // Parsing tests: OSC8 links + + @Test + fun `parse single link`() { + val pattern = outputPattern("hello world") + assertThat(pattern.text).isEqualTo("hello world") + assertThat(pattern.osc8Hyperlinks).containsExactly( + osc8Hyperlink(6, 11, "https://example.com") + ) + } + + @Test + fun `parse empty link tag produces no link`() { + val pattern = outputPattern("") + assertThat(pattern.text).isEqualTo("") + assertThat(pattern.osc8Hyperlinks).isEmpty() + } + + @Test + fun `parse multiple links`() { + val pattern = outputPattern("aa bb") + assertThat(pattern.text).isEqualTo("aa bb") + assertThat(pattern.osc8Hyperlinks).containsExactly( + osc8Hyperlink(0, 2, "https://a"), + osc8Hyperlink(3, 5, "https://b"), + ) + } + + @Test + fun `parse link with cursor inside`() { + val pattern = outputPattern("world") + assertThat(pattern.text).isEqualTo("world") + assertThat(pattern.osc8Hyperlinks).containsExactly( + osc8Hyperlink(0, 5, "https://example.com") + ) + assertThat(pattern.cursorOffset).isEqualTo(3) + } + + @Test + fun `parse link and style side by side`() { + val pattern = outputPattern("hi there") + assertThat(pattern.text).isEqualTo("hi there") + assertThat(pattern.styles).containsExactly(styleRange(0, 2, TerminalOutputPattern.STYLES[0])) + assertThat(pattern.osc8Hyperlinks).containsExactly(osc8Hyperlink(3, 8, "https://example.com")) + } + // Parsing validation tests @Test @@ -286,6 +333,48 @@ class TerminalOutputPatternTest { ) } + @Test + fun `link without href throws`() { + assertThatThrownBy { outputPattern("text") } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("href") + } + + @Test + fun `nested link tags throw`() { + assertThatThrownBy { outputPattern("hello world") } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Nested") + } + + @Test + fun `style nested inside link throws`() { + assertThatThrownBy { outputPattern("text") } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Nested") + } + + @Test + fun `link nested inside style throws`() { + assertThatThrownBy { outputPattern("text") } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Nested") + } + + @Test + fun `link spanning multiple lines throws`() { + assertThatThrownBy { outputPattern("hello\nworld") } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("multiple lines") + } + + @Test + fun `cursor at boundary inside link throws`() { + assertThatThrownBy { outputPattern("hello") } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("boundary") + } + // toString round-trip tests @Test @@ -353,6 +442,21 @@ class TerminalOutputPatternTest { assertToStringRoundTrip("") } + @Test + fun `toString with link`() { + assertToStringRoundTrip("hello world") + } + + @Test + fun `toString with link and cursor`() { + assertToStringRoundTrip("world") + } + + @Test + fun `toString with link and style side by side`() { + assertToStringRoundTrip("hi there") + } + private fun assertToStringRoundTrip(input: String) { val pattern = outputPattern(input) val reconstructed = pattern.toString() @@ -447,6 +551,29 @@ class TerminalOutputPatternTest { assertThat(split.hashCode()).isEqualTo(partialMerge.hashCode()) } + @Test + fun `same link pattern is equal`() { + val a = outputPattern("hello") + val b = outputPattern("hello") + assertThat(a).isEqualTo(b) + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `different link uri not equal`() { + assertThat(outputPattern("hello")) + .isNotEqualTo(outputPattern("hello")) + } + + @Test + fun `link vs no link not equal`() { + assertThat(outputPattern("hello")).isNotEqualTo(outputPattern("hello")) + } + + private fun osc8Hyperlink(startOffset: Long, endOffset: Long, uri: String): Osc8Hyperlink { + return Osc8Hyperlink(startOffset, endOffset, uri) + } + private fun styleRange( startOffset: Long, endOffset: Long,