diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index e357539..0ac349e 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -51,3 +51,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771745084,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix the menus and implement the patch page"]],"content":"Signed commit: fix the menus and implement the patch page","id":"b4e946a2acfc7c71b7c3d3a533186dc500edcd4e3f277aa5f83fa08fe5d2ffa7","sig":"226f5ae08cd5dd27baf8cca64889d27bcd40aa4655a274ba19ef068e394be99c916bdf86569169800e4dfdfe89e34f834bb95a4a404bda7712cbbf537633a6f5"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771747544,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","handle panel-switching on mobile"]],"content":"Signed commit: handle panel-switching on mobile","id":"1b65fafbc3cef0e06fc9fd9e7c2478f3028ecea0974173cbac59a9afcb1defe9","sig":"fe917e8c371c9567bf677ac5f21175ee4f3783e8a9a0b0cb4f43f17f235041306422e73ec048b7a3638ba4268faaca6bf415593cea4cd89761f43edb51184bca"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771750234,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","reformatting design"]],"content":"Signed commit: reformatting design","id":"3d9cac8d0ed3abac1a42c891a2352f21e6bf60c98af7fcac3b1703c5ab965f9f","sig":"d08ea355c001bf0c83eb0ab06e3dcae32a1bad0c565b626167e9c2218372532b2ba11e87f79521cafabc58c8cc5be5d9fb72235aec4dcb9f3f2556c040fc3599"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771750596,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix git folders"]],"content":"Signed commit: fix git folders","id":"3d2475034fdfa5eea36e5caad946460b034a1e4e16b6ba6e3f7fb9b6e1b0a31f","sig":"3eb6e3300081a53434e0f692f0c46618369089bb25047a83138ef3ffd485f749cf817b480f5c8ff0458bb846d04654ba2730ba7d42272739af18a13e8dcb4ed4"} diff --git a/src/lib/services/git/api-repo-fetcher.ts b/src/lib/services/git/api-repo-fetcher.ts index 78ad9ec..8e7d7b0 100644 --- a/src/lib/services/git/api-repo-fetcher.ts +++ b/src/lib/services/git/api-repo-fetcher.ts @@ -223,16 +223,23 @@ async function fetchFromGitHub(owner: string, repo: string): Promise item.type === 'blob' || item.type === 'tree') - .map((item: any) => ({ - name: item.path.split('/').pop(), - path: item.path, - type: item.type === 'tree' ? 'dir' : 'file', - size: item.size - })) || [] - : []; + let files: ApiFile[] = []; + if (treeResponse?.ok) { + const treeData = await treeResponse.json(); + // Check if the tree was truncated (GitHub API limitation) + if (treeData.truncated) { + logger.warn({ owner, repo }, 'GitHub tree response was truncated, some files may be missing'); + // For truncated trees, we could make additional requests, but for now just log a warning + } + files = treeData.tree + ?.filter((item: any) => item.type === 'blob' || item.type === 'tree') + .map((item: any) => ({ + name: item.path.split('/').pop(), + path: item.path, + type: item.type === 'tree' ? 'dir' : 'file', + size: item.size + })) || []; + } // Try to fetch README let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined; diff --git a/src/lib/services/git/file-manager.ts b/src/lib/services/git/file-manager.ts index 6d33478..24ce032 100644 --- a/src/lib/services/git/file-manager.ts +++ b/src/lib/services/git/file-manager.ts @@ -473,6 +473,7 @@ export class FileManager { const cacheKey = RepoCache.fileListKey(npub, repoName, ref, path); const cached = repoCache.get(cacheKey); if (cached !== null) { + logger.debug({ npub, repoName, path, ref, cachedCount: cached.length }, '[FileManager] Returning cached file list'); return cached; } @@ -480,33 +481,144 @@ export class FileManager { try { // Get the tree for the specified path - const tree = await git.raw(['ls-tree', '-l', ref, path || '.']); + // For directories, git ls-tree needs a trailing slash to list contents + // For root, use '.' + // Note: git ls-tree returns paths relative to repo root, not relative to the specified path + const gitPath = path ? (path.endsWith('/') ? path : `${path}/`) : '.'; + logger.debug({ npub, repoName, path, ref, gitPath }, '[FileManager] Calling git ls-tree'); + const tree = await git.raw(['ls-tree', '-l', ref, gitPath]); - if (!tree) { + if (!tree || !tree.trim()) { const emptyResult: FileEntry[] = []; // Cache empty result for shorter time (30 seconds) repoCache.set(cacheKey, emptyResult, 30 * 1000); + logger.debug({ npub, repoName, path, ref, gitPath }, '[FileManager] git ls-tree returned empty result'); return emptyResult; } + + logger.debug({ npub, repoName, path, ref, gitPath, treeLength: tree.length, firstLines: tree.split('\n').slice(0, 5) }, '[FileManager] git ls-tree output'); const entries: FileEntry[] = []; const lines = tree.trim().split('\n').filter(line => line.length > 0); + // Normalize the path for comparison (ensure it ends with / for directory matching) + const normalizedPath = path ? (path.endsWith('/') ? path : `${path}/`) : ''; + + logger.debug({ path, normalizedPath, lineCount: lines.length }, '[FileManager] Starting to parse entries'); + for (const line of lines) { // Format: \t - const match = line.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)\s+(.+)$/); - if (match) { - const [, , type, , size, name] = match; - const fullPath = path ? join(path, name) : name; - - entries.push({ - name, - path: fullPath, - type: type === 'tree' ? 'directory' : 'file', - size: size !== '-' ? parseInt(size, 10) : undefined - }); + // Note: git ls-tree uses a tab character between size and filename + // The format is: mode type object sizepath + // Important: git ls-tree returns paths relative to repo root, not relative to the specified path + // We need to handle both spaces and tabs + const tabIndex = line.lastIndexOf('\t'); + if (tabIndex === -1) { + // Try space-separated format as fallback + const match = line.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)\s+(.+)$/); + if (match) { + const [, , type, , size, gitPath] = match; + // git ls-tree always returns paths relative to repo root + // If we're listing a subdirectory, the returned paths will start with that directory + let fullPath: string; + let displayName: string; + + if (normalizedPath) { + // We're listing a subdirectory + if (gitPath.startsWith(normalizedPath)) { + // Path already includes the directory prefix (normal case) + fullPath = gitPath; + // Extract just the filename/dirname (relative to the requested path) + const relativePath = gitPath.slice(normalizedPath.length); + // Remove any leading/trailing slashes + const cleanRelative = relativePath.replace(/^\/+|\/+$/g, ''); + // For display name, get the first component (the immediate child) + // This handles both files (image.png) and nested dirs (screenshots/image.png -> screenshots) + displayName = cleanRelative.split('/')[0] || cleanRelative; + } else { + // Path doesn't start with directory prefix - this shouldn't happen normally + // but handle it by joining + logger.debug({ path, normalizedPath, gitPath }, '[FileManager] Path does not start with normalized path, joining (space-separated)'); + fullPath = join(path, gitPath); + displayName = gitPath.split('/').pop() || gitPath; + } + } else { + // Root directory listing - paths are relative to root + fullPath = gitPath; + displayName = gitPath.split('/')[0]; // Get first component + } + + logger.debug({ gitPath, path, normalizedPath, fullPath, displayName, type }, '[FileManager] Parsed entry (space-separated)'); + + entries.push({ + name: displayName, + path: fullPath, + type: type === 'tree' ? 'directory' : 'file', + size: size !== '-' ? parseInt(size, 10) : undefined + }); + } else { + logger.debug({ line, path, ref }, '[FileManager] Line did not match expected format (space-separated)'); + } + } else { + // Tab-separated format (standard) + const beforeTab = line.substring(0, tabIndex); + const gitPath = line.substring(tabIndex + 1); + const match = beforeTab.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)$/); + if (match) { + const [, , type, , size] = match; + // git ls-tree always returns paths relative to repo root + // If we're listing a subdirectory, the returned paths will start with that directory + let fullPath: string; + let displayName: string; + + if (normalizedPath) { + // We're listing a subdirectory + if (gitPath.startsWith(normalizedPath)) { + // Path already includes the directory prefix (normal case) + fullPath = gitPath; + // Extract just the filename/dirname (relative to the requested path) + const relativePath = gitPath.slice(normalizedPath.length); + // Remove any leading/trailing slashes + const cleanRelative = relativePath.replace(/^\/+|\/+$/g, ''); + // For display name, get the first component (the immediate child) + // This handles both files (image.png) and nested dirs (screenshots/image.png -> screenshots) + displayName = cleanRelative.split('/')[0] || cleanRelative; + } else { + // Path doesn't start with directory prefix - this shouldn't happen normally + // but handle it by joining + logger.debug({ path, normalizedPath, gitPath }, '[FileManager] Path does not start with normalized path, joining (tab-separated)'); + fullPath = join(path, gitPath); + displayName = gitPath.split('/').pop() || gitPath; + } + } else { + // Root directory listing - paths are relative to root + fullPath = gitPath; + displayName = gitPath.split('/')[0]; // Get first component + } + + logger.debug({ gitPath, path, normalizedPath, fullPath, displayName, type }, '[FileManager] Parsed entry (tab-separated)'); + + entries.push({ + name: displayName, + path: fullPath, + type: type === 'tree' ? 'directory' : 'file', + size: size !== '-' ? parseInt(size, 10) : undefined + }); + } else { + logger.debug({ line, path, ref, beforeTab }, '[FileManager] Line did not match expected format (tab-separated)'); + } } } + + // Debug logging to help diagnose missing files + logger.debug({ + npub, + repoName, + path, + ref, + entryCount: entries.length, + entries: entries.map(e => ({ name: e.name, path: e.path, type: e.type })) + }, '[FileManager] Parsed file entries'); const sortedEntries = entries.sort((a, b) => { // Directories first, then files, both alphabetically diff --git a/src/lib/styles/repo.css b/src/lib/styles/repo.css index 6dc0c50..f8aa9c7 100644 --- a/src/lib/styles/repo.css +++ b/src/lib/styles/repo.css @@ -494,6 +494,292 @@ margin: 2rem 0; } +/* Preview toggle button */ +.preview-toggle-button { + padding: 0.5rem 1rem; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.875rem; + transition: background 0.2s ease, border-color 0.2s ease; + margin-right: 0.5rem; +} + +.preview-toggle-button:hover { + background: var(--bg-tertiary); + border-color: var(--accent); +} + +/* File action buttons (copy, download) */ +.file-action-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 0.25rem; + cursor: pointer; + transition: background 0.2s ease, border-color 0.2s ease; + flex-shrink: 0; +} + +.file-action-button:hover:not(:disabled) { + background: var(--bg-tertiary); + border-color: var(--accent); +} + +.file-action-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.file-action-button .icon-inline { + width: 1rem; + height: 1rem; + filter: brightness(0) saturate(100%) invert(1) !important; /* Default white for dark themes */ + opacity: 1 !important; + transition: filter 0.3s ease, opacity 0.3s ease; +} + +/* Light theme: black icon */ +:global([data-theme="light"]) .file-action-button .icon-inline { + filter: brightness(0) saturate(100%) !important; /* Black in light theme */ + opacity: 1 !important; +} + +/* Dark themes: white icon */ +:global([data-theme="dark"]) .file-action-button .icon-inline, +:global([data-theme="black"]) .file-action-button .icon-inline { + filter: brightness(0) saturate(100%) invert(1) !important; /* White in dark themes */ + opacity: 1 !important; +} + +/* Hover: white for visibility */ +.file-action-button:hover:not(:disabled) .icon-inline { + filter: brightness(0) saturate(100%) invert(1) !important; + opacity: 1 !important; +} + +/* Light theme hover: keep black */ +:global([data-theme="light"]) .file-action-button:hover:not(:disabled) .icon-inline { + filter: brightness(0) saturate(100%) !important; + opacity: 1 !important; +} + +/* File preview styling (same as readme-content.markdown) */ +.file-preview.markdown { + line-height: 1.6; + font-size: 1rem; + color: var(--text-primary); + font-family: inherit; + padding: 1.5rem; +} + +.file-preview.markdown :global(p) { + margin: 0 0 1rem 0; + line-height: 1.6; +} + +.file-preview.markdown :global(h1), +.file-preview.markdown :global(h2), +.file-preview.markdown :global(h3), +.file-preview.markdown :global(h4), +.file-preview.markdown :global(h5), +.file-preview.markdown :global(h6) { + margin-top: 1.5rem; + margin-bottom: 0.75rem; + color: var(--text-primary); + line-height: 1.4; + font-weight: 600; +} + +.file-preview.markdown :global(h1:first-child), +.file-preview.markdown :global(h2:first-child), +.file-preview.markdown :global(h3:first-child) { + margin-top: 0; +} + +.file-preview.markdown :global(h1) { + font-size: 2rem; + border-bottom: 2px solid var(--border-color); + padding-bottom: 0.5rem; +} + +.file-preview.markdown :global(h2) { + font-size: 1.5rem; +} + +.file-preview.markdown :global(h3) { + font-size: 1.25rem; +} + +.file-preview.markdown :global(ul), +.file-preview.markdown :global(ol) { + margin: 1rem 0; + padding-left: 2rem; + line-height: 1.6; +} + +.file-preview.markdown :global(li) { + margin: 0.5rem 0; + line-height: 1.6; +} + +.file-preview.markdown :global(pre) { + margin: 0 0 1rem 0; + padding: 0; + background: transparent; + border: none; + overflow: visible; +} + +.file-preview.markdown :global(pre:last-child) { + margin-bottom: 0; +} + +.file-preview.markdown :global(blockquote) { + border-left: 4px solid var(--border-color); + padding-left: 1rem; + margin: 1rem 0; + color: var(--text-secondary); + font-style: italic; +} + +.file-preview.markdown :global(a) { + color: var(--accent); + text-decoration: none; +} + +.file-preview.markdown :global(a:hover) { + text-decoration: underline; +} + +.file-preview.markdown :global(img) { + max-width: 100%; + height: auto; + border-radius: 4px; + margin: 1rem 0; +} + +.file-preview.markdown :global(table) { + width: 100%; + border-collapse: collapse; + margin: 1rem 0; +} + +.file-preview.markdown :global(table th), +.file-preview.markdown :global(table td) { + border: 1px solid var(--border-color); + padding: 0.5rem; + text-align: left; +} + +.file-preview.markdown :global(table th) { + background: var(--bg-secondary); + font-weight: 600; +} + +.file-preview.markdown :global(hr) { + border: none; + border-top: 1px solid var(--border-color); + margin: 2rem 0; +} + +/* CSV table wrapper and styling */ +.file-preview :global(.csv-table-wrapper), +.readme-content :global(.csv-table-wrapper) { + width: 100%; + overflow-x: auto; + margin: 1rem 0; + -webkit-overflow-scrolling: touch; +} + +.file-preview :global(.csv-table), +.readme-content :global(.csv-table) { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; + min-width: 100%; + display: table; +} + +.file-preview :global(.csv-table thead), +.readme-content :global(.csv-table thead) { + background: var(--bg-secondary); +} + +.file-preview :global(.csv-table th), +.readme-content :global(.csv-table th) { + border: 1px solid var(--border-color); + padding: 0.75rem; + text-align: left; + font-weight: 600; + position: sticky; + top: 0; + background: var(--bg-secondary); + z-index: 1; +} + +.file-preview :global(.csv-table td), +.readme-content :global(.csv-table td) { + border: 1px solid var(--border-color); + padding: 0.5rem 0.75rem; + text-align: left; + white-space: nowrap; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-preview :global(.csv-table tbody tr:hover), +.readme-content :global(.csv-table tbody tr:hover) { + background: var(--bg-tertiary); +} + +.file-preview :global(.csv-table tbody tr:nth-child(even)), +.readme-content :global(.csv-table tbody tr:nth-child(even)) { + background: var(--bg-secondary); +} + +.file-preview :global(.csv-table tbody tr:nth-child(even):hover), +.readme-content :global(.csv-table tbody tr:nth-child(even):hover) { + background: var(--bg-tertiary); +} + +/* CSV empty and error states */ +.file-preview :global(.csv-empty), +.readme-content :global(.csv-empty), +.file-preview :global(.csv-error), +.readme-content :global(.csv-error) { + padding: 1rem; + text-align: center; + color: var(--text-secondary); +} + +/* Make CSV tables scrollable horizontally on mobile */ +@media (max-width: 768px) { + .file-preview :global(.csv-table-wrapper), + .readme-content :global(.csv-table-wrapper) { + margin: 0.5rem 0; + } + + .file-preview :global(.csv-table), + .readme-content :global(.csv-table) { + font-size: 0.8125rem; + } + + .file-preview :global(.csv-table th), + .readme-content :global(.csv-table th), + .file-preview :global(.csv-table td), + .readme-content :global(.csv-table td) { + padding: 0.5rem; + } +} + /* Mobile responsive */ @media (max-width: 768px) { .repo-layout { diff --git a/src/routes/api/repos/[npub]/[repo]/raw/+server.ts b/src/routes/api/repos/[npub]/[repo]/raw/+server.ts index 93c4ab7..31a1ce5 100644 --- a/src/routes/api/repos/[npub]/[repo]/raw/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/raw/+server.ts @@ -3,23 +3,33 @@ */ import type { RequestHandler } from './$types'; -import { fileManager } from '$lib/services/service-registry.js'; +import { fileManager, repoManager } from '$lib/services/service-registry.js'; import { createRepoGetHandler } from '$lib/utils/api-handlers.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import { handleValidationError } from '$lib/utils/error-handler.js'; +import { spawn } from 'child_process'; +import { join } from 'path'; +import { promisify } from 'util'; + +const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT + ? process.env.GIT_REPO_ROOT + : '/repos'; + +// Check if a file extension is a binary image type +function isBinaryImage(ext: string): boolean { + const binaryImageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'apng', 'avif']; + return binaryImageExtensions.includes(ext.toLowerCase()); +} export const GET: RequestHandler = createRepoGetHandler( async (context: RepoRequestContext, event: RequestEvent) => { const filePath = context.path || event.url.searchParams.get('path'); - const ref = context.ref || 'HEAD'; + const ref = context.ref || event.url.searchParams.get('ref') || 'HEAD'; if (!filePath) { throw handleValidationError('Missing path parameter', { operation: 'getRawFile', npub: context.npub, repo: context.repo }); } - // Get file content - const fileData = await fileManager.getFileContent(context.npub, context.repo, filePath, ref); - // Determine content type based on file extension const ext = filePath.split('.').pop()?.toLowerCase(); const contentTypeMap: Record = { @@ -35,6 +45,8 @@ export const GET: RequestHandler = createRepoGetHandler( 'jpeg': 'image/jpeg', 'gif': 'image/gif', 'webp': 'image/webp', + 'bmp': 'image/bmp', + 'ico': 'image/x-icon', 'pdf': 'application/pdf', 'txt': 'text/plain', 'md': 'text/markdown', @@ -44,14 +56,98 @@ export const GET: RequestHandler = createRepoGetHandler( const contentType = contentTypeMap[ext || ''] || 'text/plain'; - // Return raw file content - return new Response(fileData.content, { - headers: { - 'Content-Type': contentType, - 'Content-Disposition': `inline; filename="${filePath.split('/').pop()}"`, - 'Cache-Control': 'public, max-age=3600' - } - }); + // For binary image files, use git cat-file to get raw binary data + if (ext && isBinaryImage(ext)) { + const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); + + // Get the blob hash for the file + return new Promise((resolve, reject) => { + // First, get the object hash using git ls-tree + const lsTreeProcess = spawn('git', ['ls-tree', ref, filePath], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let lsTreeOutput = ''; + let lsTreeError = ''; + + lsTreeProcess.stdout.on('data', (data: Buffer) => { + lsTreeOutput += data.toString(); + }); + + lsTreeProcess.stderr.on('data', (data: Buffer) => { + lsTreeError += data.toString(); + }); + + lsTreeProcess.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Failed to get file hash: ${lsTreeError || 'Unknown error'}`)); + return; + } + + // Parse the output: format is "mode type hash\tpath" + const match = lsTreeOutput.match(/^\d+\s+\w+\s+([a-f0-9]{40})\s+/); + if (!match) { + reject(new Error('Failed to parse file hash from git ls-tree output')); + return; + } + + const blobHash = match[1]; + + // Now get the binary content using git cat-file + const catFileProcess = spawn('git', ['cat-file', 'blob', blobHash], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + const chunks: Buffer[] = []; + let catFileError = ''; + + catFileProcess.stdout.on('data', (data: Buffer) => { + chunks.push(data); + }); + + catFileProcess.stderr.on('data', (data: Buffer) => { + catFileError += data.toString(); + }); + + catFileProcess.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Failed to get file content: ${catFileError || 'Unknown error'}`)); + return; + } + + const binaryContent = Buffer.concat(chunks); + resolve(new Response(binaryContent, { + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `inline; filename="${filePath.split('/').pop()}"`, + 'Cache-Control': 'public, max-age=3600' + } + })); + }); + + catFileProcess.on('error', (err) => { + reject(new Error(`Failed to execute git cat-file: ${err.message}`)); + }); + }); + + lsTreeProcess.on('error', (err) => { + reject(new Error(`Failed to execute git ls-tree: ${err.message}`)); + }); + }); + } else { + // For text files (including SVG), use the existing method + const fileData = await fileManager.getFileContent(context.npub, context.repo, filePath, ref); + + return new Response(fileData.content, { + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `inline; filename="${filePath.split('/').pop()}"`, + 'Cache-Control': 'public, max-age=3600' + } + }); + } }, { operation: 'getRawFile' } ); diff --git a/src/routes/api/repos/[npub]/[repo]/tree/+server.ts b/src/routes/api/repos/[npub]/[repo]/tree/+server.ts index 56f0793..2a3b755 100644 --- a/src/routes/api/repos/[npub]/[repo]/tree/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/tree/+server.ts @@ -40,17 +40,53 @@ export const GET: RequestHandler = createRepoGetHandler( // Return API data directly without cloning const path = context.path || ''; // Filter files by path if specified - const filteredFiles = path - ? apiData.files.filter(f => f.path.startsWith(path)) - : apiData.files.filter(f => !f.path.includes('/') || f.path.split('/').length === 1); + let filteredFiles: typeof apiData.files; + if (path) { + // Normalize path: ensure it ends with / for directory matching + const normalizedPath = path.endsWith('/') ? path : `${path}/`; + // Filter files that are directly in this directory (not in subdirectories) + filteredFiles = apiData.files.filter(f => { + // File must start with the normalized path + if (!f.path.startsWith(normalizedPath)) { + return false; + } + // Get the relative path after the directory prefix + const relativePath = f.path.slice(normalizedPath.length); + // If relative path is empty, skip (this would be the directory itself) + if (!relativePath) { + return false; + } + // Remove trailing slash from relative path for directories + const cleanRelativePath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath; + // Check if it's directly in this directory (no additional / in the relative path) + // This works for both files (e.g., "icon.svg") and directories (e.g., "subfolder") + return !cleanRelativePath.includes('/'); + }); + } else { + // Root directory: show only files and directories in root + filteredFiles = apiData.files.filter(f => { + // Remove trailing slash for directories + const cleanPath = f.path.endsWith('/') ? f.path.slice(0, -1) : f.path; + const pathParts = cleanPath.split('/'); + // Include only items in root (single path segment) + return pathParts.length === 1; + }); + } // Normalize type: API returns 'dir' but frontend expects 'directory' - const normalizedFiles = filteredFiles.map(f => ({ - name: f.name, - path: f.path, - type: (f.type === 'dir' ? 'directory' : 'file') as 'file' | 'directory', - size: f.size - })); + // Also update name to be just the filename/dirname for display + const normalizedFiles = filteredFiles.map(f => { + // Extract display name from path + const cleanPath = f.path.endsWith('/') ? f.path.slice(0, -1) : f.path; + const pathParts = cleanPath.split('/'); + const displayName = pathParts[pathParts.length - 1] || f.name; + return { + name: displayName, + path: f.path, + type: (f.type === 'dir' ? 'directory' : 'file') as 'file' | 'directory', + size: f.size + }; + }); return json(normalizedFiles); } @@ -108,6 +144,15 @@ export const GET: RequestHandler = createRepoGetHandler( try { const files = await fileManager.listFiles(context.npub, context.repo, ref, path); + // Debug logging to help diagnose missing files + logger.debug({ + npub: context.npub, + repo: context.repo, + path, + ref, + fileCount: files.length, + files: files.map(f => ({ name: f.name, path: f.path, type: f.type })) + }, '[Tree] Returning files from fileManager.listFiles'); return json(files); } catch (err) { // Log the actual error for debugging diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 2aeba1b..eacdac4 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -711,6 +711,62 @@ 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 { + if (!html || !filePath) return html; + + // Get the directory of the current file + const fileDir = filePath.includes('/') + ? filePath.substring(0, filePath.lastIndexOf('/')) + : ''; + + // Get current branch for the API URL + const branch = currentBranch || defaultBranch || 'main'; + + // Rewrite relative image paths + return html.replace(/]*)\ssrc=["']([^"']+)["']([^>]*)>/gi, (match, before, src, after) => { + // Skip if it's already an absolute URL (http/https/data) + if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:') || src.startsWith('/api/')) { + return match; + } + + // Resolve relative path + let imagePath: string; + if (src.startsWith('/')) { + // Absolute path from repo root + imagePath = src.substring(1); + } else if (src.startsWith('./')) { + // Relative to current file directory + imagePath = fileDir ? `${fileDir}/${src.substring(2)}` : src.substring(2); + } else { + // Relative to current file directory + imagePath = fileDir ? `${fileDir}/${src}` : src; + } + + // Normalize path (remove .. and .) + const pathParts = imagePath.split('/').filter(p => p !== '.' && p !== ''); + const normalizedPath: string[] = []; + for (const part of pathParts) { + if (part === '..') { + normalizedPath.pop(); + } else { + normalizedPath.push(part); + } + } + imagePath = normalizedPath.join('/'); + + // Build API URL + const apiUrl = `/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(imagePath)}&ref=${encodeURIComponent(branch)}`; + + return ``; + }); + } // Fork let forkInfo = $state<{ isFork: boolean; originalRepo: { npub: string; repo: string } | null } | null>(null); @@ -754,43 +810,74 @@ readmePath = data.path; readmeIsMarkdown = data.isMarkdown; - // Render markdown if needed - if (readmeIsMarkdown && readmeContent) { - try { - const MarkdownIt = (await import('markdown-it')).default; - const hljsModule = await import('highlight.js'); - const hljs = hljsModule.default || hljsModule; - - const md = new MarkdownIt({ - html: true, // Enable HTML tags in source - linkify: true, // Autoconvert URL-like text to links - typographer: true, // Enable some language-neutral replacement + quotes beautification - breaks: true, // Convert '\n' in paragraphs into
- highlight: function (str: string, lang: string): string { - if (lang && hljs.getLanguage(lang)) { - try { - return '
' +
-                             hljs.highlight(str, { language: lang }).value +
-                             '
'; - } catch (err) { - // Fallback to escaped HTML if highlighting fails - // This is expected for unsupported languages + // Reset preview mode for README + showFilePreview = true; + readmeHtml = ''; + + // Render markdown or asciidoc if needed + if (readmeContent) { + const ext = readmePath?.split('.').pop()?.toLowerCase() || ''; + if (readmeIsMarkdown || ext === 'md' || ext === 'markdown') { + try { + const MarkdownIt = (await import('markdown-it')).default; + const hljsModule = await import('highlight.js'); + const hljs = hljsModule.default || hljsModule; + + const md = new MarkdownIt({ + html: true, // Enable HTML tags in source + linkify: true, // Autoconvert URL-like text to links + typographer: true, // Enable some language-neutral replacement + quotes beautification + breaks: true, // Convert '\n' in paragraphs into
+ highlight: function (str: string, lang: string): string { + if (lang && hljs.getLanguage(lang)) { + try { + return '
' +
+                               hljs.highlight(str, { language: lang }).value +
+                               '
'; + } catch (err) { + // Fallback to escaped HTML if highlighting fails + // This is expected for unsupported languages + } } + return '
' + md.utils.escapeHtml(str) + '
'; } - return '
' + md.utils.escapeHtml(str) + '
'; - } - }); - - readmeHtml = md.render(readmeContent); - console.log('[README] Markdown rendered successfully, HTML length:', readmeHtml.length); - } catch (err) { - console.error('[README] Error rendering markdown:', err); - // Fallback: show as plain text if rendering fails + }); + + let rendered = md.render(readmeContent); + // Rewrite image paths to point to repository API + rendered = rewriteImagePaths(rendered, readmePath); + readmeHtml = rendered; + console.log('[README] Markdown rendered successfully, HTML length:', readmeHtml.length); + } catch (err) { + console.error('[README] Error rendering markdown:', err); + readmeHtml = ''; + } + } else if (ext === 'adoc' || ext === 'asciidoc') { + try { + const Asciidoctor = (await import('@asciidoctor/core')).default; + const asciidoctor = Asciidoctor(); + const converted = asciidoctor.convert(readmeContent, { + safe: 'safe', + attributes: { + 'source-highlighter': 'highlight.js' + } + }); + let rendered = typeof converted === 'string' ? converted : String(converted); + // Rewrite image paths to point to repository API + rendered = rewriteImagePaths(rendered, readmePath); + readmeHtml = rendered; + readmeIsMarkdown = true; // Treat as markdown for display purposes + } catch (err) { + console.error('[README] Error rendering asciidoc:', err); + readmeHtml = ''; + } + } else if (ext === 'html' || ext === 'htm') { + // Rewrite image paths to point to repository API + readmeHtml = rewriteImagePaths(readmeContent, readmePath); + readmeIsMarkdown = true; // Treat as markdown for display purposes + } else { readmeHtml = ''; } - } else { - // Clear HTML if not markdown - readmeHtml = ''; } } } @@ -855,6 +942,179 @@ return langMap[ext.toLowerCase()] || 'plaintext'; } + // Check if file type supports preview mode + function supportsPreview(ext: string): boolean { + const previewExtensions = ['md', 'markdown', 'adoc', 'asciidoc', 'html', 'htm', 'csv']; + return previewExtensions.includes(ext.toLowerCase()); + } + + // Check if a file is an image based on extension + function isImageFileType(ext: string): boolean { + const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'apng', 'avif']; + return imageExtensions.includes(ext.toLowerCase()); + } + + // Render markdown, asciidoc, or HTML files as HTML + async function renderFileAsHtml(content: string, ext: string) { + try { + const lowerExt = ext.toLowerCase(); + + if (lowerExt === 'md' || lowerExt === 'markdown') { + // Render markdown + const MarkdownIt = (await import('markdown-it')).default; + const hljsModule = await import('highlight.js'); + const hljs = hljsModule.default || hljsModule; + + const md = new MarkdownIt({ + html: true, + linkify: true, + typographer: true, + breaks: true, + highlight: function (str: string, lang: string): string { + if (lang && hljs.getLanguage(lang)) { + try { + return hljs.highlight(str, { language: lang }).value; + } catch (__) {} + } + try { + return hljs.highlightAuto(str).value; + } catch (__) {} + return ''; + } + }); + + let rendered = md.render(content); + // Rewrite image paths to point to repository API + rendered = rewriteImagePaths(rendered, currentFile); + fileHtml = rendered; + } else if (lowerExt === 'adoc' || lowerExt === 'asciidoc') { + // Render asciidoc + const Asciidoctor = (await import('@asciidoctor/core')).default; + const asciidoctor = Asciidoctor(); + const converted = asciidoctor.convert(content, { + safe: 'safe', + attributes: { + 'source-highlighter': 'highlight.js' + } + }); + let rendered = typeof converted === 'string' ? converted : String(converted); + // Rewrite image paths to point to repository API + rendered = rewriteImagePaths(rendered, currentFile); + fileHtml = rendered; + } else if (lowerExt === 'html' || lowerExt === 'htm') { + // HTML files - rewrite image paths + let rendered = content; + rendered = rewriteImagePaths(rendered, currentFile); + fileHtml = rendered; + } else if (lowerExt === 'csv') { + // Parse CSV and render as HTML table + fileHtml = renderCsvAsTable(content); + } + } catch (err) { + console.error('Error rendering file as HTML:', err); + fileHtml = ''; + } + } + + // Parse CSV content and render as HTML table + function renderCsvAsTable(csvContent: string): string { + try { + // Parse CSV - handle quoted fields and escaped quotes + const lines = csvContent.split(/\r?\n/).filter(line => line.trim() !== ''); + if (lines.length === 0) { + return '

Empty CSV file

'; + } + + const rows: string[][] = []; + + for (const line of lines) { + const row: string[] = []; + let currentField = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + const nextChar = line[i + 1]; + + if (char === '"') { + if (inQuotes && nextChar === '"') { + // Escaped quote + currentField += '"'; + i++; // Skip next quote + } else { + // Toggle quote state + inQuotes = !inQuotes; + } + } else if (char === ',' && !inQuotes) { + // Field separator + row.push(currentField); + currentField = ''; + } else { + currentField += char; + } + } + + // Add the last field + row.push(currentField); + rows.push(row); + } + + if (rows.length === 0) { + return '

No data in CSV file

'; + } + + // Find the maximum number of columns to ensure consistent table structure + const maxColumns = Math.max(...rows.map(row => row.length)); + + // Determine if first row should be treated as header (if it has more than 1 row) + const hasHeader = rows.length > 1; + const headerRow = hasHeader ? rows[0] : null; + const dataRows = hasHeader ? rows.slice(1) : rows; + + // Build HTML table + let html = '
'; + + // Add header row if we have one + if (hasHeader && headerRow) { + html += ''; + for (let i = 0; i < maxColumns; i++) { + const cell = headerRow[i] || ''; + html += ``; + } + html += ''; + } + + // Add data rows + html += ''; + for (const row of dataRows) { + html += ''; + for (let i = 0; i < maxColumns; i++) { + const cell = row[i] || ''; + html += ``; + } + html += ''; + } + html += '
${escapeHtml(cell)}
${escapeHtml(cell)}
'; + + return html; + } catch (err) { + console.error('Error parsing CSV:', err); + return `

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

`; + } + } + + // Escape HTML to prevent XSS + function escapeHtml(text: string): string { + const map: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, (m) => map[m]); + } + async function applySyntaxHighlighting(content: string, ext: string) { try { const hljsModule = await import('highlight.js'); @@ -2622,51 +2882,73 @@ : 'master'); } - const url = `/api/repos/${npub}/${repo}/file?path=${encodeURIComponent(filePath)}&ref=${encodeURIComponent(branchName)}`; - const response = await fetch(url, { - headers: buildApiHeaders() - }); + // Determine language from file extension first to check if it's an image + const ext = filePath.split('.').pop()?.toLowerCase() || ''; - if (!response.ok) { - // Handle rate limiting specifically to prevent loops - if (response.status === 429) { - const error = new Error(`Failed to load file: Too Many Requests`); - console.warn('[File Load] Rate limited, please wait before retrying'); - throw error; - } - throw new Error(`Failed to load file: ${response.statusText}`); - } - - const data = await response.json(); - fileContent = data.content; - editedContent = data.content; - currentFile = filePath; - hasChanges = false; + // Check if this is an image file BEFORE making the API call + isImageFile = isImageFileType(ext); - // Reset README auto-load flag when a file is successfully loaded - if (filePath && filePath.toLowerCase().includes('readme')) { - readmeAutoLoadAttempted = false; - } - - // Determine language from file extension - const ext = filePath.split('.').pop()?.toLowerCase(); - if (ext === 'md' || ext === 'markdown') { - fileLanguage = 'markdown'; - } else if (ext === 'adoc' || ext === 'asciidoc') { - fileLanguage = 'asciidoc'; - } else { + 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'; - } - - // Apply syntax highlighting for read-only view (non-maintainers) - if (fileContent && !isMaintainer) { - await applySyntaxHighlighting(fileContent, ext || ''); - } - - // Apply syntax highlighting to file content if not in editor - if (fileContent && !isMaintainer) { - // For read-only view, apply highlight.js - await applySyntaxHighlighting(fileContent, ext || ''); + currentFile = filePath; + hasChanges = false; + } else { + // Not an image, load file content normally + imageUrl = null; + + const url = `/api/repos/${npub}/${repo}/file?path=${encodeURIComponent(filePath)}&ref=${encodeURIComponent(branchName)}`; + const response = await fetch(url, { + headers: buildApiHeaders() + }); + + if (!response.ok) { + // Handle rate limiting specifically to prevent loops + if (response.status === 429) { + const error = new Error(`Failed to load file: Too Many Requests`); + console.warn('[File Load] Rate limited, please wait before retrying'); + throw error; + } + throw new Error(`Failed to load file: ${response.statusText}`); + } + + const data = await response.json(); + fileContent = data.content; + editedContent = data.content; + currentFile = filePath; + hasChanges = false; + + // Reset README auto-load flag when a file is successfully loaded + if (filePath && filePath.toLowerCase().includes('readme')) { + readmeAutoLoadAttempted = false; + } + + if (ext === 'md' || ext === 'markdown') { + fileLanguage = 'markdown'; + } else if (ext === 'adoc' || ext === 'asciidoc') { + fileLanguage = 'asciidoc'; + } else { + fileLanguage = 'text'; + } + + // Reset preview mode to default (preview) when loading a new file + showFilePreview = true; + fileHtml = ''; + + // Render markdown/asciidoc/HTML/CSV files as HTML for preview + if (fileContent && (ext === 'md' || ext === 'markdown' || ext === 'adoc' || ext === 'asciidoc' || ext === 'html' || ext === 'htm' || ext === 'csv')) { + await renderFileAsHtml(fileContent, ext || ''); + } + + // Apply syntax highlighting for read-only view (non-maintainers) - only if not in preview mode + if (fileContent && !isMaintainer && !showFilePreview) { + await applySyntaxHighlighting(fileContent, ext || ''); + } } } catch (err) { error = err instanceof Error ? err.message : 'Failed to load file'; @@ -2694,6 +2976,81 @@ } } + // Copy file content to clipboard + async function copyFileContent(event?: Event) { + if (!fileContent || copyingFile) return; + + copyingFile = true; + try { + await navigator.clipboard.writeText(fileContent); + // Show temporary feedback + const button = event?.target as HTMLElement; + if (button) { + const originalTitle = button.getAttribute('title') || ''; + button.setAttribute('title', 'Copied!'); + setTimeout(() => { + button.setAttribute('title', originalTitle); + }, 2000); + } + } catch (err) { + console.error('Failed to copy file content:', err); + alert('Failed to copy file content to clipboard'); + } finally { + copyingFile = false; + } + } + + // Download file + function downloadFile() { + if (!fileContent || !currentFile) return; + + try { + // Determine MIME type based on file extension + const ext = currentFile.split('.').pop()?.toLowerCase() || ''; + const mimeTypes: Record = { + 'js': 'text/javascript', + 'ts': 'text/typescript', + 'json': 'application/json', + 'css': 'text/css', + 'html': 'text/html', + 'htm': 'text/html', + 'md': 'text/markdown', + 'txt': 'text/plain', + 'csv': 'text/csv', + 'xml': 'application/xml', + 'svg': 'image/svg+xml', + 'py': 'text/x-python', + 'java': 'text/x-java-source', + 'c': 'text/x-csrc', + 'cpp': 'text/x-c++src', + 'h': 'text/x-csrc', + 'hpp': 'text/x-c++src', + 'sh': 'text/x-shellscript', + 'bash': 'text/x-shellscript', + 'yaml': 'text/yaml', + 'yml': 'text/yaml', + 'toml': 'text/toml', + 'ini': 'text/plain', + 'conf': 'text/plain', + 'log': 'text/plain' + }; + + const mimeType = mimeTypes[ext] || 'text/plain'; + const blob = new Blob([fileContent], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = currentFile.split('/').pop() || 'file'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (err) { + console.error('Failed to download file:', err); + alert('Failed to download file'); + } + } + function handleBack() { if (pathStack.length > 0) { const parentPath = pathStack.pop() || ''; @@ -4456,6 +4813,17 @@

README

+ {#if readmePath && supportsPreview((readmePath.split('.').pop() || '').toLowerCase())} + + {/if} View Raw Download ZIP + {/if} + {#if currentFile && fileContent} + + + {/if} {#if isMaintainer}