| Author | Dave Jarvis <email> |
|---|---|
| Date | 2026-02-08 22:21:27 GMT-0800 |
| Commit | e6216b58df3b88bf73bbb1d6b4d01984bebaf6b9 |
| Parent | e6a99b7 |
| } | ||
| + // New capability: Allow Files to compare themselves to other Files | ||
| + public function compare(File $other): int { | ||
| + // 1. Sort Directories before Files | ||
| + if ($this->isDir !== $other->isDir) { | ||
| + return $this->isDir ? -1 : 1; | ||
| + } | ||
| + // 2. Sort Alphabetically by Name | ||
| + return strcasecmp($this->name, $other->name); | ||
| + } | ||
| + | ||
| public function render(FileRenderer $renderer): void { | ||
| $renderer->renderFileItem( | ||
| ); | ||
| } | ||
| + | ||
| + // ... [Rest of the class methods: getIconClass, getFormattedSize, etc. remain unchanged] ... | ||
| private function getIconClass(): string { | ||
| <?php | ||
| -class Git { | ||
| - private string $path; | ||
| - private string $objPath; | ||
| - | ||
| - public function __construct(string $repoPath) { | ||
| - $this->path = rtrim($repoPath, '/'); | ||
| - $this->objPath = $this->path . '/objects'; | ||
| - } | ||
| - | ||
| - public function getObjectSize(string $sha): int { | ||
| - $loose = "{$this->objPath}/" . substr($sha, 0, 2) . "/" . substr($sha, 2); | ||
| - if (file_exists($loose)) { | ||
| - $f = @fopen($loose, 'rb'); | ||
| - if (!$f) return 0; | ||
| - $ctx = inflate_init(ZLIB_ENCODING_DEFLATE); | ||
| - $data = ''; | ||
| - while (!feof($f)) { | ||
| - $chunk = fread($f, 128); | ||
| - $inflated = @inflate_add($ctx, $chunk, ZLIB_NO_FLUSH); | ||
| - if ($inflated === false) break; | ||
| - $data .= $inflated; | ||
| - if (strpos($data, "\0") !== false) break; | ||
| - } | ||
| - fclose($f); | ||
| - $header = explode("\0", $data, 2)[0]; | ||
| - $parts = explode(' ', $header); | ||
| - return isset($parts[1]) ? (int)$parts[1] : 0; | ||
| - } | ||
| - return $this->getPackedObjectSize($sha); | ||
| - } | ||
| - | ||
| - private function getPackedObjectSize(string $sha): int { | ||
| - $info = $this->getPackOffset($sha); | ||
| - if (!$info) return 0; | ||
| - | ||
| - $pf = @fopen($info['file'], 'rb'); | ||
| - if (!$pf) return 0; | ||
| - | ||
| - fseek($pf, $info['offset']); | ||
| - $byte = ord(fread($pf, 1)); | ||
| - $type = ($byte >> 4) & 7; | ||
| - $size = $byte & 15; | ||
| - $shift = 4; | ||
| - while ($byte & 128) { | ||
| - $byte = ord(fread($pf, 1)); | ||
| - $size |= (($byte & 127) << $shift); | ||
| - $shift += 7; | ||
| - } | ||
| - | ||
| - if ($type === 6 || $type === 7) { | ||
| - if ($type === 6) { // OFS_DELTA | ||
| - $byte = ord(fread($pf, 1)); | ||
| - while ($byte & 128) { $byte = ord(fread($pf, 1)); } | ||
| - } else { // REF_DELTA | ||
| - fread($pf, 20); | ||
| - } | ||
| - | ||
| - $ctx = inflate_init(ZLIB_ENCODING_DEFLATE); | ||
| - $buffer = ''; | ||
| - $found = false; | ||
| - while (!$found && !feof($pf)) { | ||
| - $chunk = fread($pf, 512); | ||
| - $inflated = @inflate_add($ctx, $chunk, ZLIB_NO_FLUSH); | ||
| - if ($inflated === false) { fclose($pf); return 0; } | ||
| - $buffer .= $inflated; | ||
| - if (strlen($buffer) > 32) $found = true; | ||
| - } | ||
| - | ||
| - $pos = 0; | ||
| - // Skip Source Size | ||
| - if (!isset($buffer[$pos])) { fclose($pf); return 0; } | ||
| - $byte = ord($buffer[$pos++]); | ||
| - while ($byte & 128) { | ||
| - if (!isset($buffer[$pos])) break; | ||
| - $byte = ord($buffer[$pos++]); | ||
| - } | ||
| - // Read Target Size | ||
| - if (!isset($buffer[$pos])) { fclose($pf); return 0; } | ||
| - $byte = ord($buffer[$pos++]); | ||
| - $size = $byte & 127; | ||
| - $shift = 7; | ||
| - while ($byte & 128) { | ||
| - if (!isset($buffer[$pos])) break; | ||
| - $byte = ord($buffer[$pos++]); | ||
| - $size |= (($byte & 127) << $shift); | ||
| - $shift += 7; | ||
| - } | ||
| - } | ||
| - fclose($pf); | ||
| - return $size; | ||
| - } | ||
| - | ||
| - public function eachRepository(callable $callback): void { | ||
| - if (!is_dir($this->path)) return; | ||
| - $repos = []; | ||
| - foreach (glob($this->path . '/*.git') as $path) { | ||
| - if (is_dir($path)) { | ||
| - $name = basename($path, '.git'); | ||
| - $repos[$name] = ['path' => $path, 'name' => urldecode($name), 'safe_name' => $name]; | ||
| - } | ||
| - } | ||
| - uasort($repos, fn($a, $b) => strcasecmp($a['name'], $b['name'])); | ||
| - foreach ($repos as $repo) $callback($repo); | ||
| - } | ||
| - | ||
| - public function getMainBranch(): ?array { | ||
| - $branches = []; | ||
| - $this->eachBranch(function($name, $sha) use (&$branches) { $branches[$name] = $sha; }); | ||
| - foreach (['main', 'master', 'trunk', 'develop'] as $b) { | ||
| - if (isset($branches[$b])) return ['name' => $b, 'hash' => $branches[$b]]; | ||
| - } | ||
| - if (!empty($branches)) { | ||
| - $f = array_key_first($branches); | ||
| - return ['name' => $f, 'hash' => $branches[$f]]; | ||
| - } | ||
| - return null; | ||
| - } | ||
| - | ||
| - public function eachBranch(callable $callback): void { $this->scanRefs('refs/heads', $callback); } | ||
| - public function eachTag(callable $callback): void { $this->scanRefs('refs/tags', $callback); } | ||
| - | ||
| - public function walk(string $refOrSha, callable $callback): void { | ||
| - $sha = $this->resolve($refOrSha); | ||
| - if (!$sha) return; | ||
| - $data = $this->read($sha); | ||
| - if (!$data) return; | ||
| - if (preg_match('/^tree ([0-9a-f]{40})$/m', $data, $m)) { | ||
| - $data = $this->read($m[1]); | ||
| - if (!$data) return; | ||
| - } elseif (!$this->isTreeData($data)) return; | ||
| - | ||
| - $pos = 0; | ||
| - while ($pos < strlen($data)) { | ||
| - $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)); | ||
| - $callback((object)['name' => $name, 'sha' => $entrySha, 'isDir' => ($mode === '40000'), 'mode' => $mode]); | ||
| - $pos = $null + 21; | ||
| - } | ||
| - } | ||
| - | ||
| - private function isTreeData(string $data): bool { | ||
| - if (strlen($data) < 25) return false; | ||
| - if (preg_match('/^(40000|100644|100755|120000) /', $data)) { | ||
| - $null = strpos($data, "\0"); | ||
| - return ($null !== false && ($null + 21 <= strlen($data))); | ||
| - } | ||
| - return false; | ||
| - } | ||
| - | ||
| - public function history(string $refOrSha, int $limit, callable $callback): void { | ||
| - $currentSha = $this->resolve($refOrSha); | ||
| - $count = 0; | ||
| - while ($currentSha && $count < $limit) { | ||
| - $data = $this->read($currentSha); | ||
| - if (!$data) break; | ||
| - $message = (strpos($data, "\n\n") !== false) ? substr($data, strpos($data, "\n\n") + 2) : ''; | ||
| - preg_match('/^author (.*) <(.*)> (\d+)/m', $data, $auth); | ||
| - $callback((object)['sha' => $currentSha, 'message' => trim($message), 'author' => $auth[1] ?? 'Unknown', 'email' => $auth[2] ?? '', 'date' => (int)($auth[3] ?? 0)]); | ||
| - $currentSha = preg_match('/^parent ([0-9a-f]{40})$/m', $data, $m) ? $m[1] : null; | ||
| - $count++; | ||
| - } | ||
| - } | ||
| - | ||
| - public function stream(string $sha, callable $callback): void { | ||
| - $data = $this->read($sha); | ||
| - if ($data) $callback($data); | ||
| - } | ||
| - | ||
| - private function resolve(string $input): ?string { | ||
| - if (preg_match('/^[0-9a-f]{40}$/', $input)) return $input; | ||
| - if ($input === 'HEAD' && file_exists($h = "{$this->path}/HEAD")) { | ||
| - $head = trim(file_get_contents($h)); | ||
| - return (strpos($head, 'ref: ') === 0) ? $this->resolve(substr($head, 5)) : $head; | ||
| - } | ||
| - foreach ([$input, "refs/heads/$input", "refs/tags/$input"] as $p) { | ||
| - if (file_exists($f = "{$this->path}/$p")) return trim(file_get_contents($f)); | ||
| - } | ||
| - if (file_exists($packed = "{$this->path}/packed-refs")) { | ||
| - foreach (file($packed) as $line) { | ||
| - if ($line[0] === '#' || $line[0] === '^') continue; | ||
| - $parts = explode(' ', trim($line)); | ||
| - if (count($parts) >= 2 && ($parts[1] === $input || $parts[1] === "refs/heads/$input" || $parts[1] === "refs/tags/$input")) return $parts[0]; | ||
| - } | ||
| - } | ||
| - return null; | ||
| - } | ||
| - | ||
| - private function read(string $sha): ?string { | ||
| - $loose = "{$this->objPath}/" . substr($sha, 0, 2) . "/" . substr($sha, 2); | ||
| - if (file_exists($loose)) { | ||
| - $inflated = @gzuncompress(file_get_contents($loose)); | ||
| - return $inflated ? explode("\0", $inflated, 2)[1] : null; | ||
| - } | ||
| - return $this->fromPack($sha); | ||
| - } | ||
| - | ||
| - private function scanRefs(string $prefix, callable $callback): void { | ||
| - $dir = "{$this->path}/$prefix"; | ||
| - if (is_dir($dir)) { | ||
| - foreach (array_diff(scandir($dir), ['.', '..']) as $f) { | ||
| - $callback($f, trim(file_get_contents("$dir/$f"))); | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - // --- FIX: Delta-Aware Pack Reader --- | ||
| - | ||
| - private function fromPack(string $sha): ?string { | ||
| - $info = $this->getPackOffset($sha); | ||
| - if (!$info) return null; | ||
| - $pf = @fopen($info['file'], 'rb'); | ||
| - if (!$pf) return null; | ||
| - $data = $this->readPackEntry($pf, $info['offset']); | ||
| - fclose($pf); | ||
| - return $data; | ||
| - } | ||
| - | ||
| - private function readPackEntry($pf, int $offset): ?string { | ||
| - fseek($pf, $offset); | ||
| - $byte = ord(fread($pf, 1)); | ||
| - $type = ($byte >> 4) & 7; | ||
| - $size = $byte & 15; | ||
| - $shift = 4; | ||
| - while ($byte & 128) { | ||
| - $byte = ord(fread($pf, 1)); | ||
| - $size |= (($byte & 127) << $shift); | ||
| - $shift += 7; | ||
| - } | ||
| - | ||
| - // Type 6: OBJ_OFS_DELTA | ||
| - if ($type === 6) { | ||
| - $byte = ord(fread($pf, 1)); | ||
| - $negOffset = $byte & 127; | ||
| - while ($byte & 128) { | ||
| - $byte = ord(fread($pf, 1)); | ||
| - $negOffset = (($negOffset + 1) << 7) | ($byte & 127); | ||
| - } | ||
| - $baseOffset = $offset - $negOffset; | ||
| - // Recursively read base | ||
| - $base = $this->readPackEntry($pf, $baseOffset); | ||
| - | ||
| - // SEEK BACK to delta content! (Recursion moved the pointer) | ||
| - // Header size calculation (1 byte type + varints) to find data start | ||
| - // It's safer to just re-read the header logic to skip it, or store data pos. | ||
| - fseek($pf, $offset); | ||
| - $b = ord(fread($pf, 1)); | ||
| - while ($b & 128) { $b = ord(fread($pf, 1)); } | ||
| - $b = ord(fread($pf, 1)); // First byte of offset | ||
| - while ($b & 128) { $b = ord(fread($pf, 1)); } | ||
| - | ||
| - $delta = @gzuncompress(fread($pf, 16777216)); // Read reasonable chunk | ||
| - return $this->applyDelta($base, $delta); | ||
| - } | ||
| - | ||
| - // Type 7: OBJ_REF_DELTA | ||
| - if ($type === 7) { | ||
| - $baseSha = bin2hex(fread($pf, 20)); | ||
| - $base = $this->read($baseSha); // Resolve from any source | ||
| - $delta = @gzuncompress(fread($pf, 16777216)); | ||
| - return $this->applyDelta($base, $delta); | ||
| - } | ||
| - | ||
| - // Standard Objects (1-4) | ||
| - return @gzuncompress(fread($pf, 16777216)); | ||
| - } | ||
| - | ||
| - private function applyDelta(?string $base, ?string $delta): string { | ||
| - if (!$base || !$delta) return ''; | ||
| - $pos = 0; | ||
| - // Skip Source Size (VarInt) | ||
| - $byte = ord($delta[$pos++]); | ||
| - while ($byte & 128) { $byte = ord($delta[$pos++]); } | ||
| - // Skip Target Size (VarInt) | ||
| - $byte = ord($delta[$pos++]); | ||
| - while ($byte & 128) { $byte = ord($delta[$pos++]); } | ||
| - | ||
| - $out = ''; | ||
| - while ($pos < strlen($delta)) { | ||
| - $opcode = ord($delta[$pos++]); | ||
| - if ($opcode & 128) { // Copy | ||
| - $off = 0; $len = 0; | ||
| - if ($opcode & 1) $off |= ord($delta[$pos++]); | ||
| - if ($opcode & 2) $off |= ord($delta[$pos++]) << 8; | ||
| - if ($opcode & 4) $off |= ord($delta[$pos++]) << 16; | ||
| - if ($opcode & 8) $off |= ord($delta[$pos++]) << 24; | ||
| - if ($opcode & 16) $len |= ord($delta[$pos++]); | ||
| - if ($opcode & 32) $len |= ord($delta[$pos++]) << 8; | ||
| - if ($opcode & 64) $len |= ord($delta[$pos++]) << 16; | ||
| - if ($len === 0) $len = 0x10000; | ||
| - $out .= substr($base, $off, $len); | ||
| - } else { // Insert | ||
| - $len = $opcode & 127; | ||
| - $out .= substr($delta, $pos, $len); | ||
| - $pos += $len; | ||
| - } | ||
| - } | ||
| - return $out; | ||
| - } | ||
| - | ||
| - private function getPackOffset(string $sha): ?array { | ||
| - $packs = glob("{$this->objPath}/pack/*.idx"); | ||
| - if (!$packs) return null; | ||
| - | ||
| - $binSha = hex2bin($sha); | ||
| - $firstByte = ord($binSha[0]); | ||
| - | ||
| - foreach ($packs as $idxFile) { | ||
| - $f = @fopen($idxFile, 'rb'); | ||
| - if (!$f) continue; | ||
| - | ||
| - // V2 Signature Check | ||
| - $sig = fread($f, 4); | ||
| - $ver = unpack('N', fread($f, 4))[1]; | ||
| - if ($sig !== "\377tOc" || $ver !== 2) { fclose($f); continue; } | ||
| - | ||
| - // Fanout Lookup | ||
| +require_once 'File.php'; | ||
| + | ||
| +class Git { | ||
| + private string $path; | ||
| + private string $objPath; | ||
| + | ||
| + public function __construct(string $repoPath) { | ||
| + $this->path = rtrim($repoPath, '/'); | ||
| + $this->objPath = $this->path . '/objects'; | ||
| + } | ||
| + | ||
| + public function getObjectSize(string $sha): int { | ||
| + $loose = "{$this->objPath}/" . substr($sha, 0, 2) . "/" . substr($sha, 2); | ||
| + if (file_exists($loose)) { | ||
| + $f = @fopen($loose, 'rb'); | ||
| + if (!$f) return 0; | ||
| + $ctx = inflate_init(ZLIB_ENCODING_DEFLATE); | ||
| + $data = ''; | ||
| + while (!feof($f)) { | ||
| + $chunk = fread($f, 128); | ||
| + $inflated = @inflate_add($ctx, $chunk, ZLIB_NO_FLUSH); | ||
| + if ($inflated === false) break; | ||
| + $data .= $inflated; | ||
| + if (strpos($data, "\0") !== false) break; | ||
| + } | ||
| + fclose($f); | ||
| + $header = explode("\0", $data, 2)[0]; | ||
| + $parts = explode(' ', $header); | ||
| + return isset($parts[1]) ? (int)$parts[1] : 0; | ||
| + } | ||
| + return $this->getPackedObjectSize($sha); | ||
| + } | ||
| + | ||
| + private function getPackedObjectSize(string $sha): int { | ||
| + $info = $this->getPackOffset($sha); | ||
| + if (!$info) return 0; | ||
| + | ||
| + $pf = @fopen($info['file'], 'rb'); | ||
| + if (!$pf) return 0; | ||
| + | ||
| + fseek($pf, $info['offset']); | ||
| + $byte = ord(fread($pf, 1)); | ||
| + $type = ($byte >> 4) & 7; | ||
| + $size = $byte & 15; | ||
| + $shift = 4; | ||
| + while ($byte & 128) { | ||
| + $byte = ord(fread($pf, 1)); | ||
| + $size |= (($byte & 127) << $shift); | ||
| + $shift += 7; | ||
| + } | ||
| + | ||
| + if ($type === 6 || $type === 7) { | ||
| + if ($type === 6) { // OFS_DELTA | ||
| + $byte = ord(fread($pf, 1)); | ||
| + while ($byte & 128) { $byte = ord(fread($pf, 1)); } | ||
| + } else { // REF_DELTA | ||
| + fread($pf, 20); | ||
| + } | ||
| + | ||
| + $ctx = inflate_init(ZLIB_ENCODING_DEFLATE); | ||
| + $buffer = ''; | ||
| + $found = false; | ||
| + while (!$found && !feof($pf)) { | ||
| + $chunk = fread($pf, 512); | ||
| + $inflated = @inflate_add($ctx, $chunk, ZLIB_NO_FLUSH); | ||
| + if ($inflated === false) { fclose($pf); return 0; } | ||
| + $buffer .= $inflated; | ||
| + if (strlen($buffer) > 32) $found = true; | ||
| + } | ||
| + | ||
| + $pos = 0; | ||
| + // Skip Source Size | ||
| + if (!isset($buffer[$pos])) { fclose($pf); return 0; } | ||
| + $byte = ord($buffer[$pos++]); | ||
| + while ($byte & 128) { | ||
| + if (!isset($buffer[$pos])) break; | ||
| + $byte = ord($buffer[$pos++]); | ||
| + } | ||
| + // Read Target Size | ||
| + if (!isset($buffer[$pos])) { fclose($pf); return 0; } | ||
| + $byte = ord($buffer[$pos++]); | ||
| + $size = $byte & 127; | ||
| + $shift = 7; | ||
| + while ($byte & 128) { | ||
| + if (!isset($buffer[$pos])) break; | ||
| + $byte = ord($buffer[$pos++]); | ||
| + $size |= (($byte & 127) << $shift); | ||
| + $shift += 7; | ||
| + } | ||
| + } | ||
| + fclose($pf); | ||
| + return $size; | ||
| + } | ||
| + | ||
| + public function eachRepository(callable $callback): void { | ||
| + if (!is_dir($this->path)) return; | ||
| + $repos = []; | ||
| + foreach (glob($this->path . '/*.git') as $path) { | ||
| + if (is_dir($path)) { | ||
| + $name = basename($path, '.git'); | ||
| + $repos[$name] = ['path' => $path, 'name' => urldecode($name), 'safe_name' => $name]; | ||
| + } | ||
| + } | ||
| + uasort($repos, fn($a, $b) => strcasecmp($a['name'], $b['name'])); | ||
| + foreach ($repos as $repo) $callback($repo); | ||
| + } | ||
| + | ||
| + public function getMainBranch(): ?array { | ||
| + $branches = []; | ||
| + $this->eachBranch(function($name, $sha) use (&$branches) { $branches[$name] = $sha; }); | ||
| + foreach (['main', 'master', 'trunk', 'develop'] as $b) { | ||
| + if (isset($branches[$b])) return ['name' => $b, 'hash' => $branches[$b]]; | ||
| + } | ||
| + if (!empty($branches)) { | ||
| + $f = array_key_first($branches); | ||
| + return ['name' => $f, 'hash' => $branches[$f]]; | ||
| + } | ||
| + return null; | ||
| + } | ||
| + | ||
| + public function eachBranch(callable $callback): void { $this->scanRefs('refs/heads', $callback); } | ||
| + public function eachTag(callable $callback): void { $this->scanRefs('refs/tags', $callback); } | ||
| + | ||
| + public function walk(string $refOrSha, callable $callback): void { | ||
| + $sha = $this->resolve($refOrSha); | ||
| + if (!$sha) return; | ||
| + $data = $this->read($sha); | ||
| + if (!$data) return; | ||
| + if (preg_match('/^tree ([0-9a-f]{40})$/m', $data, $m)) { | ||
| + $data = $this->read($m[1]); | ||
| + if (!$data) return; | ||
| + } elseif (!$this->isTreeData($data)) return; | ||
| + | ||
| + $pos = 0; | ||
| + while ($pos < strlen($data)) { | ||
| + $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)); | ||
| + | ||
| + // Calculate logic internally to encapsulate File creation | ||
| + $isDir = ($mode === '40000' || $mode === '040000'); | ||
| + $size = $isDir ? 0 : $this->getObjectSize($entrySha); | ||
| + | ||
| + $callback(new File($name, $entrySha, $mode, 0, $size)); | ||
| + | ||
| + $pos = $null + 21; | ||
| + } | ||
| + } | ||
| + | ||
| + private function isTreeData(string $data): bool { | ||
| + if (strlen($data) < 25) return false; | ||
| + if (preg_match('/^(40000|100644|100755|120000) /', $data)) { | ||
| + $null = strpos($data, "\0"); | ||
| + return ($null !== false && ($null + 21 <= strlen($data))); | ||
| + } | ||
| + return false; | ||
| + } | ||
| + | ||
| + public function history(string $refOrSha, int $limit, callable $callback): void { | ||
| + $currentSha = $this->resolve($refOrSha); | ||
| + $count = 0; | ||
| + while ($currentSha && $count < $limit) { | ||
| + $data = $this->read($currentSha); | ||
| + if (!$data) break; | ||
| + $message = (strpos($data, "\n\n") !== false) ? substr($data, strpos($data, "\n\n") + 2) : ''; | ||
| + preg_match('/^author (.*) <(.*)> (\d+)/m', $data, $auth); | ||
| + $callback((object)['sha' => $currentSha, 'message' => trim($message), 'author' => $auth[1] ?? 'Unknown', 'email' => $auth[2] ?? '', 'date' => (int)($auth[3] ?? 0)]); | ||
| + $currentSha = preg_match('/^parent ([0-9a-f]{40})$/m', $data, $m) ? $m[1] : null; | ||
| + $count++; | ||
| + } | ||
| + } | ||
| + | ||
| + public function stream(string $sha, callable $callback): void { | ||
| + $data = $this->read($sha); | ||
| + if ($data) $callback($data); | ||
| + } | ||
| + | ||
| + private function resolve(string $input): ?string { | ||
| + if (preg_match('/^[0-9a-f]{40}$/', $input)) return $input; | ||
| + if ($input === 'HEAD' && file_exists($h = "{$this->path}/HEAD")) { | ||
| + $head = trim(file_get_contents($h)); | ||
| + return (strpos($head, 'ref: ') === 0) ? $this->resolve(substr($head, 5)) : $head; | ||
| + } | ||
| + foreach ([$input, "refs/heads/$input", "refs/tags/$input"] as $p) { | ||
| + if (file_exists($f = "{$this->path}/$p")) return trim(file_get_contents($f)); | ||
| + } | ||
| + if (file_exists($packed = "{$this->path}/packed-refs")) { | ||
| + foreach (file($packed) as $line) { | ||
| + if ($line[0] === '#' || $line[0] === '^') continue; | ||
| + $parts = explode(' ', trim($line)); | ||
| + if (count($parts) >= 2 && ($parts[1] === $input || $parts[1] === "refs/heads/$input" || $parts[1] === "refs/tags/$input")) return $parts[0]; | ||
| + } | ||
| + } | ||
| + return null; | ||
| + } | ||
| + | ||
| + private function read(string $sha): ?string { | ||
| + $loose = "{$this->objPath}/" . substr($sha, 0, 2) . "/" . substr($sha, 2); | ||
| + if (file_exists($loose)) { | ||
| + $inflated = @gzuncompress(file_get_contents($loose)); | ||
| + return $inflated ? explode("\0", $inflated, 2)[1] : null; | ||
| + } | ||
| + return $this->fromPack($sha); | ||
| + } | ||
| + | ||
| + private function scanRefs(string $prefix, callable $callback): void { | ||
| + $dir = "{$this->path}/$prefix"; | ||
| + if (is_dir($dir)) { | ||
| + foreach (array_diff(scandir($dir), ['.', '..']) as $f) { | ||
| + $callback($f, trim(file_get_contents("$dir/$f"))); | ||
| + } | ||
| + } | ||
| + } | ||
| + | ||
| + private function fromPack(string $sha): ?string { | ||
| + $info = $this->getPackOffset($sha); | ||
| + if (!$info) return null; | ||
| + $pf = @fopen($info['file'], 'rb'); | ||
| + if (!$pf) return null; | ||
| + $data = $this->readPackEntry($pf, $info['offset']); | ||
| + fclose($pf); | ||
| + return $data; | ||
| + } | ||
| + | ||
| + private function readPackEntry($pf, int $offset): ?string { | ||
| + fseek($pf, $offset); | ||
| + $byte = ord(fread($pf, 1)); | ||
| + $type = ($byte >> 4) & 7; | ||
| + $size = $byte & 15; | ||
| + $shift = 4; | ||
| + while ($byte & 128) { | ||
| + $byte = ord(fread($pf, 1)); | ||
| + $size |= (($byte & 127) << $shift); | ||
| + $shift += 7; | ||
| + } | ||
| + | ||
| + // Type 6: OBJ_OFS_DELTA | ||
| + if ($type === 6) { | ||
| + $byte = ord(fread($pf, 1)); | ||
| + $negOffset = $byte & 127; | ||
| + while ($byte & 128) { | ||
| + $byte = ord(fread($pf, 1)); | ||
| + $negOffset = (($negOffset + 1) << 7) | ($byte & 127); | ||
| + } | ||
| + $baseOffset = $offset - $negOffset; | ||
| + $base = $this->readPackEntry($pf, $baseOffset); | ||
| + | ||
| + fseek($pf, $offset); | ||
| + $b = ord(fread($pf, 1)); | ||
| + while ($b & 128) { $b = ord(fread($pf, 1)); } | ||
| + $b = ord(fread($pf, 1)); | ||
| + while ($b & 128) { $b = ord(fread($pf, 1)); } | ||
| + | ||
| + $delta = @gzuncompress(fread($pf, 16777216)); | ||
| + return $this->applyDelta($base, $delta); | ||
| + } | ||
| + | ||
| + // Type 7: OBJ_REF_DELTA | ||
| + if ($type === 7) { | ||
| + $baseSha = bin2hex(fread($pf, 20)); | ||
| + $base = $this->read($baseSha); | ||
| + $delta = @gzuncompress(fread($pf, 16777216)); | ||
| + return $this->applyDelta($base, $delta); | ||
| + } | ||
| + | ||
| + return @gzuncompress(fread($pf, 16777216)); | ||
| + } | ||
| + | ||
| + private function applyDelta(?string $base, ?string $delta): string { | ||
| + if (!$base || !$delta) return ''; | ||
| + $pos = 0; | ||
| + // Skip Source Size | ||
| + $byte = ord($delta[$pos++]); | ||
| + while ($byte & 128) { $byte = ord($delta[$pos++]); } | ||
| + // Skip Target Size | ||
| + $byte = ord($delta[$pos++]); | ||
| + while ($byte & 128) { $byte = ord($delta[$pos++]); } | ||
| + | ||
| + $out = ''; | ||
| + while ($pos < strlen($delta)) { | ||
| + $opcode = ord($delta[$pos++]); | ||
| + if ($opcode & 128) { // Copy | ||
| + $off = 0; $len = 0; | ||
| + if ($opcode & 1) $off |= ord($delta[$pos++]); | ||
| + if ($opcode & 2) $off |= ord($delta[$pos++]) << 8; | ||
| + if ($opcode & 4) $off |= ord($delta[$pos++]) << 16; | ||
| + if ($opcode & 8) $off |= ord($delta[$pos++]) << 24; | ||
| + if ($opcode & 16) $len |= ord($delta[$pos++]); | ||
| + if ($opcode & 32) $len |= ord($delta[$pos++]) << 8; | ||
| + if ($opcode & 64) $len |= ord($delta[$pos++]) << 16; | ||
| + if ($len === 0) $len = 0x10000; | ||
| + $out .= substr($base, $off, $len); | ||
| + } else { // Insert | ||
| + $len = $opcode & 127; | ||
| + $out .= substr($delta, $pos, $len); | ||
| + $pos += $len; | ||
| + } | ||
| + } | ||
| + return $out; | ||
| + } | ||
| + | ||
| + private function getPackOffset(string $sha): ?array { | ||
| + $packs = glob("{$this->objPath}/pack/*.idx"); | ||
| + if (!$packs) return null; | ||
| + | ||
| + $binSha = hex2bin($sha); | ||
| + $firstByte = ord($binSha[0]); | ||
| + | ||
| + foreach ($packs as $idxFile) { | ||
| + $f = @fopen($idxFile, 'rb'); | ||
| + if (!$f) continue; | ||
| + | ||
| + $sig = fread($f, 4); | ||
| + $ver = unpack('N', fread($f, 4))[1]; | ||
| + if ($sig !== "\377tOc" || $ver !== 2) { fclose($f); continue; } | ||
| + | ||
| $fanoutOffset = 8; | ||
| if ($firstByte > 0) { |
| } | ||
| - // Shared Layout Logic | ||
| - protected function renderLayout($contentCallback, $currentRepo = 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 Config::SITE_TITLE . ($this->title ? ' - ' . htmlspecialchars($this->title) : ''); ?></title> | ||
| - <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | ||
| - <link rel="stylesheet" href="repo.css"> | ||
| - </head> | ||
| - <body> | ||
| - <div class="container"> | ||
| - <header> | ||
| - <h1><?php echo Config::SITE_TITLE; ?></h1> | ||
| - <nav class="nav"> | ||
| - <a href="?">Home</a> | ||
| - <?php if ($currentRepo): | ||
| - $safeName = urlencode($currentRepo['safe_name']); ?> | ||
| - <a href="?repo=<?php echo $safeName; ?>">Files</a> | ||
| - <a href="?action=commits&repo=<?php echo $safeName; ?>">Commits</a> | ||
| - <a href="?action=refs&repo=<?php echo $safeName; ?>">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 ($this->repositories as $r): ?> | ||
| - <option value="<?php echo htmlspecialchars($r['safe_name']); ?>" | ||
| - <?php echo $r['safe_name'] === $currentRepo['safe_name'] ? 'selected' : ''; ?>> | ||
| - <?php echo htmlspecialchars($r['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 call_user_func($contentCallback); ?> | ||
| - | ||
| - </div> | ||
| - </body> | ||
| - </html> | ||
| - <?php | ||
| - } | ||
| - | ||
| - protected function time_elapsed_string($timestamp) { | ||
| - if (!$timestamp) return 'never'; | ||
| - $diff = time() - $timestamp; | ||
| - if ($diff < 5) return 'just now'; | ||
| - $tokens = [31536000 => 'year', 2592000 => 'month', 604800 => 'week', 86400 => 'day', 3600 => 'hour', 60 => 'minute', 1 => 'second']; | ||
| - foreach ($tokens as $unit => $text) { | ||
| - if ($diff < $unit) continue; | ||
| - $num = floor($diff / $unit); | ||
| - return $num . ' ' . $text . (($num > 1) ? 's' : '') . ' ago'; | ||
| - } | ||
| - return 'just now'; | ||
| - } | ||
| -} | ||
| - | ||
| -class HomePage extends BasePage { | ||
| - public function render() { | ||
| - $this->renderLayout(function() { | ||
| - echo '<h2>Repositories</h2>'; | ||
| - if (empty($this->repositories)) { | ||
| - echo '<div class="empty-state">No repositories found in ' . htmlspecialchars(Config::getReposPath()) . '</div>'; | ||
| - return; | ||
| - } | ||
| - echo '<div class="repo-grid">'; | ||
| - foreach ($this->repositories as $repo) { | ||
| - $this->renderRepoCard($repo); | ||
| - } | ||
| - echo '</div>'; | ||
| - }); | ||
| - } | ||
| - | ||
| - private function renderRepoCard($repo) { | ||
| - $git = new Git($repo['path']); | ||
| - $main = $git->getMainBranch(); | ||
| - | ||
| - $stats = ['branches' => 0, 'tags' => 0]; | ||
| - $git->eachBranch(function() use (&$stats) { $stats['branches']++; }); | ||
| - $git->eachTag(function() use (&$stats) { $stats['tags']++; }); | ||
| - | ||
| - echo '<a href="?repo=' . urlencode($repo['safe_name']) . '" class="repo-card">'; | ||
| - echo '<h3>' . htmlspecialchars($repo['name']) . '</h3>'; | ||
| - if ($main) echo '<p>Branch: ' . htmlspecialchars($main['name']) . '</p>'; | ||
| - echo '<p>' . $stats['branches'] . ' branches, ' . $stats['tags'] . ' tags</p>'; | ||
| - | ||
| - if ($main) { | ||
| - $git->history('HEAD', 1, function($c) { | ||
| - echo '<p style="margin-top: 8px; color: #58a6ff;">' . $this->time_elapsed_string($c->date) . '</p>'; | ||
| - }); | ||
| - } | ||
| - echo '</a>'; | ||
| - } | ||
| -} | ||
| - | ||
| -class CommitsPage extends BasePage { | ||
| - private $currentRepo; | ||
| - private $git; | ||
| - private $hash; | ||
| - | ||
| - public function __construct($allRepos, $currentRepo, $git, $hash) { | ||
| - parent::__construct($allRepos); | ||
| - $this->currentRepo = $currentRepo; | ||
| - $this->git = $git; | ||
| - $this->hash = $hash; | ||
| - $this->title = $currentRepo['name']; | ||
| - } | ||
| - | ||
| - public function render() { | ||
| - $this->renderLayout(function() { | ||
| - $main = $this->git->getMainBranch(); | ||
| - if (!$main) { | ||
| - echo '<div class="empty-state"><h3>No branches</h3><p>Empty repository.</p></div>'; | ||
| - return; | ||
| - } | ||
| - | ||
| - $this->renderBreadcrumbs(); | ||
| - echo '<h2>Commit History <span class="branch-badge">' . htmlspecialchars($main['name']) . '</span></h2>'; | ||
| - echo '<div class="commit-list">'; | ||
| - | ||
| - $start = $this->hash ?: $main['hash']; | ||
| - $repoParam = '&repo=' . urlencode($this->currentRepo['safe_name']); | ||
| - | ||
| - $this->git->history($start, 100, function($commit) use ($repoParam) { | ||
| - $msg = htmlspecialchars(explode("\n", $commit->message)[0]); | ||
| - echo '<div class="commit-row">'; | ||
| - echo '<a href="?action=commit&hash=' . $commit->sha . $repoParam . '" class="sha">' . substr($commit->sha, 0, 7) . '</a>'; | ||
| - echo '<span class="message">' . $msg . '</span>'; | ||
| - echo '<span class="meta">' . htmlspecialchars($commit->author) . ' • ' . date('Y-m-d', $commit->date) . '</span>'; | ||
| - echo '</div>'; | ||
| - }); | ||
| - echo '</div>'; | ||
| - }, $this->currentRepo); | ||
| - } | ||
| - | ||
| - private function renderBreadcrumbs() { | ||
| - echo '<div class="breadcrumb">'; | ||
| - echo '<a href="?">Repositories</a><span>/</span>'; | ||
| - echo '<a href="?repo=' . urlencode($this->currentRepo['safe_name']) . '">' . htmlspecialchars($this->currentRepo['name']) . '</a><span>/</span>'; | ||
| - echo '<span>Commits</span></div>'; | ||
| - } | ||
| -} | ||
| - | ||
| -class FilePage extends BasePage { | ||
| - private $currentRepo; | ||
| - private $git; | ||
| - private $hash; | ||
| - | ||
| - public function __construct($allRepos, $currentRepo, $git, $hash) { | ||
| - parent::__construct($allRepos); | ||
| - $this->currentRepo = $currentRepo; | ||
| - $this->git = $git; | ||
| - $this->hash = $hash; | ||
| - $this->title = $currentRepo['name']; | ||
| - } | ||
| - | ||
| - public function render() { | ||
| - $this->renderLayout(function() { | ||
| - $main = $this->git->getMainBranch(); | ||
| - if (!$main) { | ||
| - echo '<div class="empty-state"><h3>No branches</h3></div>'; | ||
| - return; | ||
| - } | ||
| - | ||
| - $target = $this->hash ?: $main['hash']; | ||
| - $entries = []; | ||
| - $this->git->walk($target, function($e) use (&$entries) { | ||
| - $entries[] = (array)$e; | ||
| - }); | ||
| - | ||
| - if (!empty($entries)) { | ||
| - $this->renderTree($main, $target, $entries); | ||
| - } else { | ||
| - $this->renderBlob($target); | ||
| - } | ||
| - }, $this->currentRepo); | ||
| - } | ||
| - | ||
| - private function renderTree($main, $targetHash, $entries) { | ||
| - $this->renderBreadcrumbs($targetHash, 'Tree'); | ||
| - echo '<h2>' . htmlspecialchars($this->currentRepo['name']) . ' <span class="branch-badge">' . htmlspecialchars($main['name']) . '</span></h2>'; | ||
| - | ||
| - usort($entries, function($a, $b) { | ||
| - return ($b['isDir'] <=> $a['isDir']) ?: strcasecmp($a['name'], $b['name']); | ||
| - }); | ||
| - | ||
| - echo '<div class="file-list">'; | ||
| - $renderer = new HtmlFileRenderer($this->currentRepo['safe_name']); | ||
| - | ||
| - foreach ($entries as $e) { | ||
| - $size = $e['isDir'] ? 0 : $this->git->getObjectSize($e['sha']); | ||
| - | ||
| - $file = new File( | ||
| - $e['name'], | ||
| - $e['sha'], | ||
| - $e['mode'], | ||
| - 0, | ||
| - $size | ||
| - ); | ||
| - | ||
| - $file->render($renderer); | ||
| - } | ||
| - | ||
| - echo '</div>'; | ||
| - } | ||
| - | ||
| - private function renderBlob($targetHash) { | ||
| - $repoParam = '&repo=' . urlencode($this->currentRepo['safe_name']); | ||
| - | ||
| - // 1. Get size first (cheap operation) | ||
| - $size = $this->git->getObjectSize($targetHash); | ||
| - | ||
| - // 2. Sniff content type (requires reading data) | ||
| - // Note: This relies on the Git class reading the object. | ||
| - $buffer = ''; | ||
| - $this->git->stream($targetHash, function($d) use (&$buffer) { | ||
| - if (strlen($buffer) < 12) $buffer .= $d; | ||
| - }); | ||
| - | ||
| - $filename = $_GET['name'] ?? ''; | ||
| - $category = MediaTypeSniffer::isCategory($buffer, $filename); | ||
| - $mimeType = MediaTypeSniffer::isMediaType($buffer, $filename); | ||
| - | ||
| - $this->renderBreadcrumbs($targetHash, 'File'); | ||
| - | ||
| - // Calculate Raw URL for media sources | ||
| - $rawUrl = '?action=raw&hash=' . $targetHash . $repoParam . '&name=' . urlencode($filename); | ||
| - | ||
| - // 3. Render logic based on Category -> Size | ||
| - if ($category === MediaTypeSniffer::CAT_VIDEO) { | ||
| - // Allow Video Playback (Browser handles streaming via raw URL) | ||
| - echo '<div class="blob-content" style="text-align:center; padding: 20px; background: #000;">'; | ||
| - echo '<video controls style="max-width: 100%; max-height: 80vh;">'; | ||
| - echo '<source src="' . $rawUrl . '" type="' . $mimeType . '">'; | ||
| - echo 'Your browser does not support the video element.'; | ||
| - echo '</video>'; | ||
| - echo '</div>'; | ||
| - | ||
| - } elseif ($category === MediaTypeSniffer::CAT_AUDIO) { | ||
| - // Allow Audio Playback | ||
| - echo '<div class="blob-content" style="text-align:center; padding: 40px; background: #f6f8fa;">'; | ||
| - echo '<audio controls style="width: 100%; max-width: 600px;">'; | ||
| - echo '<source src="' . $rawUrl . '" type="' . $mimeType . '">'; | ||
| - echo 'Your browser does not support the audio element.'; | ||
| - echo '</audio>'; | ||
| - echo '</div>'; | ||
| - | ||
| - } elseif ($category === MediaTypeSniffer::CAT_IMAGE) { | ||
| - // Allow Image Viewing | ||
| - echo '<div class="blob-content" style="text-align:center; padding: 20px; background: #f6f8fa;">'; | ||
| - echo '<img src="' . $rawUrl . '" style="max-width: 100%; border: 1px solid #dfe2e5;">'; | ||
| - echo '</div>'; | ||
| - | ||
| - } elseif ($category === MediaTypeSniffer::CAT_TEXT) { | ||
| - // Text: Enforce 512KB Limit | ||
| - if ($size > 524288) { | ||
| - $this->renderDownloadState($targetHash, "File is too large to display (" . $this->formatSize($size) . ")."); | ||
| - } else { | ||
| - $content = ''; | ||
| - $this->git->stream($targetHash, function($d) use (&$content) { $content .= $d; }); | ||
| - echo '<div class="blob-content"><pre class="blob-code">' . htmlspecialchars($content) . '</pre></div>'; | ||
| - } | ||
| - | ||
| - } else { | ||
| - // Binary/Other: Download | ||
| - $this->renderDownloadState($targetHash, "This is a binary file."); | ||
| - } | ||
| - } | ||
| - | ||
| - private function renderDownloadState($hash, $reason) { | ||
| - $url = '?action=raw&hash=' . $hash . '&repo=' . urlencode($this->currentRepo['safe_name']); | ||
| - echo '<div class="empty-state" style="text-align: center; padding: 40px; border: 1px solid #e1e4e8; border-radius: 6px; margin-top: 10px;">'; | ||
| - echo '<p style="margin-bottom: 20px; color: #586069;">' . htmlspecialchars($reason) . '</p>'; | ||
| - echo '<a href="' . $url . '" style="display: inline-block; padding: 6px 16px; background: #0366d6; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Download Raw File</a>'; | ||
| - echo '</div>'; | ||
| - } | ||
| - | ||
| - private function formatSize($size) { | ||
| - if ($size <= 0) return '0 B'; | ||
| - $units = ['B', 'KB', 'MB', 'GB']; | ||
| - $i = (int)floor(log($size, 1024)); | ||
| - return round($size / pow(1024, $i), 1) . ' ' . $units[$i]; | ||
| - } | ||
| - | ||
| - private function renderBreadcrumbs($hash, $type) { | ||
| - echo '<div class="breadcrumb">'; | ||
| - echo '<a href="?">Repositories</a><span>/</span>'; | ||
| - echo '<a href="?repo=' . urlencode($this->currentRepo['safe_name']) . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>'; | ||
| - if ($this->hash) echo '<span>/</span><span>' . $type . ' ' . substr($hash, 0, 7) . '</span>'; | ||
| - echo '</div>'; | ||
| - } | ||
| -} | ||
| - | ||
| -class RawPage implements Page { | ||
| - private $git; | ||
| - private $hash; | ||
| - | ||
| - public function __construct($git, $hash) { | ||
| - $this->git = $git; | ||
| - $this->hash = $hash; | ||
| - } | ||
| - | ||
| - public function render() { | ||
| - // Clear any previous output buffering to ensure a clean binary stream | ||
| - while (ob_get_level()) ob_end_clean(); | ||
| - | ||
| - $size = $this->git->getObjectSize($this->hash); | ||
| - $filename = $_GET['name'] ?? 'file'; | ||
| - | ||
| - // Sniff MIME type (read first 12 bytes) | ||
| - $buffer = ''; | ||
| - $this->git->stream($this->hash, function($d) use (&$buffer) { | ||
| - if (strlen($buffer) < 12) $buffer .= $d; | ||
| - }); | ||
| - | ||
| - $mime = MediaTypeSniffer::isMediaType($buffer, $filename); | ||
| - if (!$mime) $mime = 'application/octet-stream'; | ||
| - | ||
| - // Headers for media playback | ||
| - header('Content-Type: ' . $mime); | ||
| - header('Content-Length: ' . $size); | ||
| - header('Content-Disposition: inline; filename="' . basename($filename) . '"'); | ||
| - | ||
| - // Output the file content | ||
| + protected function renderLayout($contentCallback, $currentRepo = 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 Config::SITE_TITLE . ($this->title ? ' - ' . htmlspecialchars($this->title) : ''); ?></title> | ||
| + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | ||
| + <link rel="stylesheet" href="repo.css"> | ||
| + </head> | ||
| + <body> | ||
| + <div class="container"> | ||
| + <header> | ||
| + <h1><?php echo Config::SITE_TITLE; ?></h1> | ||
| + <nav class="nav"> | ||
| + <a href="?">Home</a> | ||
| + <?php if ($currentRepo): | ||
| + $safeName = urlencode($currentRepo['safe_name']); ?> | ||
| + <a href="?repo=<?php echo $safeName; ?>">Files</a> | ||
| + <a href="?action=commits&repo=<?php echo $safeName; ?>">Commits</a> | ||
| + <a href="?action=refs&repo=<?php echo $safeName; ?>">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 ($this->repositories as $r): ?> | ||
| + <option value="<?php echo htmlspecialchars($r['safe_name']); ?>" | ||
| + <?php echo $r['safe_name'] === $currentRepo['safe_name'] ? 'selected' : ''; ?>> | ||
| + <?php echo htmlspecialchars($r['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 call_user_func($contentCallback); ?> | ||
| + | ||
| + </div> | ||
| + </body> | ||
| + </html> | ||
| + <?php | ||
| + } | ||
| + | ||
| + protected function time_elapsed_string($timestamp) { | ||
| + if (!$timestamp) return 'never'; | ||
| + $diff = time() - $timestamp; | ||
| + if ($diff < 5) return 'just now'; | ||
| + $tokens = [31536000 => 'year', 2592000 => 'month', 604800 => 'week', 86400 => 'day', 3600 => 'hour', 60 => 'minute', 1 => 'second']; | ||
| + foreach ($tokens as $unit => $text) { | ||
| + if ($diff < $unit) continue; | ||
| + $num = floor($diff / $unit); | ||
| + return $num . ' ' . $text . (($num > 1) ? 's' : '') . ' ago'; | ||
| + } | ||
| + return 'just now'; | ||
| + } | ||
| +} | ||
| + | ||
| +class HomePage extends BasePage { | ||
| + public function render() { | ||
| + $this->renderLayout(function() { | ||
| + echo '<h2>Repositories</h2>'; | ||
| + if (empty($this->repositories)) { | ||
| + echo '<div class="empty-state">No repositories found in ' . htmlspecialchars(Config::getReposPath()) . '</div>'; | ||
| + return; | ||
| + } | ||
| + echo '<div class="repo-grid">'; | ||
| + foreach ($this->repositories as $repo) { | ||
| + $this->renderRepoCard($repo); | ||
| + } | ||
| + echo '</div>'; | ||
| + }); | ||
| + } | ||
| + | ||
| + private function renderRepoCard($repo) { | ||
| + $git = new Git($repo['path']); | ||
| + $main = $git->getMainBranch(); | ||
| + | ||
| + $stats = ['branches' => 0, 'tags' => 0]; | ||
| + $git->eachBranch(function() use (&$stats) { $stats['branches']++; }); | ||
| + $git->eachTag(function() use (&$stats) { $stats['tags']++; }); | ||
| + | ||
| + echo '<a href="?repo=' . urlencode($repo['safe_name']) . '" class="repo-card">'; | ||
| + echo '<h3>' . htmlspecialchars($repo['name']) . '</h3>'; | ||
| + if ($main) echo '<p>Branch: ' . htmlspecialchars($main['name']) . '</p>'; | ||
| + echo '<p>' . $stats['branches'] . ' branches, ' . $stats['tags'] . ' tags</p>'; | ||
| + | ||
| + if ($main) { | ||
| + $git->history('HEAD', 1, function($c) { | ||
| + echo '<p style="margin-top: 8px; color: #58a6ff;">' . $this->time_elapsed_string($c->date) . '</p>'; | ||
| + }); | ||
| + } | ||
| + echo '</a>'; | ||
| + } | ||
| +} | ||
| + | ||
| +class CommitsPage extends BasePage { | ||
| + private $currentRepo; | ||
| + private $git; | ||
| + private $hash; | ||
| + | ||
| + public function __construct($allRepos, $currentRepo, $git, $hash) { | ||
| + parent::__construct($allRepos); | ||
| + $this->currentRepo = $currentRepo; | ||
| + $this->git = $git; | ||
| + $this->hash = $hash; | ||
| + $this->title = $currentRepo['name']; | ||
| + } | ||
| + | ||
| + public function render() { | ||
| + $this->renderLayout(function() { | ||
| + $main = $this->git->getMainBranch(); | ||
| + if (!$main) { | ||
| + echo '<div class="empty-state"><h3>No branches</h3><p>Empty repository.</p></div>'; | ||
| + return; | ||
| + } | ||
| + | ||
| + $this->renderBreadcrumbs(); | ||
| + echo '<h2>Commit History <span class="branch-badge">' . htmlspecialchars($main['name']) . '</span></h2>'; | ||
| + echo '<div class="commit-list">'; | ||
| + | ||
| + $start = $this->hash ?: $main['hash']; | ||
| + $repoParam = '&repo=' . urlencode($this->currentRepo['safe_name']); | ||
| + | ||
| + $this->git->history($start, 100, function($commit) use ($repoParam) { | ||
| + $msg = htmlspecialchars(explode("\n", $commit->message)[0]); | ||
| + echo '<div class="commit-row">'; | ||
| + echo '<a href="?action=commit&hash=' . $commit->sha . $repoParam . '" class="sha">' . substr($commit->sha, 0, 7) . '</a>'; | ||
| + echo '<span class="message">' . $msg . '</span>'; | ||
| + echo '<span class="meta">' . htmlspecialchars($commit->author) . ' • ' . date('Y-m-d', $commit->date) . '</span>'; | ||
| + echo '</div>'; | ||
| + }); | ||
| + echo '</div>'; | ||
| + }, $this->currentRepo); | ||
| + } | ||
| + | ||
| + private function renderBreadcrumbs() { | ||
| + echo '<div class="breadcrumb">'; | ||
| + echo '<a href="?">Repositories</a><span>/</span>'; | ||
| + echo '<a href="?repo=' . urlencode($this->currentRepo['safe_name']) . '">' . htmlspecialchars($this->currentRepo['name']) . '</a><span>/</span>'; | ||
| + echo '<span>Commits</span></div>'; | ||
| + } | ||
| +} | ||
| + | ||
| +class FilePage extends BasePage { | ||
| + private $currentRepo; | ||
| + private $git; | ||
| + private $hash; | ||
| + | ||
| + public function __construct($allRepos, $currentRepo, $git, $hash) { | ||
| + parent::__construct($allRepos); | ||
| + $this->currentRepo = $currentRepo; | ||
| + $this->git = $git; | ||
| + $this->hash = $hash; | ||
| + $this->title = $currentRepo['name']; | ||
| + } | ||
| + | ||
| + public function render() { | ||
| + $this->renderLayout(function() { | ||
| + $main = $this->git->getMainBranch(); | ||
| + if (!$main) { | ||
| + echo '<div class="empty-state"><h3>No branches</h3></div>'; | ||
| + return; | ||
| + } | ||
| + | ||
| + $target = $this->hash ?: $main['hash']; | ||
| + $entries = []; | ||
| + | ||
| + // Entries are now File objects | ||
| + $this->git->walk($target, function($file) use (&$entries) { | ||
| + $entries[] = $file; | ||
| + }); | ||
| + | ||
| + if (!empty($entries)) { | ||
| + $this->renderTree($main, $target, $entries); | ||
| + } else { | ||
| + $this->renderBlob($target); | ||
| + } | ||
| + }, $this->currentRepo); | ||
| + } | ||
| + | ||
| + private function renderTree($main, $targetHash, $entries) { | ||
| + $this->renderBreadcrumbs($targetHash, 'Tree'); | ||
| + echo '<h2>' . htmlspecialchars($this->currentRepo['name']) . ' <span class="branch-badge">' . htmlspecialchars($main['name']) . '</span></h2>'; | ||
| + | ||
| + // Encapsulated sorting via File::compare | ||
| + usort($entries, function($a, $b) { | ||
| + return $a->compare($b); | ||
| + }); | ||
| + | ||
| + echo '<div class="file-list">'; | ||
| + $renderer = new HtmlFileRenderer($this->currentRepo['safe_name']); | ||
| + | ||
| + foreach ($entries as $file) { | ||
| + $file->render($renderer); | ||
| + } | ||
| + | ||
| + echo '</div>'; | ||
| + } | ||
| + | ||
| + private function renderBlob($targetHash) { | ||
| + $repoParam = '&repo=' . urlencode($this->currentRepo['safe_name']); | ||
| + | ||
| + $size = $this->git->getObjectSize($targetHash); | ||
| + | ||
| + $buffer = ''; | ||
| + $this->git->stream($targetHash, function($d) use (&$buffer) { | ||
| + if (strlen($buffer) < 12) $buffer .= $d; | ||
| + }); | ||
| + | ||
| + $filename = $_GET['name'] ?? ''; | ||
| + $category = MediaTypeSniffer::isCategory($buffer, $filename); | ||
| + $mimeType = MediaTypeSniffer::isMediaType($buffer, $filename); | ||
| + | ||
| + $this->renderBreadcrumbs($targetHash, 'File'); | ||
| + | ||
| + $rawUrl = '?action=raw&hash=' . $targetHash . $repoParam . '&name=' . urlencode($filename); | ||
| + | ||
| + if ($category === MediaTypeSniffer::CAT_VIDEO) { | ||
| + echo '<div class="blob-content" style="text-align:center; padding: 20px; background: #000;">'; | ||
| + echo '<video controls style="max-width: 100%; max-height: 80vh;">'; | ||
| + echo '<source src="' . $rawUrl . '" type="' . $mimeType . '">'; | ||
| + echo 'Your browser does not support the video element.'; | ||
| + echo '</video>'; | ||
| + echo '</div>'; | ||
| + | ||
| + } elseif ($category === MediaTypeSniffer::CAT_AUDIO) { | ||
| + echo '<div class="blob-content" style="text-align:center; padding: 40px; background: #f6f8fa;">'; | ||
| + echo '<audio controls style="width: 100%; max-width: 600px;">'; | ||
| + echo '<source src="' . $rawUrl . '" type="' . $mimeType . '">'; | ||
| + echo 'Your browser does not support the audio element.'; | ||
| + echo '</audio>'; | ||
| + echo '</div>'; | ||
| + | ||
| + } elseif ($category === MediaTypeSniffer::CAT_IMAGE) { | ||
| + echo '<div class="blob-content" style="text-align:center; padding: 20px; background: #f6f8fa;">'; | ||
| + echo '<img src="' . $rawUrl . '" style="max-width: 100%; border: 1px solid #dfe2e5;">'; | ||
| + echo '</div>'; | ||
| + | ||
| + } elseif ($category === MediaTypeSniffer::CAT_TEXT) { | ||
| + if ($size > 524288) { | ||
| + $this->renderDownloadState($targetHash, "File is too large to display (" . $this->formatSize($size) . ")."); | ||
| + } else { | ||
| + $content = ''; | ||
| + $this->git->stream($targetHash, function($d) use (&$content) { $content .= $d; }); | ||
| + echo '<div class="blob-content"><pre class="blob-code">' . htmlspecialchars($content) . '</pre></div>'; | ||
| + } | ||
| + | ||
| + } else { | ||
| + $this->renderDownloadState($targetHash, "This is a binary file."); | ||
| + } | ||
| + } | ||
| + | ||
| + private function renderDownloadState($hash, $reason) { | ||
| + $url = '?action=raw&hash=' . $hash . '&repo=' . urlencode($this->currentRepo['safe_name']); | ||
| + echo '<div class="empty-state" style="text-align: center; padding: 40px; border: 1px solid #e1e4e8; border-radius: 6px; margin-top: 10px;">'; | ||
| + echo '<p style="margin-bottom: 20px; color: #586069;">' . htmlspecialchars($reason) . '</p>'; | ||
| + echo '<a href="' . $url . '" style="display: inline-block; padding: 6px 16px; background: #0366d6; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Download Raw File</a>'; | ||
| + echo '</div>'; | ||
| + } | ||
| + | ||
| + private function formatSize($size) { | ||
| + if ($size <= 0) return '0 B'; | ||
| + $units = ['B', 'KB', 'MB', 'GB']; | ||
| + $i = (int)floor(log($size, 1024)); | ||
| + return round($size / pow(1024, $i), 1) . ' ' . $units[$i]; | ||
| + } | ||
| + | ||
| + private function renderBreadcrumbs($hash, $type) { | ||
| + echo '<div class="breadcrumb">'; | ||
| + echo '<a href="?">Repositories</a><span>/</span>'; | ||
| + echo '<a href="?repo=' . urlencode($this->currentRepo['safe_name']) . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>'; | ||
| + if ($this->hash) echo '<span>/</span><span>' . $type . ' ' . substr($hash, 0, 7) . '</span>'; | ||
| + echo '</div>'; | ||
| + } | ||
| +} | ||
| + | ||
| +class RawPage implements Page { | ||
| + private $git; | ||
| + private $hash; | ||
| + | ||
| + public function __construct($git, $hash) { | ||
| + $this->git = $git; | ||
| + $this->hash = $hash; | ||
| + } | ||
| + | ||
| + public function render() { | ||
| + while (ob_get_level()) ob_end_clean(); | ||
| + | ||
| + $size = $this->git->getObjectSize($this->hash); | ||
| + $filename = $_GET['name'] ?? 'file'; | ||
| + | ||
| + $buffer = ''; | ||
| + $this->git->stream($this->hash, function($d) use (&$buffer) { | ||
| + if (strlen($buffer) < 12) $buffer .= $d; | ||
| + }); | ||
| + | ||
| + $mime = MediaTypeSniffer::isMediaType($buffer, $filename); | ||
| + if (!$mime) $mime = 'application/octet-stream'; | ||
| + | ||
| + header('Content-Type: ' . $mime); | ||
| + header('Content-Length: ' . $size); | ||
| + header('Content-Disposition: inline; filename="' . basename($filename) . '"'); | ||
| + | ||
| $this->git->stream($this->hash, function($data) { | ||
| echo $data; |
| Delta | 646 lines added, 657 lines removed, 11-line decrease |
|---|