Refactor URL highlighting into separate utility class

- Extract URL detection and highlighting logic from terminal.ts into UrlHighlighter utility class
- Move all URL processing methods (processLinks, createUrlLinks, getLineText, createClickableInLine, wrapTextInClickable) to new file
- Keep terminal.ts focused on core terminal functionality
- Maintain all existing URL highlighting functionality including multi-line URL support

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mario Zechner 2025-06-18 02:12:01 +02:00
parent 85b2eadf40
commit 2e599d4679
2 changed files with 226 additions and 215 deletions

View file

@ -1,6 +1,7 @@
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { Terminal as XtermTerminal, IBufferLine, IBufferCell } from '@xterm/xterm';
import { UrlHighlighter } from '../utils/url-highlighter.js';
@customElement('vibe-terminal')
export class Terminal extends LitElement {
@ -494,7 +495,7 @@ export class Terminal extends LitElement {
const linkProcessStart = performance.now();
// Process links after rendering
this.processLinks();
UrlHighlighter.processLinks(this.container);
const linkProcessEnd = performance.now();
const renderEnd = performance.now();
@ -747,220 +748,6 @@ export class Terminal extends LitElement {
return Math.max(0, buffer.length - this.actualRows);
}
private processLinks() {
if (!this.container) return;
// Get all terminal lines
const lines = this.container.querySelectorAll('.terminal-line');
if (lines.length === 0) return;
for (let i = 0; i < lines.length; i++) {
const lineText = this.getLineText(lines[i]);
// Look for http(s):// in this line
const httpMatch = lineText.match(/(https?:\/\/)/);
if (httpMatch) {
const urlStart = httpMatch.index!;
let fullUrl = '';
let endLine = i;
// Build the URL by scanning from the http part until we hit whitespace
for (let j = i; j < lines.length; j++) {
let remainingText = '';
if (j === i) {
// Current line: start from http position
remainingText = lineText.substring(urlStart);
} else {
// Subsequent lines: take the whole trimmed line
remainingText = this.getLineText(lines[j]).trim();
}
// Stop if line is empty (after trimming)
if (remainingText === '') {
endLine = j - 1; // URL ended on previous line
break;
}
// Find first whitespace character in this line's text
const whitespaceMatch = remainingText.match(/\s/);
if (whitespaceMatch) {
// Found whitespace, URL ends here
fullUrl += remainingText.substring(0, whitespaceMatch.index);
endLine = j;
break;
} else {
// No whitespace, take the whole line
fullUrl += remainingText;
endLine = j;
// If this is the last line, we're done
if (j === lines.length - 1) break;
}
}
// Now create links for this URL across the lines it spans
if (fullUrl.length > 7) {
// More than just "http://"
this.createUrlLinks(lines, fullUrl, i, endLine, urlStart);
}
}
}
}
private createUrlLinks(
lines: NodeListOf<Element>,
fullUrl: string,
startLine: number,
endLine: number,
startCol: number
) {
let remainingUrl = fullUrl;
for (let lineIdx = startLine; lineIdx <= endLine; lineIdx++) {
const line = lines[lineIdx];
const lineText = this.getLineText(line);
if (lineIdx === startLine) {
// First line: URL starts at startCol
const lineUrlPart = lineText.substring(startCol);
const urlPartLength = Math.min(lineUrlPart.length, remainingUrl.length);
this.createClickableInLine(line, fullUrl, 'url', startCol, startCol + urlPartLength);
remainingUrl = remainingUrl.substring(urlPartLength);
} else {
// Subsequent lines: take from start of trimmed content
const trimmedLine = lineText.trim();
const urlPartLength = Math.min(trimmedLine.length, remainingUrl.length);
if (urlPartLength > 0) {
const startColForLine = lineText.indexOf(trimmedLine);
this.createClickableInLine(
line,
fullUrl,
'url',
startColForLine,
startColForLine + urlPartLength
);
remainingUrl = remainingUrl.substring(urlPartLength);
}
}
if (remainingUrl.length === 0) break;
}
}
private getLineText(lineElement: Element): string {
// Get the text content, preserving spaces but removing HTML tags
const textContent = lineElement.textContent || '';
return textContent;
}
private createClickableInLine(
lineElement: Element,
url: string,
type: 'url',
startCol: number,
endCol: number
) {
if (startCol >= endCol) return;
// We need to work with the actual DOM structure, not just text
const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT, null);
const textNodes: Text[] = [];
let node;
while ((node = walker.nextNode())) {
textNodes.push(node as Text);
}
let currentPos = 0;
let foundStart = false;
let foundEnd = false;
for (const textNode of textNodes) {
const nodeText = textNode.textContent || '';
const nodeStart = currentPos;
const nodeEnd = currentPos + nodeText.length;
// Check if this text node contains part of our link
if (!foundEnd && nodeEnd > startCol && nodeStart < endCol) {
const linkStart = Math.max(0, startCol - nodeStart);
const linkEnd = Math.min(nodeText.length, endCol - nodeStart);
if (linkStart < linkEnd) {
this.wrapTextInClickable(
textNode,
linkStart,
linkEnd,
url,
!foundStart,
nodeEnd >= endCol
);
foundStart = true;
if (nodeEnd >= endCol) {
foundEnd = true;
break;
}
}
}
currentPos = nodeEnd;
}
}
private wrapTextInClickable(
textNode: Text,
start: number,
end: number,
url: string,
_isFirst: boolean,
_isLast: boolean
) {
const parent = textNode.parentNode;
if (!parent) return;
const nodeText = textNode.textContent || '';
const beforeText = nodeText.substring(0, start);
const linkText = nodeText.substring(start, end);
const afterText = nodeText.substring(end);
// Create the link element
const linkElement = document.createElement('a');
linkElement.className = 'terminal-link';
linkElement.href = url;
linkElement.target = '_blank';
linkElement.rel = 'noopener noreferrer';
linkElement.style.color = '#4fc3f7';
linkElement.style.textDecoration = 'underline';
linkElement.style.cursor = 'pointer';
linkElement.textContent = linkText;
// Add hover effects
linkElement.addEventListener('mouseenter', () => {
linkElement.style.backgroundColor = 'rgba(79, 195, 247, 0.2)';
});
linkElement.addEventListener('mouseleave', () => {
linkElement.style.backgroundColor = '';
});
// Replace the text node with the new structure
const fragment = document.createDocumentFragment();
if (beforeText) {
fragment.appendChild(document.createTextNode(beforeText));
}
fragment.appendChild(linkElement);
if (afterText) {
fragment.appendChild(document.createTextNode(afterText));
}
parent.replaceChild(fragment, textNode);
}
render() {
return html`
<style>

View file

@ -0,0 +1,224 @@
/**
* URL Highlighter utility for DOM terminal
*
* Handles detection and highlighting of URLs in terminal content,
* including multi-line URLs that span across terminal lines.
*/
export class UrlHighlighter {
/**
* Process all lines in a container and highlight any URLs found
* @param container - The DOM container containing terminal lines
*/
static processLinks(container: HTMLElement): void {
// Get all terminal lines
const lines = container.querySelectorAll('.terminal-line');
if (lines.length === 0) return;
for (let i = 0; i < lines.length; i++) {
const lineText = this.getLineText(lines[i]);
// Look for http(s):// in this line
const httpMatch = lineText.match(/(https?:\/\/)/);
if (httpMatch) {
const urlStart = httpMatch.index!;
let fullUrl = '';
let endLine = i;
// Build the URL by scanning from the http part until we hit whitespace
for (let j = i; j < lines.length; j++) {
let remainingText = '';
if (j === i) {
// Current line: start from http position
remainingText = lineText.substring(urlStart);
} else {
// Subsequent lines: take the whole trimmed line
remainingText = this.getLineText(lines[j]).trim();
}
// Stop if line is empty (after trimming)
if (remainingText === '') {
endLine = j - 1; // URL ended on previous line
break;
}
// Find first whitespace character in this line's text
const whitespaceMatch = remainingText.match(/\s/);
if (whitespaceMatch) {
// Found whitespace, URL ends here
fullUrl += remainingText.substring(0, whitespaceMatch.index);
endLine = j;
break;
} else {
// No whitespace, take the whole line
fullUrl += remainingText;
endLine = j;
// If this is the last line, we're done
if (j === lines.length - 1) break;
}
}
// Now create links for this URL across the lines it spans
if (fullUrl.length > 7) {
// More than just "http://"
this.createUrlLinks(lines, fullUrl, i, endLine, urlStart);
}
}
}
}
private static createUrlLinks(
lines: NodeListOf<Element>,
fullUrl: string,
startLine: number,
endLine: number,
startCol: number
): void {
let remainingUrl = fullUrl;
for (let lineIdx = startLine; lineIdx <= endLine; lineIdx++) {
const line = lines[lineIdx];
const lineText = this.getLineText(line);
if (lineIdx === startLine) {
// First line: URL starts at startCol
const lineUrlPart = lineText.substring(startCol);
const urlPartLength = Math.min(lineUrlPart.length, remainingUrl.length);
this.createClickableInLine(line, fullUrl, 'url', startCol, startCol + urlPartLength);
remainingUrl = remainingUrl.substring(urlPartLength);
} else {
// Subsequent lines: take from start of trimmed content
const trimmedLine = lineText.trim();
const urlPartLength = Math.min(trimmedLine.length, remainingUrl.length);
if (urlPartLength > 0) {
const startColForLine = lineText.indexOf(trimmedLine);
this.createClickableInLine(
line,
fullUrl,
'url',
startColForLine,
startColForLine + urlPartLength
);
remainingUrl = remainingUrl.substring(urlPartLength);
}
}
if (remainingUrl.length === 0) break;
}
}
private static getLineText(lineElement: Element): string {
// Get the text content, preserving spaces but removing HTML tags
const textContent = lineElement.textContent || '';
return textContent;
}
private static createClickableInLine(
lineElement: Element,
url: string,
type: 'url',
startCol: number,
endCol: number
): void {
if (startCol >= endCol) return;
// We need to work with the actual DOM structure, not just text
const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT, null);
const textNodes: Text[] = [];
let node;
while ((node = walker.nextNode())) {
textNodes.push(node as Text);
}
let currentPos = 0;
let foundStart = false;
let foundEnd = false;
for (const textNode of textNodes) {
const nodeText = textNode.textContent || '';
const nodeStart = currentPos;
const nodeEnd = currentPos + nodeText.length;
// Check if this text node contains part of our link
if (!foundEnd && nodeEnd > startCol && nodeStart < endCol) {
const linkStart = Math.max(0, startCol - nodeStart);
const linkEnd = Math.min(nodeText.length, endCol - nodeStart);
if (linkStart < linkEnd) {
this.wrapTextInClickable(
textNode,
linkStart,
linkEnd,
url,
!foundStart,
nodeEnd >= endCol
);
foundStart = true;
if (nodeEnd >= endCol) {
foundEnd = true;
break;
}
}
}
currentPos = nodeEnd;
}
}
private static wrapTextInClickable(
textNode: Text,
start: number,
end: number,
url: string,
_isFirst: boolean,
_isLast: boolean
): void {
const parent = textNode.parentNode;
if (!parent) return;
const nodeText = textNode.textContent || '';
const beforeText = nodeText.substring(0, start);
const linkText = nodeText.substring(start, end);
const afterText = nodeText.substring(end);
// Create the link element
const linkElement = document.createElement('a');
linkElement.className = 'terminal-link';
linkElement.href = url;
linkElement.target = '_blank';
linkElement.rel = 'noopener noreferrer';
linkElement.style.color = '#4fc3f7';
linkElement.style.textDecoration = 'underline';
linkElement.style.cursor = 'pointer';
linkElement.textContent = linkText;
// Add hover effects
linkElement.addEventListener('mouseenter', () => {
linkElement.style.backgroundColor = 'rgba(79, 195, 247, 0.2)';
});
linkElement.addEventListener('mouseleave', () => {
linkElement.style.backgroundColor = '';
});
// Replace the text node with the new structure
const fragment = document.createDocumentFragment();
if (beforeText) {
fragment.appendChild(document.createTextNode(beforeText));
}
fragment.appendChild(linkElement);
if (afterText) {
fragment.appendChild(document.createTextNode(afterText));
}
parent.replaceChild(fragment, textNode);
}
}