diff --git a/README.md b/README.md index 79ed17d..48b3e92 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,44 @@ ws.onmessage = (event) => { }; ``` +### URL Detection + +Ghostty-web automatically detects and makes clickable: + +- **OSC 8 hyperlinks** - Explicit terminal escape sequences (e.g., from `ls --hyperlink`) +- **Plain text URLs** - Common protocols detected via regex (https, http, mailto, ssh, git, ftp, tel, magnet) + +URLs are detected on hover and can be opened with Ctrl/Cmd+Click. + +```typescript +// URL detection works automatically after opening terminal +await term.open(container); + +// URLs in output become clickable automatically +term.write('Visit https://github.com for code\r\n'); +term.write('Contact mailto:support@example.com\r\n'); +``` + +**Custom Link Providers** + +Register custom providers to detect additional link types: + +```typescript +import { UrlRegexProvider } from '@coder/ghostty-web'; + +// Create custom provider +const myProvider = { + provideLinks(y, callback) { + // Your detection logic here + const links = detectCustomLinks(y); + callback(links); + }, +}; + +// Register after opening terminal +term.registerLinkProvider(myProvider); +``` + See [AGENTS.md](AGENTS.md) for development guide and code patterns. ## Why This Approach? diff --git a/bun.lock b/bun.lock index c817547..a4023c7 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@types/bun": "^1.3.2", + "happy-dom": "^20.0.10", "prettier": "^3.6.2", "typescript": "^5.9.3", "vite": "^4.5.0", @@ -116,10 +117,12 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - "@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + "@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + "@volar/language-core": ["@volar/language-core@2.4.23", "", { "dependencies": { "@volar/source-map": "2.4.23" } }, "sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ=="], "@volar/source-map": ["@volar/source-map@2.4.23", "", {}, "sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q=="], @@ -184,6 +187,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "happy-dom": ["happy-dom@20.0.10", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-6umCCHcjQrhP5oXhrHQQvLB0bwb1UzHAHdsXy+FjtKoYjUhmNZsQL8NivwM1vDvNEChJabVrUYxUnp/ZdYmy2g=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -266,7 +271,7 @@ "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], @@ -278,6 +283,8 @@ "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "@microsoft/api-extractor/typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], @@ -288,8 +295,12 @@ "ajv-formats/ajv": ["ajv@8.13.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.4.1" } }, "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA=="], + "bun-types/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], } } diff --git a/demo/index.html b/demo/index.html index 3d6a7bf..66f1df3 100644 --- a/demo/index.html +++ b/demo/index.html @@ -337,6 +337,18 @@

🔍 Buffer Access API (NEW!)

+
+

🔗 URL Detection (NEW!)

+

+ Plain text URLs are automatically detected and made clickable. Hover to see cursor change, + Ctrl/Cmd+Click to open. +

+
+ + +
+
+
⚠️ Warning: Full Filesystem Access This demo has unrestricted access to your entire filesystem. It's meant for local @@ -903,6 +915,66 @@

🔍 Buffer Access API (NEW!)

logBufferEvent(` Row ${i}: ${wrapped ? '↪ wrapped' : '↓ new line'}`, true); } }); + + // ======================================================================= + // URL Detection Test Buttons + // ======================================================================= + + document.getElementById('btn-testUrls').addEventListener('click', () => { + term.write('\r\n'); + term.write( + '═══════════════════════════════════════════════════════════════════════════════\r\n' + ); + term.write('🔗 URL Detection Test\r\n'); + term.write( + '═══════════════════════════════════════════════════════════════════════════════\r\n' + ); + term.write('\r\n'); + term.write( + 'Hover over URLs to see cursor change, Ctrl/Cmd+Click to open in new tab:\r\n' + ); + term.write('\r\n'); + term.write( + '1. HTTPS: Visit https://github.com/coder/ghostty-web for the source code\r\n' + ); + term.write('2. HTTP: Check out http://example.com for more information\r\n'); + term.write('3. Email: Contact mailto:support@example.com for help\r\n'); + term.write('4. SSH: Connect via ssh://user@server.com:22/path\r\n'); + term.write('5. Git: Clone git://github.com/user/repo.git to get started\r\n'); + term.write('6. FTP: Download from ftp://files.example.com/archive.zip\r\n'); + term.write('7. Tel: Call tel:+1-555-123-4567 for support\r\n'); + term.write('8. Multiple URLs: https://a.com and https://b.com on same line\r\n'); + term.write('\r\n'); + term.write('Try clicking the URLs above!\r\n'); + term.write('\r\n'); + }); + + document.getElementById('btn-testUrlEdgeCases').addEventListener('click', () => { + term.write('\r\n'); + term.write( + '═══════════════════════════════════════════════════════════════════════════════\r\n' + ); + term.write('⚠️ URL Edge Cases\r\n'); + term.write( + '═══════════════════════════════════════════════════════════════════════════════\r\n' + ); + term.write('\r\n'); + term.write('URLs should be detected correctly in these cases:\r\n'); + term.write('\r\n'); + term.write('1. With period: Check https://example.com. More text here.\r\n'); + term.write('2. With comma: See https://example.com, then continue.\r\n'); + term.write('3. With parentheses: (visit https://example.com) for details\r\n'); + term.write('4. With exclamation: Visit https://example.com! Right now!\r\n'); + term.write('5. With query: https://example.com?foo=bar&baz=qux\r\n'); + term.write('6. With fragment: https://example.com/page#section-one\r\n'); + term.write('7. With port: https://example.com:8080/api/endpoint\r\n'); + term.write('\r\n'); + term.write('These should NOT be detected (not URLs):\r\n'); + term.write('8. File path: /home/user/file.txt\r\n'); + term.write('9. Relative path: ./relative/path/to/file\r\n'); + term.write('\r\n'); + }); + // Expose terminal to console for debugging window.term = term; } diff --git a/lib/index.ts b/lib/index.ts index d47ab1c..2931b45 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -43,3 +43,9 @@ export type { SelectionCoordinates } from './selection-manager'; // Addons export { FitAddon } from './addons/fit'; export type { ITerminalDimensions } from './addons/fit'; + +// Link providers +export { OSC8LinkProvider } from './providers/osc8-link-provider'; +export { UrlRegexProvider } from './providers/url-regex-provider'; +export { LinkDetector } from './link-detector'; +export type { ILink, ILinkProvider, IBufferCellPosition } from './types'; diff --git a/lib/providers/url-regex-provider.ts b/lib/providers/url-regex-provider.ts new file mode 100644 index 0000000..82dad11 --- /dev/null +++ b/lib/providers/url-regex-provider.ts @@ -0,0 +1,150 @@ +/** + * URL Regex Link Provider + * + * Detects plain text URLs using regex pattern matching. + * Supports common protocols but excludes file paths. + * + * This provider runs after OSC8LinkProvider, so explicit hyperlinks + * take precedence over regex-detected URLs. + */ + +import type { IBufferRange, ILink, ILinkProvider } from '../types'; + +/** + * URL Regex Provider + * + * Detects plain text URLs on a single line using regex. + * Does not support multi-line URLs or file paths. + * + * Supported protocols: + * - https://, http:// + * - mailto: + * - ftp://, ssh://, git:// + * - tel:, magnet: + * - gemini://, gopher://, news: + */ +export class UrlRegexProvider implements ILinkProvider { + /** + * URL regex pattern + * Matches common protocols followed by valid URL characters + * Excludes file paths (no ./ or ../ or bare /) + */ + private static readonly URL_REGEX = + /(?:https?:\/\/|mailto:|ftp:\/\/|ssh:\/\/|git:\/\/|tel:|magnet:|gemini:\/\/|gopher:\/\/|news:)[\w\-.~:\/?#@!$&*+,;=%]+/gi; + + /** + * Characters to strip from end of URLs + * Common punctuation that's unlikely to be part of the URL + */ + private static readonly TRAILING_PUNCTUATION = /[.,;!?)\]]+$/; + + constructor(private terminal: ITerminalForUrlProvider) {} + + /** + * Provide all regex-detected URLs on the given row + */ + provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void { + const links: ILink[] = []; + + const line = this.terminal.buffer.active.getLine(y); + if (!line) { + callback(undefined); + return; + } + + // Convert line cells to text + const lineText = this.lineToText(line); + + // Reset regex state (global flag maintains state) + UrlRegexProvider.URL_REGEX.lastIndex = 0; + + // Find all URL matches in the line + let match: RegExpExecArray | null = UrlRegexProvider.URL_REGEX.exec(lineText); + while (match !== null) { + let url = match[0]; + const startX = match.index; + let endX = match.index + url.length - 1; // Inclusive end + + // Strip trailing punctuation + const stripped = url.replace(UrlRegexProvider.TRAILING_PUNCTUATION, ''); + if (stripped.length < url.length) { + url = stripped; + endX = startX + url.length - 1; + } + + // Skip if URL is too short (e.g., just "http://") + if (url.length > 8) { + links.push({ + text: url, + range: { + start: { x: startX, y }, + end: { x: endX, y }, + }, + activate: (event) => { + // Open link if Ctrl/Cmd is pressed + if (event.ctrlKey || event.metaKey) { + window.open(url, '_blank', 'noopener,noreferrer'); + } + }, + }); + } + + // Get next match + match = UrlRegexProvider.URL_REGEX.exec(lineText); + } + + callback(links.length > 0 ? links : undefined); + } + + /** + * Convert a buffer line to plain text string + */ + private lineToText(line: IBufferLineForUrlProvider): string { + const chars: string[] = []; + + for (let x = 0; x < line.length; x++) { + const cell = line.getCell(x); + if (!cell) { + chars.push(' '); + continue; + } + + const codepoint = cell.getCodepoint(); + // Skip null characters and control characters + if (codepoint === 0 || codepoint < 32) { + chars.push(' '); + } else { + chars.push(String.fromCodePoint(codepoint)); + } + } + + return chars.join(''); + } + + dispose(): void { + // No resources to clean up + } +} + +/** + * Minimal terminal interface required by UrlRegexProvider + */ +export interface ITerminalForUrlProvider { + buffer: { + active: { + getLine(y: number): IBufferLineForUrlProvider | undefined; + }; + }; +} + +/** + * Minimal buffer line interface for URL detection + */ +interface IBufferLineForUrlProvider { + length: number; + getCell(x: number): + | { + getCodepoint(): number; + } + | undefined; +} diff --git a/lib/renderer.ts b/lib/renderer.ts index 310c3e0..1a2cf0a 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -108,6 +108,16 @@ export class CanvasRenderer { private hoveredHyperlinkId: number = 0; private previousHoveredHyperlinkId: number = 0; + // Regex link hover tracking (for links without hyperlink_id) + private hoveredLinkRange: { startX: number; startY: number; endX: number; endY: number } | null = + null; + private previousHoveredLinkRange: { + startX: number; + startY: number; + endX: number; + endY: number; + } | null = null; + constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) { this.canvas = canvas; const ctx = canvas.getContext('2d', { alpha: false }); @@ -313,6 +323,8 @@ export class CanvasRenderer { // Track rows with hyperlinks that need redraw when hover changes const hyperlinkRows = new Set(); const hyperlinkChanged = this.hoveredHyperlinkId !== this.previousHoveredHyperlinkId; + const linkRangeChanged = + JSON.stringify(this.hoveredLinkRange) !== JSON.stringify(this.previousHoveredLinkRange); if (hyperlinkChanged) { // Find rows containing the old or new hovered hyperlink @@ -352,6 +364,27 @@ export class CanvasRenderer { this.previousHoveredHyperlinkId = this.hoveredHyperlinkId; } + // Track rows affected by link range changes (for regex URLs) + if (linkRangeChanged) { + // Add rows from old range + if (this.previousHoveredLinkRange) { + for ( + let y = this.previousHoveredLinkRange.startY; + y <= this.previousHoveredLinkRange.endY; + y++ + ) { + hyperlinkRows.add(y); + } + } + // Add rows from new range + if (this.hoveredLinkRange) { + for (let y = this.hoveredLinkRange.startY; y <= this.hoveredLinkRange.endY; y++) { + hyperlinkRows.add(y); + } + } + this.previousHoveredLinkRange = this.hoveredLinkRange; + } + // Track if anything was actually rendered let anyLinesRendered = false; @@ -524,7 +557,7 @@ export class CanvasRenderer { this.ctx.stroke(); } - // Draw hyperlink underline + // Draw hyperlink underline (for OSC8 hyperlinks) if (cell.hyperlink_id > 0) { const isHovered = cell.hyperlink_id === this.hoveredHyperlinkId; @@ -539,6 +572,26 @@ export class CanvasRenderer { this.ctx.stroke(); } } + + // Draw regex link underline (for plain text URLs) + if (this.hoveredLinkRange) { + const range = this.hoveredLinkRange; + // Check if this cell is within the hovered link range + const isInRange = + (y === range.startY && x >= range.startX && (y < range.endY || x <= range.endX)) || + (y > range.startY && y < range.endY) || + (y === range.endY && x <= range.endX && (y > range.startY || x >= range.startX)); + + if (isInRange) { + const underlineY = cellY + this.metrics.baseline + 2; + this.ctx.strokeStyle = '#4A90E2'; // Blue underline on hover + this.ctx.lineWidth = 1; + this.ctx.beginPath(); + this.ctx.moveTo(cellX, underlineY); + this.ctx.lineTo(cellX + cellWidth, underlineY); + this.ctx.stroke(); + } + } } /** @@ -733,6 +786,21 @@ export class CanvasRenderer { this.hoveredHyperlinkId = hyperlinkId; } + /** + * Set the currently hovered link range for rendering underlines (for regex-detected URLs) + * Pass null to clear the hover state + */ + public setHoveredLinkRange( + range: { + startX: number; + startY: number; + endX: number; + endY: number; + } | null + ): void { + this.hoveredLinkRange = range; + } + /** * Get character cell width (for coordinate conversion) */ diff --git a/lib/terminal.ts b/lib/terminal.ts index cf883b7..110f7d5 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -28,6 +28,7 @@ import type { } from './interfaces'; import { LinkDetector } from './link-detector'; import { OSC8LinkProvider } from './providers/osc8-link-provider'; +import { UrlRegexProvider } from './providers/url-regex-provider'; import { CanvasRenderer } from './renderer'; import { SelectionManager } from './selection-manager'; import type { ILink, ILinkProvider } from './types'; @@ -266,8 +267,11 @@ export class Terminal implements ITerminalCore { // Initialize link detection system this.linkDetector = new LinkDetector(this); - // Register OSC 8 hyperlink provider + // Register link providers + // OSC8 first (explicit hyperlinks take precedence) this.linkDetector.registerProvider(new OSC8LinkProvider(this)); + // URL regex second (fallback for plain text URLs) + this.linkDetector.registerProvider(new UrlRegexProvider(this)); // Setup mouse event handling for links and scrollbar // Use capture phase to intercept scrollbar clicks before SelectionManager @@ -987,6 +991,32 @@ export class Terminal implements ITerminalCore { if (this.element) { this.element.style.cursor = link ? 'pointer' : 'text'; } + + // Update renderer for underline (for regex URLs without hyperlink_id) + if (this.renderer) { + if (link) { + // Convert buffer coordinates to viewport coordinates + const scrollbackLength = this.wasmTerm?.getScrollbackLength() || 0; + + // Calculate viewport Y for start and end positions + const startViewportY = link.range.start.y - scrollbackLength + this.viewportY; + const endViewportY = link.range.end.y - scrollbackLength + this.viewportY; + + // Only show underline if link is visible in viewport + if (startViewportY < this.rows && endViewportY >= 0) { + this.renderer.setHoveredLinkRange({ + startX: link.range.start.x, + startY: Math.max(0, startViewportY), + endX: link.range.end.x, + endY: Math.min(this.rows - 1, endViewportY), + }); + } else { + this.renderer.setHoveredLinkRange(null); + } + } else { + this.renderer.setHoveredLinkRange(null); + } + } } }) .catch((err) => { @@ -1006,6 +1036,8 @@ export class Terminal implements ITerminalCore { // The 60fps render loop will pick up the change automatically } + // Clear regex link underline + this.renderer.setHoveredLinkRange(null); } if (this.currentHoveredLink) { diff --git a/lib/url-detection.test.ts b/lib/url-detection.test.ts new file mode 100644 index 0000000..f31dfc2 --- /dev/null +++ b/lib/url-detection.test.ts @@ -0,0 +1,187 @@ +/** + * URL Detection Tests + * + * Tests for the UrlRegexProvider to ensure plain text URLs + * are correctly detected and made clickable. + */ + +import { describe, expect, test } from 'bun:test'; +import { UrlRegexProvider } from './providers/url-regex-provider'; +import type { ILink } from './types'; + +/** + * Mock terminal for testing + */ +function createMockTerminal(lineText: string) { + const cells = Array.from(lineText).map((char) => ({ + getCodepoint: () => char.codePointAt(0) || 0, + })); + + return { + buffer: { + active: { + getLine: (y: number) => { + if (y !== 0) return undefined; + return { + length: cells.length, + getCell: (x: number) => cells[x], + }; + }, + }, + }, + }; +} + +/** + * Helper to get links from provider + */ +function getLinks(lineText: string): Promise { + const terminal = createMockTerminal(lineText) as any; + const provider = new UrlRegexProvider(terminal); + + return new Promise((resolve) => { + provider.provideLinks(0, resolve); + }); +} + +describe('URL Detection', () => { + test('detects HTTPS URLs', async () => { + const links = await getLinks('Visit https://github.com for code'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('https://github.com'); + expect(links?.[0].range.start.x).toBe(6); + // End is inclusive - last character is at index 23 (https://github.com is 19 chars, starts at 6) + expect(links?.[0].range.end.x).toBe(23); + }); + + test('detects HTTP URLs', async () => { + const links = await getLinks('Check http://example.com'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('http://example.com'); + }); + + test('detects mailto: links', async () => { + const links = await getLinks('Email: mailto:test@example.com'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('mailto:test@example.com'); + }); + + test('detects ssh:// URLs', async () => { + const links = await getLinks('Connect via ssh://user@server.com'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('ssh://user@server.com'); + }); + + test('detects git:// URLs', async () => { + const links = await getLinks('Clone git://github.com/repo.git'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('git://github.com/repo.git'); + }); + + test('detects ftp:// URLs', async () => { + const links = await getLinks('Download ftp://files.example.com/file'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('ftp://files.example.com/file'); + }); + + test('strips trailing period', async () => { + const links = await getLinks('Check https://example.com.'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('https://example.com'); + // Should NOT include the trailing period + expect(links?.[0].text.endsWith('.')).toBe(false); + }); + + test('strips trailing comma', async () => { + const links = await getLinks('See https://example.com, or else'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('https://example.com'); + }); + + test('strips trailing parenthesis', async () => { + const links = await getLinks('(see https://example.com)'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('https://example.com'); + }); + + test('strips trailing exclamation', async () => { + const links = await getLinks('Visit https://example.com!'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('https://example.com'); + }); + + test('handles multiple URLs on same line', async () => { + const links = await getLinks('https://a.com and https://b.com'); + expect(links).toBeDefined(); + expect(links?.length).toBe(2); + expect(links?.[0].text).toBe('https://a.com'); + expect(links?.[1].text).toBe('https://b.com'); + }); + + test('returns undefined when no URL present', async () => { + const links = await getLinks('No URLs here'); + expect(links).toBeUndefined(); + }); + + test('handles URLs with query parameters', async () => { + const links = await getLinks('https://example.com?foo=bar&baz=qux'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('https://example.com?foo=bar&baz=qux'); + }); + + test('handles URLs with fragments', async () => { + const links = await getLinks('https://example.com/page#section'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('https://example.com/page#section'); + }); + + test('handles URLs with ports', async () => { + const links = await getLinks('https://example.com:8080/path'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('https://example.com:8080/path'); + }); + + test('does not detect file paths', async () => { + const links = await getLinks('/home/user/file.txt'); + expect(links).toBeUndefined(); + }); + + test('does not detect relative paths', async () => { + const links = await getLinks('./relative/path'); + expect(links).toBeUndefined(); + }); + + test('link has activate function', async () => { + const links = await getLinks('https://example.com'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(typeof links?.[0].activate).toBe('function'); + }); + + test('detects tel: URLs', async () => { + const links = await getLinks('Call tel:+1234567890'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toBe('tel:+1234567890'); + }); + + test('detects magnet: URLs', async () => { + const links = await getLinks('Download magnet:?xt=urn:btih:abc123'); + expect(links).toBeDefined(); + expect(links?.length).toBe(1); + expect(links?.[0].text).toContain('magnet:?xt=urn:btih:abc123'); + }); +}); diff --git a/package.json b/package.json index f880d41..17f4e07 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@types/bun": "^1.3.2", + "happy-dom": "^20.0.10", "prettier": "^3.6.2", "typescript": "^5.9.3", "vite": "^4.5.0",