Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/treetrek.git
M .gitignore
1
.htaccess
21
order.txt
32
favicon.ico
A .htaccess
1
RewriteEngine On
2
3
RewriteCond %{HTTP_USER_AGENT} SemrushBot [NC]
4
RewriteRule .* - [F]
5
6
RewriteRule ^(git|pages|render)/ - [F]
7
8
RewriteCond %{REQUEST_URI} !^/index\.php$
9
RewriteCond %{REQUEST_FILENAME} -f
10
RewriteRule \.php$ - [F]
11
12
RewriteCond %{REQUEST_FILENAME} !-f
13
RewriteRule ^(.*)$ index.php [QSA,L]
14
15
deny from 185.213.83.81
16
117
M File.php
9898
  }
9999
100
  public function isName( string $name ): bool {
101
    return $this->name === $name;
102
  }
103
100104
  public function emitRawHeaders(): void {
101105
    header( "Content-Type: " . $this->mediaType );
...
144148
145149
  private function detectBinary(): bool {
146
    return !str_starts_with( $this->mediaType, 'text/' );
150
    return $this->mediaType !== 'application/x-empty'
151
        && !str_starts_with( $this->mediaType, 'text/' );
147152
  }
148153
A README.md
1
# Git Repository Viewer
2
3
TreeTrek is a free, read-only, open-source, dependency- and authentication-free
4
PHP application for browsing raw Git repositories in either a self-hosted or
5
shared host environment. It functions entirely through direct filesystem access
6
without invoking system commands.
7
8
## Core Git Implementation
9
10
The engine implements low-level Git protocols to navigate and extract data from
11
the repository's internal storage structure.
12
13
* **Object Resolution**: Resolves and reads commits, trees, blobs, and tags.
14
* **Packfile Navigation**: Navigates .idx and .pack files using binary fanout
15
searches for rapid object lookup.
16
* **Reference Handling**: Resolves HEAD, branches, and tags, including
17
support for the packed-refs format.
18
* **Smart HTTP Protocol**: Implements the Git Smart HTTP protocol for
19
read-only cloning via standard Git clients.
20
21
## Content and Visualization
22
23
The user interface provides developer-focused tools for inspecting history
24
and repository content.
25
26
* **LCS Diff Engine**: Includes a Longest Common Subsequence (LCS) algorithm
27
to display line-level changes.
28
* **Syntax Highlighting**: Uses a regex-based engine with rules for common
29
programming languages.
30
* **Multimedia Rendering**: Detects media types to natively render
31
images, video, and audio files in the browser.
32
* **Breadcrumb Navigation**: Features a dynamic path trail and repository
33
selector for seamless project switching.
34
* **Streamed Content**: Streams object data in chunks to handle large files
35
without exceeding memory limits.
36
37
## Curation and Architecture
38
39
Project organization and system behavior are managed through flat-file
40
configurations and an object-oriented design. This structure separates
41
repository logic from the presentation layer.
42
43
* **Repository Curation**: Uses an `order.txt` file to define the
44
display sequence and visibility of projects.
45
* **Project Exclusion**: Allows hiding specific repositories by
46
prefixing their names with a hyphen in the `order.txt` config.
47
* **Stateless Routing**: Maps clean URIs directly to specialized page
48
controllers for consistent navigation.
49
150
M RepositoryList.php
2222
      }
2323
24
      $repos[$basename] = [
25
        'name' => $basename,
26
        'safe_name' => $basename,
24
      $name = $basename;
25
26
      if( str_ends_with( $name, '.git' ) ) {
27
        $name = substr( $name, 0, -4 );
28
      }
29
30
      $repos[$name] = [
31
        'name' => $name,
32
        'safe_name' => $name,
2733
        'path' => $dir
2834
      ];
M Router.php
99
require_once __DIR__ . '/pages/TagsPage.php';
1010
require_once __DIR__ . '/pages/ClonePage.php';
11
require_once __DIR__ . '/pages/ComparePage.php';
1112
1213
class Router {
1314
  private $repos = [];
1415
  private $git;
1516
1617
  public function __construct( string $reposPath ) {
1718
    $this->git = new Git( $reposPath );
1819
    $list = new RepositoryList( $reposPath );
19
2020
    $list->eachRepository( function( $repo ) {
21
      $this->repos[] = $repo;
21
      $this->repos[$repo['safe_name']] = $repo;
2222
    } );
2323
  }
2424
2525
  public function route(): Page {
26
    $reqRepo = $_GET['repo'] ?? '';
27
    $action = $_GET['action'] ?? 'file';
28
    $hash = $this->sanitize( $_GET['hash'] ?? '' );
29
    $subPath = '';
26
    if( empty( $_GET ) && !empty( $_SERVER['QUERY_STRING'] ) ) {
27
      parse_str( $_SERVER['QUERY_STRING'], $_GET );
28
    }
29
3030
    $uri = parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH );
31
    $scriptName = $_SERVER['SCRIPT_NAME'];
31
    $scriptName = dirname( $_SERVER['SCRIPT_NAME'] );
3232
33
    if( strpos( $uri, $scriptName ) === 0 ) {
33
    if( $scriptName !== '/' && strpos( $uri, $scriptName ) === 0 ) {
3434
      $uri = substr( $uri, strlen( $scriptName ) );
3535
    }
3636
37
    if( preg_match( '#^/([^/]+)\.git(?:/(.*))?$#', $uri, $matches ) ) {
38
      $reqRepo = urldecode( $matches[1] );
39
      $subPath = isset( $matches[2] ) ? ltrim( $matches[2], '/' ) : '';
40
      $action = 'clone';
37
    $uri = trim( $uri, '/' );
38
    $parts = explode( '/', $uri );
39
40
    if( !empty( $parts ) && $parts[0] === 'repo' ) {
41
      array_shift( $parts );
4142
    }
4243
43
    $currRepo = null;
44
    $decoded = urldecode( $reqRepo );
44
    if( empty( $parts ) || empty( $parts[0] ) ) {
45
      return new HomePage( $this->repos, $this->git );
46
    }
4547
46
    foreach( $this->repos as $repo ) {
47
      if( $repo['safe_name'] === $reqRepo || $repo['name'] === $decoded ) {
48
        $currRepo = $repo;
48
    $repoName = array_shift( $parts );
4949
50
        break;
51
      }
50
    if( str_ends_with( $repoName, '.git' ) ) {
51
      $realName = substr( $repoName, 0, -4 );
52
      $repoPath = $this->repos[$realName]['path'] ??
53
                  $this->repos[$repoName]['path'] ?? null;
5254
53
      $prefix = $repo['safe_name'] . '/';
55
      if( !$repoPath ) {
56
        http_response_code( 404 );
57
        echo "Repository not found";
58
        exit;
59
      }
5460
55
      if( strpos( $reqRepo, $prefix ) === 0 ) {
56
        $currRepo = $repo;
57
        $subPath = substr( $reqRepo, strlen( $prefix ) );
58
        $action = 'clone';
61
      $this->git->setRepository( $repoPath );
5962
60
        break;
61
      }
63
      return new ClonePage( $this->git, implode( '/', $parts ) );
6264
    }
6365
64
    if( $currRepo ) {
65
      $this->git->setRepository( $currRepo['path'] );
66
    if( !isset( $this->repos[$repoName] ) ) {
67
      return new HomePage( $this->repos, $this->git );
6668
    }
67
68
    $routes = [
69
      'home'    => fn() => new HomePage( $this->repos, $this->git ),
70
      'file'    => fn() => new FilePage( $this->repos, $currRepo, $this->git, $hash ),
71
      'raw'     => fn() => new RawPage( $this->git, $hash ),
72
      'commit'  => fn() => new DiffPage( $this->repos, $currRepo, $this->git, $hash ),
73
      'commits' => fn() => new CommitsPage( $this->repos, $currRepo, $this->git, $hash ),
74
      'tags'    => fn() => new TagsPage( $this->repos, $currRepo, $this->git ),
75
      'clone'   => fn() => new ClonePage( $this->git, $subPath ),
76
    ];
7769
78
    $action = !$currRepo ? 'home' : $action;
70
    $currRepo = $this->repos[$repoName];
71
    $this->git->setRepository( $currRepo['path'] );
72
    $action = array_shift( $parts ) ?: 'tree';
73
    $hash = '';
74
    $path = '';
75
    $baseHash = '';
7976
80
    return ($routes[$action] ?? $routes['file'])();
81
  }
77
    if( in_array( $action, ['tree', 'blob', 'raw', 'commits'] ) ) {
78
      $hash = array_shift( $parts ) ?: 'HEAD';
79
      $path = implode( '/', $parts );
80
    } elseif( $action === 'commit' ) {
81
      $hash = array_shift( $parts );
82
    } elseif( $action === 'compare' ) {
83
      $hash = array_shift( $parts );
84
      $baseHash = array_shift( $parts );
85
    }
8286
83
  private function sanitize( $path ) {
84
    $path = str_replace( [ '..', '\\', "\0" ], [ '', '/', '' ], $path );
87
    $_GET['repo'] = $repoName;
88
    $_GET['action'] = $action;
89
    $_GET['hash'] = $hash;
90
    $_GET['name'] = $path;
8591
86
    return preg_replace( '/[^a-zA-Z0-9_\-\.\/]/', '', $path );
92
    return match( $action ) {
93
      'tree', 'blob' => new FilePage(
94
        $this->repos, $currRepo, $this->git, $hash, $path
95
      ),
96
      'raw' => new RawPage( $this->git, $hash ),
97
      'commits' => new CommitsPage( $this->repos, $currRepo, $this->git, $hash ),
98
      'commit' => new DiffPage( $this->repos, $currRepo, $this->git, $hash ),
99
      'tags' => new TagsPage( $this->repos, $currRepo, $this->git ),
100
      'compare' => new ComparePage(
101
        $this->repos, $currRepo, $this->git, $hash, $baseHash
102
      ),
103
      default => new FilePage( $this->repos, $currRepo, $this->git, 'HEAD', '' )
104
    };
87105
  }
88106
}
M Tag.php
3030
  }
3131
32
  public function render( TagRenderer $renderer ): void {
32
  public function render( TagRenderer $renderer, ?Tag $prevTag = null ): void {
3333
    $renderer->renderTagItem(
3434
      $this->name,
3535
      $this->sha,
3636
      $this->targetSha,
37
      $prevTag ? $prevTag->targetSha : null,
3738
      $this->timestamp,
3839
      $this->message,
3940
      $this->author
4041
    );
4142
  }
4243
}
44
4345
M UrlBuilder.php
3434
  public function build() {
3535
    if( $this->switcher ) {
36
      $url = "window.location.href='?repo=' + encodeURIComponent(" .
37
             $this->switcher . ")";
38
    } else {
39
      $params = [];
36
      return "window.location.href='/repo/' + " . $this->switcher;
37
    }
4038
41
      if( $this->repo ) {
42
        $params['repo'] = $this->repo;
43
      }
39
    if( !$this->repo ) {
40
      return '/';
41
    }
4442
45
      if( $this->action ) {
46
        $params['action'] = $this->action;
47
      }
43
    $url = '/repo/' . $this->repo;
4844
49
      if( $this->hash ) {
50
        $params['hash'] = $this->hash;
51
      }
45
    if( !$this->action && $this->name ) {
46
      $this->action = 'tree';
47
    }
5248
53
      if( $this->name ) {
54
        $params['name'] = $this->name;
49
    if( $this->action ) {
50
      $url .= '/' . $this->action;
51
52
      if( $this->hash ) {
53
         $url .= '/' . $this->hash;
54
      } elseif( in_array( $this->action, ['tree', 'blob', 'raw', 'commits'] ) ) {
55
         $url .= '/HEAD';
5556
      }
57
    }
5658
57
      $url = empty( $params ) ? '?' : '?' . http_build_query( $params );
59
    if( $this->name ) {
60
      $url .= '/' . ltrim( $this->name, '/' );
5861
    }
5962
M git/Git.php
2222
    $this->objectsPath = $this->repoPath . '/objects';
2323
24
    $this->refs  = new GitRefs( $this->repoPath );
25
    $this->packs = new GitPacks( $this->objectsPath );
26
  }
27
28
  public function resolve( string $reference ): string {
29
    return $this->refs->resolve( $reference );
30
  }
31
32
  public function getMainBranch(): array {
33
    return $this->refs->getMainBranch();
34
  }
35
36
  public function eachBranch( callable $callback ): void {
37
    $this->refs->scanRefs( 'refs/heads', $callback );
38
  }
39
40
  public function eachTag( callable $callback ): void {
41
    $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use (
42
      $callback
43
    ) {
44
      $data = $this->read( $sha );
45
      $tag  = $this->parseTagData( $name, $sha, $data );
46
47
      $callback( $tag );
48
    } );
49
  }
50
51
  private function parseTagData(
52
    string $name,
53
    string $sha,
54
    string $data
55
  ): Tag {
56
    $isAnnotated = strncmp( $data, 'object ', 7 ) === 0;
57
58
    $targetSha = $isAnnotated
59
      ? $this->extractPattern(
60
          $data,
61
          '/^object ([0-9a-f]{40})$/m',
62
          1,
63
          $sha
64
        )
65
      : $sha;
66
67
    $pattern = $isAnnotated
68
      ? '/^tagger (.*) <(.*)> (\d+) [+\-]\d{4}$/m'
69
      : '/^author (.*) <(.*)> (\d+) [+\-]\d{4}$/m';
70
71
    $identity = $this->parseIdentity( $data, $pattern );
72
    $message  = $this->extractMessage( $data );
73
74
    return new Tag(
75
      $name,
76
      $sha,
77
      $targetSha,
78
      $identity['timestamp'],
79
      $message,
80
      $identity['name']
81
    );
82
  }
83
84
  private function extractPattern(
85
    string $data,
86
    string $pattern,
87
    int $group,
88
    string $default = ''
89
  ): string {
90
    $matches = [];
91
92
    $result = preg_match( $pattern, $data, $matches )
93
      ? $matches[$group]
94
      : $default;
95
96
    return $result;
97
  }
98
99
  private function parseIdentity( string $data, string $pattern ): array {
100
    $matches = [];
101
    $found   = preg_match( $pattern, $data, $matches );
102
103
    return [
104
      'name'      => $found ? trim( $matches[1] ) : 'Unknown',
105
      'email'     => $found ? $matches[2] : '',
106
      'timestamp' => $found ? (int)$matches[3] : 0
107
    ];
108
  }
109
110
  private function extractMessage( string $data ): string {
111
    $pos = strpos( $data, "\n\n" );
112
113
    return $pos !== false ? trim( substr( $data, $pos + 2 ) ) : '';
114
  }
115
116
  public function getObjectSize( string $sha ): int {
117
    $size = $this->packs->getSize( $sha );
118
119
    return $size !== null ? $size : $this->getLooseObjectSize( $sha );
120
  }
121
122
  public function peek( string $sha, int $length = 255 ): string {
123
    $size = $this->packs->getSize( $sha );
124
125
    return $size === null
126
      ? $this->peekLooseObject( $sha, $length )
127
      : $this->packs->peek( $sha, $length ) ?? '';
128
  }
129
130
  public function read( string $sha ): string {
131
    $size = $this->getObjectSize( $sha );
132
133
    if( $size > self::MAX_READ_SIZE ) {
134
      return '';
135
    }
136
137
    $content = '';
138
139
    $this->slurp( $sha, function( $chunk ) use ( &$content ) {
140
      $content .= $chunk;
141
    } );
142
143
    return $content;
144
  }
145
146
  public function readFile( string $hash, string $name ) {
147
    return new File(
148
      $name,
149
      $hash,
150
      '100644',
151
      0,
152
      $this->getObjectSize( $hash ),
153
      $this->peek( $hash )
154
    );
155
  }
156
157
  public function stream( string $sha, callable $callback ): void {
158
    $this->slurp( $sha, $callback );
159
  }
160
161
  private function slurp( string $sha, callable $callback ): void {
162
    $loosePath = $this->getLoosePath( $sha );
163
164
    if( is_file( $loosePath ) ) {
165
      $this->slurpLooseObject( $loosePath, $callback );
166
    } else {
167
      $this->slurpPackedObject( $sha, $callback );
168
    }
169
  }
170
171
  private function iterateInflated( string $path, callable $processor ): void {
172
    $this->withInflatedFile(
173
      $path,
174
      function( $fileHandle, $inflator ) use ( $processor ) {
175
        $headerFound = false;
176
        $buffer      = '';
177
178
        while( !feof( $fileHandle ) ) {
179
          $chunk    = fread( $fileHandle, 16384 );
180
          $inflated = inflate_add( $inflator, $chunk );
181
182
          if( $inflated === false ) {
183
            break;
184
          }
185
186
          if( !$headerFound ) {
187
            $buffer .= $inflated;
188
            $nullPos = strpos( $buffer, "\0" );
189
190
            if( $nullPos !== false ) {
191
              $headerFound = true;
192
              $header      = substr( $buffer, 0, $nullPos );
193
              $body        = substr( $buffer, $nullPos + 1 );
194
195
              if( $processor( $body, $header ) === false ) {
196
                return;
197
              }
198
            }
199
          } else {
200
            if( $processor( $inflated, null ) === false ) {
201
              return;
202
            }
203
          }
204
        }
205
      }
206
    );
207
  }
208
209
  private function slurpLooseObject(
210
    string $path,
211
    callable $callback
212
  ): void {
213
    $this->iterateInflated(
214
      $path,
215
      function( $chunk ) use ( $callback ) {
216
        if( $chunk !== '' ) {
217
          $callback( $chunk );
218
        }
219
        return true;
220
      }
221
    );
222
  }
223
224
  private function withInflatedFile( string $path, callable $callback ): void {
225
    $fileHandle = fopen( $path, 'rb' );
226
    $inflator   = $fileHandle ? inflate_init( ZLIB_ENCODING_DEFLATE ) : null;
227
228
    if( $fileHandle && $inflator ) {
229
      $callback( $fileHandle, $inflator );
230
      fclose( $fileHandle );
231
    }
232
  }
233
234
  private function slurpPackedObject(
235
    string $sha,
236
    callable $callback
237
  ): void {
238
    $streamed = $this->packs->stream( $sha, $callback );
239
240
    if( !$streamed ) {
241
      $data = $this->packs->read( $sha );
242
243
      if( $data !== null && $data !== '' ) {
244
        $callback( $data );
245
      }
246
    }
247
  }
248
249
  private function peekLooseObject( string $sha, int $length ): string {
250
    $path = $this->getLoosePath( $sha );
251
252
    return is_file( $path )
253
      ? $this->inflateLooseObjectPrefix( $path, $length )
254
      : '';
255
  }
256
257
  private function inflateLooseObjectPrefix(
258
    string $path,
259
    int $length
260
  ): string {
261
    $buffer = '';
262
263
    $this->iterateInflated(
264
      $path,
265
      function( $chunk ) use ( $length, &$buffer ) {
266
        $buffer .= $chunk;
267
        return strlen( $buffer ) < $length;
268
      }
269
    );
270
271
    return substr( $buffer, 0, $length );
272
  }
273
274
  public function history( string $ref, int $limit, callable $callback ): void {
275
    $currentSha = $this->resolve( $ref );
276
    $count      = 0;
277
278
    while( $currentSha !== '' && $count < $limit ) {
279
      $commit = $this->parseCommit( $currentSha );
280
281
      if( $commit === null ) {
282
        break;
283
      }
284
285
      $callback( $commit );
286
      $currentSha = $commit->parentSha;
287
      $count++;
288
    }
289
  }
290
291
  private function parseCommit( string $sha ): ?object {
292
    $data = $this->read( $sha );
293
294
    return $data === '' ? null : $this->buildCommitObject( $sha, $data );
295
  }
296
297
  private function buildCommitObject( string $sha, string $data ): object {
298
    $identity  = $this->parseIdentity( $data, '/^author (.*) <(.*)> (\d+)/m' );
299
    $message   = $this->extractMessage( $data );
300
    $parentSha = $this->extractPattern(
301
      $data,
302
      '/^parent ([0-9a-f]{40})$/m',
303
      1
304
    );
305
306
    return (object)[
307
      'sha'       => $sha,
308
      'message'   => $message,
309
      'author'    => $identity['name'],
310
      'email'     => $identity['email'],
311
      'date'      => $identity['timestamp'],
312
      'parentSha' => $parentSha
313
    ];
314
  }
315
316
  public function walk( string $refOrSha, callable $callback ): void {
317
    $sha = $this->resolve( $refOrSha );
318
319
    if( $sha !== '' ) {
320
      $this->walkTree( $sha, $callback );
321
    }
322
  }
323
324
  private function walkTree( string $sha, callable $callback ): void {
325
    $data     = $this->read( $sha );
326
    $treeData = $data !== '' && preg_match(
327
      '/^tree ([0-9a-f]{40})$/m',
328
      $data,
329
      $matches
330
    ) ? $this->read( $matches[1] ) : $data;
331
332
    if( $treeData !== '' && $this->isTreeData( $treeData ) ) {
333
      $this->processTree( $treeData, $callback );
334
    }
335
  }
336
337
  private function processTree( string $data, callable $callback ): void {
338
    $position = 0;
339
    $length   = strlen( $data );
340
341
    while( $position < $length ) {
342
      $result = $this->parseTreeEntry( $data, $position, $length );
343
344
      if( $result === null ) {
345
        break;
346
      }
347
348
      $callback( $result['file'] );
349
      $position = $result['nextPosition'];
350
    }
351
  }
352
353
  private function parseTreeEntry(
354
    string $data,
355
    int $position,
356
    int $length
357
  ): ?array {
358
    $spacePos = strpos( $data, ' ', $position );
359
    $nullPos  = strpos( $data, "\0", $spacePos );
360
361
    $hasValidPositions =
362
      $spacePos !== false &&
363
      $nullPos !== false &&
364
      $nullPos + 21 <= $length;
365
366
    return $hasValidPositions
367
      ? $this->buildTreeEntryResult( $data, $position, $spacePos, $nullPos )
368
      : null;
369
  }
370
371
  private function buildTreeEntryResult(
372
    string $data,
373
    int $position,
374
    int $spacePos,
375
    int $nullPos
376
  ): array {
377
    $mode = substr( $data, $position, $spacePos - $position );
378
    $name = substr( $data, $spacePos + 1, $nullPos - $spacePos - 1 );
379
    $sha  = bin2hex( substr( $data, $nullPos + 1, 20 ) );
380
381
    $isDirectory = $mode === '40000' || $mode === '040000';
382
    $size        = $isDirectory ? 0 : $this->getObjectSize( $sha );
383
    $contents    = $isDirectory ? '' : $this->peek( $sha );
384
385
    $file = new File( $name, $sha, $mode, 0, $size, $contents );
386
387
    return [
388
      'file'         => $file,
389
      'nextPosition' => $nullPos + 21
390
    ];
391
  }
392
393
  private function isTreeData( string $data ): bool {
394
    $pattern        = '/^(40000|100644|100755|120000|160000) /';
395
    $minLength      = strlen( $data ) >= 25;
396
    $matchesPattern = $minLength && preg_match( $pattern, $data );
397
    $nullPos        = $matchesPattern ? strpos( $data, "\0" ) : false;
398
399
    return $matchesPattern &&
400
      $nullPos !== false &&
401
      $nullPos + 21 <= strlen( $data );
402
  }
403
404
  private function getLoosePath( string $sha ): string {
405
    return "{$this->objectsPath}/" .
406
      substr( $sha, 0, 2 ) . "/" .
407
      substr( $sha, 2 );
408
  }
409
410
  private function getLooseObjectSize( string $sha ): int {
411
    $path = $this->getLoosePath( $sha );
412
413
    return is_file( $path ) ? $this->readLooseObjectHeader( $path ) : 0;
414
  }
415
416
  private function readLooseObjectHeader( string $path ): int {
417
    $size = 0;
418
419
    $this->iterateInflated(
420
      $path,
421
      function( $chunk, $header ) use ( &$size ) {
422
        if( $header !== null ) {
423
          $parts = explode( ' ', $header );
424
          $size  = isset( $parts[1] ) ? (int)$parts[1] : 0;
425
        }
426
        return false;
427
      }
428
    );
429
430
    return $size;
431
  }
432
433
  public function streamRaw( string $subPath ): bool {
434
    return strpos( $subPath, '..' ) === false
435
      ? $this->streamRawFile( $subPath )
436
      : false;
437
  }
438
439
  private function streamRawFile( string $subPath ): bool {
440
    $fullPath = "{$this->repoPath}/$subPath";
441
442
    return file_exists( $fullPath )
443
      ? $this->streamIfPathValid( $fullPath )
444
      : false;
445
  }
446
447
  private function streamIfPathValid( string $fullPath ): bool {
448
    $realPath = realpath( $fullPath );
449
    $repoReal = realpath( $this->repoPath );
450
    $isValid  = $realPath && strpos( $realPath, $repoReal ) === 0;
451
452
    return $isValid ? readfile( $fullPath ) !== false : false;
453
  }
454
455
  public function eachRef( callable $callback ): void {
456
    $head = $this->resolve( 'HEAD' );
457
458
    if( $head !== '' ) {
459
      $callback( 'HEAD', $head );
460
    }
461
462
    $this->refs->scanRefs(
463
      'refs/heads',
464
      function( $name, $sha ) use ( $callback ) {
465
        $callback( "refs/heads/$name", $sha );
466
      }
467
    );
468
469
    $this->refs->scanRefs(
470
      'refs/tags',
471
      function( $name, $sha ) use ( $callback ) {
472
        $callback( "refs/tags/$name", $sha );
473
      }
474
    );
24
    $this->refs        = new GitRefs( $this->repoPath );
25
    $this->packs       = new GitPacks( $this->objectsPath );
26
  }
27
28
  public function resolve( string $reference ): string {
29
    return $this->refs->resolve( $reference );
30
  }
31
32
  public function getMainBranch(): array {
33
    return $this->refs->getMainBranch();
34
  }
35
36
  public function eachBranch( callable $callback ): void {
37
    $this->refs->scanRefs( 'refs/heads', $callback );
38
  }
39
40
  public function eachTag( callable $callback ): void {
41
    $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use (
42
      $callback
43
    ) {
44
      $data = $this->read( $sha );
45
      $tag  = $this->parseTagData( $name, $sha, $data );
46
47
      $callback( $tag );
48
    } );
49
  }
50
51
  public function walk(
52
    string $refOrSha,
53
    callable $callback,
54
    string $path = ''
55
  ): void {
56
    $sha     = $this->resolve( $refOrSha );
57
    $treeSha = '';
58
59
    if( $sha !== '' ) {
60
      $treeSha = $this->getTreeSha( $sha );
61
    }
62
63
    if( $path !== '' && $treeSha !== '' ) {
64
      $info    = $this->resolvePath( $treeSha, $path );
65
      $treeSha = $info['isDir'] ? $info['sha'] : '';
66
    }
67
68
    if( $treeSha !== '' ) {
69
      $this->walkTree( $treeSha, $callback );
70
    }
71
  }
72
73
  public function readFile( string $ref, string $path ): File {
74
    $sha  = $this->resolve( $ref );
75
    $tree = $sha !== '' ? $this->getTreeSha( $sha ) : '';
76
    $info = $tree !== '' ? $this->resolvePath( $tree, $path ) : [];
77
    $file = new MissingFile();
78
79
    if( isset( $info['sha'] ) && !$info['isDir'] && $info['sha'] !== '' ) {
80
      $file = new File(
81
        basename( $path ),
82
        $info['sha'],
83
        $info['mode'],
84
        0,
85
        $this->getObjectSize( $info['sha'] ),
86
        $this->peek( $info['sha'] )
87
      );
88
    }
89
90
    return $file;
91
  }
92
93
  public function getObjectSize( string $sha, string $path = '' ): int {
94
    $target = $sha;
95
96
    if( $path !== '' ) {
97
      $info   = $this->resolvePath(
98
        $this->getTreeSha( $this->resolve( $sha ) ),
99
        $path
100
      );
101
      $target = $info['sha'] ?? '';
102
    }
103
104
    return $target !== ''
105
      ? $this->packs->getSize( $target ) ?? $this->getLooseObjectSize( $target )
106
      : 0;
107
  }
108
109
  public function stream(
110
    string $sha,
111
    callable $callback,
112
    string $path = ''
113
  ): void {
114
    $target = $sha;
115
116
    if( $path !== '' ) {
117
      $info   = $this->resolvePath(
118
        $this->getTreeSha( $this->resolve( $sha ) ),
119
        $path
120
      );
121
      $target = isset( $info['isDir'] ) && !$info['isDir']
122
        ? $info['sha']
123
        : '';
124
    }
125
126
    if( $target !== '' ) {
127
      $this->slurp( $target, $callback );
128
    }
129
  }
130
131
  private function getTreeSha( string $commitOrTreeSha ): string {
132
    $data = $this->read( $commitOrTreeSha );
133
    $sha  = $commitOrTreeSha;
134
135
    if( preg_match( '/^object ([0-9a-f]{40})/m', $data, $matches ) ) {
136
      $sha = $this->getTreeSha( $matches[1] );
137
    }
138
139
    if( $sha === $commitOrTreeSha &&
140
        preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches ) ) {
141
      $sha = $matches[1];
142
    }
143
144
    return $sha;
145
  }
146
147
  private function resolvePath( string $treeSha, string $path ): array {
148
    $parts = explode( '/', trim( $path, '/' ) );
149
    $sha   = $treeSha;
150
    $mode  = '40000';
151
152
    foreach( $parts as $part ) {
153
      $entry = $part !== '' && $sha !== ''
154
        ? $this->findTreeEntry( $sha, $part )
155
        : [ 'sha' => '', 'mode' => '' ];
156
157
      $sha   = $entry['sha'];
158
      $mode  = $entry['mode'];
159
    }
160
161
    return [
162
      'sha'   => $sha,
163
      'mode'  => $mode,
164
      'isDir' => $mode === '40000' || $mode === '040000'
165
    ];
166
  }
167
168
  private function findTreeEntry( string $treeSha, string $name ): array {
169
    $data  = $this->read( $treeSha );
170
    $pos   = 0;
171
    $len   = strlen( $data );
172
    $entry = [ 'sha' => '', 'mode' => '' ];
173
174
    while( $pos < $len ) {
175
      $space = strpos( $data, ' ', $pos );
176
      $eos   = strpos( $data, "\0", $space );
177
178
      if( $space === false || $eos === false ) {
179
        break;
180
      }
181
182
      if( substr( $data, $space + 1, $eos - $space - 1 ) === $name ) {
183
        $entry = [
184
          'sha'  => bin2hex( substr( $data, $eos + 1, 20 ) ),
185
          'mode' => substr( $data, $pos, $space - $pos )
186
        ];
187
        break;
188
      }
189
190
      $pos   = $eos + 21;
191
    }
192
193
    return $entry;
194
  }
195
196
  private function parseTagData(
197
    string $name,
198
    string $sha,
199
    string $data
200
  ): Tag {
201
    $isAnn   = strncmp( $data, 'object ', 7 ) === 0;
202
    $pattern = $isAnn
203
      ? '/^tagger (.*) <(.*)> (\d+) [+\-]\d{4}$/m'
204
      : '/^author (.*) <(.*)> (\d+) [+\-]\d{4}$/m';
205
    $id      = $this->parseIdentity( $data, $pattern );
206
    $target  = $isAnn
207
      ? $this->extractPattern( $data, '/^object (.*)$/m', 1, $sha )
208
      : $sha;
209
210
    return new Tag(
211
      $name,
212
      $sha,
213
      $target,
214
      $id['timestamp'],
215
      $this->extractMessage( $data ),
216
      $id['name']
217
    );
218
  }
219
220
  private function extractPattern(
221
    string $data,
222
    string $pattern,
223
    int $group,
224
    string $default = ''
225
  ): string {
226
    return preg_match( $pattern, $data, $matches )
227
      ? $matches[$group]
228
      : $default;
229
  }
230
231
  private function parseIdentity( string $data, string $pattern ): array {
232
    $found = preg_match( $pattern, $data, $matches );
233
234
    return [
235
      'name'      => $found ? trim($matches[1]) : 'Unknown',
236
      'email'     => $found ? $matches[2] : '',
237
      'timestamp' => $found ? (int)$matches[3] : 0
238
    ];
239
  }
240
241
  private function extractMessage( string $data ): string {
242
    $pos = strpos( $data, "\n\n" );
243
244
    return $pos !== false ? trim( substr( $data, $pos + 2 ) ) : '';
245
  }
246
247
  public function peek( string $sha, int $length = 255 ): string {
248
    $size = $this->packs->getSize( $sha );
249
250
    return $size === null
251
      ? $this->peekLooseObject( $sha, $length )
252
      : $this->packs->peek( $sha, $length ) ?? '';
253
  }
254
255
  public function read( string $sha ): string {
256
    $size    = $this->getObjectSize( $sha );
257
    $content = '';
258
259
    if( $size > 0 && $size <= self::MAX_READ_SIZE ) {
260
      $this->slurp( $sha, function( $chunk ) use ( &$content ) {
261
        $content .= $chunk;
262
      } );
263
    }
264
265
    return $content;
266
  }
267
268
  private function slurp( string $sha, callable $callback ): void {
269
    $path = $this->getLoosePath( $sha );
270
271
    if( is_file($path) ) {
272
      $this->slurpLooseObject( $path, $callback );
273
    }
274
275
    if( !is_file($path) ) {
276
      $this->slurpPackedObject( $sha, $callback );
277
    }
278
  }
279
280
  private function iterateInflated( string $path, callable $processor ): void {
281
    $this->withInflatedFile(
282
      $path,
283
      function( $handle, $inflator ) use ( $processor ) {
284
        $found = false;
285
        $buffer = '';
286
287
        while( !feof($handle) ) {
288
          $inflated = inflate_add( $inflator, fread( $handle, 16384 ) );
289
290
          if( $inflated === false ) {
291
            break;
292
          }
293
294
          if( !$found ) {
295
            $buffer .= $inflated;
296
            $eos     = strpos( $buffer, "\0" );
297
298
            if( $eos !== false ) {
299
              $found = true;
300
              $body  = substr( $buffer, $eos + 1 );
301
              $head  = substr( $buffer, 0, $eos );
302
303
              if( $processor( $body, $head ) === false ) {
304
                break;
305
              }
306
            }
307
          } elseif( $found ) {
308
            if( $processor( $inflated, null ) === false ) {
309
              break;
310
            }
311
          }
312
        }
313
      }
314
    );
315
  }
316
317
  private function slurpLooseObject(
318
    string $path,
319
    callable $callback
320
  ): void {
321
    $this->iterateInflated(
322
      $path,
323
      function( $chunk ) use ( $callback ) {
324
        if( $chunk !== '' ) {
325
          $callback( $chunk );
326
        }
327
328
        return true;
329
      }
330
    );
331
  }
332
333
  private function withInflatedFile( string $path, callable $callback ): void {
334
    $handle = fopen( $path, 'rb' );
335
    $infl   = $handle ? inflate_init( ZLIB_ENCODING_DEFLATE ) : null;
336
337
    if( $handle && $infl ) {
338
      $callback( $handle, $infl );
339
      fclose( $handle );
340
    }
341
  }
342
343
  private function slurpPackedObject( string $sha, callable $callback ): void {
344
    $streamed = $this->packs->stream( $sha, $callback );
345
346
    if( !$streamed ) {
347
      $data = $this->packs->read( $sha );
348
349
      if( $data !== null && $data !== '' ) {
350
        $callback( $data );
351
      }
352
    }
353
  }
354
355
  private function peekLooseObject( string $sha, int $length ): string {
356
    $path = $this->getLoosePath( $sha );
357
358
    return is_file($path)
359
      ? $this->inflateLooseObjectPrefix( $path, $length )
360
      : '';
361
  }
362
363
  private function inflateLooseObjectPrefix(
364
    string $path,
365
    int $length
366
  ): string {
367
    $buf = '';
368
369
    $this->iterateInflated(
370
      $path,
371
      function( $chunk ) use ( $length, &$buf ) {
372
        $buf .= $chunk;
373
374
        return strlen($buf) < $length;
375
      }
376
    );
377
378
    return substr( $buf, 0, $length );
379
  }
380
381
  public function history( string $ref, int $limit, callable $callback ): void {
382
    $sha   = $this->resolve( $ref );
383
    $count = 0;
384
385
    while( $sha !== '' && $count < $limit ) {
386
      $commit = $this->parseCommit( $sha );
387
388
      if( $commit->sha === '' ) {
389
        $sha = '';
390
      }
391
392
      if( $sha !== '' ) {
393
        $callback( $commit );
394
        $sha   = $commit->parentSha;
395
        $count++;
396
      }
397
    }
398
  }
399
400
  private function parseCommit( string $sha ): object {
401
    $data = $this->read( $sha );
402
403
    return $data !== ''
404
      ? $this->buildCommitObject( $sha, $data )
405
      : (object)[ 'sha' => '' ];
406
  }
407
408
  private function buildCommitObject( string $sha, string $data ): object {
409
    $id = $this->parseIdentity( $data, '/^author (.*) <(.*)> (\d+)/m' );
410
411
    return (object)[
412
      'sha'       => $sha,
413
      'message'   => $this->extractMessage( $data ),
414
      'author'    => $id['name'],
415
      'email'     => $id['email'],
416
      'date'      => $id['timestamp'],
417
      'parentSha' => $this->extractPattern( $data, '/^parent (.*)$/m', 1 )
418
    ];
419
  }
420
421
  private function walkTree( string $sha, callable $callback ): void {
422
    $data = $this->read( $sha );
423
    $tree = $data !== '' && preg_match( '/^tree (.*)$/m', $data, $m )
424
      ? $this->read($m[1])
425
      : $data;
426
427
    if( $tree !== '' && $this->isTreeData($tree) ) {
428
      $this->processTree( $tree, $callback );
429
    }
430
  }
431
432
  private function processTree( string $data, callable $callback ): void {
433
    $pos = 0;
434
    $len = strlen( $data );
435
436
    while( $pos < $len ) {
437
      $entry = $this->parseTreeEntry( $data, $pos, $len );
438
439
      if( $entry === null ) {
440
        break;
441
      }
442
443
      $callback( $entry['file'] );
444
      $pos = $entry['nextPosition'];
445
    }
446
  }
447
448
  private function parseTreeEntry(
449
    string $data,
450
    int $pos,
451
    int $len
452
  ): ?array {
453
    $space = strpos( $data, ' ', $pos );
454
    $eos   = strpos( $data, "\0", $space );
455
456
    return $space !== false && $eos !== false && $eos + 21 <= $len
457
      ? $this->buildTreeEntryResult( $data, $pos, $space, $eos )
458
      : null;
459
  }
460
461
  private function buildTreeEntryResult(
462
    string $data,
463
    int $pos,
464
    int $space,
465
    int $eos
466
  ): array {
467
    $mode = substr( $data, $pos, $space - $pos );
468
    $sha  = bin2hex( substr( $data, $eos + 1, 20 ) );
469
    $isD  = $mode === '40000' || $mode === '040000';
470
471
    return [
472
      'file' => new File(
473
        substr( $data, $space + 1, $eos - $space - 1 ),
474
        $sha,
475
        $mode,
476
        0,
477
        $isD ? 0 : $this->getObjectSize( $sha ),
478
        $isD ? '' : $this->peek( $sha )
479
      ),
480
      'nextPosition' => $eos + 21
481
    ];
482
  }
483
484
  private function isTreeData( string $data ): bool {
485
    $len   = strlen( $data );
486
    $patt  = '/^(40000|100644|100755|120000|160000) /';
487
    $match = $len >= 25 && preg_match( $patt, $data );
488
    $eos   = $match ? strpos( $data, "\0" ) : false;
489
490
    return $match && $eos !== false && $eos + 21 <= $len;
491
  }
492
493
  private function getLoosePath( string $sha ): string {
494
    return "{$this->objectsPath}/" .
495
      substr( $sha, 0, 2 ) . "/" .
496
      substr( $sha, 2 );
497
  }
498
499
  private function getLooseObjectSize( string $sha ): int {
500
    $path = $this->getLoosePath( $sha );
501
502
    return is_file($path) ? $this->readLooseObjectHeader($path) : 0;
503
  }
504
505
  private function readLooseObjectHeader( string $path ): int {
506
    $size = 0;
507
508
    $this->iterateInflated( $path, function( $chunk, $header ) use ( &$size ) {
509
      if( $header !== null ) {
510
        $parts = explode( ' ', $header );
511
        $size  = isset( $parts[1] ) ? (int)$parts[1] : 0;
512
      }
513
514
      return false;
515
    } );
516
517
    return $size;
518
  }
519
520
  public function streamRaw( string $subPath ): bool {
521
    return strpos($subPath, '..') === false
522
      ? $this->streamRawFile( $subPath )
523
      : false;
524
  }
525
526
  private function streamRawFile( string $subPath ): bool {
527
    $path = "{$this->repoPath}/$subPath";
528
529
    return is_file($path) ? $this->streamIfPathValid($path) : false;
530
  }
531
532
  private function streamIfPathValid( string $fullPath ): bool {
533
    $real    = realpath( $fullPath );
534
    $repo    = realpath( $this->repoPath );
535
    $isValid = $real && strpos($real, $repo) === 0;
536
537
    return $isValid ? readfile($fullPath) !== false : false;
538
  }
539
540
  public function eachRef( callable $callback ): void {
541
    $head = $this->resolve( 'HEAD' );
542
543
    if( $head !== '' ) {
544
      $callback( 'HEAD', $head );
545
    }
546
547
    $this->refs->scanRefs( 'refs/heads', function( $n, $s ) use ( $callback ) {
548
      $callback( "refs/heads/$n", $s );
549
    } );
550
551
    $this->refs->scanRefs( 'refs/tags', function( $n, $s ) use ( $callback ) {
552
      $callback( "refs/tags/$n", $s );
553
    } );
554
  }
555
556
  public function collectObjects( array $wants, array $haves = [] ): array {
557
    $objs = [];
558
    $seen = [];
559
560
    foreach( $wants as $sha ) {
561
      $this->collectObjectsRecursive( $sha, $objs, $seen );
562
    }
563
564
    foreach( $haves as $sha ) {
565
      unset($objs[$sha]);
566
    }
567
568
    return $objs;
569
  }
570
571
  private function collectObjectsRecursive(
572
    string $sha,
573
    array &$objs,
574
    array &$seen
575
  ): void {
576
    if( !isset( $seen[$sha] ) ) {
577
      $seen[$sha] = true;
578
      $data       = $this->read( $sha );
579
      $type       = $this->getObjectType( $data );
580
      $objs[$sha] = [ 'type' => $type, 'size' => strlen($data) ];
581
582
      if( $type === 1 ) {
583
        $this->collectCommitLinks( $data, $objs, $seen );
584
      }
585
586
      if( $type === 2 ) {
587
        $this->collectTreeLinks( $data, $objs, $seen );
588
      }
589
590
      if( $type === 4 && preg_match( '/^object (.*)$/m', $data, $m ) ) {
591
        $this->collectObjectsRecursive( $m[1], $objs, $seen );
592
      }
593
    }
594
  }
595
596
  private function collectCommitLinks( $data, &$objs, &$seen ): void {
597
    if( preg_match( '/^tree (.*)$/m', $data, $m ) ) {
598
      $this->collectObjectsRecursive( $m[1], $objs, $seen );
599
    }
600
601
    if( preg_match( '/^parent (.*)$/m', $data, $m ) ) {
602
      $this->collectObjectsRecursive( $m[1], $objs, $seen );
603
    }
604
  }
605
606
  private function collectTreeLinks( $data, &$objs, &$seen ): void {
607
    $pos = 0;
608
    $len = strlen( $data );
609
610
    while( $pos < $len ) {
611
      $space = strpos( $data, ' ', $pos );
612
      $eos   = strpos( $data, "\0", $space );
613
614
      if( $space === false || $eos === false ) {
615
        break;
616
      }
617
618
      $sha   = bin2hex( substr( $data, $eos + 1, 20 ) );
619
      $this->collectObjectsRecursive( $sha, $objs, $seen );
620
      $pos   = $eos + 21;
621
    }
622
  }
623
624
  private function getObjectType( string $data ): int {
625
    $isTree = strpos($data, "tree ") === 0;
626
    $isObj  = strpos($data, "object ") === 0;
627
628
    return $isTree
629
      ? 1
630
      : ( $this->isTreeData($data) ? 2 : ( $isObj ? 4 : 3 ) );
631
  }
632
633
  public function generatePackfile( array $objs ): string {
634
    $pData = '';
635
636
    if( empty($objs) ) {
637
      $pData = "PACK" . pack( 'N', 2 ) . pack( 'N', 0 );
638
    }
639
640
    if( !empty($objs) ) {
641
      $data = '';
642
643
      foreach( $objs as $sha => $info ) {
644
        $cont  = $this->read( $sha );
645
        $size  = strlen($cont);
646
        $byte  = $info['type'] << 4 | $size & 0x0f;
647
        $size >>= 4;
648
649
        while( $size > 0 ) {
650
          $data .= chr( $byte | 0x80 );
651
          $byte  = $size & 0x7f;
652
          $size >>= 7;
653
        }
654
655
        $data .= chr( $byte ) . gzcompress( $cont );
656
      }
657
658
      $pData = "PACK" . pack( 'N', 2 ) . pack( 'N', count($objs) ) . $data;
659
    }
660
661
    return $pData . hash( 'sha1', $pData, true );
662
  }
663
}
664
665
class MissingFile extends File {
666
  public function __construct() {
667
    parent::__construct( '', '', '0', 0, 0, '' );
668
  }
669
670
  public function emitRawHeaders(): void {
671
    header( "HTTP/1.1 404 Not Found" );
672
    exit;
475673
  }
476674
}
M git/GitDiff.php
1010
  }
1111
12
  public function diff( string $oldSha, string $newSha ) {
13
    $oldTree = $oldSha ? $this->getTreeHash( $oldSha ) : null;
14
    $newTree = $newSha ? $this->getTreeHash( $newSha ) : null;
15
16
    return $this->diffTrees( $oldTree, $newTree );
17
  }
18
1219
  public function compare( string $commitHash ) {
1320
    $commitData = $this->git->read( $commitHash );
M pages/BasePage.php
2121
      <title><?php
2222
        echo Config::SITE_TITLE .
23
          ($this->title ? ' - ' . htmlspecialchars( $this->title ) : '');
23
          ( $this->title ? ' - ' . htmlspecialchars( $this->title ) : '' );
2424
      ?></title>
25
      <link rel="stylesheet"
26
            href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
27
      <link rel="stylesheet" href="repo.css">
25
      <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
26
      <link rel="stylesheet" href="/styles/repo.css">
2827
    </head>
2928
    <body>
3029
    <div class="container">
3130
      <header>
3231
        <h1><?php echo Config::SITE_TITLE; ?></h1>
3332
        <nav class="nav">
3433
          <a href="<?php echo (new UrlBuilder())->build(); ?>">Home</a>
3534
          <?php if( $currentRepo ):
3635
            $safeName = $currentRepo['safe_name'];
37
            $repoUrl = (new UrlBuilder())->withRepo( $safeName );
3836
            ?>
39
            <a href="<?php echo $repoUrl->build(); ?>">Files</a>
37
            <a href="<?php echo (new UrlBuilder())->withRepo( $safeName )->withAction( 'tree' )->build(); ?>">Files</a>
4038
            <a href="<?php echo (new UrlBuilder())->withRepo( $safeName )->withAction( 'commits' )->build(); ?>">Commits</a>
41
            <a href="<?php echo (new UrlBuilder())->withRepo( $safeName )->withAction( 'refs' )->build(); ?>">Branches</a>
4239
            <a href="<?php echo (new UrlBuilder())->withRepo( $safeName )->withAction( 'tags' )->build(); ?>">Tags</a>
4340
          <?php endif; ?>
...
5754
          <?php endif; ?>
5855
        </nav>
59
60
        <?php if( $currentRepo ): ?>
61
          <div class="repo-info-banner">
62
            <span class="current-repo">
63
              Current: <strong><?php
64
                echo htmlspecialchars( $currentRepo['name'] );
65
              ?></strong>
66
            </span>
67
          </div>
68
        <?php endif; ?>
6956
      </header>
7057
...
7865
7966
  protected function renderBreadcrumbs( $repo, $trail = [] ) {
80
    $home = (new UrlBuilder())->build();
8167
    $repoUrl = (new UrlBuilder())->withRepo( $repo['safe_name'] )->build();
8268
8369
    $parts = array_merge(
8470
      [
85
        '<a href="' . $home . '">Repositories</a>',
86
        '<a href="' . $repoUrl . '">' . htmlspecialchars( $repo['name'] ) . '</a>'
71
        '<a href="' . $repoUrl . '" class="repo-breadcrumb">' .
72
          htmlspecialchars( $repo['name'] ) . '</a>'
8773
      ],
8874
      $trail
M pages/ClonePage.php
1212
1313
  public function render() {
14
    if( $this->subPath === 'info/refs' ) {
14
    if( $this->subPath === '' ) {
15
      $this->redirectBrowser();
16
    } elseif( str_ends_with( $this->subPath, 'info/refs' ) ) {
1517
      $this->renderInfoRefs();
16
      return;
18
    } elseif( str_ends_with( $this->subPath, 'git-upload-pack' ) ) {
19
      $this->handleUploadPack();
20
    } elseif( str_ends_with( $this->subPath, 'git-receive-pack' ) ) {
21
      http_response_code( 403 );
22
      echo "Read-only repository.";
23
    } elseif( $this->subPath === 'HEAD' ) {
24
      $this->serve( 'HEAD', 'text/plain' );
25
    } elseif( strpos( $this->subPath, 'objects/' ) === 0 ) {
26
      $this->serve( $this->subPath, 'application/x-git-object' );
27
    } else {
28
      http_response_code( 404 );
29
      echo "Not Found";
1730
    }
1831
19
    if( $this->subPath === 'HEAD' ) {
20
      $this->serve( 'HEAD', 'text/plain' );
21
      return;
32
    exit;
33
  }
34
35
  private function redirectBrowser(): void {
36
    $url = str_replace( '.git', '', $_SERVER['REQUEST_URI'] );
37
38
    header( "Location: $url" );
39
  }
40
41
  private function renderInfoRefs(): void {
42
    $service = $_GET['service'] ?? '';
43
44
    if( $service === 'git-upload-pack' ) {
45
      header( 'Content-Type: application/x-git-upload-pack-advertisement' );
46
      header( 'Cache-Control: no-cache' );
47
48
      $this->packetWrite( "# service=git-upload-pack\n" );
49
      $this->packetFlush();
50
51
      $refs = [];
52
      $this->git->eachRef( function( $ref, $sha ) use ( &$refs ) {
53
        $refs[] = ['ref' => $ref, 'sha' => $sha];
54
      } );
55
56
      $caps = "multi_ack_detailed thin-pack side-band side-band-64k " .
57
              "ofs-delta shallow no-progress include-tag";
58
59
      if( empty( $refs ) ) {
60
        $this->packetWrite(
61
          "0000000000000000000000000000000000000000 capabilities^{}\0" .
62
          $caps . "\n"
63
        );
64
      } else {
65
        $this->packetWrite(
66
          $refs[0]['sha'] . " " . $refs[0]['ref'] . "\0" . $caps . "\n"
67
        );
68
69
        for( $i = 1; $i < count( $refs ); $i++ ) {
70
          $this->packetWrite(
71
            $refs[$i]['sha'] . " " . $refs[$i]['ref'] . "\n"
72
          );
73
        }
74
      }
75
76
      $this->packetFlush();
77
    } else {
78
      header( 'Content-Type: text/plain' );
79
80
      if( !$this->git->streamRaw( 'info/refs' ) ) {
81
        $this->git->eachRef( function( $ref, $sha ) {
82
          echo "$sha\t$ref\n";
83
        } );
84
      }
2285
    }
86
  }
2387
24
    if( strpos( $this->subPath, 'objects/' ) === 0 ) {
25
      $this->serve( $this->subPath, 'application/x-git-object' );
26
      return;
88
  private function handleUploadPack(): void {
89
    header( 'Content-Type: application/x-git-upload-pack-result' );
90
    header( 'Cache-Control: no-cache' );
91
92
    $input = file_get_contents( 'php://input' );
93
    $wants = [];
94
    $haves = [];
95
    $offset = 0;
96
97
    while( $offset < strlen( $input ) ) {
98
      $line = $this->readPacketLine( $input, $offset );
99
100
      if( $line === null || $line === 'done' ) {
101
        break;
102
      }
103
104
      if( $line === '' ) {
105
        continue;
106
      }
107
108
      $trim = trim( $line );
109
110
      if( strpos( $trim, 'want ' ) === 0 ) {
111
        $wants[] = explode( ' ', $trim )[1];
112
      } elseif( strpos( $trim, 'have ' ) === 0 ) {
113
        $haves[] = explode( ' ', $trim )[1];
114
      }
27115
    }
28116
29
    $this->serve( $this->subPath, 'text/plain' );
117
    if( $wants ) {
118
      $this->packetWrite( "NAK\n" );
119
120
      $objects = $this->git->collectObjects( $wants, $haves );
121
      $pack    = $this->git->generatePackfile( $objects );
122
123
      $this->sendSidebandData( 1, $pack );
124
    }
125
126
    $this->packetFlush();
30127
  }
31128
32
  private function renderInfoRefs(): void {
33
    header( 'Content-Type: text/plain' );
129
  private function sendSidebandData( int $band, string $data ): void {
130
    $chunkSize = 65000;
131
    $len = strlen( $data );
34132
35
    if( $this->git->streamRaw( 'info/refs' ) ) {
36
      exit;
133
    for( $offset = 0; $offset < $len; $offset += $chunkSize ) {
134
      $chunk = substr( $data, $offset, $chunkSize );
135
136
      $this->packetWrite( chr( $band ) . $chunk );
37137
    }
138
  }
38139
39
    $this->git->eachRef( function( $ref, $sha ) {
40
      echo "$sha\t$ref\n";
41
    } );
140
  private function readPacketLine( string $input, int &$offset ): ?string {
141
    $line = null;
42142
43
    exit;
143
    if( $offset + 4 <= strlen( $input ) ) {
144
      $lenHex = substr( $input, $offset, 4 );
145
146
      if( ctype_xdigit( $lenHex ) ) {
147
        $len = hexdec( $lenHex );
148
        $offset += 4;
149
        $valid = $len >= 4 && $offset + ( $len - 4 ) <= strlen( $input );
150
151
        $line = ($len === 0)
152
          ? ''
153
          : ($valid ? substr( $input, $offset, $len - 4 ) : null);
154
155
        $offset += ($len >= 4) ? ($len - 4) : 0;
156
      }
157
    }
158
159
    return $line;
44160
  }
45161
46162
  private function serve( string $path, string $contentType ): void {
47163
    header( 'Content-Type: ' . $contentType );
48
49
    $success = $this->git->streamRaw( $path );
50164
51
    if( !$success ) {
165
    if( !$this->git->streamRaw( $path ) ) {
52166
      http_response_code( 404 );
53
      echo "File not found: $path";
167
      echo "Missing: $path";
54168
    }
169
  }
55170
56
    exit;
171
  private function packetWrite( string $data ): void {
172
    printf( "%04x%s", strlen( $data ) + 4, $data );
173
  }
174
175
  private function packetFlush(): void {
176
    echo "0000";
57177
  }
58178
}
A pages/ComparePage.php
1
<?php
2
require_once __DIR__ . '/BasePage.php';
3
require_once __DIR__ . '/../git/GitDiff.php';
4
5
class ComparePage extends BasePage {
6
  private $currentRepo;
7
  private $git;
8
  private $newSha;
9
  private $oldSha;
10
11
  public function __construct(
12
    array $repositories,
13
    array $currentRepo,
14
    Git $git,
15
    string $newSha,
16
    string $oldSha
17
  ) {
18
    parent::__construct( $repositories );
19
    $this->currentRepo = $currentRepo;
20
    $this->git = $git;
21
    $this->newSha = $newSha;
22
    $this->oldSha = $oldSha;
23
    $this->title = $currentRepo['name'] . ' - Compare';
24
  }
25
26
  public function render() {
27
    $this->renderLayout( function() {
28
      $shortNew = substr( $this->newSha, 0, 7 );
29
      $shortOld = substr( $this->oldSha, 0, 7 );
30
31
      $this->renderBreadcrumbs(
32
        $this->currentRepo,
33
        ["Compare $shortNew...$shortOld"]
34
      );
35
36
      $differ = new GitDiff( $this->git );
37
      $changes = $differ->diff( $this->oldSha, $this->newSha );
38
39
      if( empty( $changes ) ) {
40
        echo '<div class="empty-state"><h3>No changes</h3>' .
41
             '<p>No differences.</p></div>';
42
        return;
43
      }
44
45
      foreach( $changes as $change ) {
46
        $this->renderDiffFile( $change );
47
      }
48
    }, $this->currentRepo );
49
  }
50
51
  private function renderDiffFile( array $change ) {
52
    $typeMap = [
53
      'A' => 'added',
54
      'D' => 'deleted',
55
      'M' => 'modified'
56
    ];
57
58
    $statusClass = $typeMap[$change['type']] ?? 'modified';
59
    $path = htmlspecialchars( $change['path'] );
60
61
    echo '<div class="diff-file">';
62
    echo '<div class="diff-header ' . $statusClass . '">';
63
    echo '<span class="diff-status-icon">' . $change['type'] . '</span> ';
64
    echo $path;
65
    echo '</div>';
66
67
    if( $change['is_binary'] ) {
68
      echo '<div class="diff-content binary">Binary file</div>';
69
    } elseif( !empty( $change['hunks'] ) ) {
70
      echo '<div class="diff-content">';
71
      echo '<table class="diff-table"><tbody>';
72
73
      foreach( $change['hunks'] as $line ) {
74
        $this->renderDiffLine( $line );
75
      }
76
77
      echo '</tbody></table>';
78
      echo '</div>';
79
    }
80
81
    echo '</div>';
82
  }
83
84
  private function renderDiffLine( array $line ) {
85
    if( isset( $line['t'] ) && $line['t'] === 'gap' ) {
86
      echo '<tr class="diff-gap"><td colspan="3">...</td></tr>';
87
      return;
88
    }
89
90
    $class = match( $line['t'] ) {
91
      '+' => 'diff-add',
92
      '-' => 'diff-del',
93
      default => ''
94
    };
95
96
    echo '<tr class="' . $class . '">';
97
    echo '<td class="diff-line-num">' . ($line['no'] ?? '') . '</td>';
98
    echo '<td class="diff-line-num">' . ($line['nn'] ?? '') . '</td>';
99
    echo '<td class="diff-code"><pre>' .
100
         htmlspecialchars( $line['l'] ?? '' ) . '</pre></td>';
101
    echo '</tr>';
102
  }
103
}
1104
M pages/FilePage.php
1111
  private $git;
1212
  private $hash;
13
  private $path;
1314
1415
  public function __construct(
1516
    array $repositories,
1617
    array $currentRepo,
1718
    Git $git,
18
    string $hash = ''
19
    string $hash = 'HEAD',
20
    string $path = ''
1921
  ) {
2022
    parent::__construct( $repositories );
2123
    $this->currentRepo = $currentRepo;
2224
    $this->git = $git;
23
    $this->hash = $hash;
25
    $this->hash = $hash ?: 'HEAD';
26
    $this->path = $path;
2427
    $this->title = $currentRepo['name'];
2528
  }
...
3235
        echo '<div class="empty-state"><h3>No branches</h3></div>';
3336
      } else {
34
        $target = $this->hash ?: $main['hash'];
37
        $target  = $this->hash;
3538
        $entries = [];
3639
3740
        $this->git->walk( $target, function( $file ) use ( &$entries ) {
3841
          $entries[] = $file;
39
        } );
42
        }, $this->path );
4043
41
        if( !empty( $entries ) ) {
44
        if( !empty( $entries ) && !$this->isExactFileMatch( $entries ) ) {
4245
          $this->renderTree( $main, $target, $entries );
4346
        } else {
4447
          $this->renderBlob( $target );
4548
        }
4649
      }
4750
    }, $this->currentRepo );
4851
  }
4952
50
  private function renderTree( $main, $targetHash, $entries ) {
51
    $path = $_GET['name'] ?? '';
53
  private function isExactFileMatch($entries) {
54
     return count( $entries ) === 1 &&
55
            $entries[0]->isName( basename( $this->path ) ) &&
56
            !$entries[0]->isDir;
57
  }
5258
53
    $this->emitBreadcrumbs( $targetHash, 'Tree', $path );
59
  private function renderTree( $main, $targetHash, $entries ) {
60
    $this->emitBreadcrumbs( $targetHash, 'Tree', $this->path );
5461
5562
    echo '<h2>' . htmlspecialchars( $this->currentRepo['name'] ) .
5663
         ' <span class="branch-badge">' .
57
         htmlspecialchars( $main['name'] ) . '</span></h2>';
64
         htmlspecialchars( $targetHash ) . '</span></h2>';
5865
5966
    usort( $entries, function( $a, $b ) {
6067
      return $a->compare( $b );
6168
    } );
6269
6370
    echo '<table class="file-list-table">';
64
    echo '<thead>';
65
    echo '<tr>';
66
    echo '<th></th>';
67
    echo '<th>Name</th>';
68
    echo '<th class="file-mode-cell">Mode</th>';
69
    echo '<th class="file-size-cell">Size</th>';
70
    echo '</tr>';
71
    echo '</thead>';
71
    echo '<thead><tr><th></th><th>Name</th><th class="file-mode-cell">Mode</th><th class="file-size-cell">Size</th></tr></thead>';
7272
    echo '<tbody>';
7373
74
    $currentPath = $this->hash ? $path : '';
7574
    $renderer = new HtmlFileRenderer(
76
      $this->currentRepo['safe_name'], $currentPath
75
      $this->currentRepo['safe_name'],
76
      $this->path,
77
      $targetHash
7778
    );
7879
7980
    foreach( $entries as $file ) {
8081
      $file->renderListEntry( $renderer );
8182
    }
8283
83
    echo '</tbody>';
84
    echo '</table>';
84
    echo '</tbody></table>';
8585
  }
8686
8787
  private function renderBlob( $targetHash ) {
88
    $filename = $_GET['name'] ?? '';
88
    $filename = $this->path;
8989
    $file = $this->git->readFile( $targetHash, $filename );
90
    $size = $this->git->getObjectSize( $targetHash );
91
    $renderer = new HtmlFileRenderer( $this->currentRepo['safe_name'] );
92
93
    $this->emitBreadcrumbs( $targetHash, 'File', $filename );
90
    $size = $this->git->getObjectSize( $targetHash, $filename );
9491
95
    if( $size === 0 ) {
96
      $this->renderDownloadState( $targetHash, "This file is empty." );
97
    } else {
98
      $rawUrl = (new UrlBuilder())
99
        ->withRepo( $this->currentRepo['safe_name'] )
100
        ->withAction( 'raw' )
101
        ->withHash( $targetHash )
102
        ->withName( $filename )
103
        ->build();
92
    $renderer = new HtmlFileRenderer( $this->currentRepo['safe_name'], dirname($filename), $targetHash );
10493
105
      if( !$file->renderMedia( $renderer, $rawUrl ) ) {
106
        if( $file->isText() ) {
107
          if( $size > self::MAX_DISPLAY_SIZE ) {
108
            ob_start();
109
            $file->renderSize( $renderer );
110
            $sizeStr = ob_get_clean();
94
    $this->emitBreadcrumbs( $targetHash, 'File', $filename );
11195
112
            $this->renderDownloadState(
113
              $targetHash,
114
              "File is too large to display ($sizeStr)."
115
            );
116
          } else {
117
            $content = '';
96
    if( $size === 0 && !$file ) {
97
       echo '<div class="empty-state">File not found.</div>';
98
       return;
99
    }
118100
119
            $this->git->stream( $targetHash, function( $d ) use ( &$content ) {
120
              $content .= $d;
121
            } );
101
    $rawUrl = (new UrlBuilder())
102
      ->withRepo( $this->currentRepo['safe_name'] )
103
      ->withAction( 'raw' )
104
      ->withHash( $targetHash )
105
      ->withName( $filename )
106
      ->build();
122107
123
            if( $size > self::MAX_HIGHLIGHT_SIZE ) {
124
              echo '<div class="blob-content"><pre class="blob-code">' .
125
                   htmlspecialchars( $content ) . '</pre></div>';
126
            } else {
127
              echo '<div class="blob-content"><pre class="blob-code">' .
128
                   $file->highlight( $renderer, $content ) . '</pre></div>';
129
            }
130
          }
108
    if( !$file->renderMedia( $renderer, $rawUrl ) ) {
109
      if( $file->isText() ) {
110
        if( $size > self::MAX_DISPLAY_SIZE ) {
111
          ob_start();
112
          $file->renderSize( $renderer );
113
          $sizeStr = ob_get_clean();
114
          $this->renderDownloadState( $targetHash, "File is too large to display ($sizeStr)." );
131115
        } else {
132
          $this->renderDownloadState( $targetHash, "This is a binary file." );
116
          $content = '';
117
          $this->git->stream( $targetHash, function( $d ) use ( &$content ) {
118
            $content .= $d;
119
          }, $filename );
120
121
          echo '<div class="blob-content"><pre class="blob-code">' .
122
               ($size > self::MAX_HIGHLIGHT_SIZE
123
                  ? htmlspecialchars( $content )
124
                  : $file->highlight( $renderer, $content )) .
125
               '</pre></div>';
133126
        }
127
      } else {
128
        $this->renderDownloadState( $targetHash, "This is a binary file." );
134129
      }
135130
    }
136131
  }
137132
138133
  private function renderDownloadState( $hash, $reason ) {
139
    $filename = $_GET['name'] ?? '';
140134
    $url = (new UrlBuilder())
141135
        ->withRepo( $this->currentRepo['safe_name'] )
142136
        ->withAction( 'raw' )
143137
        ->withHash( $hash )
144
        ->withName( $filename )
138
        ->withName( $this->path )
145139
        ->build();
146140
...
166160
          $url = (new UrlBuilder())
167161
            ->withRepo( $this->currentRepo['safe_name'] )
162
            ->withAction( 'tree' )
163
            ->withHash( $hash )
168164
            ->withName( $acc )
169165
            ->build();
170166
171
          $trail[] = '<a href="' . $url . '">' .
172
                      htmlspecialchars( $part ) . '</a>';
167
          $trail[] = '<a href="' . $url . '">' . htmlspecialchars( $part ) . '</a>';
173168
        }
174169
      }
175
    } elseif( $this->hash ) {
170
    } elseif( $hash ) {
176171
      $trail[] = $type . ' ' . substr( $hash, 0, 7 );
177172
    }
M pages/RawPage.php
77
88
  public function __construct( $git, $hash ) {
9
    $this->git = $git;
9
    $this->git  = $git;
1010
    $this->hash = $hash;
1111
  }
1212
1313
  public function render() {
14
    $filename = basename( $_GET['name'] ?? '' ) ?: 'file';
15
    $file = $this->git->readFile( $this->hash, $filename );
14
    $name = $_GET['name'] ?? '';
15
    $file = $this->git->readFile( $this->hash, $name );
1616
1717
    while( ob_get_level() ) {
1818
      ob_end_clean();
1919
    }
2020
2121
    $file->emitRawHeaders();
2222
    $this->git->stream( $this->hash, function( $d ) {
2323
      echo $d;
24
    } );
24
    }, $name );
2525
2626
    exit;
M pages/TagsPage.php
11
<?php
22
require_once __DIR__ . '/BasePage.php';
3
require_once __DIR__ . '/../render/TagRenderer.php';
3
require_once __DIR__ . '/../render/HtmlTagRenderer.php';
44
55
class TagsPage extends BasePage {
...
2121
    $this->renderLayout( function() {
2222
      $this->renderBreadcrumbs( $this->currentRepo, ['Tags'] );
23
2423
      echo '<h2>Tags</h2>';
25
      echo '<table class="tag-table">';
26
      echo '<thead>';
27
      echo '<tr>';
28
      echo '<th>Name</th>';
29
      echo '<th>Message</th>';
30
      echo '<th>Author</th>';
31
      echo '<th class="tag-age-header">Age</th>';
32
      echo '<th class="tag-commit-header">Commit</th>';
33
      echo '</tr>';
34
      echo '</thead>';
35
      echo '<tbody>';
3624
3725
      $tags = [];
38
3926
      $this->git->eachTag( function( Tag $tag ) use ( &$tags ) {
4027
        $tags[] = $tag;
4128
      } );
4229
4330
      usort( $tags, function( Tag $a, Tag $b ) {
4431
        return $a->compare( $b );
4532
      } );
46
47
      $renderer = new HtmlTagRenderer( $this->currentRepo['safe_name'] );
4833
4934
      if( empty( $tags ) ) {
50
        echo '<tr><td colspan="5"><div class="empty-state">' .
51
             '<p>No tags found.</p></div></td></tr>';
35
        echo '<p>No tags found.</p>';
5236
      } else {
53
        foreach( $tags as $tag ) {
54
          $tag->render( $renderer );
37
        $renderer = new HtmlTagRenderer( $this->currentRepo['safe_name'] );
38
39
        echo '<table class="tag-table">';
40
        echo '<thead>';
41
        echo '<tr>';
42
        echo '<th>Name</th><th>Message</th><th>Author</th>';
43
        echo '<th class="tag-age-header">Age</th>';
44
        echo '<th class="tag-commit-header">Commit</th>';
45
        echo '</tr>';
46
        echo '</thead>';
47
        echo '<tbody>';
48
49
        $count = count( $tags );
50
        for( $i = 0; $i < $count; $i++ ) {
51
          $tag = $tags[$i];
52
          $prevTag = $tags[$i + 1] ?? null;
53
          $tag->render( $renderer, $prevTag );
5554
        }
56
      }
5755
58
      echo '</tbody>';
59
      echo '</table>';
56
        echo '</tbody>';
57
        echo '</table>';
58
      }
6059
    }, $this->currentRepo );
6160
  }
M render/HtmlFileRenderer.php
77
  private string $repoSafeName;
88
  private string $currentPath;
9
  private string $currentRef;
910
10
  public function __construct( string $repoSafeName, string $currentPath = '' ) {
11
  public function __construct( string $repoSafeName, string $currentPath = '', string $currentRef = 'HEAD' ) {
1112
    $this->repoSafeName = $repoSafeName;
1213
    $this->currentPath = trim( $currentPath, '/' );
14
    $this->currentRef = $currentRef;
1315
  }
1416
...
2123
    int $size
2224
  ): void {
23
    $fullPath = ($this->currentPath === '' ? '' : $this->currentPath . '/') .
24
      $name;
25
    $fullPath = ($this->currentPath === '' ? '' : $this->currentPath . '/') . $name;
2526
26
    // 2. Refactor: Use UrlBuilder instead of manual string concatenation
27
    $isDir = ($mode === '40000' || $mode === '040000');
28
    $action = $isDir ? 'tree' : 'blob';
29
2730
    $url = (new UrlBuilder())
2831
      ->withRepo( $this->repoSafeName )
29
      ->withHash( $sha )
32
      ->withAction( $action )
33
      ->withHash( $this->currentRef )
3034
      ->withName( $fullPath )
3135
      ->build();
3236
3337
    echo '<tr>';
34
    echo '<td class="file-icon-cell">';
35
    echo '<i class="fas ' . $iconClass . '"></i>';
36
    echo '</td>';
37
    echo '<td class="file-name-cell">';
38
    echo '<a href="' . $url . '">' . htmlspecialchars( $name ) . '</a>';
39
    echo '</td>';
40
    echo '<td class="file-mode-cell">';
41
    echo $this->formatMode( $mode );
42
    echo '</td>';
43
    echo '<td class="file-size-cell">';
44
45
    if( $size > 0 ) {
46
      echo $this->formatSize( $size );
47
    }
48
49
    echo '</td>';
38
    echo '<td class="file-icon-cell"><i class="fas ' . $iconClass . '"></i></td>';
39
    echo '<td class="file-name-cell"><a href="' . $url . '">' . htmlspecialchars( $name ) . '</a></td>';
40
    echo '<td class="file-mode-cell">' . $this->formatMode( $mode ) . '</td>';
41
    echo '<td class="file-size-cell">' . ($size > 0 ? $this->formatSize( $size ) : '') . '</td>';
5042
    echo '</tr>';
5143
  }
52
53
  public function renderMedia(
54
    File $file,
55
    string $url,
56
    string $mediaType
57
  ): bool {
58
    $rendered = false;
5944
45
  public function renderMedia( File $file, string $url, string $mediaType ): bool {
6046
    if( $file->isImage() ) {
61
      echo '<div class="blob-content blob-content-image">' .
62
        '<img src="' . $url . '"></div>';
63
      $rendered = true;
47
      echo '<div class="blob-content blob-content-image"><img src="' . $url . '"></div>';
48
      return true;
6449
    } elseif( $file->isVideo() ) {
65
      echo '<div class="blob-content blob-content-video">' .
66
        '<video controls><source src="' . $url . '" type="' .
67
        $mediaType . '"></video></div>';
68
      $rendered = true;
50
      echo '<div class="blob-content blob-content-video"><video controls><source src="' . $url . '" type="' . $mediaType . '"></video></div>';
51
      return true;
6952
    } elseif( $file->isAudio() ) {
70
      echo '<div class="blob-content blob-content-audio">' .
71
        '<audio controls><source src="' . $url . '" type="' .
72
        $mediaType . '"></audio></div>';
73
      $rendered = true;
53
      echo '<div class="blob-content blob-content-audio"><audio controls><source src="' . $url . '" type="' . $mediaType . '"></audio></div>';
54
      return true;
7455
    }
75
76
    return $rendered;
56
    return false;
7757
  }
7858
7959
  public function renderSize( int $bytes ): void {
8060
    echo $this->formatSize( $bytes );
8161
  }
8262
83
  public function highlight(
84
    string $filename,
85
    string $content,
86
    string $mediaType
87
  ): string {
63
  public function highlight( string $filename, string $content, string $mediaType ): string {
8864
    return (new Highlighter($filename, $content, $mediaType))->render();
8965
  }
9066
9167
  public function renderTime( int $timestamp ): void {
9268
    $tokens = [
93
      31536000 => 'year',
94
      2592000 => 'month',
95
      604800 => 'week',
96
      86400 => 'day',
97
      3600 => 'hour',
98
      60 => 'minute',
99
      1 => 'second'
69
      31536000 => 'year', 2592000 => 'month', 604800 => 'week',
70
      86400 => 'day', 3600 => 'hour', 60 => 'minute', 1 => 'second'
10071
    ];
101
10272
    $diff = $timestamp ? time() - $timestamp : null;
10373
    $result = 'never';
10474
10575
    if( $diff && $diff >= 5 ) {
10676
      foreach( $tokens as $unit => $text ) {
107
        if( $diff < $unit ) {
108
          continue;
109
        }
110
77
        if( $diff < $unit ) continue;
11178
        $num = floor( $diff / $unit );
11279
        $result = $num . ' ' . $text . ($num > 1 ? 's' : '') . ' ago';
...
12592
12693
    while( $bytes >= 1024 && $i < count( $units ) - 1 ) {
127
      $bytes /= 1024;
128
      $i++;
94
      $bytes /= 1024; $i++;
12995
    }
13096
...
143109
  }
144110
}
145
146111
A render/HtmlTagRenderer.php
1
<?php
2
require_once __DIR__ . '/TagRenderer.php';
3
4
class HtmlTagRenderer implements TagRenderer {
5
  private string $repoSafeName;
6
7
  public function __construct( string $repoSafeName ) {
8
    $this->repoSafeName = $repoSafeName;
9
  }
10
11
  public function renderTagItem(
12
    string $name,
13
    string $sha,
14
    string $targetSha,
15
    ?string $prevTargetSha,
16
    int $timestamp,
17
    string $message,
18
    string $author
19
  ): void {
20
    $filesUrl = (new UrlBuilder())
21
      ->withRepo( $this->repoSafeName )
22
      ->withAction( 'tree' )
23
      ->withHash( $name )
24
      ->build();
25
26
    $commitUrl = (new UrlBuilder())
27
      ->withRepo( $this->repoSafeName )
28
      ->withAction( 'commit' )
29
      ->withHash( $targetSha )
30
      ->build();
31
32
    if( $prevTargetSha ) {
33
      $diffUrl = (new UrlBuilder())
34
        ->withRepo( $this->repoSafeName )
35
        ->withAction( 'compare' )
36
        ->withHash( $targetSha )
37
        ->withName( $prevTargetSha )
38
        ->build();
39
    } else {
40
      $diffUrl = $commitUrl;
41
    }
42
43
    echo '<tr>';
44
    echo '<td class="tag-name">';
45
    echo '<a href="' . $filesUrl . '"><i class="fas fa-tag"></i> ' .
46
         htmlspecialchars( $name ) . '</a>';
47
    echo '</td>';
48
    echo '<td class="tag-message">';
49
50
    echo ($message !== '') ? htmlspecialchars( strtok( $message, "\n" ) ) :
51
      '<span style="color: #484f58; font-style: italic;">No description</span>';
52
53
    echo '</td>';
54
    echo '<td class="tag-author">' . htmlspecialchars( $author ) . '</td>';
55
    echo '<td class="tag-time">';
56
    $this->renderTime( $timestamp );
57
    echo '</td>';
58
    echo '<td class="tag-hash">';
59
    echo '<a href="' . $diffUrl . '" class="commit-hash">' .
60
         substr( $sha, 0, 7 ) . '</a>';
61
    echo '</td>';
62
    echo '</tr>';
63
  }
64
65
  public function renderTime( int $timestamp ): void {
66
    if( !$timestamp ) {
67
      echo 'never';
68
      return;
69
    }
70
71
    $diff = time() - $timestamp;
72
73
    if( $diff < 5 ) {
74
      echo 'just now';
75
      return;
76
    }
77
78
    $tokens = [
79
      31536000 => 'year',
80
      2592000 => 'month',
81
      604800 => 'week',
82
      86400 => 'day',
83
      3600 => 'hour',
84
      60 => 'minute',
85
      1 => 'second'
86
    ];
87
88
    foreach( $tokens as $unit => $text ) {
89
      if( $diff < $unit ) {
90
        continue;
91
      }
92
93
      $num = floor( $diff / $unit );
94
95
      echo $num . ' ' . $text . ($num > 1 ? 's' : '') . ' ago';
96
      return;
97
    }
98
  }
99
}
1100
M render/LanguageDefinitions.php
148148
      ],
149149
      'xml' => [
150
        'comment'   => '/()/s',
150
        'comment'   => '/()/',
151151
        'string'    => '/' . $str . '/',
152
        'tag'       => '/(<\/?[a-zA-Z0-9:-]+|\s*\/?>|<\?xml|\?>)/',
152
        'tag'       => '/(<\/?[!a-zA-Z0-9:-]+|\s*\/?>|<\?xml|\?>)/',
153153
        'attribute' => '/([a-zA-Z0-9:-]+)(?=\=)/',
154154
      ],
155155
      'html' => [
156
        'comment'   => '/()/s',
156
        'comment'   => '/()/',
157157
        'string'    => '/' . $str . '/',
158
        'tag'       => '/(<\/?[a-zA-Z0-9:-]+|\s*\/?>)/',
158
        'tag'       => '/(<\/?[!a-zA-Z0-9:-]+|\s*\/?>)/',
159159
        'attribute' => '/([a-zA-Z0-9:-]+)(?=\=)/',
160160
      ],
M render/TagRenderer.php
55
    string $sha,
66
    string $targetSha,
7
    ?string $prevTargetSha,
78
    int $timestamp,
89
    string $message,
910
    string $author
1011
  ): void;
1112
1213
  public function renderTime( int $timestamp ): void;
13
}
14
15
class HtmlTagRenderer implements TagRenderer {
16
  private string $repoSafeName;
17
18
  public function __construct( string $repoSafeName ) {
19
    $this->repoSafeName = $repoSafeName;
20
  }
21
22
  public function renderTagItem(
23
    string $name,
24
    string $sha,
25
    string $targetSha,
26
    int $timestamp,
27
    string $message,
28
    string $author
29
  ): void {
30
    $repoParam = '&repo=' . urlencode( $this->repoSafeName );
31
    $filesUrl = '?hash=' . $targetSha . $repoParam;
32
    $commitUrl = '?action=commit&hash=' . $targetSha . $repoParam;
33
34
    echo '<tr>';
35
    echo '<td class="tag-name">';
36
    echo '<a href="' . $filesUrl . '"><i class="fas fa-tag"></i> ' .
37
         htmlspecialchars( $name ) . '</a>';
38
    echo '</td>';
39
    echo '<td class="tag-message">';
40
41
    echo ($message !== '') ? htmlspecialchars( strtok( $message, "\n" ) ) :
42
      '<span style="color: #484f58; font-style: italic;">No description</span>';
43
44
    echo '</td>';
45
    echo '<td class="tag-author">' . htmlspecialchars( $author ) . '</td>';
46
    echo '<td class="tag-time">';
47
    $this->renderTime( $timestamp );
48
    echo '</td>';
49
    echo '<td class="tag-hash">';
50
    echo '<a href="' . $commitUrl . '" class="commit-hash">' .
51
         substr( $sha, 0, 7 ) . '</a>';
52
    echo '</td>';
53
    echo '</tr>';
54
  }
55
56
  public function renderTime( int $timestamp ): void {
57
    if( !$timestamp ) {
58
      echo 'never';
59
      return;
60
    }
61
62
    $diff = time() - $timestamp;
63
64
    if( $diff < 5 ) {
65
      echo 'just now';
66
      return;
67
    }
68
69
    $tokens = [
70
      31536000 => 'year',
71
      2592000 => 'month',
72
      604800 => 'week',
73
      86400 => 'day',
74
      3600 => 'hour',
75
      60 => 'minute',
76
      1 => 'second'
77
    ];
78
79
    foreach( $tokens as $unit => $text ) {
80
      if( $diff < $unit ) {
81
        continue;
82
      }
83
84
      $num = floor( $diff / $unit );
85
86
      echo $num . ' ' . $text . ($num > 1 ? 's' : '') . ' ago';
87
      return;
88
    }
89
  }
9014
}
9115
D repo.css
1
* {
2
  margin: 0;
3
  padding: 0;
4
  box-sizing: border-box;
5
}
6
7
body {
8
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
9
  background: #0d1117;
10
  color: #c9d1d9;
11
  line-height: 1.6;
12
}
13
14
.container {
15
  max-width: 1200px;
16
  margin: 0 auto;
17
  padding: 20px;
18
}
19
20
header {
21
  border-bottom: 1px solid #30363d;
22
  padding-bottom: 20px;
23
  margin-bottom: 30px;
24
}
25
26
h1 {
27
  color: #f0f6fc;
28
  font-size: 1.8rem;
29
  margin-bottom: 10px;
30
}
31
32
h2 {
33
  color: #f0f6fc;
34
  font-size: 1.4rem;
35
  margin: 20px 0 15px;
36
  padding-bottom: 10px;
37
  border-bottom: 1px solid #21262d;
38
}
39
40
h3 {
41
  color: #f0f6fc;
42
  font-size: 1.1rem;
43
  margin: 15px 0 10px;
44
}
45
46
.nav {
47
  margin-top: 10px;
48
  display: flex;
49
  gap: 20px;
50
  flex-wrap: wrap;
51
  align-items: center;
52
}
53
54
.nav a {
55
  color: #58a6ff;
56
  text-decoration: none;
57
}
58
59
.nav a:hover {
60
  text-decoration: underline;
61
}
62
63
.repo-selector {
64
  margin-left: auto;
65
  display: flex;
66
  align-items: center;
67
  gap: 10px;
68
}
69
70
.repo-selector label {
71
  color: #8b949e;
72
  font-size: 0.875rem;
73
}
74
75
.repo-selector select {
76
  background: #21262d;
77
  color: #f0f6fc;
78
  border: 1px solid #30363d;
79
  padding: 6px 12px;
80
  border-radius: 6px;
81
  font-size: 0.875rem;
82
  cursor: pointer;
83
}
84
85
.repo-selector select:hover {
86
  border-color: #58a6ff;
87
}
88
89
.commit-list {
90
  list-style: none;
91
  margin-top: 20px;
92
}
93
94
.commit-item {
95
  background: #161b22;
96
  border: 1px solid #30363d;
97
  border-radius: 6px;
98
  padding: 16px;
99
  margin-bottom: 12px;
100
  transition: border-color 0.2s;
101
}
102
103
.commit-item:hover {
104
  border-color: #58a6ff;
105
}
106
107
.commit-hash {
108
  font-family: 'SFMono-Regular', Consolas, monospace;
109
  font-size: 0.85rem;
110
  color: #58a6ff;
111
  text-decoration: none;
112
}
113
114
.commit-meta {
115
  font-size: 0.875rem;
116
  color: #8b949e;
117
  margin-top: 8px;
118
}
119
120
.commit-author {
121
  color: #f0f6fc;
122
  font-weight: 500;
123
}
124
125
.commit-date {
126
  color: #8b949e;
127
}
128
129
.commit-message {
130
  margin-top: 8px;
131
  color: #c9d1d9;
132
  white-space: pre-wrap;
133
}
134
135
.file-list {
136
  background: #161b22;
137
  border: 1px solid #30363d;
138
  border-radius: 6px;
139
  overflow: hidden;
140
}
141
142
.file-item {
143
  display: flex;
144
  align-items: center;
145
  padding: 12px 16px;
146
  border-bottom: 1px solid #21262d;
147
  text-decoration: none;
148
  color: #c9d1d9;
149
  transition: background 0.2s;
150
}
151
152
.file-item:last-child {
153
  border-bottom: none;
154
}
155
156
.file-item:hover {
157
  background: #1f242c;
158
}
159
160
.file-mode {
161
  font-family: monospace;
162
  color: #8b949e;
163
  width: 80px;
164
  font-size: 0.875rem;
165
}
166
167
.file-name {
168
  flex: 1;
169
  color: #58a6ff;
170
}
171
172
.file-item:hover .file-name {
173
  text-decoration: underline;
174
}
175
176
.breadcrumb {
177
  background: #161b22;
178
  border: 1px solid #30363d;
179
  border-radius: 6px;
180
  padding: 12px 16px;
181
  margin-bottom: 20px;
182
  color: #8b949e;
183
}
184
185
.breadcrumb a {
186
  color: #58a6ff;
187
  text-decoration: none;
188
}
189
190
.breadcrumb a:hover {
191
  text-decoration: underline;
192
}
193
194
.blob-content {
195
  background: #161b22;
196
  border: 1px solid #30363d;
197
  border-radius: 6px;
198
  overflow: hidden;
199
  max-width: 100%;
200
}
201
202
.blob-header {
203
  background: #21262d;
204
  padding: 12px 16px;
205
  border-bottom: 1px solid #30363d;
206
  font-size: 0.875rem;
207
  color: #8b949e;
208
}
209
210
.blob-code {
211
  padding: 16px;
212
  overflow-x: auto;
213
  font-family: 'SFMono-Regular', Consolas, monospace;
214
  font-size: 0.875rem;
215
  line-height: 1.6;
216
  white-space: pre-wrap;
217
  overflow-wrap: break-word;
218
}
219
220
.blob-code pre {
221
    overflow-x: auto;
222
}
223
224
.refs-list {
225
  display: grid;
226
  gap: 10px;
227
}
228
229
.ref-item {
230
  background: #161b22;
231
  border: 1px solid #30363d;
232
  border-radius: 6px;
233
  padding: 12px 16px;
234
  display: flex;
235
  align-items: center;
236
  gap: 12px;
237
}
238
239
.ref-type {
240
  background: #238636;
241
  color: white;
242
  padding: 2px 8px;
243
  border-radius: 12px;
244
  font-size: 0.75rem;
245
  font-weight: 600;
246
  text-transform: uppercase;
247
}
248
249
.ref-type.tag {
250
  background: #8957e5;
251
}
252
253
.ref-name {
254
  font-weight: 600;
255
  color: #f0f6fc;
256
}
257
258
.empty-state {
259
  text-align: center;
260
  padding: 60px 20px;
261
  color: #8b949e;
262
}
263
264
.commit-details {
265
  background: #161b22;
266
  border: 1px solid #30363d;
267
  border-radius: 6px;
268
  padding: 20px;
269
  margin-bottom: 20px;
270
}
271
272
.commit-header {
273
  margin-bottom: 20px;
274
}
275
276
.commit-title {
277
  font-size: 1.25rem;
278
  color: #f0f6fc;
279
  margin-bottom: 10px;
280
}
281
282
.commit-info {
283
  display: grid;
284
  gap: 8px;
285
  font-size: 0.875rem;
286
}
287
288
.commit-info-row {
289
  display: flex;
290
  gap: 10px;
291
}
292
293
.commit-info-label {
294
  color: #8b949e;
295
  width: 80px;
296
  flex-shrink: 0;
297
}
298
299
.commit-info-value {
300
  color: #c9d1d9;
301
  font-family: monospace;
302
}
303
304
.parent-link {
305
  color: #58a6ff;
306
  text-decoration: none;
307
}
308
309
.parent-link:hover {
310
  text-decoration: underline;
311
}
312
313
.repo-grid {
314
  display: grid;
315
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
316
  gap: 16px;
317
  margin-top: 20px;
318
}
319
320
.repo-card {
321
  background: #161b22;
322
  border: 1px solid #30363d;
323
  border-radius: 8px;
324
  padding: 20px;
325
  text-decoration: none;
326
  color: inherit;
327
  transition: border-color 0.2s, transform 0.1s;
328
}
329
330
.repo-card:hover {
331
  border-color: #58a6ff;
332
  transform: translateY(-2px);
333
}
334
335
.repo-card h3 {
336
  color: #58a6ff;
337
  margin-bottom: 8px;
338
  font-size: 1.1rem;
339
}
340
341
.repo-card p {
342
  color: #8b949e;
343
  font-size: 0.875rem;
344
  margin: 0;
345
}
346
347
.current-repo {
348
  background: #21262d;
349
  border: 1px solid #58a6ff;
350
  padding: 8px 16px;
351
  border-radius: 6px;
352
  font-size: 0.875rem;
353
  color: #f0f6fc;
354
}
355
356
.current-repo strong {
357
  color: #58a6ff;
358
}
359
360
.branch-badge {
361
  background: #238636;
362
  color: white;
363
  padding: 2px 8px;
364
  border-radius: 12px;
365
  font-size: 0.75rem;
366
  font-weight: 600;
367
  margin-left: 10px;
368
}
369
370
.commit-row {
371
  display: flex;
372
  padding: 10px 0;
373
  border-bottom: 1px solid #30363d;
374
  gap: 15px;
375
  align-items: baseline;
376
}
377
378
.commit-row:last-child {
379
  border-bottom: none;
380
}
381
382
.commit-row .sha {
383
  font-family: monospace;
384
  color: #58a6ff;
385
  text-decoration: none;
386
}
387
388
.commit-row .message {
389
  flex: 1;
390
  font-weight: 500;
391
}
392
393
.commit-row .meta {
394
  font-size: 0.85em;
395
  color: #8b949e;
396
  white-space: nowrap;
397
}
398
399
.blob-content-image {
400
  text-align: center;
401
  padding: 20px;
402
  background: #0d1117;
403
}
404
405
.blob-content-image img {
406
  max-width: 100%;
407
  border: 1px solid #30363d;
408
}
409
410
.blob-content-video {
411
  text-align: center;
412
  padding: 20px;
413
  background: #000;
414
}
415
416
.blob-content-video video {
417
  max-width: 100%;
418
  max-height: 80vh;
419
}
420
421
.blob-content-audio {
422
  text-align: center;
423
  padding: 40px;
424
  background: #161b22;
425
}
426
427
.blob-content-audio audio {
428
  width: 100%;
429
  max-width: 600px;
430
}
431
432
.download-state {
433
  text-align: center;
434
  padding: 40px;
435
  border: 1px solid #30363d;
436
  border-radius: 6px;
437
  margin-top: 10px;
438
}
439
440
.download-state p {
441
  margin-bottom: 20px;
442
  color: #8b949e;
443
}
444
445
.btn-download {
446
  display: inline-block;
447
  padding: 6px 16px;
448
  background: #238636;
449
  color: white;
450
  text-decoration: none;
451
  border-radius: 6px;
452
  font-weight: 600;
453
}
454
455
.repo-info-banner {
456
  margin-top: 15px;
457
}
458
459
.file-icon-container {
460
  width: 20px;
461
  text-align: center;
462
  margin-right: 5px;
463
  color: #8b949e;
464
}
465
466
.file-size {
467
  color: #8b949e;
468
  font-size: 0.8em;
469
  margin-left: 10px;
470
}
471
472
.file-date {
473
  color: #8b949e;
474
  font-size: 0.8em;
475
  margin-left: auto;
476
}
477
478
.repo-card-time {
479
  margin-top: 8px;
480
  color: #58a6ff;
481
}
482
483
484
.diff-container {
485
  display: flex;
486
  flex-direction: column;
487
  gap: 20px;
488
}
489
490
.diff-file {
491
  background: #161b22;
492
  border: 1px solid #30363d;
493
  border-radius: 6px;
494
  overflow: hidden;
495
}
496
497
.diff-header {
498
  background: #21262d;
499
  padding: 10px 16px;
500
  border-bottom: 1px solid #30363d;
501
  display: flex;
502
  align-items: center;
503
  gap: 10px;
504
}
505
506
.diff-path {
507
  font-family: monospace;
508
  font-size: 0.9rem;
509
  color: #f0f6fc;
510
}
511
512
.diff-binary {
513
  padding: 20px;
514
  text-align: center;
515
  color: #8b949e;
516
  font-style: italic;
517
}
518
519
.diff-content {
520
  overflow-x: auto;
521
}
522
523
.diff-content table {
524
  width: 100%;
525
  border-collapse: collapse;
526
  font-family: 'SFMono-Regular', Consolas, monospace;
527
  font-size: 12px;
528
}
529
530
.diff-content td {
531
  padding: 2px 0;
532
  line-height: 20px;
533
}
534
535
.diff-num {
536
  width: 1%;
537
  min-width: 40px;
538
  text-align: right;
539
  padding-right: 10px;
540
  color: #6e7681;
541
  user-select: none;
542
  background: #0d1117;
543
  border-right: 1px solid #30363d;
544
}
545
546
.diff-num::before {
547
  content: attr(data-num);
548
}
549
550
.diff-code {
551
  padding-left: 10px;
552
  white-space: pre-wrap;
553
  word-break: break-all;
554
  color: #c9d1d9;
555
}
556
557
.diff-marker {
558
  display: inline-block;
559
  width: 15px;
560
  user-select: none;
561
  color: #8b949e;
562
}
563
564
/* Protanopia Safe Colors: Blue (Add) and Yellow (Del) */
565
.diff-add {
566
  background-color: rgba(2, 59, 149, 0.25);
567
}
568
.diff-add .diff-code {
569
  color: #79c0ff;
570
}
571
.diff-add .diff-marker {
572
  color: #79c0ff;
573
}
574
575
.diff-del {
576
  background-color: rgba(148, 99, 0, 0.25);
577
}
578
.diff-del .diff-code {
579
  color: #d29922;
580
}
581
.diff-del .diff-marker {
582
  color: #d29922;
583
}
584
585
.diff-gap {
586
  background: #0d1117;
587
  color: #484f58;
588
  text-align: center;
589
  font-size: 0.8em;
590
  height: 20px;
591
}
592
.diff-gap td {
593
  padding: 0;
594
  line-height: 20px;
595
  background: rgba(110, 118, 129, 0.1);
596
}
597
598
.status-add { color: #58a6ff; }
599
.status-del { color: #d29922; }
600
.status-mod { color: #a371f7; }
601
602
.tag-table, .file-list-table {
603
  width: 100%;
604
  border-collapse: collapse;
605
  margin-top: 10px;
606
  background: #161b22;
607
  border: 1px solid #30363d;
608
  border-radius: 6px;
609
  overflow: hidden;
610
}
611
612
.tag-table th, .file-list-table th {
613
  text-align: left;
614
  padding: 10px 16px;
615
  border-bottom: 2px solid #30363d;
616
  color: #8b949e;
617
  font-size: 0.875rem;
618
  font-weight: 600;
619
  white-space: nowrap;
620
}
621
622
.tag-table td, .file-list-table td {
623
  padding: 12px 16px;
624
  border-bottom: 1px solid #21262d;
625
  vertical-align: middle;
626
  color: #c9d1d9;
627
  font-size: 0.9rem;
628
}
629
630
.tag-table tr:hover td, .file-list-table tr:hover td {
631
  background: #161b22;
632
}
633
634
.tag-table .tag-name {
635
  min-width: 140px;
636
  width: 20%;
637
}
638
639
.tag-table .tag-message {
640
  width: auto;
641
  white-space: normal;
642
  word-break: break-word;
643
  color: #c9d1d9;
644
  font-weight: 500;
645
}
646
647
.tag-table .tag-author,
648
.tag-table .tag-time,
649
.tag-table .tag-hash {
650
  width: 1%;
651
  white-space: nowrap;
652
}
653
654
.tag-table .tag-time {
655
  text-align: right;
656
  color: #8b949e;
657
}
658
659
.tag-table .tag-hash {
660
  text-align: right;
661
}
662
663
.tag-table .tag-name a {
664
  color: #58a6ff;
665
  text-decoration: none;
666
  font-family: 'SFMono-Regular', Consolas, monospace;
667
}
668
669
.tag-table .tag-author {
670
  color: #c9d1d9;
671
}
672
673
.tag-table .tag-age-header {
674
  text-align: right;
675
}
676
677
.tag-table .tag-commit-header {
678
  text-align: right;
679
}
680
681
.tag-table .commit-hash {
682
  font-family: 'SFMono-Regular', Consolas, monospace;
683
  color: #58a6ff;
684
  text-decoration: none;
685
}
686
687
.tag-table .commit-hash:hover {
688
  text-decoration: underline;
689
}
690
691
.file-list-table .file-icon-cell {
692
    width: 20px;
693
    text-align: center;
694
    color: #8b949e;
695
    padding-right: 0;
696
}
697
698
.file-list-table .file-name-cell a {
699
    color: #58a6ff;
700
    text-decoration: none;
701
    font-weight: 500;
702
}
703
704
.file-list-table .file-name-cell a:hover {
705
    text-decoration: underline;
706
}
707
708
.file-list-table .file-mode-cell {
709
    font-family: 'SFMono-Regular', Consolas, monospace;
710
    color: #8b949e;
711
    font-size: 0.8rem;
712
    width: 1%;
713
    white-space: nowrap;
714
    text-align: center;
715
}
716
717
.file-list-table .file-size-cell {
718
    color: #8b949e;
719
    text-align: right;
720
    width: 1%;
721
    white-space: nowrap;
722
    font-size: 0.85rem;
723
}
724
725
.file-list-table .file-date-cell {
726
    color: #8b949e;
727
    text-align: right;
728
    width: 150px;
729
    font-size: 0.85rem;
730
    white-space: nowrap;
731
}
732
733
734
.blob-code {
735
  font-family: 'SFMono-Regular', Consolas, monospace;
736
  background-color: #161b22;
737
  color: #fcfcfa;
738
  font-size: 0.875rem;
739
  line-height: 1.6;
740
  tab-size: 2;
741
}
742
743
.hl-comment,
744
.hl-doc-comment {
745
  color: #727072;
746
  font-style: italic;
747
}
748
749
.hl-function,
750
.hl-method {
751
  color: #78dce8;
752
}
753
754
.hl-tag {
755
  color: #3e8bff;
756
}
757
758
.hl-class,
759
.hl-interface,
760
.hl-struct {
761
  color: #a9dc76;
762
}
763
764
.hl-type {
765
  color: #a9dc76;
766
}
767
768
.hl-keyword,
769
.hl-storage,
770
.hl-modifier,
771
.hl-statement {
772
  color: #ff6188;
773
  font-weight: 600;
774
}
775
776
.hl-string,
777
.hl-string_interp {
778
  color: #ffd866;
779
}
780
781
.hl-number,
782
.hl-boolean,
783
.hl-constant,
784
.hl-preprocessor {
785
  color: #ab9df2;
786
}
787
788
.hl-variable {
789
  color: #fcfcfa;
790
}
791
792
.hl-attribute,
793
.hl-property {
794
  color: #fc9867;
795
}
796
797
.hl-operator,
798
.hl-punctuation,
799
.hl-escape {
800
  color: #939293;
801
}
802
803
.hl-interp-punct {
804
  color: #ff6188;
805
}
806
807
.hl-math {
808
  color: #ab9df2;
809
  font-style: italic;
810
}
811
812
.hl-code {
813
  display: inline-block;
814
  width: 100%;
815
  background-color: #0d1117;
816
  color: #c9d1d9;
817
  padding: 2px 4px;
818
  border-radius: 3px;
819
}
820
821
@media (max-width: 768px) {
822
  .container {
823
    padding: 10px;
824
  }
825
826
  h1 { font-size: 1.5rem; }
827
  h2 { font-size: 1.2rem; }
828
829
  .nav {
830
    flex-direction: column;
831
    align-items: flex-start;
832
    gap: 10px;
833
  }
834
835
  .repo-selector {
836
    margin-left: 0;
837
    width: 100%;
838
  }
839
840
  .repo-selector select {
841
    flex: 1;
842
  }
843
844
  .file-list-table th,
845
  .file-list-table td {
846
    padding: 8px 10px;
847
  }
848
849
  .file-list-table .file-mode-cell,
850
  .file-list-table .file-date-cell {
851
    display: none;
852
  }
853
854
  .commit-details {
855
    padding: 15px;
856
  }
857
858
  .commit-title {
859
    font-size: 1.1rem;
860
    word-break: break-word;
861
  }
862
863
  .commit-info-row {
864
    flex-direction: column;
865
    gap: 2px;
866
    margin-bottom: 10px;
867
  }
868
869
  .commit-info-label {
870
    width: 100%;
871
    font-size: 0.8rem;
872
    color: #8b949e;
873
  }
874
875
  .commit-info-value {
876
    word-break: break-all;
877
    font-family: 'SFMono-Regular', Consolas, monospace;
878
    font-size: 0.9rem;
879
    padding-left: 0;
880
  }
881
882
  .commit-row {
883
    flex-direction: column;
884
    gap: 5px;
885
  }
886
887
  .commit-row .message {
888
    width: 100%;
889
    white-space: normal;
890
  }
891
892
  .commit-row .meta {
893
    font-size: 0.8rem;
894
  }
895
896
  .tag-table .tag-author,
897
  .tag-table .tag-time,
898
  .tag-table .tag-hash {
899
    font-size: 0.8rem;
900
  }
901
902
  .blob-code, .diff-content {
903
    overflow-x: scroll;
904
    -webkit-overflow-scrolling: touch;
905
  }
906
}
907
908
@media screen and (orientation: landscape) and (max-height: 600px) {
909
  .container {
910
    max-width: 100%;
911
  }
912
913
  header {
914
    margin-bottom: 15px;
915
    padding-bottom: 10px;
916
  }
917
918
  .file-list-table .file-date-cell {
919
    display: table-cell;
920
  }
921
}
9221
M robots.txt
33
crawl-delay: 60
44
5
User-agent: Googlebot
6
Disallow: /_mobile/
7
Disallow: /*repo=
8
Disallow: /*action=
9
A styles/repo.css
1
* {
2
  margin: 0;
3
  padding: 0;
4
  box-sizing: border-box;
5
}
6
7
body {
8
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
9
  background: #0d1117;
10
  color: #c9d1d9;
11
  line-height: 1.6;
12
}
13
14
.container {
15
  max-width: 1200px;
16
  margin: 0 auto;
17
  padding: 20px;
18
}
19
20
header {
21
  padding-bottom: 10px;
22
  margin-bottom: 20px;
23
}
24
25
h1 {
26
  color: #f0f6fc;
27
  font-size: 1.8rem;
28
  margin-bottom: 10px;
29
}
30
31
h2 {
32
  color: #f0f6fc;
33
  font-size: 1.4rem;
34
  margin: 20px 0 15px;
35
  padding-bottom: 10px;
36
  border-bottom: 1px solid #21262d;
37
}
38
39
h3 {
40
  color: #f0f6fc;
41
  font-size: 1.1rem;
42
  margin: 15px 0 10px;
43
}
44
45
.nav {
46
  margin-top: 10px;
47
  display: flex;
48
  gap: 20px;
49
  flex-wrap: wrap;
50
  align-items: center;
51
}
52
53
.nav a {
54
  color: #58a6ff;
55
  text-decoration: none;
56
}
57
58
.nav a:hover {
59
  text-decoration: underline;
60
}
61
62
.repo-selector {
63
  margin-left: auto;
64
  display: flex;
65
  align-items: center;
66
  gap: 10px;
67
}
68
69
.repo-selector label {
70
  color: #8b949e;
71
  font-size: 0.875rem;
72
}
73
74
.repo-selector select {
75
  background: #21262d;
76
  color: #f0f6fc;
77
  border: 1px solid #30363d;
78
  padding: 6px 12px;
79
  border-radius: 6px;
80
  font-size: 0.875rem;
81
  cursor: pointer;
82
}
83
84
.repo-selector select:hover {
85
  border-color: #58a6ff;
86
}
87
88
.commit-list {
89
  list-style: none;
90
  margin-top: 20px;
91
}
92
93
.commit-item {
94
  background: #161b22;
95
  border: 1px solid #30363d;
96
  border-radius: 6px;
97
  padding: 16px;
98
  margin-bottom: 12px;
99
  transition: border-color 0.2s;
100
}
101
102
.commit-item:hover {
103
  border-color: #58a6ff;
104
}
105
106
.commit-hash {
107
  font-family: 'SFMono-Regular', Consolas, monospace;
108
  font-size: 0.85rem;
109
  color: #58a6ff;
110
  text-decoration: none;
111
}
112
113
.commit-meta {
114
  font-size: 0.875rem;
115
  color: #8b949e;
116
  margin-top: 8px;
117
}
118
119
.commit-author {
120
  color: #f0f6fc;
121
  font-weight: 500;
122
}
123
124
.commit-date {
125
  color: #8b949e;
126
}
127
128
.commit-message {
129
  margin-top: 8px;
130
  color: #c9d1d9;
131
  white-space: pre-wrap;
132
}
133
134
.file-list {
135
  background: #161b22;
136
  border: 1px solid #30363d;
137
  border-radius: 6px;
138
  overflow: hidden;
139
}
140
141
.file-item {
142
  display: flex;
143
  align-items: center;
144
  padding: 12px 16px;
145
  border-bottom: 1px solid #21262d;
146
  text-decoration: none;
147
  color: #c9d1d9;
148
  transition: background 0.2s;
149
}
150
151
.file-item:last-child {
152
  border-bottom: none;
153
}
154
155
.file-item:hover {
156
  background: #1f242c;
157
}
158
159
.file-mode {
160
  font-family: monospace;
161
  color: #8b949e;
162
  width: 80px;
163
  font-size: 0.875rem;
164
}
165
166
.file-name {
167
  flex: 1;
168
  color: #58a6ff;
169
}
170
171
.file-item:hover .file-name {
172
  text-decoration: underline;
173
}
174
175
.breadcrumb {
176
  background: #161b22;
177
  border: 1px solid #30363d;
178
  border-radius: 6px;
179
  padding: 12px 16px;
180
  margin-bottom: 20px;
181
  color: #8b949e;
182
}
183
184
.breadcrumb a {
185
  color: #58a6ff;
186
  text-decoration: none;
187
}
188
189
.breadcrumb a:hover {
190
  text-decoration: underline;
191
}
192
193
.repo-breadcrumb {
194
  display: inline-block;
195
  border: 1px solid #58a6ff;
196
  border-radius: 12px;
197
  padding: 0 10px;
198
  margin: 0 4px;
199
}
200
201
.repo-breadcrumb:hover {
202
  text-decoration: none;
203
  background-color: rgba(88, 166, 255, 0.1);
204
}
205
206
.blob-content {
207
  background: #161b22;
208
  border: 1px solid #30363d;
209
  border-radius: 6px;
210
  overflow: hidden;
211
  max-width: 100%;
212
}
213
214
.blob-header {
215
  background: #21262d;
216
  padding: 12px 16px;
217
  border-bottom: 1px solid #30363d;
218
  font-size: 0.875rem;
219
  color: #8b949e;
220
}
221
222
.blob-code {
223
  padding: 16px;
224
  overflow-x: auto;
225
  font-family: 'SFMono-Regular', Consolas, monospace;
226
  font-size: 0.875rem;
227
  line-height: 1.6;
228
  white-space: pre-wrap;
229
  overflow-wrap: break-word;
230
}
231
232
.blob-code pre {
233
    overflow-x: auto;
234
}
235
236
.refs-list {
237
  display: grid;
238
  gap: 10px;
239
}
240
241
.ref-item {
242
  background: #161b22;
243
  border: 1px solid #30363d;
244
  border-radius: 6px;
245
  padding: 12px 16px;
246
  display: flex;
247
  align-items: center;
248
  gap: 12px;
249
}
250
251
.ref-type {
252
  background: #238636;
253
  color: white;
254
  padding: 2px 8px;
255
  border-radius: 12px;
256
  font-size: 0.75rem;
257
  font-weight: 600;
258
  text-transform: uppercase;
259
}
260
261
.ref-type.tag {
262
  background: #8957e5;
263
}
264
265
.ref-name {
266
  font-weight: 600;
267
  color: #f0f6fc;
268
}
269
270
.empty-state {
271
  text-align: center;
272
  padding: 60px 20px;
273
  color: #8b949e;
274
}
275
276
.commit-details {
277
  background: #161b22;
278
  border: 1px solid #30363d;
279
  border-radius: 6px;
280
  padding: 20px;
281
  margin-bottom: 20px;
282
}
283
284
.commit-header {
285
  margin-bottom: 20px;
286
}
287
288
.commit-title {
289
  font-size: 1.25rem;
290
  color: #f0f6fc;
291
  margin-bottom: 10px;
292
}
293
294
.commit-info {
295
  display: grid;
296
  gap: 8px;
297
  font-size: 0.875rem;
298
}
299
300
.commit-info-row {
301
  display: flex;
302
  gap: 10px;
303
}
304
305
.commit-info-label {
306
  color: #8b949e;
307
  width: 80px;
308
  flex-shrink: 0;
309
}
310
311
.commit-info-value {
312
  color: #c9d1d9;
313
  font-family: monospace;
314
}
315
316
.parent-link {
317
  color: #58a6ff;
318
  text-decoration: none;
319
}
320
321
.parent-link:hover {
322
  text-decoration: underline;
323
}
324
325
.repo-grid {
326
  display: grid;
327
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
328
  gap: 16px;
329
  margin-top: 20px;
330
}
331
332
.repo-card {
333
  background: #161b22;
334
  border: 1px solid #30363d;
335
  border-radius: 8px;
336
  padding: 20px;
337
  text-decoration: none;
338
  color: inherit;
339
  transition: border-color 0.2s, transform 0.1s;
340
}
341
342
.repo-card:hover {
343
  border-color: #58a6ff;
344
  transform: translateY(-2px);
345
}
346
347
.repo-card h3 {
348
  color: #58a6ff;
349
  margin-bottom: 8px;
350
  font-size: 1.1rem;
351
}
352
353
.repo-card p {
354
  color: #8b949e;
355
  font-size: 0.875rem;
356
  margin: 0;
357
}
358
359
.current-repo {
360
  background: #21262d;
361
  border: 1px solid #58a6ff;
362
  padding: 8px 16px;
363
  border-radius: 6px;
364
  font-size: 0.875rem;
365
  color: #f0f6fc;
366
}
367
368
.current-repo strong {
369
  color: #58a6ff;
370
}
371
372
.branch-badge {
373
  background: #238636;
374
  color: white;
375
  padding: 2px 8px;
376
  border-radius: 12px;
377
  font-size: 0.75rem;
378
  font-weight: 600;
379
  margin-left: 10px;
380
}
381
382
.commit-row {
383
  display: flex;
384
  padding: 10px 0;
385
  border-bottom: 1px solid #30363d;
386
  gap: 15px;
387
  align-items: baseline;
388
}
389
390
.commit-row:last-child {
391
  border-bottom: none;
392
}
393
394
.commit-row .sha {
395
  font-family: monospace;
396
  color: #58a6ff;
397
  text-decoration: none;
398
}
399
400
.commit-row .message {
401
  flex: 1;
402
  font-weight: 500;
403
}
404
405
.commit-row .meta {
406
  font-size: 0.85em;
407
  color: #8b949e;
408
  white-space: nowrap;
409
}
410
411
.blob-content-image {
412
  text-align: center;
413
  padding: 20px;
414
  background: #0d1117;
415
}
416
417
.blob-content-image img {
418
  max-width: 100%;
419
  border: 1px solid #30363d;
420
}
421
422
.blob-content-video {
423
  text-align: center;
424
  padding: 20px;
425
  background: #000;
426
}
427
428
.blob-content-video video {
429
  max-width: 100%;
430
  max-height: 80vh;
431
}
432
433
.blob-content-audio {
434
  text-align: center;
435
  padding: 40px;
436
  background: #161b22;
437
}
438
439
.blob-content-audio audio {
440
  width: 100%;
441
  max-width: 600px;
442
}
443
444
.download-state {
445
  text-align: center;
446
  padding: 40px;
447
  border: 1px solid #30363d;
448
  border-radius: 6px;
449
  margin-top: 10px;
450
}
451
452
.download-state p {
453
  margin-bottom: 20px;
454
  color: #8b949e;
455
}
456
457
.btn-download {
458
  display: inline-block;
459
  padding: 6px 16px;
460
  background: #238636;
461
  color: white;
462
  text-decoration: none;
463
  border-radius: 6px;
464
  font-weight: 600;
465
}
466
467
.repo-info-banner {
468
  margin-top: 15px;
469
}
470
471
.file-icon-container {
472
  width: 20px;
473
  text-align: center;
474
  margin-right: 5px;
475
  color: #8b949e;
476
}
477
478
.file-size {
479
  color: #8b949e;
480
  font-size: 0.8em;
481
  margin-left: 10px;
482
}
483
484
.file-date {
485
  color: #8b949e;
486
  font-size: 0.8em;
487
  margin-left: auto;
488
}
489
490
.repo-card-time {
491
  margin-top: 8px;
492
  color: #58a6ff;
493
}
494
495
496
.diff-container {
497
  display: flex;
498
  flex-direction: column;
499
  gap: 20px;
500
}
501
502
.diff-file {
503
  background: #161b22;
504
  border: 1px solid #30363d;
505
  border-radius: 6px;
506
  overflow: hidden;
507
}
508
509
.diff-header {
510
  background: #21262d;
511
  padding: 10px 16px;
512
  border-bottom: 1px solid #30363d;
513
  display: flex;
514
  align-items: center;
515
  gap: 10px;
516
}
517
518
.diff-path {
519
  font-family: monospace;
520
  font-size: 0.9rem;
521
  color: #f0f6fc;
522
}
523
524
.diff-binary {
525
  padding: 20px;
526
  text-align: center;
527
  color: #8b949e;
528
  font-style: italic;
529
}
530
531
.diff-content {
532
  overflow-x: auto;
533
}
534
535
.diff-content table {
536
  width: 100%;
537
  border-collapse: collapse;
538
  font-family: 'SFMono-Regular', Consolas, monospace;
539
  font-size: 12px;
540
}
541
542
.diff-content td {
543
  padding: 2px 0;
544
  line-height: 20px;
545
}
546
547
.diff-num {
548
  width: 1%;
549
  min-width: 40px;
550
  text-align: right;
551
  padding-right: 10px;
552
  color: #6e7681;
553
  user-select: none;
554
  background: #0d1117;
555
  border-right: 1px solid #30363d;
556
}
557
558
.diff-num::before {
559
  content: attr(data-num);
560
}
561
562
.diff-code {
563
  padding-left: 10px;
564
  white-space: pre-wrap;
565
  word-break: break-all;
566
  color: #c9d1d9;
567
}
568
569
.diff-marker {
570
  display: inline-block;
571
  width: 15px;
572
  user-select: none;
573
  color: #8b949e;
574
}
575
576
/* Protanopia Safe Colors: Blue (Add) and Yellow (Del) */
577
.diff-add {
578
  background-color: rgba(2, 59, 149, 0.25);
579
}
580
.diff-add .diff-code {
581
  color: #79c0ff;
582
}
583
.diff-add .diff-marker {
584
  color: #79c0ff;
585
}
586
587
.diff-del {
588
  background-color: rgba(148, 99, 0, 0.25);
589
}
590
.diff-del .diff-code {
591
  color: #d29922;
592
}
593
.diff-del .diff-marker {
594
  color: #d29922;
595
}
596
597
.diff-gap {
598
  background: #0d1117;
599
  color: #484f58;
600
  text-align: center;
601
  font-size: 0.8em;
602
  height: 20px;
603
}
604
.diff-gap td {
605
  padding: 0;
606
  line-height: 20px;
607
  background: rgba(110, 118, 129, 0.1);
608
}
609
610
.status-add { color: #58a6ff; }
611
.status-del { color: #d29922; }
612
.status-mod { color: #a371f7; }
613
614
.tag-table, .file-list-table {
615
  width: 100%;
616
  border-collapse: collapse;
617
  margin-top: 10px;
618
  background: #161b22;
619
  border: 1px solid #30363d;
620
  border-radius: 6px;
621
  overflow: hidden;
622
}
623
624
.tag-table th, .file-list-table th {
625
  text-align: left;
626
  padding: 10px 16px;
627
  border-bottom: 2px solid #30363d;
628
  color: #8b949e;
629
  font-size: 0.875rem;
630
  font-weight: 600;
631
  white-space: nowrap;
632
}
633
634
.tag-table td, .file-list-table td {
635
  padding: 12px 16px;
636
  border-bottom: 1px solid #21262d;
637
  vertical-align: middle;
638
  color: #c9d1d9;
639
  font-size: 0.9rem;
640
}
641
642
.tag-table tr:hover td, .file-list-table tr:hover td {
643
  background: #161b22;
644
}
645
646
.tag-table .tag-name {
647
  min-width: 140px;
648
  width: 20%;
649
}
650
651
.tag-table .tag-message {
652
  width: auto;
653
  white-space: normal;
654
  word-break: break-word;
655
  color: #c9d1d9;
656
  font-weight: 500;
657
}
658
659
.tag-table .tag-author,
660
.tag-table .tag-time,
661
.tag-table .tag-hash {
662
  width: 1%;
663
  white-space: nowrap;
664
}
665
666
.tag-table .tag-time {
667
  text-align: right;
668
  color: #8b949e;
669
}
670
671
.tag-table .tag-hash {
672
  text-align: right;
673
}
674
675
.tag-table .tag-name a {
676
  color: #58a6ff;
677
  text-decoration: none;
678
  font-family: 'SFMono-Regular', Consolas, monospace;
679
}
680
681
.tag-table .tag-author {
682
  color: #c9d1d9;
683
}
684
685
.tag-table .tag-age-header {
686
  text-align: right;
687
}
688
689
.tag-table .tag-commit-header {
690
  text-align: right;
691
}
692
693
.tag-table .commit-hash {
694
  font-family: 'SFMono-Regular', Consolas, monospace;
695
  color: #58a6ff;
696
  text-decoration: none;
697
}
698
699
.tag-table .commit-hash:hover {
700
  text-decoration: underline;
701
}
702
703
.file-list-table .file-icon-cell {
704
    width: 20px;
705
    text-align: center;
706
    color: #8b949e;
707
    padding-right: 0;
708
}
709
710
.file-list-table .file-name-cell a {
711
    color: #58a6ff;
712
    text-decoration: none;
713
    font-weight: 500;
714
}
715
716
.file-list-table .file-name-cell a:hover {
717
    text-decoration: underline;
718
}
719
720
.file-list-table .file-mode-cell {
721
    font-family: 'SFMono-Regular', Consolas, monospace;
722
    color: #8b949e;
723
    font-size: 0.8rem;
724
    width: 1%;
725
    white-space: nowrap;
726
    text-align: center;
727
}
728
729
.file-list-table .file-size-cell {
730
    color: #8b949e;
731
    text-align: right;
732
    width: 1%;
733
    white-space: nowrap;
734
    font-size: 0.85rem;
735
}
736
737
.file-list-table .file-date-cell {
738
    color: #8b949e;
739
    text-align: right;
740
    width: 150px;
741
    font-size: 0.85rem;
742
    white-space: nowrap;
743
}
744
745
746
.blob-code {
747
  font-family: 'SFMono-Regular', Consolas, monospace;
748
  background-color: #161b22;
749
  color: #fcfcfa;
750
  font-size: 0.875rem;
751
  line-height: 1.6;
752
  tab-size: 2;
753
}
754
755
.hl-comment,
756
.hl-doc-comment {
757
  color: #727072;
758
  font-style: italic;
759
}
760
761
.hl-function,
762
.hl-method {
763
  color: #78dce8;
764
}
765
766
.hl-tag {
767
  color: #3e8bff;
768
}
769
770
.hl-class,
771
.hl-interface,
772
.hl-struct {
773
  color: #a9dc76;
774
}
775
776
.hl-type {
777
  color: #a9dc76;
778
}
779
780
.hl-keyword,
781
.hl-storage,
782
.hl-modifier,
783
.hl-statement {
784
  color: #ff6188;
785
  font-weight: 600;
786
}
787
788
.hl-string,
789
.hl-string_interp {
790
  color: #ffd866;
791
}
792
793
.hl-number,
794
.hl-boolean,
795
.hl-constant,
796
.hl-preprocessor {
797
  color: #ab9df2;
798
}
799
800
.hl-variable {
801
  color: #fcfcfa;
802
}
803
804
.hl-attribute,
805
.hl-property {
806
  color: #fc9867;
807
}
808
809
.hl-operator,
810
.hl-punctuation,
811
.hl-escape {
812
  color: #939293;
813
}
814
815
.hl-interp-punct {
816
  color: #ff6188;
817
}
818
819
.hl-math {
820
  color: #ab9df2;
821
  font-style: italic;
822
}
823
824
.hl-code {
825
  display: inline-block;
826
  width: 100%;
827
  background-color: #0d1117;
828
  color: #c9d1d9;
829
  padding: 2px 4px;
830
  border-radius: 3px;
831
}
832
833
@media (max-width: 768px) {
834
  .container {
835
    padding: 10px;
836
  }
837
838
  h1 { font-size: 1.5rem; }
839
  h2 { font-size: 1.2rem; }
840
841
  .nav {
842
    flex-direction: column;
843
    align-items: flex-start;
844
    gap: 10px;
845
  }
846
847
  .repo-selector {
848
    margin-left: 0;
849
    width: 100%;
850
  }
851
852
  .repo-selector select {
853
    flex: 1;
854
  }
855
856
  .file-list-table th,
857
  .file-list-table td {
858
    padding: 8px 10px;
859
  }
860
861
  .file-list-table .file-mode-cell,
862
  .file-list-table .file-date-cell {
863
    display: none;
864
  }
865
866
  .commit-details {
867
    padding: 15px;
868
  }
869
870
  .commit-title {
871
    font-size: 1.1rem;
872
    word-break: break-word;
873
  }
874
875
  .commit-info-row {
876
    flex-direction: column;
877
    gap: 2px;
878
    margin-bottom: 10px;
879
  }
880
881
  .commit-info-label {
882
    width: 100%;
883
    font-size: 0.8rem;
884
    color: #8b949e;
885
  }
886
887
  .commit-info-value {
888
    word-break: break-all;
889
    font-family: 'SFMono-Regular', Consolas, monospace;
890
    font-size: 0.9rem;
891
    padding-left: 0;
892
  }
893
894
  .commit-row {
895
    flex-direction: column;
896
    gap: 5px;
897
  }
898
899
  .commit-row .message {
900
    width: 100%;
901
    white-space: normal;
902
  }
903
904
  .commit-row .meta {
905
    font-size: 0.8rem;
906
  }
907
908
  .tag-table .tag-author,
909
  .tag-table .tag-time,
910
  .tag-table .tag-hash {
911
    font-size: 0.8rem;
912
  }
913
914
  .blob-code, .diff-content {
915
    overflow-x: scroll;
916
    -webkit-overflow-scrolling: touch;
917
  }
918
}
919
920
@media screen and (orientation: landscape) and (max-height: 600px) {
921
  .container {
922
    max-width: 100%;
923
  }
924
925
  header {
926
    margin-bottom: 15px;
927
    padding-bottom: 10px;
928
  }
929
930
  .file-list-table .file-date-cell {
931
    display: table-cell;
932
  }
933
}
1934