Index: trac/htdocs/css/diff.css
===================================================================
--- trac/htdocs/css/diff.css	(revision 8263)
+++ trac/htdocs/css/diff.css	(working copy)
@@ -186,3 +186,11 @@
 .diff pre { background: #fff; border: 1px solid #ddd; font-size: 85%;
   margin: 0;
 }
+
+/* Override .diff table for property tables */
+.diff table.props {
+ border: 0px;
+}
+.diff table.props td {
+ background: inherit;
+}
Index: trac/htdocs/css/browser.css
===================================================================
--- trac/htdocs/css/browser.css	(revision 8263)
+++ trac/htdocs/css/browser.css	(working copy)
@@ -174,7 +174,7 @@
  margin: 0 0 .4em 1.6em;
  padding: 0;
 }
-#info .props li { padding: 0; overflow: auto; }
+#info .props > li { padding: 2px 0; overflow: auto; }
 
 /* Styles for the HTML preview */
 #preview { background: #fff; clear: both; margin: 0 }
Index: trac/versioncontrol/svn_fs.py
===================================================================
--- trac/versioncontrol/svn_fs.py	(revision 8263)
+++ trac/versioncontrol/svn_fs.py	(working copy)
@@ -60,9 +60,10 @@
 from trac.versioncontrol.cache import CachedRepository
 from trac.versioncontrol.svn_authz import SubversionAuthorizer
 from trac.versioncontrol.web_ui.browser import IPropertyRenderer
-from trac.util import embedded_numbers
+from trac.versioncontrol.web_ui.changeset import IPropertyDiffRenderer
+from trac.util import Ranges, embedded_numbers, to_ranges
 from trac.util.text import exception_to_unicode, to_unicode
-from trac.util.translation import _
+from trac.util.translation import _, tag_
 from trac.util.datefmt import utc
 
 
@@ -308,7 +309,7 @@
 
 
 class SubversionPropertyRenderer(Component):
-    implements(IPropertyRenderer)
+    implements(IPropertyRenderer, IPropertyDiffRenderer)
 
     def __init__(self):
         self._externals_map = {}
@@ -323,7 +324,7 @@
         if name == 'svn:externals':
             return self._render_externals(props[name])
         elif name == 'svn:mergeinfo' or name.startswith('svnmerge-'):
-            return self._render_mergeinfo(props[name])
+            return self._render_mergeinfo(name, mode, context, props)
         elif name == 'svn:needs-lock':
             return self._render_needslock(context)
 
@@ -389,17 +390,191 @@
         return tag.ul([tag.li(tag.a(label, href=href, title=title))
                        for label, href, title in externals_data])
 
-    def _render_mergeinfo(self, prop):
-        prop = prop.rsplit(':', 1)
-        if len(prop) == 2:
-            prop[1] = prop[1].replace(',', u',\u200b')
-        return ':'.join(prop)
+    def _get_blocked_revs(self, props, name, path):
+        """Return the revisions blocked from merging for the given property
+        name and path.
+        """
+        if name == 'svnmerge-integrated':
+            prop = props.get('svnmerge-blocked', '')
+        else:
+            return ""
+        for line in prop.splitlines():
+            try:
+                p, revs = line.split(':', 1)
+                if p.strip('/') == path:
+                    return revs
+            except Exception:
+                pass
+        return ""
 
+    def _render_mergeinfo(self, name, mode, context, props):
+        """Parse svn:mergeinfo and svnmerge-* properties, converting branch
+        names to links and providing links to the revision log for merged
+        and eligible revisions.
+        """
+        has_eligible = name in ('svnmerge-integrated', 'svn:mergeinfo')
+        no_revisions = tag.span(_('revisions'), title=_('No revisions'))
+        no_eligible = tag.span(_('eligible'), title=_('No eligible revisions'))
+        visible_rows = []
+        hidden_rows = []
+        repos = self.env.get_repository()
+        repos = getattr(repos, 'repos', repos)
+        # repos is always a SubversionRepository now
+        target_path = context.resource.id
+        target_rev = context.resource.version
+        if has_eligible:
+            ancestor_path_first_revs = {}
+            node = repos.get_node(target_path, target_rev)
+            while node:
+                node = node.get_copy_origin()
+                if node and node.path != target_path:
+                    ancestor_path_first_revs[node.path] = node.rev + 1
+        for line in props[name].splitlines():
+            node = None
+            try:
+                path, revs = line.split(':', 1)
+                spath = path.strip('/')
+                revs = revs.strip()
+                if 'LOG_VIEW' in context.perm('source', spath):
+                    row = [tag.a(path, title=_('View dir'),
+                                 href=context.href.browser(spath,
+                                                rev=context.resource.version))]
+                    if revs:
+                        label = (_('merged'), _('blocked'))[
+                                name.endswith('blocked')]
+                        row.append(self._get_revs_link(label, context, spath, 
+                                                       revs))
+                    else:
+                        row.append(no_revisions)
+                    try:
+                        import time
+                        now = time.time()
+                        node = repos.get_node(path, target_rev)
+                    except NoSuchNode:
+                        node = None
+                    if node and has_eligible:
+                        first_rev = ancestor_path_first_revs.get(path)
+                        if not first_rev:
+                            first_rev = node.get_copy_origin_rev() or 1
+                        if target_rev - first_rev < 1000:
+                            eligible = set(r for p, r in 
+                                           repos._history(path, first_rev, 
+                                                          target_rev, 
+                                                          node.pool))
+                        else:
+                            eligible = set(range(first_rev, target_rev))
+                            
+                        print spath, 't1', time.time() - now
+                        eligible -= set(Ranges(revs))
+                        blocked = self._get_blocked_revs(props, name, spath)
+                        eligible -= set(Ranges(blocked))
+                        if eligible:
+                            eligible = to_ranges(eligible)
+                            print spath, 't2', time.time() - now
+                            row.append(tag.a(_('eligible'),
+                                       title=eligible.replace(',', ', '),
+                                       href=context.href.log(spath,
+                                                             revs=eligible)))
+                        else:
+                            row.append(no_eligible)
+                    if node:
+                        visible_rows.append(tag.td(each) for each in row)
+                    else:
+                        hidden_rows.append(tag.td(e) for e in line.split(':'))
+                else:
+                    revs = revs.replace(',', u',\u200b')
+                    visible_rows.append(tag.td(path),
+                                        tag.td(revs,
+                                        colspan=has_eligible and 2 or None))
+            except Exception, e:
+                self.log.warning('Rendering of property %s failed: %s', name,
+                                 exception_to_unicode(e))
+                rows = [visible_rows, hidden_rows][bool(node)]
+                rows.append(tag.td(line, colspan=has_eligible and 3 or 2))
+        vtable = tag.table(tag.tbody(tag.tr(each) for each in visible_rows))
+        ttable = htable = None
+        if hidden_rows:
+            # FIXME: the ids below should be unique...
+            ttable = tag.table(tag.tbody(tag.tr(tag.td(
+                tag.a(_('Toggle deleted branches'), 
+                      href="#toggleoldmerged", id="toggleoldmerged"))))),
+            htable = tag.table(tag.tbody(tag.tr(each) for each in hidden_rows), 
+                               style='display: none', id="oldmerged")
+        return tag.div(vtable, ttable, htable)
+    
     def _render_needslock(self, context):
         return tag.img(src=context.href.chrome('common/lock-locked.png'),
                        alt="needs lock", title="needs lock")
 
+    def _get_revs_link(self, label, context, spath, revs):
+        if ',' in revs or '-' in revs:
+            revs_href = context.href.log(spath, revs=revs)
+        else:
+            revs_href = context.href.changeset(revs, spath)
+        return tag.a(label, title=revs.replace(',', ', '), href=revs_href)
 
+    
+    # IPropertyDiffRenderer methods
+
+    def match_property_diff(self, name):
+        return (name == 'svn:mergeinfo' or name.startswith('svnmerge-')) and \
+                4 or 0
+
+    def render_property_diff(self, name, old_context, old_props,
+                             new_context, new_props, options):
+        # build 3 columns table showing modifications on merge sources
+        # || source (added|modified|removed) || added revs || removed revs ||
+        def parse_sources(props):
+            sources = {}
+            for line in props[name].splitlines():
+                path, revs = line.split(':', 1)
+                spath = path.strip('/')
+                revs = revs.strip()
+                sources[spath] = set(Ranges(revs))
+            return sources
+        old_sources = parse_sources(old_props)
+        new_sources = parse_sources(new_props)
+        # go through new sources, detect modified ones or added ones
+        modified_and_added_sources = []
+        for spath, new_revs in new_sources.iteritems():
+            if spath in old_sources:
+                old_revs = old_sources.pop(spath)
+                status = ''
+            else:
+                old_revs = set()
+                status = _(' (added)')
+            def revs_link(revs, context):
+                if revs:
+                    revs = to_ranges(revs)
+                    return self._get_revs_link(revs.replace(',', u',\u200b'),
+                            context, spath, revs)
+            source_href = new_context.href.browser(spath,
+                                              rev=new_context.resource.version)
+            added = revs_link(new_revs - old_revs, new_context)
+            removed = revs_link(old_revs - new_revs, old_context)
+            if added or removed:
+                modified_and_added_sources.append([
+                    tag.a(spath, status, title=_('View dir'), href=source_href),
+                    added and tag_("%(revs)s merged", revs=tag.tt(added)),
+                    removed and tag_("%(revs)s no longer merged", 
+                                     revs=tag.tt(removed)),
+                ]) 
+        # go through remaining old sources, those were deleted
+        removed_sources = []
+        for spath, old_revs in old_sources.iteritems():
+            removed_sources.append(
+                    tag.a(_("source %(source)s removed", source=spath),
+                           title=_('View dir'),
+                           href=old_context.href.browser(spath,
+                                            rev=old_context.resource.version)))
+        return tag.li(tag_("Property %(prop)s changed", prop=tag.strong(name)),
+                tag.table(tag.tbody(
+                    [tag.tr(tag.td(dir), tag.td(added), tag.td(removed))
+                     for dir, added, removed in modified_and_added_sources],
+                    [tag.tr(tag.td(dir, colspan=3)) 
+                     for dir in removed_sources]), class_='props'))
+
+
 class SubversionRepository(Repository):
     """Repository implementation based on the svn.fs API."""
 
@@ -734,7 +909,7 @@
 
 class SubversionNode(Node):
 
-    def __init__(self, path, rev, repos, pool=None, parent=None):
+    def __init__(self, path, rev, repos, pool=None, parent_root=None):
         self.repos = repos
         self.fs_ptr = repos.fs_ptr
         self.authz = repos.authz
@@ -744,8 +919,8 @@
         self._requested_rev = rev
         pool = self.pool()
 
-        if parent and parent._requested_rev == self._requested_rev:
-            self.root = parent.root
+        if parent_root:
+            self.root = parent_root
         else:
             self.root = fs.revision_root(self.fs_ptr, rev, self.pool())
         node_type = fs.check_path(self.root, self._scoped_path_utf8, pool)
@@ -791,7 +966,7 @@
                                                             path.strip('/'))):
                 continue
             yield SubversionNode(path, self._requested_rev, self.repos,
-                                 self.pool, self)
+                                 self.pool, self.root)
 
     def get_history(self, limit=None):
         newer = None # 'newer' is the previously seen history tuple
@@ -871,7 +1046,23 @@
         return fs.node_prop(self.root, self._scoped_path_utf8, name,
                             self.pool())
 
+    def get_copy_origin_rev(self):
+        root_and_path = fs.closest_copy(self.root, self._scoped_path_utf8,
+                                        self.pool())
+        if root_and_path:
+            return fs.revision_root_revision(root_and_path[0])
 
+    def get_copy_origin(self):
+        root_and_path = fs.closest_copy(self.root, self._scoped_path_utf8,
+                                        self.pool())
+        if root_and_path:
+            root, path = root_and_path
+            rev = fs.revision_root_revision(root)
+            if (path, rev) == (self.path, self.rev):
+                rev, path = fs.copied_from(root, path, self.pool())
+            return SubversionNode(path, rev, self.repos, self.pool, root)
+
+
 class SubversionChangeset(Changeset):
 
     def __init__(self, rev, authz, scope, fs_ptr, pool=None):
Index: trac/versioncontrol/templates/browser.html
===================================================================
--- trac/versioncontrol/templates/browser.html	(revision 8263)
+++ trac/versioncontrol/templates/browser.html	(working copy)
@@ -14,6 +14,10 @@
     <script type="text/javascript" src="${chrome.htdocs_location}js/folding.js"></script>
     <script type="text/javascript">
       jQuery(document).ready(function($) {
+        $("#toggleoldmerged").click(function() {
+                  $("#oldmerged").slideToggle("fast");
+                  return false;
+        });
         $("#jumploc input").hide();
         $("#jumploc select").change(function () {
           this.parentNode.parentNode.submit();
Index: trac/util/__init__.py
===================================================================
--- trac/util/__init__.py	(revision 8263)
+++ trac/util/__init__.py	(working copy)
@@ -542,6 +542,33 @@
         else:
             return 0
 
+def to_ranges(revs):
+    """Converts a list of revisions to a minimal set of ranges.
+    
+    >>> to_ranges([2, 12, 3, 6, 9, 1, 5, 11])
+    '1-3,5-6,9,11-12'
+    >>> to_ranges([])
+    ''
+    """
+    ranges = []
+    begin = end = None
+    def store():
+        if end == begin:
+            ranges.append(str(begin))
+        else:
+            ranges.append('%d-%d' % (begin, end))
+    for rev in sorted(revs):
+        if begin is None:
+            begin = end = rev
+        elif rev == end + 1:
+            end = rev
+        else:
+            store()
+            begin = end = rev
+    if begin is not None:
+        store()
+    return ','.join(ranges)
+
 def content_disposition(type, filename=None):
     """Generate a properly escaped Content-Disposition header"""
     if filename is not None:

