import {createTippy} from '../modules/tippy.ts'; import {GET} from '../modules/fetch.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts'; import type {Instance as TippyInstance} from 'tippy.js'; interface SourcegraphConfig { enabled: boolean; repoFullName: string; commitID: string; } interface HoverResult { contents?: string; range?: { start: {line: number; character: number}; end: {line: number; character: number}; }; } interface Location { repo: string; path: string; line: number; character: number; } let activeTippy: TippyInstance | null = null; let hoverTimeout: number | null = null; let refsPanel: HTMLElement | null = null; async function fetchHover(config: SourcegraphConfig, path: string, line: number, char: number): Promise { const params = new URLSearchParams({ path, line: String(line), character: String(char), ref: config.commitID, }); try { const resp = await GET(`/api/v1/repos/${config.repoFullName}/sourcegraph/hover?${params}`); if (!resp.ok) return null; const data = await resp.json(); return data.contents ? data : null; } catch { return null; } } async function fetchDefinition(config: SourcegraphConfig, path: string, line: number, char: number): Promise { const params = new URLSearchParams({ path, line: String(line), character: String(char), ref: config.commitID, }); try { const resp = await GET(`/api/v1/repos/${config.repoFullName}/sourcegraph/definition?${params}`); if (!resp.ok) return []; return await resp.json(); } catch { return []; } } async function fetchReferences(config: SourcegraphConfig, path: string, line: number, char: number): Promise { const params = new URLSearchParams({ path, line: String(line), character: String(char), ref: config.commitID, }); try { const resp = await GET(`/api/v1/repos/${config.repoFullName}/sourcegraph/references?${params}`); if (!resp.ok) return []; return await resp.json(); } catch { return []; } } function hideActiveTippy(): void { if (activeTippy) { activeTippy.destroy(); activeTippy = null; } } function hideRefsPanel(): void { if (refsPanel) { refsPanel.remove(); refsPanel = null; } } async function createHoverContent( contents: string, config: SourcegraphConfig, path: string, line: number, char: number, ): Promise { const el = document.createElement('div'); el.className = 'sourcegraph-hover'; // Content is pre-rendered as HTML by the backend const contentDiv = document.createElement('div'); contentDiv.className = 'sg-content markup'; contentDiv.innerHTML = contents; el.appendChild(contentDiv); // Pre-fetch definitions to know if button should be enabled const definitions = await fetchDefinition(config, path, line, char); // Action buttons const actionsDiv = document.createElement('div'); actionsDiv.className = 'sg-actions'; const goToDefBtn = document.createElement('button'); goToDefBtn.className = 'ui mini basic button'; goToDefBtn.textContent = 'Go to definition'; if (definitions.length === 0) { goToDefBtn.disabled = true; goToDefBtn.classList.add('disabled'); } else { goToDefBtn.addEventListener('click', () => { hideActiveTippy(); if (definitions.length === 1) { navigateToLocation(definitions[0]); } else { showLocationsPanel('Definitions', definitions); } }); } actionsDiv.appendChild(goToDefBtn); const findRefsBtn = document.createElement('button'); findRefsBtn.className = 'ui mini basic button'; findRefsBtn.textContent = 'Find references'; findRefsBtn.addEventListener('click', async () => { hideActiveTippy(); const locations = await fetchReferences(config, path, line, char); if (locations.length > 0) { showLocationsPanel('References', locations); } }); actionsDiv.appendChild(findRefsBtn); el.appendChild(actionsDiv); return el; } function escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function normalizeRepoName(repo: string): string { // Sourcegraph returns repo names like "git.example.com/owner/repo" // We need just "owner/repo" for Gitea URLs const parts = repo.split('/'); if (parts.length >= 3) { // Has domain prefix - return last two parts (owner/repo) return parts.slice(-2).join('/'); } return repo; } function navigateToLocation(loc: Location): void { // Build URL to the location // If it's in the same repo, navigate to file // If it's in another repo, we need to construct the full URL const currentPath = window.location.pathname; const repoMatch = currentPath.match(/^\/([^/]+\/[^/]+)/); const currentRepo = repoMatch ? repoMatch[1] : ''; // Normalize repo name (strip domain prefix if present) const targetRepo = normalizeRepoName(loc.repo || ''); let url: string; if (targetRepo === currentRepo || !targetRepo) { // Same repo - construct relative URL const basePath = currentPath.replace(/\/src\/.*$/, ''); url = `${basePath}/src/branch/main/${loc.path}#L${loc.line + 1}`; } else { // Different repo url = `/${targetRepo}/src/branch/main/${loc.path}#L${loc.line + 1}`; } window.location.href = url; } function showLocationsPanel(title: string, locations: Location[]): void { hideRefsPanel(); const panel = document.createElement('div'); panel.className = 'sg-refs-panel'; const header = document.createElement('div'); header.className = 'sg-refs-header'; header.innerHTML = ` ${escapeHtml(title)} (${locations.length}) `; panel.appendChild(header); header.querySelector('.sg-refs-close')?.addEventListener('click', hideRefsPanel); const list = document.createElement('div'); list.className = 'sg-refs-list'; for (const loc of locations) { const item = document.createElement('a'); item.className = 'sg-refs-item'; item.href = '#'; item.innerHTML = ` ${escapeHtml(loc.path)} :${loc.line + 1} `; item.addEventListener('click', (e) => { e.preventDefault(); navigateToLocation(loc); }); list.appendChild(item); } panel.appendChild(list); document.body.appendChild(panel); refsPanel = panel; } function getTokenPosition(container: HTMLElement, token: Element, path: string): {path: string; line: number; char: number} | null { const row = token.closest('tr'); if (!row) return null; // Support both data-line-number (file view, blame) and data-line-num (diff view) let lineEl = row.querySelector('[data-line-number]') as HTMLElement | null; let line: number; if (lineEl) { line = parseInt(lineEl.dataset.lineNumber || '0', 10); } else { // For diff views, prefer the "new" line number (right side) for code intelligence lineEl = row.querySelector('.lines-num-new[data-line-num]') as HTMLElement | null; if (!lineEl) { lineEl = row.querySelector('[data-line-num]') as HTMLElement | null; } if (!lineEl) return null; line = parseInt(lineEl.dataset.lineNum || '0', 10); } if (isNaN(line) || line <= 0) return null; // Calculate character offset within the line const codeCell = row.querySelector('.lines-code .code-inner'); if (!codeCell) return null; let char = 0; const walker = document.createTreeWalker(codeCell, NodeFilter.SHOW_TEXT); let node: Node | null; while ((node = walker.nextNode())) { if (token.contains(node)) { // Found the text node containing or preceding our token break; } char += node.textContent?.length || 0; } // Sourcegraph uses 0-indexed lines return {path, line: line - 1, char}; } function isHoverableToken(el: Element): boolean { // Check if it's a span inside code-inner (syntax highlighted token) if (el.tagName !== 'SPAN') return false; const codeInner = el.closest('.code-inner'); return codeInner !== null; } export function initSourcegraph(): void { registerGlobalInitFunc('initSourcegraph', (container: HTMLElement) => { const configStr = container.dataset.sourcegraph; if (!configStr) return; let config: SourcegraphConfig; try { config = JSON.parse(configStr); } catch { return; } if (!config.enabled) return; const path = container.dataset.sgPath || ''; if (!path) return; // Set up hover listener with debouncing container.addEventListener('mouseover', (e) => { const target = e.target as HTMLElement; if (!isHoverableToken(target)) return; // Clear any existing timeout if (hoverTimeout) { window.clearTimeout(hoverTimeout); } hoverTimeout = window.setTimeout(async () => { const pos = getTokenPosition(container, target, path); if (!pos) return; const hover = await fetchHover(config, pos.path, pos.line, pos.char); if (!hover?.contents) return; // Hide any existing tippy hideActiveTippy(); // Create and show new tippy const content = await createHoverContent(hover.contents, config, pos.path, pos.line, pos.char); activeTippy = createTippy(target, { content, theme: 'default', interactive: true, trigger: 'manual', placement: 'top', maxWidth: 600, onHide: () => { activeTippy = null; }, }); activeTippy.show(); }, 300); }); container.addEventListener('mouseout', (e) => { const target = e.target as HTMLElement; if (!isHoverableToken(target)) return; if (hoverTimeout) { window.clearTimeout(hoverTimeout); hoverTimeout = null; } }); // Close tippy and refs panel when clicking outside document.addEventListener('click', (e) => { const target = e.target as Node; // Close refs panel if clicking outside it if (refsPanel && !refsPanel.contains(target)) { hideRefsPanel(); } // Close tippy if clicking outside it if (activeTippy) { const tippyBox = document.querySelector('.tippy-box'); if (tippyBox && !tippyBox.contains(target)) { hideActiveTippy(); } } }); }); }