Simplify multi-line URL detection with whitespace-based approach

- Detect http(s):// in current line with regex
- Scan subsequent lines until first whitespace character
- Much simpler and more reliable than URL validation
- Use actual <a> tags instead of spans for proper linking
- Remove complex string highlighting (focus on URLs only)
- Cleaner, more maintainable code

🤖 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 00:24:17 +02:00
parent 98bb30f89f
commit 97143abc20
2 changed files with 100 additions and 166 deletions

View file

@ -227,16 +227,6 @@
content += 'Another long URL in middle of text:\r\n'; content += 'Another long URL in middle of text:\r\n';
content += 'Check out this amazing resource at https://very-long-domain-name.example.com/path/to/\r\n'; content += 'Check out this amazing resource at https://very-long-domain-name.example.com/path/to/\r\n';
content += 'some/deeply/nested/resource/with/query?param1=value1&param2=value2 for more details.\r\n\r\n'; content += 'some/deeply/nested/resource/with/query?param1=value1&param2=value2 for more details.\r\n\r\n';
content += 'Multi-line strings:\r\n';
content += 'config = {\r\n';
content += ' "database_url": "postgresql://user:password@localhost:5432/mydb?sslmode=require&\r\n';
content += 'connection_timeout=30&pool_size=10",\r\n';
content += ' "api_key": `very-long-api-key-that-spans-multiple-lines-\r\n';
content += 'because-it-is-really-really-long-and-complex`,\r\n';
content += ' "message": \'This is a single quoted string that\r\n';
content += 'spans multiple lines for demonstration\'\r\n';
content += '}\r\n\r\n';
// Separator // Separator
content += '\x1b[90m' + '═'.repeat(100) + '\x1b[0m\r\n\r\n'; content += '\x1b[90m' + '═'.repeat(100) + '\x1b[0m\r\n\r\n';

View file

@ -550,77 +550,94 @@ export class Terminal extends LitElement {
const lines = this.container.querySelectorAll('.terminal-line'); const lines = this.container.querySelectorAll('.terminal-line');
if (lines.length === 0) return; if (lines.length === 0) return;
// Extract text content from all lines for multi-line URL detection for (let i = 0; i < lines.length; i++) {
const fullText = Array.from(lines) const lineText = this.getLineText(lines[i]);
.map((line) => this.getLineText(line))
.join('\n');
// Find URLs and strings // Look for http(s):// in this line
const patterns: Array<{ text: string; start: number; end: number; type: 'url' | 'string' }> = const httpMatch = lineText.match(/(https?:\/\/)/);
[]; if (httpMatch) {
const urlStart = httpMatch.index!;
let fullUrl = '';
let endLine = i;
// URL regex that matches common URL patterns // Build the URL by scanning from the http part until we hit whitespace
const urlRegex = /(https?:\/\/[^\s\n<>"']+)/gi; for (let j = i; j < lines.length; j++) {
let match; let remainingText = '';
while ((match = urlRegex.exec(fullText)) !== null) {
patterns.push({
text: match[1],
start: match.index,
end: match.index + match[1].length,
type: 'url',
});
}
// String regex that matches quoted strings (single, double, backtick) if (j === i) {
const stringRegex = /(['"`])((?:\\.|(?!\1)[^\\])*?)\1/gs; // Current line: start from http position
urlRegex.lastIndex = 0; // Reset regex remainingText = lineText.substring(urlStart);
while ((match = stringRegex.exec(fullText)) !== null) { } else {
// Only highlight strings that span multiple lines or are reasonably long // Subsequent lines: take the whole trimmed line
const stringContent = match[0]; remainingText = this.getLineText(lines[j]).trim();
const hasNewline = stringContent.includes('\n'); }
const isLong = stringContent.length > 20;
if (hasNewline || isLong) { // Find first whitespace character in this line's text
patterns.push({ const whitespaceMatch = remainingText.match(/\s/);
text: stringContent, if (whitespaceMatch) {
start: match.index, // Found whitespace, URL ends here
end: match.index + stringContent.length, fullUrl += remainingText.substring(0, whitespaceMatch.index);
type: 'string', 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);
}
} }
} }
}
if (patterns.length === 0) return; private createUrlLinks(
lines: NodeListOf<Element>,
fullUrl: string,
startLine: number,
endLine: number,
startCol: number
) {
let remainingUrl = fullUrl;
// Sort by start position to handle overlaps for (let lineIdx = startLine; lineIdx <= endLine; lineIdx++) {
patterns.sort((a, b) => a.start - b.start); const line = lines[lineIdx];
const lineText = this.getLineText(line);
// Remove overlapping patterns (URLs take precedence over strings) if (lineIdx === startLine) {
const filteredPatterns = []; // First line: URL starts at startCol
for (const pattern of patterns) { const lineUrlPart = lineText.substring(startCol);
const hasOverlap = filteredPatterns.some( const urlPartLength = Math.min(lineUrlPart.length, remainingUrl.length);
(existing) => pattern.start < existing.end && pattern.end > existing.start
); this.createClickableInLine(line, fullUrl, 'url', startCol, startCol + urlPartLength);
if (!hasOverlap) { remainingUrl = remainingUrl.substring(urlPartLength);
filteredPatterns.push(pattern); } 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;
} }
// Convert character positions to line/column positions
const patternPositions = filteredPatterns.map((patternInfo) => {
const { text, start, end, type } = patternInfo;
return {
text,
type,
startPos: this.charPosToLineCol(fullText, start),
endPos: this.charPosToLineCol(fullText, end),
};
});
// Apply styling to each pattern
patternPositions.forEach(({ text, type, startPos, endPos }) => {
this.createClickableSpans(lines, text, type, startPos, endPos);
});
} }
private getLineText(lineElement: Element): string { private getLineText(lineElement: Element): string {
@ -629,62 +646,10 @@ export class Terminal extends LitElement {
return textContent; return textContent;
} }
private charPosToLineCol(fullText: string, charPos: number): { line: number; col: number } {
const lines = fullText.split('\n');
let currentPos = 0;
for (let line = 0; line < lines.length; line++) {
const lineLength = lines[line].length;
if (charPos <= currentPos + lineLength) {
return { line, col: charPos - currentPos };
}
currentPos += lineLength + 1; // +1 for the newline character
}
return { line: lines.length - 1, col: lines[lines.length - 1].length };
}
private createClickableSpans(
lines: NodeListOf<Element>,
text: string,
type: 'url' | 'string',
startPos: { line: number; col: number },
endPos: { line: number; col: number }
) {
// Handle single-line and multi-line patterns
if (startPos.line === endPos.line) {
// Single line pattern
this.createClickableInLine(lines[startPos.line], text, type, startPos.col, endPos.col);
} else {
// Multi-line pattern
for (let lineIdx = startPos.line; lineIdx <= endPos.line; lineIdx++) {
const line = lines[lineIdx];
if (!line) continue;
let startCol, endCol;
if (lineIdx === startPos.line) {
// First line: from startPos.col to end of line
startCol = startPos.col;
endCol = this.getLineText(line).length;
} else if (lineIdx === endPos.line) {
// Last line: from start of line to endPos.col
startCol = 0;
endCol = endPos.col;
} else {
// Middle lines: entire line
startCol = 0;
endCol = this.getLineText(line).length;
}
this.createClickableInLine(line, text, type, startCol, endCol);
}
}
}
private createClickableInLine( private createClickableInLine(
lineElement: Element, lineElement: Element,
text: string, url: string,
type: 'url' | 'string', type: 'url',
startCol: number, startCol: number,
endCol: number endCol: number
) { ) {
@ -718,8 +683,7 @@ export class Terminal extends LitElement {
textNode, textNode,
linkStart, linkStart,
linkEnd, linkEnd,
text, url,
type,
!foundStart, !foundStart,
nodeEnd >= endCol nodeEnd >= endCol
); );
@ -739,8 +703,7 @@ export class Terminal extends LitElement {
textNode: Text, textNode: Text,
start: number, start: number,
end: number, end: number,
text: string, url: string,
type: 'url' | 'string',
_isFirst: boolean, _isFirst: boolean,
_isLast: boolean _isLast: boolean
) { ) {
@ -749,42 +712,28 @@ export class Terminal extends LitElement {
const nodeText = textNode.textContent || ''; const nodeText = textNode.textContent || '';
const beforeText = nodeText.substring(0, start); const beforeText = nodeText.substring(0, start);
const clickableText = nodeText.substring(start, end); const linkText = nodeText.substring(start, end);
const afterText = nodeText.substring(end); const afterText = nodeText.substring(end);
// Create the clickable element // Create the link element
const clickableElement = document.createElement('span'); 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;
if (type === 'url') { // Add hover effects
clickableElement.className = 'terminal-link'; linkElement.addEventListener('mouseenter', () => {
clickableElement.style.color = '#4fc3f7'; linkElement.style.backgroundColor = 'rgba(79, 195, 247, 0.2)';
clickableElement.style.textDecoration = 'underline'; });
clickableElement.style.cursor = 'pointer';
clickableElement.setAttribute('data-url', text);
// Add click handler for URLs linkElement.addEventListener('mouseleave', () => {
clickableElement.addEventListener('click', (e) => { linkElement.style.backgroundColor = '';
e.preventDefault(); });
window.open(text, '_blank');
});
// Add hover effects for URLs
clickableElement.addEventListener('mouseenter', () => {
clickableElement.style.backgroundColor = 'rgba(79, 195, 247, 0.2)';
});
clickableElement.addEventListener('mouseleave', () => {
clickableElement.style.backgroundColor = '';
});
} else {
// String styling
clickableElement.className = 'terminal-string';
clickableElement.style.color = '#ce9178';
clickableElement.style.backgroundColor = 'rgba(206, 145, 120, 0.1)';
clickableElement.setAttribute('data-string', text);
}
clickableElement.textContent = clickableText;
// Replace the text node with the new structure // Replace the text node with the new structure
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
@ -793,7 +742,7 @@ export class Terminal extends LitElement {
fragment.appendChild(document.createTextNode(beforeText)); fragment.appendChild(document.createTextNode(beforeText));
} }
fragment.appendChild(clickableElement); fragment.appendChild(linkElement);
if (afterText) { if (afterText) {
fragment.appendChild(document.createTextNode(afterText)); fragment.appendChild(document.createTextNode(afterText));
@ -883,11 +832,6 @@ export class Terminal extends LitElement {
.terminal-link:hover { .terminal-link:hover {
background-color: rgba(79, 195, 247, 0.2); background-color: rgba(79, 195, 247, 0.2);
} }
.terminal-string {
color: #ce9178;
background-color: rgba(206, 145, 120, 0.1);
}
</style> </style>
<div id="terminal-container" class="terminal-container w-full h-full"></div> <div id="terminal-container" class="terminal-container w-full h-full"></div>
`; `;