Browse Source

refactor 7

Nostr-Signature: 80f54ac61390cfbc8f2496a162d7065c447033a2e085ab5886c8138e337e93f9 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc f64bd2c965eff3534ad68e245651c189dc925d9613dd85557d88af8c692361ade13ccdd9deb88ae07eb227aa002af99d525a7fdf6f29eca854b5a02882ef226f
main
Silberengel 2 weeks ago
parent
commit
a16be39f2f
  1. 1
      nostr/commit-signatures.jsonl
  2. 173
      src/routes/repos/[npub]/[repo]/+page.svelte
  3. 135
      src/routes/repos/[npub]/[repo]/services/branch-operations.ts
  4. 362
      src/routes/repos/[npub]/[repo]/services/file-operations.ts
  5. 90
      src/routes/repos/[npub]/[repo]/utils/file-helpers.ts

1
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":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":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":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"}

173
src/routes/repos/[npub]/[repo]/+page.svelte

@ -80,11 +80,22 @@
import { import {
saveFile as saveFileService, saveFile as saveFileService,
createFile as createFileService, 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'; } from './services/file-operations.js';
import {
findReadmeFile as findReadmeFileUtil,
formatPubkey as formatPubkeyUtil,
getMimeType as getMimeTypeUtil
} from './utils/file-helpers.js';
import { import {
createBranch as createBranchService, createBranch as createBranchService,
deleteBranch as deleteBranchService deleteBranch as deleteBranchService,
loadBranches as loadBranchesService,
handleBranchChange as handleBranchChangeService
} from './services/branch-operations.js'; } from './services/branch-operations.js';
import { import {
loadTags as loadTagsService, loadTags as loadTagsService,
@ -1509,88 +1520,7 @@
} }
async function loadBranches() { async function loadBranches() {
try { await loadBranchesService(state, repoCloneUrls);
const response = await fetch(`/api/repos/${state.npub}/${state.repo}/branches`, {
headers: buildApiHeaders()
});
if (response.ok) {
state.git.branches = await response.json();
// If repo is not cloned but we got state.git.branches, API fallback is available
if (state.clone.isCloned === false && state.git.branches.length > 0) {
state.clone.apiFallbackAvailable = true;
}
if (state.git.branches.length > 0) {
// Branches can be an array of objects with .name property or array of strings
const branchNames = state.git.branches.map((b: any) => typeof b === 'string' ? b : b.name);
// Fetch the actual default branch from the API
try {
const defaultBranchResponse = await fetch(`/api/repos/${state.npub}/${state.repo}/default-branch`, {
headers: buildApiHeaders()
});
if (defaultBranchResponse.ok) {
const defaultBranchData = await defaultBranchResponse.json();
state.git.defaultBranch = defaultBranchData.state.git.defaultBranch || defaultBranchData.branch || null;
}
} catch (err) {
console.warn('Failed to fetch default branch, using fallback logic:', err);
}
// Fallback: Detect default branch: prefer master, then main, then first branch
if (!state.git.defaultBranch) {
if (branchNames.includes('master')) {
state.git.defaultBranch = 'master';
} else if (branchNames.includes('main')) {
state.git.defaultBranch = 'main';
} else {
state.git.defaultBranch = branchNames[0];
}
}
// Only update state.git.currentBranch if it's not set or if the current branch doesn't exist
// Also validate that state.git.currentBranch doesn't contain invalid characters (like '#')
if (!state.git.currentBranch ||
typeof state.git.currentBranch !== 'string' ||
state.git.currentBranch.includes('#') ||
!branchNames.includes(state.git.currentBranch)) {
state.git.currentBranch = state.git.defaultBranch;
}
} else {
// No state.git.branches exist - set state.git.currentBranch to null to show "no state.git.branches" in header
state.git.currentBranch = null;
}
} else if (response.status === 404) {
// Check if this is a "not cloned" state.error - API fallback might be available
const errorText = await response.text().catch(() => '');
if (errorText.includes('not cloned locally')) {
// Repository is not cloned - check if API fallback might be available
if (repoCloneUrls && repoCloneUrls.length > 0) {
// We have clone URLs, so API fallback might work - mark as unknown for now
// It will be set to true if a subsequent request succeeds
state.clone.apiFallbackAvailable = null;
// Don't set state.repoNotFound or state.error yet - allow API fallback to be attempted
} else {
// No clone URLs, API fallback won't work
state.repoNotFound = true;
state.clone.apiFallbackAvailable = false;
state.error = errorText || `Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`;
}
} else {
// Generic 404 - repository doesn't exist
state.repoNotFound = true;
state.clone.apiFallbackAvailable = false;
state.error = `Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`;
}
} else if (response.status === 403) {
// Access denied - don't set state.repoNotFound, allow retry after login
const errorText = await response.text().catch(() => response.statusText);
state.error = `Access denied: ${errorText}. You may need to log in or you may not have permission to view this repository.`;
console.warn('[Branches] Access denied, user may need to log in');
}
} catch (err) {
console.error('Failed to load branches:', err);
}
} }
async function loadFiles(path: string = '') { 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 // Auto-load README if we're in the root directory and no file is currently selected
// Only attempt once per path to prevent loops // Only attempt once per path to prevent loops
if (path === '' && !state.files.currentFile && !state.metadata.readmeAutoLoadAttempted) { if (path === '' && !state.files.currentFile && !state.metadata.readmeAutoLoadAttempted) {
const readmeFile = findReadmeFile(state.files.list); const readmeFile = findReadmeFileUtil(state.files.list);
if (readmeFile) { if (readmeFile) {
state.metadata.readmeAutoLoadAttempted = true; state.metadata.readmeAutoLoadAttempted = true;
// Clear any existing timeout // 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) { async function loadFile(filePath: string) {
state.loading.main = true; state.loading.main = true;
state.error = null; state.error = null;
@ -2113,7 +2006,11 @@
getUserEmail, getUserEmail,
getUserName, getUserName,
loadFiles, loadFiles,
loadFile loadFile,
renderFileAsHtml,
applySyntaxHighlighting,
findReadmeFile: findReadmeFileUtil,
rewriteImagePaths
}); });
} }
@ -2165,7 +2062,12 @@
await createFileService(state, { await createFileService(state, {
getUserEmail, getUserEmail,
getUserName, getUserName,
loadFiles loadFiles,
loadFile,
renderFileAsHtml,
applySyntaxHighlighting,
findReadmeFile: findReadmeFileUtil,
rewriteImagePaths
}); });
} }
@ -2173,19 +2075,34 @@
await deleteFileService(filePath, state, { await deleteFileService(filePath, state, {
getUserEmail, getUserEmail,
getUserName, getUserName,
loadFiles loadFiles,
loadFile,
renderFileAsHtml,
applySyntaxHighlighting,
findReadmeFile: findReadmeFileUtil,
rewriteImagePaths
}); });
} }
async function createBranch() { async function createBranch() {
await createBranchService(state, repoAnnouncement, { await createBranchService(state, repoAnnouncement, {
loadBranches loadBranches,
loadFiles,
loadFile,
loadReadme,
loadCommitHistory,
loadDocumentation
}); });
} }
async function deleteBranch(branchName: string) { async function deleteBranch(branchName: string) {
await deleteBranchService(branchName, state, { await deleteBranchService(branchName, state, {
loadBranches loadBranches,
loadFiles,
loadFile,
loadReadme,
loadCommitHistory,
loadDocumentation
}); });
} }
@ -2328,7 +2245,7 @@
// Initialize tab change effect // Initialize tab change effect
const lastTab = { value: null as string | null }; const lastTab = { value: null as string | null };
useTabChangeEffect(state, lastTab, findReadmeFile, { useTabChangeEffect(state, lastTab, findReadmeFileUtil, {
loadFiles, loadFiles,
loadFile, loadFile,
loadCommitHistory, loadCommitHistory,

135
src/routes/repos/[npub]/[repo]/services/branch-operations.ts

@ -5,10 +5,15 @@
import type { NostrEvent } from '$lib/types/nostr.js'; import type { NostrEvent } from '$lib/types/nostr.js';
import type { RepoState } from '../stores/repo-state.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 { interface BranchOperationsCallbacks {
loadBranches: () => Promise<void>; loadBranches: () => Promise<void>;
loadFiles: (path: string) => Promise<void>;
loadFile: (path: string) => Promise<void>;
loadReadme: () => Promise<void>;
loadCommitHistory: () => Promise<void>;
loadDocumentation: () => Promise<void>;
} }
/** /**
@ -99,3 +104,131 @@ export async function deleteBranch(
state.saving = false; state.saving = false;
} }
} }
/**
* Load branches from the repository
*/
export async function loadBranches(
state: RepoState,
repoCloneUrls: string[] | undefined
): Promise<void> {
try {
const data = await apiRequest<Array<string | { name: string; commit?: any }>>(
`/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<void> {
state.git.currentBranch = branch;
// Reload all branch-dependent data
const reloadPromises: Promise<void>[] = [];
// 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);
}

362
src/routes/repos/[npub]/[repo]/services/file-operations.ts

@ -7,13 +7,18 @@
import type { NostrEvent } from '$lib/types/nostr.js'; import type { NostrEvent } from '$lib/types/nostr.js';
import type { RepoState } from '../stores/repo-state.js'; import type { RepoState } from '../stores/repo-state.js';
import { isNIP07Available, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.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 { interface FileOperationsCallbacks {
getUserEmail: () => Promise<string>; getUserEmail: () => Promise<string>;
getUserName: () => Promise<string>; getUserName: () => Promise<string>;
loadFiles: (path: string) => Promise<void>; loadFiles: (path: string) => Promise<void>;
loadFile?: (path: string) => Promise<void>; loadFile?: (path: string) => Promise<void>;
renderFileAsHtml: (content: string, ext: string) => Promise<void>;
applySyntaxHighlighting: (content: string, ext: string) => Promise<void>;
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; state.loading.readme = false;
} }
} }
/**
* Load files from a directory
*/
export async function loadFiles(
path: string,
state: RepoState,
repoCloneUrls: string[] | undefined,
readmeAutoLoadTimeout: { value: ReturnType<typeof setTimeout> | null },
callbacks: FileOperationsCallbacks
): Promise<void> {
// 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<Array<{ name: string; path: string; type: 'file' | 'directory'; size?: number }>>(
`/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<void> {
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<typeof setInterval> | null },
autoSaveFile: () => Promise<void>
): Promise<void> {
// 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<void> {
// 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<NostrEvent, 'sig' | 'id'> = {
kind: KIND.COMMIT_SIGNATURE,
pubkey: '', // Will be filled by NIP-07
created_at: timestamp,
tags: [
['author', authorName, authorEmail],
['message', autoCommitMessage]
],
content: `Signed commit: ${autoCommitMessage}`
};
commitSignatureEvent = await signEventWithNIP07(eventTemplate);
} catch (err) {
console.warn('Failed to sign commit with NIP-07:', err);
// Continue without signature if signing fails
}
}
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
}
}

90
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<string, string> = {
'js': 'text/javascript',
'ts': 'text/typescript',
'json': 'application/json',
'css': 'text/css',
'html': 'text/html',
'htm': 'text/html',
'md': 'text/markdown',
'txt': 'text/plain',
'csv': 'text/csv',
'xml': 'application/xml',
'svg': 'image/svg+xml',
'py': 'text/x-python',
'java': 'text/x-java-source',
'c': 'text/x-csrc',
'cpp': 'text/x-c++src',
'h': 'text/x-csrc',
'hpp': 'text/x-c++src',
'sh': 'text/x-shellscript',
'bash': 'text/x-shellscript',
'yaml': 'text/yaml',
'yml': 'text/yaml',
'toml': 'text/toml',
'ini': 'text/plain',
'conf': 'text/plain',
'log': 'text/plain'
};
return mimeTypes[ext.toLowerCase()] || 'text/plain';
}
Loading…
Cancel
Save