Merge branch 'jn/gitweb-js'
authorJunio C Hamano <gitster@pobox.com>
Thu, 26 May 2011 17:31:57 +0000 (10:31 -0700)
committerJunio C Hamano <gitster@pobox.com>
Thu, 26 May 2011 17:31:57 +0000 (10:31 -0700)
* jn/gitweb-js:
gitweb: Make JavaScript ability to adjust timezones configurable
gitweb.js: Add UI for selecting common timezone to display dates
gitweb: JavaScript ability to adjust time based on timezone
gitweb: Unify the way long timestamp is displayed
gitweb: Refactor generating of long dates into format_timestamp_html
gitweb.js: Provide getElementsByClassName method (if it not exists)
gitweb.js: Introduce code to handle cookies from JavaScript
gitweb.js: Extract and improve datetime handling
gitweb.js: Provide default values for padding in padLeftStr and padLeft
gitweb.js: Update and improve comments in JavaScript files
gitweb: Split JavaScript for maintability, combining on build

1  2 
.gitignore
gitweb/gitweb.perl
gitweb/static/gitweb.css
diff --combined .gitignore
index 4bdb83975c080dc035697c3f8d1d8b5aa097c667,9e9500540ec6f3c73c5899e8309324c50a1e0706..acffdfaae684dc49d7205d580fd8921ca4696f7f
  /git-quiltimport
  /git-read-tree
  /git-rebase
 +/git-rebase--am
  /git-rebase--interactive
 +/git-rebase--merge
  /git-receive-pack
  /git-reflog
  /git-relink
  /git-rm
  /git-send-email
  /git-send-pack
 +/git-sh-i18n
 +/git-sh-i18n--envsubst
  /git-sh-setup
 +/git-sh-i18n
  /git-shell
  /git-shortlog
  /git-show
  /gitk-git/gitk-wish
  /gitweb/GITWEB-BUILD-OPTIONS
  /gitweb/gitweb.cgi
+ /gitweb/static/gitweb.js
  /gitweb/static/gitweb.min.*
  /test-chmtime
  /test-ctype
diff --combined gitweb/gitweb.perl
index 02d8839b9c64d65db6c3713555a317c119bac9f4,ac335b6deada78d8096e028db27a1bfc6bcebe3b..240dd4701cc7f313b51754b0ffc7ca4a11ab0b7a
@@@ -115,14 -115,6 +115,14 @@@ our $projects_list = "++GITWEB_LIST++"
  # the width (in characters) of the projects list "Description" column
  our $projects_list_description_width = 25;
  
 +# group projects by category on the projects list
 +# (enabled if this variable evaluates to true)
 +our $projects_list_group_categories = 0;
 +
 +# default category if none specified
 +# (leave the empty string for no category)
 +our $project_list_default_category = "";
 +
  # default order of projects list
  # valid values are none, project, descr, owner, and age
  our $default_projects_order = "project";
@@@ -194,7 -186,7 +194,7 @@@ our %known_snapshot_formats = 
                'type' => 'application/x-gzip',
                'suffix' => '.tar.gz',
                'format' => 'tar',
 -              'compressor' => ['gzip']},
 +              'compressor' => ['gzip', '-n']},
  
        'tbz2' => {
                'display' => 'tar.bz2',
@@@ -420,23 -412,20 +420,23 @@@ our %feature = 
                'override' => 0,
                'default' => []},
  
 -      # Allow gitweb scan project content tags described in ctags/
 -      # of project repository, and display the popular Web 2.0-ish
 -      # "tag cloud" near the project list. Note that this is something
 -      # COMPLETELY different from the normal Git tags.
 +      # Allow gitweb scan project content tags of project repository,
 +      # and display the popular Web 2.0-ish "tag cloud" near the projects
 +      # list.  Note that this is something COMPLETELY different from the
 +      # normal Git tags.
  
        # gitweb by itself can show existing tags, but it does not handle
 -      # tagging itself; you need an external application for that.
 -      # For an example script, check Girocco's cgi/tagproj.cgi.
 +      # tagging itself; you need to do it externally, outside gitweb.
 +      # The format is described in git_get_project_ctags() subroutine.
        # You may want to install the HTML::TagCloud Perl module to get
        # a pretty tag cloud instead of just a list of tags.
  
        # To enable system wide have in $GITWEB_CONFIG
 -      # $feature{'ctags'}{'default'} = ['path_to_tag_script'];
 +      # $feature{'ctags'}{'default'} = [1];
        # Project specific override is not supported.
 +
 +      # In the future whether ctags editing is enabled might depend
 +      # on the value, but using 1 should always mean no editing of ctags.
        'ctags' => {
                'override' => 0,
                'default' => [0]},
                'override' => 0,
                'default' => [0]},
  
+       # Enable and configure ability to change common timezone for dates
+       # in gitweb output via JavaScript.  Enabled by default.
+       # Project specific override is not supported.
+       'javascript-timezone' => {
+               'override' => 0,
+               'default' => [
+                       'local',     # default timezone: 'utc', 'local', or '(-|+)HHMM' format,
+                                    # or undef to turn off this feature
+                       'gitweb_tz', # name of cookie where to store selected timezone
+                       'datetime',  # CSS class used to mark up dates for manipulation
+               ]},
        # Syntax highlighting support. This is based on Daniel Svensson's
        # and Sham Chukoury's work in gitweb-xmms2.git.
        # It requires the 'highlight' program present in $PATH,
@@@ -631,30 -632,18 +643,30 @@@ sub filter_snapshot_fmts 
  # if it is true then gitweb config would be run for each request.
  our $per_request_config = 1;
  
 +# read and parse gitweb config file given by its parameter.
 +# returns true on success, false on recoverable error, allowing
 +# to chain this subroutine, using first file that exists.
 +# dies on errors during parsing config file, as it is unrecoverable.
 +sub read_config_file {
 +      my $filename = shift;
 +      return unless defined $filename;
 +      # die if there are errors parsing config file
 +      if (-e $filename) {
 +              do $filename;
 +              die $@ if $@;
 +              return 1;
 +      }
 +      return;
 +}
 +
  our ($GITWEB_CONFIG, $GITWEB_CONFIG_SYSTEM);
  sub evaluate_gitweb_config {
        our $GITWEB_CONFIG = $ENV{'GITWEB_CONFIG'} || "++GITWEB_CONFIG++";
        our $GITWEB_CONFIG_SYSTEM = $ENV{'GITWEB_CONFIG_SYSTEM'} || "++GITWEB_CONFIG_SYSTEM++";
 -      # die if there are errors parsing config file
 -      if (-e $GITWEB_CONFIG) {
 -              do $GITWEB_CONFIG;
 -              die $@ if $@;
 -      } elsif (-e $GITWEB_CONFIG_SYSTEM) {
 -              do $GITWEB_CONFIG_SYSTEM;
 -              die $@ if $@;
 -      }
 +
 +      # use first config file that exists
 +      read_config_file($GITWEB_CONFIG) or
 +      read_config_file($GITWEB_CONFIG_SYSTEM);
  }
  
  # Get loadavg of system, to compare against $maxload.
@@@ -726,7 -715,6 +738,7 @@@ our @cgi_param_mapping = 
        snapshot_format => "sf",
        extra_options => "opt",
        search_use_regexp => "sr",
 +      ctag => "by_tag",
        # this must be last entry (for manipulation from JavaScript)
        javascript => "js"
  );
@@@ -2582,94 -2570,37 +2594,94 @@@ sub git_get_path_by_hash 
  ## ......................................................................
  ## git utility functions, directly accessing git repository
  
 -sub git_get_project_description {
 -      my $path = shift;
 +# get the value of config variable either from file named as the variable
 +# itself in the repository ($GIT_DIR/$name file), or from gitweb.$name
 +# configuration variable in the repository config file.
 +sub git_get_file_or_project_config {
 +      my ($path, $name) = @_;
  
        $git_dir = "$projectroot/$path";
 -      open my $fd, '<', "$git_dir/description"
 -              or return git_get_project_config('description');
 -      my $descr = <$fd>;
 +      open my $fd, '<', "$git_dir/$name"
 +              or return git_get_project_config($name);
 +      my $conf = <$fd>;
        close $fd;
 -      if (defined $descr) {
 -              chomp $descr;
 +      if (defined $conf) {
 +              chomp $conf;
        }
 -      return $descr;
 +      return $conf;
  }
  
 -sub git_get_project_ctags {
 +sub git_get_project_description {
        my $path = shift;
 +      return git_get_file_or_project_config($path, 'description');
 +}
 +
 +sub git_get_project_category {
 +      my $path = shift;
 +      return git_get_file_or_project_config($path, 'category');
 +}
 +
 +
 +# supported formats:
 +# * $GIT_DIR/ctags/<tagname> file (in 'ctags' subdirectory)
 +#   - if its contents is a number, use it as tag weight,
 +#   - otherwise add a tag with weight 1
 +# * $GIT_DIR/ctags file, each line is a tag (with weight 1)
 +#   the same value multiple times increases tag weight
 +# * `gitweb.ctag' multi-valued repo config variable
 +sub git_get_project_ctags {
 +      my $project = shift;
        my $ctags = {};
  
 -      $git_dir = "$projectroot/$path";
 -      opendir my $dh, "$git_dir/ctags"
 -              or return $ctags;
 -      foreach (grep { -f $_ } map { "$git_dir/ctags/$_" } readdir($dh)) {
 -              open my $ct, '<', $_ or next;
 -              my $val = <$ct>;
 -              chomp $val;
 -              close $ct;
 -              my $ctag = $_; $ctag =~ s#.*/##;
 -              $ctags->{$ctag} = $val;
 +      $git_dir = "$projectroot/$project";
 +      if (opendir my $dh, "$git_dir/ctags") {
 +              my @files = grep { -f $_ } map { "$git_dir/ctags/$_" } readdir($dh);
 +              foreach my $tagfile (@files) {
 +                      open my $ct, '<', $tagfile
 +                              or next;
 +                      my $val = <$ct>;
 +                      chomp $val if $val;
 +                      close $ct;
 +
 +                      (my $ctag = $tagfile) =~ s#.*/##;
 +                      if ($val =~ /\d+/) {
 +                              $ctags->{$ctag} = $val;
 +                      } else {
 +                              $ctags->{$ctag} = 1;
 +                      }
 +              }
 +              closedir $dh;
 +
 +      } elsif (open my $fh, '<', "$git_dir/ctags") {
 +              while (my $line = <$fh>) {
 +                      chomp $line;
 +                      $ctags->{$line}++ if $line;
 +              }
 +              close $fh;
 +
 +      } else {
 +              my $taglist = config_to_multi(git_get_project_config('ctag'));
 +              foreach my $tag (@$taglist) {
 +                      $ctags->{$tag}++;
 +              }
        }
 -      closedir $dh;
 -      $ctags;
 +
 +      return $ctags;
 +}
 +
 +# return hash, where keys are content tags ('ctags'),
 +# and values are sum of weights of given tag in every project
 +sub git_gather_all_ctags {
 +      my $projects = shift;
 +      my $ctags = {};
 +
 +      foreach my $p (@$projects) {
 +              foreach my $ct (keys %{$p->{'ctags'}}) {
 +                      $ctags->{$ct} += $p->{'ctags'}->{$ct};
 +              }
 +      }
 +
 +      return $ctags;
  }
  
  sub git_populate_project_tagcloud {
        }
  
        my $cloud;
 +      my $matched = $cgi->param('by_tag');
        if (eval { require HTML::TagCloud; 1; }) {
                $cloud = HTML::TagCloud->new;
 -              foreach (sort keys %ctags_lc) {
 +              foreach my $ctag (sort keys %ctags_lc) {
                        # Pad the title with spaces so that the cloud looks
                        # less crammed.
 -                      my $title = $ctags_lc{$_}->{topname};
 +                      my $title = esc_html($ctags_lc{$ctag}->{topname});
                        $title =~ s/ /&nbsp;/g;
                        $title =~ s/^/&nbsp;/g;
                        $title =~ s/$/&nbsp;/g;
 -                      $cloud->add($title, $home_link."?by_tag=".$_, $ctags_lc{$_}->{count});
 +                      if (defined $matched && $matched eq $ctag) {
 +                              $title = qq(<span class="match">$title</span>);
 +                      }
 +                      $cloud->add($title, href(project=>undef, ctag=>$ctag),
 +                                  $ctags_lc{$ctag}->{count});
                }
        } else {
 -              $cloud = \%ctags_lc;
 +              $cloud = {};
 +              foreach my $ctag (keys %ctags_lc) {
 +                      my $title = esc_html($ctags_lc{$ctag}->{topname}, -nbsp=>1);
 +                      if (defined $matched && $matched eq $ctag) {
 +                              $title = qq(<span class="match">$title</span>);
 +                      }
 +                      $cloud->{$ctag}{count} = $ctags_lc{$ctag}->{count};
 +                      $cloud->{$ctag}{ctag} =
 +                              $cgi->a({-href=>href(project=>undef, ctag=>$ctag)}, $title);
 +              }
        }
 -      $cloud;
 +      return $cloud;
  }
  
  sub git_show_project_tagcloud {
        my ($cloud, $count) = @_;
 -      print STDERR ref($cloud)."..\n";
        if (ref $cloud eq 'HTML::TagCloud') {
                return $cloud->html_and_css($count);
        } else {
 -              my @tags = sort { $cloud->{$a}->{count} <=> $cloud->{$b}->{count} } keys %$cloud;
 -              return '<p align="center">' . join (', ', map {
 -                      $cgi->a({-href=>"$home_link?by_tag=$_"}, $cloud->{$_}->{topname})
 -              } splice(@tags, 0, $count)) . '</p>';
 +              my @tags = sort { $cloud->{$a}->{'count'} <=> $cloud->{$b}->{'count'} } keys %$cloud;
 +              return
 +                      '<div id="htmltagcloud"'.($project ? '' : ' align="center"').'>' .
 +                      join (', ', map {
 +                              $cloud->{$_}->{'ctag'}
 +                      } splice(@tags, 0, $count)) .
 +                      '</div>';
        }
  }
  
@@@ -2748,23 -2663,21 +2760,23 @@@ sub git_get_project_url_list 
  }
  
  sub git_get_projects_list {
 -      my ($filter) = @_;
 +      my $filter = shift || '';
        my @list;
  
 -      $filter ||= '';
        $filter =~ s/\.git$//;
  
 -      my $check_forks = gitweb_check_feature('forks');
 -
        if (-d $projects_list) {
                # search in directory
 -              my $dir = $projects_list . ($filter ? "/$filter" : '');
 +              my $dir = $projects_list;
                # remove the trailing "/"
                $dir =~ s!/+$!!;
 -              my $pfxlen = length("$dir");
 -              my $pfxdepth = ($dir =~ tr!/!!);
 +              my $pfxlen = length("$projects_list");
 +              my $pfxdepth = ($projects_list =~ tr!/!!);
 +              # when filtering, search only given subdirectory
 +              if ($filter) {
 +                      $dir .= "/$filter";
 +                      $dir =~ s!/+$!!;
 +              }
  
                File::Find::find({
                        follow_fast => 1, # follow symbolic links
                                # only directories can be git repositories
                                return unless (-d $_);
                                # don't traverse too deep (Find is super slow on os x)
 +                              # $project_maxdepth excludes depth of $projectroot
                                if (($File::Find::name =~ tr!/!!) - $pfxdepth > $project_maxdepth) {
                                        $File::Find::prune = 1;
                                        return;
                                }
  
 -                              my $subdir = substr($File::Find::name, $pfxlen + 1);
 +                              my $path = substr($File::Find::name, $pfxlen + 1);
                                # we check related file in $projectroot
 -                              my $path = ($filter ? "$filter/" : '') . $subdir;
                                if (check_export_ok("$projectroot/$path")) {
                                        push @list, { path => $path };
                                        $File::Find::prune = 1;
                # 'git%2Fgit.git Linus+Torvalds'
                # 'libs%2Fklibc%2Fklibc.git H.+Peter+Anvin'
                # 'linux%2Fhotplug%2Fudev.git Greg+Kroah-Hartman'
 -              my %paths;
                open my $fd, '<', $projects_list or return;
        PROJECT:
                while (my $line = <$fd>) {
                        if (!defined $path) {
                                next;
                        }
 -                      if ($filter ne '') {
 -                              # looking for forks;
 -                              my $pfx = substr($path, 0, length($filter));
 -                              if ($pfx ne $filter) {
 -                                      next PROJECT;
 -                              }
 -                              my $sfx = substr($path, length($filter));
 -                              if ($sfx !~ /^\/.*\.git$/) {
 -                                      next PROJECT;
 -                              }
 -                      } elsif ($check_forks) {
 -                      PATH:
 -                              foreach my $filter (keys %paths) {
 -                                      # looking for forks;
 -                                      my $pfx = substr($path, 0, length($filter));
 -                                      if ($pfx ne $filter) {
 -                                              next PATH;
 -                                      }
 -                                      my $sfx = substr($path, length($filter));
 -                                      if ($sfx !~ /^\/.*\.git$/) {
 -                                              next PATH;
 -                                      }
 -                                      # is a fork, don't include it in
 -                                      # the list
 -                                      next PROJECT;
 -                              }
 +                      # if $filter is rpovided, check if $path begins with $filter
 +                      if ($filter && $path !~ m!^\Q$filter\E/!) {
 +                              next;
                        }
                        if (check_export_ok("$projectroot/$path")) {
                                my $pr = {
                                        owner => to_utf8($owner),
                                };
                                push @list, $pr;
 -                              (my $forks_path = $path) =~ s/\.git$//;
 -                              $paths{$forks_path}++;
                        }
                }
                close $fd;
        return @list;
  }
  
 +# written with help of Tree::Trie module (Perl Artistic License, GPL compatibile)
 +# as side effects it sets 'forks' field to list of forks for forked projects
 +sub filter_forks_from_projects_list {
 +      my $projects = shift;
 +
 +      my %trie; # prefix tree of directories (path components)
 +      # generate trie out of those directories that might contain forks
 +      foreach my $pr (@$projects) {
 +              my $path = $pr->{'path'};
 +              $path =~ s/\.git$//;      # forks of 'repo.git' are in 'repo/' directory
 +              next if ($path =~ m!/$!); # skip non-bare repositories, e.g. 'repo/.git'
 +              next unless ($path);      # skip '.git' repository: tests, git-instaweb
 +              next unless (-d $path);   # containing directory exists
 +              $pr->{'forks'} = [];      # there can be 0 or more forks of project
 +
 +              # add to trie
 +              my @dirs = split('/', $path);
 +              # walk the trie, until either runs out of components or out of trie
 +              my $ref = \%trie;
 +              while (scalar @dirs &&
 +                     exists($ref->{$dirs[0]})) {
 +                      $ref = $ref->{shift @dirs};
 +              }
 +              # create rest of trie structure from rest of components
 +              foreach my $dir (@dirs) {
 +                      $ref = $ref->{$dir} = {};
 +              }
 +              # create end marker, store $pr as a data
 +              $ref->{''} = $pr if (!exists $ref->{''});
 +      }
 +
 +      # filter out forks, by finding shortest prefix match for paths
 +      my @filtered;
 + PROJECT:
 +      foreach my $pr (@$projects) {
 +              # trie lookup
 +              my $ref = \%trie;
 +      DIR:
 +              foreach my $dir (split('/', $pr->{'path'})) {
 +                      if (exists $ref->{''}) {
 +                              # found [shortest] prefix, is a fork - skip it
 +                              push @{$ref->{''}{'forks'}}, $pr;
 +                              next PROJECT;
 +                      }
 +                      if (!exists $ref->{$dir}) {
 +                              # not in trie, cannot have prefix, not a fork
 +                              push @filtered, $pr;
 +                              next PROJECT;
 +                      }
 +                      # If the dir is there, we just walk one step down the trie.
 +                      $ref = $ref->{$dir};
 +              }
 +              # we ran out of trie
 +              # (shouldn't happen: it's either no match, or end marker)
 +              push @filtered, $pr;
 +      }
 +
 +      return @filtered;
 +}
 +
 +# note: fill_project_list_info must be run first,
 +# for 'descr_long' and 'ctags' to be filled
 +sub search_projects_list {
 +      my ($projlist, %opts) = @_;
 +      my $tagfilter  = $opts{'tagfilter'};
 +      my $searchtext = $opts{'searchtext'};
 +
 +      return @$projlist
 +              unless ($tagfilter || $searchtext);
 +
 +      my @projects;
 + PROJECT:
 +      foreach my $pr (@$projlist) {
 +
 +              if ($tagfilter) {
 +                      next unless ref($pr->{'ctags'}) eq 'HASH';
 +                      next unless
 +                              grep { lc($_) eq lc($tagfilter) } keys %{$pr->{'ctags'}};
 +              }
 +
 +              if ($searchtext) {
 +                      next unless
 +                              $pr->{'path'} =~ /$searchtext/ ||
 +                              $pr->{'descr_long'} =~ /$searchtext/;
 +              }
 +
 +              push @projects, $pr;
 +      }
 +
 +      return @projects;
 +}
 +
  our $gitweb_project_owner = undef;
  sub git_get_project_list_from_file {
  
@@@ -3897,9 -3744,20 +3909,20 @@@ sub git_footer_html 
                      qq!startBlame("!. href(action=>"blame_data", -replay=>1) .qq!",\n!.
                      qq!           "!. href() .qq!");\n!.
                      qq!</script>\n!;
-       } elsif (gitweb_check_feature('javascript-actions')) {
+       } else {
+               my ($jstimezone, $tz_cookie, $datetime_class) =
+                       gitweb_get_feature('javascript-timezone');
                print qq!<script type="text/javascript">\n!.
-                     qq!window.onload = fixLinks;\n!.
+                     qq!window.onload = function () {\n!;
+               if (gitweb_check_feature('javascript-actions')) {
+                       print qq!       fixLinks();\n!;
+               }
+               if ($jstimezone && $tz_cookie && $datetime_class) {
+                       print qq!       var tz_cookie = { name: '$tz_cookie', expires: 14, path: '/' };\n!. # in days
+                             qq!       onloadTZSetup('$jstimezone', tz_cookie, '$datetime_class');\n!;
+               }
+               print qq!};\n!.
                      qq!</script>\n!;
        }
  
@@@ -4103,22 -3961,25 +4126,25 @@@ sub git_print_section 
        print $cgi->end_div;
  }
  
- sub print_local_time {
-       print format_local_time(@_);
- }
+ sub format_timestamp_html {
+       my $date = shift;
+       my $strtime = $date->{'rfc2822'};
  
- sub format_local_time {
-       my $localtime = '';
-       my %date = @_;
-       if ($date{'hour_local'} < 6) {
-               $localtime .= sprintf(" (<span class=\"atnight\">%02d:%02d</span> %s)",
-                       $date{'hour_local'}, $date{'minute_local'}, $date{'tz_local'});
-       } else {
-               $localtime .= sprintf(" (%02d:%02d %s)",
-                       $date{'hour_local'}, $date{'minute_local'}, $date{'tz_local'});
+       my (undef, undef, $datetime_class) =
+               gitweb_get_feature('javascript-timezone');
+       if ($datetime_class) {
+               $strtime = qq!<span class="$datetime_class">$strtime</span>!;
+       }
+       my $localtime_format = '(%02d:%02d %s)';
+       if ($date->{'hour_local'} < 6) {
+               $localtime_format = '(<span class="atnight">%02d:%02d</span> %s)';
        }
+       $strtime .= ' ' .
+                   sprintf($localtime_format,
+                           $date->{'hour_local'}, $date->{'minute_local'}, $date->{'tz_local'});
  
-       return $localtime;
+       return $strtime;
  }
  
  # Outputs the author name and date in long form
@@@ -4131,10 -3992,9 +4157,9 @@@ sub git_print_authorship 
        my %ad = parse_date($co->{'author_epoch'}, $co->{'author_tz'});
        print "<$tag class=\"author_date\">" .
              format_search_author($author, "author", esc_html($author)) .
-             " [$ad{'rfc2822'}";
-       print_local_time(%ad) if ($opts{-localtime});
-       print "]" . git_get_avatar($co->{'author_email'}, -pad_before => 1)
-                 . "</$tag>\n";
+             " [".format_timestamp_html(\%ad)."]".
+             git_get_avatar($co->{'author_email'}, -pad_before => 1) .
+             "</$tag>\n";
  }
  
  # Outputs table rows containing the full author or committer information,
@@@ -4151,16 -4011,16 +4176,16 @@@ sub git_print_authorship_rows 
                my %wd = parse_date($co->{"${who}_epoch"}, $co->{"${who}_tz"});
                print "<tr><td>$who</td><td>" .
                      format_search_author($co->{"${who}_name"}, $who,
-                              esc_html($co->{"${who}_name"})) . " " .
+                                          esc_html($co->{"${who}_name"})) . " " .
                      format_search_author($co->{"${who}_email"}, $who,
-                              esc_html("<" . $co->{"${who}_email"} . ">")) .
+                                          esc_html("<" . $co->{"${who}_email"} . ">")) .
                      "</td><td rowspan=\"2\">" .
                      git_get_avatar($co->{"${who}_email"}, -size => 'double') .
                      "</td></tr>\n" .
                      "<tr>" .
-                     "<td></td><td> $wd{'rfc2822'}";
-               print_local_time(%wd);
-               print "</td>" .
+                     "<td></td><td>" .
+                     format_timestamp_html(\%wd) .
+                     "</td>" .
                      "</tr>\n";
        }
  }
@@@ -4903,12 -4763,11 +4928,12 @@@ sub git_patchset_body 
  
  # . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
  
 -# fills project list info (age, description, owner, forks) for each
 -# project in the list, removing invalid projects from returned list
 +# fills project list info (age, description, owner, category, forks)
 +# for each project in the list, removing invalid projects from
 +# returned list
  # NOTE: modifies $projlist, but does not remove entries from it
  sub fill_project_list_info {
 -      my ($projlist, $check_forks) = @_;
 +      my $projlist = shift;
        my @projects;
  
        my $show_ctags = gitweb_check_feature('ctags');
                if (!defined $pr->{'owner'}) {
                        $pr->{'owner'} = git_get_project_owner("$pr->{'path'}") || "";
                }
 -              if ($check_forks) {
 -                      my $pname = $pr->{'path'};
 -                      if (($pname =~ s/\.git$//) &&
 -                          ($pname !~ /\/$/) &&
 -                          (-d "$projectroot/$pname")) {
 -                              $pr->{'forks'} = "-d $projectroot/$pname";
 -                      } else {
 -                              $pr->{'forks'} = 0;
 -                      }
 +              if ($show_ctags) {
 +                      $pr->{'ctags'} = git_get_project_ctags($pr->{'path'});
 +              }
 +              if ($projects_list_group_categories && !defined $pr->{'category'}) {
 +                      my $cat = git_get_project_category($pr->{'path'}) ||
 +                                                         $project_list_default_category;
 +                      $pr->{'category'} = to_utf8($cat);
                }
 -              $show_ctags and $pr->{'ctags'} = git_get_project_ctags($pr->{'path'});
 +
                push @projects, $pr;
        }
  
        return @projects;
  }
  
 +sub sort_projects_list {
 +      my ($projlist, $order) = @_;
 +      my @projects;
 +
 +      my %order_info = (
 +              project => { key => 'path', type => 'str' },
 +              descr => { key => 'descr_long', type => 'str' },
 +              owner => { key => 'owner', type => 'str' },
 +              age => { key => 'age', type => 'num' }
 +      );
 +      my $oi = $order_info{$order};
 +      return @$projlist unless defined $oi;
 +      if ($oi->{'type'} eq 'str') {
 +              @projects = sort {$a->{$oi->{'key'}} cmp $b->{$oi->{'key'}}} @$projlist;
 +      } else {
 +              @projects = sort {$a->{$oi->{'key'}} <=> $b->{$oi->{'key'}}} @$projlist;
 +      }
 +
 +      return @projects;
 +}
 +
 +# returns a hash of categories, containing the list of project
 +# belonging to each category
 +sub build_projlist_by_category {
 +      my ($projlist, $from, $to) = @_;
 +      my %categories;
 +
 +      $from = 0 unless defined $from;
 +      $to = $#$projlist if (!defined $to || $#$projlist < $to);
 +
 +      for (my $i = $from; $i <= $to; $i++) {
 +              my $pr = $projlist->[$i];
 +              push @{$categories{ $pr->{'category'} }}, $pr;
 +      }
 +
 +      return wantarray ? %categories : \%categories;
 +}
 +
  # print 'sort by' <th> element, generating 'sort by $name' replay link
  # if that order is not selected
  sub print_sort_th {
@@@ -5004,15 -4827,70 +5029,15 @@@ sub format_sort_th 
        return $sort_th;
  }
  
 -sub git_project_list_body {
 -      # actually uses global variable $project
 -      my ($projlist, $order, $from, $to, $extra, $no_header) = @_;
 -
 -      my $check_forks = gitweb_check_feature('forks');
 -      my @projects = fill_project_list_info($projlist, $check_forks);
 +sub git_project_list_rows {
 +      my ($projlist, $from, $to, $check_forks) = @_;
  
 -      $order ||= $default_projects_order;
        $from = 0 unless defined $from;
 -      $to = $#projects if (!defined $to || $#projects < $to);
 +      $to = $#$projlist if (!defined $to || $#$projlist < $to);
  
 -      my %order_info = (
 -              project => { key => 'path', type => 'str' },
 -              descr => { key => 'descr_long', type => 'str' },
 -              owner => { key => 'owner', type => 'str' },
 -              age => { key => 'age', type => 'num' }
 -      );
 -      my $oi = $order_info{$order};
 -      if ($oi->{'type'} eq 'str') {
 -              @projects = sort {$a->{$oi->{'key'}} cmp $b->{$oi->{'key'}}} @projects;
 -      } else {
 -              @projects = sort {$a->{$oi->{'key'}} <=> $b->{$oi->{'key'}}} @projects;
 -      }
 -
 -      my $show_ctags = gitweb_check_feature('ctags');
 -      if ($show_ctags) {
 -              my %ctags;
 -              foreach my $p (@projects) {
 -                      foreach my $ct (keys %{$p->{'ctags'}}) {
 -                              $ctags{$ct} += $p->{'ctags'}->{$ct};
 -                      }
 -              }
 -              my $cloud = git_populate_project_tagcloud(\%ctags);
 -              print git_show_project_tagcloud($cloud, 64);
 -      }
 -
 -      print "<table class=\"project_list\">\n";
 -      unless ($no_header) {
 -              print "<tr>\n";
 -              if ($check_forks) {
 -                      print "<th></th>\n";
 -              }
 -              print_sort_th('project', $order, 'Project');
 -              print_sort_th('descr', $order, 'Description');
 -              print_sort_th('owner', $order, 'Owner');
 -              print_sort_th('age', $order, 'Last Change');
 -              print "<th></th>\n" . # for links
 -                    "</tr>\n";
 -      }
        my $alternate = 1;
 -      my $tagfilter = $cgi->param('by_tag');
        for (my $i = $from; $i <= $to; $i++) {
 -              my $pr = $projects[$i];
 -
 -              next if $tagfilter and $show_ctags and not grep { lc $_ eq lc $tagfilter } keys %{$pr->{'ctags'}};
 -              next if $searchtext and not $pr->{'path'} =~ /$searchtext/
 -                      and not $pr->{'descr_long'} =~ /$searchtext/;
 -              # Weed out forks or non-matching entries of search
 -              if ($check_forks) {
 -                      my $forkbase = $project; $forkbase ||= ''; $forkbase =~ s#\.git$#/#;
 -                      $forkbase="^$forkbase" if $forkbase;
 -                      next if not $searchtext and not $tagfilter and $show_ctags
 -                              and $pr->{'path'} =~ m#$forkbase.*/.*#; # regexp-safe
 -              }
 +              my $pr = $projlist->[$i];
  
                if ($alternate) {
                        print "<tr class=\"dark\">\n";
                        print "<tr class=\"light\">\n";
                }
                $alternate ^= 1;
 +
                if ($check_forks) {
                        print "<td>";
                        if ($pr->{'forks'}) {
 -                              print "<!-- $pr->{'forks'} -->\n";
 -                              print $cgi->a({-href => href(project=>$pr->{'path'}, action=>"forks")}, "+");
 +                              my $nforks = scalar @{$pr->{'forks'}};
 +                              if ($nforks > 0) {
 +                                      print $cgi->a({-href => href(project=>$pr->{'path'}, action=>"forks"),
 +                                                     -title => "$nforks forks"}, "+");
 +                              } else {
 +                                      print $cgi->span({-title => "$nforks forks"}, "+");
 +                              }
                        }
                        print "</td>\n";
                }
                      "</td>\n" .
                      "</tr>\n";
        }
 +}
 +
 +sub git_project_list_body {
 +      # actually uses global variable $project
 +      my ($projlist, $order, $from, $to, $extra, $no_header) = @_;
 +      my @projects = @$projlist;
 +
 +      my $check_forks = gitweb_check_feature('forks');
 +      my $show_ctags  = gitweb_check_feature('ctags');
 +      my $tagfilter = $show_ctags ? $cgi->param('by_tag') : undef;
 +      $check_forks = undef
 +              if ($tagfilter || $searchtext);
 +
 +      # filtering out forks before filling info allows to do less work
 +      @projects = filter_forks_from_projects_list(\@projects)
 +              if ($check_forks);
 +      @projects = fill_project_list_info(\@projects);
 +      # searching projects require filling to be run before it
 +      @projects = search_projects_list(\@projects,
 +                                       'searchtext' => $searchtext,
 +                                       'tagfilter'  => $tagfilter)
 +              if ($tagfilter || $searchtext);
 +
 +      $order ||= $default_projects_order;
 +      $from = 0 unless defined $from;
 +      $to = $#projects if (!defined $to || $#projects < $to);
 +
 +      # short circuit
 +      if ($from > $to) {
 +              print "<center>\n".
 +                    "<b>No such projects found</b><br />\n".
 +                    "Click ".$cgi->a({-href=>href(project=>undef)},"here")." to view all projects<br />\n".
 +                    "</center>\n<br />\n";
 +              return;
 +      }
 +
 +      @projects = sort_projects_list(\@projects, $order);
 +
 +      if ($show_ctags) {
 +              my $ctags = git_gather_all_ctags(\@projects);
 +              my $cloud = git_populate_project_tagcloud($ctags);
 +              print git_show_project_tagcloud($cloud, 64);
 +      }
 +
 +      print "<table class=\"project_list\">\n";
 +      unless ($no_header) {
 +              print "<tr>\n";
 +              if ($check_forks) {
 +                      print "<th></th>\n";
 +              }
 +              print_sort_th('project', $order, 'Project');
 +              print_sort_th('descr', $order, 'Description');
 +              print_sort_th('owner', $order, 'Owner');
 +              print_sort_th('age', $order, 'Last Change');
 +              print "<th></th>\n" . # for links
 +                    "</tr>\n";
 +      }
 +
 +      if ($projects_list_group_categories) {
 +              # only display categories with projects in the $from-$to window
 +              @projects = sort {$a->{'category'} cmp $b->{'category'}} @projects[$from..$to];
 +              my %categories = build_projlist_by_category(\@projects, $from, $to);
 +              foreach my $cat (sort keys %categories) {
 +                      unless ($cat eq "") {
 +                              print "<tr>\n";
 +                              if ($check_forks) {
 +                                      print "<td></td>\n";
 +                              }
 +                              print "<td class=\"category\" colspan=\"5\">".esc_html($cat)."</td>\n";
 +                              print "</tr>\n";
 +                      }
 +
 +                      git_project_list_rows($categories{$cat}, undef, undef, $check_forks);
 +              }
 +      } else {
 +              git_project_list_rows(\@projects, $from, $to, $check_forks);
 +      }
 +
        if (defined $extra) {
                print "<tr>\n";
                if ($check_forks) {
@@@ -5588,10 -5382,7 +5613,10 @@@ sub git_forks 
  }
  
  sub git_project_index {
 -      my @projects = git_get_projects_list($project);
 +      my @projects = git_get_projects_list();
 +      if (!@projects) {
 +              die_error(404, "No projects found");
 +      }
  
        print $cgi->header(
                -type => 'text/plain',
@@@ -5633,11 -5424,7 +5658,11 @@@ sub git_summary 
        my $check_forks = gitweb_check_feature('forks');
  
        if ($check_forks) {
 +              # find forks of a project
                @forklist = git_get_projects_list($project);
 +              # filter out forks of forks
 +              @forklist = filter_forks_from_projects_list(\@forklist)
 +                      if (@forklist);
        }
  
        git_header_html();
              "<tr id=\"metadata_desc\"><td>description</td><td>" . esc_html($descr) . "</td></tr>\n" .
              "<tr id=\"metadata_owner\"><td>owner</td><td>" . esc_html($owner) . "</td></tr>\n";
        if (defined $cd{'rfc2822'}) {
-               print "<tr id=\"metadata_lchange\"><td>last change</td><td>$cd{'rfc2822'}</td></tr>\n";
+               print "<tr id=\"metadata_lchange\"><td>last change</td>" .
+                     "<td>".format_timestamp_html(\%cd)."</td></tr>\n";
        }
  
        # use per project git URL list in $projectroot/$project/cloneurl
        my $show_ctags = gitweb_check_feature('ctags');
        if ($show_ctags) {
                my $ctags = git_get_project_ctags($project);
 -              my $cloud = git_populate_project_tagcloud($ctags);
 -              print "<tr id=\"metadata_ctags\"><td>Content tags:<br />";
 -              print "</td>\n<td>" unless %$ctags;
 -              print "<form action=\"$show_ctags\" method=\"post\"><input type=\"hidden\" name=\"p\" value=\"$project\" />Add: <input type=\"text\" name=\"t\" size=\"8\" /></form>";
 -              print "</td>\n<td>" if %$ctags;
 -              print git_show_project_tagcloud($cloud, 48);
 -              print "</td></tr>";
 +              if (%$ctags) {
 +                      # without ability to add tags, don't show if there are none
 +                      my $cloud = git_populate_project_tagcloud($ctags);
 +                      print "<tr id=\"metadata_ctags\">" .
 +                            "<td>content tags</td>" .
 +                            "<td>".git_show_project_tagcloud($cloud, 48)."</td>" .
 +                            "</tr>\n";
 +              }
        }
  
        print "</table>\n";
@@@ -7558,9 -7345,6 +7584,9 @@@ sub git_atom 
  
  sub git_opml {
        my @list = git_get_projects_list();
 +      if (!@list) {
 +              die_error(404, "No projects found");
 +      }
  
        print $cgi->header(
                -type => 'text/xml',
diff --combined gitweb/static/gitweb.css
index 4df2d163c98aaf6ab42b58d9052dbaead373a453,8dd093563eb879872821c62008b35d32a819e9d1..7d88509208417e4b1222629002ea339ecc32526e
@@@ -295,13 -295,6 +295,13 @@@ td.current_head 
        text-decoration: underline;
  }
  
 +td.category {
 +      background-color: #d9d8d1;
 +      border-top: 1px solid #000000;
 +      border-left: 1px solid #000000;
 +      font-weight: bold;
 +}
 +
  table.diff_tree span.file_status.new {
        color: #008000;
  }
@@@ -586,6 -579,39 +586,39 @@@ div.remote 
        display: inline-block;
  }
  
+ /* JavaScript-based timezone manipulation */
+ .popup { /* timezone selection UI */
+       position: absolute;
+       /* "top: 0; right: 0;" would be better, if not for bugs in browsers */
+       top: 0; left: 0;
+       border: 1px solid;
+       padding: 2px;
+       background-color: #f0f0f0;
+       font-style: normal;
+       color: #000000;
+       cursor: auto;
+ }
+ .close-button { /* close timezone selection UI without selecting */
+       /* float doesn't work within absolutely positioned container,
+        * if width of container is not set explicitly */
+       /* float: right; */
+       position: absolute;
+       top: 0px; right: 0px;
+       border:  1px solid green;
+       margin:  1px 1px 1px 1px;
+       padding-bottom: 2px;
+       width:     12px;
+       height:    10px;
+       font-size:  9px;
+       font-weight: bold;
+       text-align: center;
+       background-color: #fff0f0;
+       cursor: pointer;
+ }
  /* Style definition generated by highlight 2.4.5, http://www.andre-simon.de/ */
  
  /* Highlighting theme definition: */