You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
4943 lines
183 KiB
4943 lines
183 KiB
<script lang="ts"> |
|
import { onMount, onDestroy } from 'svelte'; |
|
import { page } from '$app/stores'; |
|
import { goto } from '$app/navigation'; |
|
import CodeEditor from '$lib/components/CodeEditor.svelte'; |
|
import PRDetail from '$lib/components/PRDetail.svelte'; |
|
import UserBadge from '$lib/components/UserBadge.svelte'; |
|
import EventCopyButton from '$lib/components/EventCopyButton.svelte'; |
|
import RepoHeaderEnhanced from '$lib/components/RepoHeaderEnhanced.svelte'; |
|
import TabsMenu from '$lib/components/TabsMenu.svelte'; |
|
import NostrLinkRenderer from '$lib/components/NostrLinkRenderer.svelte'; |
|
import TagsTab from './components/TagsTab.svelte'; |
|
import FilesTab from './components/FilesTab.svelte'; |
|
import HistoryTab from './components/HistoryTab.svelte'; |
|
import IssuesTab from './components/IssuesTab.svelte'; |
|
import PRsTab from './components/PRsTab.svelte'; |
|
import PatchesTab from './components/PatchesTab.svelte'; |
|
import DocsTab from './components/DocsTab.svelte'; |
|
import DiscussionsTab from './components/DiscussionsTab.svelte'; |
|
import CreateFileDialog from './components/dialogs/CreateFileDialog.svelte'; |
|
import CreateBranchDialog from './components/dialogs/CreateBranchDialog.svelte'; |
|
import CreateTagDialog from './components/dialogs/CreateTagDialog.svelte'; |
|
import CreateReleaseDialog from './components/dialogs/CreateReleaseDialog.svelte'; |
|
import CreateIssueDialog from './components/dialogs/CreateIssueDialog.svelte'; |
|
import CreateThreadDialog from './components/dialogs/CreateThreadDialog.svelte'; |
|
import ReplyDialog from './components/dialogs/ReplyDialog.svelte'; |
|
import CreatePRDialog from './components/dialogs/CreatePRDialog.svelte'; |
|
import CreatePatchDialog from './components/dialogs/CreatePatchDialog.svelte'; |
|
import PatchHighlightDialog from './components/dialogs/PatchHighlightDialog.svelte'; |
|
import PatchCommentDialog from './components/dialogs/PatchCommentDialog.svelte'; |
|
import CommitDialog from './components/dialogs/CommitDialog.svelte'; |
|
import VerificationDialog from './components/dialogs/VerificationDialog.svelte'; |
|
import CloneUrlVerificationDialog from './components/dialogs/CloneUrlVerificationDialog.svelte'; |
|
import { downloadRepository as downloadRepoUtil } from './utils/download.js'; |
|
import { buildApiHeaders } from './utils/api-client.js'; |
|
import '$lib/styles/repo.css'; |
|
import { getPublicKeyWithNIP07, isNIP07Available, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; |
|
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
|
import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } from '$lib/config.js'; |
|
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; |
|
import { BookmarksService } from '$lib/services/nostr/bookmarks-service.js'; |
|
import { HighlightsService } from '$lib/services/nostr/highlights-service.js'; |
|
import { KIND } from '$lib/types/nostr.js'; |
|
import { nip19 } from 'nostr-tools'; |
|
import { userStore } from '$lib/stores/user-store.js'; |
|
import { settingsStore } from '$lib/services/settings-store.js'; |
|
// Note: Announcements are now stored in nostr/repo-events.jsonl, not .nostr-announcement |
|
import type { NostrEvent } from '$lib/types/nostr.js'; |
|
import { hasUnlimitedAccess } from '$lib/utils/user-access.js'; |
|
import { createRepoState, type RepoState } from './stores/repo-state.js'; |
|
import { |
|
usePageDataEffect, |
|
usePageParamsEffect, |
|
useMaintainersEffect, |
|
useAutoSaveEffect, |
|
useUserStoreEffect, |
|
useTabSwitchEffect, |
|
useRepoImagesEffect, |
|
usePatchHighlightsEffect, |
|
useTabChangeEffect, |
|
useBranchChangeEffect |
|
} from './hooks/use-repo-effects.js'; |
|
import { |
|
getHighlightLanguage, |
|
supportsPreview, |
|
isImageFileType, |
|
renderCsvAsTable, |
|
escapeHtml, |
|
applySyntaxHighlighting as applySyntaxHighlightingUtil, |
|
renderFileAsHtml as renderFileAsHtmlUtil |
|
} from './utils/file-processing.js'; |
|
import { |
|
parseNostrLinks |
|
} from './utils/nostr-links.js'; |
|
// formatDiscussionTime is defined locally (slightly different format than utility version) |
|
import { |
|
getUserEmail as getUserEmailUtil, |
|
getUserName as getUserNameUtil |
|
} from './utils/user-profile.js'; |
|
import { |
|
saveFile as saveFileService, |
|
createFile as createFileService, |
|
deleteFile as deleteFileService |
|
} from './services/file-operations.js'; |
|
import { |
|
createBranch as createBranchService, |
|
deleteBranch as deleteBranchService |
|
} from './services/branch-operations.js'; |
|
|
|
// Consolidated state - all state variables in one object |
|
let state = $state(createRepoState()); |
|
|
|
// Local variables for component-specific state |
|
let announcementEventId: string | null = null; |
|
let applying: Record<string, boolean> = {}; |
|
|
|
// Initialize effects |
|
usePageDataEffect(state, () => $page.data); |
|
usePageParamsEffect(state, () => $page.params as { npub?: string; repo?: string }); |
|
|
|
// Extract fields from announcement for convenience |
|
const repoAnnouncement = $derived(state.pageData.announcement); |
|
const repoName = $derived(repoAnnouncement?.tags.find((t: string[]) => t[0] === 'name')?.[1] || state.repo); |
|
const repoDescription = $derived(repoAnnouncement?.tags.find((t: string[]) => t[0] === 'description')?.[1] || ''); |
|
const repoCloneUrls = $derived(repoAnnouncement?.tags |
|
.filter((t: string[]) => t[0] === 'clone') |
|
.flatMap((t: string[]) => t.slice(1)) |
|
.filter((url: string) => url && typeof url === 'string') as string[] || []); |
|
const repoMaintainers = $derived(repoAnnouncement?.tags |
|
.filter((t: string[]) => t[0] === 'maintainers') |
|
.flatMap((t: string[]) => t.slice(1)) |
|
.filter((m: string) => m && typeof m === 'string') as string[] || []); |
|
const repoOwnerPubkeyDerived = $derived(repoAnnouncement?.pubkey || ''); |
|
const repoLanguage = $derived(repoAnnouncement?.tags.find((t: string[]) => t[0] === 'language')?.[1]); |
|
const repoTopics = $derived(repoAnnouncement?.tags |
|
.filter((t: string[]) => t[0] === 't' && t[1] !== 'private') |
|
.map((t: string[]) => t[1]) |
|
.filter((t: string) => t && typeof t === 'string') as string[] || []); |
|
const repoWebsite = $derived(repoAnnouncement?.tags.find((t: string[]) => t[0] === 'website')?.[1]); |
|
const repoIsPrivate = $derived(repoAnnouncement?.tags.some((t: string[]) => |
|
(t[0] === 'private' && t[1] === 'true') || (t[0] === 't' && t[1] === 'private') |
|
) || false); |
|
|
|
// Safe page URL for SSR - computed from pageData or current URL |
|
// Must be completely SSR-safe to prevent "Cannot read properties of null" errors |
|
const pageUrl = $derived.by(() => { |
|
try { |
|
// First try pageData (safest) |
|
if (state.pageData && typeof state.pageData === 'object' && state.pageData.repoUrl) { |
|
const url = state.pageData.repoUrl; |
|
if (typeof url === 'string' && url.trim()) { |
|
return url; |
|
} |
|
} |
|
|
|
// During SSR, return empty string immediately |
|
if (typeof window === 'undefined') { |
|
return ''; |
|
} |
|
|
|
// On client, try to get from current location as fallback |
|
try { |
|
if (window && window.location && window.location.protocol && window.location.host && window.location.pathname) { |
|
return `${window.location.protocol}//${window.location.host}${window.location.pathname}`; |
|
} |
|
} catch (err) { |
|
// Silently ignore errors during SSR or if window.location is unavailable |
|
console.debug('Could not get page URL from window.location:', err); |
|
} |
|
|
|
return ''; |
|
} catch (err) { |
|
// Catch any unexpected errors and return empty string |
|
console.debug('Error computing pageUrl:', err); |
|
return ''; |
|
} |
|
}); |
|
|
|
// Safe Twitter card type - avoid IIFE in head during SSR |
|
const twitterCardType = $derived.by(() => { |
|
try { |
|
const banner = (state.pageData?.banner || state.metadata.banner) || (state.pageData?.image || state.metadata.image); |
|
if (banner && typeof banner === 'string' && banner.trim()) { |
|
return "summary_large_image"; |
|
} |
|
return "summary"; |
|
} catch { |
|
return "summary"; |
|
} |
|
}); |
|
|
|
|
|
// Helper function to safely update state only if component is still mounted |
|
function safeStateUpdate<T>(updateFn: () => T): T | null { |
|
if (!state.isMounted) return null; |
|
try { |
|
return updateFn(); |
|
} catch (err) { |
|
// Silently ignore errors during destruction |
|
if (state.isMounted) { |
|
console.warn('State update state.error (component may be destroying):', err); |
|
} |
|
return null; |
|
} |
|
} |
|
|
|
// Store event listener handler for cleanup |
|
let clickOutsideHandler: ((event: MouseEvent) => void) | null = null; |
|
|
|
// Auto-save interval (wrapped in object for effect hook) |
|
let autoSaveInterval = { value: null as ReturnType<typeof setInterval> | null }; |
|
|
|
// Cached user data (not in state store - these are temporary caches) |
|
let cachedUserEmail: string | null = null; |
|
let cachedUserName: string | null = null; |
|
|
|
// Initialize maintainers effect (using derived values directly in hook) |
|
useMaintainersEffect(state, () => repoOwnerPubkeyDerived, () => repoMaintainers, loadAllMaintainers, () => $page.data); |
|
|
|
// Initialize auto-save effect |
|
useAutoSaveEffect(state, autoSaveInterval, setupAutoSave); |
|
|
|
// Initialize user store sync effect |
|
const cachedUserData = { email: cachedUserEmail, name: cachedUserName }; |
|
useUserStoreEffect(state, cachedUserData, () => $userStore, { |
|
checkMaintainerStatus, |
|
loadBookmarkStatus, |
|
loadAllMaintainers, |
|
checkCloneStatus, |
|
loadBranches, |
|
loadFiles, |
|
loadReadme, |
|
loadTags, |
|
loadDiscussions |
|
}); |
|
|
|
// Function to toggle word wrap and refresh highlighting |
|
async function toggleWordWrap() { |
|
state.ui.wordWrap = !state.ui.wordWrap; |
|
console.log('Word wrap toggled:', state.ui.wordWrap); |
|
// Force DOM update by accessing the element |
|
await new Promise(resolve => { |
|
requestAnimationFrame(() => { |
|
requestAnimationFrame(resolve); |
|
}); |
|
}); |
|
// Re-apply syntax highlighting to refresh the display |
|
if (state.files.currentFile && state.files.content) { |
|
const ext = state.files.currentFile.split('.').pop() || ''; |
|
await applySyntaxHighlighting(state.files.content, ext); |
|
} |
|
} |
|
|
|
// Helper: Check if repo needs to be cloned for write operations |
|
const needsClone = $derived(state.clone.isCloned === false); |
|
// Helper: Check if we can use API fallback for read-only operations |
|
const canUseApiFallback = $derived(state.clone.apiFallbackAvailable === true); |
|
// Helper: Check if we have any way to view the repo (cloned or API fallback) |
|
const canViewRepo = $derived(state.clone.isCloned === true || canUseApiFallback); |
|
const cloneTooltip = 'Please clone this repo to use this feature.'; |
|
|
|
// Copy clone URL to clipboard |
|
async function copyCloneUrl() { |
|
if (state.clone.copyingUrl) return; |
|
|
|
state.clone.copyingUrl = true; |
|
try { |
|
// Use the current page URL to get the correct host and port |
|
// This ensures we use the same domain/port the user is currently viewing |
|
// Guard against SSR - $page store can only be accessed in component context |
|
if (typeof window === 'undefined') return; |
|
// Guard against SSR - $page.url might not be available |
|
if (typeof window === 'undefined' || !$page?.url) { |
|
return ''; |
|
} |
|
const currentUrl = $page.url; |
|
const host = currentUrl.host; // Includes port if present (e.g., "localhost:5173") |
|
const protocol = currentUrl.protocol.slice(0, -1); // Remove trailing ":" |
|
|
|
// Use /api/git/ format for better compatibility with commit signing hook |
|
const cloneUrl = `${protocol}://${host}/api/git/${state.npub}/${state.repo}.git`; |
|
const cloneCommand = `git clone ${cloneUrl}`; |
|
|
|
// Try to use the Clipboard API |
|
if (navigator.clipboard && navigator.clipboard.writeText) { |
|
await navigator.clipboard.writeText(cloneCommand); |
|
alert(`Clone command copied to clipboard!\n\n${cloneCommand}`); |
|
} else { |
|
// Fallback: create a temporary textarea |
|
const textarea = document.createElement('textarea'); |
|
textarea.value = cloneCommand; |
|
textarea.style.position = 'fixed'; |
|
textarea.style.opacity = '0'; |
|
document.body.appendChild(textarea); |
|
textarea.select(); |
|
document.execCommand('copy'); |
|
document.body.removeChild(textarea); |
|
alert(`Clone command copied to clipboard!\n\n${cloneCommand}`); |
|
} |
|
} catch (err) { |
|
console.error('Failed to copy clone command:', err); |
|
alert('Failed to copy clone command to clipboard'); |
|
} finally { |
|
state.clone.copyingUrl = false; |
|
} |
|
} |
|
|
|
|
|
|
|
// Tabs menu - defined after state.issues and state.prs |
|
// Order: Files, Issues, PRs, Patches, Discussion, History, Tags, Code Search, Docs |
|
// Show tabs that require cloned repo when repo is cloned OR API fallback is available |
|
const tabs = $derived.by(() => { |
|
const allTabs = [ |
|
{ id: 'files', label: 'Files', icon: '/icons/file-text.svg', requiresClone: true }, |
|
{ id: 'state.issues', label: 'Issues', icon: '/icons/alert-circle.svg', requiresClone: false }, |
|
{ id: 'state.prs', label: 'Pull Requests', icon: '/icons/git-pull-request.svg', requiresClone: false }, |
|
{ id: 'state.patches', label: 'Patches', icon: '/icons/clipboard-list.svg', requiresClone: false }, |
|
{ id: 'state.discussions', label: 'Discussions', icon: '/icons/message-circle.svg', requiresClone: false }, |
|
{ id: 'history', label: 'Commit History', icon: '/icons/git-commit.svg', requiresClone: true }, |
|
{ id: 'state.git.tags', label: 'Tags', icon: '/icons/tag.svg', requiresClone: true }, |
|
{ id: 'code-search', label: 'Code Search', icon: '/icons/search.svg', requiresClone: true }, |
|
{ id: 'docs', label: 'Docs', icon: '/icons/book.svg', requiresClone: false } |
|
]; |
|
|
|
// Show all tabs if repo is cloned OR API fallback is available |
|
// Otherwise, only show tabs that don't require state.clone.cloning |
|
if (state.clone.isCloned === false && !canUseApiFallback) { |
|
return allTabs.filter(tab => !tab.requiresClone).map(({ requiresClone, ...tab }) => tab); |
|
} |
|
|
|
// Return all tabs when repo is cloned, API fallback is available, or status is unknown (remove requiresClone property) |
|
return allTabs.map(({ requiresClone, ...tab }) => tab); |
|
}); |
|
|
|
// Initialize tab switch effect (already done above, but keeping for clarity) |
|
|
|
const highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS); |
|
|
|
// parseNostrLinks is now imported from utils/nostr-links.ts |
|
|
|
// Load events/profiles from nostr: links |
|
async function loadNostrLinks(content: string) { |
|
const links = parseNostrLinks(content); |
|
if (links.length === 0) return; |
|
|
|
const eventIds: string[] = []; |
|
const aTags: string[] = []; |
|
const npubs: string[] = []; |
|
|
|
for (const link of links) { |
|
try { |
|
if (link.type === 'nevent' || link.type === 'note1') { |
|
const decoded = nip19.decode(link.value.replace('nostr:', '')); |
|
if (decoded.type === 'nevent') { |
|
eventIds.push(decoded.data.id); |
|
} else if (decoded.type === 'note') { |
|
eventIds.push(decoded.data as string); |
|
} |
|
} else if (link.type === 'naddr') { |
|
const decoded = nip19.decode(link.value.replace('nostr:', '')); |
|
if (decoded.type === 'naddr') { |
|
const aTag = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`; |
|
aTags.push(aTag); |
|
} |
|
} else if (link.type === 'npub' || link.type === 'profile') { |
|
const decoded = nip19.decode(link.value.replace('nostr:', '')); |
|
if (decoded.type === 'npub') { |
|
npubs.push(link.value); |
|
state.discussion.nostrLinkProfiles.set(link.value, decoded.data as string); |
|
} |
|
} |
|
} catch { |
|
// Invalid nostr link, skip |
|
} |
|
} |
|
|
|
// Fetch events |
|
if (eventIds.length > 0) { |
|
try { |
|
const events = await Promise.race([ |
|
nostrClient.fetchEvents([{ ids: eventIds, limit: eventIds.length }]), |
|
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000)) |
|
]); |
|
|
|
for (const event of events) { |
|
state.discussion.nostrLinkEvents.set(event.id, event); |
|
} |
|
} catch { |
|
// Ignore fetch errors |
|
} |
|
} |
|
|
|
// Fetch a-tag events |
|
if (aTags.length > 0) { |
|
for (const aTag of aTags) { |
|
const parts = aTag.split(':'); |
|
if (parts.length === 3) { |
|
try { |
|
const kind = parseInt(parts[0]); |
|
const pubkey = parts[1]; |
|
const dTag = parts[2]; |
|
const events = await Promise.race([ |
|
nostrClient.fetchEvents([{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }]), |
|
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000)) |
|
]); |
|
|
|
if (events.length > 0) { |
|
state.discussion.nostrLinkEvents.set(events[0].id, events[0]); |
|
} |
|
} catch { |
|
// Ignore fetch errors |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
// Get event from nostr: link (local version that uses state) |
|
function getEventFromNostrLinkLocal(link: string): NostrEvent | undefined { |
|
try { |
|
if (link.startsWith('nostr:nevent1') || link.startsWith('nostr:note1')) { |
|
const decoded = nip19.decode(link.replace('nostr:', '')); |
|
if (decoded.type === 'nevent') { |
|
return state.discussion.nostrLinkEvents.get(decoded.data.id); |
|
} else if (decoded.type === 'note') { |
|
return state.discussion.nostrLinkEvents.get(decoded.data as string); |
|
} |
|
} else if (link.startsWith('nostr:naddr1')) { |
|
const decoded = nip19.decode(link.replace('nostr:', '')); |
|
if (decoded.type === 'naddr') { |
|
const eventId = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`; |
|
return Array.from(state.discussion.nostrLinkEvents.values()).find(e => { |
|
const dTag = e.tags.find(t => t[0] === 'd')?.[1]; |
|
return e.kind === decoded.data.kind && |
|
e.pubkey === decoded.data.pubkey && |
|
dTag === decoded.data.identifier; |
|
}); |
|
} |
|
} |
|
} catch { |
|
// Invalid link |
|
} |
|
return undefined; |
|
} |
|
|
|
// Get pubkey from nostr: npub/profile link (local version that uses state) |
|
function getPubkeyFromNostrLinkLocal(link: string): string | undefined { |
|
return state.discussion.nostrLinkProfiles.get(link); |
|
} |
|
|
|
// Process content with nostr links into parts for rendering (local version that uses state) |
|
function processContentWithNostrLinks(content: string): Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> { |
|
const links = parseNostrLinks(content); |
|
if (links.length === 0) { |
|
return [{ type: 'text', value: content }]; |
|
} |
|
|
|
const parts: Array<{ type: 'text' | 'event' | 'profile' | 'placeholder'; value: string; event?: NostrEvent; pubkey?: string }> = []; |
|
let lastIndex = 0; |
|
|
|
for (const link of links) { |
|
// Add text before link |
|
if (link.start > lastIndex) { |
|
const textPart = content.slice(lastIndex, link.start); |
|
if (textPart) { |
|
parts.push({ type: 'text', value: textPart }); |
|
} |
|
} |
|
|
|
// Add link |
|
const event = getEventFromNostrLinkLocal(link.value); |
|
const pubkey = getPubkeyFromNostrLinkLocal(link.value); |
|
if (event) { |
|
parts.push({ type: 'event', value: link.value, event }); |
|
} else if (pubkey) { |
|
parts.push({ type: 'profile', value: link.value, pubkey }); |
|
} else { |
|
parts.push({ type: 'placeholder', value: link.value }); |
|
} |
|
|
|
lastIndex = link.end; |
|
} |
|
|
|
// Add remaining text |
|
if (lastIndex < content.length) { |
|
const textPart = content.slice(lastIndex); |
|
if (textPart) { |
|
parts.push({ type: 'text', value: textPart }); |
|
} |
|
} |
|
|
|
return parts; |
|
} |
|
|
|
// Load full events for state.discussions and comments to get state.git.tags for blurbs |
|
async function loadDiscussionEvents(discussionsList: typeof state.discussions) { |
|
const eventIds = new Set<string>(); |
|
|
|
// Collect all event IDs |
|
for (const discussion of discussionsList) { |
|
if (discussion.id) { |
|
eventIds.add(discussion.id); |
|
} |
|
if (discussion.comments) { |
|
for (const comment of discussion.comments) { |
|
if (comment.id) { |
|
eventIds.add(comment.id); |
|
} |
|
if (comment.replies) { |
|
for (const reply of comment.replies) { |
|
if (reply.id) { |
|
eventIds.add(reply.id); |
|
} |
|
if (reply.replies) { |
|
for (const nestedReply of reply.replies) { |
|
if (nestedReply.id) { |
|
eventIds.add(nestedReply.id); |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
if (eventIds.size === 0) return; |
|
|
|
try { |
|
const events = await Promise.race([ |
|
nostrClient.fetchEvents([{ ids: Array.from(eventIds), limit: eventIds.size }]), |
|
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000)) |
|
]); |
|
|
|
for (const event of events) { |
|
state.discussion.events.set(event.id, event); |
|
} |
|
} catch { |
|
// Ignore fetch errors |
|
} |
|
} |
|
|
|
// Get discussion event by ID |
|
function getDiscussionEvent(eventId: string): NostrEvent | undefined { |
|
return state.discussion.events.get(eventId); |
|
} |
|
|
|
// Get referenced event from discussion event (e-tag, a-tag, q-tag) |
|
function getReferencedEventFromDiscussion(event: NostrEvent): NostrEvent | undefined { |
|
// Check e-tag |
|
const eTag = event.tags.find(t => t[0] === 'e' && t[1])?.[1]; |
|
if (eTag) { |
|
const referenced = state.discussion.events.get(eTag); |
|
if (referenced) return referenced; |
|
} |
|
|
|
// Check a-tag |
|
const aTag = event.tags.find(t => t[0] === 'a' && t[1])?.[1]; |
|
if (aTag) { |
|
const parts = aTag.split(':'); |
|
if (parts.length === 3) { |
|
const kind = parseInt(parts[0]); |
|
const pubkey = parts[1]; |
|
const dTag = parts[2]; |
|
return Array.from(state.discussion.events.values()).find(e => |
|
e.kind === kind && |
|
e.pubkey === pubkey && |
|
e.tags.find(t => t[0] === 'd' && t[1] === dTag) |
|
); |
|
} |
|
} |
|
|
|
// Check q-tag |
|
const qTag = event.tags.find(t => t[0] === 'q' && t[1])?.[1]; |
|
if (qTag) { |
|
return state.discussion.events.get(qTag); |
|
} |
|
|
|
return undefined; |
|
} |
|
|
|
// Format time for state.discussions |
|
function formatDiscussionTime(timestamp: number): string { |
|
const date = new Date(timestamp * 1000); |
|
const now = new Date(); |
|
const diffMs = now.getTime() - date.getTime(); |
|
const diffMins = Math.floor(diffMs / 60000); |
|
const diffHours = Math.floor(diffMs / 3600000); |
|
const diffDays = Math.floor(diffMs / 86400000); |
|
|
|
if (diffMins < 1) return 'just now'; |
|
if (diffMins < 60) return `${diffMins}m ago`; |
|
if (diffHours < 24) return `${diffHours}h ago`; |
|
if (diffDays < 7) return `${diffDays}d ago`; |
|
return date.toLocaleDateString(); |
|
} |
|
|
|
// Create a nostrClient instance for fetching events |
|
let nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
|
|
// README |
|
|
|
// Rewrite image paths in HTML to point to repository file API |
|
function rewriteImagePaths(html: string, filePath: string | null): string { |
|
if (!html || !filePath) return html; |
|
|
|
// Get the directory of the current file |
|
const fileDir = filePath.includes('/') |
|
? filePath.substring(0, filePath.lastIndexOf('/')) |
|
: ''; |
|
|
|
// Get current branch for the API URL |
|
const branch = state.git.currentBranch || state.git.defaultBranch || 'main'; |
|
|
|
// Rewrite relative image paths |
|
return html.replace(/<img([^>]*)\ssrc=["']([^"']+)["']([^>]*)>/gi, (match, before, src, after) => { |
|
// Skip if it's already an absolute URL (http/https/data) |
|
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:') || src.startsWith('/api/')) { |
|
return match; |
|
} |
|
|
|
// Resolve relative path |
|
let imagePath: string; |
|
if (src.startsWith('/')) { |
|
// Absolute path from repo root |
|
imagePath = src.substring(1); |
|
} else if (src.startsWith('./')) { |
|
// Relative to current file directory |
|
imagePath = fileDir ? `${fileDir}/${src.substring(2)}` : src.substring(2); |
|
} else { |
|
// Relative to current file directory |
|
imagePath = fileDir ? `${fileDir}/${src}` : src; |
|
} |
|
|
|
// Normalize path (remove .. and .) |
|
const pathParts = imagePath.split('/').filter(p => p !== '.' && p !== ''); |
|
const normalizedPath: string[] = []; |
|
for (const part of pathParts) { |
|
if (part === '..') { |
|
normalizedPath.pop(); |
|
} else { |
|
normalizedPath.push(part); |
|
} |
|
} |
|
imagePath = normalizedPath.join('/'); |
|
|
|
// Build API URL |
|
const apiUrl = `/api/repos/${state.npub}/${state.repo}/raw?path=${encodeURIComponent(imagePath)}&ref=${encodeURIComponent(branch)}`; |
|
|
|
return `<img${before} src="${apiUrl}"${after}>`; |
|
}); |
|
} |
|
|
|
// Fork |
|
let bookmarksService: BookmarksService | null = null; |
|
|
|
// Safe values for head section to prevent SSR errors |
|
const safeRepo = $derived(state.repo || 'Repository'); |
|
const safeRepoName = $derived.by(() => { |
|
try { |
|
return repoName || state.repo || 'Repository'; |
|
} catch { |
|
return state.repo || 'Repository'; |
|
} |
|
}); |
|
const safeRepoDescription = $derived.by(() => { |
|
try { |
|
return repoDescription || ''; |
|
} catch { |
|
return ''; |
|
} |
|
}); |
|
const safeTitle = $derived.by(() => { |
|
try { |
|
return state.pageData?.title || `${safeRepo} - Repository`; |
|
} catch { |
|
return `${safeRepo} - Repository`; |
|
} |
|
}); |
|
const safeDescription = $derived.by(() => { |
|
try { |
|
return state.pageData?.description || `Repository: ${safeRepo}`; |
|
} catch { |
|
return `Repository: ${safeRepo}`; |
|
} |
|
}); |
|
const safeImage = $derived.by(() => { |
|
try { |
|
return state.pageData?.image || state.metadata.image || null; |
|
} catch { |
|
return null; |
|
} |
|
}); |
|
const safeBanner = $derived.by(() => { |
|
try { |
|
return state.pageData?.banner || state.metadata.banner || null; |
|
} catch { |
|
return null; |
|
} |
|
}); |
|
const hasImage = $derived.by(() => { |
|
try { |
|
return safeImage && typeof safeImage === 'string' && safeImage.trim() !== ''; |
|
} catch { |
|
return false; |
|
} |
|
}); |
|
const hasBanner = $derived.by(() => { |
|
try { |
|
return safeBanner && typeof safeBanner === 'string' && safeBanner.trim() !== ''; |
|
} catch { |
|
return false; |
|
} |
|
}); |
|
|
|
// Additional safe values for head section to avoid IIFEs |
|
const safeOgDescription = $derived.by(() => { |
|
try { |
|
return state.pageData?.description || safeRepoDescription || `Repository: ${safeRepoName || safeRepo || 'Repository'}`; |
|
} catch { |
|
return 'Repository'; |
|
} |
|
}); |
|
const safeTwitterDescription = $derived.by(() => { |
|
try { |
|
return state.pageData?.description || safeRepoDescription || `Repository: ${safeRepoName || safeRepo || 'Repository'}`; |
|
} catch { |
|
return 'Repository'; |
|
} |
|
}); |
|
const safeTwitterCard = $derived.by(() => { |
|
try { |
|
return twitterCardType || 'summary'; |
|
} catch { |
|
return 'summary'; |
|
} |
|
}); |
|
const safePageUrl = $derived.by(() => { |
|
try { |
|
return pageUrl || ''; |
|
} catch { |
|
return ''; |
|
} |
|
}); |
|
|
|
// Repository owner pubkey (decoded from npub) - kept for backward compatibility with some functions |
|
let readmeAutoLoadTimeout: ReturnType<typeof setTimeout> | null = null; |
|
|
|
// Load clone URL reachability status |
|
async function loadCloneUrlReachability(forceRefresh: boolean = false) { |
|
if (!repoCloneUrls || repoCloneUrls.length === 0) { |
|
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() { |
|
if (state.loading.repoNotFound) return; |
|
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 |
|
|
|
// Render markdown, asciidoc, or HTML files as HTML |
|
async function renderFileAsHtml(content: string, ext: string) { |
|
await renderFileAsHtmlUtil(content, ext, state.files.currentFile, (html: string) => { |
|
state.preview.file.html = html; |
|
}); |
|
} |
|
|
|
// CSV and HTML utilities are now imported from utils/file-processing.ts |
|
|
|
async function applySyntaxHighlighting(content: string, ext: string) { |
|
await applySyntaxHighlightingUtil(content, ext, (html: string) => { |
|
state.preview.file.highlightedContent = html; |
|
}); |
|
} |
|
|
|
async function loadForkInfo() { |
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/fork`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
state.fork.info = await response.json(); |
|
} |
|
} catch (err) { |
|
console.error('Error state.loading.main fork info:', err); |
|
} |
|
} |
|
|
|
// Helper function to count all replies recursively (including nested ones) |
|
function countAllReplies(comments: Array<{ replies?: Array<any> }> | undefined): number { |
|
if (!comments || comments.length === 0) { |
|
return 0; |
|
} |
|
let count = comments.length; |
|
for (const comment of comments) { |
|
if (comment.replies && comment.replies.length > 0) { |
|
count += countAllReplies(comment.replies); |
|
} |
|
} |
|
return count; |
|
} |
|
|
|
async function checkCloneStatus(force: boolean = false) { |
|
if (state.clone.checking) return; |
|
if (!force && state.clone.isCloned !== null) { |
|
console.log(`[Clone Status] Skipping check - already checked: ${state.clone.isCloned}, force: ${force}`); |
|
return; |
|
} |
|
|
|
state.clone.checking = true; |
|
try { |
|
// Check if repo exists locally by trying to fetch state.git.branches |
|
// Use skipApiFallback parameter to ensure we only check local repo, not API fallback |
|
// 404 = repo not cloned, 403 = repo exists but access denied (cloned), 200 = cloned and accessible |
|
const url = `/api/repos/${state.npub}/${state.repo}/branches?skipApiFallback=true`; |
|
console.log(`[Clone Status] Checking clone status for ${state.npub}/${state.repo}...`); |
|
const response = await fetch(url, { |
|
headers: buildApiHeaders() |
|
}); |
|
// If response is 403, repo exists (cloned) but user doesn't have access |
|
// If response is 404, repo doesn't exist (not cloned) |
|
// If response is 200, repo exists and is accessible (cloned) |
|
const wasCloned = response.status !== 404; |
|
state.clone.isCloned = wasCloned; |
|
|
|
// If repo is not cloned, check if API fallback is available |
|
if (!wasCloned) { |
|
// Try to detect API fallback by checking if we have clone URLs |
|
if (repoCloneUrls && repoCloneUrls.length > 0) { |
|
// We have clone URLs, so API fallback might work - will be detected when loadBranches() runs |
|
state.clone.apiFallbackAvailable = null; // Will be set to true if a subsequent request succeeds |
|
} else { |
|
state.clone.apiFallbackAvailable = false; |
|
} |
|
} else { |
|
// Repo is cloned, API fallback not needed |
|
state.clone.apiFallbackAvailable = false; |
|
} |
|
|
|
console.log(`[Clone Status] Repo ${wasCloned ? 'is cloned' : 'is not cloned'} (status: ${response.status}), API fallback: ${state.clone.apiFallbackAvailable}`); |
|
} catch (err) { |
|
// On state.error, assume not cloned |
|
console.warn('[Clone Status] Error checking clone status:', err); |
|
state.clone.isCloned = false; |
|
state.clone.apiFallbackAvailable = false; |
|
} finally { |
|
state.clone.checking = false; |
|
} |
|
} |
|
|
|
async function cloneRepository() { |
|
if (state.clone.cloning) return; |
|
|
|
state.clone.cloning = true; |
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/clone`, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
...buildApiHeaders() |
|
} |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json().catch(() => ({ message: response.statusText })); |
|
throw new Error(errorData.message || `Failed to clone repository: ${response.statusText}`); |
|
} |
|
|
|
const data = await response.json(); |
|
|
|
if (data.alreadyExists) { |
|
alert('Repository already exists locally.'); |
|
// Force refresh clone status |
|
await checkCloneStatus(true); |
|
} else { |
|
alert('Repository cloned successfully! The repository is now available on this server.'); |
|
// Force refresh clone status |
|
await checkCloneStatus(true); |
|
// Reset API fallback status since repo is now cloned |
|
state.clone.apiFallbackAvailable = false; |
|
// Reload data to use the cloned repo instead of API |
|
await Promise.all([ |
|
loadBranches(), |
|
loadFiles(state.files.currentPath), |
|
loadReadme(), |
|
loadTags(), |
|
loadCommitHistory() |
|
]); |
|
} |
|
} catch (err) { |
|
const errorMessage = err instanceof Error ? err.message : 'Failed to clone repository'; |
|
alert(`Error: ${errorMessage}`); |
|
console.error('Error state.clone.cloning repository:', err); |
|
} finally { |
|
state.clone.cloning = false; |
|
} |
|
} |
|
|
|
async function forkRepository() { |
|
if (!state.user.pubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
state.fork.forking = true; |
|
state.error = null; |
|
|
|
try { |
|
// Security: Truncate npub in logs |
|
const truncatedNpub = state.npub.length > 16 ? `${state.npub.slice(0, 12)}...` : state.npub; |
|
console.log(`[Fork UI] Starting fork of ${truncatedNpub}/${state.repo}...`); |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/fork`, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
...buildApiHeaders() |
|
}, |
|
body: JSON.stringify({ userPubkey: state.user.pubkey }) |
|
}); |
|
|
|
const data = await response.json(); |
|
|
|
if (response.ok && data.success !== false) { |
|
const message = data.message || `Repository forked successfully! Published to ${data.fork?.publishedTo?.announcement || 0} relay(s).`; |
|
console.log(`[Fork UI] ✓ ${message}`); |
|
// Security: Truncate npub in logs |
|
const truncatedForkNpub = data.fork.npub.length > 16 ? `${data.fork.npub.slice(0, 12)}...` : data.fork.npub; |
|
console.log(`[Fork UI] - Fork location: /repos/${truncatedForkNpub}/${data.fork.repo}`); |
|
console.log(`[Fork UI] - Announcement ID: ${data.fork.announcementId}`); |
|
console.log(`[Fork UI] - Ownership Transfer ID: ${data.fork.ownershipTransferId}`); |
|
|
|
alert(`${message}\n\nRedirecting to your fork...`); |
|
goto(`/repos/${data.fork.npub}/${data.fork.repo}`); |
|
} else { |
|
const errorMessage = data.state.error || 'Failed to fork repository'; |
|
const errorDetails = data.details ? `\n\nDetails: ${data.details}` : ''; |
|
const fullError = `${errorMessage}${errorDetails}`; |
|
|
|
console.error(`[Fork UI] ✗ Fork failed: ${errorMessage}`); |
|
if (data.details) { |
|
console.error(`[Fork UI] Details: ${data.details}`); |
|
} |
|
if (data.eventName) { |
|
console.error(`[Fork UI] Failed event: ${data.eventName}`); |
|
} |
|
|
|
state.error = fullError; |
|
alert(`Fork failed!\n\n${fullError}`); |
|
} |
|
} catch (err) { |
|
const errorMessage = err instanceof Error ? err.message : 'Failed to fork repository'; |
|
console.error(`[Fork UI] ✗ Unexpected error: ${errorMessage}`, err); |
|
state.error = errorMessage; |
|
alert(`Fork failed!\n\n${errorMessage}`); |
|
} finally { |
|
state.fork.forking = false; |
|
} |
|
} |
|
|
|
async function loadDiscussions() { |
|
if (state.repoNotFound) return; |
|
state.loading.discussions = 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; |
|
|
|
// Fetch repo announcement to get project-relay tags and announcement ID |
|
const client = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
const events = await client.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [repoOwnerPubkeyDerived], |
|
'#d': [state.repo], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length === 0) { |
|
state.discussions = []; |
|
return; |
|
} |
|
|
|
const announcement = events[0]; |
|
const chatRelays = announcement.tags |
|
.filter(t => t[0] === 'project-relay') |
|
.flatMap(t => t.slice(1)) |
|
.filter(url => url && typeof url === 'string') as string[]; |
|
|
|
// Get default relays |
|
const { getGitUrl } = await import('$lib/config.js'); |
|
const { DiscussionsService } = await import('$lib/services/nostr/discussions-service.js'); |
|
|
|
// Get user's relays if available |
|
let userRelays: string[] = []; |
|
const currentUserPubkey = $userStore.userPubkey || state.user.pubkey; |
|
if (currentUserPubkey) { |
|
try { |
|
const { outbox } = await getUserRelays(currentUserPubkey, client); |
|
userRelays = outbox; |
|
} catch (err) { |
|
console.warn('Failed to get user relays, using defaults:', err); |
|
} |
|
} |
|
|
|
// Combine all available relays: default + search + chat + user relays |
|
const allRelays = [...new Set([ |
|
...DEFAULT_NOSTR_RELAYS, |
|
...DEFAULT_NOSTR_SEARCH_RELAYS, |
|
...chatRelays, |
|
...userRelays |
|
])]; |
|
|
|
console.log('[Discussions] Using all available relays for threads:', allRelays); |
|
console.log('[Discussions] Project relays from announcement:', chatRelays); |
|
|
|
const discussionsService = new DiscussionsService(allRelays); |
|
const discussionEntries = await discussionsService.getDiscussions( |
|
repoOwnerPubkey, |
|
state.repo, |
|
announcement.id, |
|
announcement.pubkey, |
|
allRelays, // Use all relays for threads |
|
allRelays // Use all relays for comments too |
|
); |
|
|
|
console.log('[Discussions] Found', discussionEntries.length, 'discussion entries'); |
|
|
|
state.discussions = discussionEntries.map(entry => ({ |
|
type: entry.type, |
|
id: entry.id, |
|
title: entry.title, |
|
content: entry.content, |
|
author: entry.author, |
|
createdAt: entry.createdAt, |
|
kind: entry.kind, |
|
pubkey: entry.pubkey, |
|
comments: entry.comments |
|
})); |
|
|
|
// Fetch full events for state.discussions and comments to get state.git.tags for blurbs |
|
await loadDiscussionEvents(state.discussions); |
|
|
|
// Fetch nostr: links from discussion content |
|
for (const discussion of state.discussions) { |
|
if (discussion.content) { |
|
await loadNostrLinks(discussion.content); |
|
} |
|
if (discussion.comments) { |
|
for (const comment of discussion.comments) { |
|
if (comment.content) { |
|
await loadNostrLinks(comment.content); |
|
} |
|
if (comment.replies) { |
|
for (const reply of comment.replies) { |
|
if (reply.content) { |
|
await loadNostrLinks(reply.content); |
|
} |
|
if (reply.replies) { |
|
for (const nestedReply of reply.replies) { |
|
if (nestedReply.content) { |
|
await loadNostrLinks(nestedReply.content); |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to load state.discussions'; |
|
console.error('Error state.loading.main state.discussions:', err); |
|
} finally { |
|
state.loading.discussions = false; |
|
} |
|
} |
|
|
|
|
|
async function createDiscussionThread() { |
|
if (!state.user.pubkey || !state.user.pubkeyHex) { |
|
state.error = 'You must be logged in to create a discussion thread'; |
|
return; |
|
} |
|
|
|
if (!state.forms.discussion.threadTitle.trim()) { |
|
state.error = 'Thread title is required'; |
|
return; |
|
} |
|
|
|
state.creating.thread = 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; |
|
|
|
// Get repo announcement to get the repo address |
|
const client = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
const events = await client.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [repoOwnerPubkeyDerived], |
|
'#d': [state.repo], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length === 0) { |
|
throw new Error('Repository announcement not found'); |
|
} |
|
|
|
const announcement = events[0]; |
|
state.metadata.address = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${state.repo}`; |
|
|
|
// Get project relays from announcement, or use default relays |
|
const chatRelays = announcement.tags |
|
.filter(t => t[0] === 'project-relay') |
|
.flatMap(t => t.slice(1)) |
|
.filter(url => url && typeof url === 'string') as string[]; |
|
|
|
// Combine all available relays |
|
let allRelays = [...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS, ...chatRelays]; |
|
if (state.user.pubkey) { |
|
try { |
|
const { outbox } = await getUserRelays(state.user.pubkey, client); |
|
allRelays = [...allRelays, ...outbox]; |
|
} catch (err) { |
|
console.warn('Failed to get user relays:', err); |
|
} |
|
} |
|
allRelays = [...new Set(allRelays)]; // Deduplicate |
|
|
|
// Create kind 11 thread event |
|
const threadEventTemplate: Omit<NostrEvent, 'sig' | 'id'> = { |
|
kind: KIND.THREAD, |
|
pubkey: state.user.pubkeyHex, |
|
created_at: Math.floor(Date.now() / 1000), |
|
tags: [ |
|
['a', state.metadata.address], |
|
['title', state.forms.discussion.threadTitle.trim()], |
|
['t', 'repo'] |
|
], |
|
content: state.forms.discussion.threadContent.trim() || '' |
|
}; |
|
|
|
// Sign the event using NIP-07 |
|
const signedEvent = await signEventWithNIP07(threadEventTemplate); |
|
|
|
// Publish to all available relays |
|
const publishClient = new NostrClient(allRelays); |
|
const result = await publishClient.publishEvent(signedEvent, allRelays); |
|
|
|
if (result.failed.length > 0 && result.success.length === 0) { |
|
throw new Error('Failed to publish thread to all relays'); |
|
} |
|
|
|
// Clear form and close dialog |
|
state.forms.discussion.threadTitle = ''; |
|
state.forms.discussion.threadContent = ''; |
|
state.openDialog = null; |
|
|
|
// Reload discussions |
|
await loadDiscussions(); |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to create discussion thread'; |
|
console.error('Error creating discussion thread:', err); |
|
} finally { |
|
state.creating.thread = false; |
|
} |
|
} |
|
|
|
async function createThreadReply() { |
|
if (!state.user.pubkey || !state.user.pubkeyHex) { |
|
state.error = 'You must be logged in to reply'; |
|
return; |
|
} |
|
|
|
if (!state.forms.discussion.replyContent.trim()) { |
|
state.error = 'Reply content is required'; |
|
return; |
|
} |
|
|
|
if (!state.discussion.replyingToThread && !state.discussion.replyingToComment) { |
|
state.error = 'Must reply to either a thread or a comment'; |
|
return; |
|
} |
|
|
|
state.creating.reply = 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; |
|
|
|
// Get repo announcement to get the repo address and relays |
|
const client = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
nostrClient = client; // Store for use in other functions |
|
const events = await client.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [repoOwnerPubkeyDerived], |
|
'#d': [state.repo], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length === 0) { |
|
throw new Error('Repository announcement not found'); |
|
} |
|
|
|
const announcement = events[0]; |
|
|
|
// Get project relays from announcement, or use default relays |
|
const chatRelays = announcement.tags |
|
.filter(t => t[0] === 'project-relay') |
|
.flatMap(t => t.slice(1)) |
|
.filter(url => url && typeof url === 'string') as string[]; |
|
|
|
// Combine all available relays |
|
let allRelays = [...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS, ...chatRelays]; |
|
if (state.user.pubkey) { |
|
try { |
|
const { outbox } = await getUserRelays(state.user.pubkey, client); |
|
allRelays = [...allRelays, ...outbox]; |
|
} catch (err) { |
|
console.warn('Failed to get user relays:', err); |
|
} |
|
} |
|
allRelays = [...new Set(allRelays)]; // Deduplicate |
|
|
|
let rootEventId: string; |
|
let rootKind: number; |
|
let rootPubkey: string; |
|
let parentEventId: string; |
|
let parentKind: number; |
|
let parentPubkey: string; |
|
|
|
if (state.discussion.replyingToComment) { |
|
// Replying to a comment - use the comment object we already have |
|
const comment = state.discussion.replyingToComment; |
|
|
|
// Determine root: if we have a thread, use it as root; otherwise use announcement |
|
if (state.discussion.replyingToThread) { |
|
rootEventId = state.discussion.replyingToThread.id; |
|
rootKind = state.discussion.replyingToThread.kind || KIND.THREAD; |
|
rootPubkey = state.discussion.replyingToThread.pubkey || state.discussion.replyingToThread.author; |
|
} else { |
|
// Comment is directly on announcement (in "Comments" pseudo-thread) |
|
rootEventId = announcement.id; |
|
rootKind = KIND.REPO_ANNOUNCEMENT; |
|
rootPubkey = announcement.pubkey; |
|
} |
|
|
|
// Parent is the comment we're replying to |
|
parentEventId = comment.id; |
|
parentKind = comment.kind || KIND.COMMENT; |
|
parentPubkey = comment.pubkey || comment.author; |
|
} else if (state.discussion.replyingToThread) { |
|
// Replying directly to a thread - use the thread object we already have |
|
rootEventId = state.discussion.replyingToThread.id; |
|
rootKind = state.discussion.replyingToThread.kind || KIND.THREAD; |
|
rootPubkey = state.discussion.replyingToThread.pubkey || state.discussion.replyingToThread.author; |
|
parentEventId = state.discussion.replyingToThread.id; |
|
parentKind = state.discussion.replyingToThread.kind || KIND.THREAD; |
|
parentPubkey = state.discussion.replyingToThread.pubkey || state.discussion.replyingToThread.author; |
|
} else { |
|
throw new Error('Must specify thread or comment to reply to'); |
|
} |
|
|
|
// Create kind 1111 comment event |
|
const commentEventTemplate: Omit<NostrEvent, 'sig' | 'id'> = { |
|
kind: KIND.COMMENT, |
|
pubkey: state.user.pubkeyHex, |
|
created_at: Math.floor(Date.now() / 1000), |
|
tags: [ |
|
['e', parentEventId, '', 'reply'], // Parent event |
|
['k', parentKind.toString()], // Parent kind |
|
['p', parentPubkey], // Parent pubkey |
|
['E', rootEventId], // Root event |
|
['K', rootKind.toString()], // Root kind |
|
['P', rootPubkey] // Root pubkey |
|
], |
|
content: state.forms.discussion.replyContent.trim() |
|
}; |
|
|
|
// Sign the event using NIP-07 |
|
const signedEvent = await signEventWithNIP07(commentEventTemplate); |
|
|
|
// Publish to all available relays |
|
const publishClient = new NostrClient(allRelays); |
|
const result = await publishClient.publishEvent(signedEvent, allRelays); |
|
|
|
if (result.failed.length > 0 && result.success.length === 0) { |
|
throw new Error('Failed to publish reply to all relays'); |
|
} |
|
|
|
// Save thread ID before clearing (for expanding after reload) |
|
const threadIdToExpand = state.discussion.replyingToThread?.id; |
|
|
|
// Clear form and close dialog |
|
state.forms.discussion.replyContent = ''; |
|
state.openDialog = null; |
|
state.discussion.replyingToThread = null; |
|
state.discussion.replyingToComment = null; |
|
|
|
// Reload state.discussions to show the new reply |
|
await loadDiscussions(); |
|
|
|
// Expand the thread if we were replying to a thread |
|
if (threadIdToExpand) { |
|
state.ui.expandedThreads.add(threadIdToExpand); |
|
state.ui.expandedThreads = new Set(state.ui.expandedThreads); // Trigger reactivity |
|
} |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to create reply'; |
|
console.error('Error creating reply:', err); |
|
} finally { |
|
state.creating.reply = false; |
|
} |
|
} |
|
|
|
function toggleThread(threadId: string) { |
|
if (state.ui.expandedThreads.has(threadId)) { |
|
state.ui.expandedThreads.delete(threadId); |
|
} else { |
|
state.ui.expandedThreads.add(threadId); |
|
} |
|
// Trigger reactivity |
|
state.ui.expandedThreads = new Set(state.ui.expandedThreads); |
|
} |
|
|
|
async function loadDocumentation() { |
|
if (state.loading.docs) return; |
|
// Reset documentation when reloading |
|
state.docs.html = null; |
|
state.docs.content = null; |
|
state.docs.kind = null; |
|
|
|
state.loading.docs = true; |
|
try { |
|
// Guard against SSR - $page store can only be accessed in component context |
|
if (typeof window === 'undefined') return; |
|
// Check if repo is private and user has access |
|
const data = $page.data as typeof state.pageData; |
|
if (repoIsPrivate) { |
|
// Check access via API |
|
const accessResponse = await fetch(`/api/repos/${state.npub}/${state.repo}/access`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (accessResponse.ok) { |
|
const accessData = await accessResponse.json(); |
|
if (!accessData.canView) { |
|
// User doesn't have access, don't load documentation |
|
state.loading.docs = false; |
|
return; |
|
} |
|
} else { |
|
// Access check failed, don't load documentation |
|
state.loading.docs = false; |
|
return; |
|
} |
|
} |
|
|
|
const decoded = nip19.decode(state.npub); |
|
if (decoded.type === 'npub') { |
|
const repoOwnerPubkey = decoded.data as string; |
|
const client = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
|
|
// First, get the repo announcement to find the documentation tag |
|
const announcementEvents = await client.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [repoOwnerPubkeyDerived], |
|
'#d': [state.repo], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (announcementEvents.length === 0) { |
|
state.loading.docs = false; |
|
return; |
|
} |
|
|
|
const announcement = announcementEvents[0]; |
|
|
|
// Look for documentation tag in the announcement |
|
const documentationTag = announcement.tags.find(t => t[0] === 'documentation'); |
|
|
|
state.docs.kind = null; |
|
|
|
if (documentationTag && documentationTag[1]) { |
|
// Parse the a-tag format: kind:pubkey:identifier |
|
const docAddress = documentationTag[1]; |
|
const parts = docAddress.split(':'); |
|
|
|
if (parts.length >= 3) { |
|
state.docs.kind = parseInt(parts[0]); |
|
const docPubkey = parts[1]; |
|
const docIdentifier = parts.slice(2).join(':'); // In case identifier contains ':' |
|
|
|
// Fetch the documentation event |
|
const docEvents = await client.fetchEvents([ |
|
{ |
|
kinds: [state.docs.kind], |
|
authors: [docPubkey], |
|
'#d': [docIdentifier], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (docEvents.length > 0) { |
|
state.docs.content = docEvents[0].content || null; |
|
} else { |
|
console.warn('Documentation event not found:', docAddress); |
|
state.docs.content = null; |
|
} |
|
} else { |
|
console.warn('Invalid documentation tag format:', docAddress); |
|
state.docs.content = null; |
|
} |
|
} else { |
|
// No documentation tag, try to use announcement content as fallback |
|
state.docs.content = announcement.content || null; |
|
// Announcement is kind 30617, not a doc kind, so keep state.docs.kind as null |
|
} |
|
|
|
// Render content based on kind: AsciiDoc for 30041 or 30818, Markdown otherwise |
|
if (state.docs.content) { |
|
// Check if we should use AsciiDoc parser (kinds 30041 or 30818) |
|
const useAsciiDoc = state.docs.kind === 30041 || state.docs.kind === 30818; |
|
|
|
if (useAsciiDoc) { |
|
// Use AsciiDoc parser |
|
const Asciidoctor = (await import('@asciidoctor/core')).default; |
|
const asciidoctor = Asciidoctor(); |
|
const converted = asciidoctor.convert(state.docs.content, { |
|
safe: 'safe', |
|
attributes: { |
|
'source-highlighter': 'highlight.js' |
|
} |
|
}); |
|
// Convert to string if it's a Document object |
|
state.docs.html = typeof converted === 'string' ? converted : String(converted); |
|
} else { |
|
// Use Markdown parser |
|
const MarkdownIt = (await import('markdown-it')).default; |
|
const hljsModule = await import('highlight.js'); |
|
const hljs = hljsModule.default || hljsModule; |
|
|
|
const md = new MarkdownIt({ |
|
highlight: function (str: string, lang: string): string { |
|
if (lang && hljs.getLanguage(lang)) { |
|
try { |
|
return hljs.highlight(str, { language: lang }).value; |
|
} catch (__) {} |
|
} |
|
return ''; |
|
} |
|
}); |
|
|
|
state.docs.html = md.render(state.docs.content); |
|
} |
|
} else { |
|
// No content found, clear HTML |
|
state.docs.html = null; |
|
} |
|
} |
|
} catch (err) { |
|
console.error('Error state.loading.main documentation:', err); |
|
state.docs.html = null; |
|
} finally { |
|
state.loading.docs = false; |
|
} |
|
} |
|
|
|
async function loadRepoImages() { |
|
try { |
|
// 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) |
|
// Initialize repo images effect |
|
useRepoImagesEffect(state, () => $page.data); |
|
|
|
onMount(async () => { |
|
// Initialize bookmarks service |
|
bookmarksService = new BookmarksService(DEFAULT_NOSTR_SEARCH_RELAYS); |
|
|
|
// Load clone URL reachability status |
|
loadCloneUrlReachability().catch(err => console.warn('Failed to load clone URL reachability:', err)); |
|
|
|
// Decode npub to get repo owner pubkey for bookmark address |
|
try { |
|
const decoded = nip19.decode(state.npub); |
|
if (decoded.type === 'npub') { |
|
state.metadata.ownerPubkey = decoded.data as string; |
|
state.metadata.address = `${KIND.REPO_ANNOUNCEMENT}:${state.metadata.ownerPubkey}:${state.repo}`; |
|
} |
|
} catch (err) { |
|
console.warn('Failed to decode npub for bookmark address:', err); |
|
} |
|
|
|
// Close menu when clicking outside (handled by RepoHeaderEnhanced component) |
|
clickOutsideHandler = (event: MouseEvent) => { |
|
if (!state.isMounted) return; |
|
try { |
|
const target = event.target as HTMLElement; |
|
if (state.ui.showRepoMenu && !target.closest('.repo-header') && state.isMounted) { |
|
state.ui.showRepoMenu = false; |
|
} |
|
} catch (err) { |
|
// Ignore errors during destruction |
|
if (state.isMounted) { |
|
console.warn('Click outside handler error:', err); |
|
} |
|
} |
|
}; |
|
|
|
document.addEventListener('click', clickOutsideHandler); |
|
|
|
await loadBranches(); |
|
if (!state.isMounted) return; |
|
|
|
// Skip other API calls if repository doesn't exist |
|
if (state.repoNotFound) { |
|
state.loading.main = false; |
|
return; |
|
} |
|
|
|
// loadBranches() already handles setting state.git.currentBranch to the default branch |
|
await loadFiles(); |
|
if (!state.isMounted) return; |
|
|
|
await checkAuth(); |
|
if (!state.isMounted) return; |
|
|
|
await loadTags(); |
|
if (!state.isMounted) return; |
|
|
|
await checkMaintainerStatus(); |
|
if (!state.isMounted) return; |
|
|
|
await loadBookmarkStatus(); |
|
if (!state.isMounted) return; |
|
|
|
await loadAllMaintainers(); |
|
if (!state.isMounted) return; |
|
|
|
// Check clone status (needed to disable write operations) |
|
await checkCloneStatus(); |
|
if (!state.isMounted) return; |
|
|
|
await checkVerification(); |
|
if (!state.isMounted) return; |
|
|
|
await loadReadme(); |
|
if (!state.isMounted) return; |
|
|
|
await loadForkInfo(); |
|
if (!state.isMounted) return; |
|
|
|
await loadRepoImages(); |
|
if (!state.isMounted) return; |
|
|
|
// Load clone URL reachability status |
|
loadCloneUrlReachability().catch(err => { |
|
if (state.isMounted) console.warn('Failed to load clone URL reachability:', err); |
|
}); |
|
|
|
// Set up auto-save if enabled |
|
setupAutoSave().catch(err => { |
|
if (state.isMounted) console.warn('Failed to setup auto-save:', err); |
|
}); |
|
}); |
|
|
|
// Cleanup on destroy - only register on client side to prevent SSR errors |
|
if (typeof window !== 'undefined') { |
|
onDestroy(() => { |
|
try { |
|
// Mark component as unmounted first to prevent any state updates |
|
state.isMounted = false; |
|
|
|
// Clean up intervals and timeouts |
|
if (autoSaveInterval.value) { |
|
clearInterval(autoSaveInterval.value); |
|
autoSaveInterval.value = null; |
|
} |
|
|
|
if (readmeAutoLoadTimeout) { |
|
clearTimeout(readmeAutoLoadTimeout); |
|
readmeAutoLoadTimeout = null; |
|
} |
|
|
|
// Clean up event listeners |
|
if (clickOutsideHandler && typeof document !== 'undefined') { |
|
document.removeEventListener('click', clickOutsideHandler); |
|
clickOutsideHandler = null; |
|
} |
|
} catch (err) { |
|
// Ignore all errors during cleanup - component is being destroyed anyway |
|
} |
|
}); |
|
} |
|
|
|
async function checkAuth() { |
|
// Check userStore first |
|
const currentUser = $userStore; |
|
if (currentUser.userPubkey && currentUser.userPubkeyHex) { |
|
state.user.pubkey = currentUser.userPubkey; |
|
state.user.pubkeyHex = currentUser.userPubkeyHex; |
|
// Recheck maintainer status and bookmark status after auth |
|
await checkMaintainerStatus(); |
|
await loadBookmarkStatus(); |
|
return; |
|
} |
|
|
|
// Fallback: try NIP-07 if store doesn't have it |
|
try { |
|
if (isNIP07Available()) { |
|
const pubkey = await getPublicKeyWithNIP07(); |
|
state.user.pubkey = pubkey; |
|
// Convert to hex if needed |
|
if (/^[0-9a-f]{64}$/i.test(pubkey)) { |
|
state.user.pubkeyHex = pubkey.toLowerCase(); |
|
} else { |
|
try { |
|
const decoded = nip19.decode(pubkey); |
|
if (decoded.type === 'npub') { |
|
state.user.pubkeyHex = decoded.data as string; |
|
} |
|
} catch { |
|
state.user.pubkeyHex = pubkey; |
|
} |
|
} |
|
// Recheck maintainer status and bookmark status after auth |
|
await checkMaintainerStatus(); |
|
await loadBookmarkStatus(); |
|
} |
|
} catch (err) { |
|
console.log('NIP-07 not available or user not connected'); |
|
state.user.pubkey = null; |
|
state.user.pubkeyHex = null; |
|
} |
|
} |
|
|
|
async function login() { |
|
// Check userStore first |
|
const currentUser = $userStore; |
|
if (currentUser.userPubkey && currentUser.userPubkeyHex) { |
|
state.user.pubkey = currentUser.userPubkey; |
|
state.user.pubkeyHex = currentUser.userPubkeyHex; |
|
// Re-check maintainer status and bookmark status after login |
|
await checkMaintainerStatus(); |
|
await loadBookmarkStatus(); |
|
// Check for pending transfers (user is already logged in via store) |
|
if (state.user.pubkeyHex) { |
|
try { |
|
const response = await fetch('/api/transfers/pending', { |
|
headers: { |
|
'X-User-Pubkey': state.user.pubkeyHex |
|
} |
|
}); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
if (data.pendingTransfers && data.pendingTransfers.length > 0) { |
|
window.dispatchEvent(new CustomEvent('pendingTransfers', { |
|
detail: { transfers: data.pendingTransfers } |
|
})); |
|
} |
|
} |
|
} catch (err) { |
|
console.error('Failed to check for pending transfers:', err); |
|
} |
|
} |
|
return; |
|
} |
|
|
|
// Fallback: try NIP-07 - need to check write access and update store |
|
try { |
|
if (!isNIP07Available()) { |
|
alert('NIP-07 extension not found. Please install a Nostr extension like Alby or nos2x.'); |
|
return; |
|
} |
|
const pubkey = await getPublicKeyWithNIP07(); |
|
let pubkeyHex: string; |
|
// Convert to hex if needed |
|
if (/^[0-9a-f]{64}$/i.test(pubkey)) { |
|
pubkeyHex = pubkey.toLowerCase(); |
|
state.user.pubkey = pubkey; |
|
} else { |
|
try { |
|
const decoded = nip19.decode(pubkey); |
|
if (decoded.type === 'npub') { |
|
pubkeyHex = decoded.data as string; |
|
state.user.pubkey = pubkey; |
|
} else { |
|
throw new Error('Invalid pubkey format'); |
|
} |
|
} catch { |
|
state.error = 'Invalid public key format'; |
|
return; |
|
} |
|
} |
|
|
|
state.user.pubkeyHex = pubkeyHex; |
|
|
|
// Check write access and update user store |
|
const { determineUserLevel } = await import('$lib/services/nostr/user-level-service.js'); |
|
const levelResult = await determineUserLevel(state.user.pubkey, state.user.pubkeyHex); |
|
|
|
// Update user store with write access level |
|
userStore.setUser( |
|
levelResult.userPubkey, |
|
levelResult.userPubkeyHex, |
|
levelResult.level, |
|
levelResult.error || null |
|
); |
|
|
|
// Update activity tracking |
|
const { updateActivity } = await import('$lib/services/activity-tracker.js'); |
|
updateActivity(); |
|
|
|
// Check for pending transfer events |
|
if (state.user.pubkeyHex) { |
|
try { |
|
const response = await fetch('/api/transfers/pending', { |
|
headers: { |
|
'X-User-Pubkey': state.user.pubkeyHex |
|
} |
|
}); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
if (data.pendingTransfers && data.pendingTransfers.length > 0) { |
|
window.dispatchEvent(new CustomEvent('pendingTransfers', { |
|
detail: { transfers: data.pendingTransfers } |
|
})); |
|
} |
|
} |
|
} catch (err) { |
|
console.error('Failed to check for pending transfers:', err); |
|
} |
|
} |
|
|
|
// Re-check maintainer status and bookmark status after login |
|
await checkMaintainerStatus(); |
|
await loadBookmarkStatus(); |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to connect'; |
|
console.error('Login error:', err); |
|
} |
|
} |
|
|
|
|
|
async function loadBookmarkStatus() { |
|
if (!state.user.pubkey || !state.metadata.address || !bookmarksService) return; |
|
|
|
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() { |
|
if (!state.user.pubkey || !state.metadata.address || !bookmarksService || state.loading.bookmark) return; |
|
|
|
state.loading.bookmark = true; |
|
try { |
|
// Get user's relays for publishing |
|
const { getUserRelays } = await import('$lib/services/nostr/user-relays.js'); |
|
const allSearchRelays = [...new Set([...DEFAULT_NOSTR_SEARCH_RELAYS, ...DEFAULT_NOSTR_RELAYS])]; |
|
const fullRelayClient = new NostrClient(allSearchRelays); |
|
const { outbox, inbox } = await getUserRelays(state.user.pubkey, fullRelayClient); |
|
const userRelays = combineRelays(outbox.length > 0 ? outbox : inbox, DEFAULT_NOSTR_RELAYS); |
|
|
|
let success = false; |
|
if (state.bookmark.isBookmarked) { |
|
success = await bookmarksService.removeBookmark(state.user.pubkey, state.metadata.address, userRelays); |
|
} else { |
|
success = await bookmarksService.addBookmark(state.user.pubkey, state.metadata.address, userRelays); |
|
} |
|
|
|
if (success) { |
|
state.bookmark.isBookmarked = !state.bookmark.isBookmarked; |
|
} else { |
|
alert(`Failed to ${state.bookmark.isBookmarked ? 'remove' : 'add'} bookmark. Please try again.`); |
|
} |
|
} catch (err) { |
|
console.error('Failed to toggle bookmark:', err); |
|
alert(`Failed to ${state.bookmark.isBookmarked ? 'remove' : 'add'} bookmark: ${String(err)}`); |
|
} finally { |
|
state.loading.bookmark = false; |
|
} |
|
} |
|
|
|
async function copyEventId() { |
|
if (!state.metadata.address || !repoOwnerPubkeyDerived) { |
|
alert('Repository address not available'); |
|
return; |
|
} |
|
|
|
try { |
|
// Parse the repo address: kind:pubkey:identifier |
|
const parts = state.metadata.address.split(':'); |
|
if (parts.length < 3) { |
|
throw new Error('Invalid repository address format'); |
|
} |
|
|
|
const kind = parseInt(parts[0]); |
|
const pubkey = parts[1]; |
|
const identifier = parts.slice(2).join(':'); // In case identifier contains ':' |
|
|
|
// Generate naddr synchronously |
|
const naddr = nip19.naddrEncode({ |
|
kind, |
|
pubkey, |
|
identifier, |
|
relays: [] // Optional: could include relays if available |
|
}); |
|
|
|
// Copy naddr to clipboard immediately (while we have user activation) |
|
try { |
|
await navigator.clipboard.writeText(naddr); |
|
} catch (clipboardErr) { |
|
// Fallback: use execCommand for older browsers or if clipboard API fails |
|
const textArea = document.createElement('textarea'); |
|
textArea.value = naddr; |
|
textArea.style.position = 'fixed'; |
|
textArea.style.opacity = '0'; |
|
document.body.appendChild(textArea); |
|
textArea.select(); |
|
try { |
|
document.execCommand('copy'); |
|
textArea.remove(); |
|
} catch (execErr) { |
|
textArea.remove(); |
|
throw new Error('Failed to copy to clipboard. Please copy manually: ' + naddr); |
|
} |
|
} |
|
|
|
// Show message with naddr |
|
alert(`Event ID copied to clipboard!\n\nnaddr (repository address):\n${naddr}`); |
|
} catch (err) { |
|
console.error('Failed to copy event ID:', err); |
|
alert(`Failed to copy event ID: ${err instanceof Error ? err.message : String(err)}`); |
|
} |
|
} |
|
|
|
async function checkMaintainerStatus() { |
|
if (state.repoNotFound || !state.user.pubkey) { |
|
state.maintainers.isMaintainer = false; |
|
return; |
|
} |
|
|
|
state.loading.maintainerStatus = true; |
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/maintainers?userPubkey=${encodeURIComponent(state.user.pubkey)}`); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
state.maintainers.isMaintainer = data.state.maintainers.isMaintainer || false; |
|
} |
|
} catch (err) { |
|
console.error('Failed to check maintainer status:', err); |
|
state.maintainers.isMaintainer = false; |
|
} finally { |
|
state.loading.maintainerStatus = false; |
|
} |
|
} |
|
|
|
async function loadAllMaintainers() { |
|
if (state.repoNotFound || state.loading.maintainers) return; |
|
|
|
state.loading.maintainers = true; |
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/maintainers`); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
const owner = data.owner; |
|
const maintainers = data.maintainers || []; |
|
|
|
// Create array with all maintainers, marking the owner |
|
const allMaintainersList: Array<{ pubkey: string; isOwner: boolean }> = []; |
|
const seen = new Set<string>(); |
|
const ownerLower = owner?.toLowerCase(); |
|
|
|
// Process all maintainers, marking owner and deduplicating |
|
for (const maintainer of maintainers) { |
|
const maintainerLower = maintainer.toLowerCase(); |
|
|
|
// Skip if we've already added this pubkey (case-insensitive check) |
|
if (seen.has(maintainerLower)) { |
|
continue; |
|
} |
|
|
|
// Mark as seen |
|
seen.add(maintainerLower); |
|
|
|
// Determine if this is the owner |
|
const isOwner = ownerLower && maintainerLower === ownerLower; |
|
|
|
// Add to list |
|
allMaintainersList.push({ |
|
pubkey: maintainer, |
|
isOwner: !!isOwner |
|
}); |
|
} |
|
|
|
// Sort: owner first, then other maintainers |
|
allMaintainersList.sort((a, b) => { |
|
if (a.isOwner && !b.isOwner) return -1; |
|
if (!a.isOwner && b.isOwner) return 1; |
|
return 0; |
|
}); |
|
|
|
// Ensure owner is always included (in case they weren't in maintainers list) |
|
if (owner && !seen.has(ownerLower)) { |
|
allMaintainersList.unshift({ pubkey: owner, isOwner: true }); |
|
} |
|
|
|
state.maintainers.all = allMaintainersList; |
|
} |
|
} catch (err) { |
|
console.error('Failed to load maintainers:', err); |
|
state.maintainers.loaded = false; // Reset flag on state.error |
|
// Fallback to pageData if available |
|
if (repoOwnerPubkeyDerived) { |
|
state.maintainers.all = [{ pubkey: repoOwnerPubkeyDerived, isOwner: true }]; |
|
if (repoMaintainers) { |
|
for (const maintainer of repoMaintainers) { |
|
if (maintainer.toLowerCase() !== repoOwnerPubkeyDerived.toLowerCase()) { |
|
state.maintainers.all.push({ pubkey: maintainer, isOwner: false }); |
|
} |
|
} |
|
} |
|
} |
|
} finally { |
|
state.loading.maintainers = false; |
|
} |
|
} |
|
|
|
async function checkVerification() { |
|
if (state.repoNotFound) return; |
|
state.loading.verification = true; |
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/verify`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
console.log('[Verification] Response:', data); |
|
state.verification.status = data; |
|
} else { |
|
console.warn('[Verification] Response not OK:', response.status, response.statusText); |
|
state.verification.status = { verified: false, error: `Verification check failed: ${response.status}` }; |
|
} |
|
} catch (err) { |
|
console.error('[Verification] Failed to check verification:', err); |
|
state.verification.status = { verified: false, error: 'Failed to check verification' }; |
|
} finally { |
|
state.loading.verification = false; |
|
console.log('[Verification] Status after check:', state.verification.status); |
|
} |
|
} |
|
|
|
async function generateAnnouncementFileForRepo() { |
|
if (!repoOwnerPubkeyDerived || !state.user.pubkeyHex) { |
|
state.error = 'Unable to generate announcement file: missing repository or user information'; |
|
return; |
|
} |
|
|
|
try { |
|
// Fetch the repository announcement event |
|
const nostrClient = new NostrClient([...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]); |
|
const events = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [repoOwnerPubkeyDerived], |
|
'#d': [state.repo], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length === 0) { |
|
state.error = 'Repository announcement not found. Please ensure the repository is registered on Nostr.'; |
|
return; |
|
} |
|
|
|
const announcement = events[0] as NostrEvent; |
|
// Generate announcement event JSON (for download/reference) |
|
state.verification.fileContent = JSON.stringify(announcement, null, 2) + '\n'; |
|
state.openDialog = 'verification'; |
|
} catch (err) { |
|
console.error('Failed to generate announcement file:', err); |
|
state.error = `Failed to generate announcement file: ${err instanceof Error ? err.message : String(err)}`; |
|
} |
|
} |
|
|
|
function copyVerificationToClipboard() { |
|
if (!state.verification.fileContent) return; |
|
|
|
navigator.clipboard.writeText(state.verification.fileContent).then(() => { |
|
alert('Verification file content copied to clipboard!'); |
|
}).catch((err) => { |
|
console.error('Failed to copy:', err); |
|
alert('Failed to copy to clipboard. Please select and copy manually.'); |
|
}); |
|
} |
|
|
|
// Verify clone URL by committing announcement |
|
async function verifyCloneUrl() { |
|
if (!state.verification.selectedCloneUrl || !state.user.pubkey || !state.user.pubkeyHex) { |
|
state.error = 'Unable to verify: missing information'; |
|
return; |
|
} |
|
|
|
if (!state.maintainers.isMaintainer && state.user.pubkeyHex !== repoOwnerPubkeyDerived) { |
|
state.error = 'Only repository owners and maintainers can verify clone URLs'; |
|
return; |
|
} |
|
|
|
// selectedCloneUrl is already set when user selects it |
|
state.error = null; |
|
|
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/verify`, { |
|
method: 'POST', |
|
headers: buildApiHeaders() |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorText = await response.text(); |
|
throw new Error(errorText || `Failed to verify: ${response.statusText}`); |
|
} |
|
|
|
const data = await response.json(); |
|
|
|
// Close dialog |
|
state.openDialog = null; |
|
state.verification.selectedCloneUrl = null; |
|
|
|
// Reload verification status after a short delay |
|
setTimeout(() => { |
|
checkVerification().catch((err: unknown) => { |
|
console.warn('Failed to reload verification status:', err); |
|
}); |
|
}, 1000); |
|
|
|
// Show success message |
|
alert(data.message || 'Repository verification initiated. The verification status will update shortly.'); |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to verify repository'; |
|
console.error('Error verifying clone URL:', err); |
|
} finally { |
|
state.verification.selectedCloneUrl = null; |
|
} |
|
} |
|
|
|
async function deleteAnnouncement() { |
|
if (!state.user.pubkey || !state.user.pubkeyHex) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
if (!repoOwnerPubkeyDerived || state.user.pubkeyHex !== repoOwnerPubkeyDerived) { |
|
alert('Only the repository owner can delete the announcement'); |
|
return; |
|
} |
|
|
|
// First confirmation |
|
if (!confirm('WARNING: Are you sure you want to delete this repository announcement?\n\nThis will permanently delete the repository announcement from Nostr relays. This action CANNOT be undone.\n\nClick OK to continue, or Cancel to abort.')) { |
|
return; |
|
} |
|
|
|
// Second confirmation for critical operation |
|
if (!confirm('FINAL CONFIRMATION: This will permanently delete the repository announcement.\n\nAre you absolutely certain you want to proceed?\n\nThis action CANNOT be undone.')) { |
|
return; |
|
} |
|
|
|
state.creating.announcement = true; |
|
state.error = null; |
|
|
|
try { |
|
// Fetch the repository announcement to get its event ID |
|
const nostrClient = new NostrClient([...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]); |
|
const events = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [repoOwnerPubkeyDerived], |
|
'#d': [state.repo], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length === 0) { |
|
throw new Error('Repository announcement not found'); |
|
} |
|
|
|
const announcement = events[0]; |
|
announcementEventId = announcement.id; |
|
|
|
// Get user relays |
|
const { outbox } = await getUserRelays(state.user.pubkeyHex, nostrClient); |
|
const combinedRelays = combineRelays(outbox); |
|
|
|
// Create deletion request (NIP-09) |
|
const deletionRequestTemplate: Omit<NostrEvent, 'sig' | 'id'> = { |
|
kind: KIND.DELETION_REQUEST, |
|
pubkey: state.user.pubkeyHex, |
|
created_at: Math.floor(Date.now() / 1000), |
|
content: `Requesting deletion of repository announcement for ${state.repo}`, |
|
tags: [ |
|
['e', announcement.id], // Reference to the announcement event |
|
['a', `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkeyDerived}:${state.repo}`], // Repository address |
|
['k', KIND.REPO_ANNOUNCEMENT.toString()] // Kind of event being deleted |
|
] |
|
}; |
|
|
|
// Sign with NIP-07 |
|
const signedDeletionRequest = await signEventWithNIP07(deletionRequestTemplate); |
|
|
|
// Publish to relays |
|
const publishResult = await nostrClient.publishEvent(signedDeletionRequest, combinedRelays); |
|
|
|
if (publishResult.success.length > 0) { |
|
alert(`Deletion request published successfully to ${publishResult.success.length} relay(s).`); |
|
} else { |
|
throw new Error(`Failed to publish deletion request to any relay. Errors: ${publishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}`); |
|
} |
|
} catch (err) { |
|
console.error('Failed to delete announcement:', err); |
|
state.error = err instanceof Error ? err.message : 'Failed to send deletion request'; |
|
alert(state.error); |
|
} finally { |
|
state.creating.announcement = false; |
|
} |
|
} |
|
|
|
function downloadVerificationFile() { |
|
if (!state.verification.fileContent) return; |
|
|
|
const blob = new Blob([state.verification.fileContent], { type: 'text/plain' }); |
|
const url = URL.createObjectURL(blob); |
|
const a = document.createElement('a'); |
|
a.href = url; |
|
a.download = 'announcement-event.json'; |
|
document.body.appendChild(a); |
|
a.click(); |
|
document.body.removeChild(a); |
|
URL.revokeObjectURL(url); |
|
} |
|
|
|
// buildApiHeaders is now imported from utils/api-client.ts - using it directly |
|
|
|
// Safe wrapper functions for SSR - use function declarations that check at call time |
|
// This ensures they're always defined and never null, even during SSR |
|
function safeCopyCloneUrl() { |
|
if (typeof window === 'undefined') return Promise.resolve(); |
|
try { |
|
return copyCloneUrl(); |
|
} catch (err) { |
|
console.warn('Error in copyCloneUrl:', err); |
|
return Promise.resolve(); |
|
} |
|
} |
|
|
|
function safeDeleteBranch(branchName: string) { |
|
if (typeof window === 'undefined') return Promise.resolve(); |
|
try { |
|
return deleteBranch(branchName); |
|
} catch (err) { |
|
console.warn('Error in deleteBranch:', err); |
|
return Promise.resolve(); |
|
} |
|
} |
|
|
|
function safeToggleBookmark() { |
|
if (typeof window === 'undefined') return Promise.resolve(); |
|
try { |
|
return toggleBookmark(); |
|
} catch (err) { |
|
console.warn('Error in toggleBookmark:', err); |
|
return Promise.resolve(); |
|
} |
|
} |
|
|
|
function safeForkRepository() { |
|
if (typeof window === 'undefined') return Promise.resolve(); |
|
try { |
|
return forkRepository(); |
|
} catch (err) { |
|
console.warn('Error in forkRepository:', err); |
|
return Promise.resolve(); |
|
} |
|
} |
|
|
|
function safeCloneRepository() { |
|
if (typeof window === 'undefined') return Promise.resolve(); |
|
try { |
|
return cloneRepository(); |
|
} catch (err) { |
|
console.warn('Error in cloneRepository:', err); |
|
return Promise.resolve(); |
|
} |
|
} |
|
|
|
function safeHandleBranchChange(branch: string) { |
|
if (typeof window === 'undefined') return; |
|
try { |
|
handleBranchChangeDirect(branch); |
|
} catch (err) { |
|
console.warn('Error in handleBranchChangeDirect:', err); |
|
} |
|
} |
|
|
|
// Download function - now using extracted utility |
|
async function downloadRepository(ref?: string, filename?: string): Promise<void> { |
|
await downloadRepoUtil({ |
|
npub: state.npub, |
|
repo: state.repo, |
|
ref, |
|
filename |
|
}); |
|
} |
|
|
|
async function loadBranches() { |
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/branches`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
state.git.branches = await response.json(); |
|
|
|
// If repo is not cloned but we got state.git.branches, API fallback is available |
|
if (state.clone.isCloned === false && state.git.branches.length > 0) { |
|
state.clone.apiFallbackAvailable = true; |
|
} |
|
if (state.git.branches.length > 0) { |
|
// Branches can be an array of objects with .name property or array of strings |
|
const branchNames = state.git.branches.map((b: any) => typeof b === 'string' ? b : b.name); |
|
|
|
// Fetch the actual default branch from the API |
|
try { |
|
const defaultBranchResponse = await fetch(`/api/repos/${state.npub}/${state.repo}/default-branch`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (defaultBranchResponse.ok) { |
|
const defaultBranchData = await defaultBranchResponse.json(); |
|
state.git.defaultBranch = defaultBranchData.state.git.defaultBranch || defaultBranchData.branch || null; |
|
} |
|
} catch (err) { |
|
console.warn('Failed to fetch default branch, using fallback logic:', err); |
|
} |
|
|
|
// Fallback: Detect default branch: prefer master, then main, then first branch |
|
if (!state.git.defaultBranch) { |
|
if (branchNames.includes('master')) { |
|
state.git.defaultBranch = 'master'; |
|
} else if (branchNames.includes('main')) { |
|
state.git.defaultBranch = 'main'; |
|
} else { |
|
state.git.defaultBranch = branchNames[0]; |
|
} |
|
} |
|
|
|
// Only update state.git.currentBranch if it's not set or if the current branch doesn't exist |
|
// Also validate that state.git.currentBranch doesn't contain invalid characters (like '#') |
|
if (!state.git.currentBranch || |
|
typeof state.git.currentBranch !== 'string' || |
|
state.git.currentBranch.includes('#') || |
|
!branchNames.includes(state.git.currentBranch)) { |
|
state.git.currentBranch = state.git.defaultBranch; |
|
} |
|
} else { |
|
// No state.git.branches exist - set state.git.currentBranch to null to show "no state.git.branches" in header |
|
state.git.currentBranch = null; |
|
} |
|
} else if (response.status === 404) { |
|
// Check if this is a "not cloned" state.error - API fallback might be available |
|
const errorText = await response.text().catch(() => ''); |
|
if (errorText.includes('not cloned locally')) { |
|
// Repository is not cloned - check if API fallback might be available |
|
if (repoCloneUrls && repoCloneUrls.length > 0) { |
|
// We have clone URLs, so API fallback might work - mark as unknown for now |
|
// It will be set to true if a subsequent request succeeds |
|
state.clone.apiFallbackAvailable = null; |
|
// Don't set state.repoNotFound or state.error yet - allow API fallback to be attempted |
|
} else { |
|
// No clone URLs, API fallback won't work |
|
state.repoNotFound = true; |
|
state.clone.apiFallbackAvailable = false; |
|
state.error = errorText || `Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`; |
|
} |
|
} else { |
|
// Generic 404 - repository doesn't exist |
|
state.repoNotFound = true; |
|
state.clone.apiFallbackAvailable = false; |
|
state.error = `Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`; |
|
} |
|
} else if (response.status === 403) { |
|
// Access denied - don't set state.repoNotFound, allow retry after login |
|
const errorText = await response.text().catch(() => response.statusText); |
|
state.error = `Access denied: ${errorText}. You may need to log in or you may not have permission to view this repository.`; |
|
console.warn('[Branches] Access denied, user may need to log in'); |
|
} |
|
} catch (err) { |
|
console.error('Failed to load branches:', err); |
|
} |
|
} |
|
|
|
async function loadFiles(path: string = '') { |
|
// Skip if repository doesn't exist |
|
if (state.repoNotFound) return; |
|
|
|
state.loading.main = true; |
|
state.error = null; |
|
try { |
|
// Validate and get a valid branch name |
|
let branchName: string; |
|
if (typeof state.git.currentBranch === 'string' && state.git.currentBranch.trim() !== '' && !state.git.currentBranch.includes('#')) { |
|
const branchNames = state.git.branches.map((b: any) => typeof b === 'string' ? b : b.name); |
|
if (branchNames.includes(state.git.currentBranch)) { |
|
branchName = state.git.currentBranch; |
|
} else { |
|
branchName = state.git.defaultBranch || (state.git.branches.length > 0 |
|
? (typeof state.git.branches[0] === 'string' ? state.git.branches[0] : state.git.branches[0].name) |
|
: 'HEAD'); |
|
} |
|
} else { |
|
branchName = state.git.defaultBranch || (state.git.branches.length > 0 |
|
? (typeof state.git.branches[0] === 'string' ? state.git.branches[0] : state.git.branches[0].name) |
|
: 'HEAD'); |
|
} |
|
|
|
const url = `/api/repos/${state.npub}/${state.repo}/tree?ref=${encodeURIComponent(branchName)}&path=${encodeURIComponent(path)}`; |
|
const response = await fetch(url, { |
|
headers: buildApiHeaders() |
|
}); |
|
|
|
if (!response.ok) { |
|
if (response.status === 404) { |
|
// Check if this is a "not cloned" state.error - API fallback might be available |
|
const errorText = await response.text().catch(() => ''); |
|
if (errorText.includes('not cloned locally')) { |
|
// Repository is not cloned - check if API fallback might be available |
|
if (repoCloneUrls && repoCloneUrls.length > 0) { |
|
// We have clone URLs, so API fallback might work - mark as unknown for now |
|
// It will be set to true if a subsequent request succeeds |
|
state.clone.apiFallbackAvailable = null; |
|
// Don't set state.repoNotFound - allow API fallback to be attempted |
|
} else { |
|
// No clone URLs, API fallback won't work |
|
state.repoNotFound = true; |
|
state.clone.apiFallbackAvailable = false; |
|
} |
|
// Throw state.error but use the actual state.error text from the API |
|
throw new Error(errorText || 'Repository not found. This repository exists in Nostr but hasn\'t been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.'); |
|
} else { |
|
// Generic 404 - repository doesn't exist |
|
state.repoNotFound = true; |
|
state.clone.apiFallbackAvailable = false; |
|
throw new Error(`Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`); |
|
} |
|
} else if (response.status === 403) { |
|
// 403 means access denied - don't set state.repoNotFound, just show state.error |
|
// This allows retry after login |
|
const accessDeniedError = new Error(`Access denied: ${response.statusText}. You may need to log in or you may not have permission to view this repository.`); |
|
// Log as info since this is normal client behavior (not logged in or no access) |
|
console.info('Access denied (normal behavior):', accessDeniedError.message); |
|
throw accessDeniedError; |
|
} |
|
throw new Error(`Failed to load files: ${response.statusText}`); |
|
} |
|
|
|
state.files.list = await response.json(); |
|
state.files.currentPath = path; |
|
|
|
// If repo is not cloned but we got state.files.list, API fallback is available |
|
if (state.clone.isCloned === false && state.files.list.length > 0) { |
|
state.clone.apiFallbackAvailable = true; |
|
} |
|
|
|
// Auto-load README if we're in the root directory and no file is currently selected |
|
// Only attempt once per path to prevent loops |
|
if (path === '' && !state.files.currentFile && !state.metadata.readmeAutoLoadAttempted) { |
|
const readmeFile = findReadmeFile(state.files.list); |
|
if (readmeFile) { |
|
state.metadata.readmeAutoLoadAttempted = true; |
|
// Clear any existing timeout |
|
if (readmeAutoLoadTimeout) { |
|
clearTimeout(readmeAutoLoadTimeout); |
|
} |
|
// Small delay to ensure UI is ready |
|
readmeAutoLoadTimeout = setTimeout(() => { |
|
loadFile(readmeFile.path).catch(err => { |
|
// If load fails (e.g., 429 rate limit), reset the flag after a delay |
|
// so we can retry later, but not immediately |
|
if (err instanceof Error && err.message.includes('Too Many Requests')) { |
|
console.warn('[README] Rate limited, will retry later'); |
|
setTimeout(() => { |
|
state.metadata.readmeAutoLoadAttempted = false; |
|
}, 5000); // Retry after 5 seconds |
|
} else { |
|
// For other errors, reset immediately |
|
state.metadata.readmeAutoLoadAttempted = false; |
|
} |
|
}); |
|
readmeAutoLoadTimeout = null; |
|
}, 100); |
|
} |
|
} else if (path !== '' || state.files.currentFile) { |
|
// Reset flag when navigating away from root or when a file is selected |
|
state.metadata.readmeAutoLoadAttempted = false; |
|
if (readmeAutoLoadTimeout) { |
|
clearTimeout(readmeAutoLoadTimeout); |
|
readmeAutoLoadTimeout = null; |
|
} |
|
} |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to load state.files.list'; |
|
// Only log as state.error if it's not a 403 (access denied), which is normal behavior |
|
if (err instanceof Error && err.message.includes('Access denied')) { |
|
// Already logged as info above, don't log again |
|
} else { |
|
console.error('Error loading files:', err); |
|
} |
|
} finally { |
|
state.loading.main = false; |
|
} |
|
} |
|
|
|
// Helper function to find README file in file list |
|
function findReadmeFile(fileList: Array<{ name: string; path: string; type: 'file' | 'directory' }>): { name: string; path: string; type: 'file' | 'directory' } | null { |
|
// Priority order for README state.files.list (most common first) |
|
const readmeExtensions = ['md', 'markdown', 'txt', 'adoc', 'asciidoc', 'rst', 'org']; |
|
|
|
// First, try to find README with extensions (prioritized order) |
|
for (const ext of readmeExtensions) { |
|
const readmeFile = fileList.find(file => |
|
file.type === 'file' && |
|
file.name.toLowerCase() === `readme.${ext}` |
|
); |
|
if (readmeFile) { |
|
return readmeFile; |
|
} |
|
} |
|
|
|
// Then check for README without extension |
|
const readmeNoExt = fileList.find(file => |
|
file.type === 'file' && |
|
file.name.toLowerCase() === 'readme' |
|
); |
|
if (readmeNoExt) { |
|
return readmeNoExt; |
|
} |
|
|
|
// Finally, check for any file starting with "readme." (case-insensitive) |
|
const readmeAny = fileList.find(file => |
|
file.type === 'file' && |
|
file.name.toLowerCase().startsWith('readme.') |
|
); |
|
if (readmeAny) { |
|
return readmeAny; |
|
} |
|
|
|
return null; |
|
} |
|
|
|
async function loadFile(filePath: string) { |
|
state.loading.main = true; |
|
state.error = null; |
|
try { |
|
// Ensure state.git.currentBranch is a string (branch name), not an object |
|
// If state.git.currentBranch is not set, use the first available branch or 'master' as fallback |
|
let branchName: string; |
|
|
|
if (typeof state.git.currentBranch === 'string' && state.git.currentBranch.trim() !== '') { |
|
// Validate that state.git.currentBranch is actually a valid branch name |
|
// Check if it exists in the state.git.branches list |
|
const branchNames = state.git.branches.map((b: any) => typeof b === 'string' ? b : b.name); |
|
if (branchNames.includes(state.git.currentBranch)) { |
|
branchName = state.git.currentBranch; |
|
} else { |
|
// state.git.currentBranch is set but not in state.git.branches list, use state.git.defaultBranch or fallback |
|
branchName = state.git.defaultBranch || (state.git.branches.length > 0 |
|
? (typeof state.git.branches[0] === 'string' ? state.git.branches[0] : state.git.branches[0].name) |
|
: 'HEAD'); |
|
} |
|
} else if (typeof state.git.currentBranch === 'object' && state.git.currentBranch !== null && 'name' in state.git.currentBranch) { |
|
branchName = (state.git.currentBranch as { name: string }).name; |
|
} else { |
|
// state.git.currentBranch is null, undefined, or invalid - use state.git.defaultBranch or fallback |
|
branchName = state.git.defaultBranch || (state.git.branches.length > 0 |
|
? (typeof state.git.branches[0] === 'string' ? state.git.branches[0] : state.git.branches[0].name) |
|
: 'HEAD'); |
|
} |
|
|
|
// Final validation: ensure branchName is a valid string |
|
// Note: We allow '#' in branch names for existing state.git.branches (they'll be URL-encoded) |
|
// Only reject if it's empty or not a string |
|
if (!branchName || typeof branchName !== 'string' || branchName.trim() === '') { |
|
console.warn('[loadFile] Invalid branch name detected, using fallback:', branchName); |
|
branchName = state.git.defaultBranch || (state.git.branches.length > 0 |
|
? (typeof state.git.branches[0] === 'string' ? state.git.branches[0] : state.git.branches[0].name) |
|
: 'HEAD'); |
|
} |
|
|
|
// Determine language from file extension first to check if it's an image |
|
const ext = filePath.split('.').pop()?.toLowerCase() || ''; |
|
|
|
// Check if this is an image file BEFORE making the API call |
|
state.preview.file.isImage = isImageFileType(ext); |
|
|
|
if (state.preview.file.isImage) { |
|
// For image state.files.list, construct the raw file URL and skip state.loading.main text content |
|
state.preview.file.imageUrl = `/api/repos/${state.npub}/${state.repo}/raw?path=${encodeURIComponent(filePath)}&ref=${encodeURIComponent(branchName)}`; |
|
state.files.content = ''; // Clear content for images |
|
state.files.editedContent = ''; // Clear edited content for images |
|
state.preview.file.html = ''; // Clear HTML for images |
|
state.preview.file.highlightedContent = ''; // Clear highlighted content |
|
state.files.language = 'text'; |
|
state.files.currentFile = filePath; |
|
state.files.hasChanges = false; |
|
} else { |
|
// Not an image, load file content normally |
|
state.preview.file.imageUrl = null; |
|
|
|
const url = `/api/repos/${state.npub}/${state.repo}/file?path=${encodeURIComponent(filePath)}&ref=${encodeURIComponent(branchName)}`; |
|
const response = await fetch(url, { |
|
headers: buildApiHeaders() |
|
}); |
|
|
|
if (!response.ok) { |
|
// Handle rate limiting specifically to prevent loops |
|
if (response.status === 429) { |
|
const error = new Error(`Failed to load file: Too Many Requests`); |
|
console.warn('[File Load] Rate limited, please wait before retrying'); |
|
throw error; |
|
} |
|
throw new Error(`Failed to load file: ${response.statusText}`); |
|
} |
|
|
|
const data = await response.json(); |
|
state.files.content = data.content; |
|
state.files.editedContent = data.content; |
|
state.files.currentFile = filePath; |
|
state.files.hasChanges = false; |
|
|
|
// Reset README auto-load flag when a file is successfully loaded |
|
if (filePath && filePath.toLowerCase().includes('readme')) { |
|
state.metadata.readmeAutoLoadAttempted = false; |
|
} |
|
|
|
if (ext === 'md' || ext === 'markdown') { |
|
state.files.language = 'markdown'; |
|
} else if (ext === 'adoc' || ext === 'asciidoc') { |
|
state.files.language = 'asciidoc'; |
|
} else { |
|
state.files.language = 'text'; |
|
} |
|
|
|
// Reset preview mode to default (preview) when state.loading.main a new file |
|
state.preview.file.showPreview = true; |
|
state.preview.file.html = ''; |
|
|
|
// Render markdown/asciidoc/HTML/CSV state.files.list as HTML for preview |
|
if (state.files.content && (ext === 'md' || ext === 'markdown' || ext === 'adoc' || ext === 'asciidoc' || ext === 'html' || ext === 'htm' || ext === 'csv')) { |
|
await renderFileAsHtml(state.files.content, ext || ''); |
|
} |
|
|
|
// Apply syntax highlighting |
|
// For state.files.list that support HTML preview (markdown, HTML, etc.), only show highlighting in raw mode |
|
// For code state.files.list and other non-markup state.files.list, always show syntax highlighting |
|
const hasHtmlPreview = supportsPreview(ext); |
|
if (state.files.content) { |
|
if (hasHtmlPreview) { |
|
// Markup files: only show highlighting when not in preview mode (raw mode) |
|
if (!state.preview.file.showPreview) { |
|
await applySyntaxHighlighting(state.files.content, ext || ''); |
|
} |
|
} else { |
|
// Code files and other non-markup files: always show syntax highlighting |
|
await applySyntaxHighlighting(state.files.content, ext || ''); |
|
} |
|
} |
|
} |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to load file'; |
|
console.error('Error state.loading.main file:', err); |
|
} finally { |
|
state.loading.main = false; |
|
} |
|
} |
|
|
|
function handleContentChange(value: string) { |
|
state.files.editedContent = value; |
|
state.files.hasChanges = value !== state.files.content; |
|
} |
|
|
|
function handleFileClick(file: { name: string; path: string; type: 'file' | 'directory' }) { |
|
if (file.type === 'directory') { |
|
state.files.pathStack.push(state.files.currentPath); |
|
loadFiles(file.path); |
|
} else { |
|
loadFile(file.path); |
|
// On mobile, switch to file viewer when a file is clicked |
|
if (window.innerWidth <= 768) { |
|
state.ui.showFileListOnMobile = false; |
|
} |
|
} |
|
} |
|
|
|
// Copy file content to clipboard |
|
async function copyFileContent(event?: Event) { |
|
if (!state.files.content || state.preview.copying) return; |
|
|
|
state.preview.copying = true; |
|
try { |
|
await navigator.clipboard.writeText(state.files.content); |
|
// Show temporary feedback |
|
const button = event?.target as HTMLElement; |
|
if (button) { |
|
const originalTitle = button.getAttribute('title') || ''; |
|
button.setAttribute('title', 'Copied!'); |
|
setTimeout(() => { |
|
button.setAttribute('title', originalTitle); |
|
}, 2000); |
|
} |
|
} catch (err) { |
|
console.error('Failed to copy file content:', err); |
|
alert('Failed to copy file content to clipboard'); |
|
} finally { |
|
state.preview.copying = false; |
|
} |
|
} |
|
|
|
// Download file |
|
function downloadFile() { |
|
if (!state.files.content || !state.files.currentFile) return; |
|
|
|
try { |
|
// Determine MIME type based on file extension |
|
const ext = state.files.currentFile.split('.').pop()?.toLowerCase() || ''; |
|
const mimeTypes: Record<string, string> = { |
|
'js': 'text/javascript', |
|
'ts': 'text/typescript', |
|
'json': 'application/json', |
|
'css': 'text/css', |
|
'html': 'text/html', |
|
'htm': 'text/html', |
|
'md': 'text/markdown', |
|
'txt': 'text/plain', |
|
'csv': 'text/csv', |
|
'xml': 'application/xml', |
|
'svg': 'image/svg+xml', |
|
'py': 'text/x-python', |
|
'java': 'text/x-java-source', |
|
'c': 'text/x-csrc', |
|
'cpp': 'text/x-c++src', |
|
'h': 'text/x-csrc', |
|
'hpp': 'text/x-c++src', |
|
'sh': 'text/x-shellscript', |
|
'bash': 'text/x-shellscript', |
|
'yaml': 'text/yaml', |
|
'yml': 'text/yaml', |
|
'toml': 'text/toml', |
|
'ini': 'text/plain', |
|
'conf': 'text/plain', |
|
'log': 'text/plain' |
|
}; |
|
|
|
const mimeType = mimeTypes[ext] || 'text/plain'; |
|
const blob = new Blob([state.files.content], { type: mimeType }); |
|
const url = URL.createObjectURL(blob); |
|
const a = document.createElement('a'); |
|
a.href = url; |
|
a.download = state.files.currentFile.split('/').pop() || 'file'; |
|
document.body.appendChild(a); |
|
a.click(); |
|
document.body.removeChild(a); |
|
URL.revokeObjectURL(url); |
|
} catch (err) { |
|
console.error('Failed to download file:', err); |
|
alert('Failed to download file'); |
|
} |
|
} |
|
|
|
function handleBack() { |
|
if (state.files.pathStack.length > 0) { |
|
const parentPath = state.files.pathStack.pop() || ''; |
|
loadFiles(parentPath); |
|
} else { |
|
loadFiles(''); |
|
} |
|
} |
|
|
|
// Cache for user profile email and name (already declared above) |
|
let fetchingUserEmail = false; |
|
let fetchingUserName = false; |
|
|
|
async function getUserEmail(): Promise<string> { |
|
return getUserEmailUtil(state.user.pubkeyHex, state.user.pubkey, { email: cachedUserEmail, name: cachedUserName }, { email: fetchingUserEmail, name: fetchingUserName }); |
|
} |
|
|
|
async function getUserName(): Promise<string> { |
|
return getUserNameUtil(state.user.pubkeyHex, state.user.pubkey, { email: cachedUserEmail, name: cachedUserName }, { email: fetchingUserEmail, name: fetchingUserName }); |
|
} |
|
|
|
async function setupAutoSave() { |
|
// Clear existing interval if any |
|
if (autoSaveInterval.value) { |
|
clearInterval(autoSaveInterval.value); |
|
autoSaveInterval.value = null; |
|
} |
|
|
|
// Check if auto-save is enabled |
|
try { |
|
const settings = await settingsStore.getSettings(); |
|
if (!settings.autoSave) { |
|
return; // Auto-save disabled |
|
} |
|
} catch (err) { |
|
console.warn('Failed to check auto-save setting:', err); |
|
return; |
|
} |
|
|
|
// Set up interval to auto-save every 10 minutes |
|
autoSaveInterval.value = setInterval(async () => { |
|
await autoSaveFile(); |
|
}, 10 * 60 * 1000); // 10 minutes |
|
} |
|
|
|
async function autoSaveFile() { |
|
// Only auto-save if: |
|
// 1. There are changes |
|
// 2. A file is open |
|
// 3. User is logged in |
|
// 4. User is a maintainer |
|
// 5. Not currently state.saving |
|
// 6. Not in clone state |
|
if (!state.files.hasChanges || !state.files.currentFile || !state.user.pubkey || !state.maintainers.isMaintainer || state.saving || needsClone) { |
|
return; |
|
} |
|
|
|
// Check auto-save setting again (in case it changed) |
|
try { |
|
const settings = await settingsStore.getSettings(); |
|
if (!settings.autoSave) { |
|
// Auto-save was disabled, clear interval |
|
if (autoSaveInterval.value) { |
|
clearInterval(autoSaveInterval.value); |
|
autoSaveInterval.value = null; |
|
} |
|
return; |
|
} |
|
} catch (err) { |
|
console.warn('Failed to check auto-save setting:', err); |
|
return; |
|
} |
|
|
|
// Generate a default commit message |
|
const autoCommitMessage = `Auto-save: ${new Date().toLocaleString()}`; |
|
|
|
try { |
|
// Get user email and name from settings |
|
const authorEmail = await getUserEmail(); |
|
const authorName = await getUserName(); |
|
|
|
// Sign commit with NIP-07 (client-side) |
|
let commitSignatureEvent: NostrEvent | null = null; |
|
if (isNIP07Available()) { |
|
try { |
|
const { KIND } = await import('$lib/types/nostr.js'); |
|
const timestamp = Math.floor(Date.now() / 1000); |
|
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = { |
|
kind: KIND.COMMIT_SIGNATURE, |
|
pubkey: '', // Will be filled by NIP-07 |
|
created_at: timestamp, |
|
tags: [ |
|
['author', authorName, authorEmail], |
|
['message', autoCommitMessage] |
|
], |
|
content: `Signed commit: ${autoCommitMessage}` |
|
}; |
|
commitSignatureEvent = await signEventWithNIP07(eventTemplate); |
|
} catch (err) { |
|
console.warn('Failed to sign commit with NIP-07:', err); |
|
// Continue without signature if signing fails |
|
} |
|
} |
|
|
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/file`, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
...buildApiHeaders() |
|
}, |
|
body: JSON.stringify({ |
|
path: state.files.currentFile, |
|
content: state.files.editedContent, |
|
message: autoCommitMessage, |
|
authorName: authorName, |
|
authorEmail: authorEmail, |
|
branch: state.git.currentBranch, |
|
userPubkey: state.user.pubkey, |
|
commitSignatureEvent: commitSignatureEvent |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json().catch(() => ({ message: response.statusText })); |
|
console.warn('Auto-save failed:', errorData.message || 'Failed to save file'); |
|
return; |
|
} |
|
|
|
// Reload file to get updated content |
|
await loadFile(state.files.currentFile); |
|
// Note: We don't show an alert for auto-save, it's silent |
|
console.log('Auto-saved file:', state.files.currentFile); |
|
} catch (err) { |
|
console.warn('Error during auto-save:', err); |
|
// Don't show state.error to user, it's silent |
|
} |
|
} |
|
|
|
async function saveFile() { |
|
await saveFileService(state, { |
|
getUserEmail, |
|
getUserName, |
|
loadFiles, |
|
loadFile |
|
}); |
|
} |
|
|
|
function handleBranchChangeDirect(branch: string) { |
|
state.git.currentBranch = branch; |
|
// Create a synthetic event for the existing handler |
|
const syntheticEvent = { |
|
target: { value: branch } |
|
} as unknown as Event; |
|
handleBranchChange(syntheticEvent); |
|
} |
|
|
|
async function handleBranchChange(event: Event) { |
|
const target = event.target as HTMLSelectElement; |
|
state.git.currentBranch = target.value; |
|
|
|
// Reload all branch-dependent data |
|
const reloadPromises: Promise<void>[] = []; |
|
|
|
// Always reload state.files.list (and current file if open) |
|
if (state.files.currentFile) { |
|
reloadPromises.push(loadFile(state.files.currentFile).catch(err => console.warn('Failed to reload file after branch change:', err))); |
|
} else { |
|
reloadPromises.push(loadFiles(state.files.currentPath).catch(err => console.warn('Failed to reload state.files.list after branch change:', err))); |
|
} |
|
|
|
// Reload README (branch-specific) |
|
reloadPromises.push(loadReadme().catch(err => console.warn('Failed to reload README after branch change:', err))); |
|
|
|
// Reload commit history if history tab is active |
|
if (state.ui.activeTab === 'history') { |
|
reloadPromises.push(loadCommitHistory().catch(err => console.warn('Failed to reload commit history after branch change:', err))); |
|
} |
|
|
|
// Reload documentation if docs tab is active (might be branch-specific) |
|
if (state.ui.activeTab === 'docs') { |
|
// Reset documentation to force reload |
|
state.docs.html = null; |
|
state.docs.content = null; |
|
state.docs.kind = null; |
|
reloadPromises.push(loadDocumentation().catch(err => console.warn('Failed to reload documentation after branch change:', err))); |
|
} |
|
|
|
// Wait for all reloads to complete |
|
await Promise.all(reloadPromises); |
|
} |
|
|
|
async function createFile() { |
|
await createFileService(state, { |
|
getUserEmail, |
|
getUserName, |
|
loadFiles |
|
}); |
|
} |
|
|
|
async function deleteFile(filePath: string) { |
|
await deleteFileService(filePath, state, { |
|
getUserEmail, |
|
getUserName, |
|
loadFiles |
|
}); |
|
} |
|
|
|
async function createBranch() { |
|
await createBranchService(state, repoAnnouncement, { |
|
loadBranches |
|
}); |
|
} |
|
|
|
async function deleteBranch(branchName: string) { |
|
await deleteBranchService(branchName, state, { |
|
loadBranches |
|
}); |
|
} |
|
|
|
async function loadCommitHistory() { |
|
state.loading.commits = true; |
|
state.error = null; |
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/commits?branch=${state.git.currentBranch}&limit=50`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
// Normalize commits: API-based commits use 'sha', local commits use 'hash' |
|
state.git.commits = data.map((commit: any) => ({ |
|
hash: commit.hash || commit.sha || '', |
|
message: commit.message || 'No message', |
|
author: commit.author || 'Unknown', |
|
date: commit.date || new Date().toISOString(), |
|
files: commit.files || [] |
|
})).filter((commit: any) => commit.hash); // Filter out commits without hash |
|
|
|
// Verify state.git.commits in background (only for cloned repos) |
|
if (state.clone.isCloned === true) { |
|
state.git.commits.forEach(commit => { |
|
verifyCommit(commit.hash).catch(err => { |
|
console.warn(`Failed to verify commit ${commit.hash}:`, err); |
|
}); |
|
}); |
|
} |
|
} |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to load commit history'; |
|
} finally { |
|
state.loading.commits = false; |
|
} |
|
} |
|
|
|
async function verifyCommit(commitHash: string) { |
|
if (state.git.verifyingCommits.has(commitHash)) return; // Already verifying |
|
if (!state.clone.isCloned) return; // Can't verify without local repo |
|
|
|
state.git.verifyingCommits.add(commitHash); |
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/commits/${commitHash}/verify`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
const verification = await response.json(); |
|
// Only update verification if there's actually a signature |
|
// If hasSignature is false or undefined, don't set verification at all |
|
if (verification.hasSignature !== false) { |
|
const commitIndex = state.git.commits.findIndex(c => c.hash === commitHash); |
|
if (commitIndex >= 0) { |
|
state.git.commits[commitIndex].verification = verification; |
|
} |
|
} |
|
} |
|
} catch (err) { |
|
console.warn(`Failed to verify commit ${commitHash}:`, err); |
|
} finally { |
|
state.git.verifyingCommits.delete(commitHash); |
|
} |
|
} |
|
|
|
async function viewDiff(commitHash: string) { |
|
// Set selected commit immediately so it shows in the right panel |
|
state.git.selectedCommit = commitHash; |
|
state.git.showDiff = false; // Start with false, will be set to true when diff loads |
|
state.loading.commits = true; |
|
state.error = null; |
|
try { |
|
// Normalize commit hash (handle both 'hash' and 'sha' properties) |
|
const getCommitHash = (c: any) => c.hash || c.sha || ''; |
|
const commitIndex = state.git.commits.findIndex(c => getCommitHash(c) === commitHash); |
|
const parentHash = commitIndex >= 0 |
|
? (state.git.commits[commitIndex + 1] ? getCommitHash(state.git.commits[commitIndex + 1]) : `${commitHash}^`) |
|
: `${commitHash}^`; |
|
|
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/diff?from=${parentHash}&to=${commitHash}`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
state.git.diffData = await response.json(); |
|
state.git.showDiff = true; |
|
} else { |
|
// Handle 404 or other errors |
|
const errorText = await response.text().catch(() => response.statusText); |
|
if (response.status === 404) { |
|
// Check if this is an API fallback commit (repo not cloned or empty) |
|
if (state.clone.isCloned === false || (state.clone.isCloned === true && state.clone.apiFallbackAvailable)) { |
|
state.error = 'Diffs are not available for commits viewed via API fallback. Please clone the repository to view diffs.'; |
|
} else { |
|
state.error = `Commit not found: ${errorText || 'The commit may not exist in the repository'}`; |
|
} |
|
} else { |
|
state.error = `Failed to load diff: ${errorText || response.statusText}`; |
|
} |
|
} |
|
} catch (err) { |
|
// Handle network errors |
|
if (err instanceof TypeError && err.message.includes('NetworkError')) { |
|
state.error = 'Network error: Unable to fetch diff. Please check your connection and try again.'; |
|
} else { |
|
state.error = err instanceof Error ? err.message : 'Failed to load diff'; |
|
} |
|
} finally { |
|
state.loading.commits = false; |
|
} |
|
} |
|
|
|
async function loadTags() { |
|
if (state.repoNotFound) return; |
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/tags`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
state.git.tags = await response.json(); |
|
// Auto-select first tag if none selected |
|
if (state.git.tags.length > 0 && !state.git.selectedTag) { |
|
state.git.selectedTag = state.git.tags[0].name; |
|
} |
|
} |
|
} catch (err) { |
|
console.error('Failed to load tags:', err); |
|
} |
|
} |
|
|
|
async function createTag() { |
|
if (!state.forms.tag.name.trim()) { |
|
alert('Please enter a tag name'); |
|
return; |
|
} |
|
|
|
if (!state.user.pubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
state.saving = true; |
|
state.error = null; |
|
|
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/tags`, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
...buildApiHeaders() |
|
}, |
|
body: JSON.stringify({ |
|
tagName: state.forms.tag.name, |
|
ref: state.forms.tag.ref, |
|
message: state.forms.tag.message || undefined, |
|
userPubkey: state.user.pubkey |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json(); |
|
throw new Error(errorData.message || 'Failed to create tag'); |
|
} |
|
|
|
state.openDialog = null; |
|
state.forms.tag.name = ''; |
|
state.forms.tag.message = ''; |
|
await loadTags(); |
|
alert('Tag created successfully!'); |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to create tag'; |
|
} finally { |
|
state.saving = false; |
|
} |
|
} |
|
|
|
async function loadReleases() { |
|
if (state.repoNotFound) return; |
|
state.loading.releases = true; |
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/releases`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
state.releases = data.map((release: any) => ({ |
|
id: release.id, |
|
tagName: release.tags.find((t: string[]) => t[0] === 'tag')?.[1] || '', |
|
tagHash: release.tags.find((t: string[]) => t[0] === 'r' && t[2] === 'tag')?.[1], |
|
releaseNotes: release.content || '', |
|
isDraft: release.tags.some((t: string[]) => t[0] === 'draft' && t[1] === 'true'), |
|
isPrerelease: release.tags.some((t: string[]) => t[0] === 'prerelease' && t[1] === 'true'), |
|
created_at: release.created_at, |
|
pubkey: release.pubkey |
|
})); |
|
} |
|
} catch (err) { |
|
console.error('Failed to load state.releases:', err); |
|
} finally { |
|
state.loading.releases = false; |
|
} |
|
} |
|
|
|
async function createRelease() { |
|
if (!state.forms.release.tagName.trim() || !state.forms.release.tagHash.trim()) { |
|
alert('Please enter a tag name and tag hash'); |
|
return; |
|
} |
|
|
|
if (!state.user.pubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
if (!state.maintainers.isMaintainer && state.user.pubkeyHex !== repoOwnerPubkeyDerived) { |
|
alert('Only repository owners and maintainers can create state.releases'); |
|
return; |
|
} |
|
|
|
state.creating.release = true; |
|
state.error = null; |
|
|
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/releases`, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
...buildApiHeaders() |
|
}, |
|
body: JSON.stringify({ |
|
tagName: state.forms.release.tagName, |
|
tagHash: state.forms.release.tagHash, |
|
releaseNotes: state.forms.release.notes, |
|
isDraft: state.forms.release.isDraft, |
|
isPrerelease: state.forms.release.isPrerelease |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json(); |
|
throw new Error(errorData.message || 'Failed to create release'); |
|
} |
|
|
|
state.openDialog = null; |
|
state.forms.release.tagName = ''; |
|
state.forms.release.tagHash = ''; |
|
state.forms.release.notes = ''; |
|
state.forms.release.isDraft = false; |
|
state.forms.release.isPrerelease = false; |
|
await loadReleases(); |
|
// Reload state.git.tags to show release indicator |
|
await loadTags(); |
|
alert('Release created successfully!'); |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to create release'; |
|
alert(state.error); |
|
} finally { |
|
state.creating.release = false; |
|
} |
|
} |
|
|
|
async function performCodeSearch() { |
|
if (!state.codeSearch.query.trim() || state.codeSearch.query.length < 2) { |
|
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() { |
|
state.loading.issues = true; |
|
state.error = null; |
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/issues`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
state.issues = data.map((issue: { id: string; tags: string[][]; content: string; status?: string; pubkey: string; created_at: number; kind?: number }) => ({ |
|
id: issue.id, |
|
subject: issue.tags.find((t: string[]) => t[0] === 'subject')?.[1] || 'Untitled', |
|
content: issue.content, |
|
status: issue.status || 'open', |
|
author: issue.pubkey, |
|
created_at: issue.created_at, |
|
kind: issue.kind || KIND.ISSUE, |
|
tags: issue.tags || [] |
|
})); |
|
// Auto-select first issue if none selected |
|
if (state.issues.length > 0 && !state.selected.issue) { |
|
state.selected.issue = state.issues[0].id; |
|
loadIssueReplies(state.issues[0].id); |
|
} |
|
} else { |
|
// Handle non-OK responses |
|
const errorText = await response.text().catch(() => response.statusText); |
|
let errorMessage = `Failed to load state.issues: ${response.status} ${response.statusText}`; |
|
try { |
|
const errorData = JSON.parse(errorText); |
|
if (errorData.message) { |
|
errorMessage = errorData.message; |
|
} |
|
} catch { |
|
// If parsing fails, use the text as-is |
|
if (errorText) { |
|
errorMessage = errorText; |
|
} |
|
} |
|
console.error('[Issues] Failed to load:', errorMessage); |
|
state.error = errorMessage; |
|
// Don't clear state.issues array - keep existing state.issues if any |
|
// state.issues = []; // Only clear if you want to show empty state on state.error |
|
} |
|
} catch (err) { |
|
const errorMessage = err instanceof Error ? err.message : 'Failed to load state.issues'; |
|
console.error('[Issues] Error state.loading.main state.issues:', err); |
|
state.error = errorMessage; |
|
} finally { |
|
state.loading.issues = false; |
|
} |
|
} |
|
|
|
async function loadIssueReplies(issueId: string) { |
|
state.loading.issueReplies = true; |
|
try { |
|
const replies = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.COMMENT], |
|
'#e': [issueId], |
|
limit: 100 |
|
} |
|
]) as NostrEvent[]; |
|
|
|
state.issueReplies = replies.map(reply => ({ |
|
id: reply.id, |
|
content: reply.content, |
|
author: reply.pubkey, |
|
created_at: reply.created_at, |
|
tags: reply.tags || [] |
|
})).sort((a, b) => a.created_at - b.created_at); |
|
} catch (err) { |
|
console.error('[Issues] Error state.loading.main replies:', err); |
|
state.issueReplies = []; |
|
} finally { |
|
state.loading.issueReplies = false; |
|
} |
|
} |
|
|
|
async function createIssue() { |
|
if (!state.forms.issue.subject.trim() || !state.forms.issue.content.trim()) { |
|
alert('Please enter a subject and content'); |
|
return; |
|
} |
|
|
|
if (!state.user.pubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
state.saving = true; |
|
state.error = null; |
|
|
|
try { |
|
const { IssuesService } = await import('$lib/services/nostr/issues-service.js'); |
|
|
|
const decoded = nip19.decode(state.npub); |
|
if (decoded.type !== 'npub') { |
|
throw new Error('Invalid npub format'); |
|
} |
|
const repoOwnerPubkey = decoded.data as string; |
|
|
|
// Get user's relays and combine with defaults |
|
const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
const { outbox } = await getUserRelays(state.user.pubkey, tempClient); |
|
const combinedRelays = combineRelays(outbox); |
|
|
|
const issuesService = new IssuesService(combinedRelays); |
|
const issue = await issuesService.createIssue( |
|
repoOwnerPubkey, |
|
state.repo, |
|
state.forms.issue.subject.trim(), |
|
state.forms.issue.content.trim(), |
|
state.forms.issue.labels.filter(l => l.trim()) |
|
); |
|
|
|
state.openDialog = null; |
|
state.forms.issue.subject = ''; |
|
state.forms.issue.content = ''; |
|
state.forms.issue.labels = ['']; |
|
await loadIssues(); |
|
alert('Issue created successfully!'); |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to create issue'; |
|
console.error('Error creating issue:', err); |
|
} finally { |
|
state.saving = false; |
|
} |
|
} |
|
|
|
async function updatePatchStatus(patchId: string, patchAuthor: string, status: string) { |
|
if (!state.user.pubkey || !state.user.pubkeyHex) { |
|
state.error = 'Please log in to update patch status'; |
|
return; |
|
} |
|
|
|
state.statusUpdates.patch[patchId] = true; |
|
state.error = null; |
|
|
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/patches`, { |
|
method: 'PATCH', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
...buildApiHeaders() |
|
}, |
|
body: JSON.stringify({ |
|
patchId, |
|
patchAuthor, |
|
status |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json(); |
|
throw new Error(errorData.message || `Failed to update patch status: ${response.statusText}`); |
|
} |
|
|
|
// Reload state.patches to get updated status |
|
await loadPatches(); |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to update patch status'; |
|
console.error('Error updating patch status:', err); |
|
} finally { |
|
state.statusUpdates.patch[patchId] = false; |
|
} |
|
} |
|
|
|
async function updateIssueStatus(issueId: string, issueAuthor: string, status: 'open' | 'closed' | 'resolved' | 'draft') { |
|
if (!state.user.pubkeyHex) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
// Check if user is maintainer or issue author |
|
const isAuthor = state.user.pubkeyHex === issueAuthor; |
|
if (!state.maintainers.isMaintainer && !isAuthor) { |
|
alert('Only repository maintainers or issue authors can update issue status'); |
|
return; |
|
} |
|
|
|
state.statusUpdates.issue = { ...state.statusUpdates.issue, [issueId]: true }; |
|
state.error = null; |
|
|
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/issues`, { |
|
method: 'PATCH', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({ |
|
issueId, |
|
issueAuthor, |
|
status |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const data = await response.json(); |
|
throw new Error(data.state.error || 'Failed to update issue status'); |
|
} |
|
|
|
await loadIssues(); |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to update issue status'; |
|
console.error('Error updating issue status:', err); |
|
} finally { |
|
state.statusUpdates.issue = { ...state.statusUpdates.issue, [issueId]: false }; |
|
} |
|
} |
|
|
|
async function loadPRs() { |
|
state.loading.prs = true; |
|
state.error = null; |
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/prs`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
state.prs = data.map((pr: { id: string; tags: string[][]; content: string; status?: string; pubkey: string; created_at: number; commitId?: string; kind?: number }) => ({ |
|
id: pr.id, |
|
subject: pr.tags.find((t: string[]) => t[0] === 'subject')?.[1] || 'Untitled', |
|
content: pr.content, |
|
status: pr.status || 'open', |
|
author: pr.pubkey, |
|
created_at: pr.created_at, |
|
commitId: pr.tags.find((t: string[]) => t[0] === 'c')?.[1], |
|
kind: pr.kind || KIND.PULL_REQUEST |
|
})); |
|
} |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to load pull requests'; |
|
} finally { |
|
state.loading.prs = false; |
|
} |
|
} |
|
|
|
async function createPR() { |
|
if (!state.forms.pr.subject.trim() || !state.forms.pr.content.trim() || !state.forms.pr.commitId.trim()) { |
|
alert('Please enter a subject, content, and commit ID'); |
|
return; |
|
} |
|
|
|
if (!state.user.pubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
state.saving = true; |
|
state.error = null; |
|
|
|
try { |
|
const { PRsService } = await import('$lib/services/nostr/prs-service.js'); |
|
const { getGitUrl } = await import('$lib/config.js'); |
|
|
|
const decoded = nip19.decode(state.npub); |
|
if (decoded.type !== 'npub') { |
|
throw new Error('Invalid npub format'); |
|
} |
|
const repoOwnerPubkey = decoded.data as string; |
|
|
|
// Get user's relays and combine with defaults |
|
const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
const { outbox } = await getUserRelays(state.user.pubkey, tempClient); |
|
const combinedRelays = combineRelays(outbox); |
|
|
|
const cloneUrl = getGitUrl(state.npub, state.repo); |
|
const prsService = new PRsService(combinedRelays); |
|
const pr = await prsService.createPullRequest( |
|
repoOwnerPubkey, |
|
state.repo, |
|
state.forms.pr.subject.trim(), |
|
state.forms.pr.content.trim(), |
|
state.forms.pr.commitId.trim(), |
|
cloneUrl, |
|
state.forms.pr.branchName.trim() || undefined, |
|
state.forms.pr.labels.filter(l => l.trim()) |
|
); |
|
|
|
state.openDialog = null; |
|
state.forms.pr.subject = ''; |
|
state.forms.pr.content = ''; |
|
state.forms.pr.commitId = ''; |
|
state.forms.pr.branchName = ''; |
|
state.forms.pr.labels = ['']; |
|
await loadPRs(); |
|
alert('Pull request created successfully!'); |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to create pull request'; |
|
console.error('Error creating PR:', err); |
|
} finally { |
|
state.saving = false; |
|
} |
|
} |
|
|
|
async function createPatch() { |
|
if (!state.forms.patch.content.trim()) { |
|
alert('Please enter patch content'); |
|
return; |
|
} |
|
|
|
if (!state.user.pubkey || !state.user.pubkeyHex) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
state.creating.patch = 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; |
|
state.metadata.address = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${state.repo}`; |
|
|
|
// Get user's relays and combine with defaults |
|
const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
const { outbox } = await getUserRelays(state.user.pubkey, tempClient); |
|
const combinedRelays = combineRelays(outbox); |
|
|
|
// Create patch event (kind 1617) |
|
const patchEventTemplate: Omit<NostrEvent, 'sig' | 'id'> = { |
|
kind: KIND.PATCH, |
|
pubkey: state.user.pubkeyHex, |
|
created_at: Math.floor(Date.now() / 1000), |
|
tags: [ |
|
['a', state.metadata.address], |
|
['p', repoOwnerPubkey], |
|
['t', 'root'] |
|
], |
|
content: state.forms.patch.content.trim() |
|
}; |
|
|
|
// Add subject if provided |
|
if (state.forms.patch.subject.trim()) { |
|
patchEventTemplate.tags.push(['subject', state.forms.patch.subject.trim()]); |
|
} |
|
|
|
// Sign the event using NIP-07 |
|
const signedEvent = await signEventWithNIP07(patchEventTemplate); |
|
|
|
// Publish to all available relays |
|
const publishClient = new NostrClient(combinedRelays); |
|
const result = await publishClient.publishEvent(signedEvent, combinedRelays); |
|
|
|
if (result.failed.length > 0 && result.success.length === 0) { |
|
throw new Error('Failed to publish patch to all relays'); |
|
} |
|
|
|
state.openDialog = null; |
|
state.forms.patch.content = ''; |
|
state.forms.patch.subject = ''; |
|
alert('Patch created successfully!'); |
|
// Reload state.patches |
|
await loadPatches(); |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to create patch'; |
|
console.error('Error creating patch:', err); |
|
} finally { |
|
state.creating.patch = false; |
|
} |
|
} |
|
|
|
async function loadPatches() { |
|
if (state.repoNotFound) return; |
|
state.loading.patches = true; |
|
state.error = null; |
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/patches`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
state.patches = data.map((patch: { id: string; tags: string[][]; content: string; pubkey: string; created_at: number; kind?: number; status?: string }) => { |
|
// Extract subject/title from various sources |
|
let subject = patch.tags.find((t: string[]) => t[0] === 'subject')?.[1]; |
|
const description = patch.tags.find((t: string[]) => t[0] === 'description')?.[1]; |
|
const alt = patch.tags.find((t: string[]) => t[0] === 'alt')?.[1]; |
|
|
|
// If no subject tag, try description or alt |
|
if (!subject) { |
|
if (description) { |
|
subject = description.trim(); |
|
} else if (alt) { |
|
// Remove "git patch: " prefix if present |
|
subject = alt.replace(/^git patch:\s*/i, '').trim(); |
|
} else { |
|
// Try to extract from patch content (git patch format) |
|
const subjectMatch = patch.content.match(/^Subject:\s*\[PATCH[^\]]*\]\s*(.+)$/m); |
|
if (subjectMatch) { |
|
subject = subjectMatch[1].trim(); |
|
} else { |
|
// Try simpler Subject: line |
|
const simpleSubjectMatch = patch.content.match(/^Subject:\s*(.+)$/m); |
|
if (simpleSubjectMatch) { |
|
subject = simpleSubjectMatch[1].trim(); |
|
} |
|
} |
|
} |
|
} |
|
|
|
return { |
|
id: patch.id, |
|
subject: subject || 'Untitled', |
|
content: patch.content, |
|
status: patch.status || 'open', |
|
author: patch.pubkey, |
|
created_at: patch.created_at, |
|
kind: patch.kind || KIND.PATCH, |
|
description: description?.trim(), |
|
tags: patch.tags || [] |
|
}; |
|
}); |
|
} |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to load state.patches'; |
|
console.error('Error state.loading.main state.patches:', err); |
|
} finally { |
|
state.loading.patches = false; |
|
} |
|
} |
|
|
|
async function loadPatchHighlights(patchId: string, patchAuthor: string) { |
|
if (!patchId || !patchAuthor) return; |
|
|
|
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( |
|
text: string, |
|
startLine: number, |
|
endLine: number, |
|
startPos: number, |
|
endPos: number |
|
) { |
|
if (!text.trim() || !state.user.pubkey) return; |
|
|
|
state.forms.patchHighlight.text = text; |
|
state.forms.patchHighlight.startLine = startLine; |
|
state.forms.patchHighlight.endLine = endLine; |
|
state.forms.patchHighlight.startPos = startPos; |
|
state.forms.patchHighlight.endPos = endPos; |
|
state.openDialog = 'patchHighlight'; |
|
} |
|
|
|
async function createPatchHighlight() { |
|
if (!state.user.pubkey || !state.forms.patchHighlight.text.trim() || !state.selected.patch) return; |
|
|
|
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 { |
|
try { |
|
return nip19.npubEncode(pubkey); |
|
} catch { |
|
return pubkey.slice(0, 8) + '...'; |
|
} |
|
} |
|
|
|
function startPatchComment(parentId?: string) { |
|
if (!state.user.pubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
state.forms.patchComment.replyingTo = parentId || null; |
|
state.openDialog = 'patchComment'; |
|
} |
|
|
|
async function createPatchComment() { |
|
if (!state.user.pubkey || !state.forms.patchComment.content.trim() || !state.selected.patch) return; |
|
|
|
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 |
|
usePatchHighlightsEffect(state, loadPatchHighlights); |
|
|
|
// Initialize tab change effect |
|
const lastTab = { value: null as string | null }; |
|
useTabChangeEffect(state, lastTab, findReadmeFile, { |
|
loadFiles, |
|
loadFile, |
|
loadCommitHistory, |
|
loadTags, |
|
loadReleases, |
|
loadIssues, |
|
loadPRs, |
|
loadDocumentation, |
|
loadDiscussions, |
|
loadPatches |
|
}); |
|
|
|
// Initialize branch change effect |
|
const lastBranch = { value: null as string | null }; |
|
useBranchChangeEffect(state, lastBranch, { |
|
loadReadme, |
|
loadFile, |
|
loadFiles, |
|
loadCommitHistory, |
|
loadDocumentation |
|
}); |
|
</script> |
|
|
|
<svelte:head> |
|
<title>{safeTitle || 'Repository'}</title> |
|
<meta name="description" content={safeDescription || 'Repository'} /> |
|
|
|
<!-- OpenGraph / Facebook --> |
|
<meta property="og:type" content="website" /> |
|
<meta property="og:title" content={safeTitle || 'Repository'} /> |
|
<meta property="og:description" content={safeOgDescription} /> |
|
<meta property="og:url" content={safePageUrl} /> |
|
{#if hasImage && safeImage} |
|
<meta property="og:image" content={safeImage} /> |
|
{/if} |
|
{#if hasBanner && safeBanner} |
|
<meta property="og:image:width" content="1200" /> |
|
<meta property="og:image:height" content="630" /> |
|
{/if} |
|
|
|
<!-- Twitter Card --> |
|
<meta name="twitter:card" content={safeTwitterCard} /> |
|
<meta name="twitter:title" content={safeTitle || 'Repository'} /> |
|
<meta name="twitter:description" content={safeTwitterDescription} /> |
|
{#if hasBanner && safeBanner} |
|
<meta name="twitter:image" content={safeBanner} /> |
|
{:else if hasImage && safeImage} |
|
<meta name="twitter:image" content={safeImage} /> |
|
{/if} |
|
</svelte:head> |
|
|
|
<div class="container"> |
|
<!-- Banner hidden on mobile, shown on desktop --> |
|
{#if state.metadata.banner && typeof state.metadata.banner === 'string' && state.metadata.banner.trim()} |
|
<div class="repo-banner desktop-only"> |
|
<img src={state.metadata.banner} alt="" onerror={(e) => { |
|
if (typeof window !== 'undefined') { |
|
console.error('[Repo Images] Failed to load banner:', state.metadata.banner); |
|
const target = e.target as HTMLImageElement; |
|
if (target) target.style.display = 'none'; |
|
} |
|
}} /> |
|
</div> |
|
{/if} |
|
|
|
{#if repoOwnerPubkeyDerived} |
|
<RepoHeaderEnhanced |
|
repoName={repoName || ''} |
|
repoDescription={repoDescription || ''} |
|
ownerNpub={state.npub || ''} |
|
ownerPubkey={repoOwnerPubkeyDerived || ''} |
|
isMaintainer={state.maintainers.isMaintainer || false} |
|
isPrivate={repoIsPrivate || false} |
|
cloneUrls={repoCloneUrls || []} |
|
branches={state.git.branches || []} |
|
currentBranch={state.git.currentBranch || null} |
|
topics={repoTopics || []} |
|
defaultBranch={state.git.defaultBranch || null} |
|
isRepoCloned={state.clone.isCloned} |
|
copyingCloneUrl={state.clone.copyingUrl || false} |
|
onBranchChange={safeHandleBranchChange} |
|
onCopyCloneUrl={safeCopyCloneUrl} |
|
onDeleteBranch={safeDeleteBranch} |
|
onMenuToggle={() => { if (typeof state.ui.showRepoMenu !== 'undefined') state.ui.showRepoMenu = !state.ui.showRepoMenu; }} |
|
showMenu={state.ui.showRepoMenu || false} |
|
userPubkey={state.user.pubkey || null} |
|
isBookmarked={state.bookmark.isBookmarked || false} |
|
loadingBookmark={state.loading.bookmark || false} |
|
onToggleBookmark={safeToggleBookmark} |
|
onFork={safeForkRepository} |
|
forking={state.fork.forking || false} |
|
onCloneToServer={safeCloneRepository} |
|
cloning={state.clone.cloning || false} |
|
checkingCloneStatus={state.clone.checking || false} |
|
onCreateIssue={() => { state.openDialog = 'createIssue'; }} |
|
onCreatePR={() => { state.openDialog = 'createPR'; }} |
|
onCreatePatch={() => { state.openDialog = 'createPatch'; }} |
|
onCreateBranch={async () => { |
|
if (!state.user.pubkey || !state.maintainers.isMaintainer || needsClone) return; |
|
try { |
|
const settings = await settingsStore.getSettings(); |
|
state.forms.branch.defaultName = settings.defaultBranch || 'master'; |
|
} catch { |
|
state.forms.branch.defaultName = 'master'; |
|
} |
|
// Preset the default branch name in the input field |
|
state.forms.branch.name = state.forms.branch.defaultName; |
|
state.forms.branch.from = null; // Reset from branch selection |
|
state.openDialog = 'createBranch'; |
|
}} |
|
onSettings={() => goto(`/signup?npub=${state.npub}&repo=${state.repo}`)} |
|
onGenerateVerification={repoOwnerPubkeyDerived && state.user.pubkeyHex === repoOwnerPubkeyDerived && state.verification.status?.verified !== true ? generateAnnouncementFileForRepo : undefined} |
|
onDeleteAnnouncement={repoOwnerPubkeyDerived && state.user.pubkeyHex === repoOwnerPubkeyDerived ? deleteAnnouncement : undefined} |
|
deletingAnnouncement={state.creating.announcement} |
|
hasUnlimitedAccess={hasUnlimitedAccess($userStore.userLevel)} |
|
needsClone={needsClone} |
|
allMaintainers={state.maintainers.all} |
|
onCopyEventId={copyEventId} |
|
/> |
|
{/if} |
|
|
|
<!-- Additional repo metadata (website, clone URLs with verification) --> |
|
|
|
{#if repoWebsite || (repoCloneUrls && repoCloneUrls.length > 0) || repoLanguage || (repoTopics && repoTopics.length > 0) || state.fork.info?.isFork} |
|
<div class="repo-metadata-section"> |
|
{#if repoWebsite} |
|
<div class="repo-website"> |
|
<a href={repoWebsite} target="_blank" rel="noopener noreferrer"> |
|
<img src="/icons/external-link.svg" alt="" class="icon-inline" /> |
|
{repoWebsite} |
|
</a> |
|
</div> |
|
{/if} |
|
{#if repoLanguage} |
|
<span class="repo-language"> |
|
<img src="/icons/file-text.svg" alt="" class="icon-inline" /> |
|
{repoLanguage} |
|
</span> |
|
{/if} |
|
{#if state.fork.info?.isFork && state.fork.info.originalRepo} |
|
<span class="fork-badge">Forked from <a href={`/repos/${state.fork.info.originalRepo.npub}/${state.fork.info.originalRepo.repo}`}>{state.fork.info.originalRepo.repo}</a></span> |
|
{/if} |
|
{#if repoCloneUrls && repoCloneUrls.length > 0} |
|
<div class="repo-clone-urls"> |
|
<div style="display: flex; align-items: center; gap: 0.5rem;"> |
|
<button |
|
class="clone-label-button" |
|
onclick={() => state.clone.urlsExpanded = !state.clone.urlsExpanded} |
|
aria-expanded={state.clone.urlsExpanded} |
|
> |
|
<span class="clone-label">Clone URLs:</span> |
|
<img src="/icons/chevron-down.svg" alt="" class="clone-toggle-icon icon-inline" class:expanded={state.clone.urlsExpanded} /> |
|
</button> |
|
<button |
|
class="reachability-refresh-button" |
|
onclick={() => loadCloneUrlReachability(true)} |
|
disabled={state.loading.reachability} |
|
title="Refresh reachability status" |
|
> |
|
{#if state.loading.reachability} |
|
Checking... |
|
{:else} |
|
<img src="/icons/refresh-cw.svg" alt="" class="refresh-icon icon-inline" /> |
|
<span>Check Reachability</span> |
|
{/if} |
|
</button> |
|
</div> |
|
<div class="clone-url-list" class:collapsed={!state.clone.urlsExpanded}> |
|
{#if state.clone.isCloned === true} |
|
<button |
|
class="copy-clone-url-button" |
|
onclick={() => copyCloneUrl()} |
|
disabled={state.clone.copyingUrl} |
|
title="Copy clone URL" |
|
> |
|
<img src="/icons/copy.svg" alt="" class="icon-inline" /> |
|
{state.clone.copyingUrl ? 'Copying...' : 'Copy Clone URL'} |
|
</button> |
|
{/if} |
|
{#each (state.clone.showAllUrls ? repoCloneUrls : repoCloneUrls.slice(0, 3)) as cloneUrl} |
|
{@const cloneVerification = state.verification.status?.cloneVerifications?.find(cv => { |
|
const normalizeUrl = (url: string) => url.replace(/\/$/, '').toLowerCase().replace(/^https?:\/\//, ''); |
|
const normalizedCv = normalizeUrl(cv.url); |
|
const normalizedClone = normalizeUrl(cloneUrl); |
|
return normalizedCv === normalizedClone || |
|
normalizedCv.includes(normalizedClone) || |
|
normalizedClone.includes(normalizedCv); |
|
})} |
|
{@const reachability = state.clone.reachability.get(cloneUrl)} |
|
{@const isChecking = state.clone.checkingReachability.has(cloneUrl)} |
|
<div class="clone-url-wrapper"> |
|
<code class="clone-url">{cloneUrl}</code> |
|
{#if state.loading.verification} |
|
<span class="verification-badge state.loading.main" title="Checking verification..."> |
|
<span style="opacity: 0.5;">⋯</span> |
|
</span> |
|
{:else if cloneVerification !== undefined} |
|
{#if cloneVerification.verified} |
|
<span |
|
class="verification-badge verified" |
|
title="Verified ownership" |
|
> |
|
<img src="/icons/check-circle.svg" alt="Verified" class="icon-inline" /> |
|
</span> |
|
{:else} |
|
{#if state.user.pubkey && (state.maintainers.isMaintainer || state.user.pubkeyHex === repoOwnerPubkeyDerived) && state.clone.isCloned === true} |
|
<button |
|
class="verification-badge unverified clickable" |
|
title="Click to verify this repository by committing the repo announcement event" |
|
onclick={() => { |
|
state.verification.selectedCloneUrl = cloneUrl; |
|
state.openDialog = 'cloneUrlVerification'; |
|
}} |
|
> |
|
<img src="/icons/alert-triangle.svg" alt="Unverified" class="icon-inline" /> |
|
</button> |
|
{:else} |
|
<span |
|
class="verification-badge unverified" |
|
title={cloneVerification.error || 'Unverified'} |
|
> |
|
<img src="/icons/alert-triangle.svg" alt="Unverified" class="icon-inline" /> |
|
</span> |
|
{/if} |
|
{/if} |
|
{:else if state.verification.status} |
|
{#if state.user.pubkey && (state.maintainers.isMaintainer || state.user.pubkeyHex === repoOwnerPubkeyDerived) && state.clone.isCloned === true} |
|
<button |
|
class="verification-badge unverified clickable" |
|
title="Click to verify this repository by committing the repo announcement event" |
|
onclick={() => { |
|
state.verification.selectedCloneUrl = cloneUrl; |
|
state.openDialog = 'cloneUrlVerification'; |
|
}} |
|
> |
|
<img src="/icons/alert-triangle.svg" alt="Unknown" class="icon-inline" /> |
|
</button> |
|
{:else} |
|
<span class="verification-badge unverified" title="Verification status unknown"> |
|
<img src="/icons/alert-triangle.svg" alt="Unknown" class="icon-inline" /> |
|
</span> |
|
{/if} |
|
{:else} |
|
{#if state.user.pubkey && (state.maintainers.isMaintainer || state.user.pubkeyHex === repoOwnerPubkeyDerived) && state.clone.isCloned === true} |
|
<button |
|
class="verification-badge unverified clickable" |
|
title="Click to verify this repository by committing the repo announcement event" |
|
onclick={() => { |
|
state.verification.selectedCloneUrl = cloneUrl; |
|
state.openDialog = 'cloneUrlVerification'; |
|
}} |
|
> |
|
<img src="/icons/alert-triangle.svg" alt="Not checked" class="icon-inline" /> |
|
</button> |
|
{:else} |
|
<span class="verification-badge unverified" title="Verification not checked"> |
|
<img src="/icons/alert-triangle.svg" alt="Not checked" class="icon-inline" /> |
|
</span> |
|
{/if} |
|
{/if} |
|
{#if isChecking || state.loading.reachability} |
|
<span class="reachability-badge state.loading.main" title="Checking reachability..."> |
|
<span style="opacity: 0.5;">⋯</span> |
|
</span> |
|
{:else if reachability !== undefined} |
|
<span |
|
class="reachability-badge" |
|
class:reachable={reachability.reachable} |
|
class:unreachable={!reachability.reachable} |
|
title={reachability.reachable |
|
? `Reachable${reachability.serverType === 'grasp' ? ' (GRASP server)' : reachability.serverType === 'git' ? ' (Git server)' : ''}` |
|
: (reachability.error || 'Unreachable')} |
|
> |
|
{#if reachability.reachable} |
|
<img src="/icons/check-circle.svg" alt="Reachable" class="icon-inline icon-success" /> |
|
{:else} |
|
<img src="/icons/x-circle.svg" alt="Unreachable" class="icon-inline icon-state.error" /> |
|
{/if} |
|
</span> |
|
{#if reachability.serverType === 'grasp'} |
|
<span class="server-type-badge grasp-badge" title="GRASP server (git server with Nostr relay and GRASP features)"> |
|
GRASP |
|
</span> |
|
{:else if reachability.serverType === 'git'} |
|
<span class="server-type-badge git-badge" title="Git server (standard git smart HTTP)"> |
|
Git |
|
</span> |
|
{/if} |
|
{/if} |
|
</div> |
|
{/each} |
|
{#if repoCloneUrls.length > 3} |
|
<button |
|
class="clone-more" |
|
onclick={() => state.clone.showAllUrls = !state.clone.showAllUrls} |
|
title={state.clone.showAllUrls ? 'Show fewer' : 'Show all clone URLs'} |
|
> |
|
{state.clone.showAllUrls ? `-${repoCloneUrls.length - 3} less` : `+${repoCloneUrls.length - 3} more`} |
|
</button> |
|
{/if} |
|
</div> |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
<main class="repo-view"> |
|
{#if state.clone.isCloned === false && (canUseApiFallback || state.clone.apiFallbackAvailable === null)} |
|
<div class="read-only-banner"> |
|
<div class="banner-content"> |
|
<img src="/icons/alert-circle.svg" alt="Info" class="banner-icon" /> |
|
<span> |
|
{#if state.clone.apiFallbackAvailable === null} |
|
Checking external clone URLs for read-only access... |
|
{:else} |
|
This repository is displayed in <strong>read-only mode</strong> using data from external clone URLs. To enable editing and full features, clone this repository to the server. |
|
{/if} |
|
</span> |
|
{#if hasUnlimitedAccess($userStore.userLevel) && state.clone.apiFallbackAvailable !== null} |
|
<button |
|
class="clone-button-banner" |
|
onclick={cloneRepository} |
|
disabled={state.clone.cloning || state.clone.checking} |
|
> |
|
{state.clone.cloning ? 'Cloning...' : (state.clone.checking ? 'Checking...' : 'Clone to Server')} |
|
</button> |
|
{/if} |
|
</div> |
|
</div> |
|
{/if} |
|
{#if state.error} |
|
<div class="state.error"> |
|
<div class="state.error-message"> |
|
<strong>Error:</strong> {state.error} |
|
</div> |
|
{#if state.error.includes('not cloned locally') && hasUnlimitedAccess($userStore.userLevel)} |
|
<div class="state.error-actions"> |
|
<button |
|
class="clone-button-inline" |
|
onclick={cloneRepository} |
|
disabled={state.clone.cloning || state.clone.checking} |
|
> |
|
{state.clone.cloning ? 'Cloning...' : (state.clone.checking ? 'Checking...' : 'Clone to Server')} |
|
</button> |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
<!-- Tabs --> |
|
|
|
{#if state.clone.isCloned === false && !canUseApiFallback && tabs.length === 0} |
|
<div class="repo-not-cloned-message"> |
|
<div class="message-content"> |
|
<h2>Repository Not Cloned</h2> |
|
<p>This repository has not been cloned to the server yet, and read-only access via external clone URLs is not available.</p> |
|
{#if hasUnlimitedAccess($userStore.userLevel)} |
|
<p>Use the "Clone to Server" option in the repository menu to clone this repository.</p> |
|
{:else} |
|
<p>Contact a server administrator with unlimited access to clone this repository.</p> |
|
{/if} |
|
</div> |
|
</div> |
|
{:else} |
|
<div class="repo-layout"> |
|
<!-- Files Tab --> |
|
{#if state.ui.activeTab === 'files' && canViewRepo} |
|
<FilesTab |
|
files={state.files.list} |
|
currentPath={state.files.currentPath} |
|
currentFile={state.files.currentFile} |
|
fileContent={state.files.content} |
|
fileLanguage={state.files.language} |
|
editedContent={state.files.editedContent} |
|
hasChanges={state.files.hasChanges} |
|
loading={state.loading.main && !state.files.currentFile} |
|
error={state.error} |
|
pathStack={state.files.pathStack} |
|
onFileClick={handleFileClick} |
|
onDirectoryClick={(path) => { |
|
state.files.currentPath = path; |
|
loadFiles(path); |
|
}} |
|
onNavigateBack={handleBack} |
|
onContentChange={(content) => { |
|
state.files.editedContent = content; |
|
state.files.hasChanges = content !== state.files.content; |
|
}} |
|
isMaintainer={state.maintainers.isMaintainer} |
|
readmeContent={state.preview.readme.content || null} |
|
readmePath={state.preview.readme.path || null} |
|
readmeHtml={state.preview.readme.html} |
|
showFilePreview={state.preview.file.showPreview} |
|
fileHtml={state.preview.file.html} |
|
highlightedFileContent={state.preview.file.highlightedContent} |
|
isImageFile={state.preview.file.isImage} |
|
imageUrl={state.preview.file.imageUrl} |
|
wordWrap={state.ui.wordWrap} |
|
{supportsPreview} |
|
onSave={() => { |
|
if (!state.user.pubkey || !state.maintainers.isMaintainer || needsClone) return; |
|
state.openDialog = 'commit'; |
|
}} |
|
onTogglePreview={() => { |
|
state.preview.file.showPreview = !state.preview.file.showPreview; |
|
if (!state.preview.file.showPreview && state.files.content && state.files.currentFile) { |
|
const ext = state.files.currentFile.split('.').pop() || ''; |
|
applySyntaxHighlighting(state.files.content, ext).catch(err => console.error('Error applying syntax highlighting:', err)); |
|
} |
|
}} |
|
onCopyFileContent={copyFileContent} |
|
onDownloadFile={downloadFile} |
|
copyingFile={state.preview.copying} |
|
saving={state.saving} |
|
needsClone={needsClone} |
|
{cloneTooltip} |
|
branches={state.git.branches} |
|
currentBranch={state.git.currentBranch} |
|
defaultBranch={state.git.defaultBranch} |
|
onBranchChange={(branch) => { |
|
state.git.currentBranch = branch; |
|
handleBranchChangeDirect(branch); |
|
}} |
|
userPubkey={state.user.pubkey} |
|
/> |
|
{/if} |
|
|
|
<!-- History Tab --> |
|
{#if state.ui.activeTab === 'history' && canViewRepo} |
|
<HistoryTab |
|
commits={state.git.commits} |
|
selectedCommit={state.git.selectedCommit} |
|
loading={state.loading.commits} |
|
error={state.error} |
|
onSelect={(hash) => { |
|
state.git.selectedCommit = hash; |
|
viewDiff(hash); |
|
}} |
|
onVerify={async (hash) => { |
|
state.git.verifyingCommits.add(hash); |
|
try { |
|
// Trigger verification logic - find the commit and verify |
|
const commit = state.git.commits.find(c => (c.hash || (c as any).sha) === hash); |
|
if (commit) { |
|
await verifyCommit(hash); |
|
} |
|
} finally { |
|
state.git.verifyingCommits.delete(hash); |
|
} |
|
}} |
|
verifyingCommits={state.git.verifyingCommits} |
|
showDiff={state.git.showDiff} |
|
diffData={state.git.diffData} |
|
/> |
|
{/if} |
|
|
|
<!-- Tags View --> |
|
<TagsTab |
|
npub={state.npub} |
|
repo={state.repo} |
|
tags={state.git.tags} |
|
releases={state.releases} |
|
selectedTag={state.git.selectedTag} |
|
isMaintainer={state.maintainers.isMaintainer} |
|
userPubkeyHex={state.user.pubkeyHex} |
|
repoOwnerPubkeyDerived={repoOwnerPubkeyDerived} |
|
isRepoCloned={state.clone.isCloned} |
|
{canViewRepo} |
|
canUseApiFallback={canUseApiFallback} |
|
{needsClone} |
|
{cloneTooltip} |
|
activeTab={state.ui.activeTab} |
|
{tabs} |
|
showLeftPanelOnMobile={state.ui.showLeftPanelOnMobile} |
|
onTagSelect={(tagName) => state.git.selectedTag = tagName} |
|
onTabChange={(tab) => state.ui.activeTab = tab as typeof state.ui.activeTab} |
|
onToggleMobilePanel={() => state.ui.showLeftPanelOnMobile = !state.ui.showLeftPanelOnMobile} |
|
onCreateTag={() => state.openDialog = 'createTag'} |
|
onCreateRelease={(tagName, tagHash) => { |
|
state.forms.release.tagName = tagName; |
|
state.forms.release.tagHash = tagHash; |
|
state.openDialog = 'createRelease'; |
|
}} |
|
onLoadTags={loadTags} |
|
/> |
|
|
|
<!-- Code Search View --> |
|
{#if state.ui.activeTab === 'code-search' && canViewRepo} |
|
<aside class="code-search-sidebar" class:hide-on-mobile={!state.ui.showLeftPanelOnMobile && state.ui.activeTab === 'code-search'}> |
|
<div class="code-search-header"> |
|
<TabsMenu |
|
activeTab={state.ui.activeTab} |
|
{tabs} |
|
onTabChange={(tab) => state.ui.activeTab = tab as typeof state.ui.activeTab} |
|
/> |
|
<h2>Code Search</h2> |
|
<button |
|
onclick={() => state.ui.showLeftPanelOnMobile = !state.ui.showLeftPanelOnMobile} |
|
class="mobile-toggle-button" |
|
title="Show content" |
|
> |
|
<img src="/icons/arrow-right.svg" alt="Show content" class="icon-inline" /> |
|
</button> |
|
</div> |
|
</aside> |
|
{/if} |
|
|
|
<!-- Issues Tab --> |
|
{#if state.ui.activeTab === 'issues'} |
|
<IssuesTab |
|
issues={state.issues} |
|
selectedIssue={state.selected.issue} |
|
loading={state.loading.issues} |
|
error={state.error} |
|
onSelect={(id) => { |
|
state.selected.issue = id; |
|
loadIssueReplies(id); |
|
}} |
|
onStatusUpdate={async (id, status) => { |
|
// Find issue and update status |
|
const issue = state.issues.find(i => i.id === id); |
|
if (issue) { |
|
await updateIssueStatus(id, issue.author, status as 'open' | 'closed' | 'resolved' | 'draft'); |
|
await loadIssues(); |
|
} |
|
}} |
|
issueReplies={state.issueReplies} |
|
loadingReplies={state.loading.issueReplies} |
|
/> |
|
{/if} |
|
|
|
<!-- Pull Requests Tab --> |
|
{#if state.ui.activeTab === 'prs'} |
|
<PRsTab |
|
prs={state.prs} |
|
selectedPR={state.selected.pr || null} |
|
loading={state.loading.prs} |
|
error={state.error} |
|
onSelect={(id) => { |
|
state.selected.pr = id; |
|
}} |
|
onStatusUpdate={async (id, status) => { |
|
// Find PR and update status - similar to updateIssueStatus |
|
const pr = state.prs.find(p => p.id === id); |
|
if (pr && state.user.pubkeyHex) { |
|
// Check if user is maintainer or PR author |
|
const isAuthor = state.user.pubkeyHex === pr.author; |
|
if (!state.maintainers.isMaintainer && !isAuthor) { |
|
alert('Only repository maintainers or PR authors can update PR status'); |
|
return; |
|
} |
|
|
|
try { |
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/prs`, { |
|
method: 'PATCH', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({ |
|
prId: id, |
|
prAuthor: pr.author, |
|
status |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const data = await response.json(); |
|
throw new Error(data.state.error || 'Failed to update PR status'); |
|
} |
|
|
|
await loadPRs(); |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to update PR status'; |
|
console.error('Error updating PR status:', err); |
|
} |
|
} |
|
}} |
|
/> |
|
{/if} |
|
|
|
<!-- Patches Tab --> |
|
{#if state.ui.activeTab === 'patches'} |
|
<PatchesTab |
|
patches={state.patches} |
|
selectedPatch={state.selected.patch} |
|
loading={state.loading.patches} |
|
error={state.error} |
|
onSelect={(id) => { |
|
state.selected.patch = id; |
|
}} |
|
onApply={async (id) => { |
|
applying[id] = true; |
|
try { |
|
const patch = state.patches.find(p => p.id === id); |
|
if (!patch) { |
|
throw new Error('Patch not found'); |
|
} |
|
|
|
if (!state.user.pubkey || !state.maintainers.isMaintainer || needsClone) { |
|
alert('Only maintainers can apply patches'); |
|
return; |
|
} |
|
|
|
if (!confirm('Apply this patch to the repository? This will create a commit with the patch changes.')) { |
|
return; |
|
} |
|
|
|
const authorEmail = await getUserEmail(); |
|
const authorName = await getUserName(); |
|
|
|
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/patches/${id}/apply`, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
...buildApiHeaders() |
|
}, |
|
body: JSON.stringify({ |
|
message: `Apply patch ${id.slice(0, 8)}: ${patch.subject}`, |
|
authorName, |
|
authorEmail, |
|
branch: state.git.currentBranch || state.git.defaultBranch || 'main' |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json(); |
|
throw new Error(errorData.message || 'Failed to apply patch'); |
|
} |
|
|
|
await loadPatches(); |
|
alert('Patch applied successfully!'); |
|
} catch (err) { |
|
state.error = err instanceof Error ? err.message : 'Failed to apply patch'; |
|
console.error('Error applying patch:', err); |
|
} finally { |
|
applying[id] = false; |
|
} |
|
}} |
|
{applying} |
|
/> |
|
{/if} |
|
|
|
<!-- Discussions Tab --> |
|
{#if state.ui.activeTab === 'discussions'} |
|
<DiscussionsTab |
|
npub={state.npub} |
|
repo={state.repo} |
|
repoAnnouncement={repoAnnouncement} |
|
userPubkey={state.user.pubkey} |
|
/> |
|
{/if} |
|
|
|
<!-- Docs Tab --> |
|
{#if state.ui.activeTab === 'docs'} |
|
<DocsTab |
|
npub={state.npub} |
|
repo={state.repo} |
|
currentBranch={state.git.currentBranch || null} |
|
relays={[...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS]} |
|
/> |
|
{/if} |
|
|
|
<!-- Files tab content is now handled by FilesTab component --> |
|
|
|
<!-- History tab content is now handled by HistoryTab component --> |
|
|
|
<!-- Tags content is now handled by TagsTab component --> |
|
|
|
|
|
{#if state.ui.activeTab === 'code-search' && canViewRepo} |
|
<div class="code-search-content" class:hide-on-mobile={state.ui.showLeftPanelOnMobile && state.ui.activeTab === 'code-search'}> |
|
<div class="content-header-mobile"> |
|
<button |
|
onclick={() => state.ui.showLeftPanelOnMobile = !state.ui.showLeftPanelOnMobile} |
|
class="mobile-toggle-button" |
|
title="Show list" |
|
> |
|
<img src="/icons/arrow-right.svg" alt="Show list" class="icon-inline mobile-toggle-left" /> |
|
</button> |
|
</div> |
|
<div class="code-search-form"> |
|
<div class="search-input-group"> |
|
<input |
|
type="text" |
|
bind:value={state.codeSearch.query} |
|
placeholder="Search code..." |
|
onkeydown={(e) => e.key === 'Enter' && performCodeSearch()} |
|
class="code-search-input" |
|
/> |
|
<select bind:value={state.codeSearch.scope} class="code-search-scope"> |
|
<option value="repo">This Repository</option> |
|
<option value="all">All Repositories</option> |
|
</select> |
|
<button onclick={performCodeSearch} disabled={state.loading.codeSearch || !state.codeSearch.query.trim()} class="search-button"> |
|
{state.loading.codeSearch ? 'Searching...' : 'Search'} |
|
</button> |
|
</div> |
|
</div> |
|
{#if state.loading.codeSearch} |
|
<div class="empty-state"> |
|
<p>Searching...</p> |
|
</div> |
|
{:else if state.codeSearch.results.length > 0} |
|
<div class="code-search-results"> |
|
<h3>Found {state.codeSearch.results.length} result{state.codeSearch.results.length !== 1 ? 's' : ''}</h3> |
|
{#each state.codeSearch.results as result} |
|
<div class="code-search-result-item"> |
|
<div class="result-header"> |
|
<span class="result-file">{result.file}</span> |
|
<span class="result-line">Line {result.line}</span> |
|
{#if state.codeSearch.scope === 'all' && 'repo' in result} |
|
<span class="result-repo">{result.repo || state.npub}/{result.repo || state.repo}</span> |
|
{/if} |
|
</div> |
|
<pre class="result-content">{result.content}</pre> |
|
</div> |
|
{/each} |
|
</div> |
|
{:else if state.codeSearch.query.trim() && !state.loading.codeSearch} |
|
<div class="empty-state"> |
|
{#if state.error} |
|
<p class="state.error-message">Error: {state.error}</p> |
|
{:else} |
|
<p>No results found</p> |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
<!-- Issues tab content is now handled by IssuesTab component --> |
|
|
|
<!-- PRs tab content is now handled by PRsTab component --> |
|
|
|
<!-- Patches tab content is now handled by PatchesTab component --> |
|
|
|
|
|
<!-- Docs tab content is now handled by DocsTab component --> |
|
</div> |
|
{/if} |
|
</main> |
|
|
|
<!-- Dialogs --> |
|
<CreateFileDialog |
|
open={state.openDialog === 'createFile' && !!state.user.pubkey && state.maintainers.isMaintainer} |
|
{state} |
|
{needsClone} |
|
{cloneTooltip} |
|
onCreate={createFile} |
|
onClose={() => state.openDialog = null} |
|
/> |
|
|
|
<CreateBranchDialog |
|
open={state.openDialog === 'createBranch' && !!state.user.pubkey && state.maintainers.isMaintainer} |
|
{state} |
|
{needsClone} |
|
{cloneTooltip} |
|
onCreate={createBranch} |
|
onClose={() => state.openDialog = null} |
|
/> |
|
|
|
<CreateTagDialog |
|
open={state.openDialog === 'createTag' && !!state.user.pubkey && state.maintainers.isMaintainer} |
|
{state} |
|
{needsClone} |
|
{cloneTooltip} |
|
onCreate={createTag} |
|
onClose={() => state.openDialog = null} |
|
/> |
|
|
|
<CreateReleaseDialog |
|
open={state.openDialog === 'createRelease' && !!state.user.pubkey && (state.maintainers.isMaintainer || state.user.pubkeyHex === repoOwnerPubkeyDerived) && !!state.clone.isCloned} |
|
{state} |
|
onCreate={createRelease} |
|
onClose={() => state.openDialog = null} |
|
/> |
|
|
|
<CreateIssueDialog |
|
open={state.openDialog === 'createIssue' && !!state.user.pubkey} |
|
{state} |
|
onCreate={createIssue} |
|
onClose={() => state.openDialog = null} |
|
/> |
|
|
|
<CreateThreadDialog |
|
open={state.openDialog === 'createThread' && !!state.user.pubkey} |
|
{state} |
|
onCreate={createDiscussionThread} |
|
onClose={() => state.openDialog = null} |
|
/> |
|
|
|
<ReplyDialog |
|
open={state.openDialog === 'reply' && !!state.user.pubkey && (!!state.discussion.replyingToThread || !!state.discussion.replyingToComment)} |
|
{state} |
|
onCreate={createThreadReply} |
|
onClose={() => state.openDialog = null} |
|
/> |
|
|
|
<CreatePRDialog |
|
open={state.openDialog === 'createPR' && !!state.user.pubkey} |
|
{state} |
|
onCreate={createPR} |
|
onClose={() => state.openDialog = null} |
|
/> |
|
|
|
<CreatePatchDialog |
|
open={state.openDialog === 'createPatch' && !!state.user.pubkey} |
|
{state} |
|
onCreate={createPatch} |
|
onClose={() => state.openDialog = null} |
|
/> |
|
|
|
<PatchHighlightDialog |
|
open={state.openDialog === 'patchHighlight'} |
|
{state} |
|
onCreate={createPatchHighlight} |
|
onClose={() => state.openDialog = null} |
|
/> |
|
|
|
<PatchCommentDialog |
|
open={state.openDialog === 'patchComment'} |
|
{state} |
|
onCreate={createPatchComment} |
|
onClose={() => state.openDialog = null} |
|
/> |
|
|
|
<CommitDialog |
|
open={state.openDialog === 'commit' && !!state.user.pubkey && state.maintainers.isMaintainer} |
|
{state} |
|
{needsClone} |
|
{cloneTooltip} |
|
onCommit={saveFile} |
|
onClose={() => state.openDialog = null} |
|
/> |
|
|
|
<VerificationDialog |
|
open={state.openDialog === 'verification' && !!state.verification.fileContent} |
|
{state} |
|
onCopy={copyVerificationToClipboard} |
|
onDownload={downloadVerificationFile} |
|
onClose={() => state.openDialog = null} |
|
/> |
|
|
|
<CloneUrlVerificationDialog |
|
open={state.openDialog === 'cloneUrlVerification'} |
|
{state} |
|
onVerify={verifyCloneUrl} |
|
onClose={() => state.openDialog = null} |
|
/> |
|
</div> |
|
|
|
<style> |
|
/* Word wrap styles - ensure they apply with highest specificity */ |
|
:global(.read-only-editor.word-wrap) { |
|
overflow-x: hidden !important; |
|
} |
|
|
|
:global(.read-only-editor.word-wrap pre) { |
|
white-space: pre-wrap !important; |
|
word-wrap: break-word !important; |
|
overflow-wrap: break-word !important; |
|
overflow-x: hidden !important; |
|
overflow-y: visible !important; |
|
max-width: 100% !important; |
|
} |
|
|
|
:global(.read-only-editor.word-wrap pre code), |
|
:global(.read-only-editor.word-wrap pre code.hljs), |
|
:global(.read-only-editor.word-wrap code.hljs), |
|
:global(.read-only-editor.word-wrap .hljs) { |
|
white-space: pre-wrap !important; |
|
word-wrap: break-word !important; |
|
overflow-wrap: break-word !important; |
|
overflow-x: hidden !important; |
|
overflow-y: visible !important; |
|
display: block !important; |
|
max-width: 100% !important; |
|
} |
|
|
|
:global(.read-only-editor.word-wrap pre code.hljs *), |
|
:global(.read-only-editor.word-wrap pre code.hljs span), |
|
:global(.read-only-editor.word-wrap code.hljs *), |
|
:global(.read-only-editor.word-wrap .hljs *) { |
|
white-space: pre-wrap !important; |
|
} |
|
|
|
/* Image preview styling */ |
|
:global(.image-preview) { |
|
display: flex; |
|
justify-content: center; |
|
align-items: center; |
|
padding: 1rem; |
|
background: var(--bg-secondary); |
|
border-radius: 4px; |
|
min-height: 200px; |
|
} |
|
|
|
:global(.file-image) { |
|
max-width: 100%; |
|
max-height: 80vh; |
|
height: auto; |
|
object-fit: contain; |
|
border-radius: 4px; |
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
|
} |
|
|
|
/* Tag-related styles have been moved to TagsTab.svelte component */ |
|
</style>
|
|
|