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,7 +60,8 @@
 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.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,152 @@
         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'))
+        rows = []
+        for line in props[name].splitlines():
+            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)
+                    if has_eligible:
+                        repos = self.env.get_repository()
+                        node = repos.get_node(spath, context.resource.version)
+                        eligible = set()
+                        for (p, rev, chg) in node.get_history():
+                            if p != spath:
+                                break
+                            eligible.add(rev)
+                        eligible -= set(Ranges(revs))
+                        blocked = self._get_blocked_revs(props, name, spath)
+                        eligible -= set(Ranges(blocked))
+                        if eligible:
+                            eligible = to_ranges(eligible)
+                            row.append(tag.a(_('eligible'),
+                                       title=eligible.replace(',', ', '),
+                                       href=context.href.log(spath,
+                                                             revs=eligible)))
+                        else:
+                            row.append(no_eligible)
+                    rows.append(tag.td(each) for each in row)
+                else:
+                    revs = revs.replace(',', u',\u200b')
+                    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.append(tag.td(line, colspan=has_eligible and 3 or 2))
+        return tag.div(tag.table(tag.tbody(tag.tr(each) for each in rows)))
+    
     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 ||
+        nothing_added = tag.span('-', title=_('nothing added'))
+        nothing_removed = tag.span('-', title=_('nothing removed'))
+        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)
+            modified_and_added_sources.append([
+                tag.a(spath, status, title=_('View dir'), href=source_href),
+                revs_link(new_revs - old_revs, new_context) or nothing_added,
+                revs_link(old_revs - new_revs, old_context) or nothing_removed,
+            ]) 
+        # go through remaining old sources, those were deleted
+        removed_sources = []
+        for spath, old_revs in old_sources.iteritems():
+            removed_sources.append(
+                    [tag.a(spath, _(' (removed)'),
+                           title=_('View dir'),
+                           href=old_context.href.browser(spath,
+                                            rev=old_context.resource.version)),
+                     nothing_added,
+                     nothing_removed])
+        return tag.div(
+            tag.table(
+                tag.thead(tag.th(c) for c in [name, _("added"), _("removed")]),
+                tag.tbody([
+                    tag.tr([tag.td(col) for col in row]) 
+                    for row in modified_and_added_sources + removed_sources])))
+
+
 class SubversionRepository(Repository):
     """Repository implementation based on the svn.fs API."""
 
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:

