366 lines
10 KiB
TypeScript
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">×</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();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|