From a16be39f2f5cb2e7ee0c23f7104b9f31f6187a53 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 26 Feb 2026 14:35:20 +0100 Subject: [PATCH] refactor 7 Nostr-Signature: 80f54ac61390cfbc8f2496a162d7065c447033a2e085ab5886c8138e337e93f9 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc f64bd2c965eff3534ad68e245651c189dc925d9613dd85557d88af8c692361ade13ccdd9deb88ae07eb227aa002af99d525a7fdf6f29eca854b5a02882ef226f --- nostr/commit-signatures.jsonl | 1 + src/routes/repos/[npub]/[repo]/+page.svelte | 173 +++------ .../[repo]/services/branch-operations.ts | 135 ++++++- .../[npub]/[repo]/services/file-operations.ts | 362 +++++++++++++++++- .../repos/[npub]/[repo]/utils/file-helpers.ts | 90 +++++ 5 files changed, 631 insertions(+), 130 deletions(-) create mode 100644 src/routes/repos/[npub]/[repo]/utils/file-helpers.ts diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 7109ae9..23235a2 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -99,3 +99,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772109639,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor the refactor"]],"content":"Signed commit: refactor the refactor","id":"62aafbdadfd37b20f1b16742a297e2b17d59dd3d6930e64e75d0d1b6a2f04bd6","sig":"050eaca1703b73443b51fd160932a2edfa04fc0a5efd3b5bb0a1e4c8b944caa60d444b2148c07b74f4ff4a589984fa524a7109a2a89c3eddf6c937b23b18c69b"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772110337,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 4"]],"content":"Signed commit: refactor 4","id":"d330d1a096e5f3951e8b2a66160a23c5ac28aa94313ecd0948c7e50baa60bdbb","sig":"febf4088cca3f7223f55ab300ed7fdb7b333c03d2534b05721dfaf9d9284f4599b385ba54379890fa6b846aed02d656a5e45429a6dd571dddbb997be6d8159b2"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772111536,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 5"]],"content":"Signed commit: refactor 5","id":"47651ed0aee8072f356fbac30b6168f2c985bcca392f9ed7d7c38d9670d90f16","sig":"2ca5d04d4a619dc3e02962249f7c650e3a561315897b329f4493e87148c5dd89fbcb6694515a72d0d17e64c9930e57bd7761e27b353275bb1ada9449330f4e1c"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772112054,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 6"]],"content":"Signed commit: refactor 6","id":"cd9b7e015ee3bd6a4c4ab7d54d90ab411a08c29f249158c4cdea2b12996b6b44","sig":"2a8fd0a3718169df1517c0b939ec9ea9793da4dc07b20d9366f09fe70b5268e94451916f975e2cea1a3741419440189d31f844e79863e84de00c0be7c449e92a"} diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 4a46298..6e7ed8e 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -80,11 +80,22 @@ import { saveFile as saveFileService, createFile as createFileService, - deleteFile as deleteFileService + deleteFile as deleteFileService, + loadFiles as loadFilesService, + loadFile as loadFileService, + setupAutoSave as setupAutoSaveService, + autoSaveFile as autoSaveFileService } from './services/file-operations.js'; + import { + findReadmeFile as findReadmeFileUtil, + formatPubkey as formatPubkeyUtil, + getMimeType as getMimeTypeUtil + } from './utils/file-helpers.js'; import { createBranch as createBranchService, - deleteBranch as deleteBranchService + deleteBranch as deleteBranchService, + loadBranches as loadBranchesService, + handleBranchChange as handleBranchChangeService } from './services/branch-operations.js'; import { loadTags as loadTagsService, @@ -1509,88 +1520,7 @@ } async function loadBranches() { - try { - const response = await fetch(`/api/repos/${state.npub}/${state.repo}/branches`, { - headers: buildApiHeaders() - }); - if (response.ok) { - state.git.branches = await response.json(); - - // If repo is not cloned but we got state.git.branches, API fallback is available - if (state.clone.isCloned === false && state.git.branches.length > 0) { - state.clone.apiFallbackAvailable = true; - } - if (state.git.branches.length > 0) { - // Branches can be an array of objects with .name property or array of strings - const branchNames = state.git.branches.map((b: any) => typeof b === 'string' ? b : b.name); - - // Fetch the actual default branch from the API - try { - const defaultBranchResponse = await fetch(`/api/repos/${state.npub}/${state.repo}/default-branch`, { - headers: buildApiHeaders() - }); - if (defaultBranchResponse.ok) { - const defaultBranchData = await defaultBranchResponse.json(); - state.git.defaultBranch = defaultBranchData.state.git.defaultBranch || defaultBranchData.branch || null; - } - } catch (err) { - console.warn('Failed to fetch default branch, using fallback logic:', err); - } - - // Fallback: Detect default branch: prefer master, then main, then first branch - if (!state.git.defaultBranch) { - if (branchNames.includes('master')) { - state.git.defaultBranch = 'master'; - } else if (branchNames.includes('main')) { - state.git.defaultBranch = 'main'; - } else { - state.git.defaultBranch = branchNames[0]; - } - } - - // Only update state.git.currentBranch if it's not set or if the current branch doesn't exist - // Also validate that state.git.currentBranch doesn't contain invalid characters (like '#') - if (!state.git.currentBranch || - typeof state.git.currentBranch !== 'string' || - state.git.currentBranch.includes('#') || - !branchNames.includes(state.git.currentBranch)) { - state.git.currentBranch = state.git.defaultBranch; - } - } else { - // No state.git.branches exist - set state.git.currentBranch to null to show "no state.git.branches" in header - state.git.currentBranch = null; - } - } else if (response.status === 404) { - // Check if this is a "not cloned" state.error - API fallback might be available - const errorText = await response.text().catch(() => ''); - if (errorText.includes('not cloned locally')) { - // Repository is not cloned - check if API fallback might be available - if (repoCloneUrls && repoCloneUrls.length > 0) { - // We have clone URLs, so API fallback might work - mark as unknown for now - // It will be set to true if a subsequent request succeeds - state.clone.apiFallbackAvailable = null; - // Don't set state.repoNotFound or state.error yet - allow API fallback to be attempted - } else { - // No clone URLs, API fallback won't work - state.repoNotFound = true; - state.clone.apiFallbackAvailable = false; - state.error = errorText || `Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`; - } - } else { - // Generic 404 - repository doesn't exist - state.repoNotFound = true; - state.clone.apiFallbackAvailable = false; - state.error = `Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`; - } - } else if (response.status === 403) { - // Access denied - don't set state.repoNotFound, allow retry after login - const errorText = await response.text().catch(() => response.statusText); - state.error = `Access denied: ${errorText}. You may need to log in or you may not have permission to view this repository.`; - console.warn('[Branches] Access denied, user may need to log in'); - } - } catch (err) { - console.error('Failed to load branches:', err); - } + await loadBranchesService(state, repoCloneUrls); } async function loadFiles(path: string = '') { @@ -1668,7 +1598,7 @@ // Auto-load README if we're in the root directory and no file is currently selected // Only attempt once per path to prevent loops if (path === '' && !state.files.currentFile && !state.metadata.readmeAutoLoadAttempted) { - const readmeFile = findReadmeFile(state.files.list); + const readmeFile = findReadmeFileUtil(state.files.list); if (readmeFile) { state.metadata.readmeAutoLoadAttempted = true; // Clear any existing timeout @@ -1714,43 +1644,6 @@ } } - // Helper function to find README file in file list - function findReadmeFile(fileList: Array<{ name: string; path: string; type: 'file' | 'directory' }>): { name: string; path: string; type: 'file' | 'directory' } | null { - // Priority order for README state.files.list (most common first) - const readmeExtensions = ['md', 'markdown', 'txt', 'adoc', 'asciidoc', 'rst', 'org']; - - // First, try to find README with extensions (prioritized order) - for (const ext of readmeExtensions) { - const readmeFile = fileList.find(file => - file.type === 'file' && - file.name.toLowerCase() === `readme.${ext}` - ); - if (readmeFile) { - return readmeFile; - } - } - - // Then check for README without extension - const readmeNoExt = fileList.find(file => - file.type === 'file' && - file.name.toLowerCase() === 'readme' - ); - if (readmeNoExt) { - return readmeNoExt; - } - - // Finally, check for any file starting with "readme." (case-insensitive) - const readmeAny = fileList.find(file => - file.type === 'file' && - file.name.toLowerCase().startsWith('readme.') - ); - if (readmeAny) { - return readmeAny; - } - - return null; - } - async function loadFile(filePath: string) { state.loading.main = true; state.error = null; @@ -2113,7 +2006,11 @@ getUserEmail, getUserName, loadFiles, - loadFile + loadFile, + renderFileAsHtml, + applySyntaxHighlighting, + findReadmeFile: findReadmeFileUtil, + rewriteImagePaths }); } @@ -2165,7 +2062,12 @@ await createFileService(state, { getUserEmail, getUserName, - loadFiles + loadFiles, + loadFile, + renderFileAsHtml, + applySyntaxHighlighting, + findReadmeFile: findReadmeFileUtil, + rewriteImagePaths }); } @@ -2173,19 +2075,34 @@ await deleteFileService(filePath, state, { getUserEmail, getUserName, - loadFiles + loadFiles, + loadFile, + renderFileAsHtml, + applySyntaxHighlighting, + findReadmeFile: findReadmeFileUtil, + rewriteImagePaths }); } async function createBranch() { await createBranchService(state, repoAnnouncement, { - loadBranches + loadBranches, + loadFiles, + loadFile, + loadReadme, + loadCommitHistory, + loadDocumentation }); } async function deleteBranch(branchName: string) { await deleteBranchService(branchName, state, { - loadBranches + loadBranches, + loadFiles, + loadFile, + loadReadme, + loadCommitHistory, + loadDocumentation }); } @@ -2328,7 +2245,7 @@ // Initialize tab change effect const lastTab = { value: null as string | null }; - useTabChangeEffect(state, lastTab, findReadmeFile, { + useTabChangeEffect(state, lastTab, findReadmeFileUtil, { loadFiles, loadFile, loadCommitHistory, diff --git a/src/routes/repos/[npub]/[repo]/services/branch-operations.ts b/src/routes/repos/[npub]/[repo]/services/branch-operations.ts index 391234b..8b0cbc9 100644 --- a/src/routes/repos/[npub]/[repo]/services/branch-operations.ts +++ b/src/routes/repos/[npub]/[repo]/services/branch-operations.ts @@ -5,10 +5,15 @@ import type { NostrEvent } from '$lib/types/nostr.js'; import type { RepoState } from '../stores/repo-state.js'; -import { apiPost, apiRequest } from '../utils/api-client.js'; +import { apiPost, apiRequest, buildApiHeaders } from '../utils/api-client.js'; interface BranchOperationsCallbacks { loadBranches: () => Promise; + loadFiles: (path: string) => Promise; + loadFile: (path: string) => Promise; + loadReadme: () => Promise; + loadCommitHistory: () => Promise; + loadDocumentation: () => Promise; } /** @@ -99,3 +104,131 @@ export async function deleteBranch( state.saving = false; } } + +/** + * Load branches from the repository + */ +export async function loadBranches( + state: RepoState, + repoCloneUrls: string[] | undefined +): Promise { + try { + const data = await apiRequest>( + `/api/repos/${state.npub}/${state.repo}/branches` + ); + + state.git.branches = data; + + // If repo is not cloned but we got branches, API fallback is available + if (state.clone.isCloned === false && state.git.branches.length > 0) { + state.clone.apiFallbackAvailable = true; + } + + if (state.git.branches.length > 0) { + // Branches can be an array of objects with .name property or array of strings + const branchNames = state.git.branches.map((b: any) => typeof b === 'string' ? b : b.name); + + // Fetch the actual default branch from the API + try { + const defaultBranchData = await apiRequest<{ defaultBranch?: string; branch?: string }>( + `/api/repos/${state.npub}/${state.repo}/default-branch` + ); + state.git.defaultBranch = defaultBranchData.defaultBranch || defaultBranchData.branch || null; + } catch (err) { + console.warn('Failed to fetch default branch, using fallback logic:', err); + } + + // Fallback: Detect default branch: prefer master, then main, then first branch + if (!state.git.defaultBranch) { + if (branchNames.includes('master')) { + state.git.defaultBranch = 'master'; + } else if (branchNames.includes('main')) { + state.git.defaultBranch = 'main'; + } else { + state.git.defaultBranch = branchNames[0]; + } + } + + // Only update 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 (!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 + state.git.currentBranch = null; + } + } catch (err: any) { + // Handle 404 - repository not found or not cloned + const errorMessage = err instanceof Error ? err.message : String(err); + if (errorMessage.includes('404') || errorMessage.includes('not found')) { + if (errorMessage.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 + state.clone.apiFallbackAvailable = null; + } else { + // No clone URLs, API fallback won't work + state.repoNotFound = true; + state.clone.apiFallbackAvailable = false; + state.error = errorMessage || `Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`; + } + } else { + // Generic 404 - repository doesn't exist + state.repoNotFound = true; + state.clone.apiFallbackAvailable = false; + state.error = `Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`; + } + } else if (errorMessage.includes('403') || errorMessage.includes('Access denied')) { + // Access denied - don't set repoNotFound, allow retry after login + state.error = `Access denied: ${errorMessage}. 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'); + } else { + console.error('Failed to load branches:', err); + } + } +} + +/** + * Handle branch change + */ +export async function handleBranchChange( + branch: string, + state: RepoState, + callbacks: BranchOperationsCallbacks +): Promise { + state.git.currentBranch = branch; + + // Reload all branch-dependent data + const reloadPromises: Promise[] = []; + + // Always reload files (and current file if open) + if (state.files.currentFile) { + reloadPromises.push(callbacks.loadFile(state.files.currentFile).catch(err => console.warn('Failed to reload file after branch change:', err))); + } else { + reloadPromises.push(callbacks.loadFiles(state.files.currentPath).catch(err => console.warn('Failed to reload files after branch change:', err))); + } + + // Reload README (branch-specific) + reloadPromises.push(callbacks.loadReadme().catch(err => console.warn('Failed to reload README after branch change:', err))); + + // Reload commit history if history tab is active + if (state.ui.activeTab === 'history') { + reloadPromises.push(callbacks.loadCommitHistory().catch(err => console.warn('Failed to reload commit history after branch change:', err))); + } + + // Reload documentation if docs tab is active (might be branch-specific) + if (state.ui.activeTab === 'docs') { + // Reset documentation to force reload + state.docs.html = null; + state.docs.content = null; + state.docs.kind = null; + reloadPromises.push(callbacks.loadDocumentation().catch(err => console.warn('Failed to reload documentation after branch change:', err))); + } + + // Wait for all reloads to complete + await Promise.all(reloadPromises); +} diff --git a/src/routes/repos/[npub]/[repo]/services/file-operations.ts b/src/routes/repos/[npub]/[repo]/services/file-operations.ts index 9f3e700..a4e9bab 100644 --- a/src/routes/repos/[npub]/[repo]/services/file-operations.ts +++ b/src/routes/repos/[npub]/[repo]/services/file-operations.ts @@ -7,13 +7,18 @@ import type { NostrEvent } from '$lib/types/nostr.js'; import type { RepoState } from '../stores/repo-state.js'; import { isNIP07Available, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; -import { apiPost } from '../utils/api-client.js'; +import { apiPost, apiRequest, buildApiHeaders } from '../utils/api-client.js'; +import { isImageFileType, supportsPreview } from '../utils/file-processing.js'; interface FileOperationsCallbacks { getUserEmail: () => Promise; getUserName: () => Promise; loadFiles: (path: string) => Promise; loadFile?: (path: string) => Promise; + renderFileAsHtml: (content: string, ext: string) => Promise; + applySyntaxHighlighting: (content: string, ext: string) => Promise; + findReadmeFile: (fileList: Array<{ name: string; path: string; type: 'file' | 'directory' }>) => { name: string; path: string; type: 'file' | 'directory' } | null; + rewriteImagePaths: (html: string, filePath: string | null) => string; } /** @@ -352,3 +357,358 @@ export async function loadReadme( state.loading.readme = false; } } + +/** + * Load files from a directory + */ +export async function loadFiles( + path: string, + state: RepoState, + repoCloneUrls: string[] | undefined, + readmeAutoLoadTimeout: { value: ReturnType | null }, + callbacks: FileOperationsCallbacks +): Promise { + // Skip if repository doesn't exist + if (state.repoNotFound) return; + + state.loading.main = true; + state.error = null; + try { + // Validate and get a valid branch name + let branchName: string; + if (typeof state.git.currentBranch === 'string' && state.git.currentBranch.trim() !== '' && !state.git.currentBranch.includes('#')) { + const branchNames = state.git.branches.map((b: any) => typeof b === 'string' ? b : b.name); + if (branchNames.includes(state.git.currentBranch)) { + branchName = state.git.currentBranch; + } else { + branchName = state.git.defaultBranch || (state.git.branches.length > 0 + ? (typeof state.git.branches[0] === 'string' ? state.git.branches[0] : state.git.branches[0].name) + : 'HEAD'); + } + } else { + branchName = state.git.defaultBranch || (state.git.branches.length > 0 + ? (typeof state.git.branches[0] === 'string' ? state.git.branches[0] : state.git.branches[0].name) + : 'HEAD'); + } + + const data = await apiRequest>( + `/api/repos/${state.npub}/${state.repo}/tree?ref=${encodeURIComponent(branchName)}&path=${encodeURIComponent(path)}` + ); + + state.files.list = data; + state.files.currentPath = path; + + // If repo is not cloned but we got files, API fallback is available + if (state.clone.isCloned === false && state.files.list.length > 0) { + state.clone.apiFallbackAvailable = true; + } + + // Auto-load README if we're in the root directory and no file is currently selected + // Only attempt once per path to prevent loops + if (path === '' && !state.files.currentFile && !state.metadata.readmeAutoLoadAttempted) { + const readmeFile = callbacks.findReadmeFile(state.files.list); + if (readmeFile) { + state.metadata.readmeAutoLoadAttempted = true; + // Clear any existing timeout + if (readmeAutoLoadTimeout.value) { + clearTimeout(readmeAutoLoadTimeout.value); + } + // Small delay to ensure UI is ready + readmeAutoLoadTimeout.value = setTimeout(() => { + if (callbacks.loadFile) { + callbacks.loadFile(readmeFile.path).catch(err => { + // If load fails (e.g., 429 rate limit), reset the flag after a delay + if (err instanceof Error && err.message.includes('Too Many Requests')) { + console.warn('[README] Rate limited, will retry later'); + setTimeout(() => { + state.metadata.readmeAutoLoadAttempted = false; + }, 5000); // Retry after 5 seconds + } else { + // For other errors, reset immediately + state.metadata.readmeAutoLoadAttempted = false; + } + }); + } + readmeAutoLoadTimeout.value = null; + }, 100); + } + } else if (path !== '' || state.files.currentFile) { + // Reset flag when navigating away from root or when a file is selected + state.metadata.readmeAutoLoadAttempted = false; + if (readmeAutoLoadTimeout.value) { + clearTimeout(readmeAutoLoadTimeout.value); + readmeAutoLoadTimeout.value = null; + } + } + } catch (err: any) { + const errorMessage = err instanceof Error ? err.message : String(err); + // Handle 404 - repository not found or not cloned + if (errorMessage.includes('404') || errorMessage.includes('not found')) { + if (errorMessage.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 + state.clone.apiFallbackAvailable = null; + } else { + // No clone URLs, API fallback won't work + state.repoNotFound = true; + state.clone.apiFallbackAvailable = false; + } + state.error = errorMessage || 'Repository not found. This repository exists in Nostr but hasn\'t been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.'; + } else { + // Generic 404 - repository doesn't exist + state.repoNotFound = true; + state.clone.apiFallbackAvailable = false; + state.error = `Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`; + } + } else if (errorMessage.includes('403') || errorMessage.includes('Access denied')) { + // 403 means access denied - don't set repoNotFound, just show error + state.error = `Access denied: ${errorMessage}. You may need to log in or you may not have permission to view this repository.`; + console.info('Access denied (normal behavior):', state.error); + } else { + state.error = errorMessage || 'Failed to load files'; + console.error('Error loading files:', err); + } + } finally { + state.loading.main = false; + } +} + +/** + * Load a single file + */ +export async function loadFile( + filePath: string, + state: RepoState, + callbacks: FileOperationsCallbacks +): Promise { + state.loading.main = true; + state.error = null; + try { + // Ensure currentBranch is a string (branch name), not an object + let branchName: string; + + if (typeof state.git.currentBranch === 'string' && state.git.currentBranch.trim() !== '') { + // Validate that currentBranch is actually a valid branch name + 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 = state.git.defaultBranch || (state.git.branches.length > 0 + ? (typeof state.git.branches[0] === 'string' ? state.git.branches[0] : state.git.branches[0].name) + : 'HEAD'); + } + } else if (typeof state.git.currentBranch === 'object' && state.git.currentBranch !== null && 'name' in state.git.currentBranch) { + branchName = (state.git.currentBranch as { name: string }).name; + } else { + // currentBranch is null, undefined, or invalid - use 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 + if (!branchName || typeof branchName !== 'string' || branchName.trim() === '') { + console.warn('[loadFile] Invalid branch name detected, using fallback:', branchName); + branchName = state.git.defaultBranch || (state.git.branches.length > 0 + ? (typeof state.git.branches[0] === 'string' ? state.git.branches[0] : state.git.branches[0].name) + : 'HEAD'); + } + + // Determine language from file extension first to check if it's an image + const ext = filePath.split('.').pop()?.toLowerCase() || ''; + + // Check if this is an image file BEFORE making the API call + state.preview.file.isImage = isImageFileType(ext); + + if (state.preview.file.isImage) { + // For image files, construct the raw file URL and skip loading text content + state.preview.file.imageUrl = `/api/repos/${state.npub}/${state.repo}/raw?path=${encodeURIComponent(filePath)}&ref=${encodeURIComponent(branchName)}`; + state.files.content = ''; // Clear content for images + state.files.editedContent = ''; // Clear edited content for images + state.preview.file.html = ''; // Clear HTML for images + state.preview.file.highlightedContent = ''; // Clear highlighted content + state.files.language = 'text'; + state.files.currentFile = filePath; + state.files.hasChanges = false; + } else { + // Not an image, load file content normally + state.preview.file.imageUrl = null; + + const data = await apiRequest<{ content: string }>( + `/api/repos/${state.npub}/${state.repo}/file?path=${encodeURIComponent(filePath)}&ref=${encodeURIComponent(branchName)}` + ); + + state.files.content = data.content; + state.files.editedContent = data.content; + state.files.currentFile = filePath; + state.files.hasChanges = false; + + // Reset README auto-load flag when a file is successfully loaded + if (filePath && filePath.toLowerCase().includes('readme')) { + state.metadata.readmeAutoLoadAttempted = false; + } + + if (ext === 'md' || ext === 'markdown') { + state.files.language = 'markdown'; + } else if (ext === 'adoc' || ext === 'asciidoc') { + state.files.language = 'asciidoc'; + } else { + state.files.language = 'text'; + } + + // Reset preview mode to default (preview) when loading a new file + state.preview.file.showPreview = true; + state.preview.file.html = ''; + + // Render markdown/asciidoc/HTML/CSV files as HTML for preview + if (state.files.content && (ext === 'md' || ext === 'markdown' || ext === 'adoc' || ext === 'asciidoc' || ext === 'html' || ext === 'htm' || ext === 'csv')) { + await callbacks.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 + const hasHtmlPreview = supportsPreview(ext); + if (state.files.content) { + if (hasHtmlPreview) { + // Markup files: only show highlighting when not in preview mode (raw mode) + if (!state.preview.file.showPreview) { + await callbacks.applySyntaxHighlighting(state.files.content, ext || ''); + } + } else { + // Code files and other non-markup files: always show syntax highlighting + await callbacks.applySyntaxHighlighting(state.files.content, ext || ''); + } + } + } + } catch (err: any) { + // Handle rate limiting specifically to prevent loops + if (err instanceof Error && err.message.includes('Too Many Requests')) { + state.error = 'Failed to load file: Too Many Requests'; + console.warn('[File Load] Rate limited, please wait before retrying'); + } else { + state.error = err instanceof Error ? err.message : 'Failed to load file'; + console.error('Error loading file:', err); + } + } finally { + state.loading.main = false; + } +} + +/** + * Setup auto-save interval + */ +export async function setupAutoSave( + autoSaveInterval: { value: ReturnType | null }, + autoSaveFile: () => Promise +): Promise { + // Clear existing interval if any + if (autoSaveInterval.value) { + clearInterval(autoSaveInterval.value); + autoSaveInterval.value = null; + } + + // Check if auto-save is enabled + try { + const { settingsStore } = await import('$lib/services/settings-store.js'); + const settings = await settingsStore.getSettings(); + if (!settings.autoSave) { + return; // Auto-save disabled + } + } catch (err) { + console.warn('Failed to check auto-save setting:', err); + return; + } + + // Set up interval to auto-save every 10 minutes + autoSaveInterval.value = setInterval(async () => { + await autoSaveFile(); + }, 10 * 60 * 1000); // 10 minutes +} + +/** + * Auto-save file + */ +export async function autoSaveFile( + state: RepoState, + needsClone: boolean, + callbacks: FileOperationsCallbacks +): Promise { + // Only auto-save if: + // 1. There are changes + // 2. A file is open + // 3. User is logged in + // 4. User is a maintainer + // 5. Not currently saving + // 6. Not in clone state + if (!state.files.hasChanges || !state.files.currentFile || !state.user.pubkey || !state.maintainers.isMaintainer || state.saving || needsClone) { + return; + } + + // Check auto-save setting again (in case it changed) + try { + const { settingsStore } = await import('$lib/services/settings-store.js'); + const settings = await settingsStore.getSettings(); + if (!settings.autoSave) { + return; + } + } catch (err) { + console.warn('Failed to check auto-save setting:', err); + return; + } + + // Generate a default commit message + const autoCommitMessage = `Auto-save: ${new Date().toLocaleString()}`; + + try { + // Get user email and name from settings + const authorEmail = await callbacks.getUserEmail(); + const authorName = await callbacks.getUserName(); + + // Sign commit with NIP-07 (client-side) + let commitSignatureEvent: NostrEvent | null = null; + if (isNIP07Available()) { + try { + const { KIND } = await import('$lib/types/nostr.js'); + const timestamp = Math.floor(Date.now() / 1000); + const eventTemplate: Omit = { + kind: KIND.COMMIT_SIGNATURE, + pubkey: '', // Will be filled by NIP-07 + created_at: timestamp, + tags: [ + ['author', authorName, authorEmail], + ['message', autoCommitMessage] + ], + content: `Signed commit: ${autoCommitMessage}` + }; + commitSignatureEvent = await signEventWithNIP07(eventTemplate); + } catch (err) { + console.warn('Failed to sign commit with NIP-07:', err); + // Continue without signature if signing fails + } + } + + await apiPost(`/api/repos/${state.npub}/${state.repo}/file`, { + path: state.files.currentFile, + content: state.files.editedContent, + message: autoCommitMessage, + authorName: authorName, + authorEmail: authorEmail, + branch: state.git.currentBranch, + userPubkey: state.user.pubkey, + commitSignatureEvent: commitSignatureEvent + }); + + // Reload file to get updated content + if (callbacks.loadFile) { + await callbacks.loadFile(state.files.currentFile); + } + // Note: We don't show an alert for auto-save, it's silent + console.log('Auto-saved file:', state.files.currentFile); + } catch (err) { + console.warn('Error during auto-save:', err); + // Don't show error to user, it's silent + } +} diff --git a/src/routes/repos/[npub]/[repo]/utils/file-helpers.ts b/src/routes/repos/[npub]/[repo]/utils/file-helpers.ts new file mode 100644 index 0000000..c8384d6 --- /dev/null +++ b/src/routes/repos/[npub]/[repo]/utils/file-helpers.ts @@ -0,0 +1,90 @@ +/** + * File helper utilities + * Pure utility functions for file operations + */ + +/** + * Find README file in file list + */ +export function findReadmeFile(fileList: Array<{ name: string; path: string; type: 'file' | 'directory' }>): { name: string; path: string; type: 'file' | 'directory' } | null { + // Priority order for README files (most common first) + const readmeExtensions = ['md', 'markdown', 'txt', 'adoc', 'asciidoc', 'rst', 'org']; + + // First, try to find README with extensions (prioritized order) + for (const ext of readmeExtensions) { + const readmeFile = fileList.find(file => + file.type === 'file' && + file.name.toLowerCase() === `readme.${ext}` + ); + if (readmeFile) { + return readmeFile; + } + } + + // Then check for README without extension + const readmeNoExt = fileList.find(file => + file.type === 'file' && + file.name.toLowerCase() === 'readme' + ); + if (readmeNoExt) { + return readmeNoExt; + } + + // Finally, check for any file starting with "readme." (case-insensitive) + const readmeAny = fileList.find(file => + file.type === 'file' && + file.name.toLowerCase().startsWith('readme.') + ); + if (readmeAny) { + return readmeAny; + } + + return null; +} + +/** + * Format pubkey to npub + */ +export function formatPubkey(pubkey: string): string { + try { + const { nip19 } = require('nostr-tools'); + return nip19.npubEncode(pubkey); + } catch { + return pubkey.slice(0, 8) + '...'; + } +} + +/** + * Get MIME type from file extension + */ +export function getMimeType(ext: string): string { + const mimeTypes: Record = { + 'js': 'text/javascript', + 'ts': 'text/typescript', + 'json': 'application/json', + 'css': 'text/css', + 'html': 'text/html', + 'htm': 'text/html', + 'md': 'text/markdown', + 'txt': 'text/plain', + 'csv': 'text/csv', + 'xml': 'application/xml', + 'svg': 'image/svg+xml', + 'py': 'text/x-python', + 'java': 'text/x-java-source', + 'c': 'text/x-csrc', + 'cpp': 'text/x-c++src', + 'h': 'text/x-csrc', + 'hpp': 'text/x-c++src', + 'sh': 'text/x-shellscript', + 'bash': 'text/x-shellscript', + 'yaml': 'text/yaml', + 'yml': 'text/yaml', + 'toml': 'text/toml', + 'ini': 'text/plain', + 'conf': 'text/plain', + 'log': 'text/plain' + }; + + return mimeTypes[ext.toLowerCase()] || 'text/plain'; +}