WIP add sourcegraph
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function createHoverContent(
|
||||
contents: string,
|
||||
config: SourcegraphConfig,
|
||||
path: string,
|
||||
line: number,
|
||||
char: number,
|
||||
): HTMLElement {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sourcegraph-hover';
|
||||
|
||||
// Render markdown content as pre-formatted text (simple approach)
|
||||
// In production, you might want to use a proper markdown renderer
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.className = 'sg-content';
|
||||
contentDiv.innerHTML = `<pre>${escapeHtml(contents)}</pre>`;
|
||||
el.appendChild(contentDiv);
|
||||
|
||||
// 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';
|
||||
goToDefBtn.addEventListener('click', async () => {
|
||||
hideActiveTippy();
|
||||
const locations = await fetchDefinition(config, path, line, char);
|
||||
if (locations.length === 1) {
|
||||
navigateToLocation(locations[0]);
|
||||
} else if (locations.length > 1) {
|
||||
showLocationsPanel('Definitions', locations);
|
||||
}
|
||||
});
|
||||
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 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] : '';
|
||||
|
||||
let url: string;
|
||||
if (loc.repo === currentRepo || !loc.repo) {
|
||||
// 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 = `/${loc.repo}/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 = 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 refs panel when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (refsPanel && !refsPanel.contains(e.target as Node)) {
|
||||
hideRefsPanel();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {initStopwatch} from './features/stopwatch.ts';
|
||||
import {initRepoFileSearch} from './features/repo-findfile.ts';
|
||||
import {initMarkupContent} from './markup/content.ts';
|
||||
import {initRepoFileView} from './features/file-view.ts';
|
||||
import {initSourcegraph} from './features/sourcegraph.ts';
|
||||
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
|
||||
import {initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarDependency, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
|
||||
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
|
||||
@@ -158,6 +159,7 @@ const initPerformanceTracer = callInitFunctions([
|
||||
initOAuth2SettingsDisableCheckbox,
|
||||
|
||||
initRepoFileView,
|
||||
initSourcegraph,
|
||||
]);
|
||||
|
||||
// it must be the last one, then the "querySelectorAll" only needs to be executed once for global init functions.
|
||||
|
||||
Reference in New Issue
Block a user