Dave Jarvis' Repositories

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

Emits File instances

Author Dave Jarvis <email>
Date 2026-02-08 22:21:27 GMT-0800
Commit e6216b58df3b88bf73bbb1d6b4d01984bebaf6b9
Parent e6a99b7
new/File.php
}
+ // 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 {
new/Git.php
<?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) {
new/Views.php
}
- // 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) . ' &bull; ' . 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) . ' &bull; ' . 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