Dave Jarvis' Repositories

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

Revises URLs to use paths instead of parameters

AuthorDave Jarvis <email>
Date2026-02-15 22:06:36 GMT-0800
Commit5b92e6a6e796551d97197c995d693b01a9d0889b
Parent90a5669
Router.php
private $repos = [];
private $git;
+ private $basePath;
public function __construct( string $reposPath ) {
$this->git = new Git( $reposPath );
$list = new RepositoryList( $reposPath );
$list->eachRepository( function( $repo ) {
- $this->repos[] = $repo;
+ $this->repos[$repo['safe_name']] = $repo;
} );
}
public function route(): Page {
- $reqRepo = $_GET['repo'] ?? '';
- $action = $_GET['action'] ?? 'file';
- $hash = $this->sanitize( $_GET['hash'] ?? '' );
- $subPath = '';
$uri = parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH );
- $scriptName = $_SERVER['SCRIPT_NAME'];
- if( strpos( $uri, $scriptName ) === 0 ) {
+ $scriptName = dirname( $_SERVER['SCRIPT_NAME'] );
+
+ if( $scriptName !== '/' && strpos( $uri, $scriptName ) === 0 ) {
$uri = substr( $uri, strlen( $scriptName ) );
}
- if( preg_match( '#^/([^/]+)\.git(?:/(.*))?$#', $uri, $matches ) ) {
- $reqRepo = urldecode( $matches[1] );
- $subPath = isset( $matches[2] ) ? ltrim( $matches[2], '/' ) : '';
- $action = 'clone';
+ $uri = trim( $uri, '/' );
+
+ if( empty( $uri ) ) {
+ return new HomePage( $this->repos, $this->git );
}
- $currRepo = null;
- $decoded = urldecode( $reqRepo );
+ $parts = explode( '/', $uri );
+ $repoName = array_shift( $parts );
- foreach( $this->repos as $repo ) {
- if( $repo['safe_name'] === $reqRepo || $repo['name'] === $decoded ) {
- $currRepo = $repo;
+ if( str_ends_with( $repoName, '.git' ) ) {
+ $realName = substr( $repoName, 0, -4 );
- break;
+ if( isset( $this->repos[$realName] ) ) {
+ $subPath = implode( '/', $parts );
+ $this->git->setRepository( $this->repos[$realName]['path'] );
+
+ return new ClonePage( $this->git, $subPath );
}
+ }
- $prefix = $repo['safe_name'] . '/';
+ if( !isset( $this->repos[$repoName] ) ) {
+ return new HomePage( $this->repos, $this->git );
+ }
- if( strpos( $reqRepo, $prefix ) === 0 ) {
- $currRepo = $repo;
- $subPath = substr( $reqRepo, strlen( $prefix ) );
- $action = 'clone';
+ $currRepo = $this->repos[$repoName];
+ $this->git->setRepository( $currRepo['path'] );
- break;
- }
+ $action = array_shift( $parts );
+
+ if( empty( $action ) ) {
+ $action = 'tree';
}
- if( $currRepo ) {
- $this->git->setRepository( $currRepo['path'] );
+ $hash = '';
+ $path = '';
+
+ if( in_array( $action, ['tree', 'blob', 'raw', 'commits'] ) ) {
+ $hash = array_shift( $parts ) ?? 'HEAD';
+ $path = implode( '/', $parts );
+ }
+ elseif( $action === 'commit' ) {
+ $hash = array_shift( $parts );
}
- $routes = [
- 'home' => fn() => new HomePage( $this->repos, $this->git ),
- 'file' => fn() => new FilePage( $this->repos, $currRepo, $this->git, $hash ),
- 'raw' => fn() => new RawPage( $this->git, $hash ),
- 'commit' => fn() => new DiffPage( $this->repos, $currRepo, $this->git, $hash ),
- 'commits' => fn() => new CommitsPage( $this->repos, $currRepo, $this->git, $hash ),
- 'tags' => fn() => new TagsPage( $this->repos, $currRepo, $this->git ),
- 'clone' => fn() => new ClonePage( $this->git, $subPath ),
- ];
+ $_GET['repo'] = $repoName;
+ $_GET['action'] = $action;
+ $_GET['hash'] = $hash;
+ $_GET['name'] = $path;
- $action = !$currRepo ? 'home' : $action;
+ switch( $action ) {
+ case 'tree':
+ case 'blob':
+ return new FilePage( $this->repos, $currRepo, $this->git, $hash, $path );
- return ($routes[$action] ?? $routes['file'])();
- }
+ case 'raw':
+ return new RawPage( $this->git, $hash );
- private function sanitize( $path ) {
- $path = str_replace( [ '..', '\\', "\0" ], [ '', '/', '' ], $path );
+ case 'commits':
+ return new CommitsPage( $this->repos, $currRepo, $this->git, $hash );
- return preg_replace( '/[^a-zA-Z0-9_\-\.\/]/', '', $path );
+ case 'commit':
+ return new DiffPage( $this->repos, $currRepo, $this->git, $hash );
+
+ case 'tags':
+ return new TagsPage( $this->repos, $currRepo, $this->git );
+
+ default:
+ return new FilePage( $this->repos, $currRepo, $this->git, 'HEAD', '' );
+ }
}
}
UrlBuilder.php
public function build() {
if( $this->switcher ) {
- $url = "window.location.href='?repo=' + encodeURIComponent(" .
- $this->switcher . ")";
- } else {
- $params = [];
+ return "window.location.href='/' + " . $this->switcher;
+ }
- if( $this->repo ) {
- $params['repo'] = $this->repo;
- }
+ if( !$this->repo ) {
+ return '/';
+ }
- if( $this->action ) {
- $params['action'] = $this->action;
- }
+ $url = '/' . $this->repo;
- if( $this->hash ) {
- $params['hash'] = $this->hash;
- }
+ if( !$this->action && $this->name ) {
+ $this->action = 'tree';
+ }
- if( $this->name ) {
- $params['name'] = $this->name;
+ if( $this->action ) {
+ $url .= '/' . $this->action;
+
+ if( $this->hash ) {
+ $url .= '/' . $this->hash;
+ } elseif( in_array( $this->action, ['tree', 'blob', 'raw', 'commits'] ) ) {
+ $url .= '/HEAD';
}
+ }
- $url = empty( $params ) ? '?' : '?' . http_build_query( $params );
+ if( $this->name ) {
+ $url .= '/' . ltrim( $this->name, '/' );
}
git/Git.php
}
- private function parseTagData(
- string $name,
- string $sha,
- string $data
- ): Tag {
- $isAnnotated = strncmp( $data, 'object ', 7 ) === 0;
-
- $targetSha = $isAnnotated
- ? $this->extractPattern(
- $data,
- '/^object ([0-9a-f]{40})$/m',
- 1,
- $sha
- )
- : $sha;
-
- $pattern = $isAnnotated
- ? '/^tagger (.*) <(.*)> (\d+) [+\-]\d{4}$/m'
- : '/^author (.*) <(.*)> (\d+) [+\-]\d{4}$/m';
-
- $identity = $this->parseIdentity( $data, $pattern );
- $message = $this->extractMessage( $data );
-
- return new Tag(
- $name,
- $sha,
- $targetSha,
- $identity['timestamp'],
- $message,
- $identity['name']
- );
- }
-
- private function extractPattern(
- string $data,
- string $pattern,
- int $group,
- string $default = ''
- ): string {
- $matches = [];
-
- $result = preg_match( $pattern, $data, $matches )
- ? $matches[$group]
- : $default;
-
- return $result;
- }
-
- private function parseIdentity( string $data, string $pattern ): array {
- $matches = [];
- $found = preg_match( $pattern, $data, $matches );
-
- return [
- 'name' => $found ? trim( $matches[1] ) : 'Unknown',
- 'email' => $found ? $matches[2] : '',
- 'timestamp' => $found ? (int)$matches[3] : 0
- ];
- }
-
- private function extractMessage( string $data ): string {
- $pos = strpos( $data, "\n\n" );
-
- return $pos !== false ? trim( substr( $data, $pos + 2 ) ) : '';
- }
-
- public function getObjectSize( string $sha ): int {
- $size = $this->packs->getSize( $sha );
-
- return $size !== null ? $size : $this->getLooseObjectSize( $sha );
- }
-
- public function peek( string $sha, int $length = 255 ): string {
- $size = $this->packs->getSize( $sha );
-
- return $size === null
- ? $this->peekLooseObject( $sha, $length )
- : $this->packs->peek( $sha, $length ) ?? '';
- }
-
- public function read( string $sha ): string {
- $size = $this->getObjectSize( $sha );
-
- if( $size > self::MAX_READ_SIZE ) {
- return '';
- }
-
- $content = '';
-
- $this->slurp( $sha, function( $chunk ) use ( &$content ) {
- $content .= $chunk;
- } );
-
- return $content;
- }
-
- public function readFile( string $hash, string $name ) {
- return new File(
- $name,
- $hash,
- '100644',
- 0,
- $this->getObjectSize( $hash ),
- $this->peek( $hash )
- );
- }
-
- public function stream( string $sha, callable $callback ): void {
- $this->slurp( $sha, $callback );
- }
-
- private function slurp( string $sha, callable $callback ): void {
- $loosePath = $this->getLoosePath( $sha );
-
- if( is_file( $loosePath ) ) {
- $this->slurpLooseObject( $loosePath, $callback );
- } else {
- $this->slurpPackedObject( $sha, $callback );
- }
- }
-
- private function iterateInflated( string $path, callable $processor ): void {
- $this->withInflatedFile(
- $path,
- function( $fileHandle, $inflator ) use ( $processor ) {
- $headerFound = false;
- $buffer = '';
-
- while( !feof( $fileHandle ) ) {
- $chunk = fread( $fileHandle, 16384 );
- $inflated = inflate_add( $inflator, $chunk );
-
- if( $inflated === false ) {
- break;
- }
-
- if( !$headerFound ) {
- $buffer .= $inflated;
- $nullPos = strpos( $buffer, "\0" );
-
- if( $nullPos !== false ) {
- $headerFound = true;
- $header = substr( $buffer, 0, $nullPos );
- $body = substr( $buffer, $nullPos + 1 );
-
- if( $processor( $body, $header ) === false ) {
- return;
- }
- }
- } else {
- if( $processor( $inflated, null ) === false ) {
- return;
- }
- }
- }
- }
- );
- }
-
- private function slurpLooseObject(
- string $path,
- callable $callback
- ): void {
- $this->iterateInflated(
- $path,
- function( $chunk ) use ( $callback ) {
- if( $chunk !== '' ) {
- $callback( $chunk );
- }
- return true;
- }
- );
- }
-
- private function withInflatedFile( string $path, callable $callback ): void {
- $fileHandle = fopen( $path, 'rb' );
- $inflator = $fileHandle ? inflate_init( ZLIB_ENCODING_DEFLATE ) : null;
-
- if( $fileHandle && $inflator ) {
- $callback( $fileHandle, $inflator );
- fclose( $fileHandle );
- }
- }
-
- private function slurpPackedObject(
- string $sha,
- callable $callback
- ): void {
- $streamed = $this->packs->stream( $sha, $callback );
-
- if( !$streamed ) {
- $data = $this->packs->read( $sha );
-
- if( $data !== null && $data !== '' ) {
- $callback( $data );
- }
- }
- }
-
- private function peekLooseObject( string $sha, int $length ): string {
- $path = $this->getLoosePath( $sha );
-
- return is_file( $path )
- ? $this->inflateLooseObjectPrefix( $path, $length )
- : '';
- }
-
- private function inflateLooseObjectPrefix(
- string $path,
- int $length
- ): string {
- $buffer = '';
-
- $this->iterateInflated(
- $path,
- function( $chunk ) use ( $length, &$buffer ) {
- $buffer .= $chunk;
- return strlen( $buffer ) < $length;
- }
- );
-
- return substr( $buffer, 0, $length );
- }
-
- public function history( string $ref, int $limit, callable $callback ): void {
- $currentSha = $this->resolve( $ref );
- $count = 0;
-
- while( $currentSha !== '' && $count < $limit ) {
- $commit = $this->parseCommit( $currentSha );
-
- if( $commit === null ) {
- break;
- }
-
- $callback( $commit );
- $currentSha = $commit->parentSha;
- $count++;
- }
- }
-
- private function parseCommit( string $sha ): ?object {
- $data = $this->read( $sha );
-
- return $data === '' ? null : $this->buildCommitObject( $sha, $data );
- }
-
- private function buildCommitObject( string $sha, string $data ): object {
- $identity = $this->parseIdentity( $data, '/^author (.*) <(.*)> (\d+)/m' );
- $message = $this->extractMessage( $data );
- $parentSha = $this->extractPattern(
- $data,
- '/^parent ([0-9a-f]{40})$/m',
- 1
- );
-
- return (object)[
- 'sha' => $sha,
- 'message' => $message,
- 'author' => $identity['name'],
- 'email' => $identity['email'],
- 'date' => $identity['timestamp'],
- 'parentSha' => $parentSha
- ];
- }
-
- public function walk( string $refOrSha, callable $callback ): void {
- $sha = $this->resolve( $refOrSha );
-
- if( $sha !== '' ) {
- $this->walkTree( $sha, $callback );
- }
- }
+ public function walk( string $refOrSha, callable $callback, string $path = '' ): void {
+ $sha = $this->resolve( $refOrSha );
+
+ if( $sha === '' ) return;
+
+ $treeSha = $this->getTreeSha( $sha );
+
+ if( $path !== '' ) {
+ $info = $this->resolvePath( $treeSha, $path );
+
+ if( !$info || !$info['isDir'] ) {
+ return;
+ }
+
+ $treeSha = $info['sha'];
+ }
+
+ if( $treeSha ) {
+ $this->walkTree( $treeSha, $callback );
+ }
+ }
+
+ public function readFile( string $ref, string $path ): ?File {
+ $sha = $this->resolve( $ref );
+ if( $sha === '' ) return null;
+
+ $treeSha = $this->getTreeSha( $sha );
+ $info = $this->resolvePath( $treeSha, $path );
+
+ if( !$info ) {
+ return null;
+ }
+
+ $size = $this->getObjectSize( $info['sha'] );
+ $content = $info['isDir'] ? '' : $this->peek( $info['sha'] );
+
+ return new File(
+ basename( $path ),
+ $info['sha'],
+ $info['mode'],
+ 0,
+ $size,
+ $content
+ );
+ }
+
+ public function getObjectSize( string $sha, string $path = '' ): int {
+ if( $path !== '' ) {
+ $rootSha = $this->resolve( $sha );
+ $treeSha = $this->getTreeSha( $rootSha );
+ $info = $this->resolvePath( $treeSha, $path );
+ $sha = $info ? $info['sha'] : '';
+ }
+
+ if( $sha === '' ) return 0;
+
+ $size = $this->packs->getSize( $sha );
+ return $size !== null ? $size : $this->getLooseObjectSize( $sha );
+ }
+
+ public function stream( string $sha, callable $callback, string $path = '' ): void {
+ if( $path !== '' ) {
+ $rootSha = $this->resolve( $sha );
+ $treeSha = $this->getTreeSha( $rootSha );
+ $info = $this->resolvePath( $treeSha, $path );
+ $sha = $info ? $info['sha'] : '';
+ }
+
+ if( $sha !== '' ) {
+ $this->slurp( $sha, $callback );
+ }
+ }
+
+ private function getTreeSha( string $commitOrTreeSha ): string {
+ $data = $this->read( $commitOrTreeSha );
+
+ if( preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches ) ) {
+ return $matches[1];
+ }
+
+ return $commitOrTreeSha;
+ }
+
+ private function resolvePath( string $treeSha, string $path ): ?array {
+ $parts = explode( '/', trim( $path, '/' ) );
+ $currentSha = $treeSha;
+ $currentMode = '40000';
+
+ foreach( $parts as $part ) {
+ if( $part === '' ) continue;
+
+ $entry = $this->findTreeEntry( $currentSha, $part );
+
+ if( !$entry ) {
+ return null;
+ }
+
+ $currentSha = $entry['sha'];
+ $currentMode = $entry['mode'];
+ }
+
+ $isDir = $currentMode === '40000' || $currentMode === '040000';
+
+ return [
+ 'sha' => $currentSha,
+ 'mode' => $currentMode,
+ 'isDir' => $isDir
+ ];
+ }
+
+ private function findTreeEntry( string $treeSha, string $name ): ?array {
+ $data = $this->read( $treeSha );
+ $position = 0;
+ $length = strlen( $data );
+
+ while( $position < $length ) {
+ // Inline parsing for speed
+ $spacePos = strpos( $data, ' ', $position );
+ $nullPos = strpos( $data, "\0", $spacePos );
+
+ if( $spacePos === false || $nullPos === false ) break;
+
+ $entryName = substr( $data, $spacePos + 1, $nullPos - $spacePos - 1 );
+
+ if( $entryName === $name ) {
+ $mode = substr( $data, $position, $spacePos - $position );
+ $sha = bin2hex( substr( $data, $nullPos + 1, 20 ) );
+ return ['sha' => $sha, 'mode' => $mode];
+ }
+
+ $position = $nullPos + 21;
+ }
+
+ return null;
+ }
+
+ private function parseTagData(
+ string $name,
+ string $sha,
+ string $data
+ ): Tag {
+ $isAnnotated = strncmp( $data, 'object ', 7 ) === 0;
+
+ $targetSha = $isAnnotated
+ ? $this->extractPattern(
+ $data,
+ '/^object ([0-9a-f]{40})$/m',
+ 1,
+ $sha
+ )
+ : $sha;
+
+ $pattern = $isAnnotated
+ ? '/^tagger (.*) <(.*)> (\d+) [+\-]\d{4}$/m'
+ : '/^author (.*) <(.*)> (\d+) [+\-]\d{4}$/m';
+
+ $identity = $this->parseIdentity( $data, $pattern );
+ $message = $this->extractMessage( $data );
+
+ return new Tag(
+ $name,
+ $sha,
+ $targetSha,
+ $identity['timestamp'],
+ $message,
+ $identity['name']
+ );
+ }
+
+ private function extractPattern(
+ string $data,
+ string $pattern,
+ int $group,
+ string $default = ''
+ ): string {
+ $matches = [];
+
+ $result = preg_match( $pattern, $data, $matches )
+ ? $matches[$group]
+ : $default;
+
+ return $result;
+ }
+
+ private function parseIdentity( string $data, string $pattern ): array {
+ $matches = [];
+ $found = preg_match( $pattern, $data, $matches );
+
+ return [
+ 'name' => $found ? trim( $matches[1] ) : 'Unknown',
+ 'email' => $found ? $matches[2] : '',
+ 'timestamp' => $found ? (int)$matches[3] : 0
+ ];
+ }
+
+ private function extractMessage( string $data ): string {
+ $pos = strpos( $data, "\n\n" );
+
+ return $pos !== false ? trim( substr( $data, $pos + 2 ) ) : '';
+ }
+
+ public function peek( string $sha, int $length = 255 ): string {
+ $size = $this->packs->getSize( $sha );
+
+ return $size === null
+ ? $this->peekLooseObject( $sha, $length )
+ : $this->packs->peek( $sha, $length ) ?? '';
+ }
+
+ public function read( string $sha ): string {
+ $size = $this->getObjectSize( $sha );
+
+ if( $size > self::MAX_READ_SIZE ) {
+ return '';
+ }
+
+ $content = '';
+
+ $this->slurp( $sha, function( $chunk ) use ( &$content ) {
+ $content .= $chunk;
+ } );
+
+ return $content;
+ }
+
+ private function slurp( string $sha, callable $callback ): void {
+ $loosePath = $this->getLoosePath( $sha );
+
+ if( is_file( $loosePath ) ) {
+ $this->slurpLooseObject( $loosePath, $callback );
+ } else {
+ $this->slurpPackedObject( $sha, $callback );
+ }
+ }
+
+ private function iterateInflated( string $path, callable $processor ): void {
+ $this->withInflatedFile(
+ $path,
+ function( $fileHandle, $inflator ) use ( $processor ) {
+ $headerFound = false;
+ $buffer = '';
+
+ while( !feof( $fileHandle ) ) {
+ $chunk = fread( $fileHandle, 16384 );
+ $inflated = inflate_add( $inflator, $chunk );
+
+ if( $inflated === false ) {
+ break;
+ }
+
+ if( !$headerFound ) {
+ $buffer .= $inflated;
+ $nullPos = strpos( $buffer, "\0" );
+
+ if( $nullPos !== false ) {
+ $headerFound = true;
+ $header = substr( $buffer, 0, $nullPos );
+ $body = substr( $buffer, $nullPos + 1 );
+
+ if( $processor( $body, $header ) === false ) {
+ return;
+ }
+ }
+ } else {
+ if( $processor( $inflated, null ) === false ) {
+ return;
+ }
+ }
+ }
+ }
+ );
+ }
+
+ private function slurpLooseObject(
+ string $path,
+ callable $callback
+ ): void {
+ $this->iterateInflated(
+ $path,
+ function( $chunk ) use ( $callback ) {
+ if( $chunk !== '' ) {
+ $callback( $chunk );
+ }
+
+ return true;
+ }
+ );
+ }
+
+ private function withInflatedFile( string $path, callable $callback ): void {
+ $fileHandle = fopen( $path, 'rb' );
+ $inflator = $fileHandle ? inflate_init( ZLIB_ENCODING_DEFLATE ) : null;
+
+ if( $fileHandle && $inflator ) {
+ $callback( $fileHandle, $inflator );
+ fclose( $fileHandle );
+ }
+ }
+
+ private function slurpPackedObject(
+ string $sha,
+ callable $callback
+ ): void {
+ $streamed = $this->packs->stream( $sha, $callback );
+
+ if( !$streamed ) {
+ $data = $this->packs->read( $sha );
+
+ if( $data !== null && $data !== '' ) {
+ $callback( $data );
+ }
+ }
+ }
+
+ private function peekLooseObject( string $sha, int $length ): string {
+ $path = $this->getLoosePath( $sha );
+
+ return is_file( $path )
+ ? $this->inflateLooseObjectPrefix( $path, $length )
+ : '';
+ }
+
+ private function inflateLooseObjectPrefix(
+ string $path,
+ int $length
+ ): string {
+ $buffer = '';
+
+ $this->iterateInflated(
+ $path,
+ function( $chunk ) use ( $length, &$buffer ) {
+ $buffer .= $chunk;
+ return strlen( $buffer ) < $length;
+ }
+ );
+
+ return substr( $buffer, 0, $length );
+ }
+
+ public function history( string $ref, int $limit, callable $callback ): void {
+ $currentSha = $this->resolve( $ref );
+ $count = 0;
+
+ while( $currentSha !== '' && $count < $limit ) {
+ $commit = $this->parseCommit( $currentSha );
+
+ if( $commit === null ) {
+ break;
+ }
+
+ $callback( $commit );
+ $currentSha = $commit->parentSha;
+ $count++;
+ }
+ }
+
+ private function parseCommit( string $sha ): ?object {
+ $data = $this->read( $sha );
+
+ return $data === '' ? null : $this->buildCommitObject( $sha, $data );
+ }
+
+ private function buildCommitObject( string $sha, string $data ): object {
+ $identity = $this->parseIdentity( $data, '/^author (.*) <(.*)> (\d+)/m' );
+ $message = $this->extractMessage( $data );
+ $parentSha = $this->extractPattern(
+ $data,
+ '/^parent ([0-9a-f]{40})$/m',
+ 1
+ );
+
+ return (object)[
+ 'sha' => $sha,
+ 'message' => $message,
+ 'author' => $identity['name'],
+ 'email' => $identity['email'],
+ 'date' => $identity['timestamp'],
+ 'parentSha' => $parentSha
+ ];
+ }
+
+ // --- Tree Processing ---
private function walkTree( string $sha, callable $callback ): void {
pages/BasePage.php
($this->title ? ' - ' . htmlspecialchars( $this->title ) : '');
?></title>
- <link rel="stylesheet"
- href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
- <link rel="stylesheet" href="repo.css">
+ <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="<?php echo (new UrlBuilder())->build(); ?>">Home</a>
<?php if( $currentRepo ):
$safeName = $currentRepo['safe_name'];
- $repoUrl = (new UrlBuilder())->withRepo( $safeName );
?>
- <a href="<?php echo $repoUrl->build(); ?>">Files</a>
+ <a href="<?php echo (new UrlBuilder())->withRepo( $safeName )->withAction( 'tree' )->build(); ?>">Files</a>
<a href="<?php echo (new UrlBuilder())->withRepo( $safeName )->withAction( 'commits' )->build(); ?>">Commits</a>
- <a href="<?php echo (new UrlBuilder())->withRepo( $safeName )->withAction( 'refs' )->build(); ?>">Branches</a>
<a href="<?php echo (new UrlBuilder())->withRepo( $safeName )->withAction( 'tags' )->build(); ?>">Tags</a>
<?php endif; ?>
<div class="repo-info-banner">
<span class="current-repo">
- Current: <strong><?php
- echo htmlspecialchars( $currentRepo['name'] );
- ?></strong>
+ Current: <strong><?php echo htmlspecialchars( $currentRepo['name'] ); ?></strong>
</span>
</div>
pages/FilePage.php
private $git;
private $hash;
+ private $path;
public function __construct(
array $repositories,
array $currentRepo,
Git $git,
- string $hash = ''
+ string $hash = 'HEAD',
+ string $path = ''
) {
parent::__construct( $repositories );
$this->currentRepo = $currentRepo;
$this->git = $git;
- $this->hash = $hash;
+ $this->hash = $hash ?: 'HEAD';
+ $this->path = $path;
$this->title = $currentRepo['name'];
}
echo '<div class="empty-state"><h3>No branches</h3></div>';
} else {
- $target = $this->hash ?: $main['hash'];
+ $target = $this->hash;
$entries = [];
$this->git->walk( $target, function( $file ) use ( &$entries ) {
$entries[] = $file;
- } );
+ }, $this->path );
- if( !empty( $entries ) ) {
+ if( !empty( $entries ) && !$this->isExactFileMatch( $entries ) ) {
$this->renderTree( $main, $target, $entries );
} else {
$this->renderBlob( $target );
}
}
}, $this->currentRepo );
}
- private function renderTree( $main, $targetHash, $entries ) {
- $path = $_GET['name'] ?? '';
+ private function isExactFileMatch($entries) {
+ return count( $entries ) === 1 &&
+ $entries[0]->name === basename( $this->path ) &&
+ !$entries[0]->isDir;
+ }
- $this->emitBreadcrumbs( $targetHash, 'Tree', $path );
+ private function renderTree( $main, $targetHash, $entries ) {
+ $this->emitBreadcrumbs( $targetHash, 'Tree', $this->path );
echo '<h2>' . htmlspecialchars( $this->currentRepo['name'] ) .
' <span class="branch-badge">' .
- htmlspecialchars( $main['name'] ) . '</span></h2>';
+ htmlspecialchars( $targetHash ) . '</span></h2>';
usort( $entries, function( $a, $b ) {
return $a->compare( $b );
} );
echo '<table class="file-list-table">';
- echo '<thead>';
- echo '<tr>';
- echo '<th></th>';
- echo '<th>Name</th>';
- echo '<th class="file-mode-cell">Mode</th>';
- echo '<th class="file-size-cell">Size</th>';
- echo '</tr>';
- echo '</thead>';
+ echo '<thead><tr><th></th><th>Name</th><th class="file-mode-cell">Mode</th><th class="file-size-cell">Size</th></tr></thead>';
echo '<tbody>';
- $currentPath = $this->hash ? $path : '';
$renderer = new HtmlFileRenderer(
- $this->currentRepo['safe_name'], $currentPath
+ $this->currentRepo['safe_name'],
+ $this->path,
+ $targetHash
);
foreach( $entries as $file ) {
$file->renderListEntry( $renderer );
}
- echo '</tbody>';
- echo '</table>';
+ echo '</tbody></table>';
}
private function renderBlob( $targetHash ) {
- $filename = $_GET['name'] ?? '';
+ $filename = $this->path;
$file = $this->git->readFile( $targetHash, $filename );
- $size = $this->git->getObjectSize( $targetHash );
- $renderer = new HtmlFileRenderer( $this->currentRepo['safe_name'] );
-
- $this->emitBreadcrumbs( $targetHash, 'File', $filename );
+ $size = $this->git->getObjectSize( $targetHash, $filename );
- if( $size === 0 ) {
- $this->renderDownloadState( $targetHash, "This file is empty." );
- } else {
- $rawUrl = (new UrlBuilder())
- ->withRepo( $this->currentRepo['safe_name'] )
- ->withAction( 'raw' )
- ->withHash( $targetHash )
- ->withName( $filename )
- ->build();
+ $renderer = new HtmlFileRenderer( $this->currentRepo['safe_name'], dirname($filename), $targetHash );
- if( !$file->renderMedia( $renderer, $rawUrl ) ) {
- if( $file->isText() ) {
- if( $size > self::MAX_DISPLAY_SIZE ) {
- ob_start();
- $file->renderSize( $renderer );
- $sizeStr = ob_get_clean();
+ $this->emitBreadcrumbs( $targetHash, 'File', $filename );
- $this->renderDownloadState(
- $targetHash,
- "File is too large to display ($sizeStr)."
- );
- } else {
- $content = '';
+ if( $size === 0 && !$file ) {
+ echo '<div class="empty-state">File not found.</div>';
+ return;
+ }
- $this->git->stream( $targetHash, function( $d ) use ( &$content ) {
- $content .= $d;
- } );
+ $rawUrl = (new UrlBuilder())
+ ->withRepo( $this->currentRepo['safe_name'] )
+ ->withAction( 'raw' )
+ ->withHash( $targetHash )
+ ->withName( $filename )
+ ->build();
- if( $size > self::MAX_HIGHLIGHT_SIZE ) {
- echo '<div class="blob-content"><pre class="blob-code">' .
- htmlspecialchars( $content ) . '</pre></div>';
- } else {
- echo '<div class="blob-content"><pre class="blob-code">' .
- $file->highlight( $renderer, $content ) . '</pre></div>';
- }
- }
+ if( !$file->renderMedia( $renderer, $rawUrl ) ) {
+ if( $file->isText() ) {
+ if( $size > self::MAX_DISPLAY_SIZE ) {
+ ob_start();
+ $file->renderSize( $renderer );
+ $sizeStr = ob_get_clean();
+ $this->renderDownloadState( $targetHash, "File is too large to display ($sizeStr)." );
} else {
- $this->renderDownloadState( $targetHash, "This is a binary file." );
+ $content = '';
+ $this->git->stream( $targetHash, function( $d ) use ( &$content ) {
+ $content .= $d;
+ }, $filename );
+
+ echo '<div class="blob-content"><pre class="blob-code">' .
+ ($size > self::MAX_HIGHLIGHT_SIZE
+ ? htmlspecialchars( $content )
+ : $file->highlight( $renderer, $content )) .
+ '</pre></div>';
}
+ } else {
+ $this->renderDownloadState( $targetHash, "This is a binary file." );
}
}
}
private function renderDownloadState( $hash, $reason ) {
- $filename = $_GET['name'] ?? '';
$url = (new UrlBuilder())
->withRepo( $this->currentRepo['safe_name'] )
->withAction( 'raw' )
->withHash( $hash )
- ->withName( $filename )
+ ->withName( $this->path )
->build();
$url = (new UrlBuilder())
->withRepo( $this->currentRepo['safe_name'] )
+ ->withAction( 'tree' )
+ ->withHash( $hash )
->withName( $acc )
->build();
- $trail[] = '<a href="' . $url . '">' .
- htmlspecialchars( $part ) . '</a>';
+ $trail[] = '<a href="' . $url . '">' . htmlspecialchars( $part ) . '</a>';
}
}
- } elseif( $this->hash ) {
+ } elseif( $hash ) {
$trail[] = $type . ' ' . substr( $hash, 0, 7 );
}
render/HtmlFileRenderer.php
private string $repoSafeName;
private string $currentPath;
+ private string $currentRef;
- public function __construct( string $repoSafeName, string $currentPath = '' ) {
+ public function __construct( string $repoSafeName, string $currentPath = '', string $currentRef = 'HEAD' ) {
$this->repoSafeName = $repoSafeName;
$this->currentPath = trim( $currentPath, '/' );
+ $this->currentRef = $currentRef;
}
int $size
): void {
- $fullPath = ($this->currentPath === '' ? '' : $this->currentPath . '/') .
- $name;
+ $fullPath = ($this->currentPath === '' ? '' : $this->currentPath . '/') . $name;
- // 2. Refactor: Use UrlBuilder instead of manual string concatenation
+ $isDir = ($mode === '40000' || $mode === '040000');
+ $action = $isDir ? 'tree' : 'blob';
+
$url = (new UrlBuilder())
->withRepo( $this->repoSafeName )
- ->withHash( $sha )
+ ->withAction( $action )
+ ->withHash( $this->currentRef )
->withName( $fullPath )
->build();
echo '<tr>';
- echo '<td class="file-icon-cell">';
- echo '<i class="fas ' . $iconClass . '"></i>';
- echo '</td>';
- echo '<td class="file-name-cell">';
- echo '<a href="' . $url . '">' . htmlspecialchars( $name ) . '</a>';
- echo '</td>';
- echo '<td class="file-mode-cell">';
- echo $this->formatMode( $mode );
- echo '</td>';
- echo '<td class="file-size-cell">';
-
- if( $size > 0 ) {
- echo $this->formatSize( $size );
- }
-
- echo '</td>';
+ echo '<td class="file-icon-cell"><i class="fas ' . $iconClass . '"></i></td>';
+ echo '<td class="file-name-cell"><a href="' . $url . '">' . htmlspecialchars( $name ) . '</a></td>';
+ echo '<td class="file-mode-cell">' . $this->formatMode( $mode ) . '</td>';
+ echo '<td class="file-size-cell">' . ($size > 0 ? $this->formatSize( $size ) : '') . '</td>';
echo '</tr>';
}
-
- public function renderMedia(
- File $file,
- string $url,
- string $mediaType
- ): bool {
- $rendered = false;
+ public function renderMedia( File $file, string $url, string $mediaType ): bool {
if( $file->isImage() ) {
- echo '<div class="blob-content blob-content-image">' .
- '<img src="' . $url . '"></div>';
- $rendered = true;
+ echo '<div class="blob-content blob-content-image"><img src="' . $url . '"></div>';
+ return true;
} elseif( $file->isVideo() ) {
- echo '<div class="blob-content blob-content-video">' .
- '<video controls><source src="' . $url . '" type="' .
- $mediaType . '"></video></div>';
- $rendered = true;
+ echo '<div class="blob-content blob-content-video"><video controls><source src="' . $url . '" type="' . $mediaType . '"></video></div>';
+ return true;
} elseif( $file->isAudio() ) {
- echo '<div class="blob-content blob-content-audio">' .
- '<audio controls><source src="' . $url . '" type="' .
- $mediaType . '"></audio></div>';
- $rendered = true;
+ echo '<div class="blob-content blob-content-audio"><audio controls><source src="' . $url . '" type="' . $mediaType . '"></audio></div>';
+ return true;
}
-
- return $rendered;
+ return false;
}
public function renderSize( int $bytes ): void {
echo $this->formatSize( $bytes );
}
- public function highlight(
- string $filename,
- string $content,
- string $mediaType
- ): string {
+ public function highlight( string $filename, string $content, string $mediaType ): string {
return (new Highlighter($filename, $content, $mediaType))->render();
}
public function renderTime( int $timestamp ): void {
$tokens = [
- 31536000 => 'year',
- 2592000 => 'month',
- 604800 => 'week',
- 86400 => 'day',
- 3600 => 'hour',
- 60 => 'minute',
- 1 => 'second'
+ 31536000 => 'year', 2592000 => 'month', 604800 => 'week',
+ 86400 => 'day', 3600 => 'hour', 60 => 'minute', 1 => 'second'
];
-
$diff = $timestamp ? time() - $timestamp : null;
$result = 'never';
if( $diff && $diff >= 5 ) {
foreach( $tokens as $unit => $text ) {
- if( $diff < $unit ) {
- continue;
- }
-
+ if( $diff < $unit ) continue;
$num = floor( $diff / $unit );
$result = $num . ' ' . $text . ($num > 1 ? 's' : '') . ' ago';
while( $bytes >= 1024 && $i < count( $units ) - 1 ) {
- $bytes /= 1024;
- $i++;
+ $bytes /= 1024; $i++;
}
}
}
-
Delta548 lines added, 463 lines removed, 85-line increase