Index: svn_prop.py
===================================================================
--- svn_prop.py	(revision 9482)
+++ svn_prop.py	(working copy)
@@ -27,8 +27,9 @@
 from trac.versioncontrol.web_ui.changeset import IPropertyDiffRenderer
 from trac.util import Ranges, to_ranges
 from trac.util.translation import _, tag_
+from trac.web.href import Href
+from urllib import splittype, splithost
 
-
 class SubversionPropertyRenderer(Component):
     implements(IPropertyRenderer)
 
@@ -45,14 +46,90 @@
     
     def render_property(self, name, mode, context, props):
         if name == 'svn:externals':
-            return self._render_externals(props[name])
+            return self._render_externals(props[name], context)
         elif name == 'svn:needs-lock':
             return self._render_needslock(context)
         elif name == 'svn:mergeinfo' or name.startswith('svnmerge-'):
             return self._render_mergeinfo(name, mode, context, props)
 
-    def _render_externals(self, prop):
+    def _render_externals(self, prop, context):
+        """Render the svn:externals list as a table of external references with appropriate links.
+
+        svn:externals is a multiline property, each line representing one external reference.
+        
+        The "old" syntax of an external reference (pre-svn-1.5) is:
+        local/sub/dir   [-r REV]    scheme://fully.qualified/url
+        where the revision specification is optional (refers to youngest when omitted),
+        and may or may not contain whitespace between '-r' and 'REV'
+
+        In svn-1.5 a "new" syntax for external references was introduced, with several flavors:
+        - Fully qualified:
+        [-r REV]    scheme://fully.qualified/url    local/sub/dir   # (again with optional revision spec)
+        - Peg revision syntax:
+        scheme://fully.qualified/url@REV        local/sub/dir
+        - Relative to directory on which the property is defined:
+        [-r REV] ../[../]rel/path[@REV]         local/sub/dir       # (at most one of rev-spec & peg-revision)
+        - Relative to root of current repository:
+        [-r REV] ^/path/from/repos/root[@REV]   local/sub/dir       # (at most one of rev-spec & peg-revision)
+        - Relative to the scheme of the URL of the directory on which the property is defined:
+        [-r REV] //fully.qualified/url[@REV]    local/sub/dir       # (at most one of rev-spec & peg-revision)
+        - Relative to the root URL of the server on which the property is defined:
+        [-r REV] /url/from/server/root[@REV]    local/sub/dir       # (at most one of rev-spec & peg-revision)
+
+        The rendered externals table is of the form (WikiSyntax):
+        || [generated-link local/sub/dir] || specified external text || revision number ||
+        where the renderer performs a best-effort attempt to produce
+        the most useful generated link following this logic:
+        - For fully qualified URLs (old or new syntax), the level-1 generated-link is the URL
+          (prefixed with current scheme, in case of scheme-relative URL).
+        - For repository-root-relative paths:
+          1. If current repository is not scoped, then the target path is necessarily in the current repository,
+             so generate directly level-2 link of the form /browser/ReposName/path/from/repos/root?rev=REV
+          2. If current repository is scoped, and the target path is within the scope, proceed as (1).
+          3. If current repository is scoped, and the target path is '''NOT''' within the scope:
+             (a) If the Repository URL is defined, generate a level-1 link by stripping the scope
+                 from the Repository URL and appending the path/from/repos/root.
+             (b) Otherwise, go over all other available real repositories that has the same base-path as current one.
+                 If another repository OtherRepos is found that has the target path within its scope,
+                 directly generate level-2 link of the form /browser/OtherReposName/path/from/repos/root?rev=REV
+             (c) If did not find a match, do not generate a link.
+        - For directory-relative paths:
+          1. Traverse current repository upwards from current location N steps (N = number of '../' prefixing rel-path).
+          2. If still in scope of current repository, directly generate level-2 link.
+          3. If went out of scope:
+             (a) If the Repository URL is defined, generate a level-1 link by traversing the Repository URL.
+             (b) Otherwise, traverse the repository dir and go over all other available real repositories that has the same
+                 base-path as current one. If another repository OtherRepos is found that has the target path within its
+                 scope, directly generate level-2 link of the form /browser/OtherReposName/path/from/repos/root?rev=REV
+             (c) If did not find a match, do not generate a link.
+        - For server-relative URLS:
+          1. If the Repository URL parameter is available for current repository:
+             (a) Extract the host from the Repository URL
+             (b) Generate a level-1 link by appending the /url/from/server/root to the extracted host.
+          2. Otherwise use absolute URL of the current request as base server to generate level-1 link
+             (simple heuristic that assumes that the target repository and current Trac environment are served
+             via the same host. Of course, this is not always true, so better set the Repository URL explicitly..)
+        - In case the generated-link is level-1, perform a refinement phase:
+          (1) Lookup the level-1 link in the svn:externals map (see below).
+          (2) If found a match, apply the map template to produce a level-2 link.
+          (3) Otherwise keep the level-1 link.
+          
+        The svn:externals map is used to transform level-1 links that point directly to a SVN repository
+        (that suffers from the inability to point to a revision other than HEAD) into more "friendly" links
+        into a repository browser (another Trac instance, ViewVC, ...) (see TracIni#svn:externals-section).
+        The svn:externals map that is specified in the TracIni is further enhanced by going over available
+        repositories that has their Repository URL defined and adding entries that point back to the current Trac.
+        """
+        rm = RepositoryManager(self.env)
         if not self._externals_map:
+            # Create externals map entries from repositories with URL defined.
+            for repname, repinfo in rm.get_all_repositories().iteritems():
+                repurl = repinfo.get(u'url', '')
+                if '' != repurl:
+                    urlmap = '/browser/'
+                    if '' != repname:
+                        urlmap += repname + '/'
+                    self._externals_map[repurl] = urlmap + '%(path)s?rev=%(rev)s'
             for dummykey, value in self.config.options('svn:externals'):
                 value = value.split()
                 if len(value) != 2:
@@ -64,54 +141,217 @@
                 self._externals_map[key] = value.replace('%', '%%') \
                                            .replace('$path', '%(path)s') \
                                            .replace('$rev', '%(rev)s')
-        externals = []
+        externals_data = []
+        
+        def add_externals_url(localpath, url, rev, href):
+            if (href or '').endswith('?rev='):
+                href = href[:-5]
+            externals_data.append(((localpath, url or '', rev or ''), href))
+
+        # Use the externals map to build a better href for the external
+        def lookup_externals_map(href, rev):
+            for key, tmpl in self._externals_map.iteritems():
+                if href.startswith(key):
+                    # match
+                    path = href[len(key):].lstrip('/')
+                    return tmpl % {'path': path, 'rev': rev}
+            # no match. keep original.
+            return href
+##                
+##            prefix = []
+##            base_url = url
+##            while base_url:
+##                if base_url in self._externals_map or base_url == u'/':
+##                    break
+##                if base_url.endswith(':'):
+##                    prefix.append(base_url+'//')
+##                    base_url = ''
+##                    break
+##                base_url, pref = posixpath.split(base_url)
+##                prefix.append(pref)
+##            href = self._externals_map.get(base_url)
+##            if not href and (url.startswith('http://') or 
+##                             url.startswith('https://') or
+##                             url.startswith('/')):
+##                href = url.replace('%', '%%')
+##            if href:
+##                remotepath = ''
+##                if prefix:
+##                    remotepath = posixpath.join(*reversed(prefix))
+##                add_externals_url(localpath, url, rev,
+##                                  href % {'path': remotepath, 'rev': rev})
+##            else:
+##                add_externals_url(localpath, url, rev, None)
+            
         for external in prop.splitlines():
+            # Go over external lines and generate level-1 or level-2 links
+            external.replace('\\', '/')
+            if external.startswith('#'):
+                # Skip commented lines in prop
+                add_externals_url('', external, '', '')
+                continue
             elements = external.split()
             if not elements:
                 continue
-            localpath, rev, url = elements[0], '', elements[-1]
-            if localpath.startswith('#'):
-                externals.append((external, None, None, None, None))
+            
+            # Old-style externals syntax
+            if u'://' in elements[-1]:
+                localpath, rev, url = elements[0], '', elements[-1]
+                if len(elements) > 2:
+                    rev = elements[len(elements)==4 and 3 or 2]
+                    rev = rev.replace('-r', '')
+                add_externals_url(localpath, url, rev, lookup_externals_map(url, rev))
                 continue
-            if len(elements) == 3:
-                rev = elements[1]
+            
+            if 1 >= len(elements):
+                continue
+            
+            # New-style externals syntax
+            resource = context.resource
+            reponame = context.resource.parent.id
+            current_dir = context.resource.id.replace('\\', '/')
+            repos = rm.get_repository(reponame)
+            #target_rev = context.resource.version
+            #localpath = resource.id
+            # extract localpath, URL and revision ref (-r or peg):
+            url, localpath = elements[-2], elements[-1]
+            rev = ''
+            if len(elements) > 2:
+                rev = elements[len(elements)==4 and 1 or 0]
                 rev = rev.replace('-r', '')
-            # retrieve a matching entry in the externals map
-            prefix = []
-            base_url = url
-            while base_url:
-                if base_url in self._externals_map or base_url == u'/':
-                    break
-                base_url, pref = posixpath.split(base_url)
-                prefix.append(pref)
-            href = self._externals_map.get(base_url)
-            revstr = rev and ' at revision '+rev or ''
-            if not href and (url.startswith('http://') or 
-                             url.startswith('https://')):
-                href = url.replace('%', '%%')
-            if href:
-                remotepath = ''
-                if prefix:
-                    remotepath = posixpath.join(*reversed(prefix))
-                externals.append((localpath, revstr, base_url, remotepath,
-                                  href % {'path': remotepath, 'rev': rev}))
+            if '@' in url:
+                (url, rev) = url.split('@')
+                
+            # which flavor is the URL?
+            
+            # Fully qualified with scheme
+            if u'://' in url:
+                add_externals_url(localpath, url, rev, lookup_externals_map(url, rev))
+                continue
+            
+            # Relative to scheme
+            if url.startswith(u'//'):
+                href = '%s://%s' % (splittype(context.req.abs_href('/'))[0], url.lstrip('/'))
+                add_externals_url(localpath, url, rev, lookup_externals_map(href, rev))
+                continue
+
+            def lremove(str, substr):
+                "Deletes 'substr' from 'str' if 'substr' prefixes 'str'."
+                if str.startswith(substr):
+                    return str[len(substr):]
+                
+            def rremove(str, substr):
+                "Deletes 'substr' from 'str' if 'substr' suffixes 'str'."
+                if str.endswith(substr):
+                    return str[:-len(substr)]
+
+            # Utility function for repos-relative paths
+            def build_repos_link(repos, rel_url, rev):
+                scope = repos.scope.replace('\\', '/').strip('/')
+                if rel_url.startswith(scope):
+                    # Target path within current repository (scoped or not) - generate level-2 link.
+                    return Href('/').browser(repos.reponame, lremove(rel_url, scope), rev=rev)
+                # Target path not within current repository scope.
+                reposurl = repos.params.get(u'url', '').rstrip('/')
+                base_reposurl = rremove(reposurl, scope)
+                if '' != reposurl:
+                    # URL defined, so generate level-1 link.
+                    return lookup_externals_map('/'.join([base_reposurl, rel_url]), rev)
+                return None
+                
+            # Relative to repository root
+            if url.startswith(u'^/'):
+                rel_url = url[2:].strip('/')
+                href = build_repos_link(repos, rel_url, rev)
+                if href is None:
+                    # No match for current repository. Look for others.
+                    for otherrepos in rm.get_real_repositories():
+                        if otherrepos == repos or otherrepos.get_base() != repos.get_base():
+                            continue
+                        href = build_repos_link(otherrepos, rel_url, rev)
+                        if href is not None:
+                            break
+                if href is None:
+                    self.log.warn('external URL %s out of all repositories scopes' % url)
+                add_externals_url(localpath, url, rev, href)
+                continue
+            
+            # Relative to the current directory
+            if url.startswith(u'../'):
+                def up_one_level(path):
+                    return '/'.join(path.split('/')[:-1])
+                
+                rel_url = url
+                out_of_scope = False
+                href = None
+                while rel_url.startswith('../'):
+                    rel_url = rel_url[3:]
+                    if '' == current_dir:
+                        # no more levels to traverse.
+                        if not out_of_scope:
+                            out_of_scope = True
+                            reposurl = repos.params.get(u'url', '').rstrip('/')
+                            if '' != reposurl:
+                                # Repository URL defined, so traverse it.
+                                current_dir = reposurl
+                            else:
+                                # Repository URL not defined, so traverse repository dir instead.
+                                current_dir = repos.params['dir'].replace('\\', '/')
+                        else:
+                            # can't go out of scope again...
+                            self.log.warn('external URL %s out of humanly-possible scope' % url)
+                            break
+                    current_dir = up_one_level(current_dir)
+                if not out_of_scope:
+                    # Finished traversing without leaving current repository - worthy of level-2 link!
+                    href = Href('/').browser(reponame, current_dir if '' != current_dir else None, rel_url, rev=rev)
+                elif '' != reposurl:
+                    # Left current repository scope, but found Repository URL - worthy of level-1 link.
+                    href = lookup_externals_map('/'.join([current_dir, rel_url]), rev)
+                else:
+                    # Left current repository scope and didn't find Repository URL.
+                    # Check if another repository matches the scope.
+                    for otherrepos in rm.get_real_repositories():
+                        if otherrepos == repos or otherrepos.get_base() != repos.get_base():
+                            continue
+                        scope = otherrepos.scope.replace('\\', '/').strip('/')
+                        if rel_url.startswith(scope):
+                            # matched - generate level-2 link
+                            href = Href('/').browser(otherrepos.reponame, lremove(rel_url, scope), rev=rev)
+                            break
+                if href is None:
+                    self.log.warn('external URL %s out of all repositories scopes' % url)
+                add_externals_url(localpath, url, rev, href)
+                continue
+            
+            # Relative to server root
+            if url.startswith(u'/'):
+                reposurl = repos.params.get(u'url', '').rstrip('/')
+                if '' != reposurl:
+                    # Repository URL defined - generate level-1 link from it.
+                    scheme, href = splittype(reposurl)
+                else:
+                    # Repository URL NOT defined - generate level-1 link from request abs_url.
+                    scheme, href = splittype(context.req.abs_href('/'))
+                href = '%s://%s%s' % (scheme, splithost(href)[0], url)
+                add_externals_url(localpath, url, rev, lookup_externals_map(href, rev))
+                continue
+        
+        # Finish up with actually composing the externals table
+        trs = []
+        for label, href in externals_data:
+            if not href:
+                tr = tag.tr(tag.td(tag.a(label[0], title=\
+                                _('No matching external')),
+                            tag.td(label[1]),
+                            tag.td(label[2])))
             else:
-                externals.append((localpath, revstr, url, None, None))
-        externals_data = []
-        for localpath, rev, url, remotepath, href in externals:
-            label = localpath
-            if url is None:
-                title = ''
-            elif href:
-                if url:
-                    url = ' in ' + url
-                label += rev + url
-                title = ''.join((remotepath, rev, url))
-            else:
-                title = _('No svn:externals configured in trac.ini')
-            externals_data.append((label, href, title))
-        return tag.ul([tag.li(tag.a(label, href=href, title=title))
-                       for label, href, title in externals_data])
+                tr = tag.tr(tag.td(tag.a(label[0], href=href,
+                                   title=_('Jump to external'))),
+                            tag.td(tag.a(label[1], href=href)),
+                            tag.td(tag.a(label[2], href=href)))
+            trs.append(tr)
+        return tag.table(tag.tbody(trs))
 
     def _render_needslock(self, context):
         return tag.img(src=context.href.chrome('common/lock-locked.png'),

