Files
gitea/web_src/js/features/sourcegraph.ts
2026-02-03 20:51:07 -10:00

366 lines
10 KiB
TypeScript

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<HoverResult | null> {
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<Location[]> {
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<Location[]> {
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<HTMLElement> {
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 = `
<span class="sg-refs-title">${escapeHtml(title)} (${locations.length})</span>
<button class="sg-refs-close">&times;</button>
`;
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 = `
<span class="sg-refs-path">${escapeHtml(loc.path)}</span>
<span class="sg-refs-line">:${loc.line + 1}</span>
`;
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();
}
}
});
});
}