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

<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>