@ -112,8 +112,14 @@
import {
import {
loadPatches as loadPatchesService,
loadPatches as loadPatchesService,
createPatch as createPatchService,
createPatch as createPatchService,
updatePatchStatus as updatePatchStatusService
updatePatchStatus as updatePatchStatusService,
loadPatchHighlights as loadPatchHighlightsService,
createPatchHighlight as createPatchHighlightService,
createPatchComment as createPatchCommentService
} from './services/patch-operations.js';
} from './services/patch-operations.js';
import {
performCodeSearch as performCodeSearchService
} from './services/code-search-operations.js';
import {
import {
loadDiscussions as loadDiscussionsService,
loadDiscussions as loadDiscussionsService,
createDiscussionThread as createDiscussionThreadService,
createDiscussionThread as createDiscussionThreadService,
@ -127,8 +133,15 @@
toggleBookmark as toggleBookmarkService,
toggleBookmark as toggleBookmarkService,
checkMaintainerStatus as checkMaintainerStatusService,
checkMaintainerStatus as checkMaintainerStatusService,
loadAllMaintainers as loadAllMaintainersService,
loadAllMaintainers as loadAllMaintainersService,
checkVerification as checkVerificationService
checkVerification as checkVerificationService,
loadBookmarkStatus as loadBookmarkStatusService,
loadCloneUrlReachability as loadCloneUrlReachabilityService,
loadForkInfo as loadForkInfoService,
loadRepoImages as loadRepoImagesService
} from './services/repo-operations.js';
} from './services/repo-operations.js';
import {
loadReadme as loadReadmeService
} from './services/file-operations.js';
// Consolidated state - all state variables in one object
// Consolidated state - all state variables in one object
let state = $state(createRepoState());
let state = $state(createRepoState());
@ -772,136 +785,11 @@
// Load clone URL reachability status
// Load clone URL reachability status
async function loadCloneUrlReachability(forceRefresh: boolean = false) {
async function loadCloneUrlReachability(forceRefresh: boolean = false) {
if (!repoCloneUrls || repoCloneUrls.length === 0) {
await loadCloneUrlReachabilityService(forceRefresh, state, repoCloneUrls);
return;
}
if (state.loading.reachability) return;
state.loading.reachability = true;
try {
const response = await fetch(
`/api/repos/${ state . npub } /${ state . repo } /clone-urls/reachability${ forceRefresh ? '?forceRefresh=true' : '' } `,
{
headers: buildApiHeaders()
}
);
if (response.ok) {
const data = await response.json();
const newMap = new Map< string , { reachable : boolean ; error? : string ; checkedAt : number ; serverType : 'git' | 'grasp' | 'unknown' } > ();
if (data.results && Array.isArray(data.results)) {
for (const result of data.results) {
newMap.set(result.url, {
reachable: result.reachable,
error: result.error,
checkedAt: result.checkedAt,
serverType: result.serverType || 'unknown'
});
}
}
state.clone.reachability = newMap;
}
} catch (err) {
console.warn('Failed to load clone URL reachability:', err);
} finally {
state.loading.reachability = false;
state.clone.checkingReachability.clear();
}
}
}
async function loadReadme() {
async function loadReadme() {
if (state.loading.repoNotFound) return;
await loadReadmeService(state, rewriteImagePaths);
state.loading.readme = true;
try {
const response = await fetch(`/api/repos/${ state . npub } /${ state . repo } /readme?ref=${ state . git . currentBranch } `, {
headers: buildApiHeaders()
});
if (response.ok) {
const data = await response.json();
if (data.found) {
state.preview.readme.content = data.content;
state.preview.readme.path = data.path;
state.preview.readme.isMarkdown = data.isMarkdown;
// Reset preview mode for README
state.preview.file.showPreview = true;
state.preview.readme.html = '';
// Render markdown or asciidoc if needed
if (state.preview.readme.content) {
const ext = state.preview.readme.path?.split('.').pop()?.toLowerCase() || '';
if (state.preview.readme.isMarkdown || ext === 'md' || ext === 'markdown') {
try {
const MarkdownIt = (await import('markdown-it')).default;
const hljsModule = await import('highlight.js');
const hljs = hljsModule.default || hljsModule;
const md = new MarkdownIt({
html: true, // Enable HTML state.git.tags in source
linkify: true, // Autoconvert URL-like text to links
typographer: true, // Enable some language-neutral replacement + quotes beautification
breaks: true, // Convert '\n' in paragraphs into < br >
highlight: function (str: string, lang: string): string {
if (lang && hljs.getLanguage(lang)) {
try {
return '< pre class = "hljs" > < code > ' +
hljs.highlight(str, { language : lang } ).value +
'< / code > < / pre > ';
} catch (err) {
// Fallback to escaped HTML if highlighting fails
// This is expected for unsupported languages
}
}
return '< pre class = "hljs" > < code > ' + md.utils.escapeHtml(str) + '< / code > < / pre > ';
}
});
let rendered = md.render(state.preview.readme.content);
// Rewrite image paths to point to repository API
rendered = rewriteImagePaths(rendered, state.preview.readme.path);
state.preview.readme.html = rendered;
console.log('[README] Markdown rendered successfully, HTML length:', state.preview.readme.html.length);
} catch (err) {
console.error('[README] Error rendering markdown:', err);
state.preview.readme.html = '';
}
} else if (ext === 'adoc' || ext === 'asciidoc') {
try {
const Asciidoctor = (await import('@asciidoctor/core')).default;
const asciidoctor = Asciidoctor();
const converted = asciidoctor.convert(state.preview.readme.content, {
safe: 'safe',
attributes: {
'source-highlighter': 'highlight.js'
}
});
let rendered = typeof converted === 'string' ? converted : String(converted);
// Rewrite image paths to point to repository API
rendered = rewriteImagePaths(rendered, state.preview.readme.path);
state.preview.readme.html = rendered;
state.preview.readme.isMarkdown = true; // Treat as markdown for display purposes
} catch (err) {
console.error('[README] Error rendering asciidoc:', err);
state.preview.readme.html = '';
}
} else if (ext === 'html' || ext === 'htm') {
// Rewrite image paths to point to repository API
state.preview.readme.html = rewriteImagePaths(state.preview.readme.content || '', state.preview.readme.path);
state.preview.readme.isMarkdown = true; // Treat as markdown for display purposes
} else {
state.preview.readme.html = '';
}
}
}
}
} catch (err) {
console.error('Error state.loading.main README:', err);
} finally {
state.loading.readme = false;
}
}
}
// File processing utilities are now imported from utils/file-processing.ts
// File processing utilities are now imported from utils/file-processing.ts
@ -1011,92 +899,7 @@
}
}
async function loadRepoImages() {
async function loadRepoImages() {
try {
await loadRepoImagesService(state, repoOwnerPubkeyDerived, repoIsPrivate, $page.data);
// Get images from page data (loaded from announcement)
// Use $page.data directly to ensure we get the latest data
// Guard against SSR - $page store can only be accessed in component context
if (typeof window === 'undefined') return;
const data = $page.data as typeof state.pageData;
if (data.image) {
state.metadata.image = data.image;
console.log('[Repo Images] Loaded image from pageData:', state.metadata.image);
}
if (data.banner) {
state.metadata.banner = data.banner;
console.log('[Repo Images] Loaded banner from pageData:', state.metadata.banner);
}
// Also fetch from announcement directly as fallback (only if not private or user has access)
if (!state.metadata.image && !state.metadata.banner) {
// Guard against SSR - $page store can only be accessed in component context
if (typeof window === 'undefined') return;
const data = $page.data as typeof state.pageData;
// Check access for private repos
if (repoIsPrivate) {
const headers: Record< string , string > = {} ;
if (state.user.pubkey) {
try {
const decoded = nip19.decode(state.user.pubkey);
if (decoded.type === 'npub') {
headers['X-User-Pubkey'] = decoded.data as string;
} else {
headers['X-User-Pubkey'] = state.user.pubkey;
}
} catch {
headers['X-User-Pubkey'] = state.user.pubkey;
}
}
const accessResponse = await fetch(`/api/repos/${ state . npub } /${ state . repo } /access`, { headers } );
if (!accessResponse.ok) {
// Access check failed, don't fetch images
return;
}
const accessData = await accessResponse.json();
if (!accessData.canView) {
// User doesn't have access, don't fetch images
return;
}
}
const decoded = nip19.decode(state.npub);
if (decoded.type === 'npub') {
const repoOwnerPubkey = decoded.data as string;
const client = new NostrClient(DEFAULT_NOSTR_RELAYS);
const events = await client.fetchEvents([
{
kinds: [30617], // REPO_ANNOUNCEMENT
authors: [repoOwnerPubkeyDerived],
'#d': [state.repo],
limit: 1
}
]);
if (events.length > 0) {
const announcement = events[0];
const imageTag = announcement.tags.find((t: string[]) => t[0] === 'image');
const bannerTag = announcement.tags.find((t: string[]) => t[0] === 'banner');
if (imageTag?.[1]) {
state.metadata.image = imageTag[1];
console.log('[Repo Images] Loaded image from announcement:', state.metadata.image);
}
if (bannerTag?.[1]) {
state.metadata.banner = bannerTag[1];
console.log('[Repo Images] Loaded banner from announcement:', state.metadata.banner);
}
} else {
console.log('[Repo Images] No announcement found');
}
}
}
if (!state.metadata.image && !state.metadata.banner) {
console.log('[Repo Images] No images found in announcement');
}
} catch (err) {
console.error('Error state.loading.main repo images:', err);
}
}
}
// Reactively update images when pageData changes (only once, when data becomes available)
// Reactively update images when pageData changes (only once, when data becomes available)
@ -1373,13 +1176,7 @@
async function loadBookmarkStatus() {
async function loadBookmarkStatus() {
if (!state.user.pubkey || !state.metadata.address || !bookmarksService) return;
await loadBookmarkStatusService(state, bookmarksService);
try {
state.bookmark.isBookmarked = await bookmarksService.isBookmarked(state.user.pubkey, state.metadata.address);
} catch (err) {
console.warn('Failed to load bookmark status:', err);
}
}
}
async function toggleBookmark() {
async function toggleBookmark() {
@ -2425,50 +2222,7 @@
}
}
async function performCodeSearch() {
async function performCodeSearch() {
if (!state.codeSearch.query.trim() || state.codeSearch.query.length < 2 ) {
await performCodeSearchService(state);
state.codeSearch.results = [];
return;
}
state.loading.codeSearch = true;
state.error = null;
try {
// Get current branch for repo-specific search
const branchParam = state.codeSearch.scope === 'repo' & & state.git.currentBranch
? `& branch=${ encodeURIComponent ( state . git . currentBranch )} `
: '';
// For "All Repositories", don't pass repo filter - let it search all repos
const url = state.codeSearch.scope === 'repo'
? `/api/repos/${ state . npub } /${ state . repo } /code-search?q=${ encodeURIComponent ( state . codeSearch . query . trim ())} ${ branchParam } `
: `/api/code-search?q=${ encodeURIComponent ( state . codeSearch . query . trim ())} `;
const response = await fetch(url, {
headers: buildApiHeaders()
});
if (response.ok) {
const data = await response.json();
state.codeSearch.results = Array.isArray(data) ? data : [];
} else {
let errorMessage = 'Failed to search code';
try {
const errorData = await response.json();
errorMessage = errorData.message || errorMessage;
} catch {
errorMessage = `Search failed: ${ response . status } ${ response . statusText } `;
}
throw new Error(errorMessage);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to search code';
console.error('[Code Search] Error:', err);
state.error = errorMessage;
state.codeSearch.results = [];
} finally {
state.loading.codeSearch = false;
}
}
}
async function loadIssues() {
async function loadIssues() {
@ -2524,29 +2278,7 @@
}
}
async function loadPatchHighlights(patchId: string, patchAuthor: string) {
async function loadPatchHighlights(patchId: string, patchAuthor: string) {
if (!patchId || !patchAuthor) return;
await loadPatchHighlightsService(patchId, patchAuthor, state);
state.loading.patchHighlights = true;
try {
const decoded = nip19.decode(state.npub);
if (decoded.type !== 'npub') {
throw new Error('Invalid npub format');
}
const repoOwnerPubkey = decoded.data as string;
const response = await fetch(
`/api/repos/${ state . npub } /${ state . repo } /highlights?patchId=${ patchId } & patchAuthor=${ patchAuthor } `
);
if (response.ok) {
const data = await response.json();
state.patchHighlights = data.highlights || [];
state.patchComments = data.comments || [];
}
} catch (err) {
console.error('Failed to load patch highlights:', err);
} finally {
state.loading.patchHighlights = false;
}
}
}
function handlePatchCodeSelection(
function handlePatchCodeSelection(
@ -2567,65 +2299,7 @@
}
}
async function createPatchHighlight() {
async function createPatchHighlight() {
if (!state.user.pubkey || !state.forms.patchHighlight.text.trim() || !state.selected.patch) return;
await createPatchHighlightService(state, highlightsService, { loadPatches } );
const patch = state.patches.find(p => p.id === state.selected.patch);
if (!patch) return;
state.creating.patchHighlight = true;
state.error = null;
try {
const decoded = nip19.decode(state.npub);
if (decoded.type !== 'npub') {
throw new Error('Invalid npub format');
}
const repoOwnerPubkey = decoded.data as string;
const eventTemplate = highlightsService.createHighlightEvent(
state.forms.patchHighlight.text,
patch.id,
patch.author,
repoOwnerPubkey,
state.repo,
KIND.PATCH, // targetKind
undefined, // filePath
state.forms.patchHighlight.startLine, // lineStart
state.forms.patchHighlight.endLine, // lineEnd
undefined, // context
state.forms.patchHighlight.comment.trim() || undefined // comment
);
const signedEvent = await signEventWithNIP07(eventTemplate);
const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const { outbox } = await getUserRelays(state.user.pubkey, tempClient);
const combinedRelays = combineRelays(outbox);
const response = await fetch(`/api/repos/${ state . npub } /${ state . repo } /highlights`, {
method: 'POST',
headers: { 'Content-Type' : 'application/json' } ,
body: JSON.stringify({
type: 'highlight',
event: signedEvent,
userPubkey: state.user.pubkey
})
});
if (response.ok) {
state.openDialog = null;
state.forms.patchHighlight.text = '';
state.forms.patchHighlight.comment = '';
await loadPatchHighlights(patch.id, patch.author);
} else {
const data = await response.json();
state.error = data.state.error || 'Failed to create highlight';
}
} catch (err) {
state.error = err instanceof Error ? err.message : 'Failed to create highlight';
} finally {
state.creating.patchHighlight = false;
}
}
}
function formatPubkey(pubkey: string): string {
function formatPubkey(pubkey: string): string {
@ -2646,82 +2320,7 @@
}
}
async function createPatchComment() {
async function createPatchComment() {
if (!state.user.pubkey || !state.forms.patchComment.content.trim() || !state.selected.patch) return;
await createPatchCommentService(state, highlightsService, { loadPatches } );
const patch = state.patches.find(p => p.id === state.selected.patch);
if (!patch) return;
state.creating.patchComment = true;
state.error = null;
try {
const decoded = nip19.decode(state.npub);
if (decoded.type !== 'npub') {
throw new Error('Invalid npub format');
}
const repoOwnerPubkey = decoded.data as string;
const rootEventId = state.forms.patchComment.replyingTo || patch.id;
const rootEventKind = state.forms.patchComment.replyingTo ? KIND.COMMENT : KIND.PATCH;
const rootPubkey = state.forms.patchComment.replyingTo ?
(state.patchComments.find(c => c.id === state.forms.patchComment.replyingTo)?.pubkey || patch.author) :
patch.author;
let parentEventId: string | undefined;
let parentEventKind: number | undefined;
let parentPubkey: string | undefined;
if (state.forms.patchComment.replyingTo) {
// Reply to a comment
const parentComment = state.patchComments.find(c => c.id === state.forms.patchComment.replyingTo) ||
state.patchHighlights.flatMap(h => h.comments || []).find(c => c.id === state.forms.patchComment.replyingTo);
if (parentComment) {
parentEventId = state.forms.patchComment.replyingTo;
parentEventKind = KIND.COMMENT;
parentPubkey = parentComment.pubkey;
}
}
const eventTemplate = highlightsService.createCommentEvent(
state.forms.patchComment.content.trim(),
rootEventId,
rootEventKind,
rootPubkey,
parentEventId,
parentEventKind,
parentPubkey
);
const signedEvent = await signEventWithNIP07(eventTemplate);
const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const { outbox } = await getUserRelays(state.user.pubkey, tempClient);
const combinedRelays = combineRelays(outbox);
const response = await fetch(`/api/repos/${ state . npub } /${ state . repo } /highlights`, {
method: 'POST',
headers: { 'Content-Type' : 'application/json' } ,
body: JSON.stringify({
type: 'comment',
event: signedEvent,
userPubkey: state.user.pubkey
})
});
if (response.ok) {
state.openDialog = null;
state.forms.patchComment.content = '';
state.forms.patchComment.replyingTo = null;
await loadPatchHighlights(patch.id, patch.author);
} else {
const data = await response.json();
state.error = data.state.error || 'Failed to create comment';
}
} catch (err) {
state.error = err instanceof Error ? err.message : 'Failed to create comment';
} finally {
state.creating.patchComment = false;
}
}
}
// Initialize patch highlights effect
// Initialize patch highlights effect