diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 23235a2..1257ae4 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -100,3 +100,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772110337,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 4"]],"content":"Signed commit: refactor 4","id":"d330d1a096e5f3951e8b2a66160a23c5ac28aa94313ecd0948c7e50baa60bdbb","sig":"febf4088cca3f7223f55ab300ed7fdb7b333c03d2534b05721dfaf9d9284f4599b385ba54379890fa6b846aed02d656a5e45429a6dd571dddbb997be6d8159b2"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772111536,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 5"]],"content":"Signed commit: refactor 5","id":"47651ed0aee8072f356fbac30b6168f2c985bcca392f9ed7d7c38d9670d90f16","sig":"2ca5d04d4a619dc3e02962249f7c650e3a561315897b329f4493e87148c5dd89fbcb6694515a72d0d17e64c9930e57bd7761e27b353275bb1ada9449330f4e1c"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772112054,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 6"]],"content":"Signed commit: refactor 6","id":"cd9b7e015ee3bd6a4c4ab7d54d90ab411a08c29f249158c4cdea2b12996b6b44","sig":"2a8fd0a3718169df1517c0b939ec9ea9793da4dc07b20d9366f09fe70b5268e94451916f975e2cea1a3741419440189d31f844e79863e84de00c0be7c449e92a"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772112920,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 7"]],"content":"Signed commit: refactor 7","id":"80f54ac61390cfbc8f2496a162d7065c447033a2e085ab5886c8138e337e93f9","sig":"f64bd2c965eff3534ad68e245651c189dc925d9613dd85557d88af8c692361ade13ccdd9deb88ae07eb227aa002af99d525a7fdf6f29eca854b5a02882ef226f"} diff --git a/src/app.css b/src/app.css index 3dfd118..e19172c 100644 --- a/src/app.css +++ b/src/app.css @@ -1221,7 +1221,6 @@ button.theme-option.active img.theme-icon-option, .repo-badge:hover { background: var(--bg-secondary); border-color: var(--accent); - transform: translateY(-1px); box-shadow: 0 2px 4px var(--shadow-color-light); font-size: 0.9rem; /* Preserve font size on hover */ color: var(--text-primary); diff --git a/src/lib/components/PRDetail.svelte b/src/lib/components/PRDetail.svelte index 9b11531..dfc15c8 100644 --- a/src/lib/components/PRDetail.svelte +++ b/src/lib/components/PRDetail.svelte @@ -10,7 +10,16 @@ import { KIND } from '../types/nostr.js'; import { nip19 } from 'nostr-tools'; import CommentRenderer from './CommentRenderer.svelte'; - import type { Comment } from './CommentRenderer.svelte'; + // Define Comment type locally to match CommentRenderer's export + type Comment = { + id: string; + content: string; + author: string; + createdAt: number; + kind: number; + pubkey: string; + replies?: Comment[]; + }; import { loadNostrLinks } from '../utils/nostr-links.js'; import type { NostrEvent } from '../types/nostr.js'; @@ -129,12 +138,14 @@ await loadNostrLinks(comment.content, nostrClient, nostrLinkEvents, nostrLinkProfiles); } for (const highlight of highlights) { - if (highlight.comment) { + if (highlight.comment && typeof highlight.comment === 'string') { await loadNostrLinks(highlight.comment, nostrClient, nostrLinkEvents, nostrLinkProfiles); } if (highlight.comments) { for (const comment of highlight.comments) { - await loadNostrLinks(comment.content, nostrClient, nostrLinkEvents, nostrLinkProfiles); + if (comment.content && typeof comment.content === 'string') { + await loadNostrLinks(comment.content, nostrClient, nostrLinkEvents, nostrLinkProfiles); + } } } } @@ -734,7 +745,7 @@ margin-bottom: 1rem; } - .add-comment-btn, .reply-btn { + .add-comment-btn { padding: 0.4rem 0.8rem; background: var(--button-primary); color: var(--accent-text, #ffffff); @@ -746,11 +757,11 @@ transition: background 0.2s ease; } - .add-comment-btn:hover, .reply-btn:hover { + .add-comment-btn:hover { background: var(--button-primary-hover); } - .highlight-item, .comment-item { + .highlight-item { margin-bottom: 1.5rem; padding: 1rem; background: var(--bg-secondary); @@ -758,22 +769,7 @@ border-left: 3px solid var(--accent); } - .comment-item.nested { - margin-left: 2rem; - margin-top: 0.75rem; - border-left-color: var(--success-text); - background: var(--bg-secondary); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); - } - - .comment-item.nested .comment-content { - border-left-color: var(--success-text); - background: var(--card-bg); - margin: 0.5rem 0; - padding: 0.875rem 1rem; - } - - .highlight-header, .comment-header { + .highlight-header { display: flex; gap: 1rem; margin-bottom: 0.5rem; @@ -781,7 +777,7 @@ color: var(--text-muted); } - .highlight-author, .comment-author { + .highlight-author { font-weight: bold; color: var(--text-primary); } @@ -844,17 +840,6 @@ filter: brightness(0) saturate(100%) invert(1); } - .comment-content { - margin: 0.75rem 0; - padding: 1rem 1.25rem; - background: var(--bg-secondary); - border-radius: 6px; - border-left: 4px solid var(--accent); - color: var(--text-primary); - font-size: 1rem; - line-height: 1.6; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - } .highlight-comments { margin-top: 1rem; @@ -1004,32 +989,6 @@ background: var(--success-hover, #218838); } - .close-btn { - background: var(--error-text, #dc3545); - color: white; - } - - .close-btn:hover:not(:disabled) { - background: var(--error-hover, #c82333); - } - - .reopen-btn { - background: var(--accent, #007bff); - color: white; - } - - .reopen-btn:hover:not(:disabled) { - background: var(--accent-hover, #0056b3); - } - - .draft-btn { - background: var(--bg-tertiary, #6c757d); - color: white; - } - - .draft-btn:hover:not(:disabled) { - background: var(--bg-secondary, #5a6268); - } @media (max-width: 768px) { .pr-actions { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 64145e4..9b81d0d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -290,7 +290,7 @@ } async function checkPendingTransfers(userPubkeyHex: string) { - if (!isMounted) return; + if (!isMounted || typeof window === 'undefined') return; try { // Add timeout to prevent hanging @@ -313,14 +313,29 @@ pendingTransfers = data.pendingTransfers.filter( (t: { eventId: string }) => !dismissedTransfers.has(t.eventId) ); + } else if (isMounted) { + // Clear transfers if response is ok but no transfers + pendingTransfers = []; } + } else if (response.status === 404 && isMounted) { + // Endpoint doesn't exist - silently ignore + pendingTransfers = []; } } catch (err) { - // Only log if it's not an abort (timeout) and component is still mounted - if (isMounted && err instanceof Error && err.name !== 'AbortError') { - console.error('Failed to check for pending transfers:', err); + // Only log if it's not an abort (timeout), not a network error, and component is still mounted + if (isMounted && err instanceof Error) { + // Ignore expected errors + if (err.name === 'AbortError') { + // Timeout - silently ignore + return; + } + if (err.name === 'TypeError' && err.message.includes('NetworkError')) { + // Network error (server not available, CORS, etc.) - silently ignore + return; + } + // Log other unexpected errors + console.warn('Failed to check for pending transfers:', err); } - // Silently ignore timeouts - they're expected if the server is slow } } diff --git a/src/routes/api-docs/+page.svelte b/src/routes/api-docs/+page.svelte index 88a4e09..f28bca8 100644 --- a/src/routes/api-docs/+page.svelte +++ b/src/routes/api-docs/+page.svelte @@ -1,28 +1,47 @@ @@ -2414,7 +1911,7 @@ - {#if state.clone.isCloned === true} + {#if state.clone.isCloned === true && $page.data?.gitDomain && !$page.data.gitDomain.startsWith('localhost') && !$page.data.gitDomain.startsWith('127.0.0.1')} copyCloneUrl()} @@ -2670,6 +2167,19 @@ handleBranchChangeDirect(branch); }} userPubkey={state.user.pubkey} + activeTab={state.ui.activeTab} + {tabs} + onTabChange={(tab: string) => { + state.ui.activeTab = tab as typeof state.ui.activeTab; + // Update URL query parameter without page reload + const url = new URL($page.url); + if (tab === 'files') { + url.searchParams.delete('tab'); + } else { + url.searchParams.set('tab', tab); + } + goto(url.pathname + url.search, { replaceState: true, noScroll: true }); + }} /> {/if} @@ -2699,6 +2209,19 @@ verifyingCommits={state.git.verifyingCommits} showDiff={state.git.showDiff} diffData={state.git.diffData} + activeTab={state.ui.activeTab} + {tabs} + onTabChange={(tab: string) => { + state.ui.activeTab = tab as typeof state.ui.activeTab; + // Update URL query parameter without page reload + const url = new URL($page.url); + if (tab === 'files') { + url.searchParams.delete('tab'); + } else { + url.searchParams.set('tab', tab); + } + goto(url.pathname + url.search, { replaceState: true, noScroll: true }); + }} /> {/if} @@ -2721,7 +2244,17 @@ {tabs} showLeftPanelOnMobile={state.ui.showLeftPanelOnMobile} onTagSelect={(tagName) => state.git.selectedTag = tagName} - onTabChange={(tab) => state.ui.activeTab = tab as typeof state.ui.activeTab} + onTabChange={(tab: string) => { + state.ui.activeTab = tab as typeof state.ui.activeTab; + // Update URL without page reload + const url = new URL($page.url); + if (tab === 'files') { + url.searchParams.delete('tab'); + } else { + url.searchParams.set('tab', tab); + } + goto(url.pathname + url.search, { replaceState: true, noScroll: true }); + }} onToggleMobilePanel={() => state.ui.showLeftPanelOnMobile = !state.ui.showLeftPanelOnMobile} onCreateTag={() => state.openDialog = 'createTag'} onCreateRelease={(tagName, tagHash) => { @@ -2739,7 +2272,12 @@ state.ui.activeTab = tab as typeof state.ui.activeTab} + onTabChange={(tab: string) => { + state.ui.activeTab = tab as typeof state.ui.activeTab; + // Update URL without page reload + const newPath = `/repos/${state.npub}/${state.repo}${tab === 'files' ? '' : `/${tab}`}`; + goto(newPath, { replaceState: true, noScroll: true }); + }} /> Code Search { + state.ui.activeTab = tab as typeof state.ui.activeTab; + // Update URL query parameter without page reload + const url = new URL($page.url); + if (tab === 'files') { + url.searchParams.delete('tab'); + } else { + url.searchParams.set('tab', tab); + } + goto(url.pathname + url.search, { replaceState: true, noScroll: true }); + }} /> {/if} @@ -2821,8 +2372,21 @@ } } }} + activeTab={state.ui.activeTab} + {tabs} + onTabChange={(tab: string) => { + state.ui.activeTab = tab as typeof state.ui.activeTab; + // Update URL query parameter without page reload + const url = new URL($page.url); + if (tab === 'files') { + url.searchParams.delete('tab'); + } else { + url.searchParams.set('tab', tab); + } + goto(url.pathname + url.search, { replaceState: true, noScroll: true }); + }} /> - {/if} + {/if} {#if state.ui.activeTab === 'patches'} @@ -2834,6 +2398,12 @@ onSelect={(id) => { state.selected.patch = id; }} + onStatusUpdate={async (id, status) => { + const patch = state.patches.find(p => p.id === id); + if (patch) { + await updatePatchStatus(id, patch.author, status); + } + }} onApply={async (id) => { applying[id] = true; try { @@ -2883,8 +2453,21 @@ } }} {applying} + activeTab={state.ui.activeTab} + {tabs} + onTabChange={(tab: string) => { + state.ui.activeTab = tab as typeof state.ui.activeTab; + // Update URL query parameter without page reload + const url = new URL($page.url); + if (tab === 'files') { + url.searchParams.delete('tab'); + } else { + url.searchParams.set('tab', tab); + } + goto(url.pathname + url.search, { replaceState: true, noScroll: true }); + }} /> - {/if} + {/if} {#if state.ui.activeTab === 'discussions'} @@ -2893,8 +2476,21 @@ repo={state.repo} repoAnnouncement={repoAnnouncement} userPubkey={state.user.pubkey} + activeTab={state.ui.activeTab} + {tabs} + onTabChange={(tab: string) => { + state.ui.activeTab = tab as typeof state.ui.activeTab; + // Update URL query parameter without page reload + const url = new URL($page.url); + if (tab === 'files') { + url.searchParams.delete('tab'); + } else { + url.searchParams.set('tab', tab); + } + goto(url.pathname + url.search, { replaceState: true, noScroll: true }); + }} /> - {/if} + {/if} {#if state.ui.activeTab === 'docs'} @@ -2903,8 +2499,21 @@ repo={state.repo} currentBranch={state.git.currentBranch || null} relays={[...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS]} + activeTab={state.ui.activeTab} + {tabs} + onTabChange={(tab: string) => { + state.ui.activeTab = tab as typeof state.ui.activeTab; + // Update URL query parameter without page reload + const url = new URL($page.url); + if (tab === 'files') { + url.searchParams.delete('tab'); + } else { + url.searchParams.set('tab', tab); + } + goto(url.pathname + url.search, { replaceState: true, noScroll: true }); + }} /> - {/if} + {/if} diff --git a/src/routes/repos/[npub]/[repo]/components/DiscussionsTab.svelte b/src/routes/repos/[npub]/[repo]/components/DiscussionsTab.svelte index 57eeb72..525b31f 100644 --- a/src/routes/repos/[npub]/[repo]/components/DiscussionsTab.svelte +++ b/src/routes/repos/[npub]/[repo]/components/DiscussionsTab.svelte @@ -29,9 +29,20 @@ repo: string; repoAnnouncement?: NostrEvent; userPubkey?: string | null; + activeTab?: string; + tabs?: Array<{ id: string; label: string; icon?: string }>; + onTabChange?: (tab: string) => void; } - let { npub, repo, repoAnnouncement, userPubkey }: Props = $props(); + let { + npub, + repo, + repoAnnouncement, + userPubkey, + activeTab = '', + tabs = [], + onTabChange = () => {} + }: Props = $props(); let discussions = $state([]); let loadingDiscussions = $state(false); @@ -326,7 +337,12 @@ } - + {#snippet leftPane()} diff --git a/src/routes/repos/[npub]/[repo]/components/DocsTab.svelte b/src/routes/repos/[npub]/[repo]/components/DocsTab.svelte index ad19880..78eb6cd 100644 --- a/src/routes/repos/[npub]/[repo]/components/DocsTab.svelte +++ b/src/routes/repos/[npub]/[repo]/components/DocsTab.svelte @@ -17,13 +17,19 @@ repo?: string; currentBranch?: string | null; relays?: string[]; + activeTab?: string; + tabs?: Array<{ id: string; label: string; icon?: string }>; + onTabChange?: (tab: string) => void; } let { npub = '', repo = '', currentBranch = null, - relays = DEFAULT_NOSTR_RELAYS + relays = DEFAULT_NOSTR_RELAYS, + activeTab = '', + tabs = [], + onTabChange = () => {} }: Props = $props(); let documentationContent = $state(null); @@ -50,15 +56,47 @@ try { logger.operation('Loading documentation', { npub, repo, branch: currentBranch }); - // Try to find documentation files - const response = await fetch(`/api/repos/${npub}/${repo}/tree?ref=${currentBranch || 'HEAD'}&path=docs`); + // Try README first (faster, always available if repo has content) + const readmePromise = (async () => { + try { + const readmeResponse = await fetch(`/api/repos/${npub}/${repo}/readme?ref=${currentBranch || 'HEAD'}`); + if (readmeResponse.ok) { + const readmeData = await readmeResponse.json(); + if (readmeData.content) { + return { + content: readmeData.content, + kind: readmeData.type || 'markdown', + path: 'README.md' + }; + } + } + } catch (readmeErr) { + logger.debug({ error: readmeErr, npub, repo }, 'No README found'); + } + return null; + })(); - if (response.ok) { - const data = await response.json(); - docFiles = data.files || []; - - // Look for README or index files first - const readmeFile = docFiles.find(f => + // Try docs folder in parallel + const docsPromise = (async () => { + try { + const response = await fetch(`/api/repos/${npub}/${repo}/tree?ref=${currentBranch || 'HEAD'}&path=docs`); + if (response.ok) { + const data = await response.json(); + return Array.isArray(data) ? data : (data.files || []); + } + } catch (err) { + logger.debug({ error: err, npub, repo }, 'Docs folder not found'); + } + return []; + })(); + + // Wait for both, prefer docs folder if it has files + const [readmeResult, docsFiles] = await Promise.all([readmePromise, docsPromise]); + docFiles = docsFiles; + + if (docsFiles.length > 0) { + // Look for README or index files first in docs folder + const readmeFile = docsFiles.find((f: { name: string; path: string }) => f.name.toLowerCase() === 'readme.md' || f.name.toLowerCase() === 'readme.adoc' || f.name.toLowerCase() === 'index.md' @@ -66,26 +104,21 @@ if (readmeFile) { await loadDocFile(readmeFile.path); - } else if (docFiles.length > 0) { - // Load first file - await loadDocFile(docFiles[0].path); - } - } else { - // Try to load README from root - try { - const readmeResponse = await fetch(`/api/repos/${npub}/${repo}/readme?ref=${currentBranch || 'HEAD'}`); - if (readmeResponse.ok) { - const readmeData = await readmeResponse.json(); - documentationContent = readmeData.content || ''; - documentationKind = readmeData.type || 'markdown'; - } - } catch { - // No README found + } else { + // Load first file from docs folder + await loadDocFile(docsFiles[0].path); } + } else if (readmeResult) { + // No docs folder, use README from root + documentationContent = readmeResult.content; + documentationKind = readmeResult.kind as 'markdown' | 'asciidoc'; + selectedDoc = readmeResult.path; } - // Check for kind 30040 publication index - await checkForPublicationIndex(); + // Check for kind 30040 publication index (only if no content found yet) + if (!documentationContent && !indexEvent) { + await checkForPublicationIndex(); + } } catch (err) { error = err instanceof Error ? err.message : 'Failed to load documentation'; @@ -154,11 +187,22 @@ } - + {#snippet leftPane()} Documentation - {#if docFiles.length > 0} + {#if loading} + Loading documentation... + {:else if error} + {error} + {:else if docFiles.length > 0} {#each docFiles as file} @@ -171,12 +215,24 @@ {/each} + {:else if documentationContent} + + No custom documentation found. Displaying the ReadMe, instead. + + {:else} + + No documentation files found + {/if} {/snippet} {#snippet rightPanel()} - {#if documentationKind === '30040' && indexEvent} + {#if loading} + Loading documentation... + {:else if error} + {error} + {:else if documentationKind === '30040' && indexEvent} {:else} @@ -246,4 +306,25 @@ font-size: 0.9rem; margin-top: 0.5rem; } + + .empty-sidebar { + padding: 1rem; + text-align: center; + color: var(--text-secondary); + font-size: 0.9rem; + } + + .loading { + padding: 1rem; + text-align: center; + color: var(--text-secondary); + } + + .error { + padding: 1rem; + background: var(--error-bg, #ffebee); + color: var(--error-color, #c62828); + border-radius: 4px; + margin: 1rem; + } diff --git a/src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte b/src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte index 408355c..4f4273a 100644 --- a/src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte +++ b/src/routes/repos/[npub]/[repo]/components/DocsViewer.svelte @@ -9,19 +9,78 @@ import type { NostrEvent } from '$lib/types/nostr.js'; import { KIND } from '$lib/types/nostr.js'; import logger from '$lib/services/logger.js'; + import { renderContent } from '../utils/content-renderer.js'; + + // Rewrite image paths in HTML to point to repository file API + function rewriteImagePaths(html: string, filePath: string, npub: string, repo: string, branch: string): string { + if (!html || !filePath) return html; + + // Get the directory of the current file + const fileDir = filePath.includes('/') + ? filePath.substring(0, filePath.lastIndexOf('/')) + : ''; + + // Rewrite relative image paths + return html.replace(/]*)\ssrc=["']([^"']+)["']([^>]*)>/gi, (match, before, src, after) => { + // Skip if it's already an absolute URL (http/https/data) or already an API URL + 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 ``; + }); + } interface Props { content?: string; contentType?: 'markdown' | 'asciidoc' | 'text' | '30040'; indexEvent?: NostrEvent | null; relays?: string[]; + onItemClick?: ((item: any) => void) | null; + npub?: string; + repo?: string; + currentBranch?: string; + filePath?: string | null; } let { content = '', contentType = 'text', indexEvent = null, - relays = [] + relays = [], + onItemClick = null, + npub = '', + repo = '', + currentBranch = 'HEAD', + filePath = null }: Props = $props(); let renderedContent = $state(''); @@ -35,66 +94,29 @@ } if (content) { - renderContent(); + doRenderContent(); } }); - async function renderContent() { + async function doRenderContent() { loading = true; error = null; try { logger.operation('Rendering content', { contentType, length: content.length }); - if (contentType === 'markdown') { - 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 (err) { - // Fallback to escaped HTML - } - } - return '' + md.utils.escapeHtml(str) + ''; - } - }); - - renderedContent = md.render(content); - - // Add IDs to headings for anchor links - renderedContent = renderedContent.replace(/(.*?)<\/h[1-6]>/g, (match, level, text) => { - const textContent = text.replace(/<[^>]*>/g, '').trim(); - const slug = textContent - .toLowerCase() - .replace(/[^\w\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); - - return `${text}`; - }); - } else if (contentType === 'asciidoc') { - const asciidoctor = (await import('asciidoctor')).default(); - renderedContent = asciidoctor.convert(content, { - safe: 'safe', - attributes: { - 'source-highlighter': 'highlight.js' - } - }); + // Use the shared content renderer utility + // contentType '30040' is handled separately by PublicationIndexViewer + if (contentType === '30040') { + // Should not reach here, but handle gracefully + renderedContent = ''; } else { - // Plain text - escape HTML - renderedContent = content - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/\n/g, ''); + renderedContent = await renderContent(content, contentType as 'markdown' | 'asciidoc' | 'text'); + + // Rewrite image paths to use API endpoint + if (npub && repo && filePath) { + renderedContent = rewriteImagePaths(renderedContent, filePath, npub, repo, currentBranch); + } } logger.operation('Content rendered', { contentType }); @@ -107,10 +129,14 @@ } function handleItemClick(item: any) { - logger.debug({ item }, 'Publication index item clicked'); - // Could navigate to item URL or emit event - if (item.url) { - window.open(item.url, '_blank'); + if (onItemClick) { + onItemClick(item); + } else { + logger.debug({ item }, 'Publication index item clicked'); + // Could navigate to item URL or emit event + if (item.url) { + window.open(item.url, '_blank'); + } } } @@ -137,8 +163,9 @@ diff --git a/src/routes/repos/[npub]/[repo]/components/HistoryTab.svelte b/src/routes/repos/[npub]/[repo]/components/HistoryTab.svelte index 03adf77..4957dff 100644 --- a/src/routes/repos/[npub]/[repo]/components/HistoryTab.svelte +++ b/src/routes/repos/[npub]/[repo]/components/HistoryTab.svelte @@ -22,6 +22,9 @@ verifyingCommits?: Set; showDiff?: boolean; diffData?: Array<{ file: string; additions: number; deletions: number; diff: string }>; + activeTab?: string; + tabs?: Array<{ id: string; label: string; icon?: string }>; + onTabChange?: (tab: string) => void; } let { @@ -33,11 +36,21 @@ onVerify = () => {}, verifyingCommits = new Set(), showDiff = false, - diffData = [] + diffData = [], + activeTab = '', + tabs = [], + onTabChange = () => {} }: Props = $props(); - + {#snippet leftPane()} Commits @@ -218,6 +231,24 @@ .commit-detail { padding: 1rem; + width: 100%; + max-width: 100%; + box-sizing: border-box; + min-width: 0; + overflow-wrap: break-word; + word-wrap: break-word; + } + + .empty-state { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-width: 100%; + max-width: 100%; + height: 100%; + color: var(--text-secondary); + box-sizing: border-box; } .commit-detail-header { @@ -227,14 +258,34 @@ margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border-color); + min-width: 0; + flex-wrap: wrap; + gap: 0.5rem; + } + + .commit-detail-header h2 { + min-width: 0; + word-break: break-word; + overflow-wrap: break-word; + flex: 1; } .commit-info { margin: 1rem 0; + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; } .info-row { margin: 1rem 0; + width: 100%; + max-width: 100%; + min-width: 0; + word-break: break-word; + overflow-wrap: break-word; + box-sizing: border-box; } .commit-message-text { @@ -243,21 +294,45 @@ background: var(--bg-secondary); border-radius: 4px; white-space: pre-wrap; + word-break: break-word; + overflow-wrap: break-word; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + overflow-x: hidden; } .files-list { margin-top: 0.5rem; padding-left: 1.5rem; + word-break: break-word; + overflow-wrap: break-word; + max-width: 100%; + box-sizing: border-box; + } + + .files-list li { + word-break: break-word; + overflow-wrap: break-word; + max-width: 100%; } .diff-section { margin-top: 2rem; padding-top: 2rem; border-top: 1px solid var(--border-color); + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; } .diff-file { margin: 1rem 0; + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; } .diff-header { @@ -267,11 +342,22 @@ padding: 0.5rem; background: var(--bg-secondary); border-radius: 4px 4px 0 0; + min-width: 0; + flex-wrap: wrap; + gap: 0.5rem; + } + + .diff-header strong { + min-width: 0; + word-break: break-word; + overflow-wrap: break-word; + flex: 1; } .diff-stats { font-family: monospace; font-size: 0.9rem; + flex-shrink: 0; } .diff-content { @@ -280,7 +366,24 @@ background: var(--bg-secondary); border-radius: 0 0 4px 4px; overflow-x: auto; + overflow-y: hidden; font-family: monospace; font-size: 0.85rem; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + word-break: break-word; + overflow-wrap: break-word; + white-space: pre-wrap; + } + + .diff-content code { + display: block; + max-width: 100%; + min-width: 0; + word-break: break-word; + overflow-wrap: break-word; + white-space: pre-wrap; + box-sizing: border-box; } diff --git a/src/routes/repos/[npub]/[repo]/components/IssuesTab.svelte b/src/routes/repos/[npub]/[repo]/components/IssuesTab.svelte index e3aa257..131dee8 100644 --- a/src/routes/repos/[npub]/[repo]/components/IssuesTab.svelte +++ b/src/routes/repos/[npub]/[repo]/components/IssuesTab.svelte @@ -4,6 +4,7 @@ */ import StatusTabLayout from './StatusTabLayout.svelte'; + import { renderContent } from '../utils/content-renderer.js'; interface Props { issues: Array<{ @@ -23,6 +24,9 @@ onStatusUpdate?: (id: string, status: string) => void; issueReplies?: Array; loadingReplies?: boolean; + activeTab?: string; + tabs?: Array<{ id: string; label: string; icon?: string }>; + onTabChange?: (tab: string) => void; } let { @@ -33,20 +37,37 @@ onSelect = () => {}, onStatusUpdate = () => {}, issueReplies = [], - loadingReplies = false + loadingReplies = false, + activeTab = '', + tabs = [], + onTabChange = () => {} }: Props = $props(); const items = $derived(issues.map(issue => ({ + ...issue, id: issue.id, title: issue.subject, - status: issue.status, - ...issue + status: issue.status || 'open' }))); const selectedId = $derived(selectedIssue); + + // Cache for rendered content + let renderedContent = $state>(new Map()); + + async function getRenderedContent(content: string, kind?: number): Promise { + if (!content) return 'No content'; + const cacheKey = `${kind || 'markdown'}:${content.slice(0, 50)}`; + if (renderedContent.has(cacheKey)) { + return renderedContent.get(cacheKey)!; + } + const rendered = await renderContent(content, kind); + renderedContent.set(cacheKey, rendered); + return rendered; + } -{#snippet itemRenderer({ item })} +{#snippet itemRenderer({ item }: { item: any })} {item.subject} @@ -56,41 +77,58 @@ {/snippet} -{#snippet detailRenderer({ item })} - - - {item.subject} - - onStatusUpdate(item.id, (e.target as HTMLSelectElement).value)} - > - Open - Closed - Resolved - - +{#snippet detailRenderer({ item }: { item: any })} + {@const contentPromise = getRenderedContent(item.content || '', item.kind)} + {@const currentStatus = item.status || 'open'} + + + {item.subject} + + onStatusUpdate(item.id, (e.target as HTMLSelectElement).value)} + > + Open + Closed + Resolved + - - - {@html item.content || 'No content'} - - - {#if loadingReplies} - Loading replies... - {:else if issueReplies.length > 0} - - Replies - {#each issueReplies as reply} - - {reply.author} - {reply.content} - {new Date(reply.created_at * 1000).toLocaleString()} - - {/each} - - {/if} + + + {#await contentPromise} + Rendering content... + {:then html} + {@html html} + {:catch err} + Failed to render content: {err instanceof Error ? err.message : String(err)} + {/await} + + + {#if loadingReplies} + Loading replies... + {:else if issueReplies.length > 0} + + Replies + {#each issueReplies as reply} + {@const replyPromise = getRenderedContent(reply.content || '', reply.kind)} + + {reply.author} + + {#await replyPromise} + Rendering... + {:then html} + {@html html} + {:catch err} + {reply.content} + {/await} + + {new Date(reply.created_at * 1000).toLocaleString()} + + {/each} + + {/if} + {/snippet} diff --git a/src/routes/repos/[npub]/[repo]/components/StatusTabLayout.svelte b/src/routes/repos/[npub]/[repo]/components/StatusTabLayout.svelte index 19e1351..29510f2 100644 --- a/src/routes/repos/[npub]/[repo]/components/StatusTabLayout.svelte +++ b/src/routes/repos/[npub]/[repo]/components/StatusTabLayout.svelte @@ -20,6 +20,10 @@ statusGroups?: Array<{ label: string; value: string }>; itemRenderer?: import('svelte').Snippet<[{ item: { id: string; title: string; status: string; [key: string]: any } }]>; detailRenderer?: import('svelte').Snippet<[{ item: { id: string; title: string; status: string; [key: string]: any } }]>; + activeTab?: string; + tabs?: Array<{ id: string; label: string; icon?: string }>; + onTabChange?: (tab: string) => void; + title?: string; } let { @@ -33,7 +37,11 @@ { label: 'Closed', value: 'closed' } ], itemRenderer, - detailRenderer + detailRenderer, + activeTab = '', + tabs = [], + onTabChange = () => {}, + title = '' }: Props = $props(); let selectedItem = $derived(items.find(item => item.id === selectedId) || null); @@ -58,7 +66,14 @@ const grouped = $derived(groupByStatus()); - + {#snippet leftPane()} {#each statusGroups as { label, value }} @@ -104,6 +119,10 @@ {JSON.stringify(selectedItem, null, 2)} {/if} + {:else} + + Select an item to view details + {/if} {/snippet} @@ -167,9 +186,24 @@ .detail-view { padding: 1rem; + width: 100%; + max-width: 100%; + box-sizing: border-box; } .detail-view h2 { margin-top: 0; } + + .empty-state { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-width: 100%; + max-width: 100%; + height: 100%; + color: var(--text-secondary); + box-sizing: border-box; + } diff --git a/src/routes/repos/[npub]/[repo]/components/TabLayout.svelte b/src/routes/repos/[npub]/[repo]/components/TabLayout.svelte index 00a6094..f63ac66 100644 --- a/src/routes/repos/[npub]/[repo]/components/TabLayout.svelte +++ b/src/routes/repos/[npub]/[repo]/components/TabLayout.svelte @@ -4,49 +4,98 @@ * Provides left-pane/right-panel structure for all tabs */ + import TabsMenu from '$lib/components/TabsMenu.svelte'; + interface Props { leftPane?: any; rightPanel?: any; loading?: boolean; error?: string | null; + activeTab?: string; + tabs?: Array<{ id: string; label: string; icon?: string }>; + onTabChange?: (tab: string) => void; + title?: string; } let { leftPane = null, rightPanel = null, loading = false, - error = null + error = null, + activeTab = '', + tabs = [], + onTabChange = () => {}, + title = '' }: Props = $props(); - - - {#if loading} - Loading... - {:else if error} - {error} - {:else} - {#if leftPane} - {@render leftPane()} + + {#if tabs.length > 0} + + onTabChange(tab)} + /> + {#if title} + {title} {/if} - {/if} - + + {/if} - - {#if rightPanel} - {@render rightPanel()} - {:else} - - Select an item to view details - - {/if} + + + {#if loading} + Loading... + {:else if error} + {error} + {:else} + {#if leftPane} + {@render leftPane()} + {/if} + {/if} + + + + {#if rightPanel} + {@render rightPanel()} + {:else} + + Select an item to view details + + {/if} + diff --git a/src/routes/repos/[npub]/[repo]/components/dialogs/CloneUrlVerificationDialog.svelte b/src/routes/repos/[npub]/[repo]/components/dialogs/CloneUrlVerificationDialog.svelte index 97813b0..0cec054 100644 --- a/src/routes/repos/[npub]/[repo]/components/dialogs/CloneUrlVerificationDialog.svelte +++ b/src/routes/repos/[npub]/[repo]/components/dialogs/CloneUrlVerificationDialog.svelte @@ -25,7 +25,7 @@ {#if state.verification.selectedCloneUrl} - Clone URL: {state.verification.selectedCloneUrl} + Clone URL: {state.verification.selectedCloneUrl} {/if} {#if state.clone.isCloned !== true} @@ -34,7 +34,7 @@ {:else} - This will commit the repository announcement event to nostr/repo-events.jsonl in the default branch, which verifies that you control this repository. + This will commit the repository announcement event to nostr/repo-events.jsonl in the default branch, which verifies that you control this repository. {/if} {#if state.error} @@ -71,7 +71,7 @@ margin-bottom: 1rem; } - .verification-instructions code { + .verification-code { background: var(--bg-secondary, #f5f5f5); padding: 0.2rem 0.4rem; border-radius: 3px; diff --git a/src/routes/repos/[npub]/[repo]/components/dialogs/VerificationDialog.svelte b/src/routes/repos/[npub]/[repo]/components/dialogs/VerificationDialog.svelte index 82754c0..89be781 100644 --- a/src/routes/repos/[npub]/[repo]/components/dialogs/VerificationDialog.svelte +++ b/src/routes/repos/[npub]/[repo]/components/dialogs/VerificationDialog.svelte @@ -16,7 +16,7 @@ - The announcement event should be saved to nostr/repo-events.jsonl in your repository. + The announcement event should be saved to nostr/repo-events.jsonl in your repository. You can download the announcement event JSON below for reference. @@ -45,7 +45,7 @@ color: var(--text-secondary, #666); } - .verification-instructions code { + .verification-code { background: var(--bg-secondary, #f5f5f5); padding: 0.2rem 0.4rem; border-radius: 3px; diff --git a/src/routes/repos/[npub]/[repo]/hooks/use-repo-effects.ts b/src/routes/repos/[npub]/[repo]/hooks/use-repo-effects.ts index 27dba77..becaf57 100644 --- a/src/routes/repos/[npub]/[repo]/hooks/use-repo-effects.ts +++ b/src/routes/repos/[npub]/[repo]/hooks/use-repo-effects.ts @@ -6,17 +6,15 @@ * where $page and $userStore runes are available */ -import { page } from '$app/stores'; -import { userStore } from '$lib/stores/user-store.js'; import { settingsStore } from '$lib/services/settings-store.js'; import type { RepoState } from '../stores/repo-state.js'; /** * Sync pageData from $page store - * Must be called from component context where $page is available + * Returns effect callback that should be called within $effect in component */ -export function usePageDataEffect(state: RepoState, getPageData: () => any): void { - $effect(() => { +export function usePageDataEffect(state: RepoState, getPageData: () => any): () => void { + return () => { if (typeof window === 'undefined' || !state.isMounted) return; try { const data = getPageData(); @@ -28,15 +26,15 @@ export function usePageDataEffect(state: RepoState, getPageData: () => any): voi console.warn('Failed to update pageData:', err); } } - }); + }; } /** * Sync params from $page store - * Must be called from component context where $page is available + * Returns effect callback that should be called within $effect in component */ -export function usePageParamsEffect(state: RepoState, getPageParams: () => { npub?: string; repo?: string }): void { - $effect(() => { +export function usePageParamsEffect(state: RepoState, getPageParams: () => { npub?: string; repo?: string }): () => void { + return () => { if (typeof window === 'undefined' || !state.isMounted) return; try { const params = getPageParams(); @@ -58,12 +56,12 @@ export function usePageParamsEffect(state: RepoState, getPageParams: () => { npu // Ignore errors - params will be set eventually } } - }); + }; } /** * Load maintainers when repo data is available - * Must be called from component context where $page is available + * Returns effect callback that should be called within $effect in component */ export function useMaintainersEffect( state: RepoState, @@ -71,8 +69,8 @@ export function useMaintainersEffect( getRepoMaintainers: () => string[], loadAllMaintainers: () => Promise, getPageData: () => any -): void { - $effect(() => { +): () => void { + return () => { if (typeof window === 'undefined' || !state.isMounted) return; try { const data = getPageData(); @@ -107,18 +105,19 @@ export function useMaintainersEffect( console.warn('Maintainers effect error:', err); } } - }); + }; } /** * Watch auto-save settings and manage auto-save interval + * Returns effect callback that should be called within $effect in component */ export function useAutoSaveEffect( state: RepoState, autoSaveInterval: { value: ReturnType | null }, setupAutoSave: () => void -): void { - $effect(() => { +): () => void { + return () => { if (!state.isMounted) return; settingsStore.getSettings().then(settings => { if (!state.isMounted) return; @@ -135,12 +134,12 @@ export function useAutoSaveEffect( console.warn('Failed to check auto-save setting:', err); } }); - }); + }; } /** * Sync user state from userStore and reload data on login/logout - * Must be called from component context where $userStore is available + * Returns effect callback that should be called within $effect in component */ export function useUserStoreEffect( state: RepoState, @@ -157,8 +156,8 @@ export function useUserStoreEffect( loadTags: () => Promise; loadDiscussions: () => Promise; } -): void { - $effect(() => { +): () => void { + return () => { if (!state.isMounted) return; try { const currentUser = getUserStore(); @@ -245,18 +244,19 @@ export function useUserStoreEffect( console.warn('User store sync error:', err); } } - }); + }; } /** * Handle tab switching when clone status changes + * Returns effect callback that should be called within $effect in component */ export function useTabSwitchEffect( state: RepoState, tabs: Array<{ id: string }>, canUseApiFallback: boolean -): void { - $effect(() => { +): () => void { + return () => { if (!state.isMounted) return; if (state.clone.isCloned === false && !canUseApiFallback && tabs.length > 0) { const currentTab = tabs.find(t => t.id === state.ui.activeTab); @@ -264,15 +264,15 @@ export function useTabSwitchEffect( state.ui.activeTab = tabs[0].id as typeof state.ui.activeTab; } } - }); + }; } /** * Update repo images from pageData - * Must be called from component context where $page is available + * Returns effect callback that should be called within $effect in component */ -export function useRepoImagesEffect(state: RepoState, getPageData: () => any): void { - $effect(() => { +export function useRepoImagesEffect(state: RepoState, getPageData: () => any): () => void { + return () => { if (typeof window === 'undefined' || !state.isMounted) return; try { const data = getPageData(); @@ -290,17 +290,18 @@ export function useRepoImagesEffect(state: RepoState, getPageData: () => any): v console.warn('Image update effect error:', err); } } - }); + }; } /** * Load patch highlights when patch is selected + * Returns effect callback that should be called within $effect in component */ export function usePatchHighlightsEffect( state: RepoState, loadPatchHighlights: (patchId: string, author: string) => Promise -): void { - $effect(() => { +): () => void { + return () => { if (!state.isMounted || !state.selected.patch) return; const patch = state.patches.find(p => p.id === state.selected.patch); if (patch) { @@ -308,11 +309,12 @@ export function usePatchHighlightsEffect( if (state.isMounted) console.warn('Failed to load patch highlights:', err); }); } - }); + }; } /** * Load tab content when tab changes + * Returns effect callback that should be called within $effect in component */ export function useTabChangeEffect( state: RepoState, @@ -330,8 +332,8 @@ export function useTabChangeEffect( loadDiscussions: () => Promise; loadPatches: () => Promise; } -): void { - $effect(() => { +): () => void { + return () => { if (!state.isMounted) return; if (state.ui.activeTab !== lastTab.value) { lastTab.value = state.ui.activeTab; @@ -389,11 +391,12 @@ export function useTabChangeEffect( }); } } - }); + }; } /** * Reload branch-dependent data when branch changes + * Returns effect callback that should be called within $effect in component */ export function useBranchChangeEffect( state: RepoState, @@ -405,8 +408,8 @@ export function useBranchChangeEffect( loadCommitHistory: () => Promise; loadDocumentation: () => Promise; } -): void { - $effect(() => { +): () => void { + return () => { if (!state.isMounted) return; if (state.git.currentBranch && state.git.currentBranch !== lastBranch.value) { lastBranch.value = state.git.currentBranch; @@ -443,5 +446,5 @@ export function useBranchChangeEffect( }); } } - }); + }; } diff --git a/src/routes/repos/[npub]/[repo]/services/auth-operations.ts b/src/routes/repos/[npub]/[repo]/services/auth-operations.ts new file mode 100644 index 0000000..3e99c79 --- /dev/null +++ b/src/routes/repos/[npub]/[repo]/services/auth-operations.ts @@ -0,0 +1,122 @@ +/** + * Authentication operations service + * Handles user authentication and login + */ + +import type { RepoState } from '../stores/repo-state.js'; +import { userStore } from '$lib/stores/user-store.js'; +import { getPublicKeyWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; +import { get } from 'svelte/store'; + +/** + * Check authentication status + */ +export async function checkAuth(state: RepoState): Promise { + // Check userStore first + const currentUser = get(userStore); + if (currentUser.userPubkey && currentUser.userPubkeyHex) { + state.user.pubkey = currentUser.userPubkey; + state.user.pubkeyHex = currentUser.userPubkeyHex; + // Recheck maintainer status and bookmark status after auth + // These will be called by useUserStoreEffect hook + } +} + +/** + * Login with NIP-07 + */ +export async function login( + state: RepoState, + callbacks: { + checkMaintainerStatus: () => Promise; + loadBookmarkStatus: () => Promise; + } +): Promise { + // Check userStore first + const currentUser = get(userStore); + if (currentUser.userPubkey && currentUser.userPubkeyHex) { + state.user.pubkey = currentUser.userPubkey; + state.user.pubkeyHex = currentUser.userPubkeyHex; + // Recheck maintainer status and bookmark status after auth + await callbacks.checkMaintainerStatus(); + await callbacks.loadBookmarkStatus(); + return; + } + + try { + // Get public key from NIP-07 extension + const pubkey = await getPublicKeyWithNIP07(); + if (!pubkey) { + state.error = 'Failed to get public key from NIP-07 extension'; + return; + } + + state.user.pubkey = pubkey; + + // Convert npub to hex if needed + let pubkeyHex: string; + if (pubkey.startsWith('npub')) { + try { + const { nip19 } = await import('nostr-tools'); + const decoded = nip19.decode(pubkey); + if (decoded.type === 'npub') { + pubkeyHex = decoded.data as string; + } else { + state.error = 'Invalid public key format'; + return; + } + } catch { + state.error = 'Invalid public key format'; + return; + } + } else { + pubkeyHex = pubkey; + } + + state.user.pubkeyHex = pubkeyHex; + + // Check write access and update user store + const { determineUserLevel } = await import('$lib/services/nostr/user-level-service.js'); + const levelResult = await determineUserLevel(state.user.pubkey, state.user.pubkeyHex); + + // Update user store with write access level + userStore.setUser( + levelResult.userPubkey, + levelResult.userPubkeyHex, + levelResult.level, + levelResult.error || null + ); + + // Update activity tracking + const { updateActivity } = await import('$lib/services/activity-tracker.js'); + updateActivity(); + + // Check for pending transfer events + if (state.user.pubkeyHex) { + try { + const response = await fetch('/api/transfers/pending', { + headers: { + 'X-User-Pubkey': state.user.pubkeyHex + } + }); + if (response.ok) { + const data = await response.json(); + if (data.pendingTransfers && data.pendingTransfers.length > 0) { + window.dispatchEvent(new CustomEvent('pendingTransfers', { + detail: { transfers: data.pendingTransfers } + })); + } + } + } catch (err) { + console.error('Failed to check for pending transfers:', err); + } + } + + // Re-check maintainer status and bookmark status after login + await callbacks.checkMaintainerStatus(); + await callbacks.loadBookmarkStatus(); + } catch (err) { + state.error = err instanceof Error ? err.message : 'Failed to connect'; + console.error('Login error:', err); + } +} diff --git a/src/routes/repos/[npub]/[repo]/services/patch-handlers.ts b/src/routes/repos/[npub]/[repo]/services/patch-handlers.ts new file mode 100644 index 0000000..f1ff236 --- /dev/null +++ b/src/routes/repos/[npub]/[repo]/services/patch-handlers.ts @@ -0,0 +1,42 @@ +/** + * Patch handler utilities + * UI interaction handlers for patch operations + */ + +import type { RepoState } from '../stores/repo-state.js'; + +/** + * Handle patch code selection + */ +export function handlePatchCodeSelection( + text: string, + startLine: number, + endLine: number, + startPos: number, + endPos: number, + state: RepoState +): void { + if (!text.trim() || !state.user.pubkey) return; + + state.forms.patchHighlight.text = text; + state.forms.patchHighlight.startLine = startLine; + state.forms.patchHighlight.endLine = endLine; + state.forms.patchHighlight.startPos = startPos; + state.forms.patchHighlight.endPos = endPos; + state.openDialog = 'patchHighlight'; +} + +/** + * Start patch comment + */ +export function startPatchComment( + parentId: string | undefined, + state: RepoState +): void { + if (!state.user.pubkey) { + alert('Please connect your NIP-07 extension'); + return; + } + state.forms.patchComment.replyingTo = parentId || null; + state.openDialog = 'patchComment'; +} diff --git a/src/routes/repos/[npub]/[repo]/services/repo-operations.ts b/src/routes/repos/[npub]/[repo]/services/repo-operations.ts index 5233291..390bd05 100644 --- a/src/routes/repos/[npub]/[repo]/services/repo-operations.ts +++ b/src/routes/repos/[npub]/[repo]/services/repo-operations.ts @@ -557,3 +557,267 @@ export async function loadRepoImages( console.error('Error loading repo images:', err); } } + +/** + * Generate announcement file for repository + */ +export async function generateAnnouncementFileForRepo( + state: RepoState, + repoOwnerPubkeyDerived: string | null +): Promise { + if (!repoOwnerPubkeyDerived || !state.user.pubkeyHex) { + state.error = 'Unable to generate announcement file: missing repository or user information'; + return; + } + + try { + // Fetch the repository announcement event + const { NostrClient } = await import('$lib/services/nostr/nostr-client.js'); + const { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } = await import('$lib/config.js'); + const { KIND } = await import('$lib/types/nostr.js'); + const nostrClient = new NostrClient([...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]); + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [repoOwnerPubkeyDerived], + '#d': [state.repo], + limit: 1 + } + ]); + + if (events.length === 0) { + state.error = 'Repository announcement not found. Please ensure the repository is registered on Nostr.'; + return; + } + + const announcement = events[0] as NostrEvent; + // Generate announcement event JSON (for download/reference) + state.verification.fileContent = JSON.stringify(announcement, null, 2) + '\n'; + state.openDialog = 'verification'; + } catch (err) { + console.error('Failed to generate announcement file:', err); + state.error = `Failed to generate announcement file: ${err instanceof Error ? err.message : String(err)}`; + } +} + +/** + * Copy verification file content to clipboard + */ +export function copyVerificationToClipboard(state: RepoState): void { + if (!state.verification.fileContent) return; + + navigator.clipboard.writeText(state.verification.fileContent).then(() => { + alert('Verification file content copied to clipboard!'); + }).catch((err) => { + console.error('Failed to copy:', err); + alert('Failed to copy to clipboard. Please select and copy manually.'); + }); +} + +/** + * Download verification file + */ +export function downloadVerificationFile(state: RepoState): void { + if (!state.verification.fileContent) return; + + const blob = new Blob([state.verification.fileContent], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'announcement-event.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +/** + * Verify clone URL + */ +export async function verifyCloneUrl( + state: RepoState, + repoOwnerPubkeyDerived: string | null, + callbacks: { checkVerification: () => Promise } +): Promise { + if (!state.verification.selectedCloneUrl || !state.user.pubkey || !state.user.pubkeyHex) { + state.error = 'Unable to verify: missing information'; + return; + } + + if (!state.maintainers.isMaintainer && state.user.pubkeyHex !== repoOwnerPubkeyDerived) { + state.error = 'Only repository owners and maintainers can verify clone URLs'; + return; + } + + // selectedCloneUrl is already set when user selects it + state.error = null; + + try { + const data = await apiRequest<{ message?: string }>(`/api/repos/${state.npub}/${state.repo}/verify`, { + method: 'POST' + } as RequestInit); + + // Close dialog + state.openDialog = null; + state.verification.selectedCloneUrl = null; + + // Reload verification status after a short delay + setTimeout(() => { + callbacks.checkVerification().catch((err: unknown) => { + console.warn('Failed to reload verification status:', err); + }); + }, 1000); + + // Show success message + alert(data.message || 'Repository verification initiated. The verification status will update shortly.'); + } catch (err) { + state.error = err instanceof Error ? err.message : 'Failed to verify repository'; + console.error('Error verifying clone URL:', err); + } finally { + state.verification.selectedCloneUrl = null; + } +} + +/** + * Delete repository announcement + */ +export async function deleteAnnouncement( + state: RepoState, + repoOwnerPubkeyDerived: string | null, + announcementEventId: { value: string | null } +): Promise { + if (!state.user.pubkey || !state.user.pubkeyHex) { + alert('Please connect your NIP-07 extension'); + return; + } + + if (!repoOwnerPubkeyDerived || state.user.pubkeyHex !== repoOwnerPubkeyDerived) { + alert('Only the repository owner can delete the announcement'); + return; + } + + // First confirmation + if (!confirm('WARNING: Are you sure you want to delete this repository announcement?\n\nThis will permanently delete the repository announcement from Nostr relays. This action CANNOT be undone.\n\nClick OK to continue, or Cancel to abort.')) { + return; + } + + // Second confirmation for critical operation + if (!confirm('FINAL CONFIRMATION: This will permanently delete the repository announcement.\n\nAre you absolutely certain you want to proceed?\n\nThis action CANNOT be undone.')) { + return; + } + + state.creating.announcement = true; + state.error = null; + + try { + // Fetch the repository announcement to get its event ID + const { NostrClient } = await import('$lib/services/nostr/nostr-client.js'); + const { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } = await import('$lib/config.js'); + const { getUserRelays } = await import('$lib/services/nostr/user-relays.js'); + const { signEventWithNIP07 } = await import('$lib/services/nostr/nip07-signer.js'); + const { KIND } = await import('$lib/types/nostr.js'); + const nostrClient = new NostrClient([...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]); + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [repoOwnerPubkeyDerived], + '#d': [state.repo], + limit: 1 + } + ]); + + if (events.length === 0) { + throw new Error('Repository announcement not found'); + } + + const announcement = events[0]; + announcementEventId.value = announcement.id; + + // Get user relays + const { outbox } = await getUserRelays(state.user.pubkeyHex, nostrClient); + const combinedRelays = combineRelays(outbox); + + // Create deletion request (NIP-09) + const deletionRequestTemplate: Omit = { + kind: KIND.DELETION_REQUEST, + pubkey: state.user.pubkeyHex, + created_at: Math.floor(Date.now() / 1000), + content: `Requesting deletion of repository announcement for ${state.repo}`, + tags: [ + ['e', announcement.id], // Reference to the announcement event + ['a', `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkeyDerived}:${state.repo}`], // Repository address + ['k', KIND.REPO_ANNOUNCEMENT.toString()] // Kind of event being deleted + ] + }; + + // Sign with NIP-07 + const signedDeletionRequest = await signEventWithNIP07(deletionRequestTemplate); + + // Publish to relays + const publishResult = await nostrClient.publishEvent(signedDeletionRequest, combinedRelays); + + if (publishResult.success.length > 0) { + alert(`Deletion request published successfully to ${publishResult.success.length} relay(s).`); + } else { + throw new Error(`Failed to publish deletion request to any relay. Errors: ${publishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}`); + } + } catch (err) { + console.error('Failed to delete announcement:', err); + state.error = err instanceof Error ? err.message : 'Failed to send deletion request'; + alert(state.error); + } finally { + state.creating.announcement = false; + } +} + +/** + * Copy event ID to clipboard + */ +export async function copyEventId( + state: RepoState, + repoOwnerPubkeyDerived: string | null +): Promise { + if (!state.metadata.address || !repoOwnerPubkeyDerived) { + alert('Repository address not available'); + return; + } + + try { + const { nip19 } = await import('nostr-tools'); + const { KIND } = await import('$lib/types/nostr.js'); + + // Create naddr (NIP-19 address) for the repository + const naddr = nip19.naddrEncode({ + kind: KIND.REPO_ANNOUNCEMENT, + pubkey: repoOwnerPubkeyDerived, + identifier: state.repo, + relays: [] + }); + + // Try to use the Clipboard API + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(naddr); + } else { + // 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)}`); + } +} diff --git a/src/routes/repos/[npub]/[repo]/services/tag-operations.ts b/src/routes/repos/[npub]/[repo]/services/tag-operations.ts index 141bc8a..52a84ea 100644 --- a/src/routes/repos/[npub]/[repo]/services/tag-operations.ts +++ b/src/routes/repos/[npub]/[repo]/services/tag-operations.ts @@ -22,13 +22,15 @@ export async function loadTags( const tags = await apiRequest>( `/api/repos/${state.npub}/${state.repo}/tags` ); - state.git.tags = tags; + state.git.tags = tags || []; // Auto-select first tag if none selected if (state.git.tags.length > 0 && !state.git.selectedTag) { state.git.selectedTag = state.git.tags[0].name; } } catch (err) { - console.error('Failed to load tags:', err); + // If tags endpoint returns 404 or error, just set empty array (tags are optional) + console.warn('Failed to load tags (this is OK if repo has no tags):', err); + state.git.tags = []; } } diff --git a/src/routes/repos/[npub]/[repo]/stores/repo-state.ts b/src/routes/repos/[npub]/[repo]/stores/repo-state.ts index fa021b3..c3c8943 100644 --- a/src/routes/repos/[npub]/[repo]/stores/repo-state.ts +++ b/src/routes/repos/[npub]/[repo]/stores/repo-state.ts @@ -582,7 +582,7 @@ export function createRepoState(): RepoState { pubkeyHex: null }, ui: { - activeTab: 'files', + activeTab: 'docs', showRepoMenu: false, showFileListOnMobile: true, showLeftPanelOnMobile: true, diff --git a/src/routes/repos/[npub]/[repo]/utils/content-renderer.ts b/src/routes/repos/[npub]/[repo]/utils/content-renderer.ts new file mode 100644 index 0000000..e1aa4ee --- /dev/null +++ b/src/routes/repos/[npub]/[repo]/utils/content-renderer.ts @@ -0,0 +1,87 @@ +/** + * Content rendering utility + * Renders content as Markdown (default) or AsciiDoc (for kinds 30041 and 30818) + * Consolidates rendering logic used by DocsViewer, PRsTab, IssuesTab, and PatchesTab + */ + +/** + * Render content as HTML based on kind or contentType + * @param content - The content to render + * @param kindOrType - The Nostr event kind (30041 or 30818 for AsciiDoc) or contentType string ('asciidoc' for AsciiDoc, everything else for Markdown) + * @returns Promise - Rendered HTML + */ +export async function renderContent( + content: string, + kindOrType?: number | 'markdown' | 'asciidoc' | 'text' +): Promise { + if (!content) return ''; + + // Determine if we should use AsciiDoc + let useAsciiDoc = false; + if (typeof kindOrType === 'number') { + // Nostr event kind: 30041 or 30818 for AsciiDoc + useAsciiDoc = kindOrType === 30041 || kindOrType === 30818; + } else if (typeof kindOrType === 'string') { + // Content type string: 'asciidoc' for AsciiDoc + useAsciiDoc = kindOrType === 'asciidoc'; + } + + if (useAsciiDoc) { + // Use AsciiDoc parser + const asciidoctor = (await import('asciidoctor')).default(); + const result = asciidoctor.convert(content, { + safe: 'safe', + attributes: { + 'source-highlighter': 'highlight.js' + } + }); + return typeof result === 'string' ? result : String(result); + } else if (typeof kindOrType === 'string' && kindOrType === 'text') { + // Plain text - escape HTML + return content + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, ''); + } else { + // Use Markdown parser (default) + 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, + highlight: function (str: string, lang: string): string { + if (lang && hljs.getLanguage(lang)) { + try { + return '' + + hljs.highlight(str, { language: lang }).value + + ''; + } catch (err) { + // Fallback to escaped HTML if highlighting fails + } + } + return '' + md.utils.escapeHtml(str) + ''; + } + }); + + let rendered = md.render(content); + + // Add IDs to headings for anchor links (like DocsViewer does) + rendered = rendered.replace(/(.*?)<\/h[1-6]>/g, (match, level, text) => { + const textContent = text.replace(/<[^>]*>/g, '').trim(); + const slug = textContent + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + return `${text}`; + }); + + return rendered; + } +} diff --git a/src/routes/repos/[npub]/[repo]/utils/discussion-utils.ts b/src/routes/repos/[npub]/[repo]/utils/discussion-utils.ts index 334f3cf..abf6e19 100644 --- a/src/routes/repos/[npub]/[repo]/utils/discussion-utils.ts +++ b/src/routes/repos/[npub]/[repo]/utils/discussion-utils.ts @@ -47,3 +47,33 @@ export function getReferencedEventFromDiscussion( } return undefined; } + +/** + * Count all replies recursively + */ +export function countAllReplies(comments: Array<{ replies?: Array }> | 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; +} + +/** + * Toggle thread expansion + */ +export function toggleThread( + threadId: string, + expandedThreads: Set +): void { + if (expandedThreads.has(threadId)) { + expandedThreads.delete(threadId); + } else { + expandedThreads.add(threadId); + } +} diff --git a/src/routes/repos/[npub]/[repo]/utils/file-handlers.ts b/src/routes/repos/[npub]/[repo]/utils/file-handlers.ts new file mode 100644 index 0000000..add8567 --- /dev/null +++ b/src/routes/repos/[npub]/[repo]/utils/file-handlers.ts @@ -0,0 +1,134 @@ +/** + * File handler utilities + * UI interaction handlers for file operations + */ + +import type { RepoState } from '../stores/repo-state.js'; +import { getMimeType } from './file-helpers.js'; + +/** + * Handle content change in file editor + */ +export function handleContentChange(value: string, state: RepoState): void { + state.files.editedContent = value; + state.files.hasChanges = value !== state.files.content; +} + +/** + * Handle file click (directory or file) + */ +export function handleFileClick( + file: { name: string; path: string; type: 'file' | 'directory' }, + state: RepoState, + callbacks: { + loadFiles: (path: string) => Promise; + loadFile: (path: string) => Promise; + } +): void { + if (file.type === 'directory') { + state.files.pathStack.push(state.files.currentPath); + callbacks.loadFiles(file.path); + } else { + callbacks.loadFile(file.path); + // On mobile, switch to file viewer when a file is clicked + if (typeof window !== 'undefined' && window.innerWidth <= 768) { + state.ui.showFileListOnMobile = false; + } + } +} + +/** + * Copy file content to clipboard + */ +export async function copyFileContent( + state: RepoState, + event?: Event +): Promise { + if (!state.files.content || state.preview.copying) return; + + state.preview.copying = true; + try { + await navigator.clipboard.writeText(state.files.content); + // Show temporary feedback + const button = event?.target as HTMLElement; + if (button) { + const originalTitle = button.getAttribute('title') || ''; + button.setAttribute('title', 'Copied!'); + setTimeout(() => { + button.setAttribute('title', originalTitle); + }, 2000); + } + } catch (err) { + console.error('Failed to copy file content:', err); + alert('Failed to copy file content to clipboard'); + } finally { + state.preview.copying = false; + } +} + +/** + * Download file + */ +export function downloadFile(state: RepoState): void { + if (!state.files.content || !state.files.currentFile) return; + + try { + // Determine MIME type based on file extension + const ext = state.files.currentFile.split('.').pop()?.toLowerCase() || ''; + const mimeType = getMimeType(ext); + + const blob = new Blob([state.files.content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = state.files.currentFile.split('/').pop() || 'file'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (err) { + console.error('Failed to download file:', err); + alert('Failed to download file'); + } +} + +/** + * Handle back navigation in file browser + */ +export function handleBack( + state: RepoState, + callbacks: { + loadFiles: (path: string) => Promise; + } +): void { + if (state.files.pathStack.length > 0) { + const parentPath = state.files.pathStack.pop() || ''; + callbacks.loadFiles(parentPath); + } else { + callbacks.loadFiles(''); + } +} + +/** + * Toggle word wrap + */ +export async function toggleWordWrap( + state: RepoState, + callbacks: { + applySyntaxHighlighting: (content: string, ext: string) => Promise; + } +): Promise { + state.ui.wordWrap = !state.ui.wordWrap; + console.log('Word wrap toggled:', state.ui.wordWrap); + // Force DOM update by accessing the element + await new Promise(resolve => { + requestAnimationFrame(() => { + requestAnimationFrame(resolve); + }); + }); + // Re-apply syntax highlighting to refresh the display + if (state.files.currentFile && state.files.content) { + const ext = state.files.currentFile.split('.').pop() || ''; + await callbacks.applySyntaxHighlighting(state.files.content, ext); + } +} diff --git a/src/routes/repos/[npub]/[repo]/utils/repo-handlers.ts b/src/routes/repos/[npub]/[repo]/utils/repo-handlers.ts new file mode 100644 index 0000000..d448638 --- /dev/null +++ b/src/routes/repos/[npub]/[repo]/utils/repo-handlers.ts @@ -0,0 +1,78 @@ +/** + * Repository handler utilities + * UI interaction handlers for repository operations + */ + +import type { RepoState } from '../stores/repo-state.js'; +import type { Page } from '@sveltejs/kit'; + +/** + * Copy clone URL to clipboard + */ +export async function copyCloneUrl( + state: RepoState, + pageData: { gitDomain?: string } | undefined, + pageUrl: Page['url'] | undefined +): Promise { + if (state.clone.copyingUrl) return; + + state.clone.copyingUrl = true; + try { + // Guard against SSR + if (typeof window === 'undefined') return; + if (!pageUrl) { + return; + } + + // Use gitDomain from page data if available, otherwise use current URL host + // gitDomain is set from GIT_DOMAIN env var and is the actual production domain + let host: string; + let protocol: string; + + if (pageData?.gitDomain) { + const gitDomain = pageData.gitDomain; + // Check if gitDomain is localhost - if so, we should use the actual current domain + const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1'); + + if (isLocalhost) { + // During development, use the actual current domain from the URL + host = pageUrl.host; + protocol = pageUrl.protocol.slice(0, -1); // Remove trailing ":" + } else { + // Use the configured git domain (production) + host = gitDomain; + protocol = 'https'; // Production domains should use HTTPS + } + } else { + // Fallback to current URL + host = pageUrl.host; + protocol = pageUrl.protocol.slice(0, -1); + } + + // Use /api/git/ format for better compatibility with commit signing hook + const cloneUrl = `${protocol}://${host}/api/git/${state.npub}/${state.repo}.git`; + const cloneCommand = `git clone ${cloneUrl}`; + + // Try to use the Clipboard API + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(cloneCommand); + alert(`Clone command copied to clipboard!\n\n${cloneCommand}`); + } else { + // Fallback: create a temporary textarea + const textarea = document.createElement('textarea'); + textarea.value = cloneCommand; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + alert(`Clone command copied to clipboard!\n\n${cloneCommand}`); + } + } catch (err) { + console.error('Failed to copy clone command:', err); + alert('Failed to copy clone command to clipboard'); + } finally { + state.clone.copyingUrl = false; + } +}
No custom documentation found. Displaying the ReadMe, instead.
No documentation files found
' + - hljs.highlight(str, { language: lang }).value + - '
' + md.utils.escapeHtml(str) + '
{JSON.stringify(selectedItem, null, 2)}
Select an item to view details
- Clone URL: {state.verification.selectedCloneUrl} + Clone URL: {state.verification.selectedCloneUrl}
{state.verification.selectedCloneUrl}
- This will commit the repository announcement event to nostr/repo-events.jsonl in the default branch, which verifies that you control this repository. + This will commit the repository announcement event to nostr/repo-events.jsonl in the default branch, which verifies that you control this repository.
nostr/repo-events.jsonl
- The announcement event should be saved to nostr/repo-events.jsonl in your repository. + The announcement event should be saved to nostr/repo-events.jsonl in your repository. You can download the announcement event JSON below for reference.
' + + hljs.highlight(str, { language: lang }).value + + '