mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
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:
parent
85b2eadf40
commit
2e599d4679
2 changed files with 226 additions and 215 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import { LitElement, html } from 'lit';
|
import { LitElement, html } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { Terminal as XtermTerminal, IBufferLine, IBufferCell } from '@xterm/xterm';
|
import { Terminal as XtermTerminal, IBufferLine, IBufferCell } from '@xterm/xterm';
|
||||||
|
import { UrlHighlighter } from '../utils/url-highlighter.js';
|
||||||
|
|
||||||
@customElement('vibe-terminal')
|
@customElement('vibe-terminal')
|
||||||
export class Terminal extends LitElement {
|
export class Terminal extends LitElement {
|
||||||
|
|
@ -494,7 +495,7 @@ export class Terminal extends LitElement {
|
||||||
const linkProcessStart = performance.now();
|
const linkProcessStart = performance.now();
|
||||||
|
|
||||||
// Process links after rendering
|
// Process links after rendering
|
||||||
this.processLinks();
|
UrlHighlighter.processLinks(this.container);
|
||||||
|
|
||||||
const linkProcessEnd = performance.now();
|
const linkProcessEnd = performance.now();
|
||||||
const renderEnd = performance.now();
|
const renderEnd = performance.now();
|
||||||
|
|
@ -747,220 +748,6 @@ export class Terminal extends LitElement {
|
||||||
return Math.max(0, buffer.length - this.actualRows);
|
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() {
|
render() {
|
||||||
return html`
|
return html`
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
224
web/src/client/utils/url-highlighter.ts
Normal file
224
web/src/client/utils/url-highlighter.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue