Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/treetrek.git

Updates kimi viewer

Author Dave Jarvis <email>
Date 2026-02-08 14:07:59 GMT-0800
Commit a62d437d4a76811c0821d04be5055f3d86c5f207
Parent e4e553a
Delta 951 lines added, 834 lines removed, 117-line increase
kimi-viewer.php
ini_set('error_log', __DIR__ . '/error.log');
-// Get list of available repositories
-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;
-}
-
-// Get current repository
-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 readGitObject($repoPath, $hash) {
- $path = getObjectPath($repoPath, $hash);
- if (!$path || !file_exists($path)) {
- return false;
- }
-
- $content = file_get_contents($path);
- if ($content === false) {
- return false;
- }
-
- $decompressed = @gzuncompress($content);
- if ($decompressed === false) {
- return false;
- }
-
- $nullPos = strpos($decompressed, "\0");
- if ($nullPos === false) {
- return false;
- }
-
- $header = substr($decompressed, 0, $nullPos);
- $parts = explode(' ', $header);
-
- return [
- 'type' => $parts[0] ?? 'unknown',
- 'size' => $parts[1] ?? 0,
- 'content' => substr($decompressed, $nullPos + 1)
- ];
-}
-
-function parseTree($content) {
- $entries = [];
- $offset = 0;
-
- while ($offset < strlen($content)) {
- $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 > strlen($content)) break;
- $hash = bin2hex(substr($content, $offset, 20));
- $offset += 20;
-
- $entries[] = [
- 'mode' => $mode,
- 'name' => $name,
- 'hash' => $hash,
- 'type' => ($mode === '040000' || $mode === '40000') ? '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>
- <style>
- * {
- 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;
- }
- </style>
-</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) {
- // Default view: list all repositories
- 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 {
- // Repository selected - determine main branch and show files
- $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 {
- // Get the tree hash from the main branch commit
- $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'];
-
- // Check if we're viewing a specific tree or blob
- $viewHash = $hash ?: $treeHash;
- $viewObj = readGitObject($currentRepo['path'], $viewHash);
-
- if ($viewObj && $viewObj['type'] === 'blob') {
- // Viewing a file
- 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 {
- // Viewing tree (directory)
- $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>';
-
- // Show latest commit info
- 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]';
- $action = $entry['type'] === 'tree' ? '' : '&hash=' . $entry['hash'];
-
- echo '<a href="?repo=' . urlencode($currentRepo['safe_name']) . $action . '" 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>
-
+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);
+}
+
+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;
+ }
+
+ $types = ['', 'commit', 'tree', 'blob', 'tag', '', 'ofs_delta', 'ref_delta'];
+ $typeName = $types[$type] ?? 'unknown';
+
+ if ($type === 6 || $type === 7) {
+ fclose($pack);
+ return false;
+ }
+
+ $compressed = '';
+ $context = inflate_init(ZLIB_ENCODING_DEFLATE);
+ $uncompressed = '';
+
+ while (!feof($pack)) {
+ $chunk = fread($pack, 8192);
+ $uncompressed .= inflate_add($context, $chunk);
+
+ if (strlen($uncompressed) >= $size) {
+ break;
+ }
+ }
+
+ fclose($pack);
+
+ if (strlen($uncompressed) !== $size) {
+ return false;
+ }
+
+ return [
+ 'type' => $typeName,
+ 'size' => $size,
+ 'content' => $uncompressed
+ ];
+}
+
+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>
+ <style>
+ * {
+ 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;
+ }
+</style>
+</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>