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.
7206 lines
266 KiB
7206 lines
266 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 { 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 { 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 { fetchUserEmail, fetchUserName } from '$lib/utils/user-profile.js'; |
|
|
|
// Get page data for OpenGraph metadata - use state to avoid SSR issues |
|
// Guard against SSR - $page store can only be accessed in component context |
|
let pageData = $state<{ |
|
title?: string; |
|
description?: string; |
|
image?: string; |
|
banner?: string; |
|
repoUrl?: string; |
|
announcement?: NostrEvent; |
|
gitDomain?: string; |
|
}>({}); |
|
|
|
// Update pageData from $page when available (client-side) |
|
$effect(() => { |
|
if (typeof window === 'undefined' || !isMounted) return; |
|
try { |
|
const data = $page.data as typeof pageData; |
|
if (data && isMounted) { |
|
pageData = data || {}; |
|
} |
|
} catch (err) { |
|
// Ignore SSR errors and errors during destruction |
|
if (isMounted) { |
|
console.warn('Failed to update pageData:', err); |
|
} |
|
} |
|
}); |
|
|
|
// Guard params access during SSR - use state that gets updated reactively |
|
// Params come from the route, so we can parse from URL or get from $page.params on client |
|
let npub = $state(''); |
|
let repo = $state(''); |
|
|
|
// Update params from $page when available (client-side) |
|
$effect(() => { |
|
if (typeof window === 'undefined' || !isMounted) return; |
|
try { |
|
const params = $page.params as { npub?: string; repo?: string }; |
|
if (params && isMounted) { |
|
if (params.npub && params.npub !== npub) npub = params.npub; |
|
if (params.repo && params.repo !== repo) repo = params.repo; |
|
} |
|
} catch { |
|
// If $page.params fails, try to parse from URL path |
|
if (!isMounted) return; |
|
try { |
|
if (typeof window !== 'undefined') { |
|
const pathParts = window.location.pathname.split('/').filter(Boolean); |
|
if (pathParts[0] === 'repos' && pathParts[1] && pathParts[2] && isMounted) { |
|
npub = pathParts[1]; |
|
repo = pathParts[2]; |
|
} |
|
} |
|
} catch { |
|
// Ignore errors - params will be set eventually |
|
} |
|
} |
|
}); |
|
|
|
// Extract fields from announcement for convenience |
|
const repoAnnouncement = $derived(pageData.announcement); |
|
const repoName = $derived(repoAnnouncement?.tags.find((t: string[]) => t[0] === 'name')?.[1] || 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 (pageData && typeof pageData === 'object' && pageData.repoUrl) { |
|
const url = 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 = (pageData?.banner || repoBanner) || (pageData?.image || repoImage); |
|
if (banner && typeof banner === 'string' && banner.trim()) { |
|
return "summary_large_image"; |
|
} |
|
return "summary"; |
|
} catch { |
|
return "summary"; |
|
} |
|
}); |
|
|
|
|
|
let loading = $state(true); |
|
let error = $state<string | null>(null); |
|
let repoNotFound = $state(false); // Track if repository doesn't exist |
|
let files = $state<Array<{ name: string; path: string; type: 'file' | 'directory'; size?: number }>>([]); |
|
let currentPath = $state(''); |
|
let currentFile = $state<string | null>(null); |
|
let fileContent = $state(''); |
|
let fileLanguage = $state<'markdown' | 'asciidoc' | 'text'>('text'); |
|
let editedContent = $state(''); |
|
let hasChanges = $state(false); |
|
let saving = $state(false); |
|
let branches = $state<Array<string | { name: string; commit?: any }>>([]); |
|
let currentBranch = $state<string | null>(null); |
|
let defaultBranch = $state<string | null>(null); |
|
let commitMessage = $state(''); |
|
let userPubkey = $state<string | null>(null); |
|
let userPubkeyHex = $state<string | null>(null); |
|
let showCommitDialog = $state(false); |
|
let activeTab = $state<'files' | 'history' | 'tags' | 'issues' | 'prs' | 'docs' | 'discussions' | 'patches' | 'releases' | 'code-search'>('files'); |
|
let showRepoMenu = $state(false); |
|
|
|
// Tabs will be defined as derived after issues and prs are declared |
|
|
|
// Component mount tracking to prevent state updates after destruction |
|
let isMounted = $state(true); |
|
|
|
// Helper function to safely update state only if component is still mounted |
|
function safeStateUpdate<T>(updateFn: () => T): T | null { |
|
if (!isMounted) return null; |
|
try { |
|
return updateFn(); |
|
} catch (err) { |
|
// Silently ignore errors during destruction |
|
if (isMounted) { |
|
console.warn('State update error (component may be destroying):', err); |
|
} |
|
return null; |
|
} |
|
} |
|
|
|
// Store event listener handler for cleanup |
|
let clickOutsideHandler: ((event: MouseEvent) => void) | null = null; |
|
|
|
// Auto-save |
|
let autoSaveInterval: ReturnType<typeof setInterval> | null = null; |
|
|
|
// Load maintainers when page data changes (only once per repo, with guard) |
|
let lastRepoKey = $state<string | null>(null); |
|
let maintainersEffectRan = $state(false); |
|
|
|
$effect(() => { |
|
// Guard against SSR and component destruction |
|
if (typeof window === 'undefined' || !isMounted) return; |
|
try { |
|
const data = $page.data as typeof pageData; |
|
if (!data || !isMounted) return; |
|
|
|
const currentRepoKey = `${npub}/${repo}`; |
|
|
|
// Reset flags if repo changed |
|
if (currentRepoKey !== lastRepoKey && isMounted) { |
|
maintainersLoaded = false; |
|
maintainersEffectRan = false; |
|
lastRepoKey = currentRepoKey; |
|
} |
|
|
|
// Only load if: |
|
// 1. We have page data |
|
// 2. Effect hasn't run yet for this repo |
|
// 3. We're not currently loading |
|
// 4. Component is still mounted |
|
if (isMounted && |
|
(repoOwnerPubkeyDerived || (repoMaintainers && repoMaintainers.length > 0)) && |
|
!maintainersEffectRan && |
|
!loadingMaintainers) { |
|
maintainersEffectRan = true; // Mark as ran to prevent re-running |
|
maintainersLoaded = true; // Set flag before loading to prevent concurrent calls |
|
loadAllMaintainers().catch(err => { |
|
if (!isMounted) return; |
|
maintainersLoaded = false; // Reset on error so we can retry |
|
maintainersEffectRan = false; // Allow retry |
|
console.warn('Failed to load maintainers:', err); |
|
}); |
|
} |
|
} catch (err) { |
|
// Ignore SSR errors and errors during destruction |
|
if (isMounted) { |
|
console.warn('Maintainers effect error:', err); |
|
} |
|
} |
|
}); |
|
|
|
// Watch for auto-save setting changes |
|
$effect(() => { |
|
if (!isMounted) return; |
|
// Check auto-save setting and update interval (async, but don't await) |
|
settingsStore.getSettings().then(settings => { |
|
if (!isMounted) return; |
|
if (settings.autoSave && !autoSaveInterval) { |
|
// Auto-save was enabled, set it up |
|
setupAutoSave(); |
|
} else if (!settings.autoSave && autoSaveInterval) { |
|
// Auto-save was disabled, clear interval |
|
if (autoSaveInterval) { |
|
clearInterval(autoSaveInterval); |
|
autoSaveInterval = null; |
|
} |
|
} |
|
}).catch(err => { |
|
if (isMounted) { |
|
console.warn('Failed to check auto-save setting:', err); |
|
} |
|
}); |
|
}); |
|
|
|
// Sync with userStore |
|
$effect(() => { |
|
if (!isMounted) return; |
|
try { |
|
const currentUser = $userStore; |
|
if (!currentUser || !isMounted) return; |
|
|
|
const wasLoggedIn = userPubkey !== null || userPubkeyHex !== null; |
|
|
|
if (currentUser.userPubkey && currentUser.userPubkeyHex && isMounted) { |
|
const wasDifferent = userPubkey !== currentUser.userPubkey || userPubkeyHex !== currentUser.userPubkeyHex; |
|
userPubkey = currentUser.userPubkey; |
|
userPubkeyHex = currentUser.userPubkeyHex; |
|
|
|
// Reload data when user logs in or pubkey changes |
|
if (wasDifferent && isMounted) { |
|
// Reset repoNotFound flag when user logs in, so we can retry loading |
|
repoNotFound = false; |
|
// Clear cached email and name when user changes |
|
cachedUserEmail = null; |
|
cachedUserName = null; |
|
|
|
if (!isMounted) return; |
|
checkMaintainerStatus().catch(err => { |
|
if (isMounted) console.warn('Failed to reload maintainer status after login:', err); |
|
}); |
|
loadBookmarkStatus().catch(err => { |
|
if (isMounted) console.warn('Failed to reload bookmark status after login:', err); |
|
}); |
|
// Reset flags to allow reload |
|
maintainersLoaded = false; |
|
maintainersEffectRan = false; |
|
lastRepoKey = null; |
|
loadAllMaintainers().catch(err => { |
|
if (isMounted) console.warn('Failed to reload maintainers after login:', err); |
|
}); |
|
// Recheck clone status after login (force refresh) - delay slightly to ensure auth headers are ready |
|
setTimeout(() => { |
|
if (isMounted) { |
|
checkCloneStatus(true).catch(err => { |
|
if (isMounted) console.warn('Failed to recheck clone status after login:', err); |
|
}); |
|
} |
|
}, 100); |
|
// Reload all repository data with the new user context |
|
if (!loading && isMounted) { |
|
loadBranches().catch(err => { |
|
if (isMounted) console.warn('Failed to reload branches after login:', err); |
|
}); |
|
loadFiles().catch(err => { |
|
if (isMounted) console.warn('Failed to reload files after login:', err); |
|
}); |
|
loadReadme().catch(err => { |
|
if (isMounted) console.warn('Failed to reload readme after login:', err); |
|
}); |
|
loadTags().catch(err => { |
|
if (isMounted) console.warn('Failed to reload tags after login:', err); |
|
}); |
|
// Reload discussions when user logs in (needs user context for relay selection) |
|
loadDiscussions().catch(err => { |
|
if (isMounted) console.warn('Failed to reload discussions after login:', err); |
|
}); |
|
} |
|
} |
|
} else if (isMounted) { |
|
userPubkey = null; |
|
userPubkeyHex = null; |
|
// Clear cached email and name when user logs out |
|
cachedUserEmail = null; |
|
cachedUserName = null; |
|
|
|
// Reload data when user logs out to hide private content |
|
if (wasLoggedIn && isMounted) { |
|
checkMaintainerStatus().catch(err => { |
|
if (isMounted) console.warn('Failed to reload maintainer status after logout:', err); |
|
}); |
|
loadBookmarkStatus().catch(err => { |
|
if (isMounted) console.warn('Failed to reload bookmark status after logout:', err); |
|
}); |
|
// Reset flags to allow reload |
|
maintainersLoaded = false; |
|
maintainersEffectRan = false; |
|
lastRepoKey = null; |
|
loadAllMaintainers().catch(err => { |
|
if (isMounted) console.warn('Failed to reload maintainers after logout:', err); |
|
}); |
|
// If repo is private and user logged out, reload to trigger access check |
|
if (!loading && activeTab === 'files' && isMounted) { |
|
loadFiles().catch(err => { |
|
if (isMounted) console.warn('Failed to reload files after logout:', err); |
|
}); |
|
} |
|
} |
|
} |
|
} catch (err) { |
|
// Ignore errors during destruction |
|
if (isMounted) { |
|
console.warn('User store sync error:', err); |
|
} |
|
} |
|
}); |
|
|
|
// Navigation stack for directories |
|
let pathStack = $state<string[]>([]); |
|
|
|
// New file creation |
|
let showCreateFileDialog = $state(false); |
|
let newFileName = $state(''); |
|
let newFileContent = $state(''); |
|
|
|
// Branch creation |
|
let showCreateBranchDialog = $state(false); |
|
let newBranchName = $state(''); |
|
let newBranchFrom = $state<string | null>(null); |
|
let defaultBranchName = $state('master'); // Default branch from settings |
|
|
|
// Commit history |
|
let commits = $state<Array<{ hash: string; message: string; author: string; date: string; files: string[] }>>([]); |
|
let loadingCommits = $state(false); |
|
let selectedCommit = $state<string | null>(null); |
|
let showDiff = $state(false); |
|
let diffData = $state<Array<{ file: string; additions: number; deletions: number; diff: string }>>([]); |
|
|
|
// Tags |
|
let tags = $state<Array<{ name: string; hash: string; message?: string; date?: number }>>([]); |
|
let selectedTag = $state<string | null>(null); |
|
let showCreateTagDialog = $state(false); |
|
let newTagName = $state(''); |
|
let newTagMessage = $state(''); |
|
let newTagRef = $state('HEAD'); |
|
|
|
// Maintainer status |
|
let isMaintainer = $state(false); |
|
let loadingMaintainerStatus = $state(false); |
|
|
|
// All maintainers (including owner) for display |
|
let allMaintainers = $state<Array<{ pubkey: string; isOwner: boolean }>>([]); |
|
let loadingMaintainers = $state(false); |
|
let maintainersLoaded = $state(false); // Guard to prevent repeated loads |
|
|
|
// Clone status |
|
let isRepoCloned = $state<boolean | null>(null); // null = unknown, true = cloned, false = not cloned |
|
let checkingCloneStatus = $state(false); |
|
let cloning = $state(false); |
|
|
|
// Word wrap toggle |
|
let wordWrap = $state(false); |
|
|
|
// Function to toggle word wrap and refresh highlighting |
|
async function toggleWordWrap() { |
|
wordWrap = !wordWrap; |
|
console.log('Word wrap toggled:', wordWrap); |
|
// Force DOM update by accessing the element |
|
await new Promise(resolve => { |
|
requestAnimationFrame(() => { |
|
requestAnimationFrame(resolve); |
|
}); |
|
}); |
|
// Re-apply syntax highlighting to refresh the display |
|
if (currentFile && fileContent) { |
|
const ext = currentFile.split('.').pop() || ''; |
|
await applySyntaxHighlighting(fileContent, ext); |
|
} |
|
} |
|
let copyingCloneUrl = $state(false); |
|
let apiFallbackAvailable = $state<boolean | null>(null); // null = unknown, true = API fallback works, false = doesn't work |
|
|
|
// Helper: Check if repo needs to be cloned for write operations |
|
const needsClone = $derived(isRepoCloned === false); |
|
// Helper: Check if we can use API fallback for read-only operations |
|
const canUseApiFallback = $derived(apiFallbackAvailable === true); |
|
// Helper: Check if we have any way to view the repo (cloned or API fallback) |
|
const canViewRepo = $derived(isRepoCloned === true || canUseApiFallback); |
|
const cloneTooltip = 'Please clone this repo to use this feature.'; |
|
|
|
// Copy clone URL to clipboard |
|
async function copyCloneUrl() { |
|
if (copyingCloneUrl) return; |
|
|
|
copyingCloneUrl = 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/${npub}/${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 { |
|
copyingCloneUrl = false; |
|
} |
|
} |
|
|
|
// Verification status |
|
let verificationStatus = $state<{ |
|
verified: boolean; |
|
error?: string; |
|
message?: string; |
|
cloneVerifications?: Array<{ url: string; verified: boolean; ownerPubkey: string | null; error?: string }>; |
|
} | null>(null); |
|
let showVerificationDialog = $state(false); |
|
let verificationFileContent = $state<string | null>(null); |
|
let loadingVerification = $state(false); |
|
|
|
// Deletion request |
|
let deletingAnnouncement = $state(false); |
|
let announcementEventId = $state<string | null>(null); |
|
|
|
// Issues |
|
let issues = $state<Array<{ id: string; subject: string; content: string; status: string; author: string; created_at: number; kind: number; tags?: string[][] }>>([]); |
|
let loadingIssues = $state(false); |
|
let showCreateIssueDialog = $state(false); |
|
let newIssueSubject = $state(''); |
|
let newIssueContent = $state(''); |
|
let newIssueLabels = $state<string[]>(['']); |
|
let updatingIssueStatus = $state<Record<string, boolean>>({}); |
|
let selectedIssue = $state<string | null>(null); |
|
let issueReplies = $state<Array<{ id: string; content: string; author: string; created_at: number; tags: string[][] }>>([]); |
|
let loadingIssueReplies = $state(false); |
|
|
|
// Pull Requests |
|
let prs = $state<Array<{ id: string; subject: string; content: string; status: string; author: string; created_at: number; commitId?: string; kind: number }>>([]); |
|
let loadingPRs = $state(false); |
|
let showCreatePRDialog = $state(false); |
|
let newPRSubject = $state(''); |
|
let newPRContent = $state(''); |
|
let newPRCommitId = $state(''); |
|
let newPRBranchName = $state(''); |
|
let newPRLabels = $state<string[]>(['']); |
|
let selectedPR = $state<string | null>(null); |
|
|
|
// Tabs menu - defined after issues and 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: 'issues', label: 'Issues', icon: '/icons/alert-circle.svg', requiresClone: false }, |
|
{ id: 'prs', label: 'Pull Requests', icon: '/icons/git-pull-request.svg', requiresClone: false }, |
|
{ id: 'patches', label: 'Patches', icon: '/icons/clipboard-list.svg', requiresClone: false }, |
|
{ id: 'discussions', label: 'Discussions', icon: '/icons/message-circle.svg', requiresClone: false }, |
|
{ id: 'history', label: 'Commit History', icon: '/icons/git-commit.svg', requiresClone: true }, |
|
{ id: '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 cloning |
|
if (isRepoCloned === 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); |
|
}); |
|
|
|
// Redirect to a valid tab if current tab requires cloning but repo isn't cloned and API fallback isn't available |
|
$effect(() => { |
|
if (!isMounted) return; |
|
if (isRepoCloned === false && !canUseApiFallback && tabs.length > 0) { |
|
const currentTab = tabs.find(t => t.id === activeTab); |
|
if (!currentTab && isMounted) { |
|
// Current tab requires cloning, switch to first available tab |
|
activeTab = tabs[0].id as typeof activeTab; |
|
} |
|
} |
|
}); |
|
|
|
// Patches |
|
let patches = $state<Array<{ id: string; subject: string; content: string; author: string; created_at: number; kind: number; description?: string; tags?: string[][] }>>([]); |
|
let loadingPatches = $state(false); |
|
let selectedPatch = $state<string | null>(null); |
|
let showCreatePatchDialog = $state(false); |
|
let newPatchContent = $state(''); |
|
let newPatchSubject = $state(''); |
|
let creatingPatch = $state(false); |
|
|
|
// Documentation |
|
let documentationContent = $state<string | null>(null); |
|
let documentationHtml = $state<string | null>(null); |
|
let documentationKind = $state<number | null>(null); |
|
let loadingDocs = $state(false); |
|
|
|
// Discussion threads |
|
let showCreateThreadDialog = $state(false); |
|
let newThreadTitle = $state(''); |
|
let newThreadContent = $state(''); |
|
let creatingThread = $state(false); |
|
|
|
// Thread replies |
|
let expandedThreads = $state<Set<string>>(new Set()); |
|
let showReplyDialog = $state(false); |
|
let replyingToThread = $state<{ id: string; kind?: number; pubkey?: string; author: string } | null>(null); |
|
let replyingToComment = $state<{ id: string; kind?: number; pubkey?: string; author: string } | null>(null); |
|
let replyContent = $state(''); |
|
let creatingReply = $state(false); |
|
|
|
// Releases |
|
let releases = $state<Array<{ |
|
id: string; |
|
tagName: string; |
|
tagHash?: string; |
|
releaseNotes?: string; |
|
isDraft?: boolean; |
|
isPrerelease?: boolean; |
|
created_at: number; |
|
pubkey: string; |
|
}>>([]); |
|
let loadingReleases = $state(false); |
|
let showCreateReleaseDialog = $state(false); |
|
let newReleaseTagName = $state(''); |
|
let newReleaseTagHash = $state(''); |
|
let newReleaseNotes = $state(''); |
|
let newReleaseIsDraft = $state(false); |
|
let newReleaseIsPrerelease = $state(false); |
|
let creatingRelease = $state(false); |
|
|
|
// Code Search |
|
let codeSearchQuery = $state(''); |
|
let codeSearchResults = $state<Array<{ |
|
file: string; |
|
line: number; |
|
content: string; |
|
branch: string; |
|
}>>([]); |
|
let loadingCodeSearch = $state(false); |
|
let codeSearchScope = $state<'repo' | 'all'>('repo'); |
|
|
|
// Discussions |
|
let selectedDiscussion = $state<string | null>(null); |
|
let discussions = $state<Array<{ |
|
type: 'thread' | 'comments'; |
|
id: string; |
|
title: string; |
|
content: string; |
|
author: string; |
|
createdAt: number; |
|
kind?: number; |
|
pubkey?: string; |
|
comments?: Array<{ |
|
id: string; |
|
content: string; |
|
author: string; |
|
createdAt: number; |
|
kind?: number; |
|
pubkey?: string; |
|
replies?: Array<{ |
|
id: string; |
|
content: string; |
|
author: string; |
|
createdAt: number; |
|
kind?: number; |
|
pubkey?: string; |
|
replies?: Array<{ |
|
id: string; |
|
content: string; |
|
author: string; |
|
createdAt: number; |
|
kind?: number; |
|
pubkey?: string; |
|
}>; |
|
}>; |
|
}> |
|
}>>([]); |
|
let loadingDiscussions = $state(false); |
|
|
|
// Discussion events cache for reply/quote blurbs |
|
let discussionEvents = $state<Map<string, NostrEvent>>(new Map()); |
|
|
|
// Nostr link cache for embedded events and profiles |
|
let nostrLinkEvents = $state<Map<string, NostrEvent>>(new Map()); |
|
let nostrLinkProfiles = $state<Map<string, string>>(new Map()); // npub -> pubkey hex |
|
|
|
// Parse nostr: links from content and extract IDs/pubkeys |
|
function parseNostrLinks(content: string): Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; value: string; start: number; end: number }> { |
|
const links: Array<{ type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; value: string; start: number; end: number }> = []; |
|
const nostrLinkRegex = /nostr:(nevent1|naddr1|note1|npub1|profile1)[a-zA-Z0-9]+/g; |
|
let match; |
|
|
|
while ((match = nostrLinkRegex.exec(content)) !== null) { |
|
const fullMatch = match[0]; |
|
const prefix = match[1]; |
|
let type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'profile'; |
|
|
|
if (prefix === 'nevent1') type = 'nevent'; |
|
else if (prefix === 'naddr1') type = 'naddr'; |
|
else if (prefix === 'note1') type = 'note1'; |
|
else if (prefix === 'npub1') type = 'npub'; |
|
else if (prefix === 'profile1') type = 'profile'; |
|
else continue; |
|
|
|
links.push({ |
|
type, |
|
value: fullMatch, |
|
start: match.index, |
|
end: match.index + fullMatch.length |
|
}); |
|
} |
|
|
|
return links; |
|
} |
|
|
|
// 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); |
|
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) { |
|
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) { |
|
nostrLinkEvents.set(events[0].id, events[0]); |
|
} |
|
} catch { |
|
// Ignore fetch errors |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
// Get event from nostr: link |
|
function getEventFromNostrLink(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 nostrLinkEvents.get(decoded.data.id); |
|
} else if (decoded.type === 'note') { |
|
return 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(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 |
|
function getPubkeyFromNostrLink(link: string): string | undefined { |
|
return nostrLinkProfiles.get(link); |
|
} |
|
|
|
// Process content with nostr links into parts for rendering |
|
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 = getEventFromNostrLink(link.value); |
|
const pubkey = getPubkeyFromNostrLink(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 discussions and comments to get tags for blurbs |
|
async function loadDiscussionEvents(discussionsList: typeof 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) { |
|
discussionEvents.set(event.id, event); |
|
} |
|
} catch { |
|
// Ignore fetch errors |
|
} |
|
} |
|
|
|
// Get discussion event by ID |
|
function getDiscussionEvent(eventId: string): NostrEvent | undefined { |
|
return discussionEvents.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 = discussionEvents.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(discussionEvents.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 discussionEvents.get(qTag); |
|
} |
|
|
|
return undefined; |
|
} |
|
|
|
// Format time for 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 |
|
let readmeContent = $state<string | null>(null); |
|
let readmePath = $state<string | null>(null); |
|
let readmeIsMarkdown = $state(false); |
|
let loadingReadme = $state(false); |
|
let readmeHtml = $state<string>(''); |
|
let highlightedFileContent = $state<string>(''); |
|
let fileHtml = $state<string>(''); // Rendered HTML for markdown/asciidoc/HTML files |
|
let showFilePreview = $state(true); // Toggle between preview and raw view (default: preview) |
|
let copyingFile = $state(false); // Track copy operation |
|
let isImageFile = $state(false); // Track if current file is an image |
|
let imageUrl = $state<string | null>(null); // URL for image files |
|
|
|
// 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 = currentBranch || 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/${npub}/${repo}/raw?path=${encodeURIComponent(imagePath)}&ref=${encodeURIComponent(branch)}`; |
|
|
|
return `<img${before} src="${apiUrl}"${after}>`; |
|
}); |
|
} |
|
|
|
// Fork |
|
let forkInfo = $state<{ isFork: boolean; originalRepo: { npub: string; repo: string } | null } | null>(null); |
|
let forking = $state(false); |
|
|
|
// Bookmarks |
|
let isBookmarked = $state(false); |
|
let loadingBookmark = $state(false); |
|
let bookmarksService: BookmarksService | null = null; |
|
let repoAddress = $state<string | null>(null); |
|
|
|
// Repository images |
|
let repoImage = $state<string | null>(null); |
|
let repoBanner = $state<string | null>(null); |
|
|
|
// Safe values for head section to prevent SSR errors (must be after repoImage/repoBanner declaration) |
|
const safeRepo = $derived(repo || 'Repository'); |
|
const safeRepoName = $derived.by(() => { |
|
try { |
|
return repoName || repo || 'Repository'; |
|
} catch { |
|
return repo || 'Repository'; |
|
} |
|
}); |
|
const safeRepoDescription = $derived.by(() => { |
|
try { |
|
return repoDescription || ''; |
|
} catch { |
|
return ''; |
|
} |
|
}); |
|
const safeTitle = $derived.by(() => { |
|
try { |
|
return pageData?.title || `${safeRepo} - Repository`; |
|
} catch { |
|
return `${safeRepo} - Repository`; |
|
} |
|
}); |
|
const safeDescription = $derived.by(() => { |
|
try { |
|
return pageData?.description || `Repository: ${safeRepo}`; |
|
} catch { |
|
return `Repository: ${safeRepo}`; |
|
} |
|
}); |
|
const safeImage = $derived.by(() => { |
|
try { |
|
return pageData?.image || repoImage || null; |
|
} catch { |
|
return null; |
|
} |
|
}); |
|
const safeBanner = $derived.by(() => { |
|
try { |
|
return pageData?.banner || repoBanner || 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 pageData?.description || safeRepoDescription || `Repository: ${safeRepoName || safeRepo || 'Repository'}`; |
|
} catch { |
|
return 'Repository'; |
|
} |
|
}); |
|
const safeTwitterDescription = $derived.by(() => { |
|
try { |
|
return 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 repoOwnerPubkeyState = $state<string | null>(null); |
|
|
|
// Mobile view toggle for file list/file viewer |
|
let showFileListOnMobile = $state(true); |
|
// Mobile view toggle for all left/right panels (issues, PRs, patches, discussions, docs, history, tags) |
|
let showLeftPanelOnMobile = $state(true); |
|
// Mobile collapse for clone URLs |
|
let cloneUrlsExpanded = $state(false); |
|
// Show all clone URLs (beyond the first 3) |
|
let showAllCloneUrls = $state(false); |
|
|
|
// Clone URL reachability |
|
let cloneUrlReachability = $state<Map<string, { reachable: boolean; error?: string; checkedAt: number; serverType: 'git' | 'grasp' | 'unknown' }>>(new Map()); |
|
let loadingReachability = $state(false); |
|
let checkingReachability = $state<Set<string>>(new Set()); |
|
|
|
// Guard to prevent README auto-load loop |
|
let readmeAutoLoadAttempted = $state(false); |
|
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 (loadingReachability) return; |
|
|
|
loadingReachability = true; |
|
try { |
|
const response = await fetch( |
|
`/api/repos/${npub}/${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' |
|
}); |
|
} |
|
} |
|
|
|
cloneUrlReachability = newMap; |
|
} |
|
} catch (err) { |
|
console.warn('Failed to load clone URL reachability:', err); |
|
} finally { |
|
loadingReachability = false; |
|
checkingReachability.clear(); |
|
} |
|
} |
|
|
|
async function loadReadme() { |
|
if (repoNotFound) return; |
|
loadingReadme = true; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/readme?ref=${currentBranch}`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
if (data.found) { |
|
readmeContent = data.content; |
|
readmePath = data.path; |
|
readmeIsMarkdown = data.isMarkdown; |
|
|
|
// Reset preview mode for README |
|
showFilePreview = true; |
|
readmeHtml = ''; |
|
|
|
// Render markdown or asciidoc if needed |
|
if (readmeContent) { |
|
const ext = readmePath?.split('.').pop()?.toLowerCase() || ''; |
|
if (readmeIsMarkdown || 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 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(readmeContent); |
|
// Rewrite image paths to point to repository API |
|
rendered = rewriteImagePaths(rendered, readmePath); |
|
readmeHtml = rendered; |
|
console.log('[README] Markdown rendered successfully, HTML length:', readmeHtml.length); |
|
} catch (err) { |
|
console.error('[README] Error rendering markdown:', err); |
|
readmeHtml = ''; |
|
} |
|
} else if (ext === 'adoc' || ext === 'asciidoc') { |
|
try { |
|
const Asciidoctor = (await import('@asciidoctor/core')).default; |
|
const asciidoctor = Asciidoctor(); |
|
const converted = asciidoctor.convert(readmeContent, { |
|
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, readmePath); |
|
readmeHtml = rendered; |
|
readmeIsMarkdown = true; // Treat as markdown for display purposes |
|
} catch (err) { |
|
console.error('[README] Error rendering asciidoc:', err); |
|
readmeHtml = ''; |
|
} |
|
} else if (ext === 'html' || ext === 'htm') { |
|
// Rewrite image paths to point to repository API |
|
readmeHtml = rewriteImagePaths(readmeContent, readmePath); |
|
readmeIsMarkdown = true; // Treat as markdown for display purposes |
|
} else { |
|
readmeHtml = ''; |
|
} |
|
} |
|
} |
|
} |
|
} catch (err) { |
|
console.error('Error loading README:', err); |
|
} finally { |
|
loadingReadme = false; |
|
} |
|
} |
|
|
|
// Map file extensions to highlight.js language names |
|
function getHighlightLanguage(ext: string): string { |
|
const langMap: Record<string, string> = { |
|
'js': 'javascript', |
|
'ts': 'typescript', |
|
'jsx': 'javascript', |
|
'tsx': 'typescript', |
|
'json': 'json', |
|
'css': 'css', |
|
'html': 'xml', |
|
'xml': 'xml', |
|
'yaml': 'yaml', |
|
'yml': 'yaml', |
|
'py': 'python', |
|
'rb': 'ruby', |
|
'go': 'go', |
|
'rs': 'rust', |
|
'java': 'java', |
|
'c': 'c', |
|
'cpp': 'cpp', |
|
'h': 'c', |
|
'hpp': 'cpp', |
|
'sh': 'bash', |
|
'bash': 'bash', |
|
'zsh': 'bash', |
|
'sql': 'sql', |
|
'php': 'php', |
|
'swift': 'swift', |
|
'kt': 'kotlin', |
|
'scala': 'scala', |
|
'r': 'r', |
|
'm': 'objectivec', |
|
'mm': 'objectivec', |
|
'vue': 'xml', |
|
'svelte': 'xml', |
|
'dockerfile': 'dockerfile', |
|
'toml': 'toml', |
|
'ini': 'ini', |
|
'conf': 'ini', |
|
'log': 'plaintext', |
|
'txt': 'plaintext', |
|
'md': 'markdown', |
|
'markdown': 'markdown', |
|
'mdown': 'markdown', |
|
'mkdn': 'markdown', |
|
'mkd': 'markdown', |
|
'mdwn': 'markdown', |
|
'adoc': 'asciidoc', |
|
'asciidoc': 'asciidoc', |
|
'ad': 'asciidoc', |
|
}; |
|
return langMap[ext.toLowerCase()] || 'plaintext'; |
|
} |
|
|
|
// Check if file type supports preview mode |
|
function supportsPreview(ext: string): boolean { |
|
const previewExtensions = ['md', 'markdown', 'adoc', 'asciidoc', 'html', 'htm', 'csv']; |
|
return previewExtensions.includes(ext.toLowerCase()); |
|
} |
|
|
|
// Check if a file is an image based on extension |
|
function isImageFileType(ext: string): boolean { |
|
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'apng', 'avif']; |
|
return imageExtensions.includes(ext.toLowerCase()); |
|
} |
|
|
|
// Render markdown, asciidoc, or HTML files as HTML |
|
async function renderFileAsHtml(content: string, ext: string) { |
|
try { |
|
const lowerExt = ext.toLowerCase(); |
|
|
|
if (lowerExt === 'md' || lowerExt === 'markdown') { |
|
// Render markdown |
|
const MarkdownIt = (await import('markdown-it')).default; |
|
const hljsModule = await import('highlight.js'); |
|
const hljs = hljsModule.default || hljsModule; |
|
|
|
const md = new MarkdownIt({ |
|
html: true, |
|
linkify: true, |
|
typographer: true, |
|
breaks: true, |
|
highlight: function (str: string, lang: string): string { |
|
if (lang && hljs.getLanguage(lang)) { |
|
try { |
|
return hljs.highlight(str, { language: lang }).value; |
|
} catch (__) {} |
|
} |
|
try { |
|
return hljs.highlightAuto(str).value; |
|
} catch (__) {} |
|
return ''; |
|
} |
|
}); |
|
|
|
let rendered = md.render(content); |
|
// Rewrite image paths to point to repository API |
|
rendered = rewriteImagePaths(rendered, currentFile); |
|
fileHtml = rendered; |
|
} else if (lowerExt === 'adoc' || lowerExt === 'asciidoc') { |
|
// Render asciidoc |
|
const Asciidoctor = (await import('@asciidoctor/core')).default; |
|
const asciidoctor = Asciidoctor(); |
|
const converted = asciidoctor.convert(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, currentFile); |
|
fileHtml = rendered; |
|
} else if (lowerExt === 'html' || lowerExt === 'htm') { |
|
// HTML files - rewrite image paths |
|
let rendered = content; |
|
rendered = rewriteImagePaths(rendered, currentFile); |
|
fileHtml = rendered; |
|
} else if (lowerExt === 'csv') { |
|
// Parse CSV and render as HTML table |
|
fileHtml = renderCsvAsTable(content); |
|
} |
|
} catch (err) { |
|
console.error('Error rendering file as HTML:', err); |
|
fileHtml = ''; |
|
} |
|
} |
|
|
|
// Parse CSV content and render as HTML table |
|
function renderCsvAsTable(csvContent: string): string { |
|
try { |
|
// Parse CSV - handle quoted fields and escaped quotes |
|
const lines = csvContent.split(/\r?\n/).filter(line => line.trim() !== ''); |
|
if (lines.length === 0) { |
|
return '<div class="csv-empty"><p>Empty CSV file</p></div>'; |
|
} |
|
|
|
const rows: string[][] = []; |
|
|
|
for (const line of lines) { |
|
const row: string[] = []; |
|
let currentField = ''; |
|
let inQuotes = false; |
|
|
|
for (let i = 0; i < line.length; i++) { |
|
const char = line[i]; |
|
const nextChar = line[i + 1]; |
|
|
|
if (char === '"') { |
|
if (inQuotes && nextChar === '"') { |
|
// Escaped quote |
|
currentField += '"'; |
|
i++; // Skip next quote |
|
} else { |
|
// Toggle quote state |
|
inQuotes = !inQuotes; |
|
} |
|
} else if (char === ',' && !inQuotes) { |
|
// Field separator |
|
row.push(currentField); |
|
currentField = ''; |
|
} else { |
|
currentField += char; |
|
} |
|
} |
|
|
|
// Add the last field |
|
row.push(currentField); |
|
rows.push(row); |
|
} |
|
|
|
if (rows.length === 0) { |
|
return '<div class="csv-empty"><p>No data in CSV file</p></div>'; |
|
} |
|
|
|
// Find the maximum number of columns to ensure consistent table structure |
|
const maxColumns = Math.max(...rows.map(row => row.length)); |
|
|
|
// Determine if first row should be treated as header (if it has more than 1 row) |
|
const hasHeader = rows.length > 1; |
|
const headerRow = hasHeader ? rows[0] : null; |
|
const dataRows = hasHeader ? rows.slice(1) : rows; |
|
|
|
// Build HTML table |
|
let html = '<div class="csv-table-wrapper"><table class="csv-table">'; |
|
|
|
// Add header row if we have one |
|
if (hasHeader && headerRow) { |
|
html += '<thead><tr>'; |
|
for (let i = 0; i < maxColumns; i++) { |
|
const cell = headerRow[i] || ''; |
|
html += `<th>${escapeHtml(cell)}</th>`; |
|
} |
|
html += '</tr></thead>'; |
|
} |
|
|
|
// Add data rows |
|
html += '<tbody>'; |
|
for (const row of dataRows) { |
|
html += '<tr>'; |
|
for (let i = 0; i < maxColumns; i++) { |
|
const cell = row[i] || ''; |
|
html += `<td>${escapeHtml(cell)}</td>`; |
|
} |
|
html += '</tr>'; |
|
} |
|
html += '</tbody></table></div>'; |
|
|
|
return html; |
|
} catch (err) { |
|
console.error('Error parsing CSV:', err); |
|
return `<div class="csv-error"><p>Error parsing CSV: ${escapeHtml(err instanceof Error ? err.message : String(err))}</p></div>`; |
|
} |
|
} |
|
|
|
// Escape HTML to prevent XSS |
|
function escapeHtml(text: string): string { |
|
const map: Record<string, string> = { |
|
'&': '&', |
|
'<': '<', |
|
'>': '>', |
|
'"': '"', |
|
"'": ''' |
|
}; |
|
return text.replace(/[&<>"']/g, (m) => map[m]); |
|
} |
|
|
|
async function applySyntaxHighlighting(content: string, ext: string) { |
|
try { |
|
const hljsModule = await import('highlight.js'); |
|
// highlight.js v11+ uses default export |
|
const hljs = hljsModule.default || hljsModule; |
|
const lang = getHighlightLanguage(ext); |
|
|
|
// Register Markdown language if needed (not in highlight.js by default) |
|
if (lang === 'markdown' && !hljs.getLanguage('markdown')) { |
|
hljs.registerLanguage('markdown', function(hljs) { |
|
return { |
|
name: 'Markdown', |
|
aliases: ['md', 'mkdown', 'mkd'], |
|
contains: [ |
|
// Headers |
|
{ |
|
className: 'section', |
|
begin: /^#{1,6}\s+/, |
|
relevance: 10 |
|
}, |
|
// Bold |
|
{ |
|
className: 'strong', |
|
begin: /\*\*[^*]+\*\*/, |
|
relevance: 0 |
|
}, |
|
{ |
|
className: 'strong', |
|
begin: /__[^_]+__/, |
|
relevance: 0 |
|
}, |
|
// Italic |
|
{ |
|
className: 'emphasis', |
|
begin: /\*[^*]+\*/, |
|
relevance: 0 |
|
}, |
|
{ |
|
className: 'emphasis', |
|
begin: /_[^_]+_/, |
|
relevance: 0 |
|
}, |
|
// Inline code |
|
{ |
|
className: 'code', |
|
begin: /`[^`]+`/, |
|
relevance: 0 |
|
}, |
|
// Code blocks |
|
{ |
|
className: 'code', |
|
begin: /^```[\w]*/, |
|
end: /^```$/, |
|
contains: [{ begin: /./ }] |
|
}, |
|
// Links |
|
{ |
|
className: 'link', |
|
begin: /\[/, |
|
end: /\]/, |
|
contains: [ |
|
{ |
|
className: 'string', |
|
begin: /\(/, |
|
end: /\)/ |
|
} |
|
] |
|
}, |
|
// Images |
|
{ |
|
className: 'string', |
|
begin: /!\[/, |
|
end: /\]/ |
|
}, |
|
// Lists |
|
{ |
|
className: 'bullet', |
|
begin: /^(\s*)([*+-]|\d+\.)\s+/, |
|
relevance: 0 |
|
}, |
|
// Blockquotes |
|
{ |
|
className: 'quote', |
|
begin: /^>\s+/, |
|
relevance: 0 |
|
}, |
|
// Horizontal rules |
|
{ |
|
className: 'horizontal_rule', |
|
begin: /^(\*{3,}|-{3,}|_{3,})$/, |
|
relevance: 0 |
|
} |
|
] |
|
}; |
|
}); |
|
} |
|
|
|
// Register AsciiDoc language if needed (not in highlight.js by default) |
|
if (lang === 'asciidoc' && !hljs.getLanguage('asciidoc')) { |
|
hljs.registerLanguage('asciidoc', function(hljs) { |
|
return { |
|
name: 'AsciiDoc', |
|
aliases: ['adoc', 'asciidoc', 'ad'], |
|
contains: [ |
|
// Headers |
|
{ |
|
className: 'section', |
|
begin: /^={1,6}\s+/, |
|
relevance: 10 |
|
}, |
|
// Bold |
|
{ |
|
className: 'strong', |
|
begin: /\*\*[^*]+\*\*/, |
|
relevance: 0 |
|
}, |
|
// Italic |
|
{ |
|
className: 'emphasis', |
|
begin: /_[^_]+_/, |
|
relevance: 0 |
|
}, |
|
// Inline code |
|
{ |
|
className: 'code', |
|
begin: /`[^`]+`/, |
|
relevance: 0 |
|
}, |
|
// Code blocks |
|
{ |
|
className: 'code', |
|
begin: /^----+$/, |
|
end: /^----+$/, |
|
contains: [{ begin: /./ }] |
|
}, |
|
// Lists |
|
{ |
|
className: 'bullet', |
|
begin: /^(\*+|\.+|-+)\s+/, |
|
relevance: 0 |
|
}, |
|
// Links |
|
{ |
|
className: 'link', |
|
begin: /link:/, |
|
end: /\[/, |
|
contains: [{ begin: /\[/, end: /\]/ }] |
|
}, |
|
// Comments |
|
{ |
|
className: 'comment', |
|
begin: /^\/\/.*$/, |
|
relevance: 0 |
|
}, |
|
// Attributes |
|
{ |
|
className: 'attr', |
|
begin: /^:.*:$/, |
|
relevance: 0 |
|
} |
|
] |
|
}; |
|
}); |
|
} |
|
|
|
// Apply highlighting |
|
if (lang === 'plaintext') { |
|
highlightedFileContent = `<pre><code class="hljs">${hljs.highlight(content, { language: 'plaintext' }).value}</code></pre>`; |
|
} else if (hljs.getLanguage(lang)) { |
|
highlightedFileContent = `<pre><code class="hljs language-${lang}">${hljs.highlight(content, { language: lang }).value}</code></pre>`; |
|
} else { |
|
// Fallback to auto-detection |
|
highlightedFileContent = `<pre><code class="hljs">${hljs.highlightAuto(content).value}</code></pre>`; |
|
} |
|
} catch (err) { |
|
console.error('Error applying syntax highlighting:', err); |
|
// Fallback to plain text |
|
highlightedFileContent = `<pre><code class="hljs">${content}</code></pre>`; |
|
} |
|
} |
|
|
|
async function loadForkInfo() { |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/fork`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
forkInfo = await response.json(); |
|
} |
|
} catch (err) { |
|
console.error('Error loading 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 (checkingCloneStatus) return; |
|
if (!force && isRepoCloned !== null) { |
|
console.log(`[Clone Status] Skipping check - already checked: ${isRepoCloned}, force: ${force}`); |
|
return; |
|
} |
|
|
|
checkingCloneStatus = true; |
|
try { |
|
// Check if repo exists locally by trying to fetch branches |
|
// 404 = repo not cloned, 403 = repo exists but access denied (cloned), 200 = cloned and accessible |
|
const url = `/api/repos/${npub}/${repo}/branches`; |
|
console.log(`[Clone Status] Checking clone status for ${npub}/${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; |
|
isRepoCloned = 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 |
|
apiFallbackAvailable = null; // Will be set to true if a subsequent request succeeds |
|
} else { |
|
apiFallbackAvailable = false; |
|
} |
|
} else { |
|
// Repo is cloned, API fallback not needed |
|
apiFallbackAvailable = false; |
|
} |
|
|
|
console.log(`[Clone Status] Repo ${wasCloned ? 'is cloned' : 'is not cloned'} (status: ${response.status}), API fallback: ${apiFallbackAvailable}`); |
|
} catch (err) { |
|
// On error, assume not cloned |
|
console.warn('[Clone Status] Error checking clone status:', err); |
|
isRepoCloned = false; |
|
apiFallbackAvailable = false; |
|
} finally { |
|
checkingCloneStatus = false; |
|
} |
|
} |
|
|
|
async function cloneRepository() { |
|
if (cloning) return; |
|
|
|
cloning = true; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${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 |
|
apiFallbackAvailable = false; |
|
// Reload data to use the cloned repo instead of API |
|
await Promise.all([ |
|
loadBranches(), |
|
loadFiles(currentPath), |
|
loadReadme(), |
|
loadTags(), |
|
loadCommitHistory() |
|
]); |
|
} |
|
} catch (err) { |
|
const errorMessage = err instanceof Error ? err.message : 'Failed to clone repository'; |
|
alert(`Error: ${errorMessage}`); |
|
console.error('Error cloning repository:', err); |
|
} finally { |
|
cloning = false; |
|
} |
|
} |
|
|
|
async function forkRepository() { |
|
if (!userPubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
forking = true; |
|
error = null; |
|
|
|
try { |
|
// Security: Truncate npub in logs |
|
const truncatedNpub = npub.length > 16 ? `${npub.slice(0, 12)}...` : npub; |
|
console.log(`[Fork UI] Starting fork of ${truncatedNpub}/${repo}...`); |
|
const response = await fetch(`/api/repos/${npub}/${repo}/fork`, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
...buildApiHeaders() |
|
}, |
|
body: JSON.stringify({ userPubkey }) |
|
}); |
|
|
|
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.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}`); |
|
} |
|
|
|
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); |
|
error = errorMessage; |
|
alert(`Fork failed!\n\n${errorMessage}`); |
|
} finally { |
|
forking = false; |
|
} |
|
} |
|
|
|
async function loadDiscussions() { |
|
if (repoNotFound) return; |
|
loadingDiscussions = true; |
|
error = null; |
|
try { |
|
const decoded = nip19.decode(npub); |
|
if (decoded.type !== 'npub') { |
|
throw new Error('Invalid npub format'); |
|
} |
|
const repoOwnerPubkey = decoded.data as string; |
|
|
|
// Fetch repo announcement to get chat-relay tags and announcement ID |
|
const client = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
const events = await client.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [repoOwnerPubkeyDerived], |
|
'#d': [repo], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length === 0) { |
|
discussions = []; |
|
return; |
|
} |
|
|
|
const announcement = events[0]; |
|
const chatRelays = announcement.tags |
|
.filter(t => t[0] === 'chat-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 || userPubkey; |
|
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] Chat relays from announcement:', chatRelays); |
|
|
|
const discussionsService = new DiscussionsService(allRelays); |
|
const discussionEntries = await discussionsService.getDiscussions( |
|
repoOwnerPubkey, |
|
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'); |
|
|
|
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 discussions and comments to get tags for blurbs |
|
await loadDiscussionEvents(discussions); |
|
|
|
// Fetch nostr: links from discussion content |
|
for (const discussion of 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) { |
|
error = err instanceof Error ? err.message : 'Failed to load discussions'; |
|
console.error('Error loading discussions:', err); |
|
} finally { |
|
loadingDiscussions = false; |
|
} |
|
} |
|
|
|
|
|
async function createDiscussionThread() { |
|
if (!userPubkey || !userPubkeyHex) { |
|
error = 'You must be logged in to create a discussion thread'; |
|
return; |
|
} |
|
|
|
if (!newThreadTitle.trim()) { |
|
error = 'Thread title is required'; |
|
return; |
|
} |
|
|
|
creatingThread = true; |
|
error = null; |
|
|
|
try { |
|
const decoded = nip19.decode(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': [repo], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length === 0) { |
|
throw new Error('Repository announcement not found'); |
|
} |
|
|
|
const announcement = events[0]; |
|
const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repo}`; |
|
|
|
// Get chat relays from announcement, or use default relays |
|
const chatRelays = announcement.tags |
|
.filter(t => t[0] === 'chat-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 (userPubkey) { |
|
try { |
|
const { outbox } = await getUserRelays(userPubkey, 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: userPubkeyHex, |
|
created_at: Math.floor(Date.now() / 1000), |
|
tags: [ |
|
['a', repoAddress], |
|
['title', newThreadTitle.trim()], |
|
['t', 'repo'] |
|
], |
|
content: newThreadContent.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 |
|
newThreadTitle = ''; |
|
newThreadContent = ''; |
|
showCreateThreadDialog = false; |
|
|
|
// Reload discussions |
|
await loadDiscussions(); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to create discussion thread'; |
|
console.error('Error creating discussion thread:', err); |
|
} finally { |
|
creatingThread = false; |
|
} |
|
} |
|
|
|
async function createThreadReply() { |
|
if (!userPubkey || !userPubkeyHex) { |
|
error = 'You must be logged in to reply'; |
|
return; |
|
} |
|
|
|
if (!replyContent.trim()) { |
|
error = 'Reply content is required'; |
|
return; |
|
} |
|
|
|
if (!replyingToThread && !replyingToComment) { |
|
error = 'Must reply to either a thread or a comment'; |
|
return; |
|
} |
|
|
|
creatingReply = true; |
|
error = null; |
|
|
|
try { |
|
const decoded = nip19.decode(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': [repo], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length === 0) { |
|
throw new Error('Repository announcement not found'); |
|
} |
|
|
|
const announcement = events[0]; |
|
|
|
// Get chat relays from announcement, or use default relays |
|
const chatRelays = announcement.tags |
|
.filter(t => t[0] === 'chat-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 (userPubkey) { |
|
try { |
|
const { outbox } = await getUserRelays(userPubkey, 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 (replyingToComment) { |
|
// Replying to a comment - use the comment object we already have |
|
const comment = replyingToComment; |
|
|
|
// Determine root: if we have a thread, use it as root; otherwise use announcement |
|
if (replyingToThread) { |
|
rootEventId = replyingToThread.id; |
|
rootKind = replyingToThread.kind || KIND.THREAD; |
|
rootPubkey = replyingToThread.pubkey || 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 (replyingToThread) { |
|
// Replying directly to a thread - use the thread object we already have |
|
rootEventId = replyingToThread.id; |
|
rootKind = replyingToThread.kind || KIND.THREAD; |
|
rootPubkey = replyingToThread.pubkey || replyingToThread.author; |
|
parentEventId = replyingToThread.id; |
|
parentKind = replyingToThread.kind || KIND.THREAD; |
|
parentPubkey = replyingToThread.pubkey || 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: userPubkeyHex, |
|
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: 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 = replyingToThread?.id; |
|
|
|
// Clear form and close dialog |
|
replyContent = ''; |
|
showReplyDialog = false; |
|
replyingToThread = null; |
|
replyingToComment = null; |
|
|
|
// Reload discussions to show the new reply |
|
await loadDiscussions(); |
|
|
|
// Expand the thread if we were replying to a thread |
|
if (threadIdToExpand) { |
|
expandedThreads.add(threadIdToExpand); |
|
expandedThreads = new Set(expandedThreads); // Trigger reactivity |
|
} |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to create reply'; |
|
console.error('Error creating reply:', err); |
|
} finally { |
|
creatingReply = false; |
|
} |
|
} |
|
|
|
function toggleThread(threadId: string) { |
|
if (expandedThreads.has(threadId)) { |
|
expandedThreads.delete(threadId); |
|
} else { |
|
expandedThreads.add(threadId); |
|
} |
|
// Trigger reactivity |
|
expandedThreads = new Set(expandedThreads); |
|
} |
|
|
|
async function loadDocumentation() { |
|
if (loadingDocs) return; |
|
// Reset documentation when reloading |
|
documentationHtml = null; |
|
documentationContent = null; |
|
documentationKind = null; |
|
|
|
loadingDocs = 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 pageData; |
|
if (repoIsPrivate) { |
|
// Check access via API |
|
const accessResponse = await fetch(`/api/repos/${npub}/${repo}/access`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (accessResponse.ok) { |
|
const accessData = await accessResponse.json(); |
|
if (!accessData.canView) { |
|
// User doesn't have access, don't load documentation |
|
loadingDocs = false; |
|
return; |
|
} |
|
} else { |
|
// Access check failed, don't load documentation |
|
loadingDocs = false; |
|
return; |
|
} |
|
} |
|
|
|
const decoded = nip19.decode(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': [repo], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (announcementEvents.length === 0) { |
|
loadingDocs = false; |
|
return; |
|
} |
|
|
|
const announcement = announcementEvents[0]; |
|
|
|
// Look for documentation tag in the announcement |
|
const documentationTag = announcement.tags.find(t => t[0] === 'documentation'); |
|
|
|
documentationKind = 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) { |
|
documentationKind = 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: [documentationKind], |
|
authors: [docPubkey], |
|
'#d': [docIdentifier], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (docEvents.length > 0) { |
|
documentationContent = docEvents[0].content || null; |
|
} else { |
|
console.warn('Documentation event not found:', docAddress); |
|
documentationContent = null; |
|
} |
|
} else { |
|
console.warn('Invalid documentation tag format:', docAddress); |
|
documentationContent = null; |
|
} |
|
} else { |
|
// No documentation tag, try to use announcement content as fallback |
|
documentationContent = announcement.content || null; |
|
// Announcement is kind 30617, not a doc kind, so keep documentationKind as null |
|
} |
|
|
|
// Render content based on kind: AsciiDoc for 30041 or 30818, Markdown otherwise |
|
if (documentationContent) { |
|
// Check if we should use AsciiDoc parser (kinds 30041 or 30818) |
|
const useAsciiDoc = documentationKind === 30041 || documentationKind === 30818; |
|
|
|
if (useAsciiDoc) { |
|
// Use AsciiDoc parser |
|
const Asciidoctor = (await import('@asciidoctor/core')).default; |
|
const asciidoctor = Asciidoctor(); |
|
const converted = asciidoctor.convert(documentationContent, { |
|
safe: 'safe', |
|
attributes: { |
|
'source-highlighter': 'highlight.js' |
|
} |
|
}); |
|
// Convert to string if it's a Document object |
|
documentationHtml = 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 ''; |
|
} |
|
}); |
|
|
|
documentationHtml = md.render(documentationContent); |
|
} |
|
} else { |
|
// No content found, clear HTML |
|
documentationHtml = null; |
|
} |
|
} |
|
} catch (err) { |
|
console.error('Error loading documentation:', err); |
|
documentationHtml = null; |
|
} finally { |
|
loadingDocs = 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 pageData; |
|
if (data.image) { |
|
repoImage = data.image; |
|
console.log('[Repo Images] Loaded image from pageData:', repoImage); |
|
} |
|
if (data.banner) { |
|
repoBanner = data.banner; |
|
console.log('[Repo Images] Loaded banner from pageData:', repoBanner); |
|
} |
|
|
|
// Also fetch from announcement directly as fallback (only if not private or user has access) |
|
if (!repoImage && !repoBanner) { |
|
// Guard against SSR - $page store can only be accessed in component context |
|
if (typeof window === 'undefined') return; |
|
const data = $page.data as typeof pageData; |
|
// Check access for private repos |
|
if (repoIsPrivate) { |
|
const headers: Record<string, string> = {}; |
|
if (userPubkey) { |
|
try { |
|
const decoded = nip19.decode(userPubkey); |
|
if (decoded.type === 'npub') { |
|
headers['X-User-Pubkey'] = decoded.data as string; |
|
} else { |
|
headers['X-User-Pubkey'] = userPubkey; |
|
} |
|
} catch { |
|
headers['X-User-Pubkey'] = userPubkey; |
|
} |
|
} |
|
|
|
const accessResponse = await fetch(`/api/repos/${npub}/${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(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': [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]) { |
|
repoImage = imageTag[1]; |
|
console.log('[Repo Images] Loaded image from announcement:', repoImage); |
|
} |
|
if (bannerTag?.[1]) { |
|
repoBanner = bannerTag[1]; |
|
console.log('[Repo Images] Loaded banner from announcement:', repoBanner); |
|
} |
|
} else { |
|
console.log('[Repo Images] No announcement found'); |
|
} |
|
} |
|
} |
|
|
|
if (!repoImage && !repoBanner) { |
|
console.log('[Repo Images] No images found in announcement'); |
|
} |
|
} catch (err) { |
|
console.error('Error loading repo images:', err); |
|
} |
|
} |
|
|
|
// Reactively update images when pageData changes (only once, when data becomes available) |
|
$effect(() => { |
|
// Guard against SSR and component destruction |
|
if (typeof window === 'undefined' || !isMounted) return; |
|
try { |
|
const data = $page.data as typeof pageData; |
|
if (!data || !isMounted) return; |
|
// Only update if we have new data and don't already have the images set |
|
if (data.image && data.image !== repoImage && isMounted) { |
|
repoImage = data.image; |
|
console.log('[Repo Images] Updated image from pageData (reactive):', repoImage); |
|
} |
|
if (data.banner && data.banner !== repoBanner && isMounted) { |
|
repoBanner = data.banner; |
|
console.log('[Repo Images] Updated banner from pageData (reactive):', repoBanner); |
|
} |
|
} catch (err) { |
|
// Ignore errors during destruction |
|
if (isMounted) { |
|
console.warn('Image update effect error:', err); |
|
} |
|
} |
|
}); |
|
|
|
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(npub); |
|
if (decoded.type === 'npub') { |
|
repoOwnerPubkeyState = decoded.data as string; |
|
repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkeyState}:${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 (!isMounted) return; |
|
try { |
|
const target = event.target as HTMLElement; |
|
if (showRepoMenu && !target.closest('.repo-header') && isMounted) { |
|
showRepoMenu = false; |
|
} |
|
} catch (err) { |
|
// Ignore errors during destruction |
|
if (isMounted) { |
|
console.warn('Click outside handler error:', err); |
|
} |
|
} |
|
}; |
|
|
|
document.addEventListener('click', clickOutsideHandler); |
|
|
|
await loadBranches(); |
|
if (!isMounted) return; |
|
|
|
// Skip other API calls if repository doesn't exist |
|
if (repoNotFound) { |
|
loading = false; |
|
return; |
|
} |
|
|
|
// loadBranches() already handles setting currentBranch to the default branch |
|
await loadFiles(); |
|
if (!isMounted) return; |
|
|
|
await checkAuth(); |
|
if (!isMounted) return; |
|
|
|
await loadTags(); |
|
if (!isMounted) return; |
|
|
|
await checkMaintainerStatus(); |
|
if (!isMounted) return; |
|
|
|
await loadBookmarkStatus(); |
|
if (!isMounted) return; |
|
|
|
await loadAllMaintainers(); |
|
if (!isMounted) return; |
|
|
|
// Check clone status (needed to disable write operations) |
|
await checkCloneStatus(); |
|
if (!isMounted) return; |
|
|
|
await checkVerification(); |
|
if (!isMounted) return; |
|
|
|
await loadReadme(); |
|
if (!isMounted) return; |
|
|
|
await loadForkInfo(); |
|
if (!isMounted) return; |
|
|
|
await loadRepoImages(); |
|
if (!isMounted) return; |
|
|
|
// Load clone URL reachability status |
|
loadCloneUrlReachability().catch(err => { |
|
if (isMounted) console.warn('Failed to load clone URL reachability:', err); |
|
}); |
|
|
|
// Set up auto-save if enabled |
|
setupAutoSave().catch(err => { |
|
if (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 |
|
isMounted = false; |
|
|
|
// Clean up intervals and timeouts |
|
if (autoSaveInterval) { |
|
clearInterval(autoSaveInterval); |
|
autoSaveInterval = 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) { |
|
userPubkey = currentUser.userPubkey; |
|
userPubkeyHex = 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(); |
|
userPubkey = pubkey; |
|
// Convert to hex if needed |
|
if (/^[0-9a-f]{64}$/i.test(pubkey)) { |
|
userPubkeyHex = pubkey.toLowerCase(); |
|
} else { |
|
try { |
|
const decoded = nip19.decode(pubkey); |
|
if (decoded.type === 'npub') { |
|
userPubkeyHex = decoded.data as string; |
|
} |
|
} catch { |
|
userPubkeyHex = 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'); |
|
userPubkey = null; |
|
userPubkeyHex = null; |
|
} |
|
} |
|
|
|
async function login() { |
|
// Check userStore first |
|
const currentUser = $userStore; |
|
if (currentUser.userPubkey && currentUser.userPubkeyHex) { |
|
userPubkey = currentUser.userPubkey; |
|
userPubkeyHex = 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 (userPubkeyHex) { |
|
try { |
|
const response = await fetch('/api/transfers/pending', { |
|
headers: { |
|
'X-User-Pubkey': userPubkeyHex |
|
} |
|
}); |
|
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(); |
|
userPubkey = pubkey; |
|
} else { |
|
try { |
|
const decoded = nip19.decode(pubkey); |
|
if (decoded.type === 'npub') { |
|
pubkeyHex = decoded.data as string; |
|
userPubkey = pubkey; |
|
} else { |
|
throw new Error('Invalid pubkey format'); |
|
} |
|
} catch { |
|
error = 'Invalid public key format'; |
|
return; |
|
} |
|
} |
|
|
|
userPubkeyHex = pubkeyHex; |
|
|
|
// Check write access and update user store |
|
const { determineUserLevel } = await import('$lib/services/nostr/user-level-service.js'); |
|
const levelResult = await determineUserLevel(userPubkey, userPubkeyHex); |
|
|
|
// 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 (userPubkeyHex) { |
|
try { |
|
const response = await fetch('/api/transfers/pending', { |
|
headers: { |
|
'X-User-Pubkey': userPubkeyHex |
|
} |
|
}); |
|
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) { |
|
error = err instanceof Error ? err.message : 'Failed to connect'; |
|
console.error('Login error:', err); |
|
} |
|
} |
|
|
|
|
|
async function loadBookmarkStatus() { |
|
if (!userPubkey || !repoAddress || !bookmarksService) return; |
|
|
|
try { |
|
isBookmarked = await bookmarksService.isBookmarked(userPubkey, repoAddress); |
|
} catch (err) { |
|
console.warn('Failed to load bookmark status:', err); |
|
} |
|
} |
|
|
|
async function toggleBookmark() { |
|
if (!userPubkey || !repoAddress || !bookmarksService || loadingBookmark) return; |
|
|
|
loadingBookmark = 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(userPubkey, fullRelayClient); |
|
const userRelays = combineRelays(outbox.length > 0 ? outbox : inbox, DEFAULT_NOSTR_RELAYS); |
|
|
|
let success = false; |
|
if (isBookmarked) { |
|
success = await bookmarksService.removeBookmark(userPubkey, repoAddress, userRelays); |
|
} else { |
|
success = await bookmarksService.addBookmark(userPubkey, repoAddress, userRelays); |
|
} |
|
|
|
if (success) { |
|
isBookmarked = !isBookmarked; |
|
} else { |
|
alert(`Failed to ${isBookmarked ? 'remove' : 'add'} bookmark. Please try again.`); |
|
} |
|
} catch (err) { |
|
console.error('Failed to toggle bookmark:', err); |
|
alert(`Failed to ${isBookmarked ? 'remove' : 'add'} bookmark: ${String(err)}`); |
|
} finally { |
|
loadingBookmark = false; |
|
} |
|
} |
|
|
|
async function copyEventId() { |
|
if (!repoAddress || !repoOwnerPubkeyDerived) { |
|
alert('Repository address not available'); |
|
return; |
|
} |
|
|
|
try { |
|
// Parse the repo address: kind:pubkey:identifier |
|
const parts = repoAddress.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 (repoNotFound || !userPubkey) { |
|
isMaintainer = false; |
|
return; |
|
} |
|
|
|
loadingMaintainerStatus = true; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/maintainers?userPubkey=${encodeURIComponent(userPubkey)}`); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
isMaintainer = data.isMaintainer || false; |
|
} |
|
} catch (err) { |
|
console.error('Failed to check maintainer status:', err); |
|
isMaintainer = false; |
|
} finally { |
|
loadingMaintainerStatus = false; |
|
} |
|
} |
|
|
|
async function loadAllMaintainers() { |
|
if (repoNotFound || loadingMaintainers) return; |
|
|
|
loadingMaintainers = true; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${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 }); |
|
} |
|
|
|
allMaintainers = allMaintainersList; |
|
} |
|
} catch (err) { |
|
console.error('Failed to load maintainers:', err); |
|
maintainersLoaded = false; // Reset flag on error |
|
// Fallback to pageData if available |
|
if (repoOwnerPubkeyDerived) { |
|
allMaintainers = [{ pubkey: repoOwnerPubkeyDerived, isOwner: true }]; |
|
if (repoMaintainers) { |
|
for (const maintainer of repoMaintainers) { |
|
if (maintainer.toLowerCase() !== repoOwnerPubkeyDerived.toLowerCase()) { |
|
allMaintainers.push({ pubkey: maintainer, isOwner: false }); |
|
} |
|
} |
|
} |
|
} |
|
} finally { |
|
loadingMaintainers = false; |
|
} |
|
} |
|
|
|
async function checkVerification() { |
|
if (repoNotFound) return; |
|
loadingVerification = true; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/verify`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
console.log('[Verification] Response:', data); |
|
verificationStatus = data; |
|
} else { |
|
console.warn('[Verification] Response not OK:', response.status, response.statusText); |
|
verificationStatus = { verified: false, error: `Verification check failed: ${response.status}` }; |
|
} |
|
} catch (err) { |
|
console.error('[Verification] Failed to check verification:', err); |
|
verificationStatus = { verified: false, error: 'Failed to check verification' }; |
|
} finally { |
|
loadingVerification = false; |
|
console.log('[Verification] Status after check:', verificationStatus); |
|
} |
|
} |
|
|
|
async function generateAnnouncementFileForRepo() { |
|
if (!repoOwnerPubkeyDerived || !userPubkeyHex) { |
|
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': [repo], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length === 0) { |
|
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) |
|
verificationFileContent = JSON.stringify(announcement, null, 2) + '\n'; |
|
showVerificationDialog = true; |
|
} catch (err) { |
|
console.error('Failed to generate announcement file:', err); |
|
error = `Failed to generate announcement file: ${err instanceof Error ? err.message : String(err)}`; |
|
} |
|
} |
|
|
|
function copyVerificationToClipboard() { |
|
if (!verificationFileContent) return; |
|
|
|
navigator.clipboard.writeText(verificationFileContent).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.'); |
|
}); |
|
} |
|
|
|
async function deleteAnnouncement() { |
|
if (!userPubkey || !userPubkeyHex) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
if (!repoOwnerPubkeyDerived || userPubkeyHex !== 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; |
|
} |
|
|
|
deletingAnnouncement = true; |
|
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': [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(userPubkeyHex, nostrClient); |
|
const combinedRelays = combineRelays(outbox); |
|
|
|
// Create deletion request (NIP-09) |
|
const deletionRequestTemplate: Omit<NostrEvent, 'sig' | 'id'> = { |
|
kind: KIND.DELETION_REQUEST, |
|
pubkey: userPubkeyHex, |
|
created_at: Math.floor(Date.now() / 1000), |
|
content: `Requesting deletion of repository announcement for ${repo}`, |
|
tags: [ |
|
['e', announcement.id], // Reference to the announcement event |
|
['a', `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkeyDerived}:${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); |
|
error = err instanceof Error ? err.message : 'Failed to send deletion request'; |
|
alert(error); |
|
} finally { |
|
deletingAnnouncement = false; |
|
} |
|
} |
|
|
|
function downloadVerificationFile() { |
|
if (!verificationFileContent) return; |
|
|
|
const blob = new Blob([verificationFileContent], { 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, |
|
repo, |
|
ref, |
|
filename |
|
}); |
|
} |
|
|
|
async function loadBranches() { |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/branches`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
branches = await response.json(); |
|
|
|
// If repo is not cloned but we got branches, API fallback is available |
|
if (isRepoCloned === false && branches.length > 0) { |
|
apiFallbackAvailable = true; |
|
} |
|
if (branches.length > 0) { |
|
// Branches can be an array of objects with .name property or array of strings |
|
const branchNames = 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/${npub}/${repo}/default-branch`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (defaultBranchResponse.ok) { |
|
const defaultBranchData = await defaultBranchResponse.json(); |
|
defaultBranch = defaultBranchData.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 (!defaultBranch) { |
|
if (branchNames.includes('master')) { |
|
defaultBranch = 'master'; |
|
} else if (branchNames.includes('main')) { |
|
defaultBranch = 'main'; |
|
} else { |
|
defaultBranch = branchNames[0]; |
|
} |
|
} |
|
|
|
// Only update currentBranch if it's not set or if the current branch doesn't exist |
|
// Also validate that currentBranch doesn't contain invalid characters (like '#') |
|
if (!currentBranch || |
|
typeof currentBranch !== 'string' || |
|
currentBranch.includes('#') || |
|
!branchNames.includes(currentBranch)) { |
|
currentBranch = defaultBranch; |
|
} |
|
} else { |
|
// No branches exist - set currentBranch to null to show "no branches" in header |
|
currentBranch = null; |
|
} |
|
} else if (response.status === 404) { |
|
// Check if this is a "not cloned" 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 |
|
apiFallbackAvailable = null; |
|
// Don't set repoNotFound or error yet - allow API fallback to be attempted |
|
} else { |
|
// No clone URLs, API fallback won't work |
|
repoNotFound = true; |
|
apiFallbackAvailable = false; |
|
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 |
|
repoNotFound = true; |
|
apiFallbackAvailable = false; |
|
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 repoNotFound, allow retry after login |
|
const errorText = await response.text().catch(() => response.statusText); |
|
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 (repoNotFound) return; |
|
|
|
loading = true; |
|
error = null; |
|
try { |
|
// Validate and get a valid branch name |
|
let branchName: string; |
|
if (typeof currentBranch === 'string' && currentBranch.trim() !== '' && !currentBranch.includes('#')) { |
|
const branchNames = branches.map((b: any) => typeof b === 'string' ? b : b.name); |
|
if (branchNames.includes(currentBranch)) { |
|
branchName = currentBranch; |
|
} else { |
|
branchName = defaultBranch || (branches.length > 0 |
|
? (typeof branches[0] === 'string' ? branches[0] : branches[0].name) |
|
: 'master'); |
|
} |
|
} else { |
|
branchName = defaultBranch || (branches.length > 0 |
|
? (typeof branches[0] === 'string' ? branches[0] : branches[0].name) |
|
: 'master'); |
|
} |
|
|
|
const url = `/api/repos/${npub}/${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" 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 |
|
apiFallbackAvailable = null; |
|
// Don't set repoNotFound - allow API fallback to be attempted |
|
} else { |
|
// No clone URLs, API fallback won't work |
|
repoNotFound = true; |
|
apiFallbackAvailable = false; |
|
} |
|
// Throw error but use the actual 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 |
|
repoNotFound = true; |
|
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 repoNotFound, just show 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}`); |
|
} |
|
|
|
files = await response.json(); |
|
currentPath = path; |
|
|
|
// If repo is not cloned but we got files, API fallback is available |
|
if (isRepoCloned === false && files.length > 0) { |
|
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 === '' && !currentFile && !readmeAutoLoadAttempted) { |
|
const readmeFile = findReadmeFile(files); |
|
if (readmeFile) { |
|
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(() => { |
|
readmeAutoLoadAttempted = false; |
|
}, 5000); // Retry after 5 seconds |
|
} else { |
|
// For other errors, reset immediately |
|
readmeAutoLoadAttempted = false; |
|
} |
|
}); |
|
readmeAutoLoadTimeout = null; |
|
}, 100); |
|
} |
|
} else if (path !== '' || currentFile) { |
|
// Reset flag when navigating away from root or when a file is selected |
|
readmeAutoLoadAttempted = false; |
|
if (readmeAutoLoadTimeout) { |
|
clearTimeout(readmeAutoLoadTimeout); |
|
readmeAutoLoadTimeout = null; |
|
} |
|
} |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to load files'; |
|
// Only log as 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 { |
|
loading = 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 files (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) { |
|
loading = true; |
|
error = null; |
|
try { |
|
// Ensure currentBranch is a string (branch name), not an object |
|
// If currentBranch is not set, use the first available branch or 'master' as fallback |
|
let branchName: string; |
|
|
|
if (typeof currentBranch === 'string' && currentBranch.trim() !== '') { |
|
// Validate that currentBranch is actually a valid branch name |
|
// Check if it exists in the branches list |
|
const branchNames = branches.map((b: any) => typeof b === 'string' ? b : b.name); |
|
if (branchNames.includes(currentBranch)) { |
|
branchName = currentBranch; |
|
} else { |
|
// currentBranch is set but not in branches list, use defaultBranch or fallback |
|
branchName = defaultBranch || (branches.length > 0 |
|
? (typeof branches[0] === 'string' ? branches[0] : branches[0].name) |
|
: 'master'); |
|
} |
|
} else if (typeof currentBranch === 'object' && currentBranch !== null && 'name' in currentBranch) { |
|
branchName = (currentBranch as { name: string }).name; |
|
} else { |
|
// currentBranch is null, undefined, or invalid - use defaultBranch or fallback |
|
branchName = defaultBranch || (branches.length > 0 |
|
? (typeof branches[0] === 'string' ? branches[0] : branches[0].name) |
|
: 'master'); |
|
} |
|
|
|
// Final validation: ensure branchName is a valid string |
|
// Note: We allow '#' in branch names for existing 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 = defaultBranch || (branches.length > 0 |
|
? (typeof branches[0] === 'string' ? branches[0] : branches[0].name) |
|
: 'master'); |
|
} |
|
|
|
// 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 |
|
isImageFile = isImageFileType(ext); |
|
|
|
if (isImageFile) { |
|
// For image files, construct the raw file URL and skip loading text content |
|
imageUrl = `/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(filePath)}&ref=${encodeURIComponent(branchName)}`; |
|
fileContent = ''; // Clear content for images |
|
editedContent = ''; // Clear edited content for images |
|
fileHtml = ''; // Clear HTML for images |
|
highlightedFileContent = ''; // Clear highlighted content |
|
fileLanguage = 'text'; |
|
currentFile = filePath; |
|
hasChanges = false; |
|
} else { |
|
// Not an image, load file content normally |
|
imageUrl = null; |
|
|
|
const url = `/api/repos/${npub}/${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(); |
|
fileContent = data.content; |
|
editedContent = data.content; |
|
currentFile = filePath; |
|
hasChanges = false; |
|
|
|
// Reset README auto-load flag when a file is successfully loaded |
|
if (filePath && filePath.toLowerCase().includes('readme')) { |
|
readmeAutoLoadAttempted = false; |
|
} |
|
|
|
if (ext === 'md' || ext === 'markdown') { |
|
fileLanguage = 'markdown'; |
|
} else if (ext === 'adoc' || ext === 'asciidoc') { |
|
fileLanguage = 'asciidoc'; |
|
} else { |
|
fileLanguage = 'text'; |
|
} |
|
|
|
// Reset preview mode to default (preview) when loading a new file |
|
showFilePreview = true; |
|
fileHtml = ''; |
|
|
|
// Render markdown/asciidoc/HTML/CSV files as HTML for preview |
|
if (fileContent && (ext === 'md' || ext === 'markdown' || ext === 'adoc' || ext === 'asciidoc' || ext === 'html' || ext === 'htm' || ext === 'csv')) { |
|
await renderFileAsHtml(fileContent, ext || ''); |
|
} |
|
|
|
// Apply syntax highlighting |
|
// For files that support HTML preview (markdown, HTML, etc.), only show highlighting in raw mode |
|
// For code files and other non-markup files, always show syntax highlighting |
|
const hasHtmlPreview = supportsPreview(ext); |
|
if (fileContent) { |
|
if (hasHtmlPreview) { |
|
// Markup files: only show highlighting when not in preview mode (raw mode) |
|
if (!showFilePreview) { |
|
await applySyntaxHighlighting(fileContent, ext || ''); |
|
} |
|
} else { |
|
// Code files and other non-markup files: always show syntax highlighting |
|
await applySyntaxHighlighting(fileContent, ext || ''); |
|
} |
|
} |
|
} |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to load file'; |
|
console.error('Error loading file:', err); |
|
} finally { |
|
loading = false; |
|
} |
|
} |
|
|
|
function handleContentChange(value: string) { |
|
editedContent = value; |
|
hasChanges = value !== fileContent; |
|
} |
|
|
|
function handleFileClick(file: { name: string; path: string; type: 'file' | 'directory' }) { |
|
if (file.type === 'directory') { |
|
pathStack.push(currentPath); |
|
loadFiles(file.path); |
|
} else { |
|
loadFile(file.path); |
|
// On mobile, switch to file viewer when a file is clicked |
|
if (window.innerWidth <= 768) { |
|
showFileListOnMobile = false; |
|
} |
|
} |
|
} |
|
|
|
// Copy file content to clipboard |
|
async function copyFileContent(event?: Event) { |
|
if (!fileContent || copyingFile) return; |
|
|
|
copyingFile = true; |
|
try { |
|
await navigator.clipboard.writeText(fileContent); |
|
// 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 { |
|
copyingFile = false; |
|
} |
|
} |
|
|
|
// Download file |
|
function downloadFile() { |
|
if (!fileContent || !currentFile) return; |
|
|
|
try { |
|
// Determine MIME type based on file extension |
|
const ext = 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([fileContent], { type: mimeType }); |
|
const url = URL.createObjectURL(blob); |
|
const a = document.createElement('a'); |
|
a.href = url; |
|
a.download = 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 (pathStack.length > 0) { |
|
const parentPath = pathStack.pop() || ''; |
|
loadFiles(parentPath); |
|
} else { |
|
loadFiles(''); |
|
} |
|
} |
|
|
|
// Cache for user profile email and name |
|
let cachedUserEmail = $state<string | null>(null); |
|
let cachedUserName = $state<string | null>(null); |
|
let fetchingUserEmail = $state(false); |
|
let fetchingUserName = $state(false); |
|
|
|
async function getUserEmail(): Promise<string> { |
|
// Check settings store first |
|
try { |
|
const settings = await settingsStore.getSettings(); |
|
if (settings.userEmail && settings.userEmail.trim()) { |
|
cachedUserEmail = settings.userEmail.trim(); |
|
return cachedUserEmail; |
|
} |
|
} catch (err) { |
|
console.warn('Failed to get userEmail from settings:', err); |
|
} |
|
|
|
// Return cached email if available |
|
if (cachedUserEmail) { |
|
return cachedUserEmail; |
|
} |
|
|
|
// If no user pubkey, can't proceed |
|
if (!userPubkeyHex) { |
|
throw new Error('User not authenticated'); |
|
} |
|
|
|
// Prevent concurrent fetches |
|
if (fetchingUserEmail) { |
|
// Wait a bit and retry (shouldn't happen, but just in case) |
|
await new Promise(resolve => setTimeout(resolve, 100)); |
|
if (cachedUserEmail) { |
|
return cachedUserEmail; |
|
} |
|
} |
|
|
|
fetchingUserEmail = true; |
|
let prefillEmail: string; |
|
|
|
try { |
|
// Fetch from kind 0 event (cache or relays) |
|
prefillEmail = await fetchUserEmail(userPubkeyHex, userPubkey || undefined, DEFAULT_NOSTR_RELAYS); |
|
} catch (err) { |
|
console.warn('Failed to fetch user profile for email:', err); |
|
// Fallback to shortenednpub@gitrepublic.web |
|
const npubFromPubkey = userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : (userPubkey || 'unknown'); |
|
const shortenedNpub = npubFromPubkey.substring(0, 20); |
|
prefillEmail = `${shortenedNpub}@gitrepublic.web`; |
|
} finally { |
|
fetchingUserEmail = false; |
|
} |
|
|
|
// Prompt user for email address |
|
const userEmail = prompt( |
|
'Please enter your email address for git commits.\n\n' + |
|
'This will be used as the author email in your commits.\n' + |
|
'You can use any email address you prefer.', |
|
prefillEmail |
|
); |
|
|
|
if (userEmail && userEmail.trim()) { |
|
// Basic email validation |
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; |
|
if (emailRegex.test(userEmail.trim())) { |
|
cachedUserEmail = userEmail.trim(); |
|
// Save to settings store |
|
settingsStore.setSetting('userEmail', cachedUserEmail).catch(console.error); |
|
return cachedUserEmail; |
|
} else { |
|
alert('Invalid email format. Using fallback email address.'); |
|
} |
|
} |
|
|
|
// Use fallback if user cancelled or entered invalid email |
|
cachedUserEmail = prefillEmail; |
|
return cachedUserEmail; |
|
} |
|
|
|
async function getUserName(): Promise<string> { |
|
// Check settings store first |
|
try { |
|
const settings = await settingsStore.getSettings(); |
|
if (settings.userName && settings.userName.trim()) { |
|
cachedUserName = settings.userName.trim(); |
|
return cachedUserName; |
|
} |
|
} catch (err) { |
|
console.warn('Failed to get userName from settings:', err); |
|
} |
|
|
|
// Return cached name if available |
|
if (cachedUserName) { |
|
return cachedUserName; |
|
} |
|
|
|
// If no user pubkey, can't proceed |
|
if (!userPubkeyHex) { |
|
throw new Error('User not authenticated'); |
|
} |
|
|
|
// Prevent concurrent fetches |
|
if (fetchingUserName) { |
|
// Wait a bit and retry (shouldn't happen, but just in case) |
|
await new Promise(resolve => setTimeout(resolve, 100)); |
|
if (cachedUserName) { |
|
return cachedUserName; |
|
} |
|
} |
|
|
|
fetchingUserName = true; |
|
let prefillName: string; |
|
|
|
try { |
|
// Fetch from kind 0 event (cache or relays) |
|
prefillName = await fetchUserName(userPubkeyHex, userPubkey || undefined, DEFAULT_NOSTR_RELAYS); |
|
} catch (err) { |
|
console.warn('Failed to fetch user profile for name:', err); |
|
// Fallback to shortened npub (20 chars) |
|
const npubFromPubkey = userPubkeyHex ? nip19.npubEncode(userPubkeyHex) : (userPubkey || 'unknown'); |
|
prefillName = npubFromPubkey.substring(0, 20); |
|
} finally { |
|
fetchingUserName = false; |
|
} |
|
|
|
// Prompt user for name |
|
const userName = prompt( |
|
'Please enter your name for git commits.\n\n' + |
|
'This will be used as the author name in your commits.\n' + |
|
'You can use any name you prefer.', |
|
prefillName |
|
); |
|
|
|
if (userName && userName.trim()) { |
|
cachedUserName = userName.trim(); |
|
// Save to settings store |
|
settingsStore.setSetting('userName', cachedUserName).catch(console.error); |
|
return cachedUserName; |
|
} |
|
|
|
// Use fallback if user cancelled |
|
cachedUserName = prefillName; |
|
return cachedUserName; |
|
} |
|
|
|
async function setupAutoSave() { |
|
// Clear existing interval if any |
|
if (autoSaveInterval) { |
|
clearInterval(autoSaveInterval); |
|
autoSaveInterval = 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 = 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 saving |
|
// 6. Not in clone state |
|
if (!hasChanges || !currentFile || !userPubkey || !isMaintainer || 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) { |
|
clearInterval(autoSaveInterval); |
|
autoSaveInterval = 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/${npub}/${repo}/file`, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
...buildApiHeaders() |
|
}, |
|
body: JSON.stringify({ |
|
path: currentFile, |
|
content: editedContent, |
|
commitMessage: autoCommitMessage, |
|
authorName: authorName, |
|
authorEmail: authorEmail, |
|
branch: currentBranch, |
|
userPubkey: userPubkey, |
|
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(currentFile); |
|
// Note: We don't show an alert for auto-save, it's silent |
|
console.log('Auto-saved file:', currentFile); |
|
} catch (err) { |
|
console.warn('Error during auto-save:', err); |
|
// Don't show error to user, it's silent |
|
} |
|
} |
|
|
|
async function saveFile() { |
|
if (!currentFile || !commitMessage.trim()) { |
|
alert('Please enter a commit message'); |
|
return; |
|
} |
|
|
|
if (!userPubkey) { |
|
alert('Please connect your NIP-07 extension to save files'); |
|
return; |
|
} |
|
|
|
// Validate branch selection |
|
if (!currentBranch || typeof currentBranch !== 'string') { |
|
alert('Please select a branch before saving the file'); |
|
return; |
|
} |
|
|
|
saving = true; |
|
error = null; |
|
|
|
try { |
|
// Get user email and name (from profile or prompt) |
|
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', commitMessage.trim()] |
|
], |
|
content: `Signed commit: ${commitMessage.trim()}` |
|
}; |
|
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/${npub}/${repo}/file`, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
...buildApiHeaders() |
|
}, |
|
body: JSON.stringify({ |
|
path: currentFile, |
|
content: editedContent, |
|
commitMessage: commitMessage.trim(), |
|
authorName: authorName, |
|
authorEmail: authorEmail, |
|
branch: currentBranch, |
|
userPubkey: userPubkey, |
|
commitSignatureEvent: commitSignatureEvent // Send the signed event to server |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json().catch(() => ({ message: response.statusText })); |
|
const errorMessage = errorData.message || errorData.error || 'Failed to save file'; |
|
throw new Error(errorMessage); |
|
} |
|
|
|
// Reload file to get updated content |
|
await loadFile(currentFile); |
|
commitMessage = ''; |
|
showCommitDialog = false; |
|
alert('File saved successfully!'); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to save file'; |
|
console.error('Error saving file:', err); |
|
} finally { |
|
saving = false; |
|
} |
|
} |
|
|
|
function handleBranchChangeDirect(branch: string) { |
|
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; |
|
currentBranch = target.value; |
|
|
|
// Reload all branch-dependent data |
|
const reloadPromises: Promise<void>[] = []; |
|
|
|
// Always reload files (and current file if open) |
|
if (currentFile) { |
|
reloadPromises.push(loadFile(currentFile).catch(err => console.warn('Failed to reload file after branch change:', err))); |
|
} else { |
|
reloadPromises.push(loadFiles(currentPath).catch(err => console.warn('Failed to reload files 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 (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 (activeTab === 'docs') { |
|
// Reset documentation to force reload |
|
documentationHtml = null; |
|
documentationContent = null; |
|
documentationKind = 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() { |
|
if (!newFileName.trim()) { |
|
alert('Please enter a file name'); |
|
return; |
|
} |
|
|
|
if (!userPubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
// Validate branch selection |
|
if (!currentBranch || typeof currentBranch !== 'string') { |
|
alert('Please select a branch before creating the file'); |
|
return; |
|
} |
|
|
|
saving = true; |
|
error = null; |
|
|
|
try { |
|
// Get user email and name (from profile or prompt) |
|
const authorEmail = await getUserEmail(); |
|
const authorName = await getUserName(); |
|
const filePath = currentPath ? `${currentPath}/${newFileName}` : newFileName; |
|
const commitMsg = `Create ${newFileName}`; |
|
|
|
// 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', commitMsg] |
|
], |
|
content: `Signed commit: ${commitMsg}` |
|
}; |
|
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/${npub}/${repo}/file`, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
...buildApiHeaders() |
|
}, |
|
body: JSON.stringify({ |
|
path: filePath, |
|
content: newFileContent, |
|
commitMessage: commitMsg, |
|
authorName: authorName, |
|
authorEmail: authorEmail, |
|
branch: currentBranch, |
|
action: 'create', |
|
userPubkey: userPubkey, |
|
commitSignatureEvent: commitSignatureEvent // Send the signed event to server |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json(); |
|
throw new Error(errorData.message || 'Failed to create file'); |
|
} |
|
|
|
showCreateFileDialog = false; |
|
newFileName = ''; |
|
newFileContent = ''; |
|
await loadFiles(currentPath); |
|
alert('File created successfully!'); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to create file'; |
|
} finally { |
|
saving = false; |
|
} |
|
} |
|
|
|
async function deleteFile(filePath: string) { |
|
if (!confirm(`Are you sure you want to delete "${filePath}"?\n\nThis will permanently delete the file from the repository. This action cannot be undone.\n\nClick OK to delete, or Cancel to abort.`)) { |
|
return; |
|
} |
|
|
|
if (!userPubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
// Validate branch selection |
|
if (!currentBranch || typeof currentBranch !== 'string') { |
|
alert('Please select a branch before deleting the file'); |
|
return; |
|
} |
|
|
|
saving = true; |
|
error = null; |
|
|
|
try { |
|
// Get user email and name (from profile or prompt) |
|
const authorEmail = await getUserEmail(); |
|
const authorName = await getUserName(); |
|
const commitMsg = `Delete ${filePath}`; |
|
|
|
// 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', commitMsg] |
|
], |
|
content: `Signed commit: ${commitMsg}` |
|
}; |
|
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/${npub}/${repo}/file`, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
...buildApiHeaders() |
|
}, |
|
body: JSON.stringify({ |
|
path: filePath, |
|
commitMessage: commitMsg, |
|
authorName: authorName, |
|
authorEmail: authorEmail, |
|
branch: currentBranch, |
|
action: 'delete', |
|
userPubkey: userPubkey, |
|
commitSignatureEvent: commitSignatureEvent // Send the signed event to server |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json(); |
|
throw new Error(errorData.message || 'Failed to delete file'); |
|
} |
|
|
|
if (currentFile === filePath) { |
|
currentFile = null; |
|
} |
|
await loadFiles(currentPath); |
|
alert('File deleted successfully!'); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to delete file'; |
|
} finally { |
|
saving = false; |
|
} |
|
} |
|
|
|
async function createBranch() { |
|
if (!newBranchName.trim()) { |
|
alert('Please enter a branch name'); |
|
return; |
|
} |
|
|
|
saving = true; |
|
error = null; |
|
|
|
try { |
|
// If no branches exist, don't pass fromBranch (will use --orphan) |
|
// Otherwise, use the selected branch or current branch |
|
let fromBranch: string | undefined = newBranchFrom || currentBranch || undefined; |
|
|
|
// Include announcement if available (for empty repos) |
|
const requestBody: { branchName: string; fromBranch?: string; announcement?: NostrEvent } = { |
|
branchName: newBranchName |
|
}; |
|
if (branches.length > 0 && fromBranch) { |
|
requestBody.fromBranch = fromBranch; |
|
} |
|
// Pass announcement if available (especially useful for empty repos) |
|
if (repoAnnouncement) { |
|
requestBody.announcement = repoAnnouncement; |
|
} |
|
|
|
const response = await fetch(`/api/repos/${npub}/${repo}/branches`, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
...buildApiHeaders() |
|
}, |
|
body: JSON.stringify(requestBody) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json(); |
|
throw new Error(errorData.message || 'Failed to create branch'); |
|
} |
|
|
|
showCreateBranchDialog = false; |
|
newBranchName = ''; |
|
await loadBranches(); |
|
alert('Branch created successfully!'); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to create branch'; |
|
} finally { |
|
saving = false; |
|
} |
|
} |
|
|
|
async function deleteBranch(branchName: string) { |
|
if (!confirm(`Are you sure you want to delete the branch "${branchName}"?\n\nThis will permanently delete the branch from the repository. This action CANNOT be undone.\n\nClick OK to delete, or Cancel to abort.`)) { |
|
return; |
|
} |
|
|
|
if (!userPubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
// Prevent deleting the current branch |
|
if (branchName === currentBranch) { |
|
alert('Cannot delete the currently selected branch. Please switch to a different branch first.'); |
|
return; |
|
} |
|
|
|
saving = true; |
|
error = null; |
|
|
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/branches`, { |
|
method: 'DELETE', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
...buildApiHeaders() |
|
}, |
|
body: JSON.stringify({ |
|
branchName: branchName |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json(); |
|
throw new Error(errorData.message || 'Failed to delete branch'); |
|
} |
|
|
|
await loadBranches(); |
|
alert('Branch deleted successfully!'); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to delete branch'; |
|
alert(error); |
|
} finally { |
|
saving = false; |
|
} |
|
} |
|
|
|
async function loadCommitHistory() { |
|
loadingCommits = true; |
|
error = null; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/commits?branch=${currentBranch}&limit=50`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
// Normalize commits: API-based commits use 'sha', local commits use 'hash' |
|
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 |
|
} |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to load commit history'; |
|
} finally { |
|
loadingCommits = false; |
|
} |
|
} |
|
|
|
async function viewDiff(commitHash: string) { |
|
// Set selected commit immediately so it shows in the right panel |
|
selectedCommit = commitHash; |
|
showDiff = false; // Start with false, will be set to true when diff loads |
|
loadingCommits = true; |
|
error = null; |
|
try { |
|
// Normalize commit hash (handle both 'hash' and 'sha' properties) |
|
const getCommitHash = (c: any) => c.hash || c.sha || ''; |
|
const commitIndex = commits.findIndex(c => getCommitHash(c) === commitHash); |
|
const parentHash = commitIndex >= 0 |
|
? (commits[commitIndex + 1] ? getCommitHash(commits[commitIndex + 1]) : `${commitHash}^`) |
|
: `${commitHash}^`; |
|
|
|
const response = await fetch(`/api/repos/${npub}/${repo}/diff?from=${parentHash}&to=${commitHash}`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
diffData = await response.json(); |
|
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 (isRepoCloned === false || (isRepoCloned === true && apiFallbackAvailable)) { |
|
error = 'Diffs are not available for commits viewed via API fallback. Please clone the repository to view diffs.'; |
|
} else { |
|
error = `Commit not found: ${errorText || 'The commit may not exist in the repository'}`; |
|
} |
|
} else { |
|
error = `Failed to load diff: ${errorText || response.statusText}`; |
|
} |
|
} |
|
} catch (err) { |
|
// Handle network errors |
|
if (err instanceof TypeError && err.message.includes('NetworkError')) { |
|
error = 'Network error: Unable to fetch diff. Please check your connection and try again.'; |
|
} else { |
|
error = err instanceof Error ? err.message : 'Failed to load diff'; |
|
} |
|
} finally { |
|
loadingCommits = false; |
|
} |
|
} |
|
|
|
async function loadTags() { |
|
if (repoNotFound) return; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/tags`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
tags = await response.json(); |
|
// Auto-select first tag if none selected |
|
if (tags.length > 0 && !selectedTag) { |
|
selectedTag = tags[0].name; |
|
} |
|
} |
|
} catch (err) { |
|
console.error('Failed to load tags:', err); |
|
} |
|
} |
|
|
|
async function createTag() { |
|
if (!newTagName.trim()) { |
|
alert('Please enter a tag name'); |
|
return; |
|
} |
|
|
|
if (!userPubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
saving = true; |
|
error = null; |
|
|
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/tags`, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
...buildApiHeaders() |
|
}, |
|
body: JSON.stringify({ |
|
tagName: newTagName, |
|
ref: newTagRef, |
|
message: newTagMessage || undefined, |
|
userPubkey: userPubkey |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json(); |
|
throw new Error(errorData.message || 'Failed to create tag'); |
|
} |
|
|
|
showCreateTagDialog = false; |
|
newTagName = ''; |
|
newTagMessage = ''; |
|
await loadTags(); |
|
alert('Tag created successfully!'); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to create tag'; |
|
} finally { |
|
saving = false; |
|
} |
|
} |
|
|
|
async function loadReleases() { |
|
if (repoNotFound) return; |
|
loadingReleases = true; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/releases`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
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 releases:', err); |
|
} finally { |
|
loadingReleases = false; |
|
} |
|
} |
|
|
|
async function createRelease() { |
|
if (!newReleaseTagName.trim() || !newReleaseTagHash.trim()) { |
|
alert('Please enter a tag name and tag hash'); |
|
return; |
|
} |
|
|
|
if (!userPubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
if (!isMaintainer && userPubkeyHex !== repoOwnerPubkeyDerived) { |
|
alert('Only repository owners and maintainers can create releases'); |
|
return; |
|
} |
|
|
|
creatingRelease = true; |
|
error = null; |
|
|
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/releases`, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
...buildApiHeaders() |
|
}, |
|
body: JSON.stringify({ |
|
tagName: newReleaseTagName, |
|
tagHash: newReleaseTagHash, |
|
releaseNotes: newReleaseNotes, |
|
isDraft: newReleaseIsDraft, |
|
isPrerelease: newReleaseIsPrerelease |
|
}) |
|
}); |
|
|
|
if (!response.ok) { |
|
const errorData = await response.json(); |
|
throw new Error(errorData.message || 'Failed to create release'); |
|
} |
|
|
|
showCreateReleaseDialog = false; |
|
newReleaseTagName = ''; |
|
newReleaseTagHash = ''; |
|
newReleaseNotes = ''; |
|
newReleaseIsDraft = false; |
|
newReleaseIsPrerelease = false; |
|
await loadReleases(); |
|
// Reload tags to show release indicator |
|
await loadTags(); |
|
alert('Release created successfully!'); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to create release'; |
|
alert(error); |
|
} finally { |
|
creatingRelease = false; |
|
} |
|
} |
|
|
|
async function performCodeSearch() { |
|
if (!codeSearchQuery.trim() || codeSearchQuery.length < 2) { |
|
codeSearchResults = []; |
|
return; |
|
} |
|
|
|
loadingCodeSearch = true; |
|
error = null; |
|
|
|
try { |
|
const url = codeSearchScope === 'repo' |
|
? `/api/repos/${npub}/${repo}/code-search?q=${encodeURIComponent(codeSearchQuery.trim())}` |
|
: `/api/code-search?q=${encodeURIComponent(codeSearchQuery.trim())}&repo=${encodeURIComponent(`${npub}/${repo}`)}`; |
|
|
|
const response = await fetch(url, { |
|
headers: buildApiHeaders() |
|
}); |
|
|
|
if (response.ok) { |
|
codeSearchResults = await response.json(); |
|
} else { |
|
const errorData = await response.json(); |
|
throw new Error(errorData.message || 'Failed to search code'); |
|
} |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to search code'; |
|
codeSearchResults = []; |
|
} finally { |
|
loadingCodeSearch = false; |
|
} |
|
} |
|
|
|
async function loadIssues() { |
|
loadingIssues = true; |
|
error = null; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/issues`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
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 (issues.length > 0 && !selectedIssue) { |
|
selectedIssue = issues[0].id; |
|
loadIssueReplies(issues[0].id); |
|
} |
|
} else { |
|
// Handle non-OK responses |
|
const errorText = await response.text().catch(() => response.statusText); |
|
let errorMessage = `Failed to load 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); |
|
error = errorMessage; |
|
// Don't clear issues array - keep existing issues if any |
|
// issues = []; // Only clear if you want to show empty state on error |
|
} |
|
} catch (err) { |
|
const errorMessage = err instanceof Error ? err.message : 'Failed to load issues'; |
|
console.error('[Issues] Error loading issues:', err); |
|
error = errorMessage; |
|
} finally { |
|
loadingIssues = false; |
|
} |
|
} |
|
|
|
async function loadIssueReplies(issueId: string) { |
|
loadingIssueReplies = true; |
|
try { |
|
const replies = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.COMMENT], |
|
'#e': [issueId], |
|
limit: 100 |
|
} |
|
]) as NostrEvent[]; |
|
|
|
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 loading replies:', err); |
|
issueReplies = []; |
|
} finally { |
|
loadingIssueReplies = false; |
|
} |
|
} |
|
|
|
async function createIssue() { |
|
if (!newIssueSubject.trim() || !newIssueContent.trim()) { |
|
alert('Please enter a subject and content'); |
|
return; |
|
} |
|
|
|
if (!userPubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
saving = true; |
|
error = null; |
|
|
|
try { |
|
const { IssuesService } = await import('$lib/services/nostr/issues-service.js'); |
|
|
|
const decoded = nip19.decode(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(userPubkey, tempClient); |
|
const combinedRelays = combineRelays(outbox); |
|
|
|
const issuesService = new IssuesService(combinedRelays); |
|
const issue = await issuesService.createIssue( |
|
repoOwnerPubkey, |
|
repo, |
|
newIssueSubject.trim(), |
|
newIssueContent.trim(), |
|
newIssueLabels.filter(l => l.trim()) |
|
); |
|
|
|
showCreateIssueDialog = false; |
|
newIssueSubject = ''; |
|
newIssueContent = ''; |
|
newIssueLabels = ['']; |
|
await loadIssues(); |
|
alert('Issue created successfully!'); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to create issue'; |
|
console.error('Error creating issue:', err); |
|
} finally { |
|
saving = false; |
|
} |
|
} |
|
|
|
async function updateIssueStatus(issueId: string, issueAuthor: string, status: 'open' | 'closed' | 'resolved' | 'draft') { |
|
if (!userPubkeyHex) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
// Check if user is maintainer or issue author |
|
const isAuthor = userPubkeyHex === issueAuthor; |
|
if (!isMaintainer && !isAuthor) { |
|
alert('Only repository maintainers or issue authors can update issue status'); |
|
return; |
|
} |
|
|
|
updatingIssueStatus = { ...updatingIssueStatus, [issueId]: true }; |
|
error = null; |
|
|
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${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.error || 'Failed to update issue status'); |
|
} |
|
|
|
await loadIssues(); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to update issue status'; |
|
console.error('Error updating issue status:', err); |
|
} finally { |
|
updatingIssueStatus = { ...updatingIssueStatus, [issueId]: false }; |
|
} |
|
} |
|
|
|
async function loadPRs() { |
|
loadingPRs = true; |
|
error = null; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/prs`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
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) { |
|
error = err instanceof Error ? err.message : 'Failed to load pull requests'; |
|
} finally { |
|
loadingPRs = false; |
|
} |
|
} |
|
|
|
async function createPR() { |
|
if (!newPRSubject.trim() || !newPRContent.trim() || !newPRCommitId.trim()) { |
|
alert('Please enter a subject, content, and commit ID'); |
|
return; |
|
} |
|
|
|
if (!userPubkey) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
saving = true; |
|
error = null; |
|
|
|
try { |
|
const { PRsService } = await import('$lib/services/nostr/prs-service.js'); |
|
const { getGitUrl } = await import('$lib/config.js'); |
|
|
|
const decoded = nip19.decode(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(userPubkey, tempClient); |
|
const combinedRelays = combineRelays(outbox); |
|
|
|
const cloneUrl = getGitUrl(npub, repo); |
|
const prsService = new PRsService(combinedRelays); |
|
const pr = await prsService.createPullRequest( |
|
repoOwnerPubkey, |
|
repo, |
|
newPRSubject.trim(), |
|
newPRContent.trim(), |
|
newPRCommitId.trim(), |
|
cloneUrl, |
|
newPRBranchName.trim() || undefined, |
|
newPRLabels.filter(l => l.trim()) |
|
); |
|
|
|
showCreatePRDialog = false; |
|
newPRSubject = ''; |
|
newPRContent = ''; |
|
newPRCommitId = ''; |
|
newPRBranchName = ''; |
|
newPRLabels = ['']; |
|
await loadPRs(); |
|
alert('Pull request created successfully!'); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to create pull request'; |
|
console.error('Error creating PR:', err); |
|
} finally { |
|
saving = false; |
|
} |
|
} |
|
|
|
async function createPatch() { |
|
if (!newPatchContent.trim()) { |
|
alert('Please enter patch content'); |
|
return; |
|
} |
|
|
|
if (!userPubkey || !userPubkeyHex) { |
|
alert('Please connect your NIP-07 extension'); |
|
return; |
|
} |
|
|
|
creatingPatch = true; |
|
error = null; |
|
|
|
try { |
|
const decoded = nip19.decode(npub); |
|
if (decoded.type !== 'npub') { |
|
throw new Error('Invalid npub format'); |
|
} |
|
const repoOwnerPubkey = decoded.data as string; |
|
const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repo}`; |
|
|
|
// Get user's relays and combine with defaults |
|
const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
|
const { outbox } = await getUserRelays(userPubkey, tempClient); |
|
const combinedRelays = combineRelays(outbox); |
|
|
|
// Create patch event (kind 1617) |
|
const patchEventTemplate: Omit<NostrEvent, 'sig' | 'id'> = { |
|
kind: KIND.PATCH, |
|
pubkey: userPubkeyHex, |
|
created_at: Math.floor(Date.now() / 1000), |
|
tags: [ |
|
['a', repoAddress], |
|
['p', repoOwnerPubkey], |
|
['t', 'root'] |
|
], |
|
content: newPatchContent.trim() |
|
}; |
|
|
|
// Add subject if provided |
|
if (newPatchSubject.trim()) { |
|
patchEventTemplate.tags.push(['subject', newPatchSubject.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'); |
|
} |
|
|
|
showCreatePatchDialog = false; |
|
newPatchContent = ''; |
|
newPatchSubject = ''; |
|
alert('Patch created successfully!'); |
|
// Reload patches |
|
await loadPatches(); |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to create patch'; |
|
console.error('Error creating patch:', err); |
|
} finally { |
|
creatingPatch = false; |
|
} |
|
} |
|
|
|
async function loadPatches() { |
|
if (repoNotFound) return; |
|
loadingPatches = true; |
|
error = null; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/patches`, { |
|
headers: buildApiHeaders() |
|
}); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
patches = data.map((patch: { id: string; tags: string[][]; content: string; pubkey: string; created_at: number; kind?: number }) => { |
|
// 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, |
|
author: patch.pubkey, |
|
created_at: patch.created_at, |
|
kind: patch.kind || KIND.PATCH, |
|
description: description?.trim(), |
|
tags: patch.tags || [] |
|
}; |
|
}); |
|
} |
|
} catch (err) { |
|
error = err instanceof Error ? err.message : 'Failed to load patches'; |
|
console.error('Error loading patches:', err); |
|
} finally { |
|
loadingPatches = false; |
|
} |
|
} |
|
|
|
// Only load tab content when tab actually changes, not on every render |
|
let lastTab = $state<string | null>(null); |
|
$effect(() => { |
|
if (!isMounted) return; |
|
if (activeTab !== lastTab) { |
|
lastTab = activeTab; |
|
if (!isMounted) return; |
|
|
|
if (activeTab === 'files') { |
|
// Files tab - ensure files are loaded and README is shown if available |
|
if (files.length === 0 || currentPath !== '') { |
|
loadFiles('').catch(err => { |
|
if (isMounted) console.warn('Failed to load files:', err); |
|
}); |
|
} else if (files.length > 0 && !currentFile && isMounted) { |
|
// Files already loaded, ensure README is shown |
|
const readmeFile = findReadmeFile(files); |
|
if (readmeFile) { |
|
setTimeout(() => { |
|
if (isMounted) { |
|
loadFile(readmeFile.path).catch(err => { |
|
if (isMounted) console.warn('Failed to load README file:', err); |
|
}); |
|
} |
|
}, 100); |
|
} |
|
} |
|
} else if (activeTab === 'history' && isMounted) { |
|
loadCommitHistory().catch(err => { |
|
if (isMounted) console.warn('Failed to load commit history:', err); |
|
}); |
|
} else if (activeTab === 'tags' && isMounted) { |
|
loadTags().catch(err => { |
|
if (isMounted) console.warn('Failed to load tags:', err); |
|
}); |
|
loadReleases().catch(err => { |
|
if (isMounted) console.warn('Failed to load releases:', err); |
|
}); // Load releases to check for tag associations |
|
} else if (activeTab === 'code-search') { |
|
// Code search is performed on demand, not auto-loaded |
|
} else if (activeTab === 'issues' && isMounted) { |
|
loadIssues().catch(err => { |
|
if (isMounted) console.warn('Failed to load issues:', err); |
|
}); |
|
} else if (activeTab === 'prs' && isMounted) { |
|
loadPRs().catch(err => { |
|
if (isMounted) console.warn('Failed to load PRs:', err); |
|
}); |
|
} else if (activeTab === 'docs' && isMounted) { |
|
loadDocumentation().catch(err => { |
|
if (isMounted) console.warn('Failed to load documentation:', err); |
|
}); |
|
} else if (activeTab === 'discussions' && isMounted) { |
|
loadDiscussions().catch(err => { |
|
if (isMounted) console.warn('Failed to load discussions:', err); |
|
}); |
|
} else if (activeTab === 'patches' && isMounted) { |
|
loadPatches().catch(err => { |
|
if (isMounted) console.warn('Failed to load patches:', err); |
|
}); |
|
} |
|
} |
|
}); |
|
|
|
// Reload all branch-dependent data when branch changes |
|
let lastBranch = $state<string | null>(null); |
|
$effect(() => { |
|
if (!isMounted) return; |
|
if (currentBranch && currentBranch !== lastBranch) { |
|
lastBranch = currentBranch; |
|
if (!isMounted) return; |
|
|
|
// Reload README (always branch-specific) |
|
loadReadme().catch(err => { |
|
if (isMounted) console.warn('Failed to reload README after branch change:', err); |
|
}); |
|
|
|
// Reload files if files tab is active |
|
if (activeTab === 'files' && isMounted) { |
|
if (currentFile) { |
|
loadFile(currentFile).catch(err => { |
|
if (isMounted) console.warn('Failed to reload file after branch change:', err); |
|
}); |
|
} else { |
|
loadFiles(currentPath).catch(err => { |
|
if (isMounted) console.warn('Failed to reload files after branch change:', err); |
|
}); |
|
} |
|
} |
|
|
|
// Reload commit history if history tab is active |
|
if (activeTab === 'history' && isMounted) { |
|
loadCommitHistory().catch(err => { |
|
if (isMounted) console.warn('Failed to reload commit history after branch change:', err); |
|
}); |
|
} |
|
|
|
// Reload documentation if docs tab is active (reset to force reload) |
|
if (activeTab === 'docs' && isMounted) { |
|
documentationHtml = null; |
|
documentationContent = null; |
|
documentationKind = null; |
|
loadDocumentation().catch(err => { |
|
if (isMounted) console.warn('Failed to reload documentation after branch change:', err); |
|
}); |
|
} |
|
} |
|
}); |
|
</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 repoBanner && typeof repoBanner === 'string' && repoBanner.trim()} |
|
<div class="repo-banner desktop-only"> |
|
<img src={repoBanner} alt="" onerror={(e) => { |
|
if (typeof window !== 'undefined') { |
|
console.error('[Repo Images] Failed to load banner:', repoBanner); |
|
const target = e.target as HTMLImageElement; |
|
if (target) target.style.display = 'none'; |
|
} |
|
}} /> |
|
</div> |
|
{/if} |
|
|
|
{#if repoOwnerPubkeyDerived} |
|
<RepoHeaderEnhanced |
|
repoName={repoName || ''} |
|
repoDescription={repoDescription || ''} |
|
ownerNpub={npub || ''} |
|
ownerPubkey={repoOwnerPubkeyDerived || ''} |
|
isMaintainer={isMaintainer || false} |
|
isPrivate={repoIsPrivate || false} |
|
cloneUrls={repoCloneUrls || []} |
|
branches={branches || []} |
|
currentBranch={currentBranch || null} |
|
topics={repoTopics || []} |
|
defaultBranch={defaultBranch || null} |
|
isRepoCloned={isRepoCloned || false} |
|
copyingCloneUrl={copyingCloneUrl || false} |
|
onBranchChange={safeHandleBranchChange} |
|
onCopyCloneUrl={safeCopyCloneUrl} |
|
onDeleteBranch={safeDeleteBranch} |
|
onMenuToggle={() => { if (typeof showRepoMenu !== 'undefined') showRepoMenu = !showRepoMenu; }} |
|
showMenu={showRepoMenu || false} |
|
userPubkey={userPubkey || null} |
|
isBookmarked={isBookmarked || false} |
|
loadingBookmark={loadingBookmark || false} |
|
onToggleBookmark={safeToggleBookmark} |
|
onFork={safeForkRepository} |
|
forking={forking || false} |
|
onCloneToServer={safeCloneRepository} |
|
cloning={cloning || false} |
|
checkingCloneStatus={checkingCloneStatus || false} |
|
onCreateIssue={() => { if (typeof showCreateIssueDialog !== 'undefined') showCreateIssueDialog = true; }} |
|
onCreatePR={() => { if (typeof showCreatePRDialog !== 'undefined') showCreatePRDialog = true; }} |
|
onCreatePatch={() => { if (typeof showCreatePatchDialog !== 'undefined') showCreatePatchDialog = true; }} |
|
onCreateBranch={async () => { |
|
if (!userPubkey || !isMaintainer || needsClone) return; |
|
try { |
|
const settings = await settingsStore.getSettings(); |
|
defaultBranchName = settings.defaultBranch || 'master'; |
|
} catch { |
|
defaultBranchName = 'master'; |
|
} |
|
// Preset the default branch name in the input field |
|
newBranchName = defaultBranchName; |
|
newBranchFrom = null; // Reset from branch selection |
|
showCreateBranchDialog = true; |
|
}} |
|
onSettings={() => goto(`/signup?npub=${npub}&repo=${repo}`)} |
|
onGenerateVerification={repoOwnerPubkeyDerived && userPubkeyHex === repoOwnerPubkeyDerived && verificationStatus?.verified !== true ? generateAnnouncementFileForRepo : undefined} |
|
onDeleteAnnouncement={repoOwnerPubkeyDerived && userPubkeyHex === repoOwnerPubkeyDerived ? deleteAnnouncement : undefined} |
|
deletingAnnouncement={deletingAnnouncement} |
|
hasUnlimitedAccess={hasUnlimitedAccess($userStore.userLevel)} |
|
needsClone={needsClone} |
|
allMaintainers={allMaintainers} |
|
onCopyEventId={copyEventId} |
|
/> |
|
{/if} |
|
|
|
<!-- Additional repo metadata (website, clone URLs with verification) --> |
|
|
|
{#if repoWebsite || (repoCloneUrls && repoCloneUrls.length > 0) || repoLanguage || (repoTopics && repoTopics.length > 0) || forkInfo?.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 forkInfo?.isFork && forkInfo.originalRepo} |
|
<span class="fork-badge">Forked from <a href={`/repos/${forkInfo.originalRepo.npub}/${forkInfo.originalRepo.repo}`}>{forkInfo.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={() => cloneUrlsExpanded = !cloneUrlsExpanded} |
|
aria-expanded={cloneUrlsExpanded} |
|
> |
|
<span class="clone-label">Clone URLs:</span> |
|
<img src="/icons/chevron-down.svg" alt="" class="clone-toggle-icon icon-inline" class:expanded={cloneUrlsExpanded} /> |
|
</button> |
|
<button |
|
class="reachability-refresh-button" |
|
onclick={() => loadCloneUrlReachability(true)} |
|
disabled={loadingReachability} |
|
title="Refresh reachability status" |
|
> |
|
{#if loadingReachability} |
|
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={!cloneUrlsExpanded}> |
|
{#if isRepoCloned === true} |
|
<button |
|
class="copy-clone-url-button" |
|
onclick={() => copyCloneUrl()} |
|
disabled={copyingCloneUrl} |
|
title="Copy clone URL" |
|
> |
|
<img src="/icons/copy.svg" alt="" class="icon-inline" /> |
|
{copyingCloneUrl ? 'Copying...' : 'Copy Clone URL'} |
|
</button> |
|
{/if} |
|
{#each (showAllCloneUrls ? repoCloneUrls : repoCloneUrls.slice(0, 3)) as cloneUrl} |
|
{@const cloneVerification = verificationStatus?.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 = cloneUrlReachability.get(cloneUrl)} |
|
{@const isChecking = checkingReachability.has(cloneUrl)} |
|
<div class="clone-url-wrapper"> |
|
<code class="clone-url">{cloneUrl}</code> |
|
{#if loadingVerification} |
|
<span class="verification-badge loading" title="Checking verification..."> |
|
<span style="opacity: 0.5;">⋯</span> |
|
</span> |
|
{:else if cloneVerification !== undefined} |
|
<span |
|
class="verification-badge" |
|
class:verified={cloneVerification.verified} |
|
class:unverified={!cloneVerification.verified} |
|
title={cloneVerification.verified ? 'Verified ownership' : (cloneVerification.error || 'Unverified')} |
|
> |
|
{#if cloneVerification.verified} |
|
<img src="/icons/check-circle.svg" alt="Verified" class="icon-inline" /> |
|
{:else} |
|
<img src="/icons/alert-triangle.svg" alt="Unverified" class="icon-inline" /> |
|
{/if} |
|
</span> |
|
{:else if verificationStatus} |
|
<span class="verification-badge unverified" title="Verification status unknown"> |
|
<img src="/icons/alert-triangle.svg" alt="Unknown" class="icon-inline" /> |
|
</span> |
|
{: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 isChecking || loadingReachability} |
|
<span class="reachability-badge loading" 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-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={() => showAllCloneUrls = !showAllCloneUrls} |
|
title={showAllCloneUrls ? 'Show fewer' : 'Show all clone URLs'} |
|
> |
|
{showAllCloneUrls ? `-${repoCloneUrls.length - 3} less` : `+${repoCloneUrls.length - 3} more`} |
|
</button> |
|
{/if} |
|
</div> |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
<main class="repo-view"> |
|
{#if isRepoCloned === false && (canUseApiFallback || apiFallbackAvailable === null)} |
|
<div class="read-only-banner"> |
|
<div class="banner-content"> |
|
<img src="/icons/alert-circle.svg" alt="Info" class="banner-icon" /> |
|
<span> |
|
{#if 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) && apiFallbackAvailable !== null} |
|
<button |
|
class="clone-button-banner" |
|
onclick={cloneRepository} |
|
disabled={cloning || checkingCloneStatus} |
|
> |
|
{cloning ? 'Cloning...' : (checkingCloneStatus ? 'Checking...' : 'Clone to Server')} |
|
</button> |
|
{/if} |
|
</div> |
|
</div> |
|
{/if} |
|
{#if error} |
|
<div class="error"> |
|
<div class="error-message"> |
|
<strong>Error:</strong> {error} |
|
</div> |
|
{#if error.includes('not cloned locally') && hasUnlimitedAccess($userStore.userLevel)} |
|
<div class="error-actions"> |
|
<button |
|
class="clone-button-inline" |
|
onclick={cloneRepository} |
|
disabled={cloning || checkingCloneStatus} |
|
> |
|
{cloning ? 'Cloning...' : (checkingCloneStatus ? 'Checking...' : 'Clone to Server')} |
|
</button> |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
<!-- Tabs --> |
|
|
|
{#if isRepoCloned === 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"> |
|
<!-- File Tree Sidebar --> |
|
{#if activeTab === 'files' && canViewRepo} |
|
<aside class="file-tree" class:hide-on-mobile={!showFileListOnMobile && activeTab === 'files'}> |
|
<div class="file-tree-header"> |
|
<TabsMenu |
|
activeTab={activeTab} |
|
{tabs} |
|
onTabChange={(tab) => activeTab = tab as typeof activeTab} |
|
/> |
|
<h2>Files {#if isRepoCloned === false && canUseApiFallback}<span class="read-only-badge">Read-Only</span>{/if}</h2> |
|
<button |
|
onclick={toggleWordWrap} |
|
class="word-wrap-button" |
|
title={wordWrap ? 'Disable word wrap' : 'Enable word wrap'} |
|
aria-label={wordWrap ? 'Disable word wrap' : 'Enable word wrap'} |
|
> |
|
{wordWrap ? 'Wrap' : 'No Wrap'} |
|
</button> |
|
<div class="file-tree-actions"> |
|
{#if pathStack.length > 0 || currentPath} |
|
<button onclick={handleBack} class="back-button">← Back</button> |
|
{/if} |
|
{#if userPubkey && isMaintainer} |
|
<button |
|
onclick={() => { |
|
if (!userPubkey || !isMaintainer || needsClone) return; |
|
showCreateFileDialog = true; |
|
}} |
|
class="create-file-button" |
|
disabled={needsClone} |
|
title={needsClone ? cloneTooltip : 'Create a new file'} |
|
> |
|
<img src="/icons/plus.svg" alt="New File" class="icon" /> |
|
</button> |
|
{/if} |
|
<button |
|
onclick={() => showFileListOnMobile = !showFileListOnMobile} |
|
class="mobile-toggle-button" |
|
title="Show file viewer" |
|
> |
|
<img src="/icons/arrow-right.svg" alt="Show file viewer" class="icon-inline" /> |
|
</button> |
|
</div> |
|
</div> |
|
{#if loading && !currentFile} |
|
<div class="loading">Loading files...</div> |
|
{:else} |
|
<ul class="file-list"> |
|
{#each files as file} |
|
<li class="file-item" class:directory={file.type === 'directory'} class:selected={currentFile === file.path}> |
|
<button onclick={() => handleFileClick(file)} class="file-button"> |
|
{#if file.type === 'directory'} |
|
<img src="/icons/folder.svg" alt="Directory" class="icon-inline folder-icon" /> |
|
{:else} |
|
<img src="/icons/file-text.svg" alt="File" class="icon-inline file-icon" /> |
|
{/if} |
|
{file.name} |
|
{#if file.size !== undefined} |
|
<span class="file-size">({(file.size / 1024).toFixed(1)} KB)</span> |
|
{/if} |
|
</button> |
|
{#if userPubkey && isMaintainer && file.type === 'file'} |
|
<button |
|
onclick={() => { |
|
if (needsClone) return; |
|
deleteFile(file.path); |
|
}} |
|
class="delete-file-button" |
|
disabled={needsClone} |
|
title={needsClone ? cloneTooltip : 'Delete file'} |
|
> |
|
<img src="/icons/x.svg" alt="Delete" class="icon-small" /> |
|
</button> |
|
{/if} |
|
</li> |
|
{/each} |
|
</ul> |
|
{/if} |
|
</aside> |
|
{/if} |
|
|
|
<!-- Commit History View --> |
|
{#if activeTab === 'history' && canViewRepo} |
|
<aside class="history-sidebar" class:hide-on-mobile={!showLeftPanelOnMobile && activeTab === 'history'}> |
|
<div class="history-header"> |
|
<TabsMenu |
|
activeTab={activeTab} |
|
{tabs} |
|
onTabChange={(tab) => activeTab = tab as typeof activeTab} |
|
/> |
|
<h2>Commits {#if isRepoCloned === false && canUseApiFallback}<span class="read-only-badge">Read-Only</span>{/if}</h2> |
|
<button |
|
onclick={() => showLeftPanelOnMobile = !showLeftPanelOnMobile} |
|
class="mobile-toggle-button" |
|
title="Show content" |
|
> |
|
<img src="/icons/arrow-right.svg" alt="Show content" class="icon-inline" /> |
|
</button> |
|
</div> |
|
{#if loadingCommits} |
|
<div class="loading">Loading commits...</div> |
|
{:else if commits.length > 0} |
|
<ul class="commit-list"> |
|
{#each commits as commit} |
|
{@const commitHash = commit.hash || (commit as any).sha || ''} |
|
{#if commitHash} |
|
<li class="commit-item" class:selected={selectedCommit === commitHash}> |
|
<button onclick={() => viewDiff(commitHash)} class="commit-button"> |
|
<div class="commit-hash">{commitHash.slice(0, 7)}</div> |
|
<div class="commit-message">{commit.message || 'No message'}</div> |
|
<div class="commit-meta"> |
|
<span>{commit.author || 'Unknown'}</span> |
|
<span>{commit.date ? new Date(commit.date).toLocaleString() : 'Unknown date'}</span> |
|
</div> |
|
</button> |
|
</li> |
|
{/if} |
|
{/each} |
|
</ul> |
|
{/if} |
|
</aside> |
|
{/if} |
|
|
|
<!-- Tags View --> |
|
<TagsTab |
|
{npub} |
|
{repo} |
|
{tags} |
|
{releases} |
|
{selectedTag} |
|
{isMaintainer} |
|
{userPubkeyHex} |
|
repoOwnerPubkeyDerived={repoOwnerPubkeyDerived} |
|
{isRepoCloned} |
|
{canViewRepo} |
|
{canUseApiFallback} |
|
{needsClone} |
|
{cloneTooltip} |
|
{activeTab} |
|
{tabs} |
|
{showLeftPanelOnMobile} |
|
onTagSelect={(tagName) => selectedTag = tagName} |
|
onTabChange={(tab) => activeTab = tab as typeof activeTab} |
|
onToggleMobilePanel={() => showLeftPanelOnMobile = !showLeftPanelOnMobile} |
|
onCreateTag={() => showCreateTagDialog = true} |
|
onCreateRelease={(tagName, tagHash) => { |
|
newReleaseTagName = tagName; |
|
newReleaseTagHash = tagHash; |
|
showCreateReleaseDialog = true; |
|
}} |
|
onLoadTags={loadTags} |
|
/> |
|
|
|
<!-- Code Search View --> |
|
{#if activeTab === 'code-search' && canViewRepo} |
|
<aside class="code-search-sidebar" class:hide-on-mobile={!showLeftPanelOnMobile && activeTab === 'code-search'}> |
|
<div class="code-search-header"> |
|
<TabsMenu |
|
activeTab={activeTab} |
|
{tabs} |
|
onTabChange={(tab) => activeTab = tab as typeof activeTab} |
|
/> |
|
<h2>Code Search</h2> |
|
<button |
|
onclick={() => showLeftPanelOnMobile = !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 View --> |
|
{#if activeTab === 'issues'} |
|
<aside class="issues-sidebar" class:hide-on-mobile={!showLeftPanelOnMobile && activeTab === 'issues'}> |
|
<div class="issues-header"> |
|
<TabsMenu |
|
activeTab={activeTab} |
|
{tabs} |
|
onTabChange={(tab) => activeTab = tab as typeof activeTab} |
|
/> |
|
<h2>Issues</h2> |
|
{#if userPubkey} |
|
<button onclick={() => { |
|
if (!userPubkey) return; |
|
showCreateIssueDialog = true; |
|
}} class="create-issue-button" title="Create a new issue"> |
|
<img src="/icons/plus.svg" alt="New Issue" class="icon" /> |
|
</button> |
|
{/if} |
|
<button |
|
onclick={() => showLeftPanelOnMobile = !showLeftPanelOnMobile} |
|
class="mobile-toggle-button" |
|
title="Show content" |
|
> |
|
<img src="/icons/arrow-right.svg" alt="Show content" class="icon-inline" /> |
|
</button> |
|
</div> |
|
{#if loadingIssues} |
|
<div class="loading">Loading issues...</div> |
|
{:else if issues.length > 0} |
|
<ul class="issue-list"> |
|
{#each issues as issue} |
|
<li class="issue-item" class:selected={selectedIssue === issue.id}> |
|
<button |
|
onclick={() => { |
|
selectedIssue = issue.id; |
|
loadIssueReplies(issue.id); |
|
}} |
|
class="issue-item-button" |
|
> |
|
<div class="issue-header"> |
|
<span class="issue-status" class:open={issue.status === 'open'} class:closed={issue.status === 'closed'} class:resolved={issue.status === 'resolved'}> |
|
{issue.status} |
|
</span> |
|
</div> |
|
<div class="issue-subject">{issue.subject}</div> |
|
</button> |
|
</li> |
|
{/each} |
|
</ul> |
|
{/if} |
|
</aside> |
|
{/if} |
|
|
|
<!-- Pull Requests View --> |
|
{#if activeTab === 'prs'} |
|
<aside class="prs-sidebar" class:hide-on-mobile={!showLeftPanelOnMobile && activeTab === 'prs'}> |
|
<div class="prs-header"> |
|
<TabsMenu |
|
activeTab={activeTab} |
|
{tabs} |
|
onTabChange={(tab) => activeTab = tab as typeof activeTab} |
|
/> |
|
<h2>Pull Requests</h2> |
|
{#if userPubkey} |
|
<button onclick={() => { |
|
if (!userPubkey) return; |
|
showCreatePRDialog = true; |
|
}} class="create-pr-button" title="Create a new pull request"> |
|
<img src="/icons/plus.svg" alt="New PR" class="icon" /> |
|
</button> |
|
{/if} |
|
<button |
|
onclick={() => showLeftPanelOnMobile = !showLeftPanelOnMobile} |
|
class="mobile-toggle-button" |
|
title="Show content" |
|
> |
|
<img src="/icons/arrow-right.svg" alt="Show content" class="icon-inline" /> |
|
</button> |
|
</div> |
|
{#if loadingPRs} |
|
<div class="loading">Loading pull requests...</div> |
|
{:else if prs.length > 0} |
|
<ul class="pr-list"> |
|
{#each prs as pr} |
|
<li class="pr-item"> |
|
<div class="pr-header"> |
|
<span class="pr-status" class:open={pr.status === 'open'} class:closed={pr.status === 'closed'} class:merged={pr.status === 'merged'}> |
|
{pr.status} |
|
</span> |
|
<span class="pr-subject">{pr.subject}</span> |
|
</div> |
|
<div class="pr-meta"> |
|
<span>#{pr.id.slice(0, 7)}</span> |
|
{#if pr.commitId} |
|
<span class="pr-commit">Commit: {pr.commitId.slice(0, 7)}</span> |
|
{/if} |
|
<span>{new Date(pr.created_at * 1000).toLocaleDateString()}</span> |
|
<EventCopyButton eventId={pr.id} kind={pr.kind} pubkey={pr.author} /> |
|
</div> |
|
</li> |
|
{/each} |
|
</ul> |
|
{/if} |
|
</aside> |
|
{/if} |
|
|
|
<!-- Patches View --> |
|
{#if activeTab === 'patches'} |
|
<aside class="patches-sidebar" class:hide-on-mobile={!showLeftPanelOnMobile && activeTab === 'patches'}> |
|
<div class="patches-header"> |
|
<TabsMenu |
|
activeTab={activeTab} |
|
{tabs} |
|
onTabChange={(tab) => activeTab = tab as typeof activeTab} |
|
/> |
|
<h2>Patches</h2> |
|
{#if userPubkey} |
|
<button |
|
onclick={() => showCreatePatchDialog = true} |
|
class="create-patch-button" |
|
title="Create a new patch" |
|
> |
|
<img src="/icons/plus.svg" alt="New Patch" class="icon" /> |
|
</button> |
|
{/if} |
|
<button |
|
onclick={() => showLeftPanelOnMobile = !showLeftPanelOnMobile} |
|
class="mobile-toggle-button" |
|
title="Show content" |
|
> |
|
<img src="/icons/arrow-right.svg" alt="Show content" class="icon-inline" /> |
|
</button> |
|
</div> |
|
{#if loadingPatches} |
|
<div class="loading">Loading patches...</div> |
|
{:else if patches.length > 0} |
|
<ul class="patch-list"> |
|
{#each patches as patch} |
|
<li class="patch-item" class:selected={selectedPatch === patch.id}> |
|
<button |
|
onclick={() => selectedPatch = patch.id} |
|
class="patch-item-button" |
|
> |
|
<div class="patch-header"> |
|
<span class="patch-subject">{patch.subject}</span> |
|
</div> |
|
<div class="patch-meta"> |
|
<span>#{patch.id.slice(0, 7)}</span> |
|
<span>{new Date(patch.created_at * 1000).toLocaleDateString()}</span> |
|
<EventCopyButton eventId={patch.id} kind={patch.kind} pubkey={patch.author} /> |
|
</div> |
|
</button> |
|
</li> |
|
{/each} |
|
</ul> |
|
{/if} |
|
</aside> |
|
{/if} |
|
|
|
<!-- Discussions View --> |
|
{#if activeTab === 'discussions'} |
|
<aside class="discussions-sidebar" class:hide-on-mobile={!showLeftPanelOnMobile && activeTab === 'discussions'}> |
|
<div class="discussions-header"> |
|
<TabsMenu |
|
activeTab={activeTab} |
|
{tabs} |
|
onTabChange={(tab) => activeTab = tab as typeof activeTab} |
|
/> |
|
<h2>Discussions</h2> |
|
{#if userPubkey} |
|
<button |
|
onclick={() => showCreateThreadDialog = true} |
|
class="create-discussion-button" |
|
disabled={creatingThread} |
|
title={creatingThread ? 'Creating...' : 'New Discussion Thread'} |
|
> |
|
<img src="/icons/plus.svg" alt="New Discussion" class="icon" /> |
|
</button> |
|
{/if} |
|
<button |
|
onclick={() => showLeftPanelOnMobile = !showLeftPanelOnMobile} |
|
class="mobile-toggle-button" |
|
title="Show content" |
|
> |
|
<img src="/icons/arrow-right.svg" alt="Show content" class="icon-inline" /> |
|
</button> |
|
</div> |
|
{#if loadingDiscussions} |
|
<div class="loading">Loading discussions...</div> |
|
{:else if discussions.length > 0} |
|
<ul class="discussion-list"> |
|
{#each discussions as discussion} |
|
{@const hasComments = discussion.comments && discussion.comments.length > 0} |
|
{@const totalReplies = hasComments ? countAllReplies(discussion.comments) : 0} |
|
<li class="discussion-item" class:selected={selectedDiscussion === discussion.id}> |
|
<button |
|
onclick={() => selectedDiscussion = discussion.id} |
|
class="discussion-item-button" |
|
> |
|
<div class="discussion-header"> |
|
<span class="discussion-title">{discussion.title}</span> |
|
</div> |
|
<div class="discussion-meta"> |
|
{#if discussion.type === 'thread'} |
|
<span class="discussion-type">Thread</span> |
|
{#if hasComments} |
|
<span class="comment-count">{totalReplies} {totalReplies === 1 ? 'reply' : 'replies'}</span> |
|
{/if} |
|
{:else} |
|
<span class="discussion-type">Comments</span> |
|
{/if} |
|
<span>{new Date(discussion.createdAt * 1000).toLocaleDateString()}</span> |
|
<EventCopyButton eventId={discussion.id} kind={discussion.kind} pubkey={discussion.pubkey} /> |
|
</div> |
|
</button> |
|
</li> |
|
{/each} |
|
</ul> |
|
{/if} |
|
</aside> |
|
{/if} |
|
|
|
<!-- Docs View --> |
|
{#if activeTab === 'docs'} |
|
<aside class="docs-sidebar" class:hide-on-mobile={!showLeftPanelOnMobile && activeTab === 'docs'}> |
|
<div class="docs-header"> |
|
<TabsMenu |
|
activeTab={activeTab} |
|
{tabs} |
|
onTabChange={(tab) => activeTab = tab as typeof activeTab} |
|
/> |
|
<h2>Docs</h2> |
|
<button |
|
onclick={() => showLeftPanelOnMobile = !showLeftPanelOnMobile} |
|
class="mobile-toggle-button" |
|
title="Show content" |
|
> |
|
<img src="/icons/arrow-right.svg" alt="Show content" class="icon-inline" /> |
|
</button> |
|
</div> |
|
</aside> |
|
{/if} |
|
|
|
<!-- Editor Area / Diff View / README --> |
|
<div class="editor-area" class:hide-on-mobile={(showFileListOnMobile && activeTab === 'files') || (showLeftPanelOnMobile && activeTab !== 'files')}> |
|
{#if activeTab === 'files' && readmeContent && !currentFile} |
|
<div class="readme-section"> |
|
<div class="readme-header"> |
|
<h3>README</h3> |
|
<div class="readme-actions"> |
|
{#if readmePath && supportsPreview((readmePath.split('.').pop() || '').toLowerCase())} |
|
<button |
|
onclick={() => { |
|
showFilePreview = !showFilePreview; |
|
}} |
|
class="preview-toggle-button" |
|
title={showFilePreview ? 'Show raw' : 'Show preview'} |
|
> |
|
{showFilePreview ? 'Raw' : 'Preview'} |
|
</button> |
|
{/if} |
|
<a href={`/api/repos/${npub}/${repo}/raw?path=${readmePath}`} target="_blank" class="raw-link">View Raw</a> |
|
<button |
|
type="button" |
|
class="download-link" |
|
onclick={async (e) => { |
|
e.preventDefault(); |
|
e.stopPropagation(); |
|
e.stopImmediatePropagation(); |
|
await downloadRepository(); |
|
}} |
|
>Download ZIP</button> |
|
<button |
|
onclick={() => showFileListOnMobile = !showFileListOnMobile} |
|
class="mobile-toggle-button" |
|
title="Show file list" |
|
> |
|
<img src="/icons/arrow-right.svg" alt="Show file list" class="icon-inline mobile-toggle-left" /> |
|
</button> |
|
</div> |
|
</div> |
|
{#if loadingReadme} |
|
<div class="loading">Loading README...</div> |
|
{:else if showFilePreview && readmeHtml && readmeHtml.trim()} |
|
<div class="readme-content markdown"> |
|
{@html readmeHtml} |
|
</div> |
|
{:else if readmeContent} |
|
<div class="readme-content"> |
|
<pre><code class="hljs language-text">{readmeContent}</code></pre> |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
{#if activeTab === 'files' && currentFile} |
|
<div class="editor-header"> |
|
<span class="file-path">{currentFile}</span> |
|
<div class="editor-actions"> |
|
{#if branches.length > 0 && isMaintainer} |
|
<select |
|
bind:value={currentBranch} |
|
class="branch-selector" |
|
disabled={saving || needsClone} |
|
title="Select branch" |
|
onchange={() => { |
|
// Use the existing handleBranchChange function |
|
handleBranchChangeDirect(currentBranch || ''); |
|
}} |
|
> |
|
{#each branches as branch} |
|
{@const branchName = typeof branch === 'string' ? branch : branch.name} |
|
<option value={branchName}>{branchName}{#if branchName === defaultBranch} (default){/if}</option> |
|
{/each} |
|
</select> |
|
{:else if currentBranch && isMaintainer} |
|
<span class="branch-display" title="Current branch">{currentBranch}</span> |
|
{/if} |
|
{#if hasChanges} |
|
<span class="unsaved-indicator">● Unsaved changes</span> |
|
{/if} |
|
{#if currentFile && supportsPreview((currentFile.split('.').pop() || '').toLowerCase()) && !isMaintainer} |
|
<button |
|
onclick={() => { |
|
showFilePreview = !showFilePreview; |
|
if (!showFilePreview && fileContent && currentFile) { |
|
// When switching to raw, apply syntax highlighting |
|
const ext = currentFile.split('.').pop() || ''; |
|
applySyntaxHighlighting(fileContent, ext).catch(err => console.error('Error applying syntax highlighting:', err)); |
|
} |
|
}} |
|
class="preview-toggle-button" |
|
title={showFilePreview ? 'Show raw' : 'Show preview'} |
|
> |
|
{showFilePreview ? 'Raw' : 'Preview'} |
|
</button> |
|
{/if} |
|
{#if currentFile && fileContent} |
|
<button |
|
onclick={(e) => copyFileContent(e)} |
|
disabled={copyingFile} |
|
class="file-action-button" |
|
title="Copy raw content to clipboard" |
|
> |
|
<img src="/icons/copy.svg" alt="Copy" class="icon-inline" /> |
|
</button> |
|
<button |
|
onclick={downloadFile} |
|
class="file-action-button" |
|
title="Download file" |
|
> |
|
<img src="/icons/download.svg" alt="Download" class="icon-inline" /> |
|
</button> |
|
{/if} |
|
{#if isMaintainer} |
|
<button |
|
onclick={() => { |
|
if (!userPubkey || !isMaintainer || needsClone) return; |
|
showCommitDialog = true; |
|
}} |
|
disabled={!hasChanges || saving || needsClone} |
|
class="save-button" |
|
title={needsClone ? cloneTooltip : (hasChanges ? 'Save changes' : 'No changes to save')} |
|
> |
|
{saving ? 'Saving...' : 'Save'} |
|
</button> |
|
{:else if userPubkey} |
|
<span class="non-maintainer-notice">Only maintainers can edit files. Submit a PR instead.</span> |
|
{/if} |
|
<button |
|
onclick={() => showFileListOnMobile = !showFileListOnMobile} |
|
class="mobile-toggle-button" |
|
title="Show file list" |
|
> |
|
<img src="/icons/arrow-right.svg" alt="Show file list" class="icon-inline mobile-toggle-left" /> |
|
</button> |
|
</div> |
|
</div> |
|
|
|
{#if loading} |
|
<div class="loading">Loading file...</div> |
|
{:else} |
|
<div class="editor-container"> |
|
{#if isMaintainer} |
|
<CodeEditor |
|
content={editedContent} |
|
language={fileLanguage} |
|
onChange={handleContentChange} |
|
readOnly={needsClone || (isRepoCloned === false && canUseApiFallback)} |
|
/> |
|
{:else} |
|
<div class="read-only-editor" class:word-wrap={wordWrap}> |
|
{#if isImageFile && imageUrl} |
|
<!-- Image file: display as image --> |
|
<div class="file-preview image-preview"> |
|
<img src={imageUrl} alt={currentFile?.split('/').pop() || 'Image'} class="file-image" /> |
|
</div> |
|
{:else if currentFile && showFilePreview && fileHtml && supportsPreview((currentFile.split('.').pop() || '').toLowerCase())} |
|
<!-- Preview mode: show rendered HTML --> |
|
<div class="file-preview markdown"> |
|
{@html fileHtml} |
|
</div> |
|
{:else if highlightedFileContent} |
|
<!-- Raw mode: show syntax highlighted code --> |
|
{@html highlightedFileContent} |
|
{:else} |
|
<!-- Fallback: plain text --> |
|
<pre><code class="hljs">{fileContent}</code></pre> |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
{:else if activeTab === 'files'} |
|
<div class="content-header-mobile"> |
|
<button |
|
onclick={() => showFileListOnMobile = !showFileListOnMobile} |
|
class="mobile-toggle-button" |
|
title="Show file list" |
|
> |
|
<img src="/icons/arrow-right.svg" alt="Show file list" class="icon-inline mobile-toggle-left" /> |
|
</button> |
|
</div> |
|
<div class="empty-state"> |
|
<p>Select a file from the sidebar to view and edit it</p> |
|
</div> |
|
{/if} |
|
|
|
{#if activeTab === 'history'} |
|
<div class="commits-content" class:hide-on-mobile={showLeftPanelOnMobile && activeTab === 'history'}> |
|
<div class="content-header-mobile"> |
|
<button |
|
onclick={() => showLeftPanelOnMobile = !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> |
|
{#if selectedCommit} |
|
{@const commit = commits.find(c => (c.hash || (c as any).sha) === selectedCommit)} |
|
{#if commit} |
|
<div class="commit-detail"> |
|
<div class="commit-detail-header"> |
|
<h3>{commit.message || 'No message'}</h3> |
|
<button onclick={() => { showDiff = false; selectedCommit = null; }} class="close-button">×</button> |
|
</div> |
|
<div class="commit-meta-detail"> |
|
<span>#{selectedCommit.slice(0, 7)}</span> |
|
<span>{commit.author || 'Unknown'}</span> |
|
<span>{commit.date ? new Date(commit.date).toLocaleString() : 'Unknown date'}</span> |
|
</div> |
|
{#if loadingCommits} |
|
<div class="loading">Loading diff...</div> |
|
{:else if showDiff && diffData.length > 0} |
|
<div class="diff-view"> |
|
{#each diffData as diff} |
|
<div class="diff-file"> |
|
<div class="diff-file-header"> |
|
<span class="diff-file-name">{diff.file}</span> |
|
<span class="diff-stats"> |
|
<span class="additions">+{diff.additions}</span> |
|
<span class="deletions">-{diff.deletions}</span> |
|
</span> |
|
</div> |
|
<pre class="diff-content"><code>{diff.diff}</code></pre> |
|
</div> |
|
{/each} |
|
</div> |
|
{:else if showDiff} |
|
<div class="empty-state"> |
|
<p>No diff data available</p> |
|
</div> |
|
{:else} |
|
<div class="empty-state"> |
|
<p>Loading diff...</p> |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
{:else} |
|
<div class="empty-state"> |
|
<p>Select a commit from the sidebar to view details</p> |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
<!-- Tags content is now handled by TagsTab component --> |
|
|
|
|
|
{#if activeTab === 'code-search' && canViewRepo} |
|
<div class="code-search-content" class:hide-on-mobile={showLeftPanelOnMobile && activeTab === 'code-search'}> |
|
<div class="content-header-mobile"> |
|
<button |
|
onclick={() => showLeftPanelOnMobile = !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={codeSearchQuery} |
|
placeholder="Search code..." |
|
onkeydown={(e) => e.key === 'Enter' && performCodeSearch()} |
|
class="code-search-input" |
|
/> |
|
<select bind:value={codeSearchScope} class="code-search-scope"> |
|
<option value="repo">This Repository</option> |
|
<option value="all">All Repositories</option> |
|
</select> |
|
<button onclick={performCodeSearch} disabled={loadingCodeSearch || !codeSearchQuery.trim()} class="search-button"> |
|
{loadingCodeSearch ? 'Searching...' : 'Search'} |
|
</button> |
|
</div> |
|
</div> |
|
{#if loadingCodeSearch} |
|
<div class="empty-state"> |
|
<p>Searching...</p> |
|
</div> |
|
{:else if codeSearchResults.length > 0} |
|
<div class="code-search-results"> |
|
<h3>Found {codeSearchResults.length} result{codeSearchResults.length !== 1 ? 's' : ''}</h3> |
|
{#each codeSearchResults 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 codeSearchScope === 'all' && 'repo' in result} |
|
<span class="result-repo">{result.repo || npub}/{result.repo || repo}</span> |
|
{/if} |
|
</div> |
|
<pre class="result-content">{result.content}</pre> |
|
</div> |
|
{/each} |
|
</div> |
|
{:else if codeSearchQuery.trim() && !loadingCodeSearch} |
|
<div class="empty-state"> |
|
<p>No results found</p> |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
{#if activeTab === 'issues'} |
|
<div class="issues-content" class:hide-on-mobile={showLeftPanelOnMobile && activeTab === 'issues'}> |
|
<div class="content-header-mobile"> |
|
<button |
|
onclick={() => showLeftPanelOnMobile = !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> |
|
{#if loadingIssues} |
|
<div class="empty-state"> |
|
<p>Loading issues...</p> |
|
</div> |
|
{:else if error} |
|
<div class="empty-state error-state"> |
|
<p>Error loading issues: {error}</p> |
|
<button onclick={loadIssues} class="retry-button">Retry</button> |
|
</div> |
|
{:else if issues.length === 0} |
|
<div class="empty-state"> |
|
<p>No issues found. Create one to get started!</p> |
|
</div> |
|
{:else if selectedIssue} |
|
{@const issue = issues.find(i => i.id === selectedIssue)} |
|
{#if issue} |
|
<div class="issue-detail"> |
|
<h3>{issue.subject}</h3> |
|
<div class="issue-meta-detail"> |
|
<span class="issue-status" class:open={issue.status === 'open'} class:closed={issue.status === 'closed'} class:resolved={issue.status === 'resolved'}> |
|
{issue.status} |
|
</span> |
|
<span>#{issue.id.slice(0, 7)}</span> |
|
<span>Created {new Date(issue.created_at * 1000).toLocaleString()}</span> |
|
<EventCopyButton eventId={issue.id} kind={issue.kind} pubkey={issue.author} /> |
|
</div> |
|
<div class="issue-body"> |
|
{@html issue.content.replace(/\n/g, '<br>')} |
|
</div> |
|
|
|
{#if userPubkeyHex && (isMaintainer || userPubkeyHex === issue.author)} |
|
<div class="issue-actions"> |
|
{#if issue.status === 'open'} |
|
<button onclick={() => updateIssueStatus(issue.id, issue.author, 'closed')} disabled={updatingIssueStatus[issue.id]} class="issue-action-btn close-btn"> |
|
{updatingIssueStatus[issue.id] ? 'Closing...' : 'Close'} |
|
</button> |
|
<button onclick={() => updateIssueStatus(issue.id, issue.author, 'resolved')} disabled={updatingIssueStatus[issue.id]} class="issue-action-btn resolve-btn"> |
|
{updatingIssueStatus[issue.id] ? 'Resolving...' : 'Resolve'} |
|
</button> |
|
{:else if issue.status === 'closed' || issue.status === 'resolved'} |
|
<button onclick={() => updateIssueStatus(issue.id, issue.author, 'open')} disabled={updatingIssueStatus[issue.id]} class="issue-action-btn reopen-btn"> |
|
{updatingIssueStatus[issue.id] ? 'Reopening...' : 'Reopen'} |
|
</button> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
<div class="issue-replies"> |
|
<h4>Replies ({issueReplies.length})</h4> |
|
{#if loadingIssueReplies} |
|
<div class="loading">Loading replies...</div> |
|
{:else if issueReplies.length === 0} |
|
<div class="empty-state"> |
|
<p>No replies yet.</p> |
|
</div> |
|
{:else} |
|
{#each issueReplies as reply} |
|
<div class="issue-reply"> |
|
<div class="reply-header"> |
|
<UserBadge pubkey={reply.author} /> |
|
<span class="reply-date">{new Date(reply.created_at * 1000).toLocaleString()}</span> |
|
<EventCopyButton eventId={reply.id} kind={KIND.COMMENT} pubkey={reply.author} /> |
|
</div> |
|
<div class="reply-body"> |
|
{@html reply.content.replace(/\n/g, '<br>')} |
|
</div> |
|
</div> |
|
{/each} |
|
{/if} |
|
</div> |
|
</div> |
|
{/if} |
|
{:else} |
|
<div class="empty-state"> |
|
<p>Select an issue to view details</p> |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
{#if activeTab === 'prs'} |
|
<div class="prs-content" class:hide-on-mobile={showLeftPanelOnMobile && activeTab === 'prs'}> |
|
<div class="content-header-mobile"> |
|
<button |
|
onclick={() => showLeftPanelOnMobile = !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> |
|
{#if prs.length === 0} |
|
<div class="empty-state"> |
|
<p>No pull requests found. Create one to get started!</p> |
|
</div> |
|
{:else if selectedPR} |
|
{#each prs.filter(p => p.id === selectedPR) as pr} |
|
{@const decoded = nip19.decode(npub)} |
|
{#if decoded.type === 'npub'} |
|
{@const repoOwnerPubkey = decoded.data as string} |
|
<PRDetail |
|
{pr} |
|
{npub} |
|
{repo} |
|
{repoOwnerPubkey} |
|
isMaintainer={isMaintainer} |
|
userPubkeyHex={userPubkeyHex ?? undefined} |
|
onStatusUpdate={loadPRs} |
|
/> |
|
<button onclick={() => selectedPR = null} class="back-btn">← Back to PR List</button> |
|
{/if} |
|
{/each} |
|
{:else} |
|
{#each prs as pr} |
|
<div |
|
class="pr-detail" |
|
role="button" |
|
tabindex="0" |
|
onclick={() => selectedPR = pr.id} |
|
onkeydown={(e) => { |
|
if (e.key === 'Enter' || e.key === ' ') { |
|
e.preventDefault(); |
|
selectedPR = pr.id; |
|
} |
|
}} |
|
style="cursor: pointer;"> |
|
<h3>{pr.subject}</h3> |
|
<div class="pr-meta-detail"> |
|
<span class="pr-status" class:open={pr.status === 'open'} class:closed={pr.status === 'closed'} class:merged={pr.status === 'merged'}> |
|
{pr.status} |
|
</span> |
|
{#if pr.commitId} |
|
<span>Commit: {pr.commitId.slice(0, 7)}</span> |
|
{/if} |
|
<span>Created {new Date(pr.created_at * 1000).toLocaleString()}</span> |
|
</div> |
|
<div class="pr-body"> |
|
{@html pr.content.replace(/\n/g, '<br>')} |
|
</div> |
|
</div> |
|
{/each} |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
{#if activeTab === 'patches'} |
|
<div class="patches-content" class:hide-on-mobile={showLeftPanelOnMobile && activeTab === 'patches'}> |
|
<div class="content-header-mobile"> |
|
<button |
|
onclick={() => showLeftPanelOnMobile = !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> |
|
{#if patches.length === 0} |
|
<div class="empty-state"> |
|
<p>No patches found. Create one to get started!</p> |
|
</div> |
|
{:else if selectedPatch} |
|
{#each patches.filter(p => p.id === selectedPatch) as patch} |
|
<div class="patch-detail"> |
|
<h3>{patch.subject}</h3> |
|
<div class="patch-meta-detail"> |
|
<span>#{patch.id.slice(0, 7)}</span> |
|
<span>Created {new Date(patch.created_at * 1000).toLocaleString()}</span> |
|
<EventCopyButton eventId={patch.id} kind={patch.kind} pubkey={patch.author} /> |
|
{#if (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned} |
|
<button |
|
onclick={async () => { |
|
if (!confirm('Apply this patch to the repository? This will create a commit with the patch changes.')) return; |
|
try { |
|
const response = await fetch(`/api/repos/${npub}/${repo}/patches/${patch.id}/apply`, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
...buildApiHeaders() |
|
}, |
|
body: JSON.stringify({ |
|
branch: currentBranch || 'main', |
|
commitMessage: `Apply patch ${patch.id.slice(0, 8)}: ${patch.subject}` |
|
}) |
|
}); |
|
if (response.ok) { |
|
const data = await response.json(); |
|
alert(`Patch applied successfully! Commit: ${data.commitHash.slice(0, 7)}`); |
|
// Reload files to show changes |
|
if (activeTab === 'files') { |
|
loadFiles(currentPath); |
|
} |
|
} else { |
|
const errorData = await response.json(); |
|
alert(`Failed to apply patch: ${errorData.message || 'Unknown error'}`); |
|
} |
|
} catch (err) { |
|
alert(`Failed to apply patch: ${err instanceof Error ? err.message : 'Unknown error'}`); |
|
} |
|
}} |
|
class="apply-patch-button" |
|
title="Apply this patch to the repository" |
|
> |
|
Apply Patch |
|
</button> |
|
{/if} |
|
</div> |
|
{#if patch.description && patch.description !== patch.subject} |
|
<div class="patch-description">{patch.description}</div> |
|
{/if} |
|
<div class="patch-body"> |
|
<pre class="patch-content">{patch.content}</pre> |
|
</div> |
|
</div> |
|
{/each} |
|
{:else} |
|
<div class="empty-state"> |
|
<p>Select a patch from the sidebar to view it</p> |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
{#if activeTab === 'discussions'} |
|
<div class="discussions-content" class:hide-on-mobile={showLeftPanelOnMobile && activeTab === 'discussions'}> |
|
<div class="content-header-mobile"> |
|
<button |
|
onclick={() => showLeftPanelOnMobile = !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> |
|
{#if discussions.length === 0} |
|
<div class="empty-state"> |
|
<p>No discussions found. {#if userPubkey}Create a new discussion thread to get started!{:else}Log in to create a discussion thread.{/if}</p> |
|
</div> |
|
{:else if selectedDiscussion} |
|
{#each discussions.filter(d => d.id === selectedDiscussion) as discussion} |
|
{@const isExpanded = discussion.type === 'thread' && expandedThreads.has(discussion.id)} |
|
{@const hasComments = discussion.comments && discussion.comments.length > 0} |
|
<div class="discussion-detail"> |
|
<div class="discussion-header-detail"> |
|
<h3>{discussion.title}</h3> |
|
<div class="discussion-meta-detail"> |
|
{#if discussion.type === 'thread'} |
|
<span class="discussion-type">Thread</span> |
|
{#if hasComments} |
|
{@const totalReplies = countAllReplies(discussion.comments)} |
|
<span class="comment-count">{totalReplies} {totalReplies === 1 ? 'reply' : 'replies'}</span> |
|
{/if} |
|
{:else} |
|
<span class="discussion-type">Comments</span> |
|
{/if} |
|
<span>Created {new Date(discussion.createdAt * 1000).toLocaleString()}</span> |
|
<EventCopyButton eventId={discussion.id} kind={discussion.kind} pubkey={discussion.pubkey} /> |
|
{#if discussion.type === 'thread' && userPubkey} |
|
<button |
|
class="create-reply-button" |
|
onclick={() => { |
|
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author }; |
|
replyingToComment = null; |
|
showReplyDialog = true; |
|
}} |
|
title="Reply to thread" |
|
> |
|
<img src="/icons/plus.svg" alt="Reply" class="icon" /> |
|
</button> |
|
{/if} |
|
</div> |
|
</div> |
|
{#if discussion.content} |
|
<div class="discussion-body"> |
|
<p>{discussion.content}</p> |
|
</div> |
|
{/if} |
|
{#if discussion.type === 'thread' && hasComments} |
|
{@const totalReplies = countAllReplies(discussion.comments)} |
|
<div class="comments-section"> |
|
<h4>Replies ({totalReplies})</h4> |
|
{#each discussion.comments! as comment} |
|
<div class="comment-item"> |
|
<div class="comment-meta"> |
|
<UserBadge pubkey={comment.author} /> |
|
<span>{new Date(comment.createdAt * 1000).toLocaleString()}</span> |
|
<EventCopyButton eventId={comment.id} kind={comment.kind} pubkey={comment.pubkey} /> |
|
{#if userPubkey} |
|
<button |
|
class="create-reply-button" |
|
onclick={() => { |
|
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author }; |
|
replyingToComment = { id: comment.id, kind: comment.kind, pubkey: comment.pubkey, author: comment.author }; |
|
showReplyDialog = true; |
|
}} |
|
title="Reply to comment" |
|
> |
|
<img src="/icons/plus.svg" alt="Reply" class="icon" /> |
|
</button> |
|
{/if} |
|
</div> |
|
{#if true} |
|
{@const commentEvent = getDiscussionEvent(comment.id)} |
|
{@const referencedEvent = commentEvent ? getReferencedEventFromDiscussion(commentEvent) : undefined} |
|
{@const parts = processContentWithNostrLinks(comment.content)} |
|
<div class="comment-content"> |
|
{#if referencedEvent} |
|
<div class="referenced-event"> |
|
<div class="referenced-event-header"> |
|
<UserBadge pubkey={referencedEvent.pubkey} disableLink={true} /> |
|
<span class="referenced-event-time">{formatDiscussionTime(referencedEvent.created_at)}</span> |
|
</div> |
|
<div class="referenced-event-content">{referencedEvent.content || '(No content)'}</div> |
|
</div> |
|
{/if} |
|
<div> |
|
{#each parts as part} |
|
{#if part.type === 'text'} |
|
<span>{part.value}</span> |
|
{:else if part.type === 'event' && part.event} |
|
<div class="nostr-link-event"> |
|
<div class="nostr-link-event-header"> |
|
<UserBadge pubkey={part.event.pubkey} disableLink={true} /> |
|
<span class="nostr-link-event-time">{formatDiscussionTime(part.event.created_at)}</span> |
|
</div> |
|
<div class="nostr-link-event-content">{part.event.content || '(No content)'}</div> |
|
</div> |
|
{:else if part.type === 'profile' && part.pubkey} |
|
<UserBadge pubkey={part.pubkey} /> |
|
{:else} |
|
<span class="nostr-link-placeholder">{part.value}</span> |
|
{/if} |
|
{/each} |
|
</div> |
|
</div> |
|
{/if} |
|
{#if comment.replies && comment.replies.length > 0} |
|
<div class="nested-replies"> |
|
{#each comment.replies as reply} |
|
<div class="comment-item nested-comment"> |
|
<div class="comment-meta"> |
|
<UserBadge pubkey={reply.author} /> |
|
<span>{new Date(reply.createdAt * 1000).toLocaleString()}</span> |
|
<EventCopyButton eventId={reply.id} kind={reply.kind} pubkey={reply.pubkey} /> |
|
{#if userPubkey} |
|
<button |
|
class="create-reply-button" |
|
onclick={() => { |
|
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author }; |
|
replyingToComment = { id: reply.id, kind: reply.kind, pubkey: reply.pubkey, author: reply.author }; |
|
showReplyDialog = true; |
|
}} |
|
title="Reply to comment" |
|
> |
|
<img src="/icons/plus.svg" alt="Reply" class="icon" /> |
|
</button> |
|
{/if} |
|
</div> |
|
<div class="comment-content"> |
|
<p>{reply.content}</p> |
|
</div> |
|
{#if reply.replies && reply.replies.length > 0} |
|
<div class="nested-replies"> |
|
{#each reply.replies as nestedReply} |
|
<div class="comment-item nested-comment"> |
|
<div class="comment-meta"> |
|
<UserBadge pubkey={nestedReply.author} /> |
|
<span>{new Date(nestedReply.createdAt * 1000).toLocaleString()}</span> |
|
<EventCopyButton eventId={nestedReply.id} kind={nestedReply.kind} pubkey={nestedReply.pubkey} /> |
|
{#if userPubkey} |
|
<button |
|
class="create-reply-button" |
|
onclick={() => { |
|
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author }; |
|
replyingToComment = { id: nestedReply.id, kind: nestedReply.kind, pubkey: nestedReply.pubkey, author: nestedReply.author }; |
|
showReplyDialog = true; |
|
}} |
|
title="Reply to comment" |
|
> |
|
<img src="/icons/plus.svg" alt="Reply" class="icon" /> |
|
</button> |
|
{/if} |
|
</div> |
|
<div class="comment-content"> |
|
<p>{nestedReply.content}</p> |
|
</div> |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
{/each} |
|
</div> |
|
{:else if discussion.type === 'comments' && hasComments} |
|
{@const totalReplies = countAllReplies(discussion.comments)} |
|
<div class="comments-section"> |
|
<h4>Comments ({totalReplies})</h4> |
|
{#each discussion.comments! as comment} |
|
<div class="comment-item"> |
|
<div class="comment-meta"> |
|
<UserBadge pubkey={comment.author} /> |
|
<span>{new Date(comment.createdAt * 1000).toLocaleString()}</span> |
|
<EventCopyButton eventId={comment.id} kind={comment.kind} pubkey={comment.pubkey} /> |
|
{#if userPubkey} |
|
<button |
|
class="create-reply-button" |
|
onclick={() => { |
|
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author }; |
|
replyingToComment = { id: comment.id, kind: comment.kind, pubkey: comment.pubkey, author: comment.author }; |
|
showReplyDialog = true; |
|
}} |
|
title="Reply to comment" |
|
> |
|
<img src="/icons/plus.svg" alt="Reply" class="icon" /> |
|
</button> |
|
{/if} |
|
</div> |
|
{#if true} |
|
{@const commentEvent = getDiscussionEvent(comment.id)} |
|
{@const referencedEvent = commentEvent ? getReferencedEventFromDiscussion(commentEvent) : undefined} |
|
{@const parts = processContentWithNostrLinks(comment.content)} |
|
<div class="comment-content"> |
|
{#if referencedEvent} |
|
<div class="referenced-event"> |
|
<div class="referenced-event-header"> |
|
<UserBadge pubkey={referencedEvent.pubkey} disableLink={true} /> |
|
<span class="referenced-event-time">{formatDiscussionTime(referencedEvent.created_at)}</span> |
|
</div> |
|
<div class="referenced-event-content">{referencedEvent.content || '(No content)'}</div> |
|
</div> |
|
{/if} |
|
<div> |
|
{#each parts as part} |
|
{#if part.type === 'text'} |
|
<span>{part.value}</span> |
|
{:else if part.type === 'event' && part.event} |
|
<div class="nostr-link-event"> |
|
<div class="nostr-link-event-header"> |
|
<UserBadge pubkey={part.event.pubkey} disableLink={true} /> |
|
<span class="nostr-link-event-time">{formatDiscussionTime(part.event.created_at)}</span> |
|
</div> |
|
<div class="nostr-link-event-content">{part.event.content || '(No content)'}</div> |
|
</div> |
|
{:else if part.type === 'profile' && part.pubkey} |
|
<UserBadge pubkey={part.pubkey} /> |
|
{:else} |
|
<span class="nostr-link-placeholder">{part.value}</span> |
|
{/if} |
|
{/each} |
|
</div> |
|
</div> |
|
{/if} |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
{/each} |
|
{:else} |
|
<div class="empty-state"> |
|
<p>Select a discussion from the sidebar to view it</p> |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
{#if activeTab === 'docs'} |
|
<div class="docs-content" class:hide-on-mobile={showLeftPanelOnMobile && activeTab === 'docs'}> |
|
<div class="content-header-mobile"> |
|
<button |
|
onclick={() => showLeftPanelOnMobile = !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> |
|
{#if loadingDocs} |
|
<div class="loading">Loading documentation...</div> |
|
{:else if documentationHtml} |
|
<div class="documentation-body"> |
|
{@html documentationHtml} |
|
</div> |
|
{:else if documentationContent === null} |
|
<div class="empty-state"> |
|
<p>No documentation found for this repository.</p> |
|
</div> |
|
{:else} |
|
<div class="empty-state"> |
|
<p>Documentation content is empty.</p> |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
</div> |
|
{/if} |
|
</main> |
|
|
|
<!-- Create File Dialog --> |
|
{#if showCreateFileDialog && userPubkey && isMaintainer} |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Create new file" |
|
onclick={() => showCreateFileDialog = false} |
|
onkeydown={(e) => e.key === 'Escape' && (showCreateFileDialog = false)} |
|
tabindex="-1" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<h3>Create New File</h3> |
|
{#if branches.length > 0} |
|
<label> |
|
Branch: |
|
<select bind:value={currentBranch} disabled={saving}> |
|
{#each branches as branch} |
|
{@const branchName = typeof branch === 'string' ? branch : branch.name} |
|
<option value={branchName}>{branchName}{#if branchName === defaultBranch} (default){/if}</option> |
|
{/each} |
|
</select> |
|
</label> |
|
{:else if currentBranch} |
|
<label> |
|
Branch: |
|
<input type="text" value={currentBranch} disabled /> |
|
</label> |
|
{/if} |
|
<label> |
|
File Name: |
|
<input type="text" bind:value={newFileName} placeholder="filename.md" /> |
|
</label> |
|
<label> |
|
Content: |
|
<textarea bind:value={newFileContent} rows="10" placeholder="File content..."></textarea> |
|
</label> |
|
<div class="modal-actions"> |
|
<button onclick={() => showCreateFileDialog = false} class="cancel-button">Cancel</button> |
|
<button |
|
onclick={createFile} |
|
disabled={!newFileName.trim() || saving || needsClone || !currentBranch} |
|
class="save-button" |
|
title={needsClone ? cloneTooltip : (!currentBranch ? 'Please select a branch' : '')} |
|
> |
|
{saving ? 'Creating...' : 'Create'} |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Create Branch Dialog --> |
|
{#if showCreateBranchDialog && userPubkey && isMaintainer} |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Create new branch" |
|
onclick={() => showCreateBranchDialog = false} |
|
onkeydown={(e) => e.key === 'Escape' && (showCreateBranchDialog = false)} |
|
tabindex="-1" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<h3>Create New Branch</h3> |
|
<label> |
|
Branch Name: |
|
<input type="text" bind:value={newBranchName} placeholder="feature/new-feature" /> |
|
</label> |
|
<label> |
|
From Branch: |
|
<select bind:value={newBranchFrom}> |
|
{#if branches.length === 0} |
|
<option value={null}>No branches - will create initial branch</option> |
|
{:else} |
|
{#each branches as branch} |
|
{@const branchName = typeof branch === 'string' ? branch : (branch as { name: string }).name} |
|
{@const isDefaultBranch = branchName === defaultBranchName} |
|
{#if !isDefaultBranch} |
|
<option value={branchName}>{branchName}</option> |
|
{/if} |
|
{/each} |
|
{/if} |
|
</select> |
|
</label> |
|
<div class="modal-actions"> |
|
<button onclick={() => showCreateBranchDialog = false} class="cancel-button">Cancel</button> |
|
<button |
|
onclick={createBranch} |
|
disabled={!newBranchName.trim() || saving || needsClone} |
|
class="save-button" |
|
title={needsClone ? cloneTooltip : ''} |
|
> |
|
{saving ? 'Creating...' : 'Create Branch'} |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Create Tag Dialog --> |
|
{#if showCreateTagDialog && userPubkey && isMaintainer} |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Create new tag" |
|
onclick={() => showCreateTagDialog = false} |
|
onkeydown={(e) => e.key === 'Escape' && (showCreateTagDialog = false)} |
|
tabindex="-1" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<h3>Create New Tag</h3> |
|
<label> |
|
Tag Name: |
|
<input type="text" bind:value={newTagName} placeholder="v1.0.0" /> |
|
</label> |
|
<label> |
|
Reference (commit/branch): |
|
<input type="text" bind:value={newTagRef} placeholder="HEAD" /> |
|
</label> |
|
<label> |
|
Message (optional, for annotated tag): |
|
<textarea bind:value={newTagMessage} rows="3" placeholder="Tag message..."></textarea> |
|
</label> |
|
<div class="modal-actions"> |
|
<button onclick={() => showCreateTagDialog = false} class="cancel-button">Cancel</button> |
|
<button |
|
onclick={createTag} |
|
disabled={!newTagName.trim() || saving || needsClone} |
|
class="save-button" |
|
title={needsClone ? cloneTooltip : ''} |
|
> |
|
{saving ? 'Creating...' : 'Create Tag'} |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Create Release Dialog --> |
|
{#if showCreateReleaseDialog && userPubkey && (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned} |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Create new release" |
|
onclick={() => showCreateReleaseDialog = false} |
|
onkeydown={(e) => e.key === 'Escape' && (showCreateReleaseDialog = false)} |
|
tabindex="-1" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<h3>Create New Release</h3> |
|
<label> |
|
Tag Name: |
|
<input type="text" bind:value={newReleaseTagName} placeholder="v1.0.0" /> |
|
</label> |
|
<label> |
|
Tag Hash (commit hash): |
|
<input type="text" bind:value={newReleaseTagHash} placeholder="abc1234..." /> |
|
</label> |
|
<label> |
|
Release Notes: |
|
<textarea bind:value={newReleaseNotes} rows="10" placeholder="Release notes in markdown..."></textarea> |
|
</label> |
|
<label> |
|
<input type="checkbox" bind:checked={newReleaseIsDraft} /> |
|
Draft Release |
|
</label> |
|
<label> |
|
<input type="checkbox" bind:checked={newReleaseIsPrerelease} /> |
|
Pre-release |
|
</label> |
|
<div class="modal-actions"> |
|
<button onclick={() => showCreateReleaseDialog = false} class="cancel-button">Cancel</button> |
|
<button |
|
onclick={createRelease} |
|
disabled={!newReleaseTagName.trim() || !newReleaseTagHash.trim() || creatingRelease} |
|
class="save-button" |
|
> |
|
{creatingRelease ? 'Creating...' : 'Create Release'} |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Create Issue Dialog --> |
|
{#if showCreateIssueDialog && userPubkey} |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Create new issue" |
|
onclick={() => showCreateIssueDialog = false} |
|
onkeydown={(e) => e.key === 'Escape' && (showCreateIssueDialog = false)} |
|
tabindex="-1" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<h3>Create New Issue</h3> |
|
<label> |
|
Subject: |
|
<input type="text" bind:value={newIssueSubject} placeholder="Issue title..." /> |
|
</label> |
|
<label> |
|
Description: |
|
<textarea bind:value={newIssueContent} rows="10" placeholder="Describe the issue..."></textarea> |
|
</label> |
|
<div class="modal-actions"> |
|
<button onclick={() => showCreateIssueDialog = false} class="cancel-button">Cancel</button> |
|
<button onclick={createIssue} disabled={!newIssueSubject.trim() || !newIssueContent.trim() || saving} class="save-button"> |
|
{saving ? 'Creating...' : 'Create Issue'} |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Create Discussion Thread Dialog --> |
|
{#if showCreateThreadDialog && userPubkey} |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Create new discussion thread" |
|
onclick={() => showCreateThreadDialog = false} |
|
onkeydown={(e) => e.key === 'Escape' && (showCreateThreadDialog = false)} |
|
tabindex="-1" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<h3>Create New Discussion Thread</h3> |
|
<label> |
|
Title: |
|
<input type="text" bind:value={newThreadTitle} placeholder="Thread title..." /> |
|
</label> |
|
<label> |
|
Content: |
|
<textarea bind:value={newThreadContent} rows="10" placeholder="Start the discussion..."></textarea> |
|
</label> |
|
<div class="modal-actions"> |
|
<button onclick={() => showCreateThreadDialog = false} class="cancel-button">Cancel</button> |
|
<button onclick={createDiscussionThread} disabled={!newThreadTitle.trim() || creatingThread} class="save-button"> |
|
{creatingThread ? 'Creating...' : 'Create Thread'} |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Reply to Thread/Comment Dialog --> |
|
{#if showReplyDialog && userPubkey && (replyingToThread || replyingToComment)} |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Reply to thread" |
|
onclick={() => { |
|
showReplyDialog = false; |
|
replyingToThread = null; |
|
replyingToComment = null; |
|
replyContent = ''; |
|
}} |
|
onkeydown={(e) => e.key === 'Escape' && (showReplyDialog = false)} |
|
tabindex="-1" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<h3> |
|
{#if replyingToComment} |
|
Reply to Comment |
|
{:else if replyingToThread} |
|
Reply to Thread |
|
{:else} |
|
Reply |
|
{/if} |
|
</h3> |
|
<label> |
|
Your Reply: |
|
<textarea bind:value={replyContent} rows="8" placeholder="Write your reply..."></textarea> |
|
</label> |
|
<div class="modal-actions"> |
|
<button |
|
onclick={() => { |
|
showReplyDialog = false; |
|
replyingToThread = null; |
|
replyingToComment = null; |
|
replyContent = ''; |
|
}} |
|
class="cancel-button" |
|
> |
|
Cancel |
|
</button> |
|
<button |
|
onclick={() => createThreadReply()} |
|
disabled={!replyContent.trim() || creatingReply} |
|
class="save-button" |
|
> |
|
{creatingReply ? 'Posting...' : 'Post Reply'} |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Create PR Dialog --> |
|
{#if showCreatePRDialog && userPubkey} |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Create new pull request" |
|
onclick={() => showCreatePRDialog = false} |
|
onkeydown={(e) => e.key === 'Escape' && (showCreatePRDialog = false)} |
|
tabindex="-1" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<h3>Create New Pull Request</h3> |
|
<label> |
|
Subject: |
|
<input type="text" bind:value={newPRSubject} placeholder="PR title..." /> |
|
</label> |
|
<label> |
|
Description: |
|
<textarea bind:value={newPRContent} rows="8" placeholder="Describe your changes..."></textarea> |
|
</label> |
|
<label> |
|
Commit ID: |
|
<input type="text" bind:value={newPRCommitId} placeholder="Commit hash..." /> |
|
</label> |
|
<label> |
|
Branch Name (optional): |
|
<input type="text" bind:value={newPRBranchName} placeholder="feature/new-feature" /> |
|
</label> |
|
<div class="modal-actions"> |
|
<button onclick={() => showCreatePRDialog = false} class="cancel-button">Cancel</button> |
|
<button onclick={createPR} disabled={!newPRSubject.trim() || !newPRContent.trim() || !newPRCommitId.trim() || saving} class="save-button"> |
|
{saving ? 'Creating...' : 'Create PR'} |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Create Patch Dialog --> |
|
{#if showCreatePatchDialog && userPubkey} |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Create new patch" |
|
onclick={() => showCreatePatchDialog = false} |
|
onkeydown={(e) => e.key === 'Escape' && (showCreatePatchDialog = false)} |
|
tabindex="-1" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<h3>Create New Patch</h3> |
|
<p class="help-text">Enter your patch content in git format-patch format. Patches should be under 60KB.</p> |
|
<label> |
|
Subject (optional): |
|
<input type="text" bind:value={newPatchSubject} placeholder="Patch title..." /> |
|
</label> |
|
<label> |
|
Patch Content: |
|
<textarea bind:value={newPatchContent} rows="15" placeholder="Paste your git format-patch output here..."></textarea> |
|
</label> |
|
<div class="modal-actions"> |
|
<button onclick={() => showCreatePatchDialog = false} class="cancel-button">Cancel</button> |
|
<button onclick={createPatch} disabled={!newPatchContent.trim() || creatingPatch} class="save-button"> |
|
{creatingPatch ? 'Creating...' : 'Create Patch'} |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Commit Dialog --> |
|
{#if showCommitDialog && userPubkey && isMaintainer} |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Commit changes" |
|
onclick={() => showCommitDialog = false} |
|
onkeydown={(e) => e.key === 'Escape' && (showCommitDialog = false)} |
|
tabindex="-1" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<h3>Commit Changes</h3> |
|
{#if branches.length > 0} |
|
<label> |
|
Branch: |
|
<select bind:value={currentBranch} disabled={saving}> |
|
{#each branches as branch} |
|
{@const branchName = typeof branch === 'string' ? branch : branch.name} |
|
<option value={branchName}>{branchName}{#if branchName === defaultBranch} (default){/if}</option> |
|
{/each} |
|
</select> |
|
</label> |
|
{:else if currentBranch} |
|
<label> |
|
Branch: |
|
<input type="text" value={currentBranch} disabled /> |
|
</label> |
|
{/if} |
|
<label> |
|
Commit Message: |
|
<textarea |
|
bind:value={commitMessage} |
|
placeholder="Describe your changes..." |
|
rows="4" |
|
></textarea> |
|
</label> |
|
<div class="modal-actions"> |
|
<button onclick={() => showCommitDialog = false} class="cancel-button">Cancel</button> |
|
<button |
|
onclick={saveFile} |
|
disabled={!commitMessage.trim() || saving || needsClone || !currentBranch} |
|
class="save-button" |
|
title={needsClone ? cloneTooltip : (!currentBranch ? 'Please select a branch' : '')} |
|
> |
|
{saving ? 'Saving...' : 'Commit & Save'} |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Verification File Dialog --> |
|
{#if showVerificationDialog && verificationFileContent} |
|
<div |
|
class="modal-overlay" |
|
role="dialog" |
|
aria-modal="true" |
|
aria-label="Repository verification file" |
|
onclick={() => showVerificationDialog = false} |
|
onkeydown={(e) => e.key === 'Escape' && (showVerificationDialog = false)} |
|
tabindex="-1" |
|
> |
|
<!-- svelte-ignore a11y_click_events_have_key_events --> |
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> |
|
<div |
|
class="modal verification-modal" |
|
role="document" |
|
onclick={(e) => e.stopPropagation()} |
|
> |
|
<div class="modal-header"> |
|
<h3>Repository Verification File</h3> |
|
</div> |
|
<div class="modal-body"> |
|
<p class="verification-instructions"> |
|
The announcement event should be saved to <code>nostr/repo-events.jsonl</code> in your repository. |
|
You can download the announcement event JSON below for reference. |
|
</p> |
|
<div class="verification-file-content"> |
|
<div class="file-header"> |
|
<span class="filename">announcement-event.json</span> |
|
<div class="file-actions"> |
|
<button onclick={copyVerificationToClipboard} class="copy-button">Copy</button> |
|
<button onclick={downloadVerificationFile} class="download-button">Download</button> |
|
</div> |
|
</div> |
|
<pre class="file-content"><code>{verificationFileContent}</code></pre> |
|
</div> |
|
</div> |
|
<div class="modal-actions"> |
|
<button onclick={() => showVerificationDialog = false} class="cancel-button">Close</button> |
|
</div> |
|
</div> |
|
</div> |
|
{/if} |
|
</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>
|
|
|