diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index f0a49ee..cbb69fb 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -90,3 +90,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772010107,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix build"]],"content":"Signed commit: fix build","id":"968af17f95f1ba0cf6a4d1f04ce108a6e4eb4ec3a4f72ca6a9d2529dacb92811","sig":"1891b6131effda70ec76577efadd9ea7374ebcbd4d738d0b0650e7dce46c3e7253eccb4b8455690297b63b7c30f61a0c7dcc1af0147b2f5a631bbd91c517c32b"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772011169,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","prevent zombie git processes"]],"content":"Signed commit: prevent zombie git processes","id":"fd370d2613105f16b0cfdd55b33f50c5b724ecef272109036a7cce5477da29bc","sig":"1d3cb4392f722b1b356247bde64691576d41fdb697e8dfe62d5e7ecd5ad8ea35757da2d56db310a2005e4b5528013aa1205256e37fc230f024d3b5a2e26735bf"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772087425,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactoring 1"]],"content":"Signed commit: refactoring 1","id":"533e9f7acbdd4dc16dbe304245469d57d8d37f0c0cce53b60d99719e2acf4502","sig":"0fad2d7c44f086ceb06ce40ea8cea2d4d002ebe8caec7d78e83483348b1404cfb6256d8d3796ebd9ae6f7866a431ec4a1abe84e417d3e238b9b554b4a32481e4"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772090269,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactoring 2"]],"content":"Signed commit: refactoring 2","id":"9375bfe35e0574bc722cad243c22fdf374dcc9016f91f358ff9ddf1d0a03bb50","sig":"10fbbcbc7cab48dfd2340f0c9eceafe558d893789e4838cbe26493e5c339f7a1f015d1cc4af8bfa51d57e9a9da94bb1bb44841305d5ce7cf92db9938985d0459"} diff --git a/scripts/migrate-repo-state.js b/scripts/migrate-repo-state.js new file mode 100755 index 0000000..2a02344 --- /dev/null +++ b/scripts/migrate-repo-state.js @@ -0,0 +1,293 @@ +#!/usr/bin/env node +/** + * Migration script to update state references in +page.svelte + * This helps automate the bulk replacement of old state variable references + * with the new nested state structure. + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const rootDir = join(__dirname, '..'); +const targetFile = join(rootDir, 'src/routes/repos/[npub]/[repo]/+page.svelte'); + +// Migration mappings: old reference -> new reference +const migrations = [ + // Loading states + { pattern: /\bloading\b/g, replacement: 'state.loading.main' }, + { pattern: /\bloadingReadme\b/g, replacement: 'state.loading.readme' }, + { pattern: /\bloadingCommits\b/g, replacement: 'state.loading.commits' }, + { pattern: /\bloadingIssues\b/g, replacement: 'state.loading.issues' }, + { pattern: /\bloadingIssueReplies\b/g, replacement: 'state.loading.issueReplies' }, + { pattern: /\bloadingPRs\b/g, replacement: 'state.loading.prs' }, + { pattern: /\bloadingPatches\b/g, replacement: 'state.loading.patches' }, + { pattern: /\bloadingPatchHighlights\b/g, replacement: 'state.loading.patchHighlights' }, + { pattern: /\bloadingDocs\b/g, replacement: 'state.loading.docs' }, + { pattern: /\bloadingDiscussions\b/g, replacement: 'state.loading.discussions' }, + { pattern: /\bloadingReleases\b/g, replacement: 'state.loading.releases' }, + { pattern: /\bloadingCodeSearch\b/g, replacement: 'state.loading.codeSearch' }, + { pattern: /\bloadingBookmark\b/g, replacement: 'state.loading.bookmark' }, + { pattern: /\bloadingMaintainerStatus\b/g, replacement: 'state.loading.maintainerStatus' }, + { pattern: /\bloadingMaintainers\b/g, replacement: 'state.loading.maintainers' }, + { pattern: /\bloadingReachability\b/g, replacement: 'state.loading.reachability' }, + { pattern: /\bloadingVerification\b/g, replacement: 'state.loading.verification' }, + + // UI state + { pattern: /\bactiveTab\b/g, replacement: 'state.ui.activeTab' }, + { pattern: /\bshowRepoMenu\b/g, replacement: 'state.ui.showRepoMenu' }, + { pattern: /\bshowFileListOnMobile\b/g, replacement: 'state.ui.showFileListOnMobile' }, + { pattern: /\bshowLeftPanelOnMobile\b/g, replacement: 'state.ui.showLeftPanelOnMobile' }, + { pattern: /\bwordWrap\b/g, replacement: 'state.ui.wordWrap' }, + { pattern: /\bexpandedThreads\b/g, replacement: 'state.ui.expandedThreads' }, + + // User state + { pattern: /\buserPubkey\b/g, replacement: 'state.user.pubkey' }, + { pattern: /\buserPubkeyHex\b/g, replacement: 'state.user.pubkeyHex' }, + + // Files + { pattern: /\bfiles\b/g, replacement: 'state.files.list' }, + { pattern: /\bcurrentPath\b/g, replacement: 'state.files.currentPath' }, + { pattern: /\bcurrentFile\b/g, replacement: 'state.files.currentFile' }, + { pattern: /\bfileContent\b/g, replacement: 'state.files.content' }, + { pattern: /\bfileLanguage\b/g, replacement: 'state.files.language' }, + { pattern: /\beditedContent\b/g, replacement: 'state.files.editedContent' }, + { pattern: /\bhasChanges\b/g, replacement: 'state.files.hasChanges' }, + { pattern: /\bpathStack\b/g, replacement: 'state.files.pathStack' }, + + // Preview + { pattern: /\breadmeContent\b/g, replacement: 'state.preview.readme.content' }, + { pattern: /\breadmePath\b/g, replacement: 'state.preview.readme.path' }, + { pattern: /\breadmeIsMarkdown\b/g, replacement: 'state.preview.readme.isMarkdown' }, + { pattern: /\breadmeHtml\b/g, replacement: 'state.preview.readme.html' }, + { pattern: /\bhighlightedFileContent\b/g, replacement: 'state.preview.file.highlightedContent' }, + { pattern: /\bfileHtml\b/g, replacement: 'state.preview.file.html' }, + { pattern: /\bshowFilePreview\b/g, replacement: 'state.preview.file.showPreview' }, + { pattern: /\bcopyingFile\b/g, replacement: 'state.preview.copying' }, + { pattern: /\bisImageFile\b/g, replacement: 'state.preview.file.isImage' }, + { pattern: /\bimageUrl\b/g, replacement: 'state.preview.file.imageUrl' }, + + // Git + { pattern: /\bbranches\b/g, replacement: 'state.git.branches' }, + { pattern: /\bcurrentBranch\b/g, replacement: 'state.git.currentBranch' }, + { pattern: /\bdefaultBranch\b/g, replacement: 'state.git.defaultBranch' }, + { pattern: /\bcommits\b/g, replacement: 'state.git.commits' }, + { pattern: /\bselectedCommit\b/g, replacement: 'state.git.selectedCommit' }, + { pattern: /\bshowDiff\b/g, replacement: 'state.git.showDiff' }, + { pattern: /\bdiffData\b/g, replacement: 'state.git.diffData' }, + { pattern: /\bverifyingCommits\b/g, replacement: 'state.git.verifyingCommits' }, + { pattern: /\btags\b/g, replacement: 'state.git.tags' }, + { pattern: /\bselectedTag\b/g, replacement: 'state.git.selectedTag' }, + + // Forms + { pattern: /\bnewFileName\b/g, replacement: 'state.forms.file.fileName' }, + { pattern: /\bnewFileContent\b/g, replacement: 'state.forms.file.content' }, + { pattern: /\bnewBranchName\b/g, replacement: 'state.forms.branch.name' }, + { pattern: /\bnewBranchFrom\b/g, replacement: 'state.forms.branch.from' }, + { pattern: /\bdefaultBranchName\b/g, replacement: 'state.forms.branch.defaultName' }, + { pattern: /\bnewTagName\b/g, replacement: 'state.forms.tag.name' }, + { pattern: /\bnewTagMessage\b/g, replacement: 'state.forms.tag.message' }, + { pattern: /\bnewTagRef\b/g, replacement: 'state.forms.tag.ref' }, + { pattern: /\bcommitMessage\b/g, replacement: 'state.forms.commit.message' }, + { pattern: /\bnewIssueSubject\b/g, replacement: 'state.forms.issue.subject' }, + { pattern: /\bnewIssueContent\b/g, replacement: 'state.forms.issue.content' }, + { pattern: /\bnewIssueLabels\b/g, replacement: 'state.forms.issue.labels' }, + { pattern: /\bnewPRSubject\b/g, replacement: 'state.forms.pr.subject' }, + { pattern: /\bnewPRContent\b/g, replacement: 'state.forms.pr.content' }, + { pattern: /\bnewPRCommitId\b/g, replacement: 'state.forms.pr.commitId' }, + { pattern: /\bnewPRBranchName\b/g, replacement: 'state.forms.pr.branchName' }, + { pattern: /\bnewPRLabels\b/g, replacement: 'state.forms.pr.labels' }, + { pattern: /\bnewPatchContent\b/g, replacement: 'state.forms.patch.content' }, + { pattern: /\bnewPatchSubject\b/g, replacement: 'state.forms.patch.subject' }, + { pattern: /\bnewReleaseTagName\b/g, replacement: 'state.forms.release.tagName' }, + { pattern: /\bnewReleaseTagHash\b/g, replacement: 'state.forms.release.tagHash' }, + { pattern: /\bnewReleaseNotes\b/g, replacement: 'state.forms.release.notes' }, + { pattern: /\bnewReleaseIsDraft\b/g, replacement: 'state.forms.release.isDraft' }, + { pattern: /\bnewReleaseIsPrerelease\b/g, replacement: 'state.forms.release.isPrerelease' }, + { pattern: /\bnewThreadTitle\b/g, replacement: 'state.forms.discussion.threadTitle' }, + { pattern: /\bnewThreadContent\b/g, replacement: 'state.forms.discussion.threadContent' }, + { pattern: /\breplyContent\b/g, replacement: 'state.forms.discussion.replyContent' }, + { pattern: /\bselectedPatchText\b/g, replacement: 'state.forms.patchHighlight.text' }, + { pattern: /\bselectedPatchStartLine\b/g, replacement: 'state.forms.patchHighlight.startLine' }, + { pattern: /\bselectedPatchEndLine\b/g, replacement: 'state.forms.patchHighlight.endLine' }, + { pattern: /\bselectedPatchStartPos\b/g, replacement: 'state.forms.patchHighlight.startPos' }, + { pattern: /\bselectedPatchEndPos\b/g, replacement: 'state.forms.patchHighlight.endPos' }, + { pattern: /\bpatchHighlightComment\b/g, replacement: 'state.forms.patchHighlight.comment' }, + { pattern: /\bpatchCommentContent\b/g, replacement: 'state.forms.patchComment.content' }, + { pattern: /\breplyingToPatchComment\b/g, replacement: 'state.forms.patchComment.replyingTo' }, + + // Dialogs + { pattern: /\bshowCreateFileDialog\b/g, replacement: "state.openDialog === 'createFile'" }, + { pattern: /\bshowCreateBranchDialog\b/g, replacement: "state.openDialog === 'createBranch'" }, + { pattern: /\bshowCreateTagDialog\b/g, replacement: "state.openDialog === 'createTag'" }, + { pattern: /\bshowCommitDialog\b/g, replacement: "state.openDialog === 'commit'" }, + { pattern: /\bshowCreateIssueDialog\b/g, replacement: "state.openDialog === 'createIssue'" }, + { pattern: /\bshowCreatePRDialog\b/g, replacement: "state.openDialog === 'createPR'" }, + { pattern: /\bshowCreatePatchDialog\b/g, replacement: "state.openDialog === 'createPatch'" }, + { pattern: /\bshowCreateReleaseDialog\b/g, replacement: "state.openDialog === 'createRelease'" }, + { pattern: /\bshowCreateThreadDialog\b/g, replacement: "state.openDialog === 'createThread'" }, + { pattern: /\bshowReplyDialog\b/g, replacement: "state.openDialog === 'reply'" }, + { pattern: /\bshowVerificationDialog\b/g, replacement: "state.openDialog === 'verification'" }, + { pattern: /\bshowCloneUrlVerificationDialog\b/g, replacement: "state.openDialog === 'cloneUrlVerification'" }, + { pattern: /\bshowPatchHighlightDialog\b/g, replacement: "state.openDialog === 'patchHighlight'" }, + { pattern: /\bshowPatchCommentDialog\b/g, replacement: "state.openDialog === 'patchComment'" }, + + // Selected items + { pattern: /\bselectedIssue\b/g, replacement: 'state.selected.issue' }, + { pattern: /\bselectedPR\b/g, replacement: 'state.selected.pr' }, + { pattern: /\bselectedPatch\b/g, replacement: 'state.selected.patch' }, + { pattern: /\bselectedDiscussion\b/g, replacement: 'state.selected.discussion' }, + + // Creating flags + { pattern: /\bcreatingPatch\b/g, replacement: 'state.creating.patch' }, + { pattern: /\bcreatingThread\b/g, replacement: 'state.creating.thread' }, + { pattern: /\bcreatingReply\b/g, replacement: 'state.creating.reply' }, + { pattern: /\bcreatingRelease\b/g, replacement: 'state.creating.release' }, + { pattern: /\bcreatingPatchHighlight\b/g, replacement: 'state.creating.patchHighlight' }, + { pattern: /\bcreatingPatchComment\b/g, replacement: 'state.creating.patchComment' }, + { pattern: /\bdeletingAnnouncement\b/g, replacement: 'state.creating.announcement' }, + + // Maintainers + { pattern: /\bisMaintainer\b/g, replacement: 'state.maintainers.isMaintainer' }, + { pattern: /\ballMaintainers\b/g, replacement: 'state.maintainers.all' }, + { pattern: /\bmaintainersLoaded\b/g, replacement: 'state.maintainers.loaded' }, + { pattern: /\bmaintainersEffectRan\b/g, replacement: 'state.maintainers.effectRan' }, + { pattern: /\blastRepoKey\b/g, replacement: 'state.maintainers.lastRepoKey' }, + + // Clone + { pattern: /\bisRepoCloned\b/g, replacement: 'state.clone.isCloned' }, + { pattern: /\bcheckingCloneStatus\b/g, replacement: 'state.clone.checking' }, + { pattern: /\bcloning\b/g, replacement: 'state.clone.cloning' }, + { pattern: /\bcopyingCloneUrl\b/g, replacement: 'state.clone.copyingUrl' }, + { pattern: /\bapiFallbackAvailable\b/g, replacement: 'state.clone.apiFallbackAvailable' }, + { pattern: /\bcloneUrlsExpanded\b/g, replacement: 'state.clone.urlsExpanded' }, + { pattern: /\bshowAllCloneUrls\b/g, replacement: 'state.clone.showAllUrls' }, + { pattern: /\bcloneUrlReachability\b/g, replacement: 'state.clone.reachability' }, + { pattern: /\bcheckingReachability\b/g, replacement: 'state.clone.checkingReachability' }, + + // Verification + { pattern: /\bverificationStatus\b/g, replacement: 'state.verification.status' }, + { pattern: /\bverificationFileContent\b/g, replacement: 'state.verification.fileContent' }, + { pattern: /\bverifyingCloneUrl\b/g, replacement: 'state.verification.selectedCloneUrl' }, + { pattern: /\bselectedCloneUrlForVerification\b/g, replacement: 'state.verification.selectedCloneUrl' }, + + // Docs + { pattern: /\bdocumentationContent\b/g, replacement: 'state.docs.content' }, + { pattern: /\bdocumentationHtml\b/g, replacement: 'state.docs.html' }, + { pattern: /\bdocumentationKind\b/g, replacement: 'state.docs.kind' }, + + // Code search + { pattern: /\bcodeSearchQuery\b/g, replacement: 'state.codeSearch.query' }, + { pattern: /\bcodeSearchResults\b/g, replacement: 'state.codeSearch.results' }, + { pattern: /\bcodeSearchScope\b/g, replacement: 'state.codeSearch.scope' }, + + // Fork/Bookmark + { pattern: /\bforkInfo\b/g, replacement: 'state.fork.info' }, + { pattern: /\bforking\b/g, replacement: 'state.fork.forking' }, + { pattern: /\bisBookmarked\b/g, replacement: 'state.bookmark.isBookmarked' }, + + // Metadata + { pattern: /\brepoAddress\b/g, replacement: 'state.metadata.address' }, + { pattern: /\brepoImage\b/g, replacement: 'state.metadata.image' }, + { pattern: /\brepoBanner\b/g, replacement: 'state.metadata.banner' }, + { pattern: /\brepoOwnerPubkeyState\b/g, replacement: 'state.metadata.ownerPubkey' }, + { pattern: /\breadmeAutoLoadAttempted\b/g, replacement: 'state.metadata.readmeAutoLoadAttempted' }, + + // Discussion + { pattern: /\breplyingToThread\b/g, replacement: 'state.discussion.replyingToThread' }, + { pattern: /\breplyingToComment\b/g, replacement: 'state.discussion.replyingToComment' }, + { pattern: /\bdiscussionEvents\b/g, replacement: 'state.discussion.events' }, + { pattern: /\bnostrLinkEvents\b/g, replacement: 'state.discussion.nostrLinkEvents' }, + { pattern: /\bnostrLinkProfiles\b/g, replacement: 'state.discussion.nostrLinkProfiles' }, + + // Other + { pattern: /\bpatchEditor\b/g, replacement: 'state.patchEditor' }, + { pattern: /\bsaving\b/g, replacement: 'state.saving' }, + { pattern: /\bisMounted\b/g, replacement: 'state.isMounted' }, + { pattern: /\brepoNotFound\b/g, replacement: 'state.repoNotFound' }, + { pattern: /\berror\b/g, replacement: 'state.error' }, + + // Status updates + { pattern: /\bupdatingIssueStatus\b/g, replacement: 'state.statusUpdates.issue' }, + { pattern: /\bupdatingPatchStatus\b/g, replacement: 'state.statusUpdates.patch' }, + + // Data collections (keep as-is but ensure they're accessed via state) + { pattern: /\bissues\b/g, replacement: 'state.issues' }, + { pattern: /\bissueReplies\b/g, replacement: 'state.issueReplies' }, + { pattern: /\bprs\b/g, replacement: 'state.prs' }, + { pattern: /\bpatches\b/g, replacement: 'state.patches' }, + { pattern: /\bpatchHighlights\b/g, replacement: 'state.patchHighlights' }, + { pattern: /\bpatchComments\b/g, replacement: 'state.patchComments' }, + { pattern: /\bdiscussions\b/g, replacement: 'state.discussions' }, + { pattern: /\breleases\b/g, replacement: 'state.releases' }, +]; + +// Special cases that need context-aware replacement +const contextAwareReplacements = [ + // Dialog assignments + { + pattern: /(showCreateFileDialog|showCreateBranchDialog|showCreateTagDialog|showCommitDialog|showCreateIssueDialog|showCreatePRDialog|showCreatePatchDialog|showCreateReleaseDialog|showCreateThreadDialog|showReplyDialog|showVerificationDialog|showCloneUrlVerificationDialog|showPatchHighlightDialog|showPatchCommentDialog)\s*=\s*(true|false)/g, + replacement: (match, varName, value) => { + const dialogMap = { + showCreateFileDialog: 'createFile', + showCreateBranchDialog: 'createBranch', + showCreateTagDialog: 'createTag', + showCommitDialog: 'commit', + showCreateIssueDialog: 'createIssue', + showCreatePRDialog: 'createPR', + showCreatePatchDialog: 'createPatch', + showCreateReleaseDialog: 'createRelease', + showCreateThreadDialog: 'createThread', + showReplyDialog: 'reply', + showVerificationDialog: 'verification', + showCloneUrlVerificationDialog: 'cloneUrlVerification', + showPatchHighlightDialog: 'patchHighlight', + showPatchCommentDialog: 'patchComment' + }; + const dialogType = dialogMap[varName]; + return value === 'true' ? `state.openDialog = '${dialogType}'` : `state.openDialog = null`; + } + } +]; + +function migrateFile(content) { + let migrated = content; + + // Apply simple replacements + for (const { pattern, replacement } of migrations) { + migrated = migrated.replace(pattern, replacement); + } + + // Apply context-aware replacements + for (const { pattern, replacement } of contextAwareReplacements) { + if (typeof replacement === 'function') { + migrated = migrated.replace(pattern, replacement); + } else { + migrated = migrated.replace(pattern, replacement); + } + } + + return migrated; +} + +// Read file +console.log(`Reading ${targetFile}...`); +let content = readFileSync(targetFile, 'utf-8'); + +// Backup original +const backupFile = targetFile + '.backup'; +writeFileSync(backupFile, content, 'utf-8'); +console.log(`Backup created: ${backupFile}`); + +// Migrate +console.log('Applying migrations...'); +const migrated = migrateFile(content); + +// Write migrated file +writeFileSync(targetFile, migrated, 'utf-8'); +console.log(`Migration complete!`); +console.log(`\n⚠️ Please review the changes carefully.`); +console.log(` Some replacements may need manual adjustment.`); +console.log(` Backup saved to: ${backupFile}`); diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 8dee30b..db83f41 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -34,58 +34,49 @@ 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'; + import { createRepoState, type RepoState } from './stores/repo-state.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; - }>({}); + // Consolidated state - all state variables in one object + let state = $state(createRepoState()); + + // Local variables for component-specific state + let announcementEventId: string | null = null; + let applying: Record = {}; // Update pageData from $page when available (client-side) $effect(() => { - if (typeof window === 'undefined' || !isMounted) return; + if (typeof window === 'undefined' || !state.isMounted) return; try { - const data = $page.data as typeof pageData; - if (data && isMounted) { - pageData = data || {}; + const data = $page.data as typeof state.pageData; + if (data && state.isMounted) { + state.pageData = data || {}; } } catch (err) { // Ignore SSR errors and errors during destruction - if (isMounted) { + if (state.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; + if (typeof window === 'undefined' || !state.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; + if (params && state.isMounted) { + if (params.npub && params.npub !== state.npub) state.npub = params.npub; + if (params.repo && params.repo !== state.repo) state.repo = params.repo; } } catch { // If $page.params fails, try to parse from URL path - if (!isMounted) return; + if (!state.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]; + if (pathParts[0] === 'repos' && pathParts[1] && pathParts[2] && state.isMounted) { + state.npub = pathParts[1]; + state.repo = pathParts[2]; } } } catch { @@ -95,8 +86,8 @@ }); // 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 repoAnnouncement = $derived(state.pageData.announcement); + const repoName = $derived(repoAnnouncement?.tags.find((t: string[]) => t[0] === 'name')?.[1] || state.repo); const repoDescription = $derived(repoAnnouncement?.tags.find((t: string[]) => t[0] === 'description')?.[1] || ''); const repoCloneUrls = $derived(repoAnnouncement?.tags .filter((t: string[]) => t[0] === 'clone') @@ -122,8 +113,8 @@ const pageUrl = $derived.by(() => { try { // First try pageData (safest) - if (pageData && typeof pageData === 'object' && pageData.repoUrl) { - const url = pageData.repoUrl; + if (state.pageData && typeof state.pageData === 'object' && state.pageData.repoUrl) { + const url = state.pageData.repoUrl; if (typeof url === 'string' && url.trim()) { return url; } @@ -155,7 +146,7 @@ // Safe Twitter card type - avoid IIFE in head during SSR const twitterCardType = $derived.by(() => { try { - const banner = (pageData?.banner || repoBanner) || (pageData?.image || repoImage); + const banner = (state.pageData?.banner || state.metadata.banner) || (state.pageData?.image || state.metadata.image); if (banner && typeof banner === 'string' && banner.trim()) { return "summary_large_image"; } @@ -166,41 +157,15 @@ }); - let loading = $state(true); - let error = $state(null); - let repoNotFound = $state(false); // Track if repository doesn't exist - let files = $state>([]); - let currentPath = $state(''); - let currentFile = $state(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>([]); - let currentBranch = $state(null); - let defaultBranch = $state(null); - let commitMessage = $state(''); - let userPubkey = $state(null); - let userPubkeyHex = $state(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(updateFn: () => T): T | null { - if (!isMounted) return null; + if (!state.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); + if (state.isMounted) { + console.warn('State update state.error (component may be destroying):', err); } return null; } @@ -212,47 +177,43 @@ // Auto-save let autoSaveInterval: ReturnType | null = null; - // Load maintainers when page data changes (only once per repo, with guard) - let lastRepoKey = $state(null); - let maintainersEffectRan = $state(false); - $effect(() => { // Guard against SSR and component destruction - if (typeof window === 'undefined' || !isMounted) return; + if (typeof window === 'undefined' || !state.isMounted) return; try { - const data = $page.data as typeof pageData; - if (!data || !isMounted) return; + const data = $page.data as typeof state.pageData; + if (!data || !state.isMounted) return; - const currentRepoKey = `${npub}/${repo}`; + const currentRepoKey = `${state.npub}/${state.repo}`; // Reset flags if repo changed - if (currentRepoKey !== lastRepoKey && isMounted) { - maintainersLoaded = false; - maintainersEffectRan = false; - lastRepoKey = currentRepoKey; + if (currentRepoKey !== state.maintainers.lastRepoKey && state.isMounted) { + state.maintainers.loaded = false; + state.maintainers.effectRan = false; + state.maintainers.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 + // 3. We're not currently state.loading.main // 4. Component is still mounted - if (isMounted && + if (state.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 + !state.maintainers.effectRan && + !state.loading.maintainers) { + state.maintainers.effectRan = true; // Mark as ran to prevent re-running + state.maintainers.loaded = true; // Set flag before state.loading.main to prevent concurrent calls loadAllMaintainers().catch(err => { - if (!isMounted) return; - maintainersLoaded = false; // Reset on error so we can retry - maintainersEffectRan = false; // Allow retry + if (!state.isMounted) return; + state.maintainers.loaded = false; // Reset on state.error so we can retry + state.maintainers.effectRan = false; // Allow retry console.warn('Failed to load maintainers:', err); }); } } catch (err) { // Ignore SSR errors and errors during destruction - if (isMounted) { + if (state.isMounted) { console.warn('Maintainers effect error:', err); } } @@ -260,10 +221,10 @@ // Watch for auto-save setting changes $effect(() => { - if (!isMounted) return; + if (!state.isMounted) return; // Check auto-save setting and update interval (async, but don't await) settingsStore.getSettings().then(settings => { - if (!isMounted) return; + if (!state.isMounted) return; if (settings.autoSave && !autoSaveInterval) { // Auto-save was enabled, set it up setupAutoSave(); @@ -275,7 +236,7 @@ } } }).catch(err => { - if (isMounted) { + if (state.isMounted) { console.warn('Failed to check auto-save setting:', err); } }); @@ -283,174 +244,110 @@ // Sync with userStore $effect(() => { - if (!isMounted) return; + if (!state.isMounted) return; try { const currentUser = $userStore; - if (!currentUser || !isMounted) return; + if (!currentUser || !state.isMounted) return; - const wasLoggedIn = userPubkey !== null || userPubkeyHex !== null; + const wasLoggedIn = state.user.pubkey !== null || state.user.pubkeyHex !== null; - if (currentUser.userPubkey && currentUser.userPubkeyHex && isMounted) { - const wasDifferent = userPubkey !== currentUser.userPubkey || userPubkeyHex !== currentUser.userPubkeyHex; - userPubkey = currentUser.userPubkey; - userPubkeyHex = currentUser.userPubkeyHex; + if (currentUser.userPubkey && currentUser.userPubkeyHex && state.isMounted) { + const wasDifferent = state.user.pubkey !== currentUser.userPubkey || state.user.pubkeyHex !== currentUser.userPubkeyHex; + state.user.pubkey = currentUser.userPubkey; + state.user.pubkeyHex = 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; + if (wasDifferent && state.isMounted) { + // Reset state.repoNotFound flag when user logs in, so we can retry state.loading.main + state.loading.repoNotFound = false; // Clear cached email and name when user changes cachedUserEmail = null; cachedUserName = null; - if (!isMounted) return; + if (!state.isMounted) return; checkMaintainerStatus().catch(err => { - if (isMounted) console.warn('Failed to reload maintainer status after login:', err); + if (state.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); + if (state.isMounted) console.warn('Failed to reload bookmark status after login:', err); }); // Reset flags to allow reload - maintainersLoaded = false; - maintainersEffectRan = false; - lastRepoKey = null; + state.maintainers.loaded = false; + state.maintainers.effectRan = false; + state.maintainers.lastRepoKey = null; loadAllMaintainers().catch(err => { - if (isMounted) console.warn('Failed to reload maintainers after login:', err); + if (state.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) { + if (state.isMounted) { checkCloneStatus(true).catch(err => { - if (isMounted) console.warn('Failed to recheck clone status after login:', err); + if (state.isMounted) console.warn('Failed to recheck clone status after login:', err); }); } }, 100); // Reload all repository data with the new user context - if (!loading && isMounted) { + if (!state.loading.main && state.isMounted) { loadBranches().catch(err => { - if (isMounted) console.warn('Failed to reload branches after login:', err); + if (state.isMounted) console.warn('Failed to reload state.git.branches after login:', err); }); loadFiles().catch(err => { - if (isMounted) console.warn('Failed to reload files after login:', err); + if (state.isMounted) console.warn('Failed to reload files after login:', err); }); loadReadme().catch(err => { - if (isMounted) console.warn('Failed to reload readme after login:', err); + if (state.isMounted) console.warn('Failed to reload readme after login:', err); }); loadTags().catch(err => { - if (isMounted) console.warn('Failed to reload tags after login:', err); + if (state.isMounted) console.warn('Failed to reload state.git.tags after login:', err); }); - // Reload discussions when user logs in (needs user context for relay selection) + // Reload state.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); + if (state.isMounted) console.warn('Failed to reload state.discussions after login:', err); }); } } - } else if (isMounted) { - userPubkey = null; - userPubkeyHex = null; + } else if (state.isMounted) { + state.user.pubkey = null; + state.user.pubkeyHex = 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) { + if (wasLoggedIn && state.isMounted) { checkMaintainerStatus().catch(err => { - if (isMounted) console.warn('Failed to reload maintainer status after logout:', err); + if (state.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); + if (state.isMounted) console.warn('Failed to reload bookmark status after logout:', err); }); // Reset flags to allow reload - maintainersLoaded = false; - maintainersEffectRan = false; - lastRepoKey = null; + state.maintainers.loaded = false; + state.maintainers.effectRan = false; + state.maintainers.lastRepoKey = null; loadAllMaintainers().catch(err => { - if (isMounted) console.warn('Failed to reload maintainers after logout:', err); + if (state.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) { + if (!state.loading.main && state.ui.activeTab === 'files' && state.isMounted) { loadFiles().catch(err => { - if (isMounted) console.warn('Failed to reload files after logout:', err); + if (state.isMounted) console.warn('Failed to reload files after logout:', err); }); } } } } catch (err) { // Ignore errors during destruction - if (isMounted) { + if (state.isMounted) { console.warn('User store sync error:', err); } } }); - // Navigation stack for directories - let pathStack = $state([]); - - // 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(null); - let defaultBranchName = $state('master'); // Default branch from settings - - // Commit history - let commits = $state>([]); - let loadingCommits = $state(false); - let selectedCommit = $state(null); - let showDiff = $state(false); - let diffData = $state>([]); - let verifyingCommits = $state>(new Set()); - - // Tags - let tags = $state>([]); - let selectedTag = $state(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>([]); - let loadingMaintainers = $state(false); - let maintainersLoaded = $state(false); // Guard to prevent repeated loads - - // Clone status - let isRepoCloned = $state(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); + 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(() => { @@ -458,27 +355,25 @@ }); }); // Re-apply syntax highlighting to refresh the display - if (currentFile && fileContent) { - const ext = currentFile.split('.').pop() || ''; - await applySyntaxHighlighting(fileContent, ext); + if (state.files.currentFile && state.files.content) { + const ext = state.files.currentFile.split('.').pop() || ''; + await applySyntaxHighlighting(state.files.content, ext); } } - let copyingCloneUrl = $state(false); - let apiFallbackAvailable = $state(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); + const needsClone = $derived(state.clone.isCloned === false); // Helper: Check if we can use API fallback for read-only operations - const canUseApiFallback = $derived(apiFallbackAvailable === true); + const canUseApiFallback = $derived(state.clone.apiFallbackAvailable === true); // Helper: Check if we have any way to view the repo (cloned or API fallback) - const canViewRepo = $derived(isRepoCloned === true || canUseApiFallback); + const canViewRepo = $derived(state.clone.isCloned === true || canUseApiFallback); const cloneTooltip = 'Please clone this repo to use this feature.'; // Copy clone URL to clipboard async function copyCloneUrl() { - if (copyingCloneUrl) return; + if (state.clone.copyingUrl) return; - copyingCloneUrl = true; + state.clone.copyingUrl = true; try { // Use the current page URL to get the correct host and port // This ensures we use the same domain/port the user is currently viewing @@ -493,7 +388,7 @@ 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 cloneUrl = `${protocol}://${host}/api/git/${state.npub}/${state.repo}.git`; const cloneCommand = `git clone ${cloneUrl}`; // Try to use the Clipboard API @@ -516,72 +411,31 @@ console.error('Failed to copy clone command:', err); alert('Failed to copy clone command to clipboard'); } finally { - copyingCloneUrl = false; + state.clone.copyingUrl = 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(null); - - // Clone URL verification dialog - let showCloneUrlVerificationDialog = $state(false); - let verifyingCloneUrl = $state(false); - let selectedCloneUrlForVerification = $state(null); - let loadingVerification = $state(false); - - // Deletion request - let deletingAnnouncement = $state(false); - let announcementEventId = $state(null); - - // Issues - let issues = $state>([]); - let loadingIssues = $state(false); - let showCreateIssueDialog = $state(false); - let newIssueSubject = $state(''); - let newIssueContent = $state(''); - let newIssueLabels = $state(['']); - let updatingIssueStatus = $state>({}); - let selectedIssue = $state(null); - let issueReplies = $state>([]); - let loadingIssueReplies = $state(false); - - // Pull Requests - let prs = $state>([]); - let loadingPRs = $state(false); - let showCreatePRDialog = $state(false); - let newPRSubject = $state(''); - let newPRContent = $state(''); - let newPRCommitId = $state(''); - let newPRBranchName = $state(''); - let newPRLabels = $state(['']); - let selectedPR = $state(null); + - // Tabs menu - defined after issues and prs + // Tabs menu - defined after state.issues and state.prs // Order: Files, Issues, PRs, Patches, Discussion, History, Tags, Code Search, Docs // Show tabs that require cloned repo when repo is cloned OR API fallback is available const tabs = $derived.by(() => { const allTabs = [ { id: 'files', label: 'Files', icon: '/icons/file-text.svg', requiresClone: true }, - { id: '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: 'state.issues', label: 'Issues', icon: '/icons/alert-circle.svg', requiresClone: false }, + { id: 'state.prs', label: 'Pull Requests', icon: '/icons/git-pull-request.svg', requiresClone: false }, + { id: 'state.patches', label: 'Patches', icon: '/icons/clipboard-list.svg', requiresClone: false }, + { id: 'state.discussions', label: 'Discussions', icon: '/icons/message-circle.svg', requiresClone: false }, { id: 'history', label: 'Commit History', icon: '/icons/git-commit.svg', requiresClone: true }, - { id: 'tags', label: 'Tags', icon: '/icons/tag.svg', requiresClone: true }, + { id: 'state.git.tags', label: 'Tags', icon: '/icons/tag.svg', requiresClone: true }, { id: 'code-search', label: 'Code Search', icon: '/icons/search.svg', requiresClone: true }, { id: 'docs', label: 'Docs', icon: '/icons/book.svg', requiresClone: false } ]; // Show all tabs if repo is cloned OR API fallback is available - // Otherwise, only show tabs that don't require cloning - if (isRepoCloned === false && !canUseApiFallback) { + // Otherwise, only show tabs that don't require state.clone.cloning + if (state.clone.isCloned === false && !canUseApiFallback) { return allTabs.filter(tab => !tab.requiresClone).map(({ requiresClone, ...tab }) => tab); } @@ -589,168 +443,20 @@ 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 + // Redirect to a valid tab if current tab requires state.clone.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; + if (!state.isMounted) return; + if (state.clone.isCloned === false && !canUseApiFallback && tabs.length > 0) { + const currentTab = tabs.find(t => t.id === state.ui.activeTab); + if (!currentTab && state.isMounted) { + // Current tab requires state.clone.cloning, switch to first available tab + state.ui.activeTab = tabs[0].id as typeof state.ui.activeTab; } } }); - // Patches - let patches = $state>([]); - let updatingPatchStatus = $state>({}); - let loadingPatches = $state(false); - let selectedPatch = $state(null); - let showCreatePatchDialog = $state(false); - let newPatchContent = $state(''); - let newPatchSubject = $state(''); - let creatingPatch = $state(false); - - // Patch highlights - let patchHighlights = $state; - [key: string]: unknown; - }>>([]); - let patchComments = $state>([]); - let loadingPatchHighlights = $state(false); - let selectedPatchText = $state(''); - let selectedPatchStartLine = $state(0); - let selectedPatchEndLine = $state(0); - let selectedPatchStartPos = $state(0); - let selectedPatchEndPos = $state(0); - let showPatchHighlightDialog = $state(false); - let patchHighlightComment = $state(''); - let creatingPatchHighlight = $state(false); - let patchEditor = $state(null); // CodeEditor component instance - let showPatchCommentDialog = $state(false); - let patchCommentContent = $state(''); - let creatingPatchComment = $state(false); - let replyingToPatchComment = $state(null); - const highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS); - // Documentation - let documentationContent = $state(null); - let documentationHtml = $state(null); - let documentationKind = $state(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>(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>([]); - 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>([]); - let loadingCodeSearch = $state(false); - let codeSearchScope = $state<'repo' | 'all'>('repo'); - - // Discussions - let selectedDiscussion = $state(null); - let discussions = $state; - }>; - }> - }>>([]); - let loadingDiscussions = $state(false); - - // Discussion events cache for reply/quote blurbs - let discussionEvents = $state>(new Map()); - - // Nostr link cache for embedded events and profiles - let nostrLinkEvents = $state>(new Map()); - let nostrLinkProfiles = $state>(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 }> = []; @@ -808,7 +514,7 @@ const decoded = nip19.decode(link.value.replace('nostr:', '')); if (decoded.type === 'npub') { npubs.push(link.value); - nostrLinkProfiles.set(link.value, decoded.data as string); + state.discussion.nostrLinkProfiles.set(link.value, decoded.data as string); } } } catch { @@ -825,7 +531,7 @@ ]); for (const event of events) { - nostrLinkEvents.set(event.id, event); + state.discussion.nostrLinkEvents.set(event.id, event); } } catch { // Ignore fetch errors @@ -847,7 +553,7 @@ ]); if (events.length > 0) { - nostrLinkEvents.set(events[0].id, events[0]); + state.discussion.nostrLinkEvents.set(events[0].id, events[0]); } } catch { // Ignore fetch errors @@ -863,15 +569,15 @@ 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); + return state.discussion.nostrLinkEvents.get(decoded.data.id); } else if (decoded.type === 'note') { - return nostrLinkEvents.get(decoded.data as string); + return state.discussion.nostrLinkEvents.get(decoded.data as string); } } else if (link.startsWith('nostr:naddr1')) { const decoded = nip19.decode(link.replace('nostr:', '')); if (decoded.type === 'naddr') { const eventId = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`; - return Array.from(nostrLinkEvents.values()).find(e => { + return Array.from(state.discussion.nostrLinkEvents.values()).find(e => { const dTag = e.tags.find(t => t[0] === 'd')?.[1]; return e.kind === decoded.data.kind && e.pubkey === decoded.data.pubkey && @@ -887,7 +593,7 @@ // Get pubkey from nostr: npub/profile link function getPubkeyFromNostrLink(link: string): string | undefined { - return nostrLinkProfiles.get(link); + return state.discussion.nostrLinkProfiles.get(link); } // Process content with nostr links into parts for rendering @@ -934,8 +640,8 @@ return parts; } - // Load full events for discussions and comments to get tags for blurbs - async function loadDiscussionEvents(discussionsList: typeof discussions) { + // Load full events for state.discussions and comments to get state.git.tags for blurbs + async function loadDiscussionEvents(discussionsList: typeof state.discussions) { const eventIds = new Set(); // Collect all event IDs @@ -975,7 +681,7 @@ ]); for (const event of events) { - discussionEvents.set(event.id, event); + state.discussion.events.set(event.id, event); } } catch { // Ignore fetch errors @@ -984,7 +690,7 @@ // Get discussion event by ID function getDiscussionEvent(eventId: string): NostrEvent | undefined { - return discussionEvents.get(eventId); + return state.discussion.events.get(eventId); } // Get referenced event from discussion event (e-tag, a-tag, q-tag) @@ -992,7 +698,7 @@ // Check e-tag const eTag = event.tags.find(t => t[0] === 'e' && t[1])?.[1]; if (eTag) { - const referenced = discussionEvents.get(eTag); + const referenced = state.discussion.events.get(eTag); if (referenced) return referenced; } @@ -1004,7 +710,7 @@ const kind = parseInt(parts[0]); const pubkey = parts[1]; const dTag = parts[2]; - return Array.from(discussionEvents.values()).find(e => + return Array.from(state.discussion.events.values()).find(e => e.kind === kind && e.pubkey === pubkey && e.tags.find(t => t[0] === 'd' && t[1] === dTag) @@ -1015,13 +721,13 @@ // Check q-tag const qTag = event.tags.find(t => t[0] === 'q' && t[1])?.[1]; if (qTag) { - return discussionEvents.get(qTag); + return state.discussion.events.get(qTag); } return undefined; } - // Format time for discussions + // Format time for state.discussions function formatDiscussionTime(timestamp: number): string { const date = new Date(timestamp * 1000); const now = new Date(); @@ -1041,17 +747,6 @@ let nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); // README - let readmeContent = $state(null); - let readmePath = $state(null); - let readmeIsMarkdown = $state(false); - let loadingReadme = $state(false); - let readmeHtml = $state(''); - let highlightedFileContent = $state(''); - let fileHtml = $state(''); // 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(null); // URL for image files // Rewrite image paths in HTML to point to repository file API function rewriteImagePaths(html: string, filePath: string | null): string { @@ -1063,7 +758,7 @@ : ''; // Get current branch for the API URL - const branch = currentBranch || defaultBranch || 'main'; + const branch = state.git.currentBranch || state.git.defaultBranch || 'main'; // Rewrite relative image paths return html.replace(/]*)\ssrc=["']([^"']+)["']([^>]*)>/gi, (match, before, src, after) => { @@ -1098,33 +793,22 @@ imagePath = normalizedPath.join('/'); // Build API URL - const apiUrl = `/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(imagePath)}&ref=${encodeURIComponent(branch)}`; + const apiUrl = `/api/repos/${state.npub}/${state.repo}/raw?path=${encodeURIComponent(imagePath)}&ref=${encodeURIComponent(branch)}`; return ``; }); } // 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(null); - - // Repository images - let repoImage = $state(null); - let repoBanner = $state(null); - // Safe values for head section to prevent SSR errors (must be after repoImage/repoBanner declaration) - const safeRepo = $derived(repo || 'Repository'); + // Safe values for head section to prevent SSR errors + const safeRepo = $derived(state.repo || 'Repository'); const safeRepoName = $derived.by(() => { try { - return repoName || repo || 'Repository'; + return repoName || state.repo || 'Repository'; } catch { - return repo || 'Repository'; + return state.repo || 'Repository'; } }); const safeRepoDescription = $derived.by(() => { @@ -1136,28 +820,28 @@ }); const safeTitle = $derived.by(() => { try { - return pageData?.title || `${safeRepo} - Repository`; + return state.pageData?.title || `${safeRepo} - Repository`; } catch { return `${safeRepo} - Repository`; } }); const safeDescription = $derived.by(() => { try { - return pageData?.description || `Repository: ${safeRepo}`; + return state.pageData?.description || `Repository: ${safeRepo}`; } catch { return `Repository: ${safeRepo}`; } }); const safeImage = $derived.by(() => { try { - return pageData?.image || repoImage || null; + return state.pageData?.image || state.metadata.image || null; } catch { return null; } }); const safeBanner = $derived.by(() => { try { - return pageData?.banner || repoBanner || null; + return state.pageData?.banner || state.metadata.banner || null; } catch { return null; } @@ -1180,14 +864,14 @@ // Additional safe values for head section to avoid IIFEs const safeOgDescription = $derived.by(() => { try { - return pageData?.description || safeRepoDescription || `Repository: ${safeRepoName || safeRepo || 'Repository'}`; + return state.pageData?.description || safeRepoDescription || `Repository: ${safeRepoName || safeRepo || 'Repository'}`; } catch { return 'Repository'; } }); const safeTwitterDescription = $derived.by(() => { try { - return pageData?.description || safeRepoDescription || `Repository: ${safeRepoName || safeRepo || 'Repository'}`; + return state.pageData?.description || safeRepoDescription || `Repository: ${safeRepoName || safeRepo || 'Repository'}`; } catch { return 'Repository'; } @@ -1208,24 +892,6 @@ }); // Repository owner pubkey (decoded from npub) - kept for backward compatibility with some functions - let repoOwnerPubkeyState = $state(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>(new Map()); - let loadingReachability = $state(false); - let checkingReachability = $state>(new Set()); - - // Guard to prevent README auto-load loop - let readmeAutoLoadAttempted = $state(false); let readmeAutoLoadTimeout: ReturnType | null = null; // Load clone URL reachability status @@ -1234,12 +900,12 @@ return; } - if (loadingReachability) return; + if (state.loading.reachability) return; - loadingReachability = true; + state.loading.reachability = true; try { const response = await fetch( - `/api/repos/${npub}/${repo}/clone-urls/reachability${forceRefresh ? '?forceRefresh=true' : ''}`, + `/api/repos/${state.npub}/${state.repo}/clone-urls/reachability${forceRefresh ? '?forceRefresh=true' : ''}`, { headers: buildApiHeaders() } @@ -1247,58 +913,58 @@ if (response.ok) { const data = await response.json(); - const newMap = new Map(); + const newMap = new Map(); if (data.results && Array.isArray(data.results)) { for (const result of data.results) { newMap.set(result.url, { reachable: result.reachable, - error: result.error, + error: result.error, checkedAt: result.checkedAt, serverType: result.serverType || 'unknown' }); } } - cloneUrlReachability = newMap; + state.clone.reachability = newMap; } } catch (err) { console.warn('Failed to load clone URL reachability:', err); } finally { - loadingReachability = false; - checkingReachability.clear(); + state.loading.reachability = false; + state.clone.checkingReachability.clear(); } } async function loadReadme() { - if (repoNotFound) return; - loadingReadme = true; + if (state.loading.repoNotFound) return; + state.loading.readme = true; try { - const response = await fetch(`/api/repos/${npub}/${repo}/readme?ref=${currentBranch}`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/readme?ref=${state.git.currentBranch}`, { headers: buildApiHeaders() }); if (response.ok) { const data = await response.json(); if (data.found) { - readmeContent = data.content; - readmePath = data.path; - readmeIsMarkdown = data.isMarkdown; + state.preview.readme.content = data.content; + state.preview.readme.path = data.path; + state.preview.readme.isMarkdown = data.isMarkdown; // Reset preview mode for README - showFilePreview = true; - readmeHtml = ''; + state.preview.file.showPreview = true; + state.preview.readme.html = ''; // Render markdown or asciidoc if needed - if (readmeContent) { - const ext = readmePath?.split('.').pop()?.toLowerCase() || ''; - if (readmeIsMarkdown || ext === 'md' || ext === 'markdown') { + if (state.preview.readme.content) { + const ext = state.preview.readme.path?.split('.').pop()?.toLowerCase() || ''; + if (state.preview.readme.isMarkdown || ext === 'md' || ext === 'markdown') { try { const MarkdownIt = (await import('markdown-it')).default; const hljsModule = await import('highlight.js'); const hljs = hljsModule.default || hljsModule; const md = new MarkdownIt({ - html: true, // Enable HTML tags in source + html: true, // Enable HTML state.git.tags in source linkify: true, // Autoconvert URL-like text to links typographer: true, // Enable some language-neutral replacement + quotes beautification breaks: true, // Convert '\n' in paragraphs into
@@ -1317,20 +983,20 @@ } }); - let rendered = md.render(readmeContent); + let rendered = md.render(state.preview.readme.content); // Rewrite image paths to point to repository API - rendered = rewriteImagePaths(rendered, readmePath); - readmeHtml = rendered; - console.log('[README] Markdown rendered successfully, HTML length:', readmeHtml.length); + rendered = rewriteImagePaths(rendered, state.preview.readme.path); + state.preview.readme.html = rendered; + console.log('[README] Markdown rendered successfully, HTML length:', state.preview.readme.html.length); } catch (err) { console.error('[README] Error rendering markdown:', err); - readmeHtml = ''; + state.preview.readme.html = ''; } } else if (ext === 'adoc' || ext === 'asciidoc') { try { const Asciidoctor = (await import('@asciidoctor/core')).default; const asciidoctor = Asciidoctor(); - const converted = asciidoctor.convert(readmeContent, { + const converted = asciidoctor.convert(state.preview.readme.content, { safe: 'safe', attributes: { 'source-highlighter': 'highlight.js' @@ -1338,27 +1004,27 @@ }); 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 + rendered = rewriteImagePaths(rendered, state.preview.readme.path); + state.preview.readme.html = rendered; + state.preview.readme.isMarkdown = true; // Treat as markdown for display purposes } catch (err) { console.error('[README] Error rendering asciidoc:', err); - readmeHtml = ''; + state.preview.readme.html = ''; } } 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 + state.preview.readme.html = rewriteImagePaths(state.preview.readme.content || '', state.preview.readme.path); + state.preview.readme.isMarkdown = true; // Treat as markdown for display purposes } else { - readmeHtml = ''; + state.preview.readme.html = ''; } } } } } catch (err) { - console.error('Error loading README:', err); + console.error('Error state.loading.main README:', err); } finally { - loadingReadme = false; + state.loading.readme = false; } } @@ -1459,8 +1125,8 @@ let rendered = md.render(content); // Rewrite image paths to point to repository API - rendered = rewriteImagePaths(rendered, currentFile); - fileHtml = rendered; + rendered = rewriteImagePaths(rendered, state.files.currentFile); + state.preview.file.html = rendered; } else if (lowerExt === 'adoc' || lowerExt === 'asciidoc') { // Render asciidoc const Asciidoctor = (await import('@asciidoctor/core')).default; @@ -1473,20 +1139,20 @@ }); let rendered = typeof converted === 'string' ? converted : String(converted); // Rewrite image paths to point to repository API - rendered = rewriteImagePaths(rendered, currentFile); - fileHtml = rendered; + rendered = rewriteImagePaths(rendered, state.files.currentFile); + state.preview.file.html = rendered; } else if (lowerExt === 'html' || lowerExt === 'htm') { // HTML files - rewrite image paths let rendered = content; - rendered = rewriteImagePaths(rendered, currentFile); - fileHtml = rendered; + rendered = rewriteImagePaths(rendered, state.files.currentFile); + state.preview.file.html = rendered; } else if (lowerExt === 'csv') { // Parse CSV and render as HTML table - fileHtml = renderCsvAsTable(content); + state.preview.file.html = renderCsvAsTable(content); } } catch (err) { console.error('Error rendering file as HTML:', err); - fileHtml = ''; + state.preview.file.html = ''; } } @@ -1573,7 +1239,7 @@ return html; } catch (err) { console.error('Error parsing CSV:', err); - return `

Error parsing CSV: ${escapeHtml(err instanceof Error ? err.message : String(err))}

`; + return `

Error parsing CSV: ${escapeHtml(err instanceof Error ? err.message : String(err))}

`; } } @@ -1756,30 +1422,30 @@ // Apply highlighting if (lang === 'plaintext') { - highlightedFileContent = `
${hljs.highlight(content, { language: 'plaintext' }).value}
`; + state.preview.file.highlightedContent = `
${hljs.highlight(content, { language: 'plaintext' }).value}
`; } else if (hljs.getLanguage(lang)) { - highlightedFileContent = `
${hljs.highlight(content, { language: lang }).value}
`; + state.preview.file.highlightedContent = `
${hljs.highlight(content, { language: lang }).value}
`; } else { // Fallback to auto-detection - highlightedFileContent = `
${hljs.highlightAuto(content).value}
`; + state.preview.file.highlightedContent = `
${hljs.highlightAuto(content).value}
`; } } catch (err) { console.error('Error applying syntax highlighting:', err); // Fallback to plain text - highlightedFileContent = `
${content}
`; + state.preview.file.highlightedContent = `
${content}
`; } } async function loadForkInfo() { try { - const response = await fetch(`/api/repos/${npub}/${repo}/fork`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/fork`, { headers: buildApiHeaders() }); if (response.ok) { - forkInfo = await response.json(); + state.fork.info = await response.json(); } } catch (err) { - console.error('Error loading fork info:', err); + console.error('Error state.loading.main fork info:', err); } } @@ -1798,19 +1464,19 @@ } async function checkCloneStatus(force: boolean = false) { - if (checkingCloneStatus) return; - if (!force && isRepoCloned !== null) { - console.log(`[Clone Status] Skipping check - already checked: ${isRepoCloned}, force: ${force}`); + if (state.clone.checking) return; + if (!force && state.clone.isCloned !== null) { + console.log(`[Clone Status] Skipping check - already checked: ${state.clone.isCloned}, force: ${force}`); return; } - checkingCloneStatus = true; + state.clone.checking = true; try { - // Check if repo exists locally by trying to fetch branches + // Check if repo exists locally by trying to fetch state.git.branches // Use skipApiFallback parameter to ensure we only check local repo, not API fallback // 404 = repo not cloned, 403 = repo exists but access denied (cloned), 200 = cloned and accessible - const url = `/api/repos/${npub}/${repo}/branches?skipApiFallback=true`; - console.log(`[Clone Status] Checking clone status for ${npub}/${repo}...`); + const url = `/api/repos/${state.npub}/${state.repo}/branches?skipApiFallback=true`; + console.log(`[Clone Status] Checking clone status for ${state.npub}/${state.repo}...`); const response = await fetch(url, { headers: buildApiHeaders() }); @@ -1818,39 +1484,39 @@ // 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; + state.clone.isCloned = wasCloned; // If repo is not cloned, check if API fallback is available if (!wasCloned) { // Try to detect API fallback by checking if we have clone URLs if (repoCloneUrls && repoCloneUrls.length > 0) { // We have clone URLs, so API fallback might work - will be detected when loadBranches() runs - apiFallbackAvailable = null; // Will be set to true if a subsequent request succeeds + state.clone.apiFallbackAvailable = null; // Will be set to true if a subsequent request succeeds } else { - apiFallbackAvailable = false; + state.clone.apiFallbackAvailable = false; } } else { // Repo is cloned, API fallback not needed - apiFallbackAvailable = false; + state.clone.apiFallbackAvailable = false; } - console.log(`[Clone Status] Repo ${wasCloned ? 'is cloned' : 'is not cloned'} (status: ${response.status}), API fallback: ${apiFallbackAvailable}`); + console.log(`[Clone Status] Repo ${wasCloned ? 'is cloned' : 'is not cloned'} (status: ${response.status}), API fallback: ${state.clone.apiFallbackAvailable}`); } catch (err) { - // On error, assume not cloned + // On state.error, assume not cloned console.warn('[Clone Status] Error checking clone status:', err); - isRepoCloned = false; - apiFallbackAvailable = false; + state.clone.isCloned = false; + state.clone.apiFallbackAvailable = false; } finally { - checkingCloneStatus = false; + state.clone.checking = false; } } async function cloneRepository() { - if (cloning) return; + if (state.clone.cloning) return; - cloning = true; + state.clone.cloning = true; try { - const response = await fetch(`/api/repos/${npub}/${repo}/clone`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/clone`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1874,11 +1540,11 @@ // Force refresh clone status await checkCloneStatus(true); // Reset API fallback status since repo is now cloned - apiFallbackAvailable = false; + state.clone.apiFallbackAvailable = false; // Reload data to use the cloned repo instead of API await Promise.all([ loadBranches(), - loadFiles(currentPath), + loadFiles(state.files.currentPath), loadReadme(), loadTags(), loadCommitHistory() @@ -1887,32 +1553,32 @@ } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to clone repository'; alert(`Error: ${errorMessage}`); - console.error('Error cloning repository:', err); + console.error('Error state.clone.cloning repository:', err); } finally { - cloning = false; + state.clone.cloning = false; } } async function forkRepository() { - if (!userPubkey) { + if (!state.user.pubkey) { alert('Please connect your NIP-07 extension'); return; } - forking = true; - error = null; + state.fork.forking = true; + state.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`, { + const truncatedNpub = state.npub.length > 16 ? `${state.npub.slice(0, 12)}...` : state.npub; + console.log(`[Fork UI] Starting fork of ${truncatedNpub}/${state.repo}...`); + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/fork`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...buildApiHeaders() }, - body: JSON.stringify({ userPubkey }) + body: JSON.stringify({ userPubkey: state.user.pubkey }) }); const data = await response.json(); @@ -1929,7 +1595,7 @@ 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 errorMessage = data.state.error || 'Failed to fork repository'; const errorDetails = data.details ? `\n\nDetails: ${data.details}` : ''; const fullError = `${errorMessage}${errorDetails}`; @@ -1941,25 +1607,25 @@ console.error(`[Fork UI] Failed event: ${data.eventName}`); } - error = fullError; + state.error = fullError; alert(`Fork failed!\n\n${fullError}`); } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to fork repository'; console.error(`[Fork UI] ✗ Unexpected error: ${errorMessage}`, err); - error = errorMessage; + state.error = errorMessage; alert(`Fork failed!\n\n${errorMessage}`); } finally { - forking = false; + state.fork.forking = false; } } async function loadDiscussions() { - if (repoNotFound) return; - loadingDiscussions = true; - error = null; + if (state.repoNotFound) return; + state.loading.discussions = true; + state.error = null; try { - const decoded = nip19.decode(npub); + const decoded = nip19.decode(state.npub); if (decoded.type !== 'npub') { throw new Error('Invalid npub format'); } @@ -1971,13 +1637,13 @@ { kinds: [KIND.REPO_ANNOUNCEMENT], authors: [repoOwnerPubkeyDerived], - '#d': [repo], + '#d': [state.repo], limit: 1 } ]); if (events.length === 0) { - discussions = []; + state.discussions = []; return; } @@ -1993,7 +1659,7 @@ // Get user's relays if available let userRelays: string[] = []; - const currentUserPubkey = $userStore.userPubkey || userPubkey; + const currentUserPubkey = $userStore.userPubkey || state.user.pubkey; if (currentUserPubkey) { try { const { outbox } = await getUserRelays(currentUserPubkey, client); @@ -2017,7 +1683,7 @@ const discussionsService = new DiscussionsService(allRelays); const discussionEntries = await discussionsService.getDiscussions( repoOwnerPubkey, - repo, + state.repo, announcement.id, announcement.pubkey, allRelays, // Use all relays for threads @@ -2026,7 +1692,7 @@ console.log('[Discussions] Found', discussionEntries.length, 'discussion entries'); - discussions = discussionEntries.map(entry => ({ + state.discussions = discussionEntries.map(entry => ({ type: entry.type, id: entry.id, title: entry.title, @@ -2038,11 +1704,11 @@ comments: entry.comments })); - // Fetch full events for discussions and comments to get tags for blurbs - await loadDiscussionEvents(discussions); + // Fetch full events for state.discussions and comments to get state.git.tags for blurbs + await loadDiscussionEvents(state.discussions); // Fetch nostr: links from discussion content - for (const discussion of discussions) { + for (const discussion of state.discussions) { if (discussion.content) { await loadNostrLinks(discussion.content); } @@ -2069,30 +1735,30 @@ } } } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load discussions'; - console.error('Error loading discussions:', err); + state.error = err instanceof Error ? err.message : 'Failed to load state.discussions'; + console.error('Error state.loading.main state.discussions:', err); } finally { - loadingDiscussions = false; + state.loading.discussions = false; } } async function createDiscussionThread() { - if (!userPubkey || !userPubkeyHex) { - error = 'You must be logged in to create a discussion thread'; + if (!state.user.pubkey || !state.user.pubkeyHex) { + state.error = 'You must be logged in to create a discussion thread'; return; } - if (!newThreadTitle.trim()) { - error = 'Thread title is required'; + if (!state.forms.discussion.threadTitle.trim()) { + state.error = 'Thread title is required'; return; } - creatingThread = true; - error = null; + state.creating.thread = true; + state.error = null; try { - const decoded = nip19.decode(npub); + const decoded = nip19.decode(state.npub); if (decoded.type !== 'npub') { throw new Error('Invalid npub format'); } @@ -2104,7 +1770,7 @@ { kinds: [KIND.REPO_ANNOUNCEMENT], authors: [repoOwnerPubkeyDerived], - '#d': [repo], + '#d': [state.repo], limit: 1 } ]); @@ -2114,7 +1780,7 @@ } const announcement = events[0]; - const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repo}`; + state.metadata.address = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${state.repo}`; // Get project relays from announcement, or use default relays const chatRelays = announcement.tags @@ -2124,9 +1790,9 @@ // Combine all available relays let allRelays = [...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS, ...chatRelays]; - if (userPubkey) { + if (state.user.pubkey) { try { - const { outbox } = await getUserRelays(userPubkey, client); + const { outbox } = await getUserRelays(state.user.pubkey, client); allRelays = [...allRelays, ...outbox]; } catch (err) { console.warn('Failed to get user relays:', err); @@ -2137,14 +1803,14 @@ // Create kind 11 thread event const threadEventTemplate: Omit = { kind: KIND.THREAD, - pubkey: userPubkeyHex, + pubkey: state.user.pubkeyHex, created_at: Math.floor(Date.now() / 1000), tags: [ - ['a', repoAddress], - ['title', newThreadTitle.trim()], + ['a', state.metadata.address], + ['title', state.forms.discussion.threadTitle.trim()], ['t', 'repo'] ], - content: newThreadContent.trim() || '' + content: state.forms.discussion.threadContent.trim() || '' }; // Sign the event using NIP-07 @@ -2159,41 +1825,41 @@ } // Clear form and close dialog - newThreadTitle = ''; - newThreadContent = ''; - showCreateThreadDialog = false; + state.forms.discussion.threadTitle = ''; + state.forms.discussion.threadContent = ''; + state.openDialog = null; // Reload discussions await loadDiscussions(); } catch (err) { - error = err instanceof Error ? err.message : 'Failed to create discussion thread'; + state.error = err instanceof Error ? err.message : 'Failed to create discussion thread'; console.error('Error creating discussion thread:', err); } finally { - creatingThread = false; + state.creating.thread = false; } } async function createThreadReply() { - if (!userPubkey || !userPubkeyHex) { - error = 'You must be logged in to reply'; + if (!state.user.pubkey || !state.user.pubkeyHex) { + state.error = 'You must be logged in to reply'; return; } - if (!replyContent.trim()) { - error = 'Reply content is required'; + if (!state.forms.discussion.replyContent.trim()) { + state.error = 'Reply content is required'; return; } - if (!replyingToThread && !replyingToComment) { - error = 'Must reply to either a thread or a comment'; + if (!state.discussion.replyingToThread && !state.discussion.replyingToComment) { + state.error = 'Must reply to either a thread or a comment'; return; } - creatingReply = true; - error = null; + state.creating.reply = true; + state.error = null; try { - const decoded = nip19.decode(npub); + const decoded = nip19.decode(state.npub); if (decoded.type !== 'npub') { throw new Error('Invalid npub format'); } @@ -2206,7 +1872,7 @@ { kinds: [KIND.REPO_ANNOUNCEMENT], authors: [repoOwnerPubkeyDerived], - '#d': [repo], + '#d': [state.repo], limit: 1 } ]); @@ -2225,9 +1891,9 @@ // Combine all available relays let allRelays = [...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS, ...chatRelays]; - if (userPubkey) { + if (state.user.pubkey) { try { - const { outbox } = await getUserRelays(userPubkey, client); + const { outbox } = await getUserRelays(state.user.pubkey, client); allRelays = [...allRelays, ...outbox]; } catch (err) { console.warn('Failed to get user relays:', err); @@ -2242,15 +1908,15 @@ let parentKind: number; let parentPubkey: string; - if (replyingToComment) { + if (state.discussion.replyingToComment) { // Replying to a comment - use the comment object we already have - const comment = replyingToComment; + const comment = state.discussion.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; + if (state.discussion.replyingToThread) { + rootEventId = state.discussion.replyingToThread.id; + rootKind = state.discussion.replyingToThread.kind || KIND.THREAD; + rootPubkey = state.discussion.replyingToThread.pubkey || state.discussion.replyingToThread.author; } else { // Comment is directly on announcement (in "Comments" pseudo-thread) rootEventId = announcement.id; @@ -2262,14 +1928,14 @@ parentEventId = comment.id; parentKind = comment.kind || KIND.COMMENT; parentPubkey = comment.pubkey || comment.author; - } else if (replyingToThread) { + } else if (state.discussion.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; + rootEventId = state.discussion.replyingToThread.id; + rootKind = state.discussion.replyingToThread.kind || KIND.THREAD; + rootPubkey = state.discussion.replyingToThread.pubkey || state.discussion.replyingToThread.author; + parentEventId = state.discussion.replyingToThread.id; + parentKind = state.discussion.replyingToThread.kind || KIND.THREAD; + parentPubkey = state.discussion.replyingToThread.pubkey || state.discussion.replyingToThread.author; } else { throw new Error('Must specify thread or comment to reply to'); } @@ -2277,17 +1943,17 @@ // Create kind 1111 comment event const commentEventTemplate: Omit = { kind: KIND.COMMENT, - pubkey: userPubkeyHex, - created_at: Math.floor(Date.now() / 1000), - tags: [ - ['e', parentEventId, '', 'reply'], // Parent event - ['k', parentKind.toString()], // Parent kind + pubkey: state.user.pubkeyHex, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['e', parentEventId, '', 'reply'], // Parent event + ['k', parentKind.toString()], // Parent kind ['p', parentPubkey], // Parent pubkey ['E', rootEventId], // Root event ['K', rootKind.toString()], // Root kind ['P', rootPubkey] // Root pubkey ], - content: replyContent.trim() + content: state.forms.discussion.replyContent.trim() }; // Sign the event using NIP-07 @@ -2302,73 +1968,73 @@ } // Save thread ID before clearing (for expanding after reload) - const threadIdToExpand = replyingToThread?.id; + const threadIdToExpand = state.discussion.replyingToThread?.id; // Clear form and close dialog - replyContent = ''; - showReplyDialog = false; - replyingToThread = null; - replyingToComment = null; + state.forms.discussion.replyContent = ''; + state.openDialog = null; + state.discussion.replyingToThread = null; + state.discussion.replyingToComment = null; - // Reload discussions to show the new reply + // Reload state.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 + state.ui.expandedThreads.add(threadIdToExpand); + state.ui.expandedThreads = new Set(state.ui.expandedThreads); // Trigger reactivity } } catch (err) { - error = err instanceof Error ? err.message : 'Failed to create reply'; + state.error = err instanceof Error ? err.message : 'Failed to create reply'; console.error('Error creating reply:', err); } finally { - creatingReply = false; + state.creating.reply = false; } } function toggleThread(threadId: string) { - if (expandedThreads.has(threadId)) { - expandedThreads.delete(threadId); + if (state.ui.expandedThreads.has(threadId)) { + state.ui.expandedThreads.delete(threadId); } else { - expandedThreads.add(threadId); + state.ui.expandedThreads.add(threadId); } // Trigger reactivity - expandedThreads = new Set(expandedThreads); + state.ui.expandedThreads = new Set(state.ui.expandedThreads); } async function loadDocumentation() { - if (loadingDocs) return; + if (state.loading.docs) return; // Reset documentation when reloading - documentationHtml = null; - documentationContent = null; - documentationKind = null; + state.docs.html = null; + state.docs.content = null; + state.docs.kind = null; - loadingDocs = true; + state.loading.docs = true; try { // Guard against SSR - $page store can only be accessed in component context if (typeof window === 'undefined') return; // Check if repo is private and user has access - const data = $page.data as typeof pageData; + const data = $page.data as typeof state.pageData; if (repoIsPrivate) { // Check access via API - const accessResponse = await fetch(`/api/repos/${npub}/${repo}/access`, { + const accessResponse = await fetch(`/api/repos/${state.npub}/${state.repo}/access`, { headers: buildApiHeaders() }); if (accessResponse.ok) { const accessData = await accessResponse.json(); if (!accessData.canView) { // User doesn't have access, don't load documentation - loadingDocs = false; + state.loading.docs = false; return; } } else { // Access check failed, don't load documentation - loadingDocs = false; + state.loading.docs = false; return; } } - const decoded = nip19.decode(npub); + const decoded = nip19.decode(state.npub); if (decoded.type === 'npub') { const repoOwnerPubkey = decoded.data as string; const client = new NostrClient(DEFAULT_NOSTR_RELAYS); @@ -2378,13 +2044,13 @@ { kinds: [KIND.REPO_ANNOUNCEMENT], authors: [repoOwnerPubkeyDerived], - '#d': [repo], + '#d': [state.repo], limit: 1 } ]); if (announcementEvents.length === 0) { - loadingDocs = false; + state.loading.docs = false; return; } @@ -2393,7 +2059,7 @@ // Look for documentation tag in the announcement const documentationTag = announcement.tags.find(t => t[0] === 'documentation'); - documentationKind = null; + state.docs.kind = null; if (documentationTag && documentationTag[1]) { // Parse the a-tag format: kind:pubkey:identifier @@ -2401,14 +2067,14 @@ const parts = docAddress.split(':'); if (parts.length >= 3) { - documentationKind = parseInt(parts[0]); + state.docs.kind = parseInt(parts[0]); const docPubkey = parts[1]; const docIdentifier = parts.slice(2).join(':'); // In case identifier contains ':' // Fetch the documentation event const docEvents = await client.fetchEvents([ { - kinds: [documentationKind], + kinds: [state.docs.kind], authors: [docPubkey], '#d': [docIdentifier], limit: 1 @@ -2416,38 +2082,38 @@ ]); if (docEvents.length > 0) { - documentationContent = docEvents[0].content || null; + state.docs.content = docEvents[0].content || null; } else { console.warn('Documentation event not found:', docAddress); - documentationContent = null; + state.docs.content = null; } } else { console.warn('Invalid documentation tag format:', docAddress); - documentationContent = null; + state.docs.content = 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 + state.docs.content = announcement.content || null; + // Announcement is kind 30617, not a doc kind, so keep state.docs.kind as null } // Render content based on kind: AsciiDoc for 30041 or 30818, Markdown otherwise - if (documentationContent) { + if (state.docs.content) { // Check if we should use AsciiDoc parser (kinds 30041 or 30818) - const useAsciiDoc = documentationKind === 30041 || documentationKind === 30818; + const useAsciiDoc = state.docs.kind === 30041 || state.docs.kind === 30818; if (useAsciiDoc) { // Use AsciiDoc parser const Asciidoctor = (await import('@asciidoctor/core')).default; const asciidoctor = Asciidoctor(); - const converted = asciidoctor.convert(documentationContent, { + const converted = asciidoctor.convert(state.docs.content, { safe: 'safe', attributes: { 'source-highlighter': 'highlight.js' } }); // Convert to string if it's a Document object - documentationHtml = typeof converted === 'string' ? converted : String(converted); + state.docs.html = typeof converted === 'string' ? converted : String(converted); } else { // Use Markdown parser const MarkdownIt = (await import('markdown-it')).default; @@ -2465,18 +2131,18 @@ } }); - documentationHtml = md.render(documentationContent); + state.docs.html = md.render(state.docs.content); } } else { // No content found, clear HTML - documentationHtml = null; + state.docs.html = null; } } } catch (err) { - console.error('Error loading documentation:', err); - documentationHtml = null; + console.error('Error state.loading.main documentation:', err); + state.docs.html = null; } finally { - loadingDocs = false; + state.loading.docs = false; } } @@ -2486,38 +2152,38 @@ // 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; + const data = $page.data as typeof state.pageData; if (data.image) { - repoImage = data.image; - console.log('[Repo Images] Loaded image from pageData:', repoImage); + state.metadata.image = data.image; + console.log('[Repo Images] Loaded image from pageData:', state.metadata.image); } if (data.banner) { - repoBanner = data.banner; - console.log('[Repo Images] Loaded banner from pageData:', repoBanner); + state.metadata.banner = data.banner; + console.log('[Repo Images] Loaded banner from pageData:', state.metadata.banner); } // Also fetch from announcement directly as fallback (only if not private or user has access) - if (!repoImage && !repoBanner) { + if (!state.metadata.image && !state.metadata.banner) { // Guard against SSR - $page store can only be accessed in component context if (typeof window === 'undefined') return; - const data = $page.data as typeof pageData; + const data = $page.data as typeof state.pageData; // Check access for private repos if (repoIsPrivate) { const headers: Record = {}; - if (userPubkey) { + if (state.user.pubkey) { try { - const decoded = nip19.decode(userPubkey); + const decoded = nip19.decode(state.user.pubkey); if (decoded.type === 'npub') { headers['X-User-Pubkey'] = decoded.data as string; } else { - headers['X-User-Pubkey'] = userPubkey; + headers['X-User-Pubkey'] = state.user.pubkey; } } catch { - headers['X-User-Pubkey'] = userPubkey; + headers['X-User-Pubkey'] = state.user.pubkey; } } - const accessResponse = await fetch(`/api/repos/${npub}/${repo}/access`, { headers }); + const accessResponse = await fetch(`/api/repos/${state.npub}/${state.repo}/access`, { headers }); if (!accessResponse.ok) { // Access check failed, don't fetch images return; @@ -2529,7 +2195,7 @@ } } - const decoded = nip19.decode(npub); + const decoded = nip19.decode(state.npub); if (decoded.type === 'npub') { const repoOwnerPubkey = decoded.data as string; const client = new NostrClient(DEFAULT_NOSTR_RELAYS); @@ -2537,7 +2203,7 @@ { kinds: [30617], // REPO_ANNOUNCEMENT authors: [repoOwnerPubkeyDerived], - '#d': [repo], + '#d': [state.repo], limit: 1 } ]); @@ -2548,12 +2214,12 @@ 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); + state.metadata.image = imageTag[1]; + console.log('[Repo Images] Loaded image from announcement:', state.metadata.image); } if (bannerTag?.[1]) { - repoBanner = bannerTag[1]; - console.log('[Repo Images] Loaded banner from announcement:', repoBanner); + state.metadata.banner = bannerTag[1]; + console.log('[Repo Images] Loaded banner from announcement:', state.metadata.banner); } } else { console.log('[Repo Images] No announcement found'); @@ -2561,33 +2227,33 @@ } } - if (!repoImage && !repoBanner) { + if (!state.metadata.image && !state.metadata.banner) { console.log('[Repo Images] No images found in announcement'); } } catch (err) { - console.error('Error loading repo images:', err); + console.error('Error state.loading.main 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; + if (typeof window === 'undefined' || !state.isMounted) return; try { - const data = $page.data as typeof pageData; - if (!data || !isMounted) return; + const data = $page.data as typeof state.pageData; + if (!data || !state.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.image && data.image !== state.metadata.image && state.isMounted) { + state.metadata.image = data.image; + console.log('[Repo Images] Updated image from pageData (reactive):', state.metadata.image); } - if (data.banner && data.banner !== repoBanner && isMounted) { - repoBanner = data.banner; - console.log('[Repo Images] Updated banner from pageData (reactive):', repoBanner); + if (data.banner && data.banner !== state.metadata.banner && state.isMounted) { + state.metadata.banner = data.banner; + console.log('[Repo Images] Updated banner from pageData (reactive):', state.metadata.banner); } } catch (err) { // Ignore errors during destruction - if (isMounted) { + if (state.isMounted) { console.warn('Image update effect error:', err); } } @@ -2602,10 +2268,10 @@ // Decode npub to get repo owner pubkey for bookmark address try { - const decoded = nip19.decode(npub); + const decoded = nip19.decode(state.npub); if (decoded.type === 'npub') { - repoOwnerPubkeyState = decoded.data as string; - repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkeyState}:${repo}`; + state.metadata.ownerPubkey = decoded.data as string; + state.metadata.address = `${KIND.REPO_ANNOUNCEMENT}:${state.metadata.ownerPubkey}:${state.repo}`; } } catch (err) { console.warn('Failed to decode npub for bookmark address:', err); @@ -2613,15 +2279,15 @@ // Close menu when clicking outside (handled by RepoHeaderEnhanced component) clickOutsideHandler = (event: MouseEvent) => { - if (!isMounted) return; + if (!state.isMounted) return; try { const target = event.target as HTMLElement; - if (showRepoMenu && !target.closest('.repo-header') && isMounted) { - showRepoMenu = false; + if (state.ui.showRepoMenu && !target.closest('.repo-header') && state.isMounted) { + state.ui.showRepoMenu = false; } } catch (err) { // Ignore errors during destruction - if (isMounted) { + if (state.isMounted) { console.warn('Click outside handler error:', err); } } @@ -2630,57 +2296,57 @@ document.addEventListener('click', clickOutsideHandler); await loadBranches(); - if (!isMounted) return; + if (!state.isMounted) return; // Skip other API calls if repository doesn't exist - if (repoNotFound) { - loading = false; + if (state.repoNotFound) { + state.loading.main = false; return; } - // loadBranches() already handles setting currentBranch to the default branch + // loadBranches() already handles setting state.git.currentBranch to the default branch await loadFiles(); - if (!isMounted) return; + if (!state.isMounted) return; await checkAuth(); - if (!isMounted) return; + if (!state.isMounted) return; await loadTags(); - if (!isMounted) return; + if (!state.isMounted) return; await checkMaintainerStatus(); - if (!isMounted) return; + if (!state.isMounted) return; await loadBookmarkStatus(); - if (!isMounted) return; + if (!state.isMounted) return; await loadAllMaintainers(); - if (!isMounted) return; + if (!state.isMounted) return; // Check clone status (needed to disable write operations) await checkCloneStatus(); - if (!isMounted) return; + if (!state.isMounted) return; await checkVerification(); - if (!isMounted) return; + if (!state.isMounted) return; await loadReadme(); - if (!isMounted) return; + if (!state.isMounted) return; await loadForkInfo(); - if (!isMounted) return; + if (!state.isMounted) return; await loadRepoImages(); - if (!isMounted) return; + if (!state.isMounted) return; // Load clone URL reachability status loadCloneUrlReachability().catch(err => { - if (isMounted) console.warn('Failed to load clone URL reachability:', err); + if (state.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); + if (state.isMounted) console.warn('Failed to setup auto-save:', err); }); }); @@ -2689,7 +2355,7 @@ onDestroy(() => { try { // Mark component as unmounted first to prevent any state updates - isMounted = false; + state.isMounted = false; // Clean up intervals and timeouts if (autoSaveInterval) { @@ -2717,8 +2383,8 @@ // Check userStore first const currentUser = $userStore; if (currentUser.userPubkey && currentUser.userPubkeyHex) { - userPubkey = currentUser.userPubkey; - userPubkeyHex = currentUser.userPubkeyHex; + state.user.pubkey = currentUser.userPubkey; + state.user.pubkeyHex = currentUser.userPubkeyHex; // Recheck maintainer status and bookmark status after auth await checkMaintainerStatus(); await loadBookmarkStatus(); @@ -2729,18 +2395,18 @@ try { if (isNIP07Available()) { const pubkey = await getPublicKeyWithNIP07(); - userPubkey = pubkey; + state.user.pubkey = pubkey; // Convert to hex if needed if (/^[0-9a-f]{64}$/i.test(pubkey)) { - userPubkeyHex = pubkey.toLowerCase(); + state.user.pubkeyHex = pubkey.toLowerCase(); } else { try { const decoded = nip19.decode(pubkey); if (decoded.type === 'npub') { - userPubkeyHex = decoded.data as string; + state.user.pubkeyHex = decoded.data as string; } } catch { - userPubkeyHex = pubkey; + state.user.pubkeyHex = pubkey; } } // Recheck maintainer status and bookmark status after auth @@ -2749,8 +2415,8 @@ } } catch (err) { console.log('NIP-07 not available or user not connected'); - userPubkey = null; - userPubkeyHex = null; + state.user.pubkey = null; + state.user.pubkeyHex = null; } } @@ -2758,17 +2424,17 @@ // Check userStore first const currentUser = $userStore; if (currentUser.userPubkey && currentUser.userPubkeyHex) { - userPubkey = currentUser.userPubkey; - userPubkeyHex = currentUser.userPubkeyHex; + state.user.pubkey = currentUser.userPubkey; + state.user.pubkeyHex = currentUser.userPubkeyHex; // Re-check maintainer status and bookmark status after login await checkMaintainerStatus(); await loadBookmarkStatus(); // Check for pending transfers (user is already logged in via store) - if (userPubkeyHex) { + if (state.user.pubkeyHex) { try { const response = await fetch('/api/transfers/pending', { headers: { - 'X-User-Pubkey': userPubkeyHex + 'X-User-Pubkey': state.user.pubkeyHex } }); if (response.ok) { @@ -2797,27 +2463,27 @@ // Convert to hex if needed if (/^[0-9a-f]{64}$/i.test(pubkey)) { pubkeyHex = pubkey.toLowerCase(); - userPubkey = pubkey; + state.user.pubkey = pubkey; } else { try { const decoded = nip19.decode(pubkey); if (decoded.type === 'npub') { pubkeyHex = decoded.data as string; - userPubkey = pubkey; + state.user.pubkey = pubkey; } else { throw new Error('Invalid pubkey format'); } } catch { - error = 'Invalid public key format'; + state.error = 'Invalid public key format'; return; } } - userPubkeyHex = pubkeyHex; + 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(userPubkey, userPubkeyHex); + const levelResult = await determineUserLevel(state.user.pubkey, state.user.pubkeyHex); // Update user store with write access level userStore.setUser( @@ -2832,11 +2498,11 @@ updateActivity(); // Check for pending transfer events - if (userPubkeyHex) { + if (state.user.pubkeyHex) { try { const response = await fetch('/api/transfers/pending', { headers: { - 'X-User-Pubkey': userPubkeyHex + 'X-User-Pubkey': state.user.pubkeyHex } }); if (response.ok) { @@ -2856,63 +2522,63 @@ await checkMaintainerStatus(); await loadBookmarkStatus(); } catch (err) { - error = err instanceof Error ? err.message : 'Failed to connect'; + state.error = err instanceof Error ? err.message : 'Failed to connect'; console.error('Login error:', err); } } async function loadBookmarkStatus() { - if (!userPubkey || !repoAddress || !bookmarksService) return; + if (!state.user.pubkey || !state.metadata.address || !bookmarksService) return; try { - isBookmarked = await bookmarksService.isBookmarked(userPubkey, repoAddress); + state.bookmark.isBookmarked = await bookmarksService.isBookmarked(state.user.pubkey, state.metadata.address); } catch (err) { console.warn('Failed to load bookmark status:', err); } } async function toggleBookmark() { - if (!userPubkey || !repoAddress || !bookmarksService || loadingBookmark) return; + if (!state.user.pubkey || !state.metadata.address || !bookmarksService || state.loading.bookmark) return; - loadingBookmark = true; + state.loading.bookmark = true; try { // Get user's relays for publishing const { getUserRelays } = await import('$lib/services/nostr/user-relays.js'); const allSearchRelays = [...new Set([...DEFAULT_NOSTR_SEARCH_RELAYS, ...DEFAULT_NOSTR_RELAYS])]; const fullRelayClient = new NostrClient(allSearchRelays); - const { outbox, inbox } = await getUserRelays(userPubkey, fullRelayClient); + const { outbox, inbox } = await getUserRelays(state.user.pubkey, fullRelayClient); const userRelays = combineRelays(outbox.length > 0 ? outbox : inbox, DEFAULT_NOSTR_RELAYS); let success = false; - if (isBookmarked) { - success = await bookmarksService.removeBookmark(userPubkey, repoAddress, userRelays); + if (state.bookmark.isBookmarked) { + success = await bookmarksService.removeBookmark(state.user.pubkey, state.metadata.address, userRelays); } else { - success = await bookmarksService.addBookmark(userPubkey, repoAddress, userRelays); + success = await bookmarksService.addBookmark(state.user.pubkey, state.metadata.address, userRelays); } if (success) { - isBookmarked = !isBookmarked; + state.bookmark.isBookmarked = !state.bookmark.isBookmarked; } else { - alert(`Failed to ${isBookmarked ? 'remove' : 'add'} bookmark. Please try again.`); + alert(`Failed to ${state.bookmark.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)}`); + alert(`Failed to ${state.bookmark.isBookmarked ? 'remove' : 'add'} bookmark: ${String(err)}`); } finally { - loadingBookmark = false; + state.loading.bookmark = false; } } async function copyEventId() { - if (!repoAddress || !repoOwnerPubkeyDerived) { + if (!state.metadata.address || !repoOwnerPubkeyDerived) { alert('Repository address not available'); return; } try { // Parse the repo address: kind:pubkey:identifier - const parts = repoAddress.split(':'); + const parts = state.metadata.address.split(':'); if (parts.length < 3) { throw new Error('Invalid repository address format'); } @@ -2958,32 +2624,32 @@ } async function checkMaintainerStatus() { - if (repoNotFound || !userPubkey) { - isMaintainer = false; + if (state.repoNotFound || !state.user.pubkey) { + state.maintainers.isMaintainer = false; return; } - loadingMaintainerStatus = true; + state.loading.maintainerStatus = true; try { - const response = await fetch(`/api/repos/${npub}/${repo}/maintainers?userPubkey=${encodeURIComponent(userPubkey)}`); + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/maintainers?userPubkey=${encodeURIComponent(state.user.pubkey)}`); if (response.ok) { const data = await response.json(); - isMaintainer = data.isMaintainer || false; + state.maintainers.isMaintainer = data.state.maintainers.isMaintainer || false; } } catch (err) { console.error('Failed to check maintainer status:', err); - isMaintainer = false; + state.maintainers.isMaintainer = false; } finally { - loadingMaintainerStatus = false; + state.loading.maintainerStatus = false; } } async function loadAllMaintainers() { - if (repoNotFound || loadingMaintainers) return; + if (state.repoNotFound || state.loading.maintainers) return; - loadingMaintainers = true; + state.loading.maintainers = true; try { - const response = await fetch(`/api/repos/${npub}/${repo}/maintainers`); + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/maintainers`); if (response.ok) { const data = await response.json(); const owner = data.owner; @@ -3028,54 +2694,54 @@ allMaintainersList.unshift({ pubkey: owner, isOwner: true }); } - allMaintainers = allMaintainersList; + state.maintainers.all = allMaintainersList; } } catch (err) { console.error('Failed to load maintainers:', err); - maintainersLoaded = false; // Reset flag on error + state.maintainers.loaded = false; // Reset flag on state.error // Fallback to pageData if available if (repoOwnerPubkeyDerived) { - allMaintainers = [{ pubkey: repoOwnerPubkeyDerived, isOwner: true }]; + state.maintainers.all = [{ pubkey: repoOwnerPubkeyDerived, isOwner: true }]; if (repoMaintainers) { for (const maintainer of repoMaintainers) { if (maintainer.toLowerCase() !== repoOwnerPubkeyDerived.toLowerCase()) { - allMaintainers.push({ pubkey: maintainer, isOwner: false }); + state.maintainers.all.push({ pubkey: maintainer, isOwner: false }); } } } } } finally { - loadingMaintainers = false; + state.loading.maintainers = false; } } async function checkVerification() { - if (repoNotFound) return; - loadingVerification = true; + if (state.repoNotFound) return; + state.loading.verification = true; try { - const response = await fetch(`/api/repos/${npub}/${repo}/verify`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/verify`, { headers: buildApiHeaders() }); if (response.ok) { const data = await response.json(); console.log('[Verification] Response:', data); - verificationStatus = data; + state.verification.status = data; } else { console.warn('[Verification] Response not OK:', response.status, response.statusText); - verificationStatus = { verified: false, error: `Verification check failed: ${response.status}` }; + state.verification.status = { 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' }; + state.verification.status = { verified: false, error: 'Failed to check verification' }; } finally { - loadingVerification = false; - console.log('[Verification] Status after check:', verificationStatus); + state.loading.verification = false; + console.log('[Verification] Status after check:', state.verification.status); } } async function generateAnnouncementFileForRepo() { - if (!repoOwnerPubkeyDerived || !userPubkeyHex) { - error = 'Unable to generate announcement file: missing repository or user information'; + if (!repoOwnerPubkeyDerived || !state.user.pubkeyHex) { + state.error = 'Unable to generate announcement file: missing repository or user information'; return; } @@ -3086,30 +2752,30 @@ { kinds: [KIND.REPO_ANNOUNCEMENT], authors: [repoOwnerPubkeyDerived], - '#d': [repo], + '#d': [state.repo], limit: 1 } ]); if (events.length === 0) { - error = 'Repository announcement not found. Please ensure the repository is registered on Nostr.'; + 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) - verificationFileContent = JSON.stringify(announcement, null, 2) + '\n'; - showVerificationDialog = true; + state.verification.fileContent = JSON.stringify(announcement, null, 2) + '\n'; + state.openDialog = 'verification'; } catch (err) { console.error('Failed to generate announcement file:', err); - error = `Failed to generate announcement file: ${err instanceof Error ? err.message : String(err)}`; + state.error = `Failed to generate announcement file: ${err instanceof Error ? err.message : String(err)}`; } } function copyVerificationToClipboard() { - if (!verificationFileContent) return; + if (!state.verification.fileContent) return; - navigator.clipboard.writeText(verificationFileContent).then(() => { + navigator.clipboard.writeText(state.verification.fileContent).then(() => { alert('Verification file content copied to clipboard!'); }).catch((err) => { console.error('Failed to copy:', err); @@ -3119,21 +2785,21 @@ // Verify clone URL by committing announcement async function verifyCloneUrl() { - if (!selectedCloneUrlForVerification || !userPubkey || !userPubkeyHex) { - error = 'Unable to verify: missing information'; + if (!state.verification.selectedCloneUrl || !state.user.pubkey || !state.user.pubkeyHex) { + state.error = 'Unable to verify: missing information'; return; } - if (!isMaintainer && userPubkeyHex !== repoOwnerPubkeyDerived) { - error = 'Only repository owners and maintainers can verify clone URLs'; + if (!state.maintainers.isMaintainer && state.user.pubkeyHex !== repoOwnerPubkeyDerived) { + state.error = 'Only repository owners and maintainers can verify clone URLs'; return; } - verifyingCloneUrl = true; - error = null; + // selectedCloneUrl is already set when user selects it + state.error = null; try { - const response = await fetch(`/api/repos/${npub}/${repo}/verify`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/verify`, { method: 'POST', headers: buildApiHeaders() }); @@ -3146,8 +2812,8 @@ const data = await response.json(); // Close dialog - showCloneUrlVerificationDialog = false; - selectedCloneUrlForVerification = null; + state.openDialog = null; + state.verification.selectedCloneUrl = null; // Reload verification status after a short delay setTimeout(() => { @@ -3159,20 +2825,20 @@ // Show success message alert(data.message || 'Repository verification initiated. The verification status will update shortly.'); } catch (err) { - error = err instanceof Error ? err.message : 'Failed to verify repository'; + state.error = err instanceof Error ? err.message : 'Failed to verify repository'; console.error('Error verifying clone URL:', err); } finally { - verifyingCloneUrl = false; + state.verification.selectedCloneUrl = null; } } async function deleteAnnouncement() { - if (!userPubkey || !userPubkeyHex) { + if (!state.user.pubkey || !state.user.pubkeyHex) { alert('Please connect your NIP-07 extension'); return; } - if (!repoOwnerPubkeyDerived || userPubkeyHex !== repoOwnerPubkeyDerived) { + if (!repoOwnerPubkeyDerived || state.user.pubkeyHex !== repoOwnerPubkeyDerived) { alert('Only the repository owner can delete the announcement'); return; } @@ -3187,8 +2853,8 @@ return; } - deletingAnnouncement = true; - error = null; + state.creating.announcement = true; + state.error = null; try { // Fetch the repository announcement to get its event ID @@ -3197,7 +2863,7 @@ { kinds: [KIND.REPO_ANNOUNCEMENT], authors: [repoOwnerPubkeyDerived], - '#d': [repo], + '#d': [state.repo], limit: 1 } ]); @@ -3210,18 +2876,18 @@ announcementEventId = announcement.id; // Get user relays - const { outbox } = await getUserRelays(userPubkeyHex, nostrClient); + 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: userPubkeyHex, + pubkey: state.user.pubkeyHex, created_at: Math.floor(Date.now() / 1000), - content: `Requesting deletion of repository announcement for ${repo}`, + content: `Requesting deletion of repository announcement for ${state.repo}`, tags: [ ['e', announcement.id], // Reference to the announcement event - ['a', `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkeyDerived}:${repo}`], // Repository address + ['a', `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkeyDerived}:${state.repo}`], // Repository address ['k', KIND.REPO_ANNOUNCEMENT.toString()] // Kind of event being deleted ] }; @@ -3239,17 +2905,17 @@ } } catch (err) { console.error('Failed to delete announcement:', err); - error = err instanceof Error ? err.message : 'Failed to send deletion request'; - alert(error); + state.error = err instanceof Error ? err.message : 'Failed to send deletion request'; + alert(state.error); } finally { - deletingAnnouncement = false; + state.creating.announcement = false; } } function downloadVerificationFile() { - if (!verificationFileContent) return; + if (!state.verification.fileContent) return; - const blob = new Blob([verificationFileContent], { type: 'text/plain' }); + const blob = new Blob([state.verification.fileContent], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -3326,8 +2992,8 @@ // Download function - now using extracted utility async function downloadRepository(ref?: string, filename?: string): Promise { await downloadRepoUtil({ - npub, - repo, + npub: state.npub, + repo: state.repo, ref, filename }); @@ -3335,82 +3001,82 @@ async function loadBranches() { try { - const response = await fetch(`/api/repos/${npub}/${repo}/branches`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/branches`, { headers: buildApiHeaders() }); if (response.ok) { - branches = await response.json(); + state.git.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 repo is not cloned but we got state.git.branches, API fallback is available + if (state.clone.isCloned === false && state.git.branches.length > 0) { + state.clone.apiFallbackAvailable = true; } - if (branches.length > 0) { + if (state.git.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); + const branchNames = state.git.branches.map((b: any) => typeof b === 'string' ? b : b.name); // Fetch the actual default branch from the API try { - const defaultBranchResponse = await fetch(`/api/repos/${npub}/${repo}/default-branch`, { + const defaultBranchResponse = await fetch(`/api/repos/${state.npub}/${state.repo}/default-branch`, { headers: buildApiHeaders() }); if (defaultBranchResponse.ok) { const defaultBranchData = await defaultBranchResponse.json(); - defaultBranch = defaultBranchData.defaultBranch || defaultBranchData.branch || null; + state.git.defaultBranch = defaultBranchData.state.git.defaultBranch || defaultBranchData.branch || null; } } catch (err) { console.warn('Failed to fetch default branch, using fallback logic:', err); } // Fallback: Detect default branch: prefer master, then main, then first branch - if (!defaultBranch) { + if (!state.git.defaultBranch) { if (branchNames.includes('master')) { - defaultBranch = 'master'; + state.git.defaultBranch = 'master'; } else if (branchNames.includes('main')) { - defaultBranch = 'main'; + state.git.defaultBranch = 'main'; } else { - defaultBranch = branchNames[0]; + state.git.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; + // Only update state.git.currentBranch if it's not set or if the current branch doesn't exist + // Also validate that state.git.currentBranch doesn't contain invalid characters (like '#') + if (!state.git.currentBranch || + typeof state.git.currentBranch !== 'string' || + state.git.currentBranch.includes('#') || + !branchNames.includes(state.git.currentBranch)) { + state.git.currentBranch = state.git.defaultBranch; } } else { - // No branches exist - set currentBranch to null to show "no branches" in header - currentBranch = null; + // No state.git.branches exist - set state.git.currentBranch to null to show "no state.git.branches" in header + state.git.currentBranch = null; } } else if (response.status === 404) { - // Check if this is a "not cloned" error - API fallback might be available + // Check if this is a "not cloned" state.error - API fallback might be available const errorText = await response.text().catch(() => ''); if (errorText.includes('not cloned locally')) { // Repository is not cloned - check if API fallback might be available if (repoCloneUrls && repoCloneUrls.length > 0) { // We have clone URLs, so API fallback might work - mark as unknown for now // It will be set to true if a subsequent request succeeds - apiFallbackAvailable = null; - // Don't set repoNotFound or error yet - allow API fallback to be attempted + state.clone.apiFallbackAvailable = null; + // Don't set state.repoNotFound or state.error yet - allow API fallback to be attempted } else { // No clone URLs, API fallback won't work - 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.`; + state.repoNotFound = true; + state.clone.apiFallbackAvailable = false; + state.error = errorText || `Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`; } } else { // Generic 404 - repository doesn't exist - 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.`; + state.repoNotFound = true; + state.clone.apiFallbackAvailable = false; + state.error = `Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`; } } else if (response.status === 403) { - // Access denied - don't set repoNotFound, allow retry after login + // Access denied - don't set state.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.`; + state.error = `Access denied: ${errorText}. You may need to log in or you may not have permission to view this repository.`; console.warn('[Branches] Access denied, user may need to log in'); } } catch (err) { @@ -3420,59 +3086,59 @@ async function loadFiles(path: string = '') { // Skip if repository doesn't exist - if (repoNotFound) return; + if (state.repoNotFound) return; - loading = true; - error = null; + state.loading.main = true; + state.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; + if (typeof state.git.currentBranch === 'string' && state.git.currentBranch.trim() !== '' && !state.git.currentBranch.includes('#')) { + const branchNames = state.git.branches.map((b: any) => typeof b === 'string' ? b : b.name); + if (branchNames.includes(state.git.currentBranch)) { + branchName = state.git.currentBranch; } else { - branchName = defaultBranch || (branches.length > 0 - ? (typeof branches[0] === 'string' ? branches[0] : branches[0].name) + branchName = state.git.defaultBranch || (state.git.branches.length > 0 + ? (typeof state.git.branches[0] === 'string' ? state.git.branches[0] : state.git.branches[0].name) : 'HEAD'); } } else { - branchName = defaultBranch || (branches.length > 0 - ? (typeof branches[0] === 'string' ? branches[0] : branches[0].name) + branchName = state.git.defaultBranch || (state.git.branches.length > 0 + ? (typeof state.git.branches[0] === 'string' ? state.git.branches[0] : state.git.branches[0].name) : 'HEAD'); } - const url = `/api/repos/${npub}/${repo}/tree?ref=${encodeURIComponent(branchName)}&path=${encodeURIComponent(path)}`; + const url = `/api/repos/${state.npub}/${state.repo}/tree?ref=${encodeURIComponent(branchName)}&path=${encodeURIComponent(path)}`; const response = await fetch(url, { headers: buildApiHeaders() }); if (!response.ok) { if (response.status === 404) { - // Check if this is a "not cloned" error - API fallback might be available + // Check if this is a "not cloned" state.error - API fallback might be available const errorText = await response.text().catch(() => ''); if (errorText.includes('not cloned locally')) { // Repository is not cloned - check if API fallback might be available if (repoCloneUrls && repoCloneUrls.length > 0) { // We have clone URLs, so API fallback might work - mark as unknown for now // It will be set to true if a subsequent request succeeds - apiFallbackAvailable = null; - // Don't set repoNotFound - allow API fallback to be attempted + state.clone.apiFallbackAvailable = null; + // Don't set state.repoNotFound - allow API fallback to be attempted } else { // No clone URLs, API fallback won't work - repoNotFound = true; - apiFallbackAvailable = false; + state.repoNotFound = true; + state.clone.apiFallbackAvailable = false; } - // Throw error but use the actual error text from the API + // Throw state.error but use the actual state.error text from the API throw new Error(errorText || 'Repository not found. This repository exists in Nostr but hasn\'t been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.'); } else { // Generic 404 - repository doesn't exist - repoNotFound = true; - apiFallbackAvailable = false; + state.repoNotFound = true; + state.clone.apiFallbackAvailable = false; throw new Error(`Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`); } } else if (response.status === 403) { - // 403 means access denied - don't set repoNotFound, just show error + // 403 means access denied - don't set state.repoNotFound, just show state.error // This allows retry after login const accessDeniedError = new Error(`Access denied: ${response.statusText}. You may need to log in or you may not have permission to view this repository.`); // Log as info since this is normal client behavior (not logged in or no access) @@ -3482,20 +3148,20 @@ throw new Error(`Failed to load files: ${response.statusText}`); } - files = await response.json(); - currentPath = path; + state.files.list = await response.json(); + state.files.currentPath = path; - // If repo is not cloned but we got files, API fallback is available - if (isRepoCloned === false && files.length > 0) { - apiFallbackAvailable = true; + // If repo is not cloned but we got state.files.list, API fallback is available + if (state.clone.isCloned === false && state.files.list.length > 0) { + state.clone.apiFallbackAvailable = true; } // Auto-load README if we're in the root directory and no file is currently selected // Only attempt once per path to prevent loops - if (path === '' && !currentFile && !readmeAutoLoadAttempted) { - const readmeFile = findReadmeFile(files); + if (path === '' && !state.files.currentFile && !state.metadata.readmeAutoLoadAttempted) { + const readmeFile = findReadmeFile(state.files.list); if (readmeFile) { - readmeAutoLoadAttempted = true; + state.metadata.readmeAutoLoadAttempted = true; // Clear any existing timeout if (readmeAutoLoadTimeout) { clearTimeout(readmeAutoLoadTimeout); @@ -3508,40 +3174,40 @@ if (err instanceof Error && err.message.includes('Too Many Requests')) { console.warn('[README] Rate limited, will retry later'); setTimeout(() => { - readmeAutoLoadAttempted = false; + state.metadata.readmeAutoLoadAttempted = false; }, 5000); // Retry after 5 seconds } else { // For other errors, reset immediately - readmeAutoLoadAttempted = false; + state.metadata.readmeAutoLoadAttempted = false; } }); readmeAutoLoadTimeout = null; }, 100); } - } else if (path !== '' || currentFile) { + } else if (path !== '' || state.files.currentFile) { // Reset flag when navigating away from root or when a file is selected - readmeAutoLoadAttempted = false; + state.metadata.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 + state.error = err instanceof Error ? err.message : 'Failed to load state.files.list'; + // Only log as state.error if it's not a 403 (access denied), which is normal behavior if (err instanceof Error && err.message.includes('Access denied')) { // Already logged as info above, don't log again } else { console.error('Error loading files:', err); } } finally { - loading = false; + state.loading.main = false; } } // Helper function to find README file in file list function findReadmeFile(fileList: Array<{ name: string; path: string; type: 'file' | 'directory' }>): { name: string; path: string; type: 'file' | 'directory' } | null { - // Priority order for README files (most common first) + // Priority order for README state.files.list (most common first) const readmeExtensions = ['md', 'markdown', 'txt', 'adoc', 'asciidoc', 'rst', 'org']; // First, try to find README with extensions (prioritized order) @@ -3577,41 +3243,41 @@ } async function loadFile(filePath: string) { - loading = true; - error = null; + state.loading.main = true; + state.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 + // Ensure state.git.currentBranch is a string (branch name), not an object + // If state.git.currentBranch is not set, use the first available branch or 'master' as fallback let branchName: string; - if (typeof 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; + if (typeof state.git.currentBranch === 'string' && state.git.currentBranch.trim() !== '') { + // Validate that state.git.currentBranch is actually a valid branch name + // Check if it exists in the state.git.branches list + const branchNames = state.git.branches.map((b: any) => typeof b === 'string' ? b : b.name); + if (branchNames.includes(state.git.currentBranch)) { + branchName = state.git.currentBranch; } else { - // 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) + // state.git.currentBranch is set but not in state.git.branches list, use state.git.defaultBranch or fallback + branchName = state.git.defaultBranch || (state.git.branches.length > 0 + ? (typeof state.git.branches[0] === 'string' ? state.git.branches[0] : state.git.branches[0].name) : 'HEAD'); } - } else if (typeof currentBranch === 'object' && currentBranch !== null && 'name' in currentBranch) { - branchName = (currentBranch as { name: string }).name; + } else if (typeof state.git.currentBranch === 'object' && state.git.currentBranch !== null && 'name' in state.git.currentBranch) { + branchName = (state.git.currentBranch as { name: string }).name; } else { - // currentBranch is null, undefined, or invalid - use defaultBranch or fallback - branchName = defaultBranch || (branches.length > 0 - ? (typeof branches[0] === 'string' ? branches[0] : branches[0].name) + // state.git.currentBranch is null, undefined, or invalid - use state.git.defaultBranch or fallback + branchName = state.git.defaultBranch || (state.git.branches.length > 0 + ? (typeof state.git.branches[0] === 'string' ? state.git.branches[0] : state.git.branches[0].name) : 'HEAD'); } // Final validation: ensure branchName is a valid string - // Note: We allow '#' in branch names for existing branches (they'll be URL-encoded) + // Note: We allow '#' in branch names for existing state.git.branches (they'll be URL-encoded) // Only reject if it's empty or not a string if (!branchName || typeof branchName !== 'string' || branchName.trim() === '') { console.warn('[loadFile] Invalid branch name detected, using fallback:', branchName); - branchName = defaultBranch || (branches.length > 0 - ? (typeof branches[0] === 'string' ? branches[0] : branches[0].name) + branchName = state.git.defaultBranch || (state.git.branches.length > 0 + ? (typeof state.git.branches[0] === 'string' ? state.git.branches[0] : state.git.branches[0].name) : 'HEAD'); } @@ -3619,23 +3285,23 @@ const ext = filePath.split('.').pop()?.toLowerCase() || ''; // Check if this is an image file BEFORE making the API call - isImageFile = isImageFileType(ext); + state.preview.file.isImage = 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; + if (state.preview.file.isImage) { + // For image state.files.list, construct the raw file URL and skip state.loading.main text content + state.preview.file.imageUrl = `/api/repos/${state.npub}/${state.repo}/raw?path=${encodeURIComponent(filePath)}&ref=${encodeURIComponent(branchName)}`; + state.files.content = ''; // Clear content for images + state.files.editedContent = ''; // Clear edited content for images + state.preview.file.html = ''; // Clear HTML for images + state.preview.file.highlightedContent = ''; // Clear highlighted content + state.files.language = 'text'; + state.files.currentFile = filePath; + state.files.hasChanges = false; } else { // Not an image, load file content normally - imageUrl = null; + state.preview.file.imageUrl = null; - const url = `/api/repos/${npub}/${repo}/file?path=${encodeURIComponent(filePath)}&ref=${encodeURIComponent(branchName)}`; + const url = `/api/repos/${state.npub}/${state.repo}/file?path=${encodeURIComponent(filePath)}&ref=${encodeURIComponent(branchName)}`; const response = await fetch(url, { headers: buildApiHeaders() }); @@ -3651,82 +3317,82 @@ } const data = await response.json(); - fileContent = data.content; - editedContent = data.content; - currentFile = filePath; - hasChanges = false; + state.files.content = data.content; + state.files.editedContent = data.content; + state.files.currentFile = filePath; + state.files.hasChanges = false; // Reset README auto-load flag when a file is successfully loaded if (filePath && filePath.toLowerCase().includes('readme')) { - readmeAutoLoadAttempted = false; + state.metadata.readmeAutoLoadAttempted = false; } if (ext === 'md' || ext === 'markdown') { - fileLanguage = 'markdown'; + state.files.language = 'markdown'; } else if (ext === 'adoc' || ext === 'asciidoc') { - fileLanguage = 'asciidoc'; + state.files.language = 'asciidoc'; } else { - fileLanguage = 'text'; + state.files.language = 'text'; } - // Reset preview mode to default (preview) when loading a new file - showFilePreview = true; - fileHtml = ''; + // Reset preview mode to default (preview) when state.loading.main a new file + state.preview.file.showPreview = true; + state.preview.file.html = ''; - // Render markdown/asciidoc/HTML/CSV 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 || ''); + // Render markdown/asciidoc/HTML/CSV state.files.list as HTML for preview + if (state.files.content && (ext === 'md' || ext === 'markdown' || ext === 'adoc' || ext === 'asciidoc' || ext === 'html' || ext === 'htm' || ext === 'csv')) { + await renderFileAsHtml(state.files.content, ext || ''); } // Apply syntax highlighting - // For 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 + // For state.files.list that support HTML preview (markdown, HTML, etc.), only show highlighting in raw mode + // For code state.files.list and other non-markup state.files.list, always show syntax highlighting const hasHtmlPreview = supportsPreview(ext); - if (fileContent) { + if (state.files.content) { if (hasHtmlPreview) { // Markup files: only show highlighting when not in preview mode (raw mode) - if (!showFilePreview) { - await applySyntaxHighlighting(fileContent, ext || ''); + if (!state.preview.file.showPreview) { + await applySyntaxHighlighting(state.files.content, ext || ''); } } else { // Code files and other non-markup files: always show syntax highlighting - await applySyntaxHighlighting(fileContent, ext || ''); + await applySyntaxHighlighting(state.files.content, ext || ''); } } } } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load file'; - console.error('Error loading file:', err); + state.error = err instanceof Error ? err.message : 'Failed to load file'; + console.error('Error state.loading.main file:', err); } finally { - loading = false; + state.loading.main = false; } } function handleContentChange(value: string) { - editedContent = value; - hasChanges = value !== fileContent; + state.files.editedContent = value; + state.files.hasChanges = value !== state.files.content; } function handleFileClick(file: { name: string; path: string; type: 'file' | 'directory' }) { if (file.type === 'directory') { - pathStack.push(currentPath); + state.files.pathStack.push(state.files.currentPath); loadFiles(file.path); } else { loadFile(file.path); // On mobile, switch to file viewer when a file is clicked if (window.innerWidth <= 768) { - showFileListOnMobile = false; + state.ui.showFileListOnMobile = false; } } } // Copy file content to clipboard async function copyFileContent(event?: Event) { - if (!fileContent || copyingFile) return; + if (!state.files.content || state.preview.copying) return; - copyingFile = true; + state.preview.copying = true; try { - await navigator.clipboard.writeText(fileContent); + await navigator.clipboard.writeText(state.files.content); // Show temporary feedback const button = event?.target as HTMLElement; if (button) { @@ -3740,17 +3406,17 @@ console.error('Failed to copy file content:', err); alert('Failed to copy file content to clipboard'); } finally { - copyingFile = false; + state.preview.copying = false; } } // Download file function downloadFile() { - if (!fileContent || !currentFile) return; + if (!state.files.content || !state.files.currentFile) return; try { // Determine MIME type based on file extension - const ext = currentFile.split('.').pop()?.toLowerCase() || ''; + const ext = state.files.currentFile.split('.').pop()?.toLowerCase() || ''; const mimeTypes: Record = { 'js': 'text/javascript', 'ts': 'text/typescript', @@ -3780,11 +3446,11 @@ }; const mimeType = mimeTypes[ext] || 'text/plain'; - const blob = new Blob([fileContent], { type: mimeType }); + const blob = new Blob([state.files.content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = currentFile.split('/').pop() || 'file'; + a.download = state.files.currentFile.split('/').pop() || 'file'; document.body.appendChild(a); a.click(); document.body.removeChild(a); @@ -3796,8 +3462,8 @@ } function handleBack() { - if (pathStack.length > 0) { - const parentPath = pathStack.pop() || ''; + if (state.files.pathStack.length > 0) { + const parentPath = state.files.pathStack.pop() || ''; loadFiles(parentPath); } else { loadFiles(''); @@ -3805,10 +3471,11 @@ } // Cache for user profile email and name - let cachedUserEmail = $state(null); - let cachedUserName = $state(null); - let fetchingUserEmail = $state(false); - let fetchingUserName = $state(false); + // Cached user data (not in state store - these are temporary caches) + let cachedUserEmail: string | null = null; + let cachedUserName: string | null = null; + let fetchingUserEmail = false; + let fetchingUserName = false; async function getUserEmail(): Promise { // Check settings store first @@ -3828,7 +3495,7 @@ } // If no user pubkey, can't proceed - if (!userPubkeyHex) { + if (!state.user.pubkeyHex) { throw new Error('User not authenticated'); } @@ -3846,11 +3513,11 @@ try { // Fetch from kind 0 event (cache or relays) - prefillEmail = await fetchUserEmail(userPubkeyHex, userPubkey || undefined, DEFAULT_NOSTR_RELAYS); + prefillEmail = await fetchUserEmail(state.user.pubkeyHex, state.user.pubkey || 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 npubFromPubkey = state.user.pubkeyHex ? nip19.npubEncode(state.user.pubkeyHex) : (state.user.pubkey || 'unknown'); const shortenedNpub = npubFromPubkey.substring(0, 20); prefillEmail = `${shortenedNpub}@gitrepublic.web`; } finally { @@ -3901,7 +3568,7 @@ } // If no user pubkey, can't proceed - if (!userPubkeyHex) { + if (!state.user.pubkeyHex) { throw new Error('User not authenticated'); } @@ -3919,11 +3586,11 @@ try { // Fetch from kind 0 event (cache or relays) - prefillName = await fetchUserName(userPubkeyHex, userPubkey || undefined, DEFAULT_NOSTR_RELAYS); + prefillName = await fetchUserName(state.user.pubkeyHex, state.user.pubkey || 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'); + const npubFromPubkey = state.user.pubkeyHex ? nip19.npubEncode(state.user.pubkeyHex) : (state.user.pubkey || 'unknown'); prefillName = npubFromPubkey.substring(0, 20); } finally { fetchingUserName = false; @@ -3979,9 +3646,9 @@ // 2. A file is open // 3. User is logged in // 4. User is a maintainer - // 5. Not currently saving + // 5. Not currently state.saving // 6. Not in clone state - if (!hasChanges || !currentFile || !userPubkey || !isMaintainer || saving || needsClone) { + if (!state.files.hasChanges || !state.files.currentFile || !state.user.pubkey || !state.maintainers.isMaintainer || state.saving || needsClone) { return; } @@ -4032,20 +3699,20 @@ } } - const response = await fetch(`/api/repos/${npub}/${repo}/file`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/file`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...buildApiHeaders() }, body: JSON.stringify({ - path: currentFile, - content: editedContent, - commitMessage: autoCommitMessage, + path: state.files.currentFile, + content: state.files.editedContent, + message: autoCommitMessage, authorName: authorName, authorEmail: authorEmail, - branch: currentBranch, - userPubkey: userPubkey, + branch: state.git.currentBranch, + userPubkey: state.user.pubkey, commitSignatureEvent: commitSignatureEvent }) }); @@ -4057,34 +3724,34 @@ } // Reload file to get updated content - await loadFile(currentFile); + await loadFile(state.files.currentFile); // Note: We don't show an alert for auto-save, it's silent - console.log('Auto-saved file:', currentFile); + console.log('Auto-saved file:', state.files.currentFile); } catch (err) { console.warn('Error during auto-save:', err); - // Don't show error to user, it's silent + // Don't show state.error to user, it's silent } } async function saveFile() { - if (!currentFile || !commitMessage.trim()) { + if (!state.files.currentFile || !state.forms.commit.message.trim()) { alert('Please enter a commit message'); return; } - if (!userPubkey) { - alert('Please connect your NIP-07 extension to save files'); + if (!state.user.pubkey) { + alert('Please connect your NIP-07 extension to save state.files.list'); return; } // Validate branch selection - if (!currentBranch || typeof currentBranch !== 'string') { - alert('Please select a branch before saving the file'); + if (!state.git.currentBranch || typeof state.git.currentBranch !== 'string') { + alert('Please select a branch before state.saving the file'); return; } - saving = true; - error = null; + state.saving = true; + state.error = null; try { // Get user email and name (from profile or prompt) @@ -4103,9 +3770,9 @@ created_at: timestamp, tags: [ ['author', authorName, authorEmail], - ['message', commitMessage.trim()] + ['message', state.forms.commit.message.trim()] ], - content: `Signed commit: ${commitMessage.trim()}` + content: `Signed commit: ${state.forms.commit.message.trim()}` }; commitSignatureEvent = await signEventWithNIP07(eventTemplate); } catch (err) { @@ -4114,45 +3781,45 @@ } } - const response = await fetch(`/api/repos/${npub}/${repo}/file`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/file`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...buildApiHeaders() }, body: JSON.stringify({ - path: currentFile, - content: editedContent, - commitMessage: commitMessage.trim(), + path: state.files.currentFile, + content: state.files.editedContent, + message: state.forms.commit.message.trim(), authorName: authorName, authorEmail: authorEmail, - branch: currentBranch, - userPubkey: userPubkey, + branch: state.git.currentBranch, + userPubkey: state.user.pubkey, 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'; + const errorMessage = errorData.message || errorData.state.error || 'Failed to save file'; throw new Error(errorMessage); } // Reload file to get updated content - await loadFile(currentFile); - commitMessage = ''; - showCommitDialog = false; + await loadFile(state.files.currentFile); + state.forms.commit.message = ''; + state.openDialog = null; alert('File saved successfully!'); } catch (err) { - error = err instanceof Error ? err.message : 'Failed to save file'; - console.error('Error saving file:', err); + state.error = err instanceof Error ? err.message : 'Failed to save file'; + console.error('Error state.saving file:', err); } finally { - saving = false; + state.saving = false; } } function handleBranchChangeDirect(branch: string) { - currentBranch = branch; + state.git.currentBranch = branch; // Create a synthetic event for the existing handler const syntheticEvent = { target: { value: branch } @@ -4162,32 +3829,32 @@ async function handleBranchChange(event: Event) { const target = event.target as HTMLSelectElement; - currentBranch = target.value; + state.git.currentBranch = target.value; // Reload all branch-dependent data const reloadPromises: Promise[] = []; - // 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))); + // Always reload state.files.list (and current file if open) + if (state.files.currentFile) { + reloadPromises.push(loadFile(state.files.currentFile).catch(err => console.warn('Failed to reload file after branch change:', err))); } else { - reloadPromises.push(loadFiles(currentPath).catch(err => console.warn('Failed to reload files after branch change:', err))); + reloadPromises.push(loadFiles(state.files.currentPath).catch(err => console.warn('Failed to reload state.files.list after branch change:', err))); } // Reload README (branch-specific) reloadPromises.push(loadReadme().catch(err => console.warn('Failed to reload README after branch change:', err))); // Reload commit history if history tab is active - if (activeTab === 'history') { + if (state.ui.activeTab === 'history') { reloadPromises.push(loadCommitHistory().catch(err => console.warn('Failed to reload commit history after branch change:', err))); } // Reload documentation if docs tab is active (might be branch-specific) - if (activeTab === 'docs') { + if (state.ui.activeTab === 'docs') { // Reset documentation to force reload - documentationHtml = null; - documentationContent = null; - documentationKind = null; + state.docs.html = null; + state.docs.content = null; + state.docs.kind = null; reloadPromises.push(loadDocumentation().catch(err => console.warn('Failed to reload documentation after branch change:', err))); } @@ -4196,31 +3863,31 @@ } async function createFile() { - if (!newFileName.trim()) { + if (!state.forms.file.fileName.trim()) { alert('Please enter a file name'); return; } - if (!userPubkey) { + if (!state.user.pubkey) { alert('Please connect your NIP-07 extension'); return; } // Validate branch selection - if (!currentBranch || typeof currentBranch !== 'string') { + if (!state.git.currentBranch || typeof state.git.currentBranch !== 'string') { alert('Please select a branch before creating the file'); return; } - saving = true; - error = null; + state.saving = true; + state.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}`; + const filePath = state.files.currentPath ? `${state.files.currentPath}/${state.forms.file.fileName}` : state.forms.file.fileName; + const commitMsg = `Create ${state.forms.file.fileName}`; // Sign commit with NIP-07 (client-side) let commitSignatureEvent: NostrEvent | null = null; @@ -4245,7 +3912,7 @@ } } - const response = await fetch(`/api/repos/${npub}/${repo}/file`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/file`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -4253,13 +3920,13 @@ }, body: JSON.stringify({ path: filePath, - content: newFileContent, - commitMessage: commitMsg, + content: state.forms.file.content, + message: commitMsg, authorName: authorName, authorEmail: authorEmail, - branch: currentBranch, + branch: state.git.currentBranch, action: 'create', - userPubkey: userPubkey, + userPubkey: state.user.pubkey, commitSignatureEvent: commitSignatureEvent // Send the signed event to server }) }); @@ -4269,15 +3936,15 @@ throw new Error(errorData.message || 'Failed to create file'); } - showCreateFileDialog = false; - newFileName = ''; - newFileContent = ''; - await loadFiles(currentPath); + state.openDialog = null; + state.forms.file.fileName = ''; + state.forms.file.content = ''; + await loadFiles(state.files.currentPath); alert('File created successfully!'); } catch (err) { - error = err instanceof Error ? err.message : 'Failed to create file'; + state.error = err instanceof Error ? err.message : 'Failed to create file'; } finally { - saving = false; + state.saving = false; } } @@ -4286,19 +3953,19 @@ return; } - if (!userPubkey) { + if (!state.user.pubkey) { alert('Please connect your NIP-07 extension'); return; } // Validate branch selection - if (!currentBranch || typeof currentBranch !== 'string') { + if (!state.git.currentBranch || typeof state.git.currentBranch !== 'string') { alert('Please select a branch before deleting the file'); return; } - saving = true; - error = null; + state.saving = true; + state.error = null; try { // Get user email and name (from profile or prompt) @@ -4329,7 +3996,7 @@ } } - const response = await fetch(`/api/repos/${npub}/${repo}/file`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/file`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -4337,12 +4004,12 @@ }, body: JSON.stringify({ path: filePath, - commitMessage: commitMsg, + message: commitMsg, authorName: authorName, authorEmail: authorEmail, - branch: currentBranch, + branch: state.git.currentBranch, action: 'delete', - userPubkey: userPubkey, + userPubkey: state.user.pubkey, commitSignatureEvent: commitSignatureEvent // Send the signed event to server }) }); @@ -4352,37 +4019,37 @@ throw new Error(errorData.message || 'Failed to delete file'); } - if (currentFile === filePath) { - currentFile = null; + if (state.files.currentFile === filePath) { + state.files.currentFile = null; } - await loadFiles(currentPath); + await loadFiles(state.files.currentPath); alert('File deleted successfully!'); } catch (err) { - error = err instanceof Error ? err.message : 'Failed to delete file'; + state.error = err instanceof Error ? err.message : 'Failed to delete file'; } finally { - saving = false; + state.saving = false; } } async function createBranch() { - if (!newBranchName.trim()) { + if (!state.forms.branch.name.trim()) { alert('Please enter a branch name'); return; } - saving = true; - error = null; + state.saving = true; + state.error = null; try { - // If no branches exist, don't pass fromBranch (will use --orphan) + // If no state.git.branches exist, don't pass fromBranch (will use --orphan) // Otherwise, use the selected branch or current branch - let fromBranch: string | undefined = newBranchFrom || currentBranch || undefined; + let fromBranch: string | undefined = state.forms.branch.from || state.git.currentBranch || undefined; // Include announcement if available (for empty repos) const requestBody: { branchName: string; fromBranch?: string; announcement?: NostrEvent } = { - branchName: newBranchName + branchName: state.forms.branch.name }; - if (branches.length > 0 && fromBranch) { + if (state.git.branches.length > 0 && fromBranch) { requestBody.fromBranch = fromBranch; } // Pass announcement if available (especially useful for empty repos) @@ -4390,7 +4057,7 @@ requestBody.announcement = repoAnnouncement; } - const response = await fetch(`/api/repos/${npub}/${repo}/branches`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/branches`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -4404,14 +4071,14 @@ throw new Error(errorData.message || 'Failed to create branch'); } - showCreateBranchDialog = false; - newBranchName = ''; + state.openDialog = null; + state.forms.branch.name = ''; await loadBranches(); alert('Branch created successfully!'); } catch (err) { - error = err instanceof Error ? err.message : 'Failed to create branch'; + state.error = err instanceof Error ? err.message : 'Failed to create branch'; } finally { - saving = false; + state.saving = false; } } @@ -4420,22 +4087,22 @@ return; } - if (!userPubkey) { + if (!state.user.pubkey) { alert('Please connect your NIP-07 extension'); return; } // Prevent deleting the current branch - if (branchName === currentBranch) { + if (branchName === state.git.currentBranch) { alert('Cannot delete the currently selected branch. Please switch to a different branch first.'); return; } - saving = true; - error = null; + state.saving = true; + state.error = null; try { - const response = await fetch(`/api/repos/${npub}/${repo}/branches`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/branches`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', @@ -4454,24 +4121,24 @@ await loadBranches(); alert('Branch deleted successfully!'); } catch (err) { - error = err instanceof Error ? err.message : 'Failed to delete branch'; - alert(error); + state.error = err instanceof Error ? err.message : 'Failed to delete branch'; + alert(state.error); } finally { - saving = false; + state.saving = false; } } async function loadCommitHistory() { - loadingCommits = true; - error = null; + state.loading.commits = true; + state.error = null; try { - const response = await fetch(`/api/repos/${npub}/${repo}/commits?branch=${currentBranch}&limit=50`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/commits?branch=${state.git.currentBranch}&limit=50`, { headers: buildApiHeaders() }); if (response.ok) { const data = await response.json(); // Normalize commits: API-based commits use 'sha', local commits use 'hash' - commits = data.map((commit: any) => ({ + state.git.commits = data.map((commit: any) => ({ hash: commit.hash || commit.sha || '', message: commit.message || 'No message', author: commit.author || 'Unknown', @@ -4479,9 +4146,9 @@ files: commit.files || [] })).filter((commit: any) => commit.hash); // Filter out commits without hash - // Verify commits in background (only for cloned repos) - if (isRepoCloned === true) { - commits.forEach(commit => { + // Verify state.git.commits in background (only for cloned repos) + if (state.clone.isCloned === true) { + state.git.commits.forEach(commit => { verifyCommit(commit.hash).catch(err => { console.warn(`Failed to verify commit ${commit.hash}:`, err); }); @@ -4489,19 +4156,19 @@ } } } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load commit history'; + state.error = err instanceof Error ? err.message : 'Failed to load commit history'; } finally { - loadingCommits = false; + state.loading.commits = false; } } async function verifyCommit(commitHash: string) { - if (verifyingCommits.has(commitHash)) return; // Already verifying - if (!isRepoCloned) return; // Can't verify without local repo + if (state.git.verifyingCommits.has(commitHash)) return; // Already verifying + if (!state.clone.isCloned) return; // Can't verify without local repo - verifyingCommits.add(commitHash); + state.git.verifyingCommits.add(commitHash); try { - const response = await fetch(`/api/repos/${npub}/${repo}/commits/${commitHash}/verify`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/commits/${commitHash}/verify`, { headers: buildApiHeaders() }); if (response.ok) { @@ -4509,76 +4176,76 @@ // Only update verification if there's actually a signature // If hasSignature is false or undefined, don't set verification at all if (verification.hasSignature !== false) { - const commitIndex = commits.findIndex(c => c.hash === commitHash); + const commitIndex = state.git.commits.findIndex(c => c.hash === commitHash); if (commitIndex >= 0) { - commits[commitIndex].verification = verification; + state.git.commits[commitIndex].verification = verification; } } } } catch (err) { console.warn(`Failed to verify commit ${commitHash}:`, err); } finally { - verifyingCommits.delete(commitHash); + state.git.verifyingCommits.delete(commitHash); } } 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; + state.git.selectedCommit = commitHash; + state.git.showDiff = false; // Start with false, will be set to true when diff loads + state.loading.commits = true; + state.error = null; try { // Normalize commit hash (handle both 'hash' and 'sha' properties) const getCommitHash = (c: any) => c.hash || c.sha || ''; - const commitIndex = commits.findIndex(c => getCommitHash(c) === commitHash); + const commitIndex = state.git.commits.findIndex(c => getCommitHash(c) === commitHash); const parentHash = commitIndex >= 0 - ? (commits[commitIndex + 1] ? getCommitHash(commits[commitIndex + 1]) : `${commitHash}^`) + ? (state.git.commits[commitIndex + 1] ? getCommitHash(state.git.commits[commitIndex + 1]) : `${commitHash}^`) : `${commitHash}^`; - const response = await fetch(`/api/repos/${npub}/${repo}/diff?from=${parentHash}&to=${commitHash}`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/diff?from=${parentHash}&to=${commitHash}`, { headers: buildApiHeaders() }); if (response.ok) { - diffData = await response.json(); - showDiff = true; + state.git.diffData = await response.json(); + state.git.showDiff = true; } else { // Handle 404 or other errors const errorText = await response.text().catch(() => response.statusText); if (response.status === 404) { // Check if this is an API fallback commit (repo not cloned or empty) - if (isRepoCloned === false || (isRepoCloned === true && apiFallbackAvailable)) { - error = 'Diffs are not available for commits viewed via API fallback. Please clone the repository to view diffs.'; + if (state.clone.isCloned === false || (state.clone.isCloned === true && state.clone.apiFallbackAvailable)) { + state.error = 'Diffs are not available for commits viewed via API fallback. Please clone the repository to view diffs.'; } else { - error = `Commit not found: ${errorText || 'The commit may not exist in the repository'}`; + state.error = `Commit not found: ${errorText || 'The commit may not exist in the repository'}`; } } else { - error = `Failed to load diff: ${errorText || response.statusText}`; + state.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.'; + state.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'; + state.error = err instanceof Error ? err.message : 'Failed to load diff'; } } finally { - loadingCommits = false; + state.loading.commits = false; } } async function loadTags() { - if (repoNotFound) return; + if (state.repoNotFound) return; try { - const response = await fetch(`/api/repos/${npub}/${repo}/tags`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/tags`, { headers: buildApiHeaders() }); if (response.ok) { - tags = await response.json(); + state.git.tags = await response.json(); // Auto-select first tag if none selected - if (tags.length > 0 && !selectedTag) { - selectedTag = tags[0].name; + if (state.git.tags.length > 0 && !state.git.selectedTag) { + state.git.selectedTag = state.git.tags[0].name; } } } catch (err) { @@ -4587,31 +4254,31 @@ } async function createTag() { - if (!newTagName.trim()) { + if (!state.forms.tag.name.trim()) { alert('Please enter a tag name'); return; } - if (!userPubkey) { + if (!state.user.pubkey) { alert('Please connect your NIP-07 extension'); return; } - saving = true; - error = null; + state.saving = true; + state.error = null; try { - const response = await fetch(`/api/repos/${npub}/${repo}/tags`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/tags`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...buildApiHeaders() }, body: JSON.stringify({ - tagName: newTagName, - ref: newTagRef, - message: newTagMessage || undefined, - userPubkey: userPubkey + tagName: state.forms.tag.name, + ref: state.forms.tag.ref, + message: state.forms.tag.message || undefined, + userPubkey: state.user.pubkey }) }); @@ -4620,28 +4287,28 @@ throw new Error(errorData.message || 'Failed to create tag'); } - showCreateTagDialog = false; - newTagName = ''; - newTagMessage = ''; + state.openDialog = null; + state.forms.tag.name = ''; + state.forms.tag.message = ''; await loadTags(); alert('Tag created successfully!'); } catch (err) { - error = err instanceof Error ? err.message : 'Failed to create tag'; + state.error = err instanceof Error ? err.message : 'Failed to create tag'; } finally { - saving = false; + state.saving = false; } } async function loadReleases() { - if (repoNotFound) return; - loadingReleases = true; + if (state.repoNotFound) return; + state.loading.releases = true; try { - const response = await fetch(`/api/repos/${npub}/${repo}/releases`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/releases`, { headers: buildApiHeaders() }); if (response.ok) { const data = await response.json(); - releases = data.map((release: any) => ({ + state.releases = data.map((release: any) => ({ id: release.id, tagName: release.tags.find((t: string[]) => t[0] === 'tag')?.[1] || '', tagHash: release.tags.find((t: string[]) => t[0] === 'r' && t[2] === 'tag')?.[1], @@ -4653,44 +4320,44 @@ })); } } catch (err) { - console.error('Failed to load releases:', err); + console.error('Failed to load state.releases:', err); } finally { - loadingReleases = false; + state.loading.releases = false; } } async function createRelease() { - if (!newReleaseTagName.trim() || !newReleaseTagHash.trim()) { + if (!state.forms.release.tagName.trim() || !state.forms.release.tagHash.trim()) { alert('Please enter a tag name and tag hash'); return; } - if (!userPubkey) { + if (!state.user.pubkey) { alert('Please connect your NIP-07 extension'); return; } - if (!isMaintainer && userPubkeyHex !== repoOwnerPubkeyDerived) { - alert('Only repository owners and maintainers can create releases'); + if (!state.maintainers.isMaintainer && state.user.pubkeyHex !== repoOwnerPubkeyDerived) { + alert('Only repository owners and maintainers can create state.releases'); return; } - creatingRelease = true; - error = null; + state.creating.release = true; + state.error = null; try { - const response = await fetch(`/api/repos/${npub}/${repo}/releases`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/releases`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...buildApiHeaders() }, body: JSON.stringify({ - tagName: newReleaseTagName, - tagHash: newReleaseTagHash, - releaseNotes: newReleaseNotes, - isDraft: newReleaseIsDraft, - isPrerelease: newReleaseIsPrerelease + tagName: state.forms.release.tagName, + tagHash: state.forms.release.tagHash, + releaseNotes: state.forms.release.notes, + isDraft: state.forms.release.isDraft, + isPrerelease: state.forms.release.isPrerelease }) }); @@ -4699,43 +4366,43 @@ throw new Error(errorData.message || 'Failed to create release'); } - showCreateReleaseDialog = false; - newReleaseTagName = ''; - newReleaseTagHash = ''; - newReleaseNotes = ''; - newReleaseIsDraft = false; - newReleaseIsPrerelease = false; + state.openDialog = null; + state.forms.release.tagName = ''; + state.forms.release.tagHash = ''; + state.forms.release.notes = ''; + state.forms.release.isDraft = false; + state.forms.release.isPrerelease = false; await loadReleases(); - // Reload tags to show release indicator + // Reload state.git.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); + state.error = err instanceof Error ? err.message : 'Failed to create release'; + alert(state.error); } finally { - creatingRelease = false; + state.creating.release = false; } } async function performCodeSearch() { - if (!codeSearchQuery.trim() || codeSearchQuery.length < 2) { - codeSearchResults = []; + if (!state.codeSearch.query.trim() || state.codeSearch.query.length < 2) { + state.codeSearch.results = []; return; } - loadingCodeSearch = true; - error = null; + state.loading.codeSearch = true; + state.error = null; try { // Get current branch for repo-specific search - const branchParam = codeSearchScope === 'repo' && currentBranch - ? `&branch=${encodeURIComponent(currentBranch)}` + const branchParam = state.codeSearch.scope === 'repo' && state.git.currentBranch + ? `&branch=${encodeURIComponent(state.git.currentBranch)}` : ''; // For "All Repositories", don't pass repo filter - let it search all repos - const url = codeSearchScope === 'repo' - ? `/api/repos/${npub}/${repo}/code-search?q=${encodeURIComponent(codeSearchQuery.trim())}${branchParam}` - : `/api/code-search?q=${encodeURIComponent(codeSearchQuery.trim())}`; + const url = state.codeSearch.scope === 'repo' + ? `/api/repos/${state.npub}/${state.repo}/code-search?q=${encodeURIComponent(state.codeSearch.query.trim())}${branchParam}` + : `/api/code-search?q=${encodeURIComponent(state.codeSearch.query.trim())}`; const response = await fetch(url, { headers: buildApiHeaders() @@ -4743,7 +4410,7 @@ if (response.ok) { const data = await response.json(); - codeSearchResults = Array.isArray(data) ? data : []; + state.codeSearch.results = Array.isArray(data) ? data : []; } else { let errorMessage = 'Failed to search code'; try { @@ -4757,23 +4424,23 @@ } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to search code'; console.error('[Code Search] Error:', err); - error = errorMessage; - codeSearchResults = []; + state.error = errorMessage; + state.codeSearch.results = []; } finally { - loadingCodeSearch = false; + state.loading.codeSearch = false; } } async function loadIssues() { - loadingIssues = true; - error = null; + state.loading.issues = true; + state.error = null; try { - const response = await fetch(`/api/repos/${npub}/${repo}/issues`, { + const response = await fetch(`/api/repos/${state.npub}/${state.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 }) => ({ + state.issues = data.map((issue: { id: string; tags: string[][]; content: string; status?: string; pubkey: string; created_at: number; kind?: number }) => ({ id: issue.id, subject: issue.tags.find((t: string[]) => t[0] === 'subject')?.[1] || 'Untitled', content: issue.content, @@ -4784,14 +4451,14 @@ tags: issue.tags || [] })); // Auto-select first issue if none selected - if (issues.length > 0 && !selectedIssue) { - selectedIssue = issues[0].id; - loadIssueReplies(issues[0].id); + if (state.issues.length > 0 && !state.selected.issue) { + state.selected.issue = state.issues[0].id; + loadIssueReplies(state.issues[0].id); } } else { // Handle non-OK responses const errorText = await response.text().catch(() => response.statusText); - let errorMessage = `Failed to load issues: ${response.status} ${response.statusText}`; + let errorMessage = `Failed to load state.issues: ${response.status} ${response.statusText}`; try { const errorData = JSON.parse(errorText); if (errorData.message) { @@ -4804,21 +4471,21 @@ } } 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 + state.error = errorMessage; + // Don't clear state.issues array - keep existing state.issues if any + // state.issues = []; // Only clear if you want to show empty state on state.error } } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to load issues'; - console.error('[Issues] Error loading issues:', err); - error = errorMessage; + const errorMessage = err instanceof Error ? err.message : 'Failed to load state.issues'; + console.error('[Issues] Error state.loading.main state.issues:', err); + state.error = errorMessage; } finally { - loadingIssues = false; + state.loading.issues = false; } } async function loadIssueReplies(issueId: string) { - loadingIssueReplies = true; + state.loading.issueReplies = true; try { const replies = await nostrClient.fetchEvents([ { @@ -4828,7 +4495,7 @@ } ]) as NostrEvent[]; - issueReplies = replies.map(reply => ({ + state.issueReplies = replies.map(reply => ({ id: reply.id, content: reply.content, author: reply.pubkey, @@ -4836,31 +4503,31 @@ tags: reply.tags || [] })).sort((a, b) => a.created_at - b.created_at); } catch (err) { - console.error('[Issues] Error loading replies:', err); - issueReplies = []; + console.error('[Issues] Error state.loading.main replies:', err); + state.issueReplies = []; } finally { - loadingIssueReplies = false; + state.loading.issueReplies = false; } } async function createIssue() { - if (!newIssueSubject.trim() || !newIssueContent.trim()) { + if (!state.forms.issue.subject.trim() || !state.forms.issue.content.trim()) { alert('Please enter a subject and content'); return; } - if (!userPubkey) { + if (!state.user.pubkey) { alert('Please connect your NIP-07 extension'); return; } - saving = true; - error = null; + state.saving = true; + state.error = null; try { const { IssuesService } = await import('$lib/services/nostr/issues-service.js'); - const decoded = nip19.decode(npub); + const decoded = nip19.decode(state.npub); if (decoded.type !== 'npub') { throw new Error('Invalid npub format'); } @@ -4868,43 +4535,43 @@ // Get user's relays and combine with defaults const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS); - const { outbox } = await getUserRelays(userPubkey, tempClient); + const { outbox } = await getUserRelays(state.user.pubkey, 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()) + state.repo, + state.forms.issue.subject.trim(), + state.forms.issue.content.trim(), + state.forms.issue.labels.filter(l => l.trim()) ); - showCreateIssueDialog = false; - newIssueSubject = ''; - newIssueContent = ''; - newIssueLabels = ['']; + state.openDialog = null; + state.forms.issue.subject = ''; + state.forms.issue.content = ''; + state.forms.issue.labels = ['']; await loadIssues(); alert('Issue created successfully!'); } catch (err) { - error = err instanceof Error ? err.message : 'Failed to create issue'; + state.error = err instanceof Error ? err.message : 'Failed to create issue'; console.error('Error creating issue:', err); } finally { - saving = false; + state.saving = false; } } async function updatePatchStatus(patchId: string, patchAuthor: string, status: string) { - if (!userPubkey || !userPubkeyHex) { - error = 'Please log in to update patch status'; + if (!state.user.pubkey || !state.user.pubkeyHex) { + state.error = 'Please log in to update patch status'; return; } - updatingPatchStatus[patchId] = true; - error = null; + state.statusUpdates.patch[patchId] = true; + state.error = null; try { - const response = await fetch(`/api/repos/${npub}/${repo}/patches`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/patches`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', @@ -4922,34 +4589,34 @@ throw new Error(errorData.message || `Failed to update patch status: ${response.statusText}`); } - // Reload patches to get updated status + // Reload state.patches to get updated status await loadPatches(); } catch (err) { - error = err instanceof Error ? err.message : 'Failed to update patch status'; + state.error = err instanceof Error ? err.message : 'Failed to update patch status'; console.error('Error updating patch status:', err); } finally { - updatingPatchStatus[patchId] = false; + state.statusUpdates.patch[patchId] = false; } } async function updateIssueStatus(issueId: string, issueAuthor: string, status: 'open' | 'closed' | 'resolved' | 'draft') { - if (!userPubkeyHex) { + if (!state.user.pubkeyHex) { alert('Please connect your NIP-07 extension'); return; } // Check if user is maintainer or issue author - const isAuthor = userPubkeyHex === issueAuthor; - if (!isMaintainer && !isAuthor) { + const isAuthor = state.user.pubkeyHex === issueAuthor; + if (!state.maintainers.isMaintainer && !isAuthor) { alert('Only repository maintainers or issue authors can update issue status'); return; } - updatingIssueStatus = { ...updatingIssueStatus, [issueId]: true }; - error = null; + state.statusUpdates.issue = { ...state.statusUpdates.issue, [issueId]: true }; + state.error = null; try { - const response = await fetch(`/api/repos/${npub}/${repo}/issues`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/issues`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -4961,28 +4628,28 @@ if (!response.ok) { const data = await response.json(); - throw new Error(data.error || 'Failed to update issue status'); + throw new Error(data.state.error || 'Failed to update issue status'); } await loadIssues(); } catch (err) { - error = err instanceof Error ? err.message : 'Failed to update issue status'; + state.error = err instanceof Error ? err.message : 'Failed to update issue status'; console.error('Error updating issue status:', err); } finally { - updatingIssueStatus = { ...updatingIssueStatus, [issueId]: false }; + state.statusUpdates.issue = { ...state.statusUpdates.issue, [issueId]: false }; } } async function loadPRs() { - loadingPRs = true; - error = null; + state.loading.prs = true; + state.error = null; try { - const response = await fetch(`/api/repos/${npub}/${repo}/prs`, { + const response = await fetch(`/api/repos/${state.npub}/${state.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 }) => ({ + state.prs = data.map((pr: { id: string; tags: string[][]; content: string; status?: string; pubkey: string; created_at: number; commitId?: string; kind?: number }) => ({ id: pr.id, subject: pr.tags.find((t: string[]) => t[0] === 'subject')?.[1] || 'Untitled', content: pr.content, @@ -4994,31 +4661,31 @@ })); } } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load pull requests'; + state.error = err instanceof Error ? err.message : 'Failed to load pull requests'; } finally { - loadingPRs = false; + state.loading.prs = false; } } async function createPR() { - if (!newPRSubject.trim() || !newPRContent.trim() || !newPRCommitId.trim()) { + if (!state.forms.pr.subject.trim() || !state.forms.pr.content.trim() || !state.forms.pr.commitId.trim()) { alert('Please enter a subject, content, and commit ID'); return; } - if (!userPubkey) { + if (!state.user.pubkey) { alert('Please connect your NIP-07 extension'); return; } - saving = true; - error = null; + state.saving = true; + state.error = null; try { const { PRsService } = await import('$lib/services/nostr/prs-service.js'); const { getGitUrl } = await import('$lib/config.js'); - const decoded = nip19.decode(npub); + const decoded = nip19.decode(state.npub); if (decoded.type !== 'npub') { throw new Error('Invalid npub format'); } @@ -5026,81 +4693,81 @@ // Get user's relays and combine with defaults const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS); - const { outbox } = await getUserRelays(userPubkey, tempClient); + const { outbox } = await getUserRelays(state.user.pubkey, tempClient); const combinedRelays = combineRelays(outbox); - const cloneUrl = getGitUrl(npub, repo); + const cloneUrl = getGitUrl(state.npub, state.repo); const prsService = new PRsService(combinedRelays); const pr = await prsService.createPullRequest( repoOwnerPubkey, - repo, - newPRSubject.trim(), - newPRContent.trim(), - newPRCommitId.trim(), + state.repo, + state.forms.pr.subject.trim(), + state.forms.pr.content.trim(), + state.forms.pr.commitId.trim(), cloneUrl, - newPRBranchName.trim() || undefined, - newPRLabels.filter(l => l.trim()) + state.forms.pr.branchName.trim() || undefined, + state.forms.pr.labels.filter(l => l.trim()) ); - showCreatePRDialog = false; - newPRSubject = ''; - newPRContent = ''; - newPRCommitId = ''; - newPRBranchName = ''; - newPRLabels = ['']; + state.openDialog = null; + state.forms.pr.subject = ''; + state.forms.pr.content = ''; + state.forms.pr.commitId = ''; + state.forms.pr.branchName = ''; + state.forms.pr.labels = ['']; await loadPRs(); alert('Pull request created successfully!'); } catch (err) { - error = err instanceof Error ? err.message : 'Failed to create pull request'; + state.error = err instanceof Error ? err.message : 'Failed to create pull request'; console.error('Error creating PR:', err); } finally { - saving = false; + state.saving = false; } } async function createPatch() { - if (!newPatchContent.trim()) { + if (!state.forms.patch.content.trim()) { alert('Please enter patch content'); return; } - if (!userPubkey || !userPubkeyHex) { + if (!state.user.pubkey || !state.user.pubkeyHex) { alert('Please connect your NIP-07 extension'); return; } - creatingPatch = true; - error = null; + state.creating.patch = true; + state.error = null; try { - const decoded = nip19.decode(npub); + const decoded = nip19.decode(state.npub); if (decoded.type !== 'npub') { throw new Error('Invalid npub format'); } const repoOwnerPubkey = decoded.data as string; - const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repo}`; + state.metadata.address = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${state.repo}`; // Get user's relays and combine with defaults const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS); - const { outbox } = await getUserRelays(userPubkey, tempClient); + const { outbox } = await getUserRelays(state.user.pubkey, tempClient); const combinedRelays = combineRelays(outbox); // Create patch event (kind 1617) const patchEventTemplate: Omit = { kind: KIND.PATCH, - pubkey: userPubkeyHex, + pubkey: state.user.pubkeyHex, created_at: Math.floor(Date.now() / 1000), tags: [ - ['a', repoAddress], + ['a', state.metadata.address], ['p', repoOwnerPubkey], ['t', 'root'] ], - content: newPatchContent.trim() + content: state.forms.patch.content.trim() }; // Add subject if provided - if (newPatchSubject.trim()) { - patchEventTemplate.tags.push(['subject', newPatchSubject.trim()]); + if (state.forms.patch.subject.trim()) { + patchEventTemplate.tags.push(['subject', state.forms.patch.subject.trim()]); } // Sign the event using NIP-07 @@ -5114,31 +4781,31 @@ throw new Error('Failed to publish patch to all relays'); } - showCreatePatchDialog = false; - newPatchContent = ''; - newPatchSubject = ''; + state.openDialog = null; + state.forms.patch.content = ''; + state.forms.patch.subject = ''; alert('Patch created successfully!'); - // Reload patches + // Reload state.patches await loadPatches(); } catch (err) { - error = err instanceof Error ? err.message : 'Failed to create patch'; + state.error = err instanceof Error ? err.message : 'Failed to create patch'; console.error('Error creating patch:', err); } finally { - creatingPatch = false; + state.creating.patch = false; } } async function loadPatches() { - if (repoNotFound) return; - loadingPatches = true; - error = null; + if (state.repoNotFound) return; + state.loading.patches = true; + state.error = null; try { - const response = await fetch(`/api/repos/${npub}/${repo}/patches`, { + const response = await fetch(`/api/repos/${state.npub}/${state.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; status?: string }) => { + state.patches = data.map((patch: { id: string; tags: string[][]; content: string; pubkey: string; created_at: number; kind?: number; status?: string }) => { // Extract subject/title from various sources let subject = patch.tags.find((t: string[]) => t[0] === 'subject')?.[1]; const description = patch.tags.find((t: string[]) => t[0] === 'description')?.[1]; @@ -5180,36 +4847,36 @@ }); } } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load patches'; - console.error('Error loading patches:', err); + state.error = err instanceof Error ? err.message : 'Failed to load state.patches'; + console.error('Error state.loading.main state.patches:', err); } finally { - loadingPatches = false; + state.loading.patches = false; } } async function loadPatchHighlights(patchId: string, patchAuthor: string) { if (!patchId || !patchAuthor) return; - loadingPatchHighlights = true; + state.loading.patchHighlights = true; try { - const decoded = nip19.decode(npub); + const decoded = nip19.decode(state.npub); if (decoded.type !== 'npub') { throw new Error('Invalid npub format'); } const repoOwnerPubkey = decoded.data as string; const response = await fetch( - `/api/repos/${npub}/${repo}/highlights?patchId=${patchId}&patchAuthor=${patchAuthor}` + `/api/repos/${state.npub}/${state.repo}/highlights?patchId=${patchId}&patchAuthor=${patchAuthor}` ); if (response.ok) { const data = await response.json(); - patchHighlights = data.highlights || []; - patchComments = data.comments || []; + state.patchHighlights = data.highlights || []; + state.patchComments = data.comments || []; } } catch (err) { console.error('Failed to load patch highlights:', err); } finally { - loadingPatchHighlights = false; + state.loading.patchHighlights = false; } } @@ -5220,75 +4887,75 @@ startPos: number, endPos: number ) { - if (!text.trim() || !userPubkey) return; + if (!text.trim() || !state.user.pubkey) return; - selectedPatchText = text; - selectedPatchStartLine = startLine; - selectedPatchEndLine = endLine; - selectedPatchStartPos = startPos; - selectedPatchEndPos = endPos; - showPatchHighlightDialog = true; + state.forms.patchHighlight.text = text; + state.forms.patchHighlight.startLine = startLine; + state.forms.patchHighlight.endLine = endLine; + state.forms.patchHighlight.startPos = startPos; + state.forms.patchHighlight.endPos = endPos; + state.openDialog = 'patchHighlight'; } async function createPatchHighlight() { - if (!userPubkey || !selectedPatchText.trim() || !selectedPatch) return; + if (!state.user.pubkey || !state.forms.patchHighlight.text.trim() || !state.selected.patch) return; - const patch = patches.find(p => p.id === selectedPatch); + const patch = state.patches.find(p => p.id === state.selected.patch); if (!patch) return; - creatingPatchHighlight = true; - error = null; + state.creating.patchHighlight = true; + state.error = null; try { - const decoded = nip19.decode(npub); + const decoded = nip19.decode(state.npub); if (decoded.type !== 'npub') { throw new Error('Invalid npub format'); } const repoOwnerPubkey = decoded.data as string; const eventTemplate = highlightsService.createHighlightEvent( - selectedPatchText, + state.forms.patchHighlight.text, patch.id, patch.author, repoOwnerPubkey, - repo, + state.repo, KIND.PATCH, // targetKind undefined, // filePath - selectedPatchStartLine, // lineStart - selectedPatchEndLine, // lineEnd + state.forms.patchHighlight.startLine, // lineStart + state.forms.patchHighlight.endLine, // lineEnd undefined, // context - patchHighlightComment.trim() || undefined // comment + state.forms.patchHighlight.comment.trim() || undefined // comment ); const signedEvent = await signEventWithNIP07(eventTemplate); const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS); - const { outbox } = await getUserRelays(userPubkey, tempClient); + const { outbox } = await getUserRelays(state.user.pubkey, tempClient); const combinedRelays = combineRelays(outbox); - const response = await fetch(`/api/repos/${npub}/${repo}/highlights`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/highlights`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'highlight', event: signedEvent, - userPubkey + userPubkey: state.user.pubkey }) }); if (response.ok) { - showPatchHighlightDialog = false; - selectedPatchText = ''; - patchHighlightComment = ''; + state.openDialog = null; + state.forms.patchHighlight.text = ''; + state.forms.patchHighlight.comment = ''; await loadPatchHighlights(patch.id, patch.author); } else { const data = await response.json(); - error = data.error || 'Failed to create highlight'; + state.error = data.state.error || 'Failed to create highlight'; } } catch (err) { - error = err instanceof Error ? err.message : 'Failed to create highlight'; + state.error = err instanceof Error ? err.message : 'Failed to create highlight'; } finally { - creatingPatchHighlight = false; + state.creating.patchHighlight = false; } } @@ -5301,53 +4968,53 @@ } function startPatchComment(parentId?: string) { - if (!userPubkey) { + if (!state.user.pubkey) { alert('Please connect your NIP-07 extension'); return; } - replyingToPatchComment = parentId || null; - showPatchCommentDialog = true; + state.forms.patchComment.replyingTo = parentId || null; + state.openDialog = 'patchComment'; } async function createPatchComment() { - if (!userPubkey || !patchCommentContent.trim() || !selectedPatch) return; + if (!state.user.pubkey || !state.forms.patchComment.content.trim() || !state.selected.patch) return; - const patch = patches.find(p => p.id === selectedPatch); + const patch = state.patches.find(p => p.id === state.selected.patch); if (!patch) return; - creatingPatchComment = true; - error = null; + state.creating.patchComment = true; + state.error = null; try { - const decoded = nip19.decode(npub); + const decoded = nip19.decode(state.npub); if (decoded.type !== 'npub') { throw new Error('Invalid npub format'); } const repoOwnerPubkey = decoded.data as string; - const rootEventId = replyingToPatchComment || patch.id; - const rootEventKind = replyingToPatchComment ? KIND.COMMENT : KIND.PATCH; - const rootPubkey = replyingToPatchComment ? - (patchComments.find(c => c.id === replyingToPatchComment)?.pubkey || patch.author) : + const rootEventId = state.forms.patchComment.replyingTo || patch.id; + const rootEventKind = state.forms.patchComment.replyingTo ? KIND.COMMENT : KIND.PATCH; + const rootPubkey = state.forms.patchComment.replyingTo ? + (state.patchComments.find(c => c.id === state.forms.patchComment.replyingTo)?.pubkey || patch.author) : patch.author; let parentEventId: string | undefined; let parentEventKind: number | undefined; let parentPubkey: string | undefined; - if (replyingToPatchComment) { + if (state.forms.patchComment.replyingTo) { // Reply to a comment - const parentComment = patchComments.find(c => c.id === replyingToPatchComment) || - patchHighlights.flatMap(h => h.comments || []).find(c => c.id === replyingToPatchComment); + const parentComment = state.patchComments.find(c => c.id === state.forms.patchComment.replyingTo) || + state.patchHighlights.flatMap(h => h.comments || []).find(c => c.id === state.forms.patchComment.replyingTo); if (parentComment) { - parentEventId = replyingToPatchComment; + parentEventId = state.forms.patchComment.replyingTo; parentEventKind = KIND.COMMENT; parentPubkey = parentComment.pubkey; } } const eventTemplate = highlightsService.createCommentEvent( - patchCommentContent.trim(), + state.forms.patchComment.content.trim(), rootEventId, rootEventKind, rootPubkey, @@ -5359,150 +5026,150 @@ const signedEvent = await signEventWithNIP07(eventTemplate); const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS); - const { outbox } = await getUserRelays(userPubkey, tempClient); + const { outbox } = await getUserRelays(state.user.pubkey, tempClient); const combinedRelays = combineRelays(outbox); - const response = await fetch(`/api/repos/${npub}/${repo}/highlights`, { + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/highlights`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'comment', event: signedEvent, - userPubkey + userPubkey: state.user.pubkey }) }); if (response.ok) { - showPatchCommentDialog = false; - patchCommentContent = ''; - replyingToPatchComment = null; + state.openDialog = null; + state.forms.patchComment.content = ''; + state.forms.patchComment.replyingTo = null; await loadPatchHighlights(patch.id, patch.author); } else { const data = await response.json(); - error = data.error || 'Failed to create comment'; + state.error = data.state.error || 'Failed to create comment'; } } catch (err) { - error = err instanceof Error ? err.message : 'Failed to create comment'; + state.error = err instanceof Error ? err.message : 'Failed to create comment'; } finally { - creatingPatchComment = false; + state.creating.patchComment = false; } } // Load highlights when a patch is selected $effect(() => { - if (!isMounted || !selectedPatch) return; - const patch = patches.find(p => p.id === selectedPatch); + if (!state.isMounted || !state.selected.patch) return; + const patch = state.patches.find(p => p.id === state.selected.patch); if (patch) { loadPatchHighlights(patch.id, patch.author).catch(err => { - if (isMounted) console.warn('Failed to load patch highlights:', err); + if (state.isMounted) console.warn('Failed to load patch highlights:', err); }); } }); // Only load tab content when tab actually changes, not on every render - let lastTab = $state(null); + let lastTab: string | null = null; $effect(() => { - if (!isMounted) return; - if (activeTab !== lastTab) { - lastTab = activeTab; - if (!isMounted) return; + if (!state.isMounted) return; + if (state.ui.activeTab !== lastTab) { + lastTab = state.ui.activeTab; + if (!state.isMounted) return; - if (activeTab === 'files') { - // Files tab - ensure files are loaded and README is shown if available - if (files.length === 0 || currentPath !== '') { + if (state.ui.activeTab === 'files') { + // Files tab - ensure state.files.list are loaded and README is shown if available + if (state.files.list.length === 0 || state.files.currentPath !== '') { loadFiles('').catch(err => { - if (isMounted) console.warn('Failed to load files:', err); + if (state.isMounted) console.warn('Failed to load files:', err); }); - } else if (files.length > 0 && !currentFile && isMounted) { + } else if (state.files.list.length > 0 && !state.files.currentFile && state.isMounted) { // Files already loaded, ensure README is shown - const readmeFile = findReadmeFile(files); + const readmeFile = findReadmeFile(state.files.list); if (readmeFile) { setTimeout(() => { - if (isMounted) { + if (state.isMounted) { loadFile(readmeFile.path).catch(err => { - if (isMounted) console.warn('Failed to load README file:', err); + if (state.isMounted) console.warn('Failed to load README file:', err); }); } }, 100); } } - } else if (activeTab === 'history' && isMounted) { + } else if (state.ui.activeTab === 'history' && state.isMounted) { loadCommitHistory().catch(err => { - if (isMounted) console.warn('Failed to load commit history:', err); + if (state.isMounted) console.warn('Failed to load commit history:', err); }); - } else if (activeTab === 'tags' && isMounted) { + } else if (state.ui.activeTab === 'tags' && state.isMounted) { loadTags().catch(err => { - if (isMounted) console.warn('Failed to load tags:', err); + if (state.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') { + if (state.isMounted) console.warn('Failed to load state.releases:', err); + }); // Load state.releases to check for tag associations + } else if (state.ui.activeTab === 'code-search') { // Code search is performed on demand, not auto-loaded - } else if (activeTab === 'issues' && isMounted) { + } else if (state.ui.activeTab === 'issues' && state.isMounted) { loadIssues().catch(err => { - if (isMounted) console.warn('Failed to load issues:', err); + if (state.isMounted) console.warn('Failed to load state.issues:', err); }); - } else if (activeTab === 'prs' && isMounted) { + } else if (state.ui.activeTab === 'prs' && state.isMounted) { loadPRs().catch(err => { - if (isMounted) console.warn('Failed to load PRs:', err); + if (state.isMounted) console.warn('Failed to load PRs:', err); }); - } else if (activeTab === 'docs' && isMounted) { + } else if (state.ui.activeTab === 'docs' && state.isMounted) { loadDocumentation().catch(err => { - if (isMounted) console.warn('Failed to load documentation:', err); + if (state.isMounted) console.warn('Failed to load documentation:', err); }); - } else if (activeTab === 'discussions' && isMounted) { + } else if (state.ui.activeTab === 'discussions' && state.isMounted) { loadDiscussions().catch(err => { - if (isMounted) console.warn('Failed to load discussions:', err); + if (state.isMounted) console.warn('Failed to load state.discussions:', err); }); - } else if (activeTab === 'patches' && isMounted) { + } else if (state.ui.activeTab === 'patches' && state.isMounted) { loadPatches().catch(err => { - if (isMounted) console.warn('Failed to load patches:', err); + if (state.isMounted) console.warn('Failed to load state.patches:', err); }); } } }); // Reload all branch-dependent data when branch changes - let lastBranch = $state(null); + let lastBranch: string | null = null; $effect(() => { - if (!isMounted) return; - if (currentBranch && currentBranch !== lastBranch) { - lastBranch = currentBranch; - if (!isMounted) return; + if (!state.isMounted) return; + if (state.git.currentBranch && state.git.currentBranch !== lastBranch) { + lastBranch = state.git.currentBranch; + if (!state.isMounted) return; // Reload README (always branch-specific) loadReadme().catch(err => { - if (isMounted) console.warn('Failed to reload README after branch change:', err); + if (state.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); + // Reload state.files.list if state.files.list tab is active + if (state.ui.activeTab === 'files' && state.isMounted) { + if (state.files.currentFile) { + loadFile(state.files.currentFile).catch(err => { + if (state.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); + loadFiles(state.files.currentPath).catch(err => { + if (state.isMounted) console.warn('Failed to reload state.files.list after branch change:', err); }); } } // Reload commit history if history tab is active - if (activeTab === 'history' && isMounted) { + if (state.ui.activeTab === 'history' && state.isMounted) { loadCommitHistory().catch(err => { - if (isMounted) console.warn('Failed to reload commit history after branch change:', err); + if (state.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; + if (state.ui.activeTab === 'docs' && state.isMounted) { + state.docs.html = null; + state.docs.content = null; + state.docs.kind = null; loadDocumentation().catch(err => { - if (isMounted) console.warn('Failed to reload documentation after branch change:', err); + if (state.isMounted) console.warn('Failed to reload documentation after branch change:', err); }); } } @@ -5539,11 +5206,11 @@
- {#if repoBanner && typeof repoBanner === 'string' && repoBanner.trim()} + {#if state.metadata.banner && typeof state.metadata.banner === 'string' && state.metadata.banner.trim()}
- { + { if (typeof window !== 'undefined') { - console.error('[Repo Images] Failed to load banner:', repoBanner); + console.error('[Repo Images] Failed to load banner:', state.metadata.banner); const target = e.target as HTMLImageElement; if (target) target.style.display = 'none'; } @@ -5555,61 +5222,61 @@ { if (typeof showRepoMenu !== 'undefined') showRepoMenu = !showRepoMenu; }} - showMenu={showRepoMenu || false} - userPubkey={userPubkey || null} - isBookmarked={isBookmarked || false} - loadingBookmark={loadingBookmark || false} + onMenuToggle={() => { if (typeof state.ui.showRepoMenu !== 'undefined') state.ui.showRepoMenu = !state.ui.showRepoMenu; }} + showMenu={state.ui.showRepoMenu || false} + userPubkey={state.user.pubkey || null} + isBookmarked={state.bookmark.isBookmarked || false} + loadingBookmark={state.loading.bookmark || false} onToggleBookmark={safeToggleBookmark} onFork={safeForkRepository} - forking={forking || false} + forking={state.fork.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; }} + cloning={state.clone.cloning || false} + checkingCloneStatus={state.clone.checking || false} + onCreateIssue={() => { state.openDialog = 'createIssue'; }} + onCreatePR={() => { state.openDialog = 'createPR'; }} + onCreatePatch={() => { state.openDialog = 'createPatch'; }} onCreateBranch={async () => { - if (!userPubkey || !isMaintainer || needsClone) return; + if (!state.user.pubkey || !state.maintainers.isMaintainer || needsClone) return; try { const settings = await settingsStore.getSettings(); - defaultBranchName = settings.defaultBranch || 'master'; + state.forms.branch.defaultName = settings.defaultBranch || 'master'; } catch { - defaultBranchName = 'master'; + state.forms.branch.defaultName = 'master'; } // Preset the default branch name in the input field - newBranchName = defaultBranchName; - newBranchFrom = null; // Reset from branch selection - showCreateBranchDialog = true; + state.forms.branch.name = state.forms.branch.defaultName; + state.forms.branch.from = null; // Reset from branch selection + state.openDialog = 'createBranch'; }} - onSettings={() => goto(`/signup?npub=${npub}&repo=${repo}`)} - onGenerateVerification={repoOwnerPubkeyDerived && userPubkeyHex === repoOwnerPubkeyDerived && verificationStatus?.verified !== true ? generateAnnouncementFileForRepo : undefined} - onDeleteAnnouncement={repoOwnerPubkeyDerived && userPubkeyHex === repoOwnerPubkeyDerived ? deleteAnnouncement : undefined} - deletingAnnouncement={deletingAnnouncement} + onSettings={() => goto(`/signup?npub=${state.npub}&repo=${state.repo}`)} + onGenerateVerification={repoOwnerPubkeyDerived && state.user.pubkeyHex === repoOwnerPubkeyDerived && state.verification.status?.verified !== true ? generateAnnouncementFileForRepo : undefined} + onDeleteAnnouncement={repoOwnerPubkeyDerived && state.user.pubkeyHex === repoOwnerPubkeyDerived ? deleteAnnouncement : undefined} + deletingAnnouncement={state.creating.announcement} hasUnlimitedAccess={hasUnlimitedAccess($userStore.userLevel)} needsClone={needsClone} - allMaintainers={allMaintainers} + allMaintainers={state.maintainers.all} onCopyEventId={copyEventId} /> {/if} - {#if repoWebsite || (repoCloneUrls && repoCloneUrls.length > 0) || repoLanguage || (repoTopics && repoTopics.length > 0) || forkInfo?.isFork} + {#if repoWebsite || (repoCloneUrls && repoCloneUrls.length > 0) || repoLanguage || (repoTopics && repoTopics.length > 0) || state.fork.info?.isFork}