Browse Source

markup and csv previews in file viewer

correct image view
correct syntax view
add copy, raw, and download buttons

Nostr-Signature: 40e64c0a716e0ff594b736db14021e43583d5ff0918c1ec0c4fe2c07ddbdbc73 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc bb3a50267214a005104853e9b78dd94e4980024146978baef8612ef0400024032dd620749621f832ee9f0458e582084f12ed9c85a40c306f5bbc92e925198a97
main
Silberengel 3 weeks ago
parent
commit
7340d0f990
  1. 1
      nostr/commit-signatures.jsonl
  2. 27
      src/lib/services/git/api-repo-fetcher.ts
  3. 138
      src/lib/services/git/file-manager.ts
  4. 286
      src/lib/styles/repo.css
  5. 122
      src/routes/api/repos/[npub]/[repo]/raw/+server.ts
  6. 63
      src/routes/api/repos/[npub]/[repo]/tree/+server.ts
  7. 587
      src/routes/repos/[npub]/[repo]/+page.svelte
  8. 5
      static/icons/download.svg

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

27
src/lib/services/git/api-repo-fetcher.ts

@ -223,16 +223,23 @@ async function fetchFromGitHub(owner: string, repo: string): Promise<Partial<Api
})) }))
: []; : [];
const files: ApiFile[] = treeResponse?.ok let files: ApiFile[] = [];
? (await treeResponse.json()).tree if (treeResponse?.ok) {
?.filter((item: any) => item.type === 'blob' || item.type === 'tree') const treeData = await treeResponse.json();
.map((item: any) => ({ // Check if the tree was truncated (GitHub API limitation)
name: item.path.split('/').pop(), if (treeData.truncated) {
path: item.path, logger.warn({ owner, repo }, 'GitHub tree response was truncated, some files may be missing');
type: item.type === 'tree' ? 'dir' : 'file', // For truncated trees, we could make additional requests, but for now just log a warning
size: item.size }
})) || [] 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 // Try to fetch README
let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined; let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined;

138
src/lib/services/git/file-manager.ts

@ -473,6 +473,7 @@ export class FileManager {
const cacheKey = RepoCache.fileListKey(npub, repoName, ref, path); const cacheKey = RepoCache.fileListKey(npub, repoName, ref, path);
const cached = repoCache.get<FileEntry[]>(cacheKey); const cached = repoCache.get<FileEntry[]>(cacheKey);
if (cached !== null) { if (cached !== null) {
logger.debug({ npub, repoName, path, ref, cachedCount: cached.length }, '[FileManager] Returning cached file list');
return cached; return cached;
} }
@ -480,33 +481,144 @@ export class FileManager {
try { try {
// Get the tree for the specified path // 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[] = []; const emptyResult: FileEntry[] = [];
// Cache empty result for shorter time (30 seconds) // Cache empty result for shorter time (30 seconds)
repoCache.set(cacheKey, emptyResult, 30 * 1000); repoCache.set(cacheKey, emptyResult, 30 * 1000);
logger.debug({ npub, repoName, path, ref, gitPath }, '[FileManager] git ls-tree returned empty result');
return emptyResult; 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 entries: FileEntry[] = [];
const lines = tree.trim().split('\n').filter(line => line.length > 0); 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) { for (const line of lines) {
// Format: <mode> <type> <object> <size>\t<file> // Format: <mode> <type> <object> <size>\t<file>
const match = line.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)\s+(.+)$/); // Note: git ls-tree uses a tab character between size and filename
if (match) { // The format is: mode type object size<TAB>path
const [, , type, , size, name] = match; // Important: git ls-tree returns paths relative to repo root, not relative to the specified path
const fullPath = path ? join(path, name) : name; // We need to handle both spaces and tabs
const tabIndex = line.lastIndexOf('\t');
entries.push({ if (tabIndex === -1) {
name, // Try space-separated format as fallback
path: fullPath, const match = line.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)\s+(.+)$/);
type: type === 'tree' ? 'directory' : 'file', if (match) {
size: size !== '-' ? parseInt(size, 10) : undefined 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) => { const sortedEntries = entries.sort((a, b) => {
// Directories first, then files, both alphabetically // Directories first, then files, both alphabetically

286
src/lib/styles/repo.css

@ -494,6 +494,292 @@
margin: 2rem 0; 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 */ /* Mobile responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.repo-layout { .repo-layout {

122
src/routes/api/repos/[npub]/[repo]/raw/+server.ts

@ -3,23 +3,33 @@
*/ */
import type { RequestHandler } from './$types'; 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 { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleValidationError } from '$lib/utils/error-handler.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( export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext, event: RequestEvent) => { async (context: RepoRequestContext, event: RequestEvent) => {
const filePath = context.path || event.url.searchParams.get('path'); 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) { if (!filePath) {
throw handleValidationError('Missing path parameter', { operation: 'getRawFile', npub: context.npub, repo: context.repo }); 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 // Determine content type based on file extension
const ext = filePath.split('.').pop()?.toLowerCase(); const ext = filePath.split('.').pop()?.toLowerCase();
const contentTypeMap: Record<string, string> = { const contentTypeMap: Record<string, string> = {
@ -35,6 +45,8 @@ export const GET: RequestHandler = createRepoGetHandler(
'jpeg': 'image/jpeg', 'jpeg': 'image/jpeg',
'gif': 'image/gif', 'gif': 'image/gif',
'webp': 'image/webp', 'webp': 'image/webp',
'bmp': 'image/bmp',
'ico': 'image/x-icon',
'pdf': 'application/pdf', 'pdf': 'application/pdf',
'txt': 'text/plain', 'txt': 'text/plain',
'md': 'text/markdown', 'md': 'text/markdown',
@ -44,14 +56,98 @@ export const GET: RequestHandler = createRepoGetHandler(
const contentType = contentTypeMap[ext || ''] || 'text/plain'; const contentType = contentTypeMap[ext || ''] || 'text/plain';
// Return raw file content // For binary image files, use git cat-file to get raw binary data
return new Response(fileData.content, { if (ext && isBinaryImage(ext)) {
headers: { const repoPath = join(repoRoot, context.npub, `${context.repo}.git`);
'Content-Type': contentType,
'Content-Disposition': `inline; filename="${filePath.split('/').pop()}"`, // Get the blob hash for the file
'Cache-Control': 'public, max-age=3600' return new Promise<Response>((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' } { operation: 'getRawFile' }
); );

63
src/routes/api/repos/[npub]/[repo]/tree/+server.ts

@ -40,17 +40,53 @@ export const GET: RequestHandler = createRepoGetHandler(
// Return API data directly without cloning // Return API data directly without cloning
const path = context.path || ''; const path = context.path || '';
// Filter files by path if specified // Filter files by path if specified
const filteredFiles = path let filteredFiles: typeof apiData.files;
? apiData.files.filter(f => f.path.startsWith(path)) if (path) {
: apiData.files.filter(f => !f.path.includes('/') || f.path.split('/').length === 1); // 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' // Normalize type: API returns 'dir' but frontend expects 'directory'
const normalizedFiles = filteredFiles.map(f => ({ // Also update name to be just the filename/dirname for display
name: f.name, const normalizedFiles = filteredFiles.map(f => {
path: f.path, // Extract display name from path
type: (f.type === 'dir' ? 'directory' : 'file') as 'file' | 'directory', const cleanPath = f.path.endsWith('/') ? f.path.slice(0, -1) : f.path;
size: f.size 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); return json(normalizedFiles);
} }
@ -108,6 +144,15 @@ export const GET: RequestHandler = createRepoGetHandler(
try { try {
const files = await fileManager.listFiles(context.npub, context.repo, ref, path); 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); return json(files);
} catch (err) { } catch (err) {
// Log the actual error for debugging // Log the actual error for debugging

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

@ -711,6 +711,62 @@
let loadingReadme = $state(false); let loadingReadme = $state(false);
let readmeHtml = $state<string>(''); let readmeHtml = $state<string>('');
let highlightedFileContent = $state<string>(''); let highlightedFileContent = $state<string>('');
let fileHtml = $state<string>(''); // 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<string | null>(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(/<img([^>]*)\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 `<img${before} src="${apiUrl}"${after}>`;
});
}
// Fork // Fork
let forkInfo = $state<{ isFork: boolean; originalRepo: { npub: string; repo: string } | null } | null>(null); let forkInfo = $state<{ isFork: boolean; originalRepo: { npub: string; repo: string } | null } | null>(null);
@ -754,43 +810,74 @@
readmePath = data.path; readmePath = data.path;
readmeIsMarkdown = data.isMarkdown; readmeIsMarkdown = data.isMarkdown;
// Render markdown if needed // Reset preview mode for README
if (readmeIsMarkdown && readmeContent) { showFilePreview = true;
try { readmeHtml = '';
const MarkdownIt = (await import('markdown-it')).default;
const hljsModule = await import('highlight.js'); // Render markdown or asciidoc if needed
const hljs = hljsModule.default || hljsModule; if (readmeContent) {
const ext = readmePath?.split('.').pop()?.toLowerCase() || '';
const md = new MarkdownIt({ if (readmeIsMarkdown || ext === 'md' || ext === 'markdown') {
html: true, // Enable HTML tags in source try {
linkify: true, // Autoconvert URL-like text to links const MarkdownIt = (await import('markdown-it')).default;
typographer: true, // Enable some language-neutral replacement + quotes beautification const hljsModule = await import('highlight.js');
breaks: true, // Convert '\n' in paragraphs into <br> const hljs = hljsModule.default || hljsModule;
highlight: function (str: string, lang: string): string {
if (lang && hljs.getLanguage(lang)) { const md = new MarkdownIt({
try { html: true, // Enable HTML tags in source
return '<pre class="hljs"><code>' + linkify: true, // Autoconvert URL-like text to links
hljs.highlight(str, { language: lang }).value + typographer: true, // Enable some language-neutral replacement + quotes beautification
'</code></pre>'; breaks: true, // Convert '\n' in paragraphs into <br>
} catch (err) { highlight: function (str: string, lang: string): string {
// Fallback to escaped HTML if highlighting fails if (lang && hljs.getLanguage(lang)) {
// This is expected for unsupported languages try {
return '<pre class="hljs"><code>' +
hljs.highlight(str, { language: lang }).value +
'</code></pre>';
} catch (err) {
// Fallback to escaped HTML if highlighting fails
// This is expected for unsupported languages
}
} }
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
} }
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>'; });
}
}); let rendered = md.render(readmeContent);
// Rewrite image paths to point to repository API
readmeHtml = md.render(readmeContent); rendered = rewriteImagePaths(rendered, readmePath);
console.log('[README] Markdown rendered successfully, HTML length:', readmeHtml.length); readmeHtml = rendered;
} catch (err) { console.log('[README] Markdown rendered successfully, HTML length:', readmeHtml.length);
console.error('[README] Error rendering markdown:', err); } catch (err) {
// Fallback: show as plain text if rendering fails 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 = ''; readmeHtml = '';
} }
} else {
// Clear HTML if not markdown
readmeHtml = '';
} }
} }
} }
@ -855,6 +942,179 @@
return langMap[ext.toLowerCase()] || 'plaintext'; 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 '<div class="csv-empty"><p>Empty CSV file</p></div>';
}
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 '<div class="csv-empty"><p>No data in CSV file</p></div>';
}
// 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 = '<div class="csv-table-wrapper"><table class="csv-table">';
// Add header row if we have one
if (hasHeader && headerRow) {
html += '<thead><tr>';
for (let i = 0; i < maxColumns; i++) {
const cell = headerRow[i] || '';
html += `<th>${escapeHtml(cell)}</th>`;
}
html += '</tr></thead>';
}
// Add data rows
html += '<tbody>';
for (const row of dataRows) {
html += '<tr>';
for (let i = 0; i < maxColumns; i++) {
const cell = row[i] || '';
html += `<td>${escapeHtml(cell)}</td>`;
}
html += '</tr>';
}
html += '</tbody></table></div>';
return html;
} catch (err) {
console.error('Error parsing CSV:', err);
return `<div class="csv-error"><p>Error parsing CSV: ${escapeHtml(err instanceof Error ? err.message : String(err))}</p></div>`;
}
}
// Escape HTML to prevent XSS
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
async function applySyntaxHighlighting(content: string, ext: string) { async function applySyntaxHighlighting(content: string, ext: string) {
try { try {
const hljsModule = await import('highlight.js'); const hljsModule = await import('highlight.js');
@ -2622,51 +2882,73 @@
: 'master'); : 'master');
} }
const url = `/api/repos/${npub}/${repo}/file?path=${encodeURIComponent(filePath)}&ref=${encodeURIComponent(branchName)}`; // Determine language from file extension first to check if it's an image
const response = await fetch(url, { const ext = filePath.split('.').pop()?.toLowerCase() || '';
headers: buildApiHeaders()
});
if (!response.ok) { // Check if this is an image file BEFORE making the API call
// Handle rate limiting specifically to prevent loops isImageFile = isImageFileType(ext);
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 (isImageFile) {
if (filePath && filePath.toLowerCase().includes('readme')) { // For image files, construct the raw file URL and skip loading text content
readmeAutoLoadAttempted = false; imageUrl = `/api/repos/${npub}/${repo}/raw?path=${encodeURIComponent(filePath)}&ref=${encodeURIComponent(branchName)}`;
} fileContent = ''; // Clear content for images
editedContent = ''; // Clear edited content for images
// Determine language from file extension fileHtml = ''; // Clear HTML for images
const ext = filePath.split('.').pop()?.toLowerCase(); highlightedFileContent = ''; // Clear highlighted content
if (ext === 'md' || ext === 'markdown') {
fileLanguage = 'markdown';
} else if (ext === 'adoc' || ext === 'asciidoc') {
fileLanguage = 'asciidoc';
} else {
fileLanguage = 'text'; fileLanguage = 'text';
} currentFile = filePath;
hasChanges = false;
// Apply syntax highlighting for read-only view (non-maintainers) } else {
if (fileContent && !isMaintainer) { // Not an image, load file content normally
await applySyntaxHighlighting(fileContent, ext || ''); imageUrl = null;
}
const url = `/api/repos/${npub}/${repo}/file?path=${encodeURIComponent(filePath)}&ref=${encodeURIComponent(branchName)}`;
// Apply syntax highlighting to file content if not in editor const response = await fetch(url, {
if (fileContent && !isMaintainer) { headers: buildApiHeaders()
// For read-only view, apply highlight.js });
await applySyntaxHighlighting(fileContent, ext || '');
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) { } catch (err) {
error = err instanceof Error ? err.message : 'Failed to load file'; 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<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'
};
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() { function handleBack() {
if (pathStack.length > 0) { if (pathStack.length > 0) {
const parentPath = pathStack.pop() || ''; const parentPath = pathStack.pop() || '';
@ -4456,6 +4813,17 @@
<div class="readme-header"> <div class="readme-header">
<h3>README</h3> <h3>README</h3>
<div class="readme-actions"> <div class="readme-actions">
{#if readmePath && supportsPreview((readmePath.split('.').pop() || '').toLowerCase())}
<button
onclick={() => {
showFilePreview = !showFilePreview;
}}
class="preview-toggle-button"
title={showFilePreview ? 'Show raw' : 'Show preview'}
>
{showFilePreview ? 'Raw' : 'Preview'}
</button>
{/if}
<a href={`/api/repos/${npub}/${repo}/raw?path=${readmePath}`} target="_blank" class="raw-link">View Raw</a> <a href={`/api/repos/${npub}/${repo}/raw?path=${readmePath}`} target="_blank" class="raw-link">View Raw</a>
<a href={`/api/repos/${npub}/${repo}/download?format=zip`} class="download-link">Download ZIP</a> <a href={`/api/repos/${npub}/${repo}/download?format=zip`} class="download-link">Download ZIP</a>
<button <button
@ -4469,7 +4837,7 @@
</div> </div>
{#if loadingReadme} {#if loadingReadme}
<div class="loading">Loading README...</div> <div class="loading">Loading README...</div>
{:else if readmeIsMarkdown && readmeHtml && readmeHtml.trim()} {:else if showFilePreview && readmeHtml && readmeHtml.trim()}
<div class="readme-content markdown"> <div class="readme-content markdown">
{@html readmeHtml} {@html readmeHtml}
</div> </div>
@ -4488,6 +4856,39 @@
{#if hasChanges} {#if hasChanges}
<span class="unsaved-indicator">● Unsaved changes</span> <span class="unsaved-indicator">● Unsaved changes</span>
{/if} {/if}
{#if currentFile && supportsPreview((currentFile.split('.').pop() || '').toLowerCase()) && !isMaintainer}
<button
onclick={() => {
showFilePreview = !showFilePreview;
if (!showFilePreview && fileContent && currentFile) {
// When switching to raw, apply syntax highlighting
const ext = currentFile.split('.').pop() || '';
applySyntaxHighlighting(fileContent, ext).catch(err => console.error('Error applying syntax highlighting:', err));
}
}}
class="preview-toggle-button"
title={showFilePreview ? 'Show raw' : 'Show preview'}
>
{showFilePreview ? 'Raw' : 'Preview'}
</button>
{/if}
{#if currentFile && fileContent}
<button
onclick={(e) => copyFileContent(e)}
disabled={copyingFile}
class="file-action-button"
title="Copy raw content to clipboard"
>
<img src="/icons/copy.svg" alt="Copy" class="icon-inline" />
</button>
<button
onclick={downloadFile}
class="file-action-button"
title="Download file"
>
<img src="/icons/download.svg" alt="Download" class="icon-inline" />
</button>
{/if}
{#if isMaintainer} {#if isMaintainer}
<button <button
onclick={() => { onclick={() => {
@ -4526,9 +4927,21 @@
/> />
{:else} {:else}
<div class="read-only-editor" class:word-wrap={wordWrap}> <div class="read-only-editor" class:word-wrap={wordWrap}>
{#if highlightedFileContent} {#if isImageFile && imageUrl}
<!-- Image file: display as image -->
<div class="file-preview image-preview">
<img src={imageUrl} alt={currentFile?.split('/').pop() || 'Image'} class="file-image" />
</div>
{:else if currentFile && showFilePreview && fileHtml && supportsPreview((currentFile.split('.').pop() || '').toLowerCase())}
<!-- Preview mode: show rendered HTML -->
<div class="file-preview markdown">
{@html fileHtml}
</div>
{:else if highlightedFileContent}
<!-- Raw mode: show syntax highlighted code -->
{@html highlightedFileContent} {@html highlightedFileContent}
{:else} {:else}
<!-- Fallback: plain text -->
<pre><code class="hljs">{fileContent}</code></pre> <pre><code class="hljs">{fileContent}</code></pre>
{/if} {/if}
</div> </div>
@ -5500,4 +5913,24 @@
:global(.read-only-editor.word-wrap .hljs *) { :global(.read-only-editor.word-wrap .hljs *) {
white-space: pre-wrap !important; white-space: pre-wrap !important;
} }
/* Image preview styling */
:global(.image-preview) {
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
background: var(--bg-secondary);
border-radius: 4px;
min-height: 200px;
}
:global(.file-image) {
max-width: 100%;
max-height: 80vh;
height: auto;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
</style> </style>

5
static/icons/download.svg

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>

After

Width:  |  Height:  |  Size: 326 B

Loading…
Cancel
Save