| Author | Dave Jarvis <email> |
|---|---|
| Date | 2026-02-08 15:21:11 GMT-0800 |
| Commit | fba840356d6d2cf5ead8fe9474ebc9f259b5efd5 |
| Parent | ce9ae03 |
| -* { | ||
| - margin: 0; | ||
| - padding: 0; | ||
| - box-sizing: border-box; | ||
| -} | ||
| - | ||
| -body { | ||
| - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; | ||
| - background: #0d1117; | ||
| - color: #c9d1d9; | ||
| - line-height: 1.6; | ||
| -} | ||
| - | ||
| -.container { | ||
| - max-width: 1200px; | ||
| - margin: 0 auto; | ||
| - padding: 20px; | ||
| -} | ||
| - | ||
| -header { | ||
| - border-bottom: 1px solid #30363d; | ||
| - padding-bottom: 20px; | ||
| - margin-bottom: 30px; | ||
| -} | ||
| - | ||
| -h1 { | ||
| - color: #f0f6fc; | ||
| - font-size: 1.8rem; | ||
| - margin-bottom: 10px; | ||
| -} | ||
| - | ||
| -h2 { | ||
| - color: #f0f6fc; | ||
| - font-size: 1.4rem; | ||
| - margin: 20px 0 15px; | ||
| - padding-bottom: 10px; | ||
| - border-bottom: 1px solid #21262d; | ||
| -} | ||
| - | ||
| -h3 { | ||
| - color: #f0f6fc; | ||
| - font-size: 1.1rem; | ||
| - margin: 15px 0 10px; | ||
| -} | ||
| - | ||
| -.nav { | ||
| - margin-top: 10px; | ||
| - display: flex; | ||
| - gap: 20px; | ||
| - flex-wrap: wrap; | ||
| - align-items: center; | ||
| -} | ||
| - | ||
| -.nav a { | ||
| - color: #58a6ff; | ||
| - text-decoration: none; | ||
| -} | ||
| - | ||
| -.nav a:hover { | ||
| - text-decoration: underline; | ||
| -} | ||
| - | ||
| -.repo-selector { | ||
| - margin-left: auto; | ||
| - display: flex; | ||
| - align-items: center; | ||
| - gap: 10px; | ||
| -} | ||
| - | ||
| -.repo-selector label { | ||
| - color: #8b949e; | ||
| - font-size: 0.875rem; | ||
| -} | ||
| - | ||
| -.repo-selector select { | ||
| - background: #21262d; | ||
| - color: #f0f6fc; | ||
| - border: 1px solid #30363d; | ||
| - padding: 6px 12px; | ||
| - border-radius: 6px; | ||
| - font-size: 0.875rem; | ||
| - cursor: pointer; | ||
| -} | ||
| - | ||
| -.repo-selector select:hover { | ||
| - border-color: #58a6ff; | ||
| -} | ||
| - | ||
| -.commit-list { | ||
| - list-style: none; | ||
| -} | ||
| - | ||
| -.commit-item { | ||
| - background: #161b22; | ||
| - border: 1px solid #30363d; | ||
| - border-radius: 6px; | ||
| - padding: 16px; | ||
| - margin-bottom: 12px; | ||
| - transition: border-color 0.2s; | ||
| -} | ||
| - | ||
| -.commit-item:hover { | ||
| - border-color: #58a6ff; | ||
| -} | ||
| - | ||
| -.commit-hash { | ||
| - font-family: 'SFMono-Regular', Consolas, monospace; | ||
| - font-size: 0.85rem; | ||
| - color: #58a6ff; | ||
| - text-decoration: none; | ||
| -} | ||
| - | ||
| -.commit-hash:hover { | ||
| - text-decoration: underline; | ||
| -} | ||
| - | ||
| -.commit-meta { | ||
| - font-size: 0.875rem; | ||
| - color: #8b949e; | ||
| - margin-top: 8px; | ||
| -} | ||
| - | ||
| -.commit-author { | ||
| - color: #f0f6fc; | ||
| - font-weight: 500; | ||
| -} | ||
| - | ||
| -.commit-date { | ||
| - color: #8b949e; | ||
| -} | ||
| - | ||
| -.commit-message { | ||
| - margin-top: 8px; | ||
| - color: #c9d1d9; | ||
| - white-space: pre-wrap; | ||
| -} | ||
| - | ||
| -.file-list { | ||
| - background: #161b22; | ||
| - border: 1px solid #30363d; | ||
| - border-radius: 6px; | ||
| - overflow: hidden; | ||
| -} | ||
| - | ||
| -.file-item { | ||
| - display: flex; | ||
| - align-items: center; | ||
| - padding: 12px 16px; | ||
| - border-bottom: 1px solid #21262d; | ||
| - text-decoration: none; | ||
| - color: #c9d1d9; | ||
| - transition: background 0.2s; | ||
| -} | ||
| - | ||
| -.file-item:last-child { | ||
| - border-bottom: none; | ||
| -} | ||
| - | ||
| -.file-item:hover { | ||
| - background: #1f242c; | ||
| -} | ||
| - | ||
| -.file-mode { | ||
| - font-family: monospace; | ||
| - color: #8b949e; | ||
| - width: 80px; | ||
| - font-size: 0.875rem; | ||
| -} | ||
| - | ||
| -.file-name { | ||
| - flex: 1; | ||
| - color: #58a6ff; | ||
| -} | ||
| - | ||
| -.file-item:hover .file-name { | ||
| - text-decoration: underline; | ||
| -} | ||
| - | ||
| -.breadcrumb { | ||
| - background: #161b22; | ||
| - border: 1px solid #30363d; | ||
| - border-radius: 6px; | ||
| - padding: 12px 16px; | ||
| - margin-bottom: 20px; | ||
| -} | ||
| - | ||
| -.breadcrumb a { | ||
| - color: #58a6ff; | ||
| - text-decoration: none; | ||
| -} | ||
| - | ||
| -.breadcrumb a:hover { | ||
| - text-decoration: underline; | ||
| -} | ||
| - | ||
| -.breadcrumb span { | ||
| - color: #8b949e; | ||
| - margin: 0 8px; | ||
| -} | ||
| - | ||
| -.blob-content { | ||
| - background: #161b22; | ||
| - border: 1px solid #30363d; | ||
| - border-radius: 6px; | ||
| - overflow: hidden; | ||
| -} | ||
| - | ||
| -.blob-header { | ||
| - background: #21262d; | ||
| - padding: 12px 16px; | ||
| - border-bottom: 1px solid #30363d; | ||
| - font-size: 0.875rem; | ||
| - color: #8b949e; | ||
| -} | ||
| - | ||
| -.blob-code { | ||
| - padding: 16px; | ||
| - overflow-x: auto; | ||
| - font-family: 'SFMono-Regular', Consolas, monospace; | ||
| - font-size: 0.875rem; | ||
| - line-height: 1.6; | ||
| - white-space: pre; | ||
| -} | ||
| - | ||
| -.refs-list { | ||
| - display: grid; | ||
| - gap: 10px; | ||
| -} | ||
| - | ||
| -.ref-item { | ||
| - background: #161b22; | ||
| - border: 1px solid #30363d; | ||
| - border-radius: 6px; | ||
| - padding: 12px 16px; | ||
| - display: flex; | ||
| - align-items: center; | ||
| - gap: 12px; | ||
| -} | ||
| - | ||
| -.ref-type { | ||
| - background: #238636; | ||
| - color: white; | ||
| - padding: 2px 8px; | ||
| - border-radius: 12px; | ||
| - font-size: 0.75rem; | ||
| - font-weight: 600; | ||
| - text-transform: uppercase; | ||
| -} | ||
| - | ||
| -.ref-type.tag { | ||
| - background: #8957e5; | ||
| -} | ||
| - | ||
| -.ref-name { | ||
| - font-weight: 600; | ||
| - color: #f0f6fc; | ||
| -} | ||
| - | ||
| -.empty-state { | ||
| - text-align: center; | ||
| - padding: 60px 20px; | ||
| - color: #8b949e; | ||
| -} | ||
| - | ||
| -.commit-details { | ||
| - background: #161b22; | ||
| - border: 1px solid #30363d; | ||
| - border-radius: 6px; | ||
| - padding: 20px; | ||
| - margin-bottom: 20px; | ||
| -} | ||
| - | ||
| -.commit-header { | ||
| - margin-bottom: 20px; | ||
| -} | ||
| - | ||
| -.commit-title { | ||
| - font-size: 1.25rem; | ||
| - color: #f0f6fc; | ||
| - margin-bottom: 10px; | ||
| -} | ||
| - | ||
| -.commit-info { | ||
| - display: grid; | ||
| - gap: 8px; | ||
| - font-size: 0.875rem; | ||
| -} | ||
| - | ||
| -.commit-info-row { | ||
| - display: flex; | ||
| - gap: 10px; | ||
| -} | ||
| - | ||
| -.commit-info-label { | ||
| - color: #8b949e; | ||
| - width: 80px; | ||
| - flex-shrink: 0; | ||
| -} | ||
| - | ||
| -.commit-info-value { | ||
| - color: #c9d1d9; | ||
| - font-family: monospace; | ||
| -} | ||
| - | ||
| -.parent-link { | ||
| - color: #58a6ff; | ||
| - text-decoration: none; | ||
| -} | ||
| - | ||
| -.parent-link:hover { | ||
| - text-decoration: underline; | ||
| -} | ||
| - | ||
| -.repo-grid { | ||
| - display: grid; | ||
| - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); | ||
| - gap: 16px; | ||
| - margin-top: 20px; | ||
| -} | ||
| - | ||
| -.repo-card { | ||
| - background: #161b22; | ||
| - border: 1px solid #30363d; | ||
| - border-radius: 8px; | ||
| - padding: 20px; | ||
| - text-decoration: none; | ||
| - color: inherit; | ||
| - transition: border-color 0.2s, transform 0.1s; | ||
| -} | ||
| - | ||
| -.repo-card:hover { | ||
| - border-color: #58a6ff; | ||
| - transform: translateY(-2px); | ||
| -} | ||
| - | ||
| -.repo-card h3 { | ||
| - color: #58a6ff; | ||
| - margin-bottom: 8px; | ||
| - font-size: 1.1rem; | ||
| -} | ||
| - | ||
| -.repo-card p { | ||
| - color: #8b949e; | ||
| - font-size: 0.875rem; | ||
| - margin: 0; | ||
| -} | ||
| - | ||
| -.current-repo { | ||
| - background: #21262d; | ||
| - border: 1px solid #58a6ff; | ||
| - padding: 8px 16px; | ||
| - border-radius: 6px; | ||
| - font-size: 0.875rem; | ||
| - color: #f0f6fc; | ||
| -} | ||
| - | ||
| -.current-repo strong { | ||
| - color: #58a6ff; | ||
| -} | ||
| - | ||
| -.dir-icon, .file-icon { | ||
| - display: inline-block; | ||
| - width: 20px; | ||
| - text-align: center; | ||
| - margin-right: 8px; | ||
| - color: #8b949e; | ||
| -} | ||
| - | ||
| -.branch-badge { | ||
| - background: #238636; | ||
| - color: white; | ||
| - padding: 2px 8px; | ||
| - border-radius: 12px; | ||
| - font-size: 0.75rem; | ||
| - font-weight: 600; | ||
| - margin-left: 10px; | ||
| -} | ||
| - | ||
| -<?php | ||
| -/** | ||
| - * SimpleGit - A secure multi-repository Git viewer | ||
| - * No external dependencies, no shell execution, no uploads | ||
| - */ | ||
| - | ||
| -require_once 'Git.php'; | ||
| - | ||
| -function getHomeDirectory() { | ||
| - if (!empty($_SERVER['HOME'])) { | ||
| - return $_SERVER['HOME']; | ||
| - } | ||
| - | ||
| - if (!empty(getenv('HOME'))) { | ||
| - return getenv('HOME'); | ||
| - } | ||
| - | ||
| - if (function_exists('posix_getpwuid') && function_exists('posix_getuid')) { | ||
| - $userInfo = posix_getpwuid(posix_getuid()); | ||
| - | ||
| - if (!empty($userInfo['dir'])) { | ||
| - return $userInfo['dir']; | ||
| - } | ||
| - } | ||
| - | ||
| - return ''; | ||
| -} | ||
| - | ||
| -define('REPOS_PATH', getHomeDirectory() . '/repos'); | ||
| -define('SITE_TITLE', "Dave Jarvis' Repositories"); | ||
| - | ||
| -ini_set('display_errors', 0); | ||
| -ini_set('log_errors', 1); | ||
| -ini_set('error_log', __DIR__ . '/error.log'); | ||
| - | ||
| -// Global Git instance | ||
| -$git = new Git(REPOS_PATH); | ||
| - | ||
| -function getRepositories() { | ||
| - global $git; | ||
| - $repos = []; | ||
| - $repoList = $git->listRepositories(); | ||
| - | ||
| - foreach ($repoList as $name) { | ||
| - $path = REPOS_PATH . DIRECTORY_SEPARATOR . $name . '.git'; | ||
| - $displayName = urldecode($name); | ||
| - $repos[$name] = [ | ||
| - 'path' => $path, | ||
| - 'name' => $displayName, | ||
| - 'safe_name' => $name | ||
| - ]; | ||
| - } | ||
| - | ||
| - uasort($repos, function($a, $b) { | ||
| - return strcasecmp($a['name'], $b['name']); | ||
| - }); | ||
| - | ||
| - return $repos; | ||
| -} | ||
| - | ||
| -function getCurrentRepo() { | ||
| - $repos = getRepositories(); | ||
| - $requested = $_GET['repo'] ?? ''; | ||
| - $decodedRequested = urldecode($requested); | ||
| - | ||
| - foreach ($repos as $key => $repo) { | ||
| - if ($repo['safe_name'] === $requested || $repo['name'] === $decodedRequested) { | ||
| - return $repo; | ||
| - } | ||
| - } | ||
| - | ||
| - return null; | ||
| -} | ||
| - | ||
| -function sanitizePath($path) { | ||
| - $path = str_replace(['..', '\\', "\0"], ['', '/', ''], $path); | ||
| - return preg_replace('/[^a-zA-Z0-9_\-\.\/]/', '', $path); | ||
| -} | ||
| - | ||
| -// These functions now bridge to the Git class | ||
| -function readGitObject($repoPath, $hash) { | ||
| - global $git; | ||
| - $repoName = basename($repoPath, '.git'); | ||
| - | ||
| - // Use the class to get the object content | ||
| - // Note: The Git class returns raw uncompressed content | ||
| - $content = $git->getObject($repoPath . DIRECTORY_SEPARATOR . '.git', $hash); | ||
| - | ||
| - if (!$content) return false; | ||
| - | ||
| - // Helper to identify type for the existing UI logic | ||
| - if (str_starts_with($content, 'commit ')) return ['type' => 'commit', 'content' => substr($content, strpos($content, "\0") + 1)]; | ||
| - if (str_starts_with($content, 'tree ')) return ['type' => 'tree', 'content' => substr($content, strpos($content, "\0") + 1)]; | ||
| - | ||
| - return ['type' => 'blob', 'content' => $content]; | ||
| -} | ||
| - | ||
| -function parseTree($content) { | ||
| - // Retaining your original tree parser logic as it matches Git's binary format | ||
| - $entries = []; | ||
| - $offset = 0; | ||
| - $len = strlen($content); | ||
| - | ||
| - while ($offset < $len) { | ||
| - $spacePos = strpos($content, ' ', $offset); | ||
| - if ($spacePos === false) break; | ||
| - | ||
| - $mode = substr($content, $offset, $spacePos - $offset); | ||
| - $offset = $spacePos + 1; | ||
| - | ||
| - $nullPos = strpos($content, "\0", $offset); | ||
| - if ($nullPos === false) break; | ||
| - | ||
| - $name = substr($content, $offset, $nullPos - $offset); | ||
| - $offset = $nullPos + 1; | ||
| - | ||
| - if ($offset + 20 > $len) break; | ||
| - $hash = bin2hex(substr($content, $offset, 20)); | ||
| - $offset += 20; | ||
| - | ||
| - $isTree = in_array($mode, ['040000', '40000', '160000']); | ||
| - | ||
| - $entries[] = [ | ||
| - 'mode' => $mode, | ||
| - 'name' => $name, | ||
| - 'hash' => $hash, | ||
| - 'type' => $isTree ? 'tree' : 'blob' | ||
| - ]; | ||
| - } | ||
| - | ||
| - usort($entries, function($a, $b) { | ||
| - if ($a['type'] !== $b['type']) { | ||
| - return $a['type'] === 'tree' ? -1 : 1; | ||
| - } | ||
| - return strcasecmp($a['name'], $b['name']); | ||
| - }); | ||
| - | ||
| - return $entries; | ||
| -} | ||
| - | ||
| -function parseCommit($content) { | ||
| - $lines = explode("\n", $content); | ||
| - $commit = [ | ||
| - 'tree' => '', | ||
| - 'parents' => [], | ||
| - 'author' => '', | ||
| - 'committer' => '', | ||
| - 'message' => '' | ||
| - ]; | ||
| - | ||
| - $inMessage = false; | ||
| - $messageLines = []; | ||
| - | ||
| - foreach ($lines as $line) { | ||
| - if ($inMessage) { | ||
| - $messageLines[] = $line; | ||
| - } elseif ($line === '') { | ||
| - $inMessage = true; | ||
| - } elseif (strpos($line, 'tree ') === 0) { | ||
| - $commit['tree'] = substr($line, 5); | ||
| - } elseif (strpos($line, 'parent ') === 0) { | ||
| - $commit['parents'][] = substr($line, 7); | ||
| - } elseif (strpos($line, 'author ') === 0) { | ||
| - $commit['author'] = substr($line, 7); | ||
| - } elseif (strpos($line, 'committer ') === 0) { | ||
| - $commit['committer'] = substr($line, 10); | ||
| - } | ||
| - } | ||
| - | ||
| - $commit['message'] = implode("\n", $messageLines); | ||
| - return $commit; | ||
| -} | ||
| - | ||
| -function getHead($repoPath) { | ||
| - $headFile = $repoPath . '/HEAD'; | ||
| - if (!file_exists($headFile)) { | ||
| - return false; | ||
| - } | ||
| - | ||
| - $content = trim(file_get_contents($headFile)); | ||
| - | ||
| - if (preg_match('/^[a-f0-9]{40}$/', $content)) { | ||
| - return ['type' => 'detached', 'hash' => $content, 'ref' => null]; | ||
| - } | ||
| - | ||
| - if (strpos($content, 'ref: ') === 0) { | ||
| - $ref = substr($content, 5); | ||
| - $refFile = $repoPath . '/' . $ref; | ||
| - if (file_exists($refFile)) { | ||
| - $hash = trim(file_get_contents($refFile)); | ||
| - return ['type' => 'ref', 'hash' => $hash, 'ref' => $ref]; | ||
| - } | ||
| - } | ||
| - | ||
| - return false; | ||
| -} | ||
| - | ||
| -function listRefs($repoPath) { | ||
| - $refs = []; | ||
| - | ||
| - $branchesDir = $repoPath . '/refs/heads'; | ||
| - if (is_dir($branchesDir)) { | ||
| - foreach (glob($branchesDir . '/*') as $file) { | ||
| - if (is_file($file)) { | ||
| - $name = basename($file); | ||
| - $hash = trim(file_get_contents($file)); | ||
| - $refs['branches'][$name] = $hash; | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - $tagsDir = $repoPath . '/refs/tags'; | ||
| - if (is_dir($tagsDir)) { | ||
| - foreach (glob($tagsDir . '/*') as $file) { | ||
| - if (is_file($file)) { | ||
| - $name = basename($file); | ||
| - $content = file_get_contents($file); | ||
| - $refs['tags'][$name] = trim($content); | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - return $refs; | ||
| -} | ||
| - | ||
| -function formatDate($line) { | ||
| - if (preg_match('/(\d+)\s+([\+\-]\d{4})$/', $line, $matches)) { | ||
| - $timestamp = $matches[1]; | ||
| - return date('Y-m-d H:i:s', $timestamp); | ||
| - } | ||
| - return 'Unknown'; | ||
| -} | ||
| - | ||
| -function getAuthor($line) { | ||
| - if (preg_match('/^([^<]+)/', $line, $matches)) { | ||
| - return trim($matches[1]); | ||
| - } | ||
| - return $line; | ||
| -} | ||
| - | ||
| -function getMainBranch($repoPath) { | ||
| - $refs = listRefs($repoPath); | ||
| - | ||
| - $priority = ['main', 'master', 'trunk', 'develop']; | ||
| - foreach ($priority as $branch) { | ||
| - if (isset($refs['branches'][$branch])) { | ||
| - return ['name' => $branch, 'hash' => $refs['branches'][$branch]]; | ||
| - } | ||
| - } | ||
| - | ||
| - if (!empty($refs['branches'])) { | ||
| - $first = array_key_first($refs['branches']); | ||
| - return ['name' => $first, 'hash' => $refs['branches'][$first]]; | ||
| - } | ||
| - | ||
| - return null; | ||
| -} | ||
| - | ||
| -$action = $_GET['action'] ?? 'home'; | ||
| -$hash = sanitizePath($_GET['hash'] ?? ''); | ||
| - | ||
| -$currentRepo = getCurrentRepo(); | ||
| -$repoParam = $currentRepo ? '&repo=' . urlencode($currentRepo['safe_name']) : ''; | ||
| - | ||
| -$repositories = getRepositories(); | ||
| - | ||
| -?> | ||
| -<!DOCTYPE html> | ||
| -<html lang="en"> | ||
| -<head> | ||
| - <meta charset="UTF-8"> | ||
| - <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| - <title><?php echo SITE_TITLE; ?><?php echo $currentRepo ? ' - ' . htmlspecialchars($currentRepo['name']) : ''; ?></title> | ||
| - <link rel="stylesheet" href="kimi-style.css"> | ||
| -</head> | ||
| -<body> | ||
| - <div class="container"> | ||
| - <header> | ||
| - <h1><?php echo SITE_TITLE; ?></h1> | ||
| - <nav class="nav"> | ||
| - <a href="?">Home</a> | ||
| - <?php if ($currentRepo): ?> | ||
| - <a href="?repo=<?php echo urlencode($currentRepo['safe_name']); ?>">Files</a> | ||
| - <a href="?action=commits<?php echo $repoParam; ?>">Commits</a> | ||
| - <a href="?action=refs<?php echo $repoParam; ?>">Branches</a> | ||
| - <?php endif; ?> | ||
| - <?php if ($currentRepo): ?> | ||
| - <div class="repo-selector"> | ||
| - <label>Repository:</label> | ||
| - <select onchange="window.location.href='?repo=' + encodeURIComponent(this.value)"> | ||
| - <option value="">Select repository...</option> | ||
| - <?php foreach ($repositories as $repo): ?> | ||
| - <option value="<?php echo htmlspecialchars($repo['safe_name']); ?>" <?php echo $repo['safe_name'] === $currentRepo['safe_name'] ? 'selected' : ''; ?>> | ||
| - <?php echo htmlspecialchars($repo['name']); ?> | ||
| - </option> | ||
| - <?php endforeach; ?> | ||
| - </select> | ||
| - </div> | ||
| - <?php endif; ?> | ||
| - </nav> | ||
| - | ||
| - <?php if ($currentRepo): ?> | ||
| - <div style="margin-top: 15px;"> | ||
| - <span class="current-repo"> | ||
| - Current: <strong><?php echo htmlspecialchars($currentRepo['name']); ?></strong> | ||
| - </span> | ||
| - </div> | ||
| - <?php endif; ?> | ||
| - </header> | ||
| - | ||
| - <?php | ||
| - if (!$currentRepo) { | ||
| - echo '<h2>Repositories</h2>'; | ||
| - | ||
| - if (empty($repositories)) { | ||
| - echo '<div class="empty-state">No repositories found in ' . htmlspecialchars(REPOS_PATH) . '</div>'; | ||
| - } else { | ||
| - echo '<div class="repo-grid">'; | ||
| - foreach ($repositories as $repo) { | ||
| - $mainBranch = getMainBranch($repo['path']); | ||
| - $head = getHead($repo['path']); | ||
| - | ||
| - echo '<a href="?repo=' . urlencode($repo['safe_name']) . '" class="repo-card">'; | ||
| - echo '<h3>' . htmlspecialchars($repo['name']) . '</h3>'; | ||
| - | ||
| - if ($mainBranch) { | ||
| - echo '<p>Branch: ' . htmlspecialchars($mainBranch['name']) . '</p>'; | ||
| - } | ||
| - | ||
| - $refs = listRefs($repo['path']); | ||
| - $branchCount = count($refs['branches'] ?? []); | ||
| - $tagCount = count($refs['tags'] ?? []); | ||
| - | ||
| - echo '<p>' . $branchCount . ' branches, ' . $tagCount . ' tags</p>'; | ||
| - | ||
| - if ($head) { | ||
| - $obj = readGitObject($repo['path'], $head['hash']); | ||
| - if ($obj && $obj['type'] === 'commit') { | ||
| - $commit = parseCommit($obj['content']); | ||
| - echo '<p style="margin-top: 8px; color: #58a6ff;">' . substr($head['hash'], 0, 7) . ' - ' . htmlspecialchars(substr(trim(explode("\n", $commit['message'])[0]), 0, 50)) . '</p>'; | ||
| - } | ||
| - } | ||
| - echo '</a>'; | ||
| - } | ||
| - echo '</div>'; | ||
| - } | ||
| - } else { | ||
| - $mainBranch = getMainBranch($currentRepo['path']); | ||
| - | ||
| - if (!$mainBranch) { | ||
| - echo '<div class="empty-state">'; | ||
| - echo '<h3>No branches found</h3>'; | ||
| - echo '<p>This repository appears to be empty.</p>'; | ||
| - echo '</div>'; | ||
| - } else { | ||
| - $obj = readGitObject($currentRepo['path'], $mainBranch['hash']); | ||
| - | ||
| - if (!$obj || $obj['type'] !== 'commit') { | ||
| - echo '<div class="empty-state">Error reading commit</div>'; | ||
| - } else { | ||
| - $commit = parseCommit($obj['content']); | ||
| - $treeHash = $commit['tree']; | ||
| - | ||
| - $viewHash = $hash ?: $treeHash; | ||
| - $viewObj = readGitObject($currentRepo['path'], $viewHash); | ||
| - | ||
| - if ($viewObj && $viewObj['type'] === 'blob') { | ||
| - echo '<div class="breadcrumb">'; | ||
| - echo '<a href="?">Repositories</a>'; | ||
| - echo '<span>/</span>'; | ||
| - echo '<a href="?repo=' . urlencode($currentRepo['safe_name']) . '">' . htmlspecialchars($currentRepo['name']) . '</a>'; | ||
| - echo '<span>/</span>'; | ||
| - echo '<span>File</span>'; | ||
| - echo '</div>'; | ||
| - | ||
| - echo '<h2>' . substr($viewHash, 0, 7) . '</h2>'; | ||
| - | ||
| - echo '<div class="blob-content">'; | ||
| - echo '<div class="blob-header">' . strlen($viewObj['content']) . ' bytes</div>'; | ||
| - echo '<div class="blob-code">' . htmlspecialchars($viewObj['content']) . '</div>'; | ||
| - echo '</div>'; | ||
| - } else { | ||
| - $treeObj = readGitObject($currentRepo['path'], $viewHash); | ||
| - | ||
| - if (!$treeObj || $treeObj['type'] !== 'tree') { | ||
| - echo '<div class="empty-state">Directory not found</div>'; | ||
| - } else { | ||
| - $entries = parseTree($treeObj['content']); | ||
| - | ||
| - echo '<div class="breadcrumb">'; | ||
| - echo '<a href="?">Repositories</a>'; | ||
| - echo '<span>/</span>'; | ||
| - echo '<a href="?repo=' . urlencode($currentRepo['safe_name']) . '">' . htmlspecialchars($currentRepo['name']) . '</a>'; | ||
| - if ($viewHash !== $treeHash) { | ||
| - echo '<span>/</span>'; | ||
| - echo '<span>Tree ' . substr($viewHash, 0, 7) . '</span>'; | ||
| - } | ||
| - echo '</div>'; | ||
| - | ||
| - echo '<h2>' . htmlspecialchars($currentRepo['name']) . ' <span class="branch-badge">' . htmlspecialchars($mainBranch['name']) . '</span></h2>'; | ||
| - | ||
| - echo '<div class="commit-item" style="margin-bottom: 20px;">'; | ||
| - echo '<div><a href="?action=commit&hash=' . $mainBranch['hash'] . $repoParam . '" class="commit-hash">' . substr($mainBranch['hash'], 0, 7) . '</a></div>'; | ||
| - echo '<div class="commit-message">' . htmlspecialchars(trim($commit['message'])) . '</div>'; | ||
| - echo '<div class="commit-meta">'; | ||
| - echo '<span class="commit-author">' . htmlspecialchars(getAuthor($commit['author'])) . '</span>'; | ||
| - echo ' committed on '; | ||
| - echo '<span class="commit-date">' . formatDate($commit['committer']) . '</span>'; | ||
| - echo '</div>'; | ||
| - echo '</div>'; | ||
| - | ||
| - echo '<h3>Files</h3>'; | ||
| - | ||
| - if (empty($entries)) { | ||
| - echo '<div class="empty-state">Empty directory</div>'; | ||
| - } else { | ||
| - echo '<div class="file-list">'; | ||
| - foreach ($entries as $entry) { | ||
| - $icon = $entry['type'] === 'tree' ? '[dir]' : '[file]'; | ||
| - // CRITICAL FIX: Always include hash so directories work | ||
| - $url = '?repo=' . urlencode($currentRepo['safe_name']) . '&hash=' . $entry['hash']; | ||
| - | ||
| - echo '<a href="' . $url . '" class="file-item">'; | ||
| - echo '<span class="file-mode">' . $entry['mode'] . '</span>'; | ||
| - echo '<span class="file-name"><span class="' . ($entry['type'] === 'tree' ? 'dir-icon' : 'file-icon') . '">' . $icon . '</span> ' . htmlspecialchars($entry['name']) . '</span>'; | ||
| - echo '</a>'; | ||
| - } | ||
| - echo '</div>'; | ||
| - } | ||
| - } | ||
| - } | ||
| - } | ||
| - } | ||
| - } | ||
| - ?> | ||
| -</div> | ||
| -</body> | ||
| -</html> | ||
| - | ||
| +<?php | ||
| +require_once 'Git.php'; | ||
| + | ||
| +function getHomeDirectory() { | ||
| + if (!empty($_SERVER['HOME'])) { | ||
| + return $_SERVER['HOME']; | ||
| + } | ||
| + if (!empty(getenv('HOME'))) { | ||
| + return getenv('HOME'); | ||
| + } | ||
| + if (function_exists('posix_getpwuid') && function_exists('posix_getuid')) { | ||
| + $userInfo = posix_getpwuid(posix_getuid()); | ||
| + if (!empty($userInfo['dir'])) { | ||
| + return $userInfo['dir']; | ||
| + } | ||
| + } | ||
| + return ''; | ||
| +} | ||
| + | ||
| +define('REPOS_PATH', getHomeDirectory() . '/repos'); | ||
| +define('SITE_TITLE', "Dave Jarvis' Repositories"); | ||
| + | ||
| +ini_set('display_errors', 0); | ||
| +ini_set('log_errors', 1); | ||
| +ini_set('error_log', __DIR__ . '/error.log'); | ||
| + | ||
| +function getRepositories() { | ||
| + $repos = []; | ||
| + if (!is_dir(REPOS_PATH)) { | ||
| + return $repos; | ||
| + } | ||
| + | ||
| + foreach (glob(REPOS_PATH . '/*.git') as $path) { | ||
| + if (is_dir($path)) { | ||
| + $name = basename($path, '.git'); | ||
| + $displayName = urldecode($name); | ||
| + $repos[$name] = [ | ||
| + 'path' => $path, | ||
| + 'name' => $displayName, | ||
| + 'safe_name' => $name | ||
| + ]; | ||
| + } | ||
| + } | ||
| + | ||
| + uasort($repos, function($a, $b) { | ||
| + return strcasecmp($a['name'], $b['name']); | ||
| + }); | ||
| + | ||
| + return $repos; | ||
| +} | ||
| + | ||
| +function getCurrentRepo() { | ||
| + $repos = getRepositories(); | ||
| + $requested = $_GET['repo'] ?? ''; | ||
| + $decodedRequested = urldecode($requested); | ||
| + | ||
| + foreach ($repos as $key => $repo) { | ||
| + if ($repo['safe_name'] === $requested || $repo['name'] === $decodedRequested) { | ||
| + return $repo; | ||
| + } | ||
| + } | ||
| + | ||
| + return null; | ||
| +} | ||
| + | ||
| +function sanitizePath($path) { | ||
| + $path = str_replace(['..', '\\', "\0"], ['', '/', ''], $path); | ||
| + return preg_replace('/[^a-zA-Z0-9_\-\.\/]/', '', $path); | ||
| +} | ||
| + | ||
| +/** | ||
| + * Heuristic to detect if content is a Git Tree or Blob | ||
| + * The Git class strips headers, so we check for the binary tree entry pattern: | ||
| + * <mode> <name>\0<20-byte-sha> | ||
| + */ | ||
| +function detectType($data) { | ||
| + if (strlen($data) < 25) return 'blob'; // Too short to be a tree entry | ||
| + | ||
| + // Check for standard file modes at start | ||
| + // 40000 (dir), 100644 (file), 100755 (exec), 120000 (symlink) | ||
| + // Git binary trees start immediately with the mode of the first entry | ||
| + if (preg_match('/^(40000|100644|100755|120000) /', $data)) { | ||
| + // Double check: find null byte and ensure 20 bytes follow | ||
| + $null = strpos($data, "\0"); | ||
| + if ($null !== false && ($null + 21 <= strlen($data))) { | ||
| + return 'tree'; | ||
| + } | ||
| + } | ||
| + return 'blob'; | ||
| +} | ||
| + | ||
| +function parseTreeContent($data) { | ||
| + $entries = []; | ||
| + $pos = 0; | ||
| + $len = strlen($data); | ||
| + | ||
| + while ($pos < $len) { | ||
| + $space = strpos($data, ' ', $pos); | ||
| + $null = strpos($data, "\0", $space); | ||
| + if ($space === false || $null === false) break; | ||
| + | ||
| + $mode = substr($data, $pos, $space - $pos); | ||
| + $name = substr($data, $space + 1, $null - $space - 1); | ||
| + $entrySha = bin2hex(substr($data, $null + 1, 20)); | ||
| + | ||
| + $entries[] = [ | ||
| + 'mode' => $mode, | ||
| + 'name' => $name, | ||
| + 'hash' => $entrySha, | ||
| + 'type' => ($mode === '40000' || $mode === '040000') ? 'tree' : 'blob' | ||
| + ]; | ||
| + | ||
| + $pos = $null + 21; | ||
| + } | ||
| + | ||
| + usort($entries, function($a, $b) { | ||
| + if ($a['type'] !== $b['type']) { | ||
| + return $a['type'] === 'tree' ? -1 : 1; | ||
| + } | ||
| + return strcasecmp($a['name'], $b['name']); | ||
| + }); | ||
| + | ||
| + return $entries; | ||
| +} | ||
| + | ||
| +function getMainBranch($git) { | ||
| + $branches = []; | ||
| + $git->eachBranch(function($name, $sha) use (&$branches) { | ||
| + $branches[$name] = $sha; | ||
| + }); | ||
| + | ||
| + $priority = ['main', 'master', 'trunk', 'develop']; | ||
| + foreach ($priority as $branch) { | ||
| + if (isset($branches[$branch])) { | ||
| + return ['name' => $branch, 'hash' => $branches[$branch]]; | ||
| + } | ||
| + } | ||
| + | ||
| + if (!empty($branches)) { | ||
| + $first = array_key_first($branches); | ||
| + return ['name' => $first, 'hash' => $branches[$first]]; | ||
| + } | ||
| + | ||
| + return null; | ||
| +} | ||
| + | ||
| +$action = $_GET['action'] ?? 'home'; | ||
| +$hash = sanitizePath($_GET['hash'] ?? ''); | ||
| + | ||
| +$currentRepo = getCurrentRepo(); | ||
| +$repoParam = $currentRepo ? '&repo=' . urlencode($currentRepo['safe_name']) : ''; | ||
| + | ||
| +$repositories = getRepositories(); | ||
| +$git = $currentRepo ? new Git($currentRepo['path']) : null; | ||
| + | ||
| +?> | ||
| +<!DOCTYPE html> | ||
| +<html lang="en"> | ||
| +<head> | ||
| + <meta charset="UTF-8"> | ||
| + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| + <title><?php echo SITE_TITLE; ?><?php echo $currentRepo ? ' - ' . htmlspecialchars($currentRepo['name']) : ''; ?></title> | ||
| + <link rel="stylesheet" href="kimi-style.css"> | ||
| +</head> | ||
| +<body> | ||
| + <div class="container"> | ||
| + <header> | ||
| + <h1><?php echo SITE_TITLE; ?></h1> | ||
| + <nav class="nav"> | ||
| + <a href="?">Home</a> | ||
| + <?php if ($currentRepo): ?> | ||
| + <a href="?repo=<?php echo urlencode($currentRepo['safe_name']); ?>">Files</a> | ||
| + <a href="?action=commits<?php echo $repoParam; ?>">Commits</a> | ||
| + <a href="?action=refs<?php echo $repoParam; ?>">Branches</a> | ||
| + <?php endif; ?> | ||
| + <?php if ($currentRepo): ?> | ||
| + <div class="repo-selector"> | ||
| + <label>Repository:</label> | ||
| + <select onchange="window.location.href='?repo=' + encodeURIComponent(this.value)"> | ||
| + <option value="">Select repository...</option> | ||
| + <?php foreach ($repositories as $repo): ?> | ||
| + <option value="<?php echo htmlspecialchars($repo['safe_name']); ?>" <?php echo $repo['safe_name'] === $currentRepo['safe_name'] ? 'selected' : ''; ?>> | ||
| + <?php echo htmlspecialchars($repo['name']); ?> | ||
| + </option> | ||
| + <?php endforeach; ?> | ||
| + </select> | ||
| + </div> | ||
| + <?php endif; ?> | ||
| + </nav> | ||
| + | ||
| + <?php if ($currentRepo): ?> | ||
| + <div style="margin-top: 15px;"> | ||
| + <span class="current-repo"> | ||
| + Current: <strong><?php echo htmlspecialchars($currentRepo['name']); ?></strong> | ||
| + </span> | ||
| + </div> | ||
| + <?php endif; ?> | ||
| + </header> | ||
| + | ||
| + <?php | ||
| + if (!$currentRepo) { | ||
| + echo '<h2>Repositories</h2>'; | ||
| + | ||
| + if (empty($repositories)) { | ||
| + echo '<div class="empty-state">No repositories found in ' . htmlspecialchars(REPOS_PATH) . '</div>'; | ||
| + } else { | ||
| + echo '<div class="repo-grid">'; | ||
| + foreach ($repositories as $repo) { | ||
| + $repoGit = new Git($repo['path']); | ||
| + $mainBranch = getMainBranch($repoGit); | ||
| + | ||
| + // Count refs | ||
| + $branchCount = 0; | ||
| + $repoGit->eachBranch(function() use (&$branchCount) { $branchCount++; }); | ||
| + | ||
| + $tagCount = 0; | ||
| + $repoGit->eachTag(function() use (&$tagCount) { $tagCount++; }); | ||
| + | ||
| + echo '<a href="?repo=' . urlencode($repo['safe_name']) . '" class="repo-card">'; | ||
| + echo '<h3>' . htmlspecialchars($repo['name']) . '</h3>'; | ||
| + | ||
| + if ($mainBranch) { | ||
| + echo '<p>Branch: ' . htmlspecialchars($mainBranch['name']) . '</p>'; | ||
| + } | ||
| + | ||
| + echo '<p>' . $branchCount . ' branches, ' . $tagCount . ' tags</p>'; | ||
| + | ||
| + // Get last commit on HEAD | ||
| + $repoGit->history('HEAD', 1, function($commit) { | ||
| + echo '<p style="margin-top: 8px; color: #58a6ff;">' . substr($commit->sha, 0, 7) . ' - ' . htmlspecialchars(substr(explode("\n", $commit->message)[0], 0, 50)) . '</p>'; | ||
| + }); | ||
| + | ||
| + echo '</a>'; | ||
| + } | ||
| + echo '</div>'; | ||
| + } | ||
| + } else { | ||
| + // REPO VIEW LOGIC | ||
| + $mainBranch = getMainBranch($git); | ||
| + | ||
| + if (!$mainBranch) { | ||
| + echo '<div class="empty-state">'; | ||
| + echo '<h3>No branches found</h3>'; | ||
| + echo '<p>This repository appears to be empty.</p>'; | ||
| + echo '</div>'; | ||
| + } else { | ||
| + // Get the commit for the header info | ||
| + $commitInfo = null; | ||
| + // If hash is a commit or we are at HEAD, we can get commit info. | ||
| + // If hash is a deep blob/tree, this history call might fail or return that specific object context if it was a commit, | ||
| + // but usually we want the HEAD or the branch tip commit for the header unless we are specifically browsing a past commit. | ||
| + // For simplicity, we show the main branch tip or HEAD commit info above the file list. | ||
| + $git->history($hash ?: $mainBranch['hash'], 1, function($c) use (&$commitInfo) { | ||
| + $commitInfo = $c; | ||
| + }); | ||
| + | ||
| + // Fallback to main branch if specific hash history failed (e.g. if hash is a blob) | ||
| + if (!$commitInfo) { | ||
| + $git->history($mainBranch['hash'], 1, function($c) use (&$commitInfo) { | ||
| + $commitInfo = $c; | ||
| + }); | ||
| + } | ||
| + | ||
| + // Determine content to show | ||
| + $targetHash = $hash ?: $mainBranch['hash']; | ||
| + $viewType = 'tree'; | ||
| + $blobContent = ''; | ||
| + $treeEntries = []; | ||
| + | ||
| + if (!$hash) { | ||
| + // Root view: Use walk() which handles Commit -> Tree resolution automatically | ||
| + $git->walk($targetHash, function($entry) use (&$treeEntries) { | ||
| + $treeEntries[] = (array)$entry; | ||
| + }); | ||
| + | ||
| + // Sort walk results | ||
| + usort($treeEntries, function($a, $b) { | ||
| + if ($a['isDir'] !== $b['isDir']) return $a['isDir'] ? -1 : 1; | ||
| + return strcasecmp($a['name'], $b['name']); | ||
| + }); | ||
| + | ||
| + // Map walk 'isDir' to type 'tree' for template compatibility | ||
| + foreach ($treeEntries as &$e) { $e['type'] = $e['isDir'] ? 'tree' : 'blob'; } | ||
| + | ||
| + } else { | ||
| + // Specific Hash View: Could be Tree (Subdir) or Blob (File) | ||
| + // Git::walk only works on Commits. For direct SHAs we must stream and parse. | ||
| + $content = null; | ||
| + $git->stream($targetHash, function($data) use (&$content) { | ||
| + $content = $data; | ||
| + }); | ||
| + | ||
| + if ($content !== null) { | ||
| + if (detectType($content) === 'tree') { | ||
| + $viewType = 'tree'; | ||
| + $treeEntries = parseTreeContent($content); | ||
| + } else { | ||
| + $viewType = 'blob'; | ||
| + $blobContent = $content; | ||
| + } | ||
| + } else { | ||
| + echo '<div class="empty-state">Object not found</div>'; | ||
| + $viewType = 'error'; | ||
| + } | ||
| + } | ||
| + | ||
| + if ($viewType === 'blob') { | ||
| + echo '<div class="breadcrumb">'; | ||
| + echo '<a href="?">Repositories</a>'; | ||
| + echo '<span>/</span>'; | ||
| + echo '<a href="?repo=' . urlencode($currentRepo['safe_name']) . '">' . htmlspecialchars($currentRepo['name']) . '</a>'; | ||
| + echo '<span>/</span>'; | ||
| + echo '<span>File</span>'; | ||
| + echo '</div>'; | ||
| + | ||
| + echo '<h2>' . substr($targetHash, 0, 7) . '</h2>'; | ||
| + | ||
| + echo '<div class="blob-content">'; | ||
| + echo '<div class="blob-header">' . strlen($blobContent) . ' bytes</div>'; | ||
| + echo '<div class="blob-code">' . htmlspecialchars($blobContent) . '</div>'; | ||
| + echo '</div>'; | ||
| + } elseif ($viewType === 'tree') { | ||
| + echo '<div class="breadcrumb">'; | ||
| + echo '<a href="?">Repositories</a>'; | ||
| + echo '<span>/</span>'; | ||
| + echo '<a href="?repo=' . urlencode($currentRepo['safe_name']) . '">' . htmlspecialchars($currentRepo['name']) . '</a>'; | ||
| + if ($hash) { | ||
| + echo '<span>/</span>'; | ||
| + echo '<span>Tree ' . substr($targetHash, 0, 7) . '</span>'; | ||
| + } | ||
| + echo '</div>'; | ||
| + | ||
| + echo '<h2>' . htmlspecialchars($currentRepo['name']) . ' <span class="branch-badge">' . htmlspecialchars($mainBranch['name']) . '</span></h2>'; | ||
| + | ||
| + if ($commitInfo) { | ||
| + echo '<div class="commit-item" style="margin-bottom: 20px;">'; | ||
| + echo '<div><a href="?action=commit&hash=' . $commitInfo->sha . $repoParam . '" class="commit-hash">' . substr($commitInfo->sha, 0, 7) . '</a></div>'; | ||
| + echo '<div class="commit-message">' . htmlspecialchars(trim(explode("\n", $commitInfo->message)[0])) . '</div>'; | ||
| + echo '<div class="commit-meta">'; | ||
| + echo '<span class="commit-author">' . htmlspecialchars($commitInfo->author) . '</span>'; | ||
| + echo ' committed on '; | ||
| + echo '<span class="commit-date">' . date('Y-m-d H:i:s', $commitInfo->date) . '</span>'; | ||
| + echo '</div>'; | ||
| + echo '</div>'; | ||
| + } | ||
| + | ||
| + echo '<h3>Files</h3>'; | ||
| + | ||
| + if (empty($treeEntries)) { | ||
| + echo '<div class="empty-state">Empty directory</div>'; | ||
| + } else { | ||
| + echo '<div class="file-list">'; | ||
| + // If not at root, add parent link logic could go here, but strictly sticking to style | ||
| + | ||
| + foreach ($treeEntries as $entry) { | ||
| + $icon = $entry['type'] === 'tree' ? '[dir]' : '[file]'; | ||
| + $url = '?repo=' . urlencode($currentRepo['safe_name']) . '&hash=' . $entry['hash']; | ||
| + | ||
| + echo '<a href="' . $url . '" class="file-item">'; | ||
| + echo '<span class="file-mode">' . $entry['mode'] . '</span>'; | ||
| + echo '<span class="file-name"><span class="' . ($entry['type'] === 'tree' ? 'dir-icon' : 'file-icon') . '">' . $icon . '</span> ' . htmlspecialchars($entry['name']) . '</span>'; | ||
| + echo '</a>'; | ||
| + } | ||
| + echo '</div>'; | ||
| + } | ||
| + } | ||
| + } | ||
| + } | ||
| + ?> | ||
| +</div> | ||
| +</body> | ||
| +</html> | ||
| + | ||
| -<?php | ||
| -/** | ||
| - * SimpleGit - A secure multi-repository Git viewer | ||
| - * No external dependencies, no shell execution, no uploads | ||
| - */ | ||
| - | ||
| -function getHomeDirectory() { | ||
| - if (!empty($_SERVER['HOME'])) { | ||
| - return $_SERVER['HOME']; | ||
| - } | ||
| - | ||
| - if (!empty(getenv('HOME'))) { | ||
| - return getenv('HOME'); | ||
| - } | ||
| - | ||
| - if (function_exists('posix_getpwuid') && function_exists('posix_getuid')) { | ||
| - $userInfo = posix_getpwuid(posix_getuid()); | ||
| - | ||
| - if (!empty($userInfo['dir'])) { | ||
| - return $userInfo['dir']; | ||
| - } | ||
| - } | ||
| - | ||
| - return ''; | ||
| -} | ||
| - | ||
| -define('REPOS_PATH', getHomeDirectory() . '/repos'); | ||
| -define('SITE_TITLE', "Dave Jarvis' Repositories"); | ||
| - | ||
| -ini_set('display_errors', 0); | ||
| -ini_set('log_errors', 1); | ||
| -ini_set('error_log', __DIR__ . '/error.log'); | ||
| - | ||
| -function getRepositories() { | ||
| - $repos = []; | ||
| - if (!is_dir(REPOS_PATH)) { | ||
| - return $repos; | ||
| - } | ||
| - | ||
| - foreach (glob(REPOS_PATH . '/*.git') as $path) { | ||
| - if (is_dir($path)) { | ||
| - $name = basename($path, '.git'); | ||
| - $displayName = urldecode($name); | ||
| - $repos[$name] = [ | ||
| - 'path' => $path, | ||
| - 'name' => $displayName, | ||
| - 'safe_name' => $name | ||
| - ]; | ||
| - } | ||
| - } | ||
| - | ||
| - uasort($repos, function($a, $b) { | ||
| - return strcasecmp($a['name'], $b['name']); | ||
| - }); | ||
| - | ||
| - return $repos; | ||
| -} | ||
| - | ||
| -function getCurrentRepo() { | ||
| - $repos = getRepositories(); | ||
| - $requested = $_GET['repo'] ?? ''; | ||
| - $decodedRequested = urldecode($requested); | ||
| - | ||
| - foreach ($repos as $key => $repo) { | ||
| - if ($repo['safe_name'] === $requested || $repo['name'] === $decodedRequested) { | ||
| - return $repo; | ||
| - } | ||
| - } | ||
| - | ||
| - return null; | ||
| -} | ||
| - | ||
| -function sanitizePath($path) { | ||
| - $path = str_replace(['..', '\\', "\0"], ['', '/', ''], $path); | ||
| - return preg_replace('/[^a-zA-Z0-9_\-\.\/]/', '', $path); | ||
| -} | ||
| - | ||
| -function getObjectPath($repoPath, $hash) { | ||
| - if (!preg_match('/^[a-f0-9]{40}$/', $hash)) { | ||
| - return false; | ||
| - } | ||
| - $dir = substr($hash, 0, 2); | ||
| - $file = substr($hash, 2); | ||
| - return $repoPath . '/objects/' . $dir . '/' . $file; | ||
| -} | ||
| - | ||
| -function readPackedObject($repoPath, $hash) { | ||
| - $packDir = $repoPath . '/objects/pack'; | ||
| - if (!is_dir($packDir)) { | ||
| - return false; | ||
| - } | ||
| - | ||
| - foreach (glob($packDir . '/*.idx') as $idxFile) { | ||
| - $result = readFromPackIndex($idxFile, $hash); | ||
| - if ($result !== false) { | ||
| - return $result; | ||
| - } | ||
| - } | ||
| - | ||
| - return false; | ||
| -} | ||
| - | ||
| -function readFromPackIndex($idxFile, $hash) { | ||
| - $packFile = str_replace('.idx', '.pack', $idxFile); | ||
| - if (!file_exists($packFile)) { | ||
| - return false; | ||
| - } | ||
| - | ||
| - $idx = fopen($idxFile, 'rb'); | ||
| - if (!$idx) return false; | ||
| - | ||
| - $magic = fread($idx, 4); | ||
| - $version = 0; | ||
| - | ||
| - if ($magic === "\377tOc") { | ||
| - $versionData = fread($idx, 4); | ||
| - $version = unpack('N', $versionData)[1]; | ||
| - if ($version !== 2) { | ||
| - fclose($idx); | ||
| - return false; | ||
| - } | ||
| - } else { | ||
| - fseek($idx, 0); | ||
| - } | ||
| - | ||
| - fseek($idx, 256 * 4 - 4); | ||
| - $numObjects = unpack('N', fread($idx, 4))[1]; | ||
| - | ||
| - fseek($idx, 256 * 4); | ||
| - $targetHash = hex2bin($hash); | ||
| - $left = 0; | ||
| - $right = $numObjects - 1; | ||
| - $foundOffset = -1; | ||
| - | ||
| - while ($left <= $right) { | ||
| - $mid = (int)(($left + $right) / 2); | ||
| - fseek($idx, 256 * 4 + $mid * 20); | ||
| - $midHash = fread($idx, 20); | ||
| - | ||
| - $cmp = strcmp($midHash, $targetHash); | ||
| - if ($cmp === 0) { | ||
| - fseek($idx, 256 * 4 + $numObjects * 20 + $mid * 4); | ||
| - $offset = unpack('N', fread($idx, 4))[1]; | ||
| - | ||
| - if ($offset & 0x80000000) { | ||
| - fseek($idx, 256 * 4 + $numObjects * 24 + ($offset & 0x7fffffff) * 8); | ||
| - $offset = unpack('J', fread($idx, 8))[1]; | ||
| - } | ||
| - | ||
| - $foundOffset = $offset; | ||
| - break; | ||
| - } elseif ($cmp < 0) { | ||
| - $left = $mid + 1; | ||
| - } else { | ||
| - $right = $mid - 1; | ||
| - } | ||
| - } | ||
| - | ||
| - fclose($idx); | ||
| - | ||
| - if ($foundOffset < 0) { | ||
| - return false; | ||
| - } | ||
| - | ||
| - return readPackObject($packFile, $foundOffset); | ||
| -} | ||
| - | ||
| -/** | ||
| - * FIXED: Uses stream-aware decompression to avoid trailing data errors | ||
| - */ | ||
| -function uncompressGitData($handle) { | ||
| - $inflator = inflate_init(ZLIB_ENCODING_ANY); | ||
| - $output = ''; | ||
| - while (!feof($handle)) { | ||
| - $chunk = fread($handle, 8192); | ||
| - if ($chunk === false) break; | ||
| - $output .= inflate_add($inflator, $chunk, PHP_ZLIB_FINISH_FLUSH); | ||
| - if (inflate_get_status($inflator) === ZLIB_STREAM_END) break; | ||
| - } | ||
| - return $output; | ||
| -} | ||
| - | ||
| -function readPackObject($packFile, $offset) { | ||
| - $pack = fopen($packFile, 'rb'); | ||
| - if (!$pack) return false; | ||
| - | ||
| - fseek($pack, $offset); | ||
| - $byte = ord(fread($pack, 1)); | ||
| - $type = ($byte >> 4) & 0x07; | ||
| - $size = $byte & 0x0f; | ||
| - $shift = 4; | ||
| - | ||
| - while ($byte & 0x80) { | ||
| - $byte = ord(fread($pack, 1)); | ||
| - $size |= ($byte & 0x7f) << $shift; | ||
| - $shift += 7; | ||
| - } | ||
| - | ||
| - // Handle Offset Deltas (Type 6) | ||
| - if ($type === 6) { | ||
| - $byte = ord(fread($pack, 1)); | ||
| - $baseOffset = $byte & 0x7f; | ||
| - while ($byte & 0x80) { | ||
| - $byte = ord(fread($pack, 1)); | ||
| - $baseOffset = (($baseOffset + 1) << 7) | ($byte & 0x7f); | ||
| - } | ||
| - $deltaData = uncompressGitData($pack); | ||
| - $baseObj = readPackObject($packFile, $offset - $baseOffset); | ||
| - fclose($pack); | ||
| - return [ | ||
| - 'type' => $baseObj['type'], | ||
| - 'content' => applyGitDelta($baseObj['content'], $deltaData) | ||
| - ]; | ||
| - } | ||
| - | ||
| - // Standard Objects (Commit, Tree, Blob) | ||
| - $uncompressed = uncompressGitData($pack); | ||
| - fclose($pack); | ||
| - | ||
| - $types = ['', 'commit', 'tree', 'blob', 'tag']; | ||
| - return [ | ||
| - 'type' => $types[$type] ?? 'unknown', | ||
| - 'content' => $uncompressed | ||
| - ]; | ||
| -} | ||
| - | ||
| -function applyGitDelta($base, $delta) { | ||
| - $pos = 0; | ||
| - $readVarInt = function() use (&$delta, &$pos) { | ||
| - $res = 0; $shift = 0; | ||
| - do { | ||
| - $b = ord($delta[$pos++]); | ||
| - $res |= ($b & 0x7f) << $shift; | ||
| - $shift += 7; | ||
| - } while ($b & 0x80); | ||
| - return $res; | ||
| - }; | ||
| - | ||
| - $baseSize = $readVarInt(); | ||
| - $targetSize = $readVarInt(); | ||
| - $res = ''; | ||
| - | ||
| - while ($pos < strlen($delta)) { | ||
| - $opcode = ord($delta[$pos++]); | ||
| - if ($opcode & 0x80) { // Copy from base | ||
| - $off = 0; $sz = 0; | ||
| - if ($opcode & 0x01) $off |= ord($delta[$pos++]); | ||
| - if ($opcode & 0x02) $off |= ord($delta[$pos++] ) << 8; | ||
| - if ($opcode & 0x04) $off |= ord($delta[$pos++] ) << 16; | ||
| - if ($opcode & 0x08) $off |= ord($delta[$pos++] ) << 24; | ||
| - if ($opcode & 0x10) $sz |= ord($delta[$pos++]); | ||
| - if ($opcode & 0x20) $sz |= ord($delta[$pos++] ) << 8; | ||
| - if ($opcode & 0x40) $sz |= ord($delta[$pos++] ) << 16; | ||
| - if ($sz === 0) $sz = 0x10000; | ||
| - $res .= substr($base, $off, $sz); | ||
| - } else { // Insert new data | ||
| - $res .= substr($delta, $pos, $opcode); | ||
| - $pos += $opcode; | ||
| - } | ||
| - } | ||
| - return $res; | ||
| -} | ||
| - | ||
| -function readGitObject($repoPath, $hash) { | ||
| - $path = getObjectPath($repoPath, $hash); | ||
| - if ($path && file_exists($path)) { | ||
| - $content = @file_get_contents($path); | ||
| - if ($content !== false) { | ||
| - $decompressed = @gzuncompress($content); | ||
| - if ($decompressed !== false) { | ||
| - $nullPos = strpos($decompressed, "\0"); | ||
| - if ($nullPos !== false) { | ||
| - $header = substr($decompressed, 0, $nullPos); | ||
| - $parts = explode(' ', $header, 2); | ||
| - return [ | ||
| - 'type' => $parts[0] ?? 'unknown', | ||
| - 'size' => $parts[1] ?? 0, | ||
| - 'content' => substr($decompressed, $nullPos + 1) | ||
| - ]; | ||
| - } | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - return readPackedObject($repoPath, $hash); | ||
| -} | ||
| - | ||
| -function parseTree($content) { | ||
| - $entries = []; | ||
| - $offset = 0; | ||
| - $len = strlen($content); | ||
| - | ||
| - while ($offset < $len) { | ||
| - $spacePos = strpos($content, ' ', $offset); | ||
| - if ($spacePos === false) break; | ||
| - | ||
| - $mode = substr($content, $offset, $spacePos - $offset); | ||
| - $offset = $spacePos + 1; | ||
| - | ||
| - $nullPos = strpos($content, "\0", $offset); | ||
| - if ($nullPos === false) break; | ||
| - | ||
| - $name = substr($content, $offset, $nullPos - $offset); | ||
| - $offset = $nullPos + 1; | ||
| - | ||
| - if ($offset + 20 > $len) break; | ||
| - $hash = bin2hex(substr($content, $offset, 20)); | ||
| - $offset += 20; | ||
| - | ||
| - $isTree = in_array($mode, ['040000', '40000', '160000']); | ||
| - | ||
| - $entries[] = [ | ||
| - 'mode' => $mode, | ||
| - 'name' => $name, | ||
| - 'hash' => $hash, | ||
| - 'type' => $isTree ? 'tree' : 'blob' | ||
| - ]; | ||
| - } | ||
| - | ||
| - usort($entries, function($a, $b) { | ||
| - if ($a['type'] !== $b['type']) { | ||
| - return $a['type'] === 'tree' ? -1 : 1; | ||
| - } | ||
| - return strcasecmp($a['name'], $b['name']); | ||
| - }); | ||
| - | ||
| - return $entries; | ||
| -} | ||
| - | ||
| -function parseCommit($content) { | ||
| - $lines = explode("\n", $content); | ||
| - $commit = [ | ||
| - 'tree' => '', | ||
| - 'parents' => [], | ||
| - 'author' => '', | ||
| - 'committer' => '', | ||
| - 'message' => '' | ||
| - ]; | ||
| - | ||
| - $inMessage = false; | ||
| - $messageLines = []; | ||
| - | ||
| - foreach ($lines as $line) { | ||
| - if ($inMessage) { | ||
| - $messageLines[] = $line; | ||
| - } elseif ($line === '') { | ||
| - $inMessage = true; | ||
| - } elseif (strpos($line, 'tree ') === 0) { | ||
| - $commit['tree'] = substr($line, 5); | ||
| - } elseif (strpos($line, 'parent ') === 0) { | ||
| - $commit['parents'][] = substr($line, 7); | ||
| - } elseif (strpos($line, 'author ') === 0) { | ||
| - $commit['author'] = substr($line, 7); | ||
| - } elseif (strpos($line, 'committer ') === 0) { | ||
| - $commit['committer'] = substr($line, 10); | ||
| - } | ||
| - } | ||
| - | ||
| - $commit['message'] = implode("\n", $messageLines); | ||
| - return $commit; | ||
| -} | ||
| - | ||
| -function getHead($repoPath) { | ||
| - $headFile = $repoPath . '/HEAD'; | ||
| - if (!file_exists($headFile)) { | ||
| - return false; | ||
| - } | ||
| - | ||
| - $content = trim(file_get_contents($headFile)); | ||
| - | ||
| - if (preg_match('/^[a-f0-9]{40}$/', $content)) { | ||
| - return ['type' => 'detached', 'hash' => $content, 'ref' => null]; | ||
| - } | ||
| - | ||
| - if (strpos($content, 'ref: ') === 0) { | ||
| - $ref = substr($content, 5); | ||
| - $refFile = $repoPath . '/' . $ref; | ||
| - if (file_exists($refFile)) { | ||
| - $hash = trim(file_get_contents($refFile)); | ||
| - return ['type' => 'ref', 'hash' => $hash, 'ref' => $ref]; | ||
| - } | ||
| - } | ||
| - | ||
| - return false; | ||
| -} | ||
| - | ||
| -function listRefs($repoPath) { | ||
| - $refs = []; | ||
| - | ||
| - $branchesDir = $repoPath . '/refs/heads'; | ||
| - if (is_dir($branchesDir)) { | ||
| - foreach (glob($branchesDir . '/*') as $file) { | ||
| - if (is_file($file)) { | ||
| - $name = basename($file); | ||
| - $hash = trim(file_get_contents($file)); | ||
| - $refs['branches'][$name] = $hash; | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - $tagsDir = $repoPath . '/refs/tags'; | ||
| - if (is_dir($tagsDir)) { | ||
| - foreach (glob($tagsDir . '/*') as $file) { | ||
| - if (is_file($file)) { | ||
| - $name = basename($file); | ||
| - $content = file_get_contents($file); | ||
| - $refs['tags'][$name] = trim($content); | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - return $refs; | ||
| -} | ||
| - | ||
| -function formatDate($line) { | ||
| - if (preg_match('/(\d+)\s+([\+\-]\d{4})$/', $line, $matches)) { | ||
| - $timestamp = $matches[1]; | ||
| - return date('Y-m-d H:i:s', $timestamp); | ||
| - } | ||
| - return 'Unknown'; | ||
| -} | ||
| - | ||
| -function getAuthor($line) { | ||
| - if (preg_match('/^([^<]+)/', $line, $matches)) { | ||
| - return trim($matches[1]); | ||
| - } | ||
| - return $line; | ||
| -} | ||
| - | ||
| -function getLog($repoPath, $commitHash, $max = 20) { | ||
| - $log = []; | ||
| - $seen = []; | ||
| - $queue = [$commitHash]; | ||
| - | ||
| - while (!empty($queue) && count($log) < $max) { | ||
| - $hash = array_shift($queue); | ||
| - if (isset($seen[$hash])) continue; | ||
| - $seen[$hash] = true; | ||
| - | ||
| - $obj = readGitObject($repoPath, $hash); | ||
| - if (!$obj || $obj['type'] !== 'commit') continue; | ||
| - | ||
| - $commit = parseCommit($obj['content']); | ||
| - $commit['hash'] = $hash; | ||
| - $log[] = $commit; | ||
| - | ||
| - foreach ($commit['parents'] as $parent) { | ||
| - $queue[] = $parent; | ||
| - } | ||
| - } | ||
| - | ||
| - return $log; | ||
| -} | ||
| - | ||
| -function getMainBranch($repoPath) { | ||
| - $refs = listRefs($repoPath); | ||
| - | ||
| - $priority = ['main', 'master', 'trunk', 'develop']; | ||
| - foreach ($priority as $branch) { | ||
| - if (isset($refs['branches'][$branch])) { | ||
| - return ['name' => $branch, 'hash' => $refs['branches'][$branch]]; | ||
| - } | ||
| - } | ||
| - | ||
| - if (!empty($refs['branches'])) { | ||
| - $first = array_key_first($refs['branches']); | ||
| - return ['name' => $first, 'hash' => $refs['branches'][$first]]; | ||
| - } | ||
| - | ||
| - return null; | ||
| -} | ||
| - | ||
| -$action = $_GET['action'] ?? 'home'; | ||
| -$hash = sanitizePath($_GET['hash'] ?? ''); | ||
| - | ||
| -$currentRepo = getCurrentRepo(); | ||
| -$repoParam = $currentRepo ? '&repo=' . urlencode($currentRepo['safe_name']) : ''; | ||
| - | ||
| -$repositories = getRepositories(); | ||
| - | ||
| -?> | ||
| -<!DOCTYPE html> | ||
| -<html lang="en"> | ||
| -<head> | ||
| - <meta charset="UTF-8"> | ||
| - <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| - <title><?php echo SITE_TITLE; ?><?php echo $currentRepo ? ' - ' . htmlspecialchars($currentRepo['name']) : ''; ?></title> | ||
| - <link rel="stylesheet" href="kimi-style.css"> | ||
| -</head> | ||
| -<body> | ||
| - <div class="container"> | ||
| - <header> | ||
| - <h1><?php echo SITE_TITLE; ?></h1> | ||
| - <nav class="nav"> | ||
| - <a href="?">Home</a> | ||
| - <?php if ($currentRepo): ?> | ||
| - <a href="?repo=<?php echo urlencode($currentRepo['safe_name']); ?>">Files</a> | ||
| - <a href="?action=commits<?php echo $repoParam; ?>">Commits</a> | ||
| - <a href="?action=refs<?php echo $repoParam; ?>">Branches</a> | ||
| - <?php endif; ?> | ||
| - <?php if ($currentRepo): ?> | ||
| - <div class="repo-selector"> | ||
| - <label>Repository:</label> | ||
| - <select onchange="window.location.href='?repo=' + encodeURIComponent(this.value)"> | ||
| - <option value="">Select repository...</option> | ||
| - <?php foreach ($repositories as $repo): ?> | ||
| - <option value="<?php echo htmlspecialchars($repo['safe_name']); ?>" <?php echo $repo['safe_name'] === $currentRepo['safe_name'] ? 'selected' : ''; ?>> | ||
| - <?php echo htmlspecialchars($repo['name']); ?> | ||
| - </option> | ||
| - <?php endforeach; ?> | ||
| - </select> | ||
| - </div> | ||
| - <?php endif; ?> | ||
| - </nav> | ||
| - | ||
| - <?php if ($currentRepo): ?> | ||
| - <div style="margin-top: 15px;"> | ||
| - <span class="current-repo"> | ||
| - Current: <strong><?php echo htmlspecialchars($currentRepo['name']); ?></strong> | ||
| - </span> | ||
| - </div> | ||
| - <?php endif; ?> | ||
| - </header> | ||
| - | ||
| - <?php | ||
| - if (!$currentRepo) { | ||
| - echo '<h2>Repositories</h2>'; | ||
| - | ||
| - if (empty($repositories)) { | ||
| - echo '<div class="empty-state">No repositories found in ' . htmlspecialchars(REPOS_PATH) . '</div>'; | ||
| - } else { | ||
| - echo '<div class="repo-grid">'; | ||
| - foreach ($repositories as $repo) { | ||
| - $mainBranch = getMainBranch($repo['path']); | ||
| - $head = getHead($repo['path']); | ||
| - | ||
| - echo '<a href="?repo=' . urlencode($repo['safe_name']) . '" class="repo-card">'; | ||
| - echo '<h3>' . htmlspecialchars($repo['name']) . '</h3>'; | ||
| - | ||
| - if ($mainBranch) { | ||
| - echo '<p>Branch: ' . htmlspecialchars($mainBranch['name']) . '</p>'; | ||
| - } | ||
| - | ||
| - $refs = listRefs($repo['path']); | ||
| - $branchCount = count($refs['branches'] ?? []); | ||
| - $tagCount = count($refs['tags'] ?? []); | ||
| - | ||
| - echo '<p>' . $branchCount . ' branches, ' . $tagCount . ' tags</p>'; | ||
| - | ||
| - if ($head) { | ||
| - $obj = readGitObject($repo['path'], $head['hash']); | ||
| - if ($obj && $obj['type'] === 'commit') { | ||
| - $commit = parseCommit($obj['content']); | ||
| - echo '<p style="margin-top: 8px; color: #58a6ff;">' . substr($head['hash'], 0, 7) . ' - ' . htmlspecialchars(substr(trim(explode("\n", $commit['message'])[0]), 0, 50)) . '</p>'; | ||
| - } | ||
| - } | ||
| - echo '</a>'; | ||
| - } | ||
| - echo '</div>'; | ||
| - } | ||
| - } else { | ||
| - $mainBranch = getMainBranch($currentRepo['path']); | ||
| - | ||
| - if (!$mainBranch) { | ||
| - echo '<div class="empty-state">'; | ||
| - echo '<h3>No branches found</h3>'; | ||
| - echo '<p>This repository appears to be empty.</p>'; | ||
| - echo '</div>'; | ||
| - } else { | ||
| - $obj = readGitObject($currentRepo['path'], $mainBranch['hash']); | ||
| - | ||
| - if (!$obj || $obj['type'] !== 'commit') { | ||
| - echo '<div class="empty-state">Error reading commit</div>'; | ||
| - } else { | ||
| - $commit = parseCommit($obj['content']); | ||
| - $treeHash = $commit['tree']; | ||
| - | ||
| - $viewHash = $hash ?: $treeHash; | ||
| - $viewObj = readGitObject($currentRepo['path'], $viewHash); | ||
| - | ||
| - if ($viewObj && $viewObj['type'] === 'blob') { | ||
| - echo '<div class="breadcrumb">'; | ||
| - echo '<a href="?">Repositories</a>'; | ||
| - echo '<span>/</span>'; | ||
| - echo '<a href="?repo=' . urlencode($currentRepo['safe_name']) . '">' . htmlspecialchars($currentRepo['name']) . '</a>'; | ||
| - echo '<span>/</span>'; | ||
| - echo '<span>File</span>'; | ||
| - echo '</div>'; | ||
| - | ||
| - echo '<h2>' . substr($viewHash, 0, 7) . '</h2>'; | ||
| - | ||
| - echo '<div class="blob-content">'; | ||
| - echo '<div class="blob-header">' . strlen($viewObj['content']) . ' bytes</div>'; | ||
| - echo '<div class="blob-code">' . htmlspecialchars($viewObj['content']) . '</div>'; | ||
| - echo '</div>'; | ||
| - } else { | ||
| - $treeObj = readGitObject($currentRepo['path'], $viewHash); | ||
| - | ||
| - if (!$treeObj || $treeObj['type'] !== 'tree') { | ||
| - echo '<div class="empty-state">Directory not found</div>'; | ||
| - } else { | ||
| - $entries = parseTree($treeObj['content']); | ||
| - | ||
| - echo '<div class="breadcrumb">'; | ||
| - echo '<a href="?">Repositories</a>'; | ||
| - echo '<span>/</span>'; | ||
| - echo '<a href="?repo=' . urlencode($currentRepo['safe_name']) . '">' . htmlspecialchars($currentRepo['name']) . '</a>'; | ||
| - if ($viewHash !== $treeHash) { | ||
| - echo '<span>/</span>'; | ||
| - echo '<span>Tree ' . substr($viewHash, 0, 7) . '</span>'; | ||
| - } | ||
| - echo '</div>'; | ||
| - | ||
| - echo '<h2>' . htmlspecialchars($currentRepo['name']) . ' <span class="branch-badge">' . htmlspecialchars($mainBranch['name']) . '</span></h2>'; | ||
| - | ||
| - echo '<div class="commit-item" style="margin-bottom: 20px;">'; | ||
| - echo '<div><a href="?action=commit&hash=' . $mainBranch['hash'] . $repoParam . '" class="commit-hash">' . substr($mainBranch['hash'], 0, 7) . '</a></div>'; | ||
| - echo '<div class="commit-message">' . htmlspecialchars(trim($commit['message'])) . '</div>'; | ||
| - echo '<div class="commit-meta">'; | ||
| - echo '<span class="commit-author">' . htmlspecialchars(getAuthor($commit['author'])) . '</span>'; | ||
| - echo ' committed on '; | ||
| - echo '<span class="commit-date">' . formatDate($commit['committer']) . '</span>'; | ||
| - echo '</div>'; | ||
| - echo '</div>'; | ||
| - | ||
| - echo '<h3>Files</h3>'; | ||
| - | ||
| - if (empty($entries)) { | ||
| - echo '<div class="empty-state">Empty directory</div>'; | ||
| - } else { | ||
| - echo '<div class="file-list">'; | ||
| - foreach ($entries as $entry) { | ||
| - $icon = $entry['type'] === 'tree' ? '[dir]' : '[file]'; | ||
| - // CRITICAL FIX: Always include hash so directories work | ||
| - $url = '?repo=' . urlencode($currentRepo['safe_name']) . '&hash=' . $entry['hash']; | ||
| - | ||
| - echo '<a href="' . $url . '" class="file-item">'; | ||
| - echo '<span class="file-mode">' . $entry['mode'] . '</span>'; | ||
| - echo '<span class="file-name"><span class="' . ($entry['type'] === 'tree' ? 'dir-icon' : 'file-icon') . '">' . $icon . '</span> ' . htmlspecialchars($entry['name']) . '</span>'; | ||
| - echo '</a>'; | ||
| - } | ||
| - echo '</div>'; | ||
| - } | ||
| - } | ||
| - } | ||
| - } | ||
| - } | ||
| - } | ||
| - ?> | ||
| -</div> | ||
| -</body> | ||
| -</html> | ||
| - | ||
| Delta | 373 lines added, 1470 lines removed, 1097-line decrease |
|---|