bloodhound-trac / trac / versioncontrol / web_ui / changeset.py

   1
   2
   3
   4
   5
   6
   7
   8
   9
  10
  11
  12
  13
  14
  15
  16
  17
  18
  19
  20
  21
  22
  23
  24
  25
  26
  27
  28
  29
  30
  31
  32
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
# -*- coding: utf-8 -*-
#
# Copyright (C) 2003-2009 Edgewall Software
# Copyright (C) 2003-2005 Jonas Borgström <jonas@edgewall.com>
# Copyright (C) 2004-2006 Christopher Lenz <cmlenz@gmx.de>
# Copyright (C) 2005-2006 Christian Boos <cboos@edgewall.org>
# All rights reserved.
#
# This software is licensed as described in the file COPYING, which
# you should have received as part of this distribution. The terms
# are also available at http://trac.edgewall.org/wiki/TracLicense.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
# history and logs, available at http://trac.edgewall.org/log/.
#
# Author: Jonas Borgström <jonas@edgewall.com>
#         Christopher Lenz <cmlenz@gmx.de>
#         Christian Boos <cboos@edgewall.org>

from __future__ import with_statement

from itertools import groupby
import os
import posixpath
import re
from StringIO import StringIO

from genshi.builder import tag

from trac.config import Option, BoolOption, IntOption
from trac.core import *
from trac.mimeview.api import Mimeview
from trac.perm import IPermissionRequestor
from trac.resource import Resource, ResourceNotFound
from trac.search import ISearchSource, search_to_sql, shorten_result
from trac.timeline.api import ITimelineEventProvider
from trac.util import as_bool, content_disposition, embedded_numbers, pathjoin
from trac.util.datefmt import from_utimestamp, pretty_timedelta
from trac.util.text import exception_to_unicode, to_unicode, \
                           unicode_urlencode, shorten_line, CRLF
from trac.util.translation import _, ngettext
from trac.versioncontrol.api import RepositoryManager, Changeset, Node, \
                                    NoSuchChangeset
from trac.versioncontrol.diff import get_diff_options, diff_blocks, \
                                     unified_diff
from trac.versioncontrol.web_ui.browser import BrowserModule
from trac.web import IRequestHandler, RequestDone
from trac.web.chrome import (Chrome, INavigationContributor, add_ctxtnav, 
                             add_link, add_script, add_stylesheet, 
                             prevnext_nav, web_context)
from trac.wiki import IWikiSyntaxProvider, WikiParser
from trac.wiki.formatter import format_to


class IPropertyDiffRenderer(Interface):
    """Render node properties in TracBrowser and TracChangeset views."""

    def match_property_diff(name):
        """Indicate whether this renderer can treat the given property diffs

        Returns a quality number, ranging from 0 (unsupported) to 9
        (''perfect'' match).
        """

    def render_property_diff(name, old_context, old_props,
                             new_context, new_props, options):
        """Render the given diff of property to HTML.

        `name` is the property name as given to `match_property_diff()`,
        `old_context` corresponds to the old node being render
        (useful when the rendering depends on the node kind)
        and `old_props` is the corresponding collection of all properties.
        Same for `new_node` and `new_props`.
        `options` are the current diffs options.

        The rendered result can be one of the following:
        - `None`: the property change will be shown the normal way
          (''changed from `old` to `new`'')
        - an `unicode` value: the change will be shown as textual content
        - `Markup` or other Genshi content: the change will shown as block
          markup
        """


class DefaultPropertyDiffRenderer(Component):
    """Default version control property difference renderer."""

    implements(IPropertyDiffRenderer)

    def match_property_diff(self, name):
        return 1

    def render_property_diff(self, name, old_context, old_props,
                             new_context, new_props, options):
        old, new = old_props[name], new_props[name]
        # Render as diff only if multiline (see #3002)
        if '\n' not in old and '\n' not in new:
            return None
        unidiff = '--- \n+++ \n' + \
                  '\n'.join(unified_diff(old.splitlines(), new.splitlines(),
                                         options.get('contextlines', 3)))
        return tag.li('Property ', tag.strong(name),
                      Mimeview(self.env).render(old_context, 'text/x-diff',
                                                unidiff))


class ChangesetModule(Component):
    """Renderer providing flexible functionality for showing sets of
    differences.

    If the differences shown are coming from a specific changeset,
    then that changeset information can be shown too.

    In addition, it is possible to show only a subset of the changeset:
    Only the changes affecting a given path will be shown. This is called
    the ''restricted'' changeset.

    But the differences can also be computed in a more general way,
    between two arbitrary paths and/or between two arbitrary revisions.
    In that case, there's no changeset information displayed.
    """

    implements(INavigationContributor, IPermissionRequestor, IRequestHandler,
               ITimelineEventProvider, IWikiSyntaxProvider, ISearchSource)

    property_diff_renderers = ExtensionPoint(IPropertyDiffRenderer)
    
    timeline_show_files = Option('timeline', 'changeset_show_files', '0',
        """Number of files to show (`-1` for unlimited, `0` to disable).

        This can also be `location`, for showing the common prefix for the
        changed files. (since 0.11).
        """)

    timeline_long_messages = BoolOption('timeline', 'changeset_long_messages',
                                        'false',
        """Whether wiki-formatted changeset messages should be multiline or
        not.

        If this option is not specified or is false and `wiki_format_messages`
        is set to true, changeset messages will be single line only, losing
        some formatting (bullet points, etc).""")

    timeline_collapse = BoolOption('timeline', 'changeset_collapse_events',
                                   'false',
        """Whether consecutive changesets from the same author having 
        exactly the same message should be presented as one event.
        That event will link to the range of changesets in the log view.
        (''since 0.11'')""")

    max_diff_files = IntOption('changeset', 'max_diff_files', 0,
        """Maximum number of modified files for which the changeset view will
        attempt to show the diffs inlined (''since 0.10'').""")

    max_diff_bytes = IntOption('changeset', 'max_diff_bytes', 10000000,
        """Maximum total size in bytes of the modified files (their old size
        plus their new size) for which the changeset view will attempt to show
        the diffs inlined (''since 0.10'').""")

    wiki_format_messages = BoolOption('changeset', 'wiki_format_messages',
                                      'true',
        """Whether wiki formatting should be applied to changeset messages.
        
        If this option is disabled, changeset messages will be rendered as
        pre-formatted text.""")

    # INavigationContributor methods

    def get_active_navigation_item(self, req):
        return 'browser'

    def get_navigation_items(self, req):
        return []

    # IPermissionRequestor methods

    def get_permission_actions(self):
        return ['CHANGESET_VIEW']

    # IRequestHandler methods

    _request_re = re.compile(r"/changeset(?:/([^/]+)(/.*)?)?$")

    def match_request(self, req):
        match = re.match(self._request_re, req.path_info)
        if match:
            new, new_path = match.groups()
            if new:
                req.args['new'] = new
            if new_path:
                req.args['new_path'] = new_path
            return True

    def process_request(self, req):
        """The appropriate mode of operation is inferred from the request
        parameters:

         * If `new_path` and `old_path` are equal (or `old_path` is omitted)
           and `new` and `old` are equal (or `old` is omitted),
           then we're about to view a revision Changeset: `chgset` is True.
           Furthermore, if the path is not the root, the changeset is
           ''restricted'' to that path (only the changes affecting that path,
           its children or its ancestor directories will be shown).
         * In any other case, the set of changes corresponds to arbitrary
           differences between path@rev pairs. If `new_path` and `old_path`
           are equal, the ''restricted'' flag will also be set, meaning in this
           case that the differences between two revisions are restricted to
           those occurring on that path.

        In any case, either path@rev pairs must exist.
        """
        req.perm.require('CHANGESET_VIEW')
        
        # -- retrieve arguments
        full_new_path = new_path = req.args.get('new_path')
        new = req.args.get('new')
        full_old_path = old_path = req.args.get('old_path')
        old = req.args.get('old')
        reponame = req.args.get('reponame')

        xhr = req.get_header('X-Requested-With') == 'XMLHttpRequest'

        # -- support for the revision log ''View changes'' form,
        #    where we need to give the path and revision at the same time
        if old and '@' in old:
            old, old_path = old.split('@', 1)
        if new and '@' in new:
            new, new_path = new.split('@', 1)

        rm = RepositoryManager(self.env)
        if reponame:
            repos = rm.get_repository(reponame)
        else:
            reponame, repos, new_path = rm.get_repository_by_path(new_path)

            if old_path:
                old_reponame, old_repos, old_path = \
                    rm.get_repository_by_path(old_path)
                if old_repos != repos:
                    raise TracError(_("Can't compare across different "
                                      "repositories: %(old)s vs. %(new)s",
                                      old=old_reponame, new=reponame))

        if not repos:
            if reponame or (new_path and new_path != '/'):
                raise TracError(_("Repository '%(repo)s' not found",
                                  repo=reponame or new_path.strip('/')))
            else:
                raise TracError(_("No repository specified and no default "
                                  "repository configured."))

        # -- normalize and check for special case
        try:
            new_path = repos.normalize_path(new_path)
            new = repos.normalize_rev(new)
            full_new_path = '/' + pathjoin(repos.reponame, new_path)
            old_path = repos.normalize_path(old_path or new_path)
            old = repos.normalize_rev(old or new)
            full_old_path = '/' + pathjoin(repos.reponame, old_path)
        except NoSuchChangeset, e:
            raise ResourceNotFound(e.message, _('Invalid Changeset Number'))

        if old_path == new_path and old == new: # revert to Changeset
            old_path = old = None

        style, options, diff_data = get_diff_options(req)
        diff_opts = diff_data['options']

        # -- setup the `chgset` and `restricted` flags, see docstring above.
        chgset = not old and not old_path
        if chgset:
            restricted = new_path not in ('', '/') # (subset or not)
        else:
            restricted = old_path == new_path # (same path or not)

        # -- redirect if changing the diff options or alias requested
        if 'update' in req.args or reponame != repos.reponame:
            contextall = diff_opts['contextall'] or None
            reponame = repos.reponame or None
            if chgset:
                if restricted:
                    req.redirect(req.href.changeset(new, reponame, new_path,
                                                    contextall=contextall))
                else:
                    req.redirect(req.href.changeset(new, reponame,
                                                    contextall=contextall))
            else:
                req.redirect(req.href.changeset(new, reponame,
                                                new_path, old=old,
                                                old_path=full_old_path,
                                                contextall=contextall))

        # -- preparing the data
        if chgset:
            prev = repos.get_node(new_path, new).get_previous()
            if prev:
                prev_path, prev_rev = prev[:2]
            else:
                prev_path, prev_rev = new_path, repos.previous_rev(new)
            data = {'old_path': prev_path, 'old_rev': prev_rev,
                    'new_path': new_path, 'new_rev': new}
        else:
            if not new:
                new = repos.youngest_rev
            elif not old:
                old = repos.youngest_rev
            if not old_path:
                old_path = new_path
            data = {'old_path': old_path, 'old_rev': old,
                    'new_path': new_path, 'new_rev': new}
        data.update({'repos': repos, 'reponame': repos.reponame or None,
                     'diff': diff_data,
                     'wiki_format_messages': self.wiki_format_messages})

        if chgset:
            chgset = repos.get_changeset(new)
            req.perm(chgset.resource).require('CHANGESET_VIEW')

            # TODO: find a cheaper way to reimplement r2636
            req.check_modified(chgset.date, [
                style, ''.join(options), repos.name,
                diff_opts['contextlines'], diff_opts['contextall'],
                repos.rev_older_than(new, repos.youngest_rev),
                chgset.message, xhr,
                pretty_timedelta(chgset.date, None, 3600)])

        format = req.args.get('format')

        if format in ['diff', 'zip']:
            # choosing an appropriate filename
            rpath = new_path.replace('/','_')
            if chgset:
                if restricted:
                    filename = 'changeset_%s_%s' % (rpath, new)
                else:
                    filename = 'changeset_%s' % new
            else:
                if restricted:
                    filename = 'diff-%s-from-%s-to-%s' \
                                  % (rpath, old, new)
                elif old_path == '/': # special case for download (#238)
                    filename = '%s-%s' % (rpath, old)
                else:
                    filename = 'diff-from-%s-%s-to-%s-%s' \
                               % (old_path.replace('/','_'), old, rpath, new)
            if format == 'diff':
                self._render_diff(req, filename, repos, data)
            elif format == 'zip':
                self._render_zip(req, filename, repos, data)

        # -- HTML format
        self._render_html(req, repos, chgset, restricted, xhr, data)
        
        if chgset:
            diff_params = 'new=%s' % new
        else:
            diff_params = unicode_urlencode({
                'new_path': full_new_path, 'new': new,
                'old_path': full_old_path, 'old': old})
        add_link(req, 'alternate', '?format=diff&' + diff_params,
                 _('Unified Diff'), 'text/plain', 'diff')
        add_link(req, 'alternate', '?format=zip&' + diff_params,
                 _('Zip Archive'), 'application/zip', 'zip')
        add_script(req, 'common/js/diff.js')
        add_stylesheet(req, 'common/css/changeset.css')
        add_stylesheet(req, 'common/css/diff.css')
        add_stylesheet(req, 'common/css/code.css')
        if chgset:
            if restricted:
                prevnext_nav(req, _('Previous Change'), _('Next Change'))
            else:
                prevnext_nav(req, _('Previous Changeset'), _('Next Changeset'))
        else:
            rev_href = req.href.changeset(old, full_old_path, 
                                          old=new, old_path=full_new_path)
            add_ctxtnav(req, _('Reverse Diff'), href=rev_href)
            
        return 'changeset.html', data, None

    # Internal methods

    def _render_html(self, req, repos, chgset, restricted, xhr, data):
        """HTML version"""
        data['restricted'] = restricted
        display_rev = repos.display_rev
        data['display_rev'] = display_rev
        browser = BrowserModule(self.env)
        reponame = repos.reponame or None

        if chgset: # Changeset Mode (possibly restricted on a path)
            path, rev = data['new_path'], data['new_rev']

            # -- getting the change summary from the Changeset.get_changes
            def get_changes():
                for npath, kind, change, opath, orev in chgset.get_changes():
                    old_node = new_node = None
                    if (restricted and
                        not (npath == path or                # same path
                             npath.startswith(path + '/') or # npath is below
                             path.startswith(npath + '/'))): # npath is above
                        continue
                    if change != Changeset.ADD:
                        old_node = repos.get_node(opath, orev)
                    if change != Changeset.DELETE:
                        new_node = repos.get_node(npath, rev)
                    else:
                        # support showing paths deleted below a copy target
                        old_node.path = npath
                    yield old_node, new_node, kind, change

            def _changeset_title(rev):
                rev = display_rev(rev)
                if restricted:
                    return _('Changeset %(id)s for %(path)s', id=rev,
                             path=path)
                else:
                    return _('Changeset %(id)s', id=rev)

            data['changeset'] = chgset
            title = _changeset_title(rev)

            # Support for revision properties (#2545)
            context = web_context(req, 'changeset', chgset.rev,
                                  parent=repos.resource)
            data['context'] = context
            revprops = chgset.get_properties()
            data['properties'] = browser.render_properties('revprop', context,
                                                           revprops)
            oldest_rev = repos.oldest_rev
            if chgset.rev != oldest_rev:
                if restricted:
                    prev = repos.get_node(path, rev).get_previous()
                    if prev:
                        prev_path, prev_rev = prev[:2]
                        if prev_rev:
                            prev_href = req.href.changeset(prev_rev, reponame,
                                                           prev_path)
                    else:
                        prev_path = prev_rev = None
                else:
                    add_link(req, 'first', 
                             req.href.changeset(oldest_rev, reponame),
                             _('Changeset %(id)s', id=display_rev(oldest_rev)))
                    prev_path = data['old_path']
                    prev_rev = repos.previous_rev(chgset.rev)
                    if prev_rev:
                        prev_href = req.href.changeset(prev_rev, reponame)
                if prev_rev:
                    add_link(req, 'prev', prev_href,
                             _changeset_title(prev_rev))
            youngest_rev = repos.youngest_rev
            if str(chgset.rev) != str(youngest_rev):
                if restricted:
                    next_rev = repos.next_rev(chgset.rev, path)
                    if next_rev:
                        if repos.has_node(path, next_rev):
                            next_href = req.href.changeset(next_rev, reponame,
                                                           path)
                        else: # must be a 'D'elete or 'R'ename, show full cset
                            next_href = req.href.changeset(next_rev, reponame)
                else:
                    add_link(req, 'last', 
                             req.href.changeset(youngest_rev, reponame),
                             _('Changeset %(id)s',
                               id=display_rev(youngest_rev)))
                    next_rev = repos.next_rev(chgset.rev)
                    if next_rev:
                        next_href = req.href.changeset(next_rev, reponame)
                if next_rev:
                    add_link(req, 'next', next_href,
                             _changeset_title(next_rev))
        else: # Diff Mode
            # -- getting the change summary from the Repository.get_changes
            def get_changes():
                for d in repos.get_changes(
                    new_path=data['new_path'], new_rev=data['new_rev'],
                    old_path=data['old_path'], old_rev=data['old_rev']):
                    yield d
            title = self.title_for_diff(data)
            data['changeset'] = False

        data['title'] = title

        if 'BROWSER_VIEW' not in req.perm:
            return

        def node_info(node, annotated):
            href = req.href.browser(
                reponame, node.created_path, rev=node.created_rev,
                annotate='blame' if annotated else None)
            title = _('Show revision %(rev)s of this file in browser',
                      rev=display_rev(node.rev))
            return {'path': node.path, 'rev': node.rev,
                    'shortrev': repos.short_rev(node.rev),
                    'href': href, 'title': title}
        # Reminder: node.path may not exist at node.rev
        #           as long as node.rev==node.created_rev
        #           ... and data['old_rev'] may have nothing to do
        #           with _that_ node specific history...

        options = data['diff']['options']

        def _prop_changes(old_node, new_node):
            old_props = old_node.get_properties()
            new_props = new_node.get_properties()
            old_ctx = web_context(req, old_node.resource)
            new_ctx = web_context(req, new_node.resource)
            changed_properties = []
            if old_props != new_props:
                for k, v in sorted(old_props.items()):
                    new = old = diff = None
                    if not k in new_props:
                        old = v # won't be displayed, no need to render it
                    elif v != new_props[k]:
                        diff = self.render_property_diff(
                            k, old_ctx, old_props, new_ctx, new_props, options)
                        if not diff:
                            old = browser.render_property(k, 'changeset',
                                                          old_ctx, old_props)
                            new = browser.render_property(k, 'changeset',
                                                          new_ctx, new_props)
                    if new or old or diff:
                        changed_properties.append({'name': k, 'old': old,
                                                   'new': new, 'diff': diff})
                for k, v in sorted(new_props.items()):
                    if not k in old_props:
                        new = browser.render_property(k, 'changeset',
                                                      new_ctx, new_props)
                        if new is not None:
                            changed_properties.append({'name': k, 'new': new,
                                                       'old': None})
            return changed_properties

        def _estimate_changes(old_node, new_node):
            old_size = old_node.get_content_length()
            new_size = new_node.get_content_length()
            return old_size + new_size

        def _content_changes(old_node, new_node):
            """Returns the list of differences.

            The list is empty when no differences between comparable files
            are detected, but the return value is None for non-comparable
            files.
            """
            mview = Mimeview(self.env)
            if mview.is_binary(old_node.content_type, old_node.path):
                return None
            if mview.is_binary(new_node.content_type, new_node.path):
                return None
            old_content = old_node.get_content().read()
            if mview.is_binary(content=old_content):
                return None
            new_content = new_node.get_content().read()
            if mview.is_binary(content=new_content):
                return None

            old_content = mview.to_unicode(old_content, old_node.content_type)
            new_content = mview.to_unicode(new_content, new_node.content_type)

            if old_content != new_content:
                context = options.get('contextlines', 3)
                if context < 0 or options.get('contextall'):
                    context = None
                tabwidth = self.config['diff'].getint('tab_width') or \
                           self.config['mimeviewer'].getint('tab_width', 8)
                ignore_blank_lines = options.get('ignoreblanklines')
                ignore_case = options.get('ignorecase')
                ignore_space = options.get('ignorewhitespace')
                return diff_blocks(old_content.splitlines(),
                                   new_content.splitlines(),
                                   context, tabwidth,
                                   ignore_blank_lines=ignore_blank_lines,
                                   ignore_case=ignore_case,
                                   ignore_space_changes=ignore_space)
            else:
                return []

        diff_bytes = diff_files = 0
        if self.max_diff_bytes or self.max_diff_files:
            for old_node, new_node, kind, change in get_changes():
                if change in Changeset.DIFF_CHANGES and kind == Node.FILE \
                        and old_node.is_viewable(req.perm) \
                        and new_node.is_viewable(req.perm):
                    diff_files += 1
                    diff_bytes += _estimate_changes(old_node, new_node)
        show_diffs = (not self.max_diff_files or \
                      0 < diff_files <= self.max_diff_files) and \
                     (not self.max_diff_bytes or \
                      diff_bytes <= self.max_diff_bytes or \
                      diff_files == 1)

        # XHR is used for blame support: display the changeset view without
        # the navigation and with the changes concerning the annotated file
        annotated = False
        if xhr:
            show_diffs = False
            annotated = repos.normalize_path(req.args.get('annotate'))

        has_diffs = False
        filestats = self._prepare_filestats()
        changes = []
        files = []
        for old_node, new_node, kind, change in get_changes():
            props = []
            diffs = []
            show_old = old_node and old_node.is_viewable(req.perm)
            show_new = new_node and new_node.is_viewable(req.perm)
            show_entry = change != Changeset.EDIT
            show_diff = show_diffs or (new_node and new_node.path == annotated)

            if change in Changeset.DIFF_CHANGES and show_old and show_new:
                assert old_node and new_node
                props = _prop_changes(old_node, new_node)
                if props:
                    show_entry = True
                if kind == Node.FILE and show_diff:
                    diffs = _content_changes(old_node, new_node)
                    if diffs != []:
                        if diffs:
                            has_diffs = True
                        # elif None (means: manually compare to (previous))
                        show_entry = True
            if (show_old or show_new) and (show_entry or not show_diff):
                info = {'change': change,
                        'old': old_node and node_info(old_node, annotated),
                        'new': new_node and node_info(new_node, annotated),
                        'props': props,
                        'diffs': diffs}
                files.append(new_node.path if new_node else \
                             old_node.path if old_node else '')
                filestats[change] += 1
                if change in Changeset.DIFF_CHANGES:
                    if chgset:
                        href = req.href.changeset(new_node.rev, reponame,
                                                  new_node.path)
                        title = _('Show the changeset %(id)s restricted to '
                                  '%(path)s', id=display_rev(new_node.rev),
                                  path=new_node.path)
                    else:
                        href = req.href.changeset(
                            new_node.created_rev, reponame,
                            new_node.created_path,
                            old=old_node.created_rev,
                            old_path=pathjoin(repos.reponame, 
                                              old_node.created_path))
                        title = _('Show the %(range)s differences restricted '
                                  'to %(path)s', range='[%s:%s]' % (
                                      display_rev(old_node.rev),
                                      display_rev(new_node.rev)),
                                  path=new_node.path)
                    info['href'] = href
                    info['title'] = old_node and title
                if change in Changeset.DIFF_CHANGES and not show_diff:
                    info['hide_diff'] = True
            else:
                info = None
            changes.append(info) # the sequence should be immutable

        data.update({'has_diffs': has_diffs, 'changes': changes, 'xhr': xhr,
                     'filestats': filestats, 'annotated': annotated,
                     'files': files, 
                     'location': self._get_parent_location(files),
                     'longcol': 'Revision', 'shortcol': 'r'})

        if xhr: # render and return the content only
            stream = Chrome(self.env).render_template(req, 'changeset.html',
                                                      data, fragment=True)
            content = stream.select('//div[@id="content"]')
            str_content = content.render('xhtml', encoding='utf-8')
            req.send_header('Content-Length', len(str_content))
            req.end_headers()
            req.write(str_content)
            raise RequestDone

        return data

    def _render_diff(self, req, filename, repos, data):
        """Raw Unified Diff version"""
        req.send_response(200)
        req.send_header('Content-Type', 'text/x-patch;charset=utf-8')
        req.send_header('Content-Disposition',
                        content_disposition('attachment', filename + '.diff'))
        buf = StringIO()
        mimeview = Mimeview(self.env)

        for old_node, new_node, kind, change in repos.get_changes(
                new_path=data['new_path'], new_rev=data['new_rev'],
                old_path=data['old_path'], old_rev=data['old_rev']):
            # TODO: Property changes

            # Content changes
            if kind == Node.DIRECTORY:
                continue

            new_content = old_content = ''
            new_node_info = old_node_info = ('','')

            if old_node:
                if not old_node.is_viewable(req.perm):
                    continue
                if mimeview.is_binary(old_node.content_type, old_node.path):
                    continue
                old_content = old_node.get_content().read()
                if mimeview.is_binary(content=old_content):
                    continue
                old_node_info = (old_node.path, old_node.rev)
                old_content = mimeview.to_unicode(old_content,
                                                  old_node.content_type)
            if new_node:
                if not new_node.is_viewable(req.perm):
                    continue
                if mimeview.is_binary(new_node.content_type, new_node.path):
                    continue
                new_content = new_node.get_content().read()
                if mimeview.is_binary(content=new_content):
                    continue
                new_node_info = (new_node.path, new_node.rev)
                new_path = new_node.path
                new_content = mimeview.to_unicode(new_content,
                                                  new_node.content_type)
            else:
                old_node_path = repos.normalize_path(old_node.path)
                diff_old_path = repos.normalize_path(data['old_path'])
                new_path = pathjoin(data['new_path'],
                                    old_node_path[len(diff_old_path) + 1:])

            if old_content != new_content:
                options = data['diff']['options']
                context = options.get('contextlines', 3)
                if context < 0 or options.get('contextall'):
                    context = 3 # FIXME: unified_diff bugs with context=None
                ignore_blank_lines = options.get('ignoreblanklines')
                ignore_case = options.get('ignorecase')
                ignore_space = options.get('ignorewhitespace')
                if not old_node_info[0]:
                    old_node_info = new_node_info # support for 'A'dd changes
                buf.write('Index: ' + new_path + CRLF)
                buf.write('=' * 67 + CRLF)
                buf.write('--- %s\t(revision %s)' % old_node_info + CRLF)
                buf.write('+++ %s\t(revision %s)' % new_node_info + CRLF)
                for line in unified_diff(old_content.splitlines(),
                                         new_content.splitlines(), context,
                                         ignore_blank_lines=ignore_blank_lines,
                                         ignore_case=ignore_case,
                                         ignore_space_changes=ignore_space):
                    buf.write(line + CRLF)
                    
        diff_str = buf.getvalue().encode('utf-8')
        req.send_header('Content-Length', len(diff_str))
        req.end_headers()
        req.write(diff_str)
        raise RequestDone

    def _render_zip(self, req, filename, repos, data):
        """ZIP archive containing all the added and/or modified files."""
        req.send_response(200)
        req.send_header('Content-Type', 'application/zip')
        req.send_header('Content-Disposition',
                        content_disposition('attachment', filename + '.zip'))

        from zipfile import ZipFile, ZipInfo, ZIP_DEFLATED as compression

        buf = StringIO()
        zipfile = ZipFile(buf, 'w', compression)
        for old_node, new_node, kind, change in repos.get_changes(
            new_path=data['new_path'], new_rev=data['new_rev'],
            old_path=data['old_path'], old_rev=data['old_rev']):
            if (kind == Node.FILE or kind == Node.DIRECTORY) and \
                    change != Changeset.DELETE \
                    and new_node.is_viewable(req.perm):
                zipinfo = ZipInfo()
                # Note: unicode filenames are not supported by zipfile.
                # UTF-8 is not supported by all Zip tools either,
                # but as some do, UTF-8 is the best option here.
                zipinfo.filename = new_node.path.strip('/').encode('utf-8')
                zipinfo.flag_bits |= 0x800 # filename is encoded with utf-8
                zipinfo.date_time = new_node.last_modified.utctimetuple()[:6]
                zipinfo.compress_type = compression
                # setting zipinfo.external_attr is needed since Python 2.5
                if new_node.isfile:
                    zipinfo.external_attr = 0644 << 16L
                    content = new_node.get_content().read()
                elif new_node.isdir:
                    zipinfo.filename += '/'
                    zipinfo.external_attr = 040755 << 16L
                    content = ''
                zipfile.writestr(zipinfo, content)
        zipfile.close()

        zip_str = buf.getvalue()
        req.send_header("Content-Length", len(zip_str))
        req.end_headers()
        req.write(zip_str)
        raise RequestDone

    def title_for_diff(self, data):
        # TRANSLATOR: 'latest' (revision)
        latest = _('latest')
        if data['new_path'] == data['old_path']:
            # ''diff between 2 revisions'' mode
            return _('Diff [%(old_rev)s:%(new_rev)s] for %(path)s',
                     old_rev=data['old_rev'] or latest,
                     new_rev=data['new_rev'] or latest,
                     path=data['new_path'] or '/')
        else:
            # ''generalized diff'' mode
            return _('Diff from %(old_path)s@%(old_rev)s to %(new_path)s@'
                     '%(new_rev)s',
                     old_path=data['old_path'] or '/',
                     old_rev=data['old_rev'] or latest,
                     new_path=data['new_path'] or '/',
                     new_rev=data['new_rev'] or latest)

    def render_property_diff(self, name, old_node, old_props,
                             new_node, new_props, options):
        """Renders diffs of a node property to HTML."""
        if name in BrowserModule(self.env).hidden_properties:
            return
        candidates = []
        for renderer in self.property_diff_renderers:
            quality = renderer.match_property_diff(name)
            if quality > 0:
                candidates.append((quality, renderer))
        candidates.sort(reverse=True)
        for (quality, renderer) in candidates:
            try:
                return renderer.render_property_diff(name, old_node, old_props,
                                                     new_node, new_props,
                                                     options)
            except Exception, e:
                self.log.warning('Diff rendering failed for property %s with '
                                 'renderer %s: %s', name,
                                 renderer.__class__.__name__,
                                 exception_to_unicode(e, traceback=True))

    def _get_location(self, files):
        """Return the deepest common path for the given files.
           If all the files are actually the same, return that location."""
        if len(files) == 1:
            return files[0]
        else:
            return '/'.join(os.path.commonprefix([f.split('/') 
                                                  for f in files]))
    def _get_parent_location(self, files):
        """Only get a location when there are different files,
           otherwise return the empty string."""
        if files: 
            files.sort()
            prev = files[0]
            for f in files[1:]:
                if f != prev:
                    return self._get_location(files)
        return ''

    def _prepare_filestats(self):
        filestats = {}
        for chg in Changeset.ALL_CHANGES:
            filestats[chg] = 0
        return filestats

    # ITimelineEventProvider methods

    def get_timeline_filters(self, req):
        if 'CHANGESET_VIEW' in req.perm:
            # Non-'hidden' repositories will be listed as additional
            # repository filters, unless there is only a single repository.
            filters = []
            rm = RepositoryManager(self.env)
            repositories = rm.get_real_repositories()
            if len(repositories) > 1:
                filters = [
                    ('repo-' + repos.reponame,
                     u"\xa0\xa0-\xa0" + (repos.reponame or _('(default)')))
                    for repos in repositories
                    if not as_bool(repos.params.get('hidden'))
                    and repos.is_viewable(req.perm)]
                filters.sort()
                add_script(req, 'common/js/timeline_multirepos.js')
                changeset_label = _('Changesets in all repositories')
            else:
                changeset_label = _('Repository changesets')
            filters.insert(0, ('changeset', changeset_label))
            return filters
        else:
            return []

    def get_timeline_events(self, req, start, stop, filters):
        all_repos = 'changeset' in filters
        repo_filters = set(f for f in filters if f.startswith('repo-'))
        if all_repos or repo_filters:
            show_files = self.timeline_show_files
            show_location = show_files == 'location'
            if show_files in ('-1', 'unlimited'):
                show_files = -1
            elif show_files.isdigit():
                show_files = int(show_files)
            else:
                show_files = 0 # disabled
            
            if self.timeline_collapse:
                collapse_changesets = lambda c: (c.author, c.message)
            else:
                collapse_changesets = lambda c: c.rev
                
            uids_seen = {}
            def generate_changesets(repos):
                for _, changesets in groupby(repos.get_changesets(start, stop),
                                             key=collapse_changesets):
                    viewable_changesets = []
                    for cset in changesets:
                        cset_resource = Resource('changeset', cset.rev,
                                                 parent=repos.resource)
                        if cset.is_viewable(req.perm):
                            repos_for_uid = [repos.reponame]
                            uid = repos.get_changeset_uid(cset.rev)
                            if uid:
                                # uid can be seen in multiple repositories
                                if uid in uids_seen:
                                    uids_seen[uid].append(repos.reponame)
                                    continue # already viewable, simply append
                                uids_seen[uid] = repos_for_uid
                            viewable_changesets.append((cset, cset_resource,
                                                        repos_for_uid))
                    if viewable_changesets:
                        cset = viewable_changesets[-1][0]
                        yield ('changeset', cset.date, cset.author,
                               (viewable_changesets, 
                                show_location, show_files))

            rm = RepositoryManager(self.env)
            for repos in sorted(rm.get_real_repositories(),
                                key=lambda repos: repos.reponame):
                if all_repos or ('repo-' + repos.reponame) in repo_filters:
                    try:
                        for event in generate_changesets(repos):
                            yield event
                    except TracError, e:
                        self.log.error("Timeline event provider for repository"
                                       " '%s' failed: %r", 
                                       repos.reponame, exception_to_unicode(e))

    def render_timeline_event(self, context, field, event):
        changesets, show_location, show_files = event[3]
        cset, cset_resource, repos_for_uid = changesets[0]
        older_cset = changesets[-1][0]
        message = cset.message or ''
        reponame = cset_resource.parent.id
        rev_b, rev_a = cset.rev, older_cset.rev

        if field == 'url':
            if rev_a == rev_b:
                return context.href.changeset(rev_a, reponame or None)
            else:
                return context.href.log(reponame or None, rev=rev_b,
                                        stop_rev=rev_a)
            
        elif field == 'description':
            if self.wiki_format_messages:
                markup = ''
                if self.timeline_long_messages: # override default flavor
                    context = context.child()
                    context.set_hints(wiki_flavor='html', 
                                      preserve_newlines=True)
            else:
                markup = message
                message = None
            if 'BROWSER_VIEW' in context.perm:
                files = []
                if show_location:
                    filestats = self._prepare_filestats()
                    for c, r, repos_for_c in changesets:
                        for chg in c.get_changes():
                            resource = c.resource.parent.child('source',
                                                        chg[0] or '/', r.id)
                            if not 'FILE_VIEW' in context.perm(resource):
                                continue
                            filestats[chg[2]] += 1
                            files.append(chg[0])
                    stats = [(tag.div(class_=kind),
                              tag.span(count, ' ',
                                       count > 1 and
                                       (kind == 'copy' and
                                        'copies' or kind + 's') or kind))
                             for kind in Changeset.ALL_CHANGES
                             for count in (filestats[kind],) if count]
                    markup = tag.ul(
                        tag.li(stats, ' in ',
                               tag.strong(self._get_location(files) or '/')),
                        markup, class_="changes")
                elif show_files:
                    unique_files = set()
                    for c, r, repos_for_c in changesets:
                        for chg in c.get_changes():
                            resource = c.resource.parent.child('source',
                                                        chg[0] or '/', r.id)
                            if not 'FILE_VIEW' in context.perm(resource):
                                continue
                            if show_files > 0 and len(files) > show_files:
                                break
                            unique_files.add((chg[0], chg[2]))
                    files = [tag.li(tag.div(class_=mod), path or '/')
                             for path, mod in sorted(unique_files)]
                    if show_files > 0 and len(files) > show_files:
                        files = files[:show_files] + [tag.li(u'\u2026')]
                    markup = tag(tag.ul(files, class_="changes"), markup)
            if message:
                markup += format_to(self.env, None,
                                    context.child(cset_resource), message)
            return markup

        single = rev_a == rev_b
        if not repos_for_uid[0]:
            repos_for_uid[0] = _('(default)')
        if reponame or len(repos_for_uid) > 1:
            title = ngettext('Changeset in %(repo)s ',
                             'Changesets in %(repo)s ',
                             1 if single else 2, repo=', '.join(repos_for_uid))
        else:
            title = ngettext('Changeset ', 'Changesets ', 1 if single else 2)
        drev_a = older_cset.repos.display_rev(rev_a)
        if single:
            title = tag(title, tag.em('[%s]' % drev_a))
        else:
            drev_b = cset.repos.display_rev(rev_b)
            title = tag(title, tag.em('[%s-%s]' % (drev_a, drev_b)))
        if field == 'title':
            labels = []
            for name, head in cset.get_branches():
                if not head and name in ('default', 'master'):
                    continue
                class_ = 'branch'
                if head:
                    class_ += ' head'
                labels.append(tag.span(name, class_=class_))
            for name in cset.get_tags():
                labels.append(tag.span(name, class_='tag'))
            return title if not labels else tag(title, labels)
        elif field == 'summary':
            return _("%(title)s: %(message)s",
                     title=title, message=shorten_line(message))

    # IWikiSyntaxProvider methods

    CHANGESET_ID = r"(?:[0-9]+|[a-fA-F0-9]{8,})" # only "long enough" hexa ids

    def get_wiki_syntax(self):
        yield (
            # [...] form: start with optional intertrac: [T... or [trac ...
            r"!?\[(?P<it_changeset>%s\s*)" % WikiParser.INTERTRAC_SCHEME +
            # hex digits + optional /path for the restricted changeset
            # + optional query and fragment
            r"%s(?:/[^\]]*)?(?:\?[^\]]*)?(?:#[^\]]*)?\]|" % self.CHANGESET_ID +
            # r... form: allow r1 but not r1:2 (handled by the log syntax)
            r"(?:\b|!)r[0-9]+\b(?!:[0-9])(?:/[a-zA-Z0-9_/+-]+)?",
            lambda x, y, z:
            self._format_changeset_link(x, 'changeset',
                                        y[1:] if y[0] == 'r' else y[1:-1],
                                        y, z))

    def get_link_resolvers(self):
        yield ('changeset', self._format_changeset_link)
        yield ('diff', self._format_diff_link)

    def _format_changeset_link(self, formatter, ns, chgset, label,
                               fullmatch=None):
        intertrac = formatter.shorthand_intertrac_helper(ns, chgset, label,
                                                         fullmatch)
        if intertrac:
            return intertrac

        # identifying repository
        rm = RepositoryManager(self.env)
        chgset, params, fragment = formatter.split_link(chgset)
        sep = chgset.find('/')
        if sep > 0:
            rev, path = chgset[:sep], chgset[sep:]
        else:
            rev, path = chgset, '/'
        try:
            reponame, repos, path = rm.get_repository_by_path(path)
            if not reponame:
                reponame = rm.get_default_repository(formatter.context)
                if reponame is not None:
                    repos = rm.get_repository(reponame)
            if path == '/':
                path = None

            # rendering changeset link
            if repos:
                changeset = repos.get_changeset(rev)
                if changeset.is_viewable(formatter.perm):
                    href = formatter.href.changeset(rev,
                                                    repos.reponame or None,
                                                    path)
                    return tag.a(label, class_="changeset",
                                 title=shorten_line(changeset.message),
                                 href=href + params + fragment)
                errmsg = _("No permission to view changeset %(rev)s "
                           "on %(repos)s", rev=rev,
                           repos=reponame or _('(default)'))
            elif reponame:
                errmsg = _("Repository '%(repo)s' not found", repo=reponame)
            else:
                errmsg = _("No default repository defined")
        except TracError, e:
            errmsg = to_unicode(e)
        return tag.a(label, class_="missing changeset", title=errmsg)

    def _format_diff_link(self, formatter, ns, target, label):
        params, query, fragment = formatter.split_link(target)
        def pathrev(path):
            if '@' in path:
                return path.split('@', 1)
            else:
                return (path, None)
        if '//' in params:
            p1, p2 = params.split('//', 1)
            old, new = pathrev(p1), pathrev(p2)
            data = {'old_path': old[0], 'old_rev': old[1],
                    'new_path': new[0], 'new_rev': new[1]}
        else:
            old_path, old_rev = pathrev(params)
            new_rev = None
            if old_rev and ':' in old_rev:
                old_rev, new_rev = old_rev.split(':', 1)
            data = {'old_path': old_path, 'old_rev': old_rev,
                    'new_path': old_path, 'new_rev': new_rev}
        title = self.title_for_diff(data)
        href = None
        if any(data.values()):
            if query:
                query = '&' + query[1:]
            href = formatter.href.changeset(new_path=data['new_path'] or None,
                                            new=data['new_rev'],
                                            old_path=data['old_path'] or None,
                                            old=data['old_rev']) + query
        return tag.a(label, class_="changeset", title=title, href=href)

    # ISearchSource methods 
    
    ### FIXME: move this specific implementation into cache.py

    def get_search_filters(self, req):
        if 'CHANGESET_VIEW' in req.perm:
            yield ('changeset', _('Changesets'))

    def get_search_results(self, req, terms, filters):
        if not 'changeset' in filters:
            return
        rm = RepositoryManager(self.env)
        repositories = dict((repos.params['id'], repos)
                            for repos in rm.get_real_repositories())
        with self.env.db_query as db:
            sql, args = search_to_sql(db, ['rev', 'message', 'author'], terms)
            for id, rev, ts, author, log in db("""
                    SELECT repos, rev, time, author, message 
                    FROM revision WHERE """ + sql,
                    args):
                try:
                    rev = int(rev)
                except ValueError:
                    pass
                repos = repositories.get(id)
                if not repos:
                    continue # revisions for a no longer active repository
                cset = repos.resource.child('changeset', rev)
                if 'CHANGESET_VIEW' in req.perm(cset):
                    yield (req.href.changeset(rev, repos.reponame or None),
                           '[%s]: %s' % (rev, shorten_line(log)),
                           from_utimestamp(ts), author,
                           shorten_result(log, terms))


class AnyDiffModule(Component):

    implements(IRequestHandler)

    # IRequestHandler methods

    def match_request(self, req):
        return req.path_info == '/diff'

    def process_request(self, req):
        rm = RepositoryManager(self.env)

        if req.get_header('X-Requested-With') == 'XMLHttpRequest':
            dirname, prefix = posixpath.split(req.args.get('q'))
            prefix = prefix.lower()
            reponame, repos, path = rm.get_repository_by_path(dirname)
            # an entry is a (isdir, name, path) tuple
            def kind_order(entry):
                return (not entry[0], embedded_numbers(entry[1]))

            entries = []
            if repos:
                entries.extend((e.isdir, e.name, 
                                '/' + pathjoin(repos.reponame, e.path))
                               for e in repos.get_node(path).get_entries()
                               if e.is_viewable(req.perm))
            if not reponame:
                entries.extend((True, repos.reponame, '/' + repos.reponame)
                               for repos in rm.get_real_repositories()
                               if repos.is_viewable(req.perm))

            elem = tag.ul(
                [tag.li(tag.b(path) if isdir else path)
                 for (isdir, name, path) in sorted(entries, key=kind_order)
                 if name.lower().startswith(prefix)])

            xhtml = elem.generate().render('xhtml', encoding='utf-8')
            req.send_header('Content-Length', len(xhtml))
            req.write(xhtml)
            return

        # -- retrieve arguments
        new_path = req.args.get('new_path')
        new_rev = req.args.get('new_rev')
        old_path = req.args.get('old_path')
        old_rev = req.args.get('old_rev')

        # -- normalize and prepare rendering
        new_reponame, new_repos, new_path = \
            rm.get_repository_by_path(new_path)
        old_reponame, old_repos, old_path = \
            rm.get_repository_by_path(old_path)
        
        data = {}
        if new_repos:
            data.update(new_path='/' + pathjoin(new_repos.reponame, new_path),
                        new_rev=new_repos.normalize_rev(new_rev))
        else:
            data.update(new_path=req.args.get('new_path'), new_rev=new_rev)
        if old_repos:
            data.update(old_path='/' + pathjoin(old_repos.reponame, old_path),
                        old_rev=old_repos.normalize_rev(old_rev))
        else:
            data.update(old_path=req.args.get('old_path'), old_rev=old_rev)

        add_script(req, 'common/js/suggest.js')
        return 'diff_form.html', data, None
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.