You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
926 lines
33 KiB
926 lines
33 KiB
<script lang="ts"> |
|
import type { GitFile, GitRepoInfo } from '../../services/content/git-repo-fetcher.js'; |
|
import { fetchGitHubApi } from '../../services/github-api.js'; |
|
import { browser } from '$app/environment'; |
|
// @ts-ignore - highlight.js default export works at runtime |
|
import hljs from 'highlight.js'; |
|
import 'highlight.js/styles/vs2015.css'; |
|
import Icon from '../ui/Icon.svelte'; |
|
|
|
interface Props { |
|
files: GitFile[]; |
|
repoInfo: GitRepoInfo; |
|
} |
|
|
|
let { files, repoInfo }: Props = $props(); |
|
|
|
// Track expanded folders |
|
let expandedPaths = $state<Set<string>>(new Set()); |
|
|
|
// Track selected file and its content |
|
let selectedFile = $state<GitFile | null>(null); |
|
let fileContent = $state<string | null>(null); |
|
let loadingContent = $state(false); |
|
let contentError = $state<string | null>(null); |
|
let codeRef = $state<HTMLElement | null>(null); |
|
let fileUrl = $state<string | null>(null); // For images and media files |
|
|
|
// Build tree structure |
|
function buildTree(files: GitFile[]): any { |
|
const tree: any = {}; |
|
|
|
for (const file of files) { |
|
const parts = file.path.split('/').filter(p => p); |
|
let current = tree; |
|
|
|
for (let i = 0; i < parts.length; i++) { |
|
const part = parts[i]; |
|
const isLast = i === parts.length - 1; |
|
const isDirectory = file.type === 'dir'; |
|
|
|
if (isLast) { |
|
// Last part of path |
|
if (isDirectory) { |
|
// Directory - ensure it's an object (not a file) |
|
if (!current[part] || (current[part].path && !current[part]._isDir)) { |
|
current[part] = {}; |
|
} |
|
// Mark as directory |
|
current[part]._isDir = true; |
|
current[part]._path = file.path; |
|
} else { |
|
// File - store the GitFile object |
|
// Only overwrite if current entry is a directory placeholder without children |
|
if (!current[part] || (current[part]._isDir && Object.keys(current[part]).length <= 2)) { |
|
current[part] = file; |
|
} |
|
} |
|
} else { |
|
// Intermediate path segment - must be a directory |
|
if (!current[part]) { |
|
current[part] = {}; |
|
} else if (current[part].path && !current[part]._isDir) { |
|
// This was stored as a file, but we need it as a directory |
|
// This shouldn't happen, but convert it |
|
const existingFile = current[part]; |
|
current[part] = {}; |
|
current[part][existingFile.name] = existingFile; |
|
} |
|
current = current[part]; |
|
} |
|
} |
|
} |
|
|
|
return tree; |
|
} |
|
|
|
const fileTree = $derived.by(() => buildTree(files)); |
|
|
|
function toggleFolder(path: string) { |
|
if (expandedPaths.has(path)) { |
|
expandedPaths.delete(path); |
|
} else { |
|
expandedPaths.add(path); |
|
} |
|
expandedPaths = new Set(expandedPaths); // Trigger reactivity |
|
} |
|
|
|
function isExpanded(path: string): boolean { |
|
return expandedPaths.has(path); |
|
} |
|
|
|
async function fetchFileContent(file: GitFile) { |
|
if (selectedFile?.path === file.path && (fileContent || fileUrl)) { |
|
return; // Already loaded |
|
} |
|
|
|
selectedFile = file; |
|
loadingContent = true; |
|
contentError = null; |
|
fileContent = null; |
|
fileUrl = null; |
|
|
|
// For images and media files, we just need the URL, not the content |
|
if (isImageFile(file) || isVideoFile(file) || isAudioFile(file)) { |
|
fileUrl = getRawFileUrl(file); |
|
loadingContent = false; |
|
return; |
|
} |
|
|
|
try { |
|
// Parse the repo URL to determine platform |
|
const url = repoInfo.url; |
|
let apiUrl = ''; |
|
|
|
if (url.includes('github.com')) { |
|
// GitHub API: GET /repos/{owner}/{repo}/contents/{path} |
|
const match = url.match(/github\.com\/([^/]+)\/([^/]+)/); |
|
if (match) { |
|
const [, owner, repo] = match; |
|
apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${file.path}?ref=${repoInfo.defaultBranch}`; |
|
} |
|
} else if (url.includes('gitlab')) { |
|
// GitLab API (both gitlab.com and self-hosted): GET /projects/{id}/repository/files/{path}/raw |
|
const match = url.match(/(https?:\/\/[^/]*gitlab[^/]*)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); |
|
if (match) { |
|
const [, baseHost, owner, repo] = match; |
|
const cleanRepo = repo.replace(/\.git$/, ''); |
|
const projectPath = encodeURIComponent(`${owner}/${cleanRepo}`); |
|
const apiBase = baseHost.includes('gitlab.com') |
|
? 'https://gitlab.com/api/v4' |
|
: `${baseHost}/api/v4`; |
|
// Use proxy endpoint to avoid CORS issues |
|
// Pass file path with actual slashes - proxy will handle encoding correctly |
|
apiUrl = `/api/gitea-proxy/projects/${projectPath}/repository/files/${file.path}/raw?baseUrl=${encodeURIComponent(apiBase)}&ref=${repoInfo.defaultBranch}`; |
|
} |
|
} else if (url.includes('onedev')) { |
|
// OneDev API: similar to Gitea, uses /api/v1/repos/{owner}/{repo}/contents/{path} |
|
const match = url.match(/(https?:\/\/[^/]*onedev[^/]*)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); |
|
if (match) { |
|
const [, baseUrl, owner, repo] = match; |
|
const cleanRepo = repo.replace(/\.git$/, ''); |
|
const baseApiUrl = `${baseUrl}/api/v1`; |
|
// Use proxy endpoint to avoid CORS issues |
|
apiUrl = `/api/gitea-proxy/repos/${owner}/${cleanRepo}/contents/${file.path}?baseUrl=${encodeURIComponent(baseApiUrl)}&ref=${repoInfo.defaultBranch}`; |
|
} |
|
} else { |
|
// Try Gitea/Forgejo pattern (handles .git suffix) - generic fallback |
|
const match = url.match(/(https?:\/\/[^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); |
|
if (match) { |
|
const [, baseUrl, owner, repo] = match; |
|
const cleanRepo = repo.replace(/\.git$/, ''); |
|
const baseApiUrl = `${baseUrl}/api/v1`; |
|
// Use proxy endpoint to avoid CORS issues |
|
apiUrl = `/api/gitea-proxy/repos/${owner}/${cleanRepo}/contents/${file.path}?baseUrl=${encodeURIComponent(baseApiUrl)}&ref=${repoInfo.defaultBranch}`; |
|
} |
|
} |
|
|
|
if (!apiUrl) { |
|
throw new Error('Unable to determine API endpoint for this repository'); |
|
} |
|
|
|
// Use GitHub API helper for GitHub repos (handles rate limiting and token fallback) |
|
let response: Response; |
|
if (url.includes('github.com')) { |
|
const apiResult = await fetchGitHubApi(apiUrl); |
|
response = apiResult.response; |
|
} else { |
|
response = await fetch(apiUrl); |
|
} |
|
|
|
if (!response.ok) { |
|
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`); |
|
} |
|
|
|
// Check content type to determine how to parse the response |
|
const contentType = response.headers.get('content-type') || ''; |
|
|
|
if (url.includes('github.com')) { |
|
// GitHub API returns JSON with base64 encoded content |
|
const data = await response.json(); |
|
if (data.content) { |
|
fileContent = atob(data.content.replace(/\s/g, '')); |
|
} |
|
} else if (url.includes('gitlab') && apiUrl.includes('/raw')) { |
|
// GitLab raw file endpoint returns plain text |
|
fileContent = await response.text(); |
|
} else if (contentType.includes('application/json')) { |
|
// Other platforms that return JSON (like Gitea contents API) |
|
const data = await response.json(); |
|
if (data.content) { |
|
// Some APIs return base64 content in JSON |
|
fileContent = atob(data.content.replace(/\s/g, '')); |
|
} else if (typeof data === 'string') { |
|
fileContent = data; |
|
} |
|
} else { |
|
// Default to text for raw file endpoints |
|
fileContent = await response.text(); |
|
} |
|
} catch (error) { |
|
console.error('Error fetching file content:', error); |
|
contentError = error instanceof Error ? error.message : 'Failed to load file content'; |
|
} finally { |
|
loadingContent = false; |
|
} |
|
} |
|
|
|
function getFileIconName(file: GitFile): string { |
|
const ext = file.name.split('.').pop()?.toLowerCase() || ''; |
|
const iconMap: Record<string, string> = { |
|
// Code files |
|
'js': 'code', 'ts': 'code', 'jsx': 'code', 'tsx': 'code', |
|
'py': 'code', 'java': 'code', 'cpp': 'code', 'c': 'code', |
|
'html': 'code', 'css': 'code', 'scss': 'code', 'sass': 'code', |
|
'rs': 'code', 'go': 'code', 'php': 'code', 'rb': 'code', |
|
'sh': 'code', 'bash': 'code', 'zsh': 'code', |
|
// Data/config files |
|
'json': 'file-text', 'yaml': 'file-text', 'yml': 'file-text', 'toml': 'file-text', |
|
// Text files |
|
'md': 'file-text', 'txt': 'file-text', 'adoc': 'file-text', |
|
// Images |
|
'png': 'image', 'jpg': 'image', 'jpeg': 'image', 'gif': 'image', 'svg': 'image', |
|
// Other files |
|
'pdf': 'file-text', 'zip': 'file-text', 'tar': 'file-text', 'gz': 'file-text' |
|
}; |
|
return iconMap[ext] || 'file-text'; |
|
} |
|
|
|
function formatFileSize(bytes?: number): string { |
|
if (!bytes) return ''; |
|
if (bytes < 1024) return `${bytes} B`; |
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; |
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; |
|
} |
|
|
|
function getFileExtension(file: GitFile): string { |
|
const parts = file.name.split('.'); |
|
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ''; |
|
} |
|
|
|
function isImageFile(file: GitFile): boolean { |
|
const ext = getFileExtension(file); |
|
return ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico'].includes(ext); |
|
} |
|
|
|
function isVideoFile(file: GitFile): boolean { |
|
const ext = getFileExtension(file); |
|
return ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv', 'wmv', 'flv'].includes(ext); |
|
} |
|
|
|
function isAudioFile(file: GitFile): boolean { |
|
const ext = getFileExtension(file); |
|
return ['mp3', 'wav', 'ogg', 'oga', 'aac', 'm4a', 'flac', 'wma'].includes(ext); |
|
} |
|
|
|
function isCodeFile(file: GitFile): boolean { |
|
const ext = getFileExtension(file); |
|
const codeExtensions = [ |
|
'js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'h', 'hpp', |
|
'html', 'css', 'scss', 'sass', 'less', 'json', 'yaml', 'yml', |
|
'xml', 'md', 'txt', 'sh', 'bash', 'zsh', 'rs', 'go', 'php', 'rb', |
|
'swift', 'kt', 'dart', 'vue', 'svelte', 'r', 'sql', 'pl', 'lua', |
|
'clj', 'hs', 'elm', 'ex', 'exs', 'ml', 'fs', 'vb', 'cs', 'd', |
|
'pas', 'ada', 'erl', 'hrl', 'vim', 'vimrc', 'zshrc', 'bashrc', |
|
'dockerfile', 'makefile', 'cmake', 'gradle', 'maven', 'pom', |
|
'toml', 'ini', 'conf', 'config', 'properties', 'env', 'gitignore', |
|
'dockerignore', 'editorconfig', 'eslintrc', 'prettierrc' |
|
]; |
|
return codeExtensions.includes(ext); |
|
} |
|
|
|
function getLanguageFromExtension(file: GitFile): string { |
|
const ext = getFileExtension(file); |
|
const languageMap: Record<string, string> = { |
|
'js': 'javascript', 'ts': 'typescript', 'jsx': 'javascript', 'tsx': 'typescript', |
|
'py': 'python', 'java': 'java', 'cpp': 'cpp', 'c': 'c', 'h': 'c', 'hpp': 'cpp', |
|
'html': 'html', 'css': 'css', 'scss': 'scss', 'sass': 'sass', 'less': 'less', |
|
'json': 'json', 'yaml': 'yaml', 'yml': 'yaml', 'xml': 'xml', |
|
'md': 'markdown', 'txt': 'plaintext', 'sh': 'bash', 'bash': 'bash', 'zsh': 'bash', |
|
'rs': 'rust', 'go': 'go', 'php': 'php', 'rb': 'ruby', |
|
'swift': 'swift', 'kt': 'kotlin', 'dart': 'dart', 'vue': 'vue', 'svelte': 'svelte', |
|
'r': 'r', 'sql': 'sql', 'pl': 'perl', 'lua': 'lua', |
|
'clj': 'clojure', 'hs': 'haskell', 'elm': 'elm', 'ex': 'elixir', 'exs': 'elixir', |
|
'ml': 'ocaml', 'fs': 'fsharp', 'vb': 'vbnet', 'cs': 'csharp', 'd': 'd', |
|
'pas': 'pascal', 'ada': 'ada', 'erl': 'erlang', 'hrl': 'erlang', |
|
'vim': 'vim', 'vimrc': 'vim', 'zshrc': 'bash', 'bashrc': 'bash', |
|
'dockerfile': 'dockerfile', 'makefile': 'makefile', 'cmake': 'cmake', |
|
'gradle': 'gradle', 'maven': 'xml', 'pom': 'xml', |
|
'toml': 'toml', 'ini': 'ini', 'conf': 'ini', 'config': 'ini', |
|
'properties': 'properties', 'env': 'bash', 'gitignore': 'plaintext', |
|
'dockerignore': 'plaintext', 'editorconfig': 'ini', 'eslintrc': 'json', 'prettierrc': 'json' |
|
}; |
|
return languageMap[ext] || 'plaintext'; |
|
} |
|
|
|
function getRawFileUrl(file: GitFile): string { |
|
const url = repoInfo.url; |
|
const branch = repoInfo.defaultBranch; |
|
|
|
if (url.includes('github.com')) { |
|
const match = url.match(/github\.com\/([^/]+)\/([^/]+)/); |
|
if (match) { |
|
const [, owner, repo] = match; |
|
return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${file.path}`; |
|
} |
|
} else if (url.includes('gitlab.com')) { |
|
const match = url.match(/gitlab\.com\/([^/]+)\/([^/]+)/); |
|
if (match) { |
|
const [, owner, repo] = match; |
|
const projectPath = encodeURIComponent(`${owner}/${repo}`); |
|
return `https://gitlab.com/${owner}/${repo}/-/raw/${branch}/${file.path}`; |
|
} |
|
} else { |
|
// Try Gitea pattern |
|
const match = url.match(/(https?:\/\/[^/]+)\/([^/]+)\/([^/]+)/); |
|
if (match) { |
|
const [, baseUrl, owner, repo] = match; |
|
return `${baseUrl}/${owner}/${repo}/raw/branch/${branch}/${file.path}`; |
|
} |
|
} |
|
|
|
return ''; |
|
} |
|
|
|
// Apply syntax highlighting when file content changes |
|
$effect(() => { |
|
if (!browser || !hljs) return; // Only run in browser and if hljs is available |
|
if (fileContent && selectedFile && isCodeFile(selectedFile) && codeRef) { |
|
const language = getLanguageFromExtension(selectedFile); |
|
try { |
|
// Check if language is supported, fallback to plaintext if not |
|
if (hljs.getLanguage && hljs.getLanguage(language)) { |
|
codeRef.innerHTML = hljs.highlight(fileContent, { language }).value; |
|
codeRef.className = `hljs language-${language}`; |
|
} else { |
|
// Language not supported, use plaintext |
|
codeRef.innerHTML = hljs.highlight(fileContent, { language: 'plaintext' }).value; |
|
codeRef.className = 'hljs language-plaintext'; |
|
} |
|
} catch (error) { |
|
// If highlighting fails, just display plain text |
|
console.warn(`Failed to highlight code with language '${language}':`, error); |
|
codeRef.textContent = fileContent; |
|
codeRef.className = 'hljs language-plaintext'; |
|
} |
|
} |
|
}); |
|
</script> |
|
|
|
<div class="file-explorer"> |
|
<div class="file-tree-panel"> |
|
<div class="file-tree-header"> |
|
<h3 class="file-tree-title">Files</h3> |
|
<button |
|
onclick={() => { |
|
// Expand all |
|
const allPaths = new Set<string>(); |
|
function collectPaths(tree: any, path: string = '') { |
|
for (const [name, value] of Object.entries(tree)) { |
|
if (value && typeof value === 'object' && !('path' in value)) { |
|
const newPath = path ? `${path}/${name}` : name; |
|
allPaths.add(newPath); |
|
collectPaths(value, newPath); |
|
} |
|
} |
|
} |
|
collectPaths(fileTree); |
|
expandedPaths = allPaths; |
|
}} |
|
class="expand-all-btn" |
|
> |
|
Expand All |
|
</button> |
|
<button |
|
onclick={() => { |
|
expandedPaths = new Set(); |
|
}} |
|
class="collapse-all-btn" |
|
> |
|
Collapse All |
|
</button> |
|
</div> |
|
<div class="file-tree-content"> |
|
{#each Object.entries(fileTree) as [name, value]} |
|
{@const isFile = value && typeof value === 'object' && 'path' in value} |
|
{@const isDir = value && typeof value === 'object' && !('path' in value)} |
|
{#if isDir} |
|
{@const dirPath = name} |
|
{@const isExpandedDir = isExpanded(dirPath)} |
|
<div class="tree-item tree-folder"> |
|
<button |
|
onclick={() => toggleFolder(dirPath)} |
|
class="tree-folder-btn" |
|
aria-expanded={isExpandedDir} |
|
> |
|
<span class="tree-icon">{isExpandedDir ? '📂' : '📁'}</span> |
|
<span class="tree-name">{name}</span> |
|
</button> |
|
{#if isExpandedDir} |
|
<div class="tree-children"> |
|
{#each Object.entries(value).sort(([a, valA], [b, valB]) => { |
|
const aIsFile = valA && typeof valA === 'object' && 'path' in valA; |
|
const bIsFile = valB && typeof valB === 'object' && 'path' in valB; |
|
const aIsDir = valA && typeof valA === 'object' && !('path' in valA); |
|
const bIsDir = valB && typeof valB === 'object' && !('path' in valB); |
|
if (aIsDir && !bIsDir) return -1; |
|
if (!aIsDir && bIsDir) return 1; |
|
return a.localeCompare(b); |
|
}) as [subName, subValue]} |
|
{#if subValue && typeof subValue === 'object' && 'path' in subValue} |
|
{@const file = subValue as GitFile} |
|
<div class="tree-item tree-file" class:selected={selectedFile?.path === file.path}> |
|
<button |
|
onclick={() => fetchFileContent(file)} |
|
class="tree-file-btn" |
|
> |
|
<span class="tree-icon"><Icon name={getFileIconName(file)} size={16} /></span> |
|
<span class="tree-name">{subName}</span> |
|
{#if file.size} |
|
<span class="tree-size">{formatFileSize(file.size)}</span> |
|
{/if} |
|
</button> |
|
</div> |
|
{:else if subValue && typeof subValue === 'object'} |
|
{@const subDirPath = `${dirPath}/${subName}`} |
|
{@const isExpandedSubDir = isExpanded(subDirPath)} |
|
<div class="tree-item tree-folder tree-nested"> |
|
<button |
|
onclick={() => toggleFolder(subDirPath)} |
|
class="tree-folder-btn" |
|
aria-expanded={isExpandedSubDir} |
|
> |
|
<span class="tree-icon">{isExpandedSubDir ? '📂' : '📁'}</span> |
|
<span class="tree-name">{subName}</span> |
|
</button> |
|
{#if isExpandedSubDir} |
|
<div class="tree-children"> |
|
<!-- Recursive rendering for nested folders --> |
|
{#each Object.entries(subValue).sort(([a, valA], [b, valB]) => { |
|
const aIsFile = valA && typeof valA === 'object' && 'path' in valA; |
|
const bIsFile = valB && typeof valB === 'object' && 'path' in valB; |
|
const aIsDir = valA && typeof valA === 'object' && !('path' in valA); |
|
const bIsDir = valB && typeof valB === 'object' && !('path' in valB); |
|
if (aIsDir && !bIsDir) return -1; |
|
if (!aIsDir && bIsDir) return 1; |
|
return a.localeCompare(b); |
|
}) as [nestedName, nestedValue]} |
|
{#if nestedValue && typeof nestedValue === 'object' && 'path' in nestedValue} |
|
{@const nestedFile = nestedValue as GitFile} |
|
<div class="tree-item tree-file" class:selected={selectedFile?.path === nestedFile.path}> |
|
<button |
|
onclick={() => fetchFileContent(nestedFile)} |
|
class="tree-file-btn" |
|
> |
|
<span class="tree-icon"><Icon name={getFileIconName(nestedFile)} size={16} /></span> |
|
<span class="tree-name">{nestedName}</span> |
|
{#if nestedFile.size} |
|
<span class="tree-size">{formatFileSize(nestedFile.size)}</span> |
|
{/if} |
|
</button> |
|
</div> |
|
{:else if nestedValue && typeof nestedValue === 'object'} |
|
{@const deeperDirPath = `${subDirPath}/${nestedName}`} |
|
{@const isExpandedDeeper = isExpanded(deeperDirPath)} |
|
<div class="tree-item tree-folder tree-nested"> |
|
<button |
|
onclick={() => toggleFolder(deeperDirPath)} |
|
class="tree-folder-btn" |
|
aria-expanded={isExpandedDeeper} |
|
> |
|
<span class="tree-icon">{isExpandedDeeper ? '📂' : '📁'}</span> |
|
<span class="tree-name">{nestedName}</span> |
|
</button> |
|
{#if isExpandedDeeper} |
|
<div class="tree-children"> |
|
{#each Object.entries(nestedValue).sort(([a, valA], [b, valB]) => { |
|
const aIsFile = valA && typeof valA === 'object' && 'path' in valA; |
|
const bIsFile = valB && typeof valB === 'object' && 'path' in valB; |
|
const aIsDir = valA && typeof valA === 'object' && !('path' in valA); |
|
const bIsDir = valB && typeof valB === 'object' && !('path' in valB); |
|
if (aIsDir && !bIsDir) return -1; |
|
if (!aIsDir && bIsDir) return 1; |
|
return a.localeCompare(b); |
|
}) as [deepName, deepValue]} |
|
{#if deepValue && typeof deepValue === 'object' && 'path' in deepValue} |
|
{@const deepFile = deepValue as GitFile} |
|
<div class="tree-item tree-file" class:selected={selectedFile?.path === deepFile.path}> |
|
<button |
|
onclick={() => fetchFileContent(deepFile)} |
|
class="tree-file-btn" |
|
> |
|
<span class="tree-icon"><Icon name={getFileIconName(deepFile)} size={16} /></span> |
|
<span class="tree-name">{deepName}</span> |
|
{#if deepFile.size} |
|
<span class="tree-size">{formatFileSize(deepFile.size)}</span> |
|
{/if} |
|
</button> |
|
</div> |
|
{:else} |
|
<div class="tree-item tree-folder tree-nested"> |
|
<span class="tree-icon">📁</span> |
|
<span class="tree-name">{deepName}/</span> |
|
<span class="tree-note">(deeper nesting not fully expanded)</span> |
|
</div> |
|
{/if} |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
{:else if isFile} |
|
{@const file = value as GitFile} |
|
<div class="tree-item tree-file" class:selected={selectedFile?.path === file.path}> |
|
<button |
|
onclick={() => fetchFileContent(file)} |
|
class="tree-file-btn" |
|
> |
|
<span class="tree-icon"><Icon name={getFileIconName(file)} size={16} /></span> |
|
<span class="tree-name">{name}</span> |
|
{#if file.size} |
|
<span class="tree-size">{formatFileSize(file.size)}</span> |
|
{/if} |
|
</button> |
|
</div> |
|
{/if} |
|
{/each} |
|
</div> |
|
</div> |
|
|
|
<div class="file-content-panel"> |
|
{#if loadingContent} |
|
<div class="file-content-loading"> |
|
<p>Loading file content...</p> |
|
</div> |
|
{:else if contentError} |
|
<div class="file-content-error"> |
|
<p>Error: {contentError}</p> |
|
</div> |
|
{:else if selectedFile && (fileContent !== null || fileUrl)} |
|
<div class="file-content-header"> |
|
<h3 class="file-content-title">{selectedFile.path}</h3> |
|
<span class="file-content-size">{formatFileSize(selectedFile.size)}</span> |
|
</div> |
|
<div class="file-content-body"> |
|
{#if isImageFile(selectedFile) && fileUrl} |
|
<div class="file-image-container"> |
|
<img src={fileUrl} alt={selectedFile.name} class="file-image" /> |
|
</div> |
|
{:else if isVideoFile(selectedFile) && fileUrl} |
|
<div class="file-media-container"> |
|
<video controls class="file-video"> |
|
<source src={fileUrl} type="video/{getFileExtension(selectedFile)}" /> |
|
Your browser does not support the video tag. |
|
</video> |
|
</div> |
|
{:else if isAudioFile(selectedFile) && fileUrl} |
|
<div class="file-media-container"> |
|
<audio controls class="file-audio"> |
|
<source src={fileUrl} type="audio/{getFileExtension(selectedFile)}" /> |
|
Your browser does not support the audio tag. |
|
</audio> |
|
</div> |
|
{:else if fileContent !== null} |
|
{#if isCodeFile(selectedFile)} |
|
<pre class="file-content-code"><code bind:this={codeRef} class="hljs language-{getLanguageFromExtension(selectedFile)}">{fileContent}</code></pre> |
|
{:else} |
|
<pre class="file-content-code"><code>{fileContent}</code></pre> |
|
{/if} |
|
{/if} |
|
</div> |
|
{:else} |
|
<div class="file-content-empty"> |
|
<p>Select a file to view its contents</p> |
|
</div> |
|
{/if} |
|
</div> |
|
</div> |
|
|
|
<style> |
|
.file-explorer { |
|
display: flex; |
|
gap: 1rem; |
|
height: 1000px; |
|
min-height: 800px; |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.5rem; |
|
overflow: hidden; |
|
background: var(--fog-bg, #ffffff); |
|
} |
|
|
|
:global(.dark) .file-explorer { |
|
border-color: var(--fog-dark-border, #374151); |
|
background: var(--fog-dark-bg, #111827); |
|
} |
|
|
|
.file-tree-panel { |
|
flex: 0 0 300px; |
|
display: flex; |
|
flex-direction: column; |
|
border-right: 1px solid var(--fog-border, #e5e7eb); |
|
overflow: hidden; |
|
} |
|
|
|
:global(.dark) .file-tree-panel { |
|
border-right-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.file-tree-header { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
padding: 0.75rem; |
|
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
|
background: var(--fog-highlight, #f3f4f6); |
|
} |
|
|
|
:global(.dark) .file-tree-header { |
|
border-bottom-color: var(--fog-dark-border, #374151); |
|
background: var(--fog-dark-highlight, #374151); |
|
} |
|
|
|
.file-tree-title { |
|
flex: 1; |
|
font-size: 1rem; |
|
font-weight: 600; |
|
margin: 0; |
|
color: var(--fog-text, #1f2937); |
|
} |
|
|
|
:global(.dark) .file-tree-title { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.expand-all-btn, |
|
.collapse-all-btn { |
|
padding: 0.25rem 0.5rem; |
|
font-size: 0.75rem; |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.25rem; |
|
background: var(--fog-bg, #ffffff); |
|
color: var(--fog-text, #1f2937); |
|
cursor: pointer; |
|
transition: background-color 0.2s; |
|
} |
|
|
|
:global(.dark) .expand-all-btn, |
|
:global(.dark) .collapse-all-btn { |
|
border-color: var(--fog-dark-border, #374151); |
|
background: var(--fog-dark-bg, #111827); |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.expand-all-btn:hover, |
|
.collapse-all-btn:hover { |
|
background: var(--fog-highlight, #f3f4f6); |
|
} |
|
|
|
:global(.dark) .expand-all-btn:hover, |
|
:global(.dark) .collapse-all-btn:hover { |
|
background: var(--fog-dark-highlight, #475569); |
|
} |
|
|
|
.file-tree-content { |
|
flex: 1; |
|
overflow-y: auto; |
|
padding: 0.5rem; |
|
} |
|
|
|
.tree-item { |
|
display: flex; |
|
align-items: center; |
|
font-size: 0.875rem; |
|
} |
|
|
|
.tree-folder, |
|
.tree-file { |
|
margin: 0.125rem 0; |
|
} |
|
|
|
.tree-nested { |
|
margin-left: 1rem; |
|
} |
|
|
|
.tree-folder-btn, |
|
.tree-file-btn { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
width: 100%; |
|
padding: 0.25rem 0.5rem; |
|
border: none; |
|
background: none; |
|
text-align: left; |
|
cursor: pointer; |
|
border-radius: 0.25rem; |
|
transition: background-color 0.2s; |
|
color: var(--fog-text, #1f2937); |
|
} |
|
|
|
:global(.dark) .tree-folder-btn, |
|
:global(.dark) .tree-file-btn { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.tree-folder-btn:hover, |
|
.tree-file-btn:hover { |
|
background: var(--fog-highlight, #f3f4f6); |
|
} |
|
|
|
:global(.dark) .tree-folder-btn:hover, |
|
:global(.dark) .tree-file-btn:hover { |
|
background: var(--fog-dark-highlight, #374151); |
|
} |
|
|
|
.tree-file.selected .tree-file-btn { |
|
background: var(--fog-accent, #64748b); |
|
color: var(--fog-bg, #ffffff); |
|
} |
|
|
|
:global(.dark) .tree-file.selected .tree-file-btn { |
|
background: var(--fog-dark-accent, #94a3b8); |
|
color: var(--fog-dark-bg, #111827); |
|
} |
|
|
|
.tree-icon { |
|
font-size: 1rem; |
|
flex-shrink: 0; |
|
} |
|
|
|
.tree-name { |
|
flex: 1; |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
white-space: nowrap; |
|
} |
|
|
|
.tree-size { |
|
font-size: 0.75rem; |
|
color: var(--fog-text-light, #52667a); |
|
margin-left: 0.5rem; |
|
} |
|
|
|
:global(.dark) .tree-size { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.tree-children { |
|
margin-left: 1rem; |
|
border-left: 1px solid var(--fog-border, #e5e7eb); |
|
padding-left: 0.5rem; |
|
} |
|
|
|
:global(.dark) .tree-children { |
|
border-left-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.tree-note { |
|
font-size: 0.75rem; |
|
color: var(--fog-text-light, #52667a); |
|
font-style: italic; |
|
} |
|
|
|
:global(.dark) .tree-note { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.file-content-panel { |
|
flex: 1; |
|
display: flex; |
|
flex-direction: column; |
|
overflow: hidden; |
|
} |
|
|
|
.file-content-header { |
|
display: flex; |
|
align-items: center; |
|
justify-content: space-between; |
|
padding: 0.75rem; |
|
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
|
background: var(--fog-highlight, #f3f4f6); |
|
} |
|
|
|
:global(.dark) .file-content-header { |
|
border-bottom-color: var(--fog-dark-border, #374151); |
|
background: var(--fog-dark-highlight, #374151); |
|
} |
|
|
|
.file-content-title { |
|
font-size: 0.875rem; |
|
font-weight: 600; |
|
margin: 0; |
|
color: var(--fog-text, #1f2937); |
|
font-family: monospace; |
|
} |
|
|
|
:global(.dark) .file-content-title { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.file-content-size { |
|
font-size: 0.75rem; |
|
color: var(--fog-text-light, #52667a); |
|
} |
|
|
|
:global(.dark) .file-content-size { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.file-content-body { |
|
flex: 1; |
|
overflow: auto; |
|
padding: 1rem; |
|
} |
|
|
|
/* Code block styling is handled by highlight.js vs2015 theme */ |
|
|
|
.file-image-container { |
|
display: flex; |
|
justify-content: center; |
|
align-items: center; |
|
padding: 1rem; |
|
background: var(--fog-highlight, #f3f4f6); |
|
border-radius: 0.375rem; |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
} |
|
|
|
:global(.dark) .file-image-container { |
|
background: var(--fog-dark-highlight, #374151); |
|
border-color: var(--fog-dark-border, #475569); |
|
} |
|
|
|
.file-image { |
|
max-width: 100%; |
|
max-height: 70vh; |
|
height: auto; |
|
border-radius: 0.25rem; |
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
|
} |
|
|
|
:global(.dark) .file-image { |
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); |
|
} |
|
|
|
.file-media-container { |
|
display: flex; |
|
justify-content: center; |
|
align-items: center; |
|
padding: 1rem; |
|
background: var(--fog-highlight, #f3f4f6); |
|
border-radius: 0.375rem; |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
} |
|
|
|
:global(.dark) .file-media-container { |
|
background: var(--fog-dark-highlight, #374151); |
|
border-color: var(--fog-dark-border, #475569); |
|
} |
|
|
|
.file-video { |
|
max-width: 100%; |
|
max-height: 70vh; |
|
border-radius: 0.25rem; |
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
|
} |
|
|
|
:global(.dark) .file-video { |
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); |
|
} |
|
|
|
.file-audio { |
|
width: 100%; |
|
max-width: 600px; |
|
} |
|
|
|
.file-content-loading, |
|
.file-content-error, |
|
.file-content-empty { |
|
flex: 1; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
color: var(--fog-text-light, #52667a); |
|
} |
|
|
|
:global(.dark) .file-content-loading, |
|
:global(.dark) .file-content-error, |
|
:global(.dark) .file-content-empty { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
.file-content-error { |
|
color: var(--fog-error, #dc2626); |
|
} |
|
|
|
:global(.dark) .file-content-error { |
|
color: var(--fog-dark-error, #ef4444); |
|
} |
|
|
|
@media (max-width: 768px) { |
|
.file-explorer { |
|
flex-direction: column; |
|
height: auto; |
|
min-height: 800px; |
|
max-height: 1200px; |
|
} |
|
|
|
.file-tree-panel { |
|
flex: 0 0 300px; |
|
border-right: none; |
|
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
|
} |
|
|
|
:global(.dark) .file-tree-panel { |
|
border-bottom-color: var(--fog-dark-border, #374151); |
|
} |
|
} |
|
</style>
|
|
|