| 1 | # -*- coding: utf-8 -*-
|
|---|
| 2 | #
|
|---|
| 3 | # Copyright (C) 2003-2020 Edgewall Software
|
|---|
| 4 | # Copyright (C) 2003-2005 Jonas Borgström <jonas@edgewall.com>
|
|---|
| 5 | # Copyright (C) 2004-2006 Christopher Lenz <cmlenz@gmx.de>
|
|---|
| 6 | # Copyright (C) 2005-2006 Christian Boos <cboos@edgewall.org>
|
|---|
| 7 | # All rights reserved.
|
|---|
| 8 | #
|
|---|
| 9 | # This software is licensed as described in the file COPYING, which
|
|---|
| 10 | # you should have received as part of this distribution. The terms
|
|---|
| 11 | # are also available at https://trac.edgewall.org/wiki/TracLicense.
|
|---|
| 12 | #
|
|---|
| 13 | # This software consists of voluntary contributions made by many
|
|---|
| 14 | # individuals. For the exact contribution history, see the revision
|
|---|
| 15 | # history and logs, available at https://trac.edgewall.org/log/.
|
|---|
| 16 | #
|
|---|
| 17 | # Author: Jonas Borgström <jonas@edgewall.com>
|
|---|
| 18 | # Christopher Lenz <cmlenz@gmx.de>
|
|---|
| 19 | # Christian Boos <cboos@edgewall.org>
|
|---|
| 20 |
|
|---|
| 21 | from functools import partial
|
|---|
| 22 | from itertools import groupby
|
|---|
| 23 | import os
|
|---|
| 24 | import posixpath
|
|---|
| 25 | import re
|
|---|
| 26 |
|
|---|
| 27 | from trac.config import BoolOption, IntOption, Option
|
|---|
| 28 | from trac.core import *
|
|---|
| 29 | from trac.mimeview.api import Mimeview
|
|---|
| 30 | from trac.perm import IPermissionRequestor
|
|---|
| 31 | from trac.resource import ResourceNotFound
|
|---|
| 32 | from trac.search import ISearchSource, search_to_sql, shorten_result
|
|---|
| 33 | from trac.timeline.api import ITimelineEventProvider
|
|---|
| 34 | from trac.util import as_bool, content_disposition, embedded_numbers, pathjoin
|
|---|
| 35 | from trac.util.datefmt import from_utimestamp, pretty_timedelta
|
|---|
| 36 | from trac.util.html import tag
|
|---|
| 37 | from trac.util.presentation import to_json
|
|---|
| 38 | from trac.util.text import CRLF, exception_to_unicode, shorten_line, \
|
|---|
| 39 | to_unicode, unicode_urlencode
|
|---|
| 40 | from trac.util.translation import _, ngettext, tag_
|
|---|
| 41 | from trac.versioncontrol.api import Changeset, NoSuchChangeset, Node, \
|
|---|
| 42 | RepositoryManager
|
|---|
| 43 | from trac.versioncontrol.diff import diff_blocks, get_diff_options, \
|
|---|
| 44 | unified_diff
|
|---|
| 45 | from trac.versioncontrol.web_ui.browser import BrowserModule
|
|---|
| 46 | from trac.versioncontrol.web_ui.util import content_closing, render_zip
|
|---|
| 47 | from trac.web import IRequestHandler, RequestDone
|
|---|
| 48 | from trac.web.chrome import (Chrome, INavigationContributor, add_ctxtnav,
|
|---|
| 49 | add_link, add_script, add_stylesheet,
|
|---|
| 50 | prevnext_nav, web_context)
|
|---|
| 51 | from trac.wiki.api import IWikiSyntaxProvider, WikiParser
|
|---|
| 52 | from trac.wiki.formatter import format_to
|
|---|
| 53 |
|
|---|
| 54 |
|
|---|
| 55 | class IPropertyDiffRenderer(Interface):
|
|---|
| 56 | """Render node properties in TracBrowser and TracChangeset views."""
|
|---|
| 57 |
|
|---|
| 58 | def match_property_diff(name):
|
|---|
| 59 | """Indicate whether this renderer can treat the given property diffs
|
|---|
| 60 |
|
|---|
| 61 | Returns a quality number, ranging from 0 (unsupported) to 9
|
|---|
| 62 | (''perfect'' match).
|
|---|
| 63 | """
|
|---|
| 64 |
|
|---|
| 65 | def render_property_diff(name, old_context, old_props,
|
|---|
| 66 | new_context, new_props, options):
|
|---|
| 67 | """Render the given diff of property to HTML.
|
|---|
| 68 |
|
|---|
| 69 | `name` is the property name as given to `match_property_diff()`,
|
|---|
| 70 | `old_context` corresponds to the old node being render
|
|---|
| 71 | (useful when the rendering depends on the node kind)
|
|---|
| 72 | and `old_props` is the corresponding collection of all properties.
|
|---|
| 73 | Same for `new_node` and `new_props`.
|
|---|
| 74 | `options` are the current diffs options.
|
|---|
| 75 |
|
|---|
| 76 | The rendered result can be one of the following:
|
|---|
| 77 | - `None`: the property change will be shown the normal way
|
|---|
| 78 | (''changed from `old` to `new`'')
|
|---|
| 79 | - an `unicode` value: the change will be shown as textual content
|
|---|
| 80 | - `Markup` or `Fragment`: the change will shown as block markup
|
|---|
| 81 | """
|
|---|
| 82 |
|
|---|
| 83 |
|
|---|
| 84 | class DefaultPropertyDiffRenderer(Component):
|
|---|
| 85 | """Default version control property difference renderer."""
|
|---|
| 86 |
|
|---|
| 87 | implements(IPropertyDiffRenderer)
|
|---|
| 88 |
|
|---|
| 89 | def match_property_diff(self, name):
|
|---|
| 90 | return 1
|
|---|
| 91 |
|
|---|
| 92 | def render_property_diff(self, name, old_context, old_props,
|
|---|
| 93 | new_context, new_props, options):
|
|---|
| 94 | old, new = old_props[name], new_props[name]
|
|---|
| 95 | # Render as diff only if multiline (see #3002)
|
|---|
| 96 | if '\n' not in old and '\n' not in new:
|
|---|
| 97 | return None
|
|---|
| 98 | unidiff = '--- \n+++ \n' + \
|
|---|
| 99 | '\n'.join(unified_diff(old.splitlines(), new.splitlines(),
|
|---|
| 100 | options.get('contextlines', 3)))
|
|---|
| 101 | return tag.li(tag_("Property %(name)s", name=tag.strong(name)),
|
|---|
| 102 | Mimeview(self.env).render(old_context, 'text/x-diff',
|
|---|
| 103 | unidiff))
|
|---|
| 104 |
|
|---|
| 105 |
|
|---|
| 106 | class ChangesetModule(Component):
|
|---|
| 107 | """Renderer providing flexible functionality for showing sets of
|
|---|
| 108 | differences.
|
|---|
| 109 |
|
|---|
| 110 | If the differences shown are coming from a specific changeset,
|
|---|
| 111 | then that changeset information can be shown too.
|
|---|
| 112 |
|
|---|
| 113 | In addition, it is possible to show only a subset of the changeset:
|
|---|
| 114 | Only the changes affecting a given path will be shown. This is called
|
|---|
| 115 | the ''restricted'' changeset.
|
|---|
| 116 |
|
|---|
| 117 | But the differences can also be computed in a more general way,
|
|---|
| 118 | between two arbitrary paths and/or between two arbitrary revisions.
|
|---|
| 119 | In that case, there's no changeset information displayed.
|
|---|
| 120 | """
|
|---|
| 121 |
|
|---|
| 122 | implements(INavigationContributor, IPermissionRequestor, IRequestHandler,
|
|---|
| 123 | ITimelineEventProvider, IWikiSyntaxProvider, ISearchSource)
|
|---|
| 124 |
|
|---|
| 125 | property_diff_renderers = ExtensionPoint(IPropertyDiffRenderer)
|
|---|
| 126 |
|
|---|
| 127 | realm = RepositoryManager.changeset_realm
|
|---|
| 128 |
|
|---|
| 129 | timeline_show_files = Option('timeline', 'changeset_show_files', '0',
|
|---|
| 130 | """Number of files to show (`-1` for unlimited, `0` to disable).
|
|---|
| 131 |
|
|---|
| 132 | This can also be `location`, for showing the common prefix for the
|
|---|
| 133 | changed files.
|
|---|
| 134 | """)
|
|---|
| 135 |
|
|---|
| 136 | timeline_long_messages = BoolOption('timeline', 'changeset_long_messages',
|
|---|
| 137 | 'false',
|
|---|
| 138 | """Whether wiki-formatted changeset messages should be multiline or
|
|---|
| 139 | not.
|
|---|
| 140 |
|
|---|
| 141 | If this option is not specified or is false and `wiki_format_messages`
|
|---|
| 142 | is set to true, changeset messages will be single line only, losing
|
|---|
| 143 | some formatting (bullet points, etc).""")
|
|---|
| 144 |
|
|---|
| 145 | timeline_collapse = BoolOption('timeline', 'changeset_collapse_events',
|
|---|
| 146 | 'false',
|
|---|
| 147 | """Whether consecutive changesets from the same author having
|
|---|
| 148 | exactly the same message should be presented as one event.
|
|---|
| 149 | That event will link to the range of changesets in the log view.
|
|---|
| 150 | """)
|
|---|
| 151 |
|
|---|
| 152 | max_diff_files = IntOption('changeset', 'max_diff_files', 0,
|
|---|
| 153 | """Maximum number of modified files for which the changeset view will
|
|---|
| 154 | attempt to show the diffs inlined.""")
|
|---|
| 155 |
|
|---|
| 156 | max_diff_bytes = IntOption('changeset', 'max_diff_bytes', 10000000,
|
|---|
| 157 | """Maximum total size in bytes of the modified files (their old size
|
|---|
| 158 | plus their new size) for which the changeset view will attempt to show
|
|---|
| 159 | the diffs inlined.""")
|
|---|
| 160 |
|
|---|
| 161 | wiki_format_messages = BoolOption('changeset', 'wiki_format_messages',
|
|---|
| 162 | 'true',
|
|---|
| 163 | """Whether wiki formatting should be applied to changeset messages.
|
|---|
| 164 |
|
|---|
| 165 | If this option is disabled, changeset messages will be rendered as
|
|---|
| 166 | pre-formatted text.""")
|
|---|
| 167 |
|
|---|
| 168 | # INavigationContributor methods
|
|---|
| 169 |
|
|---|
| 170 | def get_active_navigation_item(self, req):
|
|---|
| 171 | return 'browser'
|
|---|
| 172 |
|
|---|
| 173 | def get_navigation_items(self, req):
|
|---|
| 174 | return []
|
|---|
| 175 |
|
|---|
| 176 | # IPermissionRequestor methods
|
|---|
| 177 |
|
|---|
| 178 | def get_permission_actions(self):
|
|---|
| 179 | return ['CHANGESET_VIEW']
|
|---|
| 180 |
|
|---|
| 181 | # IRequestHandler methods
|
|---|
| 182 |
|
|---|
| 183 | _request_re = re.compile(r"/changeset(?:/([^/]+)(/.*)?)?$")
|
|---|
| 184 |
|
|---|
| 185 | def match_request(self, req):
|
|---|
| 186 | match = re.match(self._request_re, req.path_info)
|
|---|
| 187 | if match:
|
|---|
| 188 | new, new_path = match.groups()
|
|---|
| 189 | if new:
|
|---|
| 190 | req.args['new'] = new
|
|---|
| 191 | if new_path:
|
|---|
| 192 | req.args['new_path'] = new_path
|
|---|
| 193 | return True
|
|---|
| 194 |
|
|---|
| 195 | def process_request(self, req):
|
|---|
| 196 | """The appropriate mode of operation is inferred from the request
|
|---|
| 197 | parameters:
|
|---|
| 198 |
|
|---|
| 199 | * If `new_path` and `old_path` are equal (or `old_path` is omitted)
|
|---|
| 200 | and `new` and `old` are equal (or `old` is omitted),
|
|---|
| 201 | then we're about to view a revision Changeset: `chgset` is True.
|
|---|
| 202 | Furthermore, if the path is not the root, the changeset is
|
|---|
| 203 | ''restricted'' to that path (only the changes affecting that path,
|
|---|
| 204 | its children or its ancestor directories will be shown).
|
|---|
| 205 | * In any other case, the set of changes corresponds to arbitrary
|
|---|
| 206 | differences between path@rev pairs. If `new_path` and `old_path`
|
|---|
| 207 | are equal, the ''restricted'' flag will also be set, meaning in this
|
|---|
| 208 | case that the differences between two revisions are restricted to
|
|---|
| 209 | those occurring on that path.
|
|---|
| 210 |
|
|---|
| 211 | In any case, either path@rev pairs must exist.
|
|---|
| 212 | """
|
|---|
| 213 | req.perm.require('CHANGESET_VIEW')
|
|---|
| 214 |
|
|---|
| 215 | # -- retrieve arguments
|
|---|
| 216 | new_path = req.args.get('new_path')
|
|---|
| 217 | new = req.args.get('new')
|
|---|
| 218 | old_path = req.args.get('old_path')
|
|---|
| 219 | old = req.args.get('old')
|
|---|
| 220 | reponame = req.args.get('reponame')
|
|---|
| 221 |
|
|---|
| 222 | # -- support for the revision log ''View changes'' form,
|
|---|
| 223 | # where we need to give the path and revision at the same time
|
|---|
| 224 | if old and '@' in old:
|
|---|
| 225 | old, old_path = old.split('@', 1)
|
|---|
| 226 | if new and '@' in new:
|
|---|
| 227 | new, new_path = new.split('@', 1)
|
|---|
| 228 |
|
|---|
| 229 | rm = RepositoryManager(self.env)
|
|---|
| 230 | if reponame:
|
|---|
| 231 | repos = rm.get_repository(reponame)
|
|---|
| 232 | else:
|
|---|
| 233 | reponame, repos, new_path = rm.get_repository_by_path(new_path)
|
|---|
| 234 |
|
|---|
| 235 | if old_path:
|
|---|
| 236 | old_reponame, old_repos, old_path = \
|
|---|
| 237 | rm.get_repository_by_path(old_path)
|
|---|
| 238 | if old_repos != repos:
|
|---|
| 239 | raise TracError(_("Can't compare across different "
|
|---|
| 240 | "repositories: %(old)s vs. %(new)s",
|
|---|
| 241 | old=old_reponame, new=reponame))
|
|---|
| 242 |
|
|---|
| 243 | if not repos:
|
|---|
| 244 | if reponame or (new_path and new_path != '/'):
|
|---|
| 245 | raise TracError(_("Repository '%(repo)s' not found",
|
|---|
| 246 | repo=reponame or new_path.strip('/')))
|
|---|
| 247 | else:
|
|---|
| 248 | raise TracError(_("No repository specified and no default "
|
|---|
| 249 | "repository configured."))
|
|---|
| 250 |
|
|---|
| 251 | # -- normalize and check for special case
|
|---|
| 252 | try:
|
|---|
| 253 | new = repos.normalize_rev(new)
|
|---|
| 254 | old = repos.normalize_rev(old or new)
|
|---|
| 255 | except NoSuchChangeset as e:
|
|---|
| 256 | raise ResourceNotFound(e, _("Invalid Changeset Number"))
|
|---|
| 257 | new_path = repos.normalize_path(new_path)
|
|---|
| 258 | old_path = repos.normalize_path(old_path or new_path)
|
|---|
| 259 | full_new_path = '/' + pathjoin(repos.reponame, new_path)
|
|---|
| 260 | full_old_path = '/' + pathjoin(repos.reponame, old_path)
|
|---|
| 261 |
|
|---|
| 262 | if old_path == new_path and old == new: # revert to Changeset
|
|---|
| 263 | old_path = old = None
|
|---|
| 264 |
|
|---|
| 265 | style, options, diff_data = get_diff_options(req)
|
|---|
| 266 | diff_opts = diff_data['options']
|
|---|
| 267 |
|
|---|
| 268 | # -- setup the `chgset` and `restricted` flags, see docstring above.
|
|---|
| 269 | chgset = not old and old_path is None
|
|---|
| 270 | if chgset:
|
|---|
| 271 | restricted = new_path not in ('', '/') # (subset or not)
|
|---|
| 272 | else:
|
|---|
| 273 | restricted = old_path == new_path # (same path or not)
|
|---|
| 274 |
|
|---|
| 275 | # -- redirect if changing the diff options or alias requested
|
|---|
| 276 | if 'update' in req.args or reponame != repos.reponame:
|
|---|
| 277 | contextall = diff_opts['contextall'] or None
|
|---|
| 278 | reponame = repos.reponame or None
|
|---|
| 279 | if chgset:
|
|---|
| 280 | if restricted:
|
|---|
| 281 | req.redirect(req.href.changeset(new, reponame, new_path,
|
|---|
| 282 | contextall=contextall))
|
|---|
| 283 | else:
|
|---|
| 284 | req.redirect(req.href.changeset(new, reponame,
|
|---|
| 285 | contextall=contextall))
|
|---|
| 286 | else:
|
|---|
| 287 | req.redirect(req.href.changeset(new, reponame,
|
|---|
| 288 | new_path, old=old,
|
|---|
| 289 | old_path=full_old_path,
|
|---|
| 290 | contextall=contextall))
|
|---|
| 291 |
|
|---|
| 292 | # -- preparing the data
|
|---|
| 293 | if chgset:
|
|---|
| 294 | prev = repos.get_node(new_path, new).get_previous()
|
|---|
| 295 | if prev:
|
|---|
| 296 | prev_path, prev_rev = prev[:2]
|
|---|
| 297 | else:
|
|---|
| 298 | prev_path, prev_rev = new_path, repos.previous_rev(new)
|
|---|
| 299 | data = {'old_path': prev_path, 'old_rev': prev_rev,
|
|---|
| 300 | 'new_path': new_path, 'new_rev': new}
|
|---|
| 301 | else:
|
|---|
| 302 | if not new:
|
|---|
| 303 | new = repos.youngest_rev
|
|---|
| 304 | elif not old:
|
|---|
| 305 | old = repos.youngest_rev
|
|---|
| 306 | if old_path is None:
|
|---|
| 307 | old_path = new_path
|
|---|
| 308 | data = {'old_path': old_path, 'old_rev': old,
|
|---|
| 309 | 'new_path': new_path, 'new_rev': new}
|
|---|
| 310 | data.update({'repos': repos, 'reponame': repos.reponame or None,
|
|---|
| 311 | 'diff': diff_data,
|
|---|
| 312 | 'wiki_format_messages': self.wiki_format_messages})
|
|---|
| 313 |
|
|---|
| 314 | if chgset:
|
|---|
| 315 | chgset = repos.get_changeset(new)
|
|---|
| 316 | req.perm(chgset.resource).require('CHANGESET_VIEW')
|
|---|
| 317 |
|
|---|
| 318 | # TODO: find a cheaper way to reimplement r2636
|
|---|
| 319 | req.check_modified(chgset.date, [
|
|---|
| 320 | style, ''.join(options), repos.name,
|
|---|
| 321 | diff_opts['contextlines'], diff_opts['contextall'],
|
|---|
| 322 | repos.rev_older_than(new, repos.youngest_rev),
|
|---|
| 323 | chgset.message, req.is_xhr,
|
|---|
| 324 | pretty_timedelta(chgset.date, None, 3600)])
|
|---|
| 325 |
|
|---|
| 326 | format = req.args.get('format')
|
|---|
| 327 |
|
|---|
| 328 | if format in ['diff', 'zip']:
|
|---|
| 329 | # choosing an appropriate filename
|
|---|
| 330 | rpath = new_path.replace('/', '_')
|
|---|
| 331 | if chgset:
|
|---|
| 332 | if restricted:
|
|---|
| 333 | filename = 'changeset_%s_%s' % (rpath, new)
|
|---|
| 334 | else:
|
|---|
| 335 | filename = 'changeset_%s' % new
|
|---|
| 336 | else:
|
|---|
| 337 | if restricted:
|
|---|
| 338 | filename = 'diff-%s-from-%s-to-%s' % (rpath, old, new)
|
|---|
| 339 | else:
|
|---|
| 340 | filename = 'diff-from-%s-%s-to-%s-%s' \
|
|---|
| 341 | % (old_path.replace('/', '_'), old, rpath, new)
|
|---|
| 342 | if format == 'diff':
|
|---|
| 343 | self._render_diff(req, filename, repos, data)
|
|---|
| 344 | elif format == 'zip':
|
|---|
| 345 | render_zip(req, filename + '.zip', repos, None,
|
|---|
| 346 | partial(self._zip_iter_nodes, req, repos, data))
|
|---|
| 347 |
|
|---|
| 348 | # -- HTML format
|
|---|
| 349 | self._render_html(req, repos, chgset, restricted, data)
|
|---|
| 350 |
|
|---|
| 351 | if chgset:
|
|---|
| 352 | diff_params = 'new=%s' % new
|
|---|
| 353 | else:
|
|---|
| 354 | diff_params = unicode_urlencode({
|
|---|
| 355 | 'new_path': full_new_path, 'new': new,
|
|---|
| 356 | 'old_path': full_old_path, 'old': old})
|
|---|
| 357 | add_link(req, 'alternate', '?format=diff&' + diff_params,
|
|---|
| 358 | _('Unified Diff'), 'text/plain', 'diff')
|
|---|
| 359 | add_link(req, 'alternate', '?format=zip&' + diff_params,
|
|---|
| 360 | _('Zip Archive'), 'application/zip', 'zip')
|
|---|
| 361 | add_script(req, 'common/js/diff.js')
|
|---|
| 362 | add_stylesheet(req, 'common/css/changeset.css')
|
|---|
| 363 | add_stylesheet(req, 'common/css/diff.css')
|
|---|
| 364 | add_stylesheet(req, 'common/css/code.css')
|
|---|
| 365 | if chgset:
|
|---|
| 366 | if restricted:
|
|---|
| 367 | prevnext_nav(req, _('Previous Change'), _('Next Change'))
|
|---|
| 368 | else:
|
|---|
| 369 | prevnext_nav(req, _('Previous Changeset'), _('Next Changeset'))
|
|---|
| 370 | else:
|
|---|
| 371 | rev_href = req.href.changeset(old, full_old_path,
|
|---|
| 372 | old=new, old_path=full_new_path)
|
|---|
| 373 | add_ctxtnav(req, _('Reverse Diff'), href=rev_href)
|
|---|
| 374 |
|
|---|
| 375 | return 'changeset.html', data
|
|---|
| 376 |
|
|---|
| 377 | # Internal methods
|
|---|
| 378 |
|
|---|
| 379 | def _render_html(self, req, repos, chgset, restricted, data):
|
|---|
| 380 | """HTML version"""
|
|---|
| 381 | data['restricted'] = restricted
|
|---|
| 382 | display_rev = repos.display_rev
|
|---|
| 383 | data['display_rev'] = display_rev
|
|---|
| 384 | browser = BrowserModule(self.env)
|
|---|
| 385 | reponame = repos.reponame or None
|
|---|
| 386 |
|
|---|
| 387 | if chgset: # Changeset Mode (possibly restricted on a path)
|
|---|
| 388 | path, rev = data['new_path'], data['new_rev']
|
|---|
| 389 |
|
|---|
| 390 | # -- getting the change summary from the Changeset.get_changes
|
|---|
| 391 | def get_changes():
|
|---|
| 392 | for npath, kind, change, opath, orev in chgset.get_changes():
|
|---|
| 393 | old_node = new_node = None
|
|---|
| 394 | if (restricted and
|
|---|
| 395 | not (npath == path or # same path
|
|---|
| 396 | npath.startswith(path + '/') or # npath is below
|
|---|
| 397 | path.startswith(npath + '/'))): # npath is above
|
|---|
| 398 | continue
|
|---|
| 399 | if change != Changeset.ADD:
|
|---|
| 400 | old_node = repos.get_node(opath, orev)
|
|---|
| 401 | if change != Changeset.DELETE:
|
|---|
| 402 | new_node = repos.get_node(npath, rev)
|
|---|
| 403 | else:
|
|---|
| 404 | # support showing paths deleted below a copy target
|
|---|
| 405 | old_node.path = npath
|
|---|
| 406 | yield old_node, new_node, kind, change
|
|---|
| 407 |
|
|---|
| 408 | def _changeset_title(rev):
|
|---|
| 409 | rev = display_rev(rev)
|
|---|
| 410 | if restricted:
|
|---|
| 411 | return _('Changeset %(id)s for %(path)s', id=rev,
|
|---|
| 412 | path=path)
|
|---|
| 413 | else:
|
|---|
| 414 | return _('Changeset %(id)s', id=rev)
|
|---|
| 415 |
|
|---|
| 416 | data['changeset'] = chgset
|
|---|
| 417 | title = _changeset_title(rev)
|
|---|
| 418 |
|
|---|
| 419 | # Support for revision properties (#2545)
|
|---|
| 420 | context = web_context(req, self.realm, chgset.rev,
|
|---|
| 421 | parent=repos.resource)
|
|---|
| 422 | data['context'] = context
|
|---|
| 423 | revprops = chgset.get_properties()
|
|---|
| 424 | data['properties'] = browser.render_properties('revprop', context,
|
|---|
| 425 | revprops)
|
|---|
| 426 | oldest_rev = repos.oldest_rev
|
|---|
| 427 | if chgset.rev != oldest_rev:
|
|---|
| 428 | if restricted:
|
|---|
| 429 | prev = repos.get_node(path, rev).get_previous()
|
|---|
| 430 | if prev:
|
|---|
| 431 | prev_path, prev_rev = prev[:2]
|
|---|
| 432 | if prev_rev:
|
|---|
| 433 | prev_href = req.href.changeset(prev_rev, reponame,
|
|---|
| 434 | prev_path)
|
|---|
| 435 | else:
|
|---|
| 436 | prev_path = prev_rev = None
|
|---|
| 437 | else:
|
|---|
| 438 | add_link(req, 'first',
|
|---|
| 439 | req.href.changeset(oldest_rev, reponame),
|
|---|
| 440 | _('Changeset %(id)s', id=display_rev(oldest_rev)))
|
|---|
| 441 | prev_path = data['old_path']
|
|---|
| 442 | prev_rev = repos.previous_rev(chgset.rev)
|
|---|
| 443 | if prev_rev:
|
|---|
| 444 | prev_href = req.href.changeset(prev_rev, reponame)
|
|---|
| 445 | if prev_rev:
|
|---|
| 446 | add_link(req, 'prev', prev_href,
|
|---|
| 447 | _changeset_title(prev_rev))
|
|---|
| 448 | youngest_rev = repos.youngest_rev
|
|---|
| 449 | if str(chgset.rev) != str(youngest_rev):
|
|---|
| 450 | if restricted:
|
|---|
| 451 | next_rev = repos.next_rev(chgset.rev, path)
|
|---|
| 452 | if next_rev:
|
|---|
| 453 | if repos.has_node(path, next_rev):
|
|---|
| 454 | next_href = req.href.changeset(next_rev, reponame,
|
|---|
| 455 | path)
|
|---|
| 456 | else: # must be 'D'elete or 'R'ename, show full cset
|
|---|
| 457 | next_href = req.href.changeset(next_rev, reponame)
|
|---|
| 458 | else:
|
|---|
| 459 | add_link(req, 'last',
|
|---|
| 460 | req.href.changeset(youngest_rev, reponame),
|
|---|
| 461 | _('Changeset %(id)s',
|
|---|
| 462 | id=display_rev(youngest_rev)))
|
|---|
| 463 | next_rev = repos.next_rev(chgset.rev)
|
|---|
| 464 | if next_rev:
|
|---|
| 465 | next_href = req.href.changeset(next_rev, reponame)
|
|---|
| 466 | if next_rev:
|
|---|
| 467 | add_link(req, 'next', next_href,
|
|---|
| 468 | _changeset_title(next_rev))
|
|---|
| 469 | else: # Diff Mode
|
|---|
| 470 | # -- getting the change summary from the Repository.get_changes
|
|---|
| 471 | def get_changes():
|
|---|
| 472 | for d in repos.get_changes(
|
|---|
| 473 | new_path=data['new_path'], new_rev=data['new_rev'],
|
|---|
| 474 | old_path=data['old_path'], old_rev=data['old_rev']):
|
|---|
| 475 | yield d
|
|---|
| 476 | title = self.title_for_diff(data)
|
|---|
| 477 | data['changeset'] = False
|
|---|
| 478 |
|
|---|
| 479 | data['title'] = title
|
|---|
| 480 |
|
|---|
| 481 | if 'BROWSER_VIEW' not in req.perm:
|
|---|
| 482 | return
|
|---|
| 483 |
|
|---|
| 484 | def node_info(node, annotated):
|
|---|
| 485 | href = req.href.browser(
|
|---|
| 486 | reponame, node.created_path, rev=node.created_rev,
|
|---|
| 487 | annotate='blame' if annotated else None)
|
|---|
| 488 | title = _("Show revision %(rev)s of this file in browser",
|
|---|
| 489 | rev=display_rev(node.rev))
|
|---|
| 490 | return {'path': node.path, 'rev': node.rev,
|
|---|
| 491 | 'shortrev': repos.short_rev(node.rev),
|
|---|
| 492 | 'href': href, 'title': title}
|
|---|
| 493 | # Reminder: node.path may not exist at node.rev
|
|---|
| 494 | # as long as node.rev==node.created_rev
|
|---|
| 495 | # ... and data['old_rev'] may have nothing to do
|
|---|
| 496 | # with _that_ node specific history...
|
|---|
| 497 |
|
|---|
| 498 | options = data['diff']['options']
|
|---|
| 499 |
|
|---|
| 500 | def _prop_changes(old_node, new_node):
|
|---|
| 501 | old_props = old_node.get_properties()
|
|---|
| 502 | new_props = new_node.get_properties()
|
|---|
| 503 | old_ctx = web_context(req, old_node.resource)
|
|---|
| 504 | new_ctx = web_context(req, new_node.resource)
|
|---|
| 505 | changed_properties = []
|
|---|
| 506 | if old_props != new_props:
|
|---|
| 507 | for k, v in sorted(old_props.items()):
|
|---|
| 508 | new = old = diff = None
|
|---|
| 509 | if k not in new_props:
|
|---|
| 510 | old = v # won't be displayed, no need to render it
|
|---|
| 511 | elif v != new_props[k]:
|
|---|
| 512 | diff = self.render_property_diff(
|
|---|
| 513 | k, old_ctx, old_props, new_ctx, new_props, options)
|
|---|
| 514 | if not diff:
|
|---|
| 515 | old = browser.render_property(k, 'changeset',
|
|---|
| 516 | old_ctx, old_props)
|
|---|
| 517 | new = browser.render_property(k, 'changeset',
|
|---|
| 518 | new_ctx, new_props)
|
|---|
| 519 | if new or old or diff:
|
|---|
| 520 | changed_properties.append({'name': k, 'old': old,
|
|---|
| 521 | 'new': new, 'diff': diff})
|
|---|
| 522 | for k, v in sorted(new_props.items()):
|
|---|
| 523 | if k not in old_props:
|
|---|
| 524 | new = browser.render_property(k, 'changeset',
|
|---|
| 525 | new_ctx, new_props)
|
|---|
| 526 | if new is not None:
|
|---|
| 527 | changed_properties.append({'name': k, 'new': new,
|
|---|
| 528 | 'old': None})
|
|---|
| 529 | return changed_properties
|
|---|
| 530 |
|
|---|
| 531 | def _estimate_changes(old_node, new_node):
|
|---|
| 532 | old_size = old_node.get_content_length()
|
|---|
| 533 | new_size = new_node.get_content_length()
|
|---|
| 534 | return old_size + new_size
|
|---|
| 535 |
|
|---|
| 536 | def _content_changes(old_node, new_node):
|
|---|
| 537 | """Returns the list of differences.
|
|---|
| 538 |
|
|---|
| 539 | The list is empty when no differences between comparable files
|
|---|
| 540 | are detected, but the return value is None for non-comparable
|
|---|
| 541 | files.
|
|---|
| 542 | """
|
|---|
| 543 | mview = Mimeview(self.env)
|
|---|
| 544 | if mview.is_binary(old_node.content_type, old_node.path):
|
|---|
| 545 | return None
|
|---|
| 546 | if mview.is_binary(new_node.content_type, new_node.path):
|
|---|
| 547 | return None
|
|---|
| 548 | old_content = _read_content(old_node)
|
|---|
| 549 | if mview.is_binary(content=old_content):
|
|---|
| 550 | return None
|
|---|
| 551 | new_content = _read_content(new_node)
|
|---|
| 552 | if mview.is_binary(content=new_content):
|
|---|
| 553 | return None
|
|---|
| 554 |
|
|---|
| 555 | old_content = mview.to_unicode(old_content, old_node.content_type)
|
|---|
| 556 | new_content = mview.to_unicode(new_content, new_node.content_type)
|
|---|
| 557 |
|
|---|
| 558 | if old_content != new_content:
|
|---|
| 559 | context = options.get('contextlines', 3)
|
|---|
| 560 | if context < 0 or options.get('contextall'):
|
|---|
| 561 | context = None
|
|---|
| 562 | tabwidth = self.config.getint('mimeviewer', 'tab_width', 8)
|
|---|
| 563 | ignore_blank_lines = options.get('ignoreblanklines')
|
|---|
| 564 | ignore_case = options.get('ignorecase')
|
|---|
| 565 | ignore_space = options.get('ignorewhitespace')
|
|---|
| 566 | return diff_blocks(old_content.splitlines(),
|
|---|
| 567 | new_content.splitlines(),
|
|---|
| 568 | context, tabwidth,
|
|---|
| 569 | ignore_blank_lines=ignore_blank_lines,
|
|---|
| 570 | ignore_case=ignore_case,
|
|---|
| 571 | ignore_space_changes=ignore_space)
|
|---|
| 572 | else:
|
|---|
| 573 | return []
|
|---|
| 574 |
|
|---|
| 575 | diff_changes = list(get_changes())
|
|---|
| 576 | # XHR is used for blame support: display the changeset view without
|
|---|
| 577 | # the navigation and with the changes concerning the annotated file
|
|---|
| 578 | diff_bytes = diff_files = 0
|
|---|
| 579 | annotated = None
|
|---|
| 580 | if req.is_xhr:
|
|---|
| 581 | show_diffs = None
|
|---|
| 582 | annotated = repos.normalize_path(req.args.get('annotate'))
|
|---|
| 583 | else:
|
|---|
| 584 | if self.max_diff_bytes or self.max_diff_files:
|
|---|
| 585 | for old_node, new_node, kind, change in diff_changes:
|
|---|
| 586 | if change in Changeset.DIFF_CHANGES and \
|
|---|
| 587 | kind == Node.FILE and \
|
|---|
| 588 | old_node.is_viewable(req.perm) and \
|
|---|
| 589 | new_node.is_viewable(req.perm):
|
|---|
| 590 | diff_files += 1
|
|---|
| 591 | diff_bytes += _estimate_changes(old_node, new_node)
|
|---|
| 592 | show_diffs = (not self.max_diff_files or
|
|---|
| 593 | 0 < diff_files <= self.max_diff_files) and \
|
|---|
| 594 | (not self.max_diff_bytes or
|
|---|
| 595 | diff_bytes <= self.max_diff_bytes or
|
|---|
| 596 | diff_files == 1)
|
|---|
| 597 |
|
|---|
| 598 | has_diffs = False
|
|---|
| 599 | filestats = self._prepare_filestats()
|
|---|
| 600 | changes = []
|
|---|
| 601 | files = []
|
|---|
| 602 | for old_node, new_node, kind, change in diff_changes:
|
|---|
| 603 | props = []
|
|---|
| 604 | diffs = []
|
|---|
| 605 | show_old = old_node and old_node.is_viewable(req.perm)
|
|---|
| 606 | show_new = new_node and new_node.is_viewable(req.perm)
|
|---|
| 607 | show_entry = change != Changeset.EDIT
|
|---|
| 608 | show_diff = show_diffs or (new_node and new_node.path == annotated)
|
|---|
| 609 |
|
|---|
| 610 | if change in Changeset.DIFF_CHANGES and show_old and show_new:
|
|---|
| 611 | assert old_node and new_node
|
|---|
| 612 | props = _prop_changes(old_node, new_node)
|
|---|
| 613 | if props:
|
|---|
| 614 | show_entry = True
|
|---|
| 615 | if kind == Node.FILE and show_diff:
|
|---|
| 616 | diffs = _content_changes(old_node, new_node)
|
|---|
| 617 | if diffs != []:
|
|---|
| 618 | if diffs:
|
|---|
| 619 | has_diffs = True
|
|---|
| 620 | # elif None (means: manually compare to (previous))
|
|---|
| 621 | show_entry = True
|
|---|
| 622 | if (show_old or show_new) and (show_entry or not show_diff):
|
|---|
| 623 | info = {'change': change,
|
|---|
| 624 | 'old': old_node and node_info(old_node, annotated),
|
|---|
| 625 | 'new': new_node and node_info(new_node, annotated),
|
|---|
| 626 | 'props': props,
|
|---|
| 627 | 'diffs': diffs}
|
|---|
| 628 | files.append(new_node.path if new_node else
|
|---|
| 629 | old_node.path if old_node else '')
|
|---|
| 630 | filestats[change] += 1
|
|---|
| 631 | if change in Changeset.DIFF_CHANGES:
|
|---|
| 632 | if chgset:
|
|---|
| 633 | href = req.href.changeset(new_node.rev, reponame,
|
|---|
| 634 | new_node.path)
|
|---|
| 635 | title = _('Show the changeset %(id)s restricted to '
|
|---|
| 636 | '%(path)s', id=display_rev(new_node.rev),
|
|---|
| 637 | path=new_node.path)
|
|---|
| 638 | else:
|
|---|
| 639 | href = req.href.changeset(
|
|---|
| 640 | new_node.created_rev, reponame,
|
|---|
| 641 | new_node.created_path,
|
|---|
| 642 | old=old_node.created_rev,
|
|---|
| 643 | old_path=pathjoin(repos.reponame,
|
|---|
| 644 | old_node.created_path))
|
|---|
| 645 | title = _('Show the %(range)s differences restricted '
|
|---|
| 646 | 'to %(path)s', range='[%s:%s]' % (
|
|---|
| 647 | display_rev(old_node.rev),
|
|---|
| 648 | display_rev(new_node.rev)),
|
|---|
| 649 | path=new_node.path)
|
|---|
| 650 | info['href'] = href
|
|---|
| 651 | info['title'] = old_node and title
|
|---|
| 652 | if change in Changeset.DIFF_CHANGES and not show_diff:
|
|---|
| 653 | info['hide_diff'] = True
|
|---|
| 654 | else:
|
|---|
| 655 | info = None
|
|---|
| 656 | changes.append(info) # the sequence should be immutable
|
|---|
| 657 |
|
|---|
| 658 | data.update({
|
|---|
| 659 | 'has_diffs': has_diffs,
|
|---|
| 660 | 'show_diffs': show_diffs,
|
|---|
| 661 | 'diff_files': diff_files,
|
|---|
| 662 | 'diff_bytes': diff_bytes,
|
|---|
| 663 | 'max_diff_files': self.max_diff_files,
|
|---|
| 664 | 'max_diff_bytes': self.max_diff_bytes,
|
|---|
| 665 | 'changes': changes,
|
|---|
| 666 | 'filestats': filestats,
|
|---|
| 667 | 'annotated': annotated,
|
|---|
| 668 | 'files': files,
|
|---|
| 669 | 'location': self._get_parent_location(files),
|
|---|
| 670 | 'longcol': 'Revision',
|
|---|
| 671 | 'shortcol': 'r'
|
|---|
| 672 | })
|
|---|
| 673 |
|
|---|
| 674 | if req.is_xhr: # render and return the content only
|
|---|
| 675 | stream = Chrome(self.env).generate_fragment(
|
|---|
| 676 | req, 'changeset_content.html', data)
|
|---|
| 677 | req.send(stream)
|
|---|
| 678 |
|
|---|
| 679 | return data
|
|---|
| 680 |
|
|---|
| 681 | def _render_diff(self, req, filename, repos, data):
|
|---|
| 682 | """Raw Unified Diff version"""
|
|---|
| 683 |
|
|---|
| 684 | output = (line.encode('utf-8') if isinstance(line, unicode) else line
|
|---|
| 685 | for line in self._iter_diff_lines(req, repos, data))
|
|---|
| 686 | if Chrome(self.env).use_chunked_encoding:
|
|---|
| 687 | length = None
|
|---|
| 688 | else:
|
|---|
| 689 | output = ''.join(output)
|
|---|
| 690 | length = len(output)
|
|---|
| 691 |
|
|---|
| 692 | req.send_response(200)
|
|---|
| 693 | req.send_header('Content-Type', 'text/x-patch;charset=utf-8')
|
|---|
| 694 | req.send_header('Content-Disposition',
|
|---|
| 695 | content_disposition('attachment', filename + '.diff'))
|
|---|
| 696 | if length is not None:
|
|---|
| 697 | req.send_header('Content-Length', length)
|
|---|
| 698 | req.end_headers()
|
|---|
| 699 | req.write(output)
|
|---|
| 700 | raise RequestDone
|
|---|
| 701 |
|
|---|
| 702 | def _iter_diff_lines(self, req, repos, data):
|
|---|
| 703 | mimeview = Mimeview(self.env)
|
|---|
| 704 |
|
|---|
| 705 | for old_node, new_node, kind, change in repos.get_changes(
|
|---|
| 706 | new_path=data['new_path'], new_rev=data['new_rev'],
|
|---|
| 707 | old_path=data['old_path'], old_rev=data['old_rev']):
|
|---|
| 708 | # TODO: Property changes
|
|---|
| 709 |
|
|---|
| 710 | # Content changes
|
|---|
| 711 | if kind == Node.DIRECTORY:
|
|---|
| 712 | continue
|
|---|
| 713 |
|
|---|
| 714 | new_content = old_content = ''
|
|---|
| 715 | new_node_info = old_node_info = ('', '')
|
|---|
| 716 |
|
|---|
| 717 | if old_node:
|
|---|
| 718 | if not old_node.is_viewable(req.perm):
|
|---|
| 719 | continue
|
|---|
| 720 | if mimeview.is_binary(old_node.content_type, old_node.path):
|
|---|
| 721 | continue
|
|---|
| 722 | old_content = _read_content(old_node)
|
|---|
| 723 | if mimeview.is_binary(content=old_content):
|
|---|
| 724 | continue
|
|---|
| 725 | old_node_info = (old_node.path, old_node.rev)
|
|---|
| 726 | old_content = mimeview.to_unicode(old_content,
|
|---|
| 727 | old_node.content_type)
|
|---|
| 728 | if new_node:
|
|---|
| 729 | if not new_node.is_viewable(req.perm):
|
|---|
| 730 | continue
|
|---|
| 731 | if mimeview.is_binary(new_node.content_type, new_node.path):
|
|---|
| 732 | continue
|
|---|
| 733 | new_content = _read_content(new_node)
|
|---|
| 734 | if mimeview.is_binary(content=new_content):
|
|---|
| 735 | continue
|
|---|
| 736 | new_node_info = (new_node.path, new_node.rev)
|
|---|
| 737 | new_path = new_node.path
|
|---|
| 738 | new_content = mimeview.to_unicode(new_content,
|
|---|
| 739 | new_node.content_type)
|
|---|
| 740 | else:
|
|---|
| 741 | old_node_path = repos.normalize_path(old_node.path)
|
|---|
| 742 | diff_old_path = repos.normalize_path(data['old_path'])
|
|---|
| 743 | new_path = pathjoin(data['new_path'],
|
|---|
| 744 | old_node_path[len(diff_old_path) + 1:])
|
|---|
| 745 |
|
|---|
| 746 | if old_content != new_content:
|
|---|
| 747 | options = data['diff']['options']
|
|---|
| 748 | context = options.get('contextlines', 3)
|
|---|
| 749 | if context < 0 or options.get('contextall'):
|
|---|
| 750 | context = 3 # FIXME: unified_diff bugs with context=None
|
|---|
| 751 | ignore_blank_lines = options.get('ignoreblanklines')
|
|---|
| 752 | ignore_case = options.get('ignorecase')
|
|---|
| 753 | ignore_space = options.get('ignorewhitespace')
|
|---|
| 754 | if not old_node_info[0]:
|
|---|
| 755 | old_node_info = new_node_info # support for 'A'dd changes
|
|---|
| 756 | yield 'Index: ' + new_path + CRLF
|
|---|
| 757 | yield '=' * 67 + CRLF
|
|---|
| 758 | yield '--- %s\t(revision %s)' % old_node_info + CRLF
|
|---|
| 759 | yield '+++ %s\t(revision %s)' % new_node_info + CRLF
|
|---|
| 760 | for line in unified_diff(old_content.splitlines(),
|
|---|
| 761 | new_content.splitlines(), context,
|
|---|
| 762 | ignore_blank_lines=ignore_blank_lines,
|
|---|
| 763 | ignore_case=ignore_case,
|
|---|
| 764 | ignore_space_changes=ignore_space):
|
|---|
| 765 | yield line + CRLF
|
|---|
| 766 |
|
|---|
| 767 | def _zip_iter_nodes(self, req, repos, data, root_node):
|
|---|
| 768 | """Node iterator yielding all the added and/or modified files."""
|
|---|
| 769 | for old_node, new_node, kind, change in repos.get_changes(
|
|---|
| 770 | new_path=data['new_path'], new_rev=data['new_rev'],
|
|---|
| 771 | old_path=data['old_path'], old_rev=data['old_rev']):
|
|---|
| 772 | if kind in (Node.FILE, Node.DIRECTORY) and \
|
|---|
| 773 | change != Changeset.DELETE \
|
|---|
| 774 | and new_node.is_viewable(req.perm):
|
|---|
| 775 | yield new_node
|
|---|
| 776 |
|
|---|
| 777 | def title_for_diff(self, data):
|
|---|
| 778 | # TRANSLATOR: 'latest' (revision)
|
|---|
| 779 | latest = _('latest')
|
|---|
| 780 | if data['new_path'] == data['old_path']:
|
|---|
| 781 | # ''diff between 2 revisions'' mode
|
|---|
| 782 | return _('Diff [%(old_rev)s:%(new_rev)s] for %(path)s',
|
|---|
| 783 | old_rev=data['old_rev'] or latest,
|
|---|
| 784 | new_rev=data['new_rev'] or latest,
|
|---|
| 785 | path=data['new_path'] or '/')
|
|---|
| 786 | else:
|
|---|
| 787 | # ''generalized diff'' mode
|
|---|
| 788 | return _('Diff from %(old_path)s@%(old_rev)s to %(new_path)s@'
|
|---|
| 789 | '%(new_rev)s',
|
|---|
| 790 | old_path=data['old_path'] or '/',
|
|---|
| 791 | old_rev=data['old_rev'] or latest,
|
|---|
| 792 | new_path=data['new_path'] or '/',
|
|---|
| 793 | new_rev=data['new_rev'] or latest)
|
|---|
| 794 |
|
|---|
| 795 | def render_property_diff(self, name, old_node, old_props,
|
|---|
| 796 | new_node, new_props, options):
|
|---|
| 797 | """Renders diffs of a node property to HTML."""
|
|---|
| 798 | if name in BrowserModule(self.env).hidden_properties:
|
|---|
| 799 | return
|
|---|
| 800 | candidates = []
|
|---|
| 801 | for renderer in self.property_diff_renderers:
|
|---|
| 802 | quality = renderer.match_property_diff(name)
|
|---|
| 803 | if quality > 0:
|
|---|
| 804 | candidates.append((quality, renderer))
|
|---|
| 805 | candidates.sort(reverse=True)
|
|---|
| 806 | for (quality, renderer) in candidates:
|
|---|
| 807 | try:
|
|---|
| 808 | return renderer.render_property_diff(name, old_node, old_props,
|
|---|
| 809 | new_node, new_props,
|
|---|
| 810 | options)
|
|---|
| 811 | except Exception as e:
|
|---|
| 812 | self.log.warning('Diff rendering failed for property %s with '
|
|---|
| 813 | 'renderer %s: %s', name,
|
|---|
| 814 | renderer.__class__.__name__,
|
|---|
| 815 | exception_to_unicode(e, traceback=True))
|
|---|
| 816 |
|
|---|
| 817 | def _get_location(self, files):
|
|---|
| 818 | """Return the deepest common path for the given files.
|
|---|
| 819 | If all the files are actually the same, return that location."""
|
|---|
| 820 | if len(files) == 1:
|
|---|
| 821 | return files[0]
|
|---|
| 822 | else:
|
|---|
| 823 | return '/'.join(os.path.commonprefix([f.split('/')
|
|---|
| 824 | for f in files]))
|
|---|
| 825 |
|
|---|
| 826 | def _get_parent_location(self, files):
|
|---|
| 827 | """Only get a location when there are different files,
|
|---|
| 828 | otherwise return the empty string."""
|
|---|
| 829 | if files:
|
|---|
| 830 | files.sort()
|
|---|
| 831 | prev = files[0]
|
|---|
| 832 | for f in files[1:]:
|
|---|
| 833 | if f != prev:
|
|---|
| 834 | return self._get_location(files)
|
|---|
| 835 | return ''
|
|---|
| 836 |
|
|---|
| 837 | def _prepare_filestats(self):
|
|---|
| 838 | filestats = {}
|
|---|
| 839 | for chg in Changeset.ALL_CHANGES:
|
|---|
| 840 | filestats[chg] = 0
|
|---|
| 841 | return filestats
|
|---|
| 842 |
|
|---|
| 843 | # ITimelineEventProvider methods
|
|---|
| 844 |
|
|---|
| 845 | def get_timeline_filters(self, req):
|
|---|
| 846 | if 'CHANGESET_VIEW' in req.perm:
|
|---|
| 847 | # Non-'hidden' repositories will be listed as additional
|
|---|
| 848 | # repository filters, unless there is only a single repository.
|
|---|
| 849 | filters = []
|
|---|
| 850 | rm = RepositoryManager(self.env)
|
|---|
| 851 | repositories = rm.get_real_repositories()
|
|---|
| 852 | if len(repositories) > 1:
|
|---|
| 853 | filters = [
|
|---|
| 854 | ('repo-' + repos.reponame,
|
|---|
| 855 | u"\xa0\xa0-\xa0" + (repos.reponame or _('(default)')))
|
|---|
| 856 | for repos in repositories
|
|---|
| 857 | if not as_bool(repos.params.get('hidden'))
|
|---|
| 858 | and repos.is_viewable(req.perm)]
|
|---|
| 859 | filters.sort()
|
|---|
| 860 | add_script(req, 'common/js/timeline_multirepos.js')
|
|---|
| 861 | changeset_label = _('Changesets in all repositories')
|
|---|
| 862 | else:
|
|---|
| 863 | changeset_label = _('Repository changesets')
|
|---|
| 864 | filters.insert(0, ('changeset', changeset_label))
|
|---|
| 865 | return filters
|
|---|
| 866 | else:
|
|---|
| 867 | return []
|
|---|
| 868 |
|
|---|
| 869 | def get_timeline_events(self, req, start, stop, filters):
|
|---|
| 870 | all_repos = 'changeset' in filters
|
|---|
| 871 | repo_filters = {f for f in filters if f.startswith('repo-')}
|
|---|
| 872 | if all_repos or repo_filters:
|
|---|
| 873 | show_files = self.timeline_show_files
|
|---|
| 874 | show_location = show_files == 'location'
|
|---|
| 875 | if show_files in ('-1', 'unlimited'):
|
|---|
| 876 | show_files = -1
|
|---|
| 877 | elif show_files.isdigit():
|
|---|
| 878 | show_files = int(show_files)
|
|---|
| 879 | else:
|
|---|
| 880 | show_files = 0 # disabled
|
|---|
| 881 |
|
|---|
| 882 | if self.timeline_collapse:
|
|---|
| 883 | collapse_changesets = lambda c: (c.author, c.message)
|
|---|
| 884 | else:
|
|---|
| 885 | collapse_changesets = lambda c: c.rev
|
|---|
| 886 |
|
|---|
| 887 | uids_seen = {}
|
|---|
| 888 | def generate_changesets(repos):
|
|---|
| 889 | for _, changesets in groupby(repos.get_changesets(start, stop),
|
|---|
| 890 | key=collapse_changesets):
|
|---|
| 891 | viewable_changesets = []
|
|---|
| 892 | for cset in changesets:
|
|---|
| 893 | if cset.is_viewable(req.perm):
|
|---|
| 894 | repos_for_uid = [repos.reponame]
|
|---|
| 895 | uid = repos.get_changeset_uid(cset.rev)
|
|---|
| 896 | if uid:
|
|---|
| 897 | # uid can be seen in multiple repositories
|
|---|
| 898 | if uid in uids_seen:
|
|---|
| 899 | uids_seen[uid].append(repos.reponame)
|
|---|
| 900 | continue # already viewable, just append
|
|---|
| 901 | uids_seen[uid] = repos_for_uid
|
|---|
| 902 | viewable_changesets.append((cset, cset.resource,
|
|---|
| 903 | repos_for_uid))
|
|---|
| 904 | if viewable_changesets:
|
|---|
| 905 | cset = viewable_changesets[-1][0]
|
|---|
| 906 | yield ('changeset', cset.date, cset.author,
|
|---|
| 907 | (viewable_changesets,
|
|---|
| 908 | show_location, show_files))
|
|---|
| 909 |
|
|---|
| 910 | rm = RepositoryManager(self.env)
|
|---|
| 911 | for repos in sorted(rm.get_real_repositories(),
|
|---|
| 912 | key=lambda repos: repos.reponame):
|
|---|
| 913 | if all_repos or ('repo-' + repos.reponame) in repo_filters:
|
|---|
| 914 | try:
|
|---|
| 915 | for event in generate_changesets(repos):
|
|---|
| 916 | yield event
|
|---|
| 917 | except TracError as e:
|
|---|
| 918 | self.log.error("Timeline event provider for repository"
|
|---|
| 919 | " '%s' failed: %r",
|
|---|
| 920 | repos.reponame, exception_to_unicode(e))
|
|---|
| 921 |
|
|---|
| 922 | def render_timeline_event(self, context, field, event):
|
|---|
| 923 | changesets, show_location, show_files = event[3]
|
|---|
| 924 | cset, cset_resource, repos_for_uid = changesets[0]
|
|---|
| 925 | older_cset = changesets[-1][0]
|
|---|
| 926 | message = cset.message or ''
|
|---|
| 927 | reponame = cset_resource.parent.id
|
|---|
| 928 | rev_b, rev_a = cset.rev, older_cset.rev
|
|---|
| 929 |
|
|---|
| 930 | if field == 'url':
|
|---|
| 931 | if rev_a == rev_b:
|
|---|
| 932 | return context.href.changeset(rev_a, reponame or None)
|
|---|
| 933 | else:
|
|---|
| 934 | return context.href.log(reponame or None, rev=rev_b,
|
|---|
| 935 | stop_rev=rev_a)
|
|---|
| 936 |
|
|---|
| 937 | elif field == 'description':
|
|---|
| 938 | if self.wiki_format_messages:
|
|---|
| 939 | markup = ''
|
|---|
| 940 | if self.timeline_long_messages: # override default flavor
|
|---|
| 941 | context = context.child()
|
|---|
| 942 | context.set_hints(wiki_flavor='html',
|
|---|
| 943 | preserve_newlines=True)
|
|---|
| 944 | else:
|
|---|
| 945 | markup = message
|
|---|
| 946 | message = None
|
|---|
| 947 | if 'BROWSER_VIEW' in context.perm:
|
|---|
| 948 | files = []
|
|---|
| 949 | if show_location:
|
|---|
| 950 | filestats = self._prepare_filestats()
|
|---|
| 951 | for c, r, repos_for_c in changesets:
|
|---|
| 952 | for chg in c.get_changes():
|
|---|
| 953 | resource = c.resource.parent.child('source',
|
|---|
| 954 | chg[0] or '/',
|
|---|
| 955 | r.id)
|
|---|
| 956 | if 'FILE_VIEW' not in context.perm(resource):
|
|---|
| 957 | continue
|
|---|
| 958 | filestats[chg[2]] += 1
|
|---|
| 959 | files.append(chg[0])
|
|---|
| 960 | stats = [(tag.div(class_=kind),
|
|---|
| 961 | tag.span(count, ' ',
|
|---|
| 962 | count > 1 and
|
|---|
| 963 | (kind == 'copy' and
|
|---|
| 964 | 'copies' or kind + 's') or kind))
|
|---|
| 965 | for kind in Changeset.ALL_CHANGES
|
|---|
| 966 | for count in (filestats[kind],) if count]
|
|---|
| 967 | markup = tag.ul(
|
|---|
| 968 | tag.li(stats, ' in ',
|
|---|
| 969 | tag.strong(self._get_location(files) or '/')),
|
|---|
| 970 | markup, class_="changes")
|
|---|
| 971 | elif show_files:
|
|---|
| 972 | unique_files = set()
|
|---|
| 973 | for c, r, repos_for_c in changesets:
|
|---|
| 974 | for chg in c.get_changes():
|
|---|
| 975 | resource = c.resource.parent.child('source',
|
|---|
| 976 | chg[0] or '/',
|
|---|
| 977 | r.id)
|
|---|
| 978 | if 'FILE_VIEW' not in context.perm(resource):
|
|---|
| 979 | continue
|
|---|
| 980 | if 0 < show_files < len(files):
|
|---|
| 981 | break
|
|---|
| 982 | unique_files.add((chg[0], chg[2]))
|
|---|
| 983 | files = [tag.li(tag.div(class_=mod), path or '/')
|
|---|
| 984 | for path, mod in sorted(unique_files)]
|
|---|
| 985 | if 0 < show_files < len(files):
|
|---|
| 986 | files = files[:show_files] + [tag.li(u'\u2026')]
|
|---|
| 987 | markup = tag(tag.ul(files, class_="changes"), markup)
|
|---|
| 988 | if message:
|
|---|
| 989 | markup += format_to(self.env, None,
|
|---|
| 990 | context.child(cset_resource), message)
|
|---|
| 991 | return markup
|
|---|
| 992 |
|
|---|
| 993 | single = rev_a == rev_b
|
|---|
| 994 | if not repos_for_uid[0]:
|
|---|
| 995 | repos_for_uid[0] = _('(default)')
|
|---|
| 996 | if reponame or len(repos_for_uid) > 1:
|
|---|
| 997 | title = ngettext('Changeset in %(repo)s ',
|
|---|
| 998 | 'Changesets in %(repo)s ',
|
|---|
| 999 | 1 if single else 2, repo=', '.join(repos_for_uid))
|
|---|
| 1000 | else:
|
|---|
| 1001 | title = ngettext('Changeset ', 'Changesets ', 1 if single else 2)
|
|---|
| 1002 | drev_a = older_cset.repos.display_rev(rev_a)
|
|---|
| 1003 | if single:
|
|---|
| 1004 | title = tag(title, tag.em('[%s]' % drev_a))
|
|---|
| 1005 | else:
|
|---|
| 1006 | drev_b = cset.repos.display_rev(rev_b)
|
|---|
| 1007 | title = tag(title, tag.em('[%s-%s]' % (drev_a, drev_b)))
|
|---|
| 1008 | if field == 'title':
|
|---|
| 1009 | labels = []
|
|---|
| 1010 | for name, head in cset.get_branches():
|
|---|
| 1011 | if not head and name in ('default', 'master'):
|
|---|
| 1012 | continue
|
|---|
| 1013 | class_ = 'branch'
|
|---|
| 1014 | if head:
|
|---|
| 1015 | class_ += ' head'
|
|---|
| 1016 | labels.append(tag.span(name, class_=class_))
|
|---|
| 1017 | for name in cset.get_tags():
|
|---|
| 1018 | labels.append(tag.span(name, class_='tag'))
|
|---|
| 1019 | for name in cset.get_bookmarks():
|
|---|
| 1020 | labels.append(tag.span(name, class_='trac-bookmark'))
|
|---|
| 1021 | return title if not labels else tag(title, labels)
|
|---|
| 1022 | elif field == 'summary':
|
|---|
| 1023 | return tag_("%(title)s: %(message)s",
|
|---|
| 1024 | title=title, message=shorten_line(message))
|
|---|
| 1025 |
|
|---|
| 1026 | # IWikiSyntaxProvider methods
|
|---|
| 1027 |
|
|---|
| 1028 | CHANGESET_ID = r"(?:[0-9]+|[a-fA-F0-9]{8,})" # only "long enough" hex ids
|
|---|
| 1029 |
|
|---|
| 1030 | def get_wiki_syntax(self):
|
|---|
| 1031 | yield (
|
|---|
| 1032 | # [...] form: start with optional intertrac: [T... or [trac ...
|
|---|
| 1033 | r"!?\[(?P<it_changeset>%s\s*)" % WikiParser.INTERTRAC_SCHEME +
|
|---|
| 1034 | # hex digits + optional /path for the restricted changeset
|
|---|
| 1035 | # + optional query and fragment
|
|---|
| 1036 | r"%s(?:/[^\]]*)?(?:\?[^\]]*)?(?:#[^\]]*)?\]|" % self.CHANGESET_ID +
|
|---|
| 1037 | # r... form: allow r1 but not r1:2 (handled by the log syntax)
|
|---|
| 1038 | r"(?:\b|!)r[0-9]+\b(?!:[0-9])(?:/[a-zA-Z0-9_/+-]+)?",
|
|---|
| 1039 | lambda x, y, z:
|
|---|
| 1040 | self._format_changeset_link(x, 'changeset',
|
|---|
| 1041 | y[1:] if y[0] == 'r' else y[1:-1],
|
|---|
| 1042 | y, z))
|
|---|
| 1043 |
|
|---|
| 1044 | def get_link_resolvers(self):
|
|---|
| 1045 | yield ('changeset', self._format_changeset_link)
|
|---|
| 1046 | yield ('diff', self._format_diff_link)
|
|---|
| 1047 |
|
|---|
| 1048 | def _format_changeset_link(self, formatter, ns, chgset, label,
|
|---|
| 1049 | fullmatch=None):
|
|---|
| 1050 | intertrac = formatter.shorthand_intertrac_helper(ns, chgset, label,
|
|---|
| 1051 | fullmatch)
|
|---|
| 1052 | if intertrac:
|
|---|
| 1053 | return intertrac
|
|---|
| 1054 |
|
|---|
| 1055 | # identifying repository
|
|---|
| 1056 | rm = RepositoryManager(self.env)
|
|---|
| 1057 | chgset, params, fragment = formatter.split_link(chgset)
|
|---|
| 1058 | sep = chgset.find('/')
|
|---|
| 1059 | if sep > 0:
|
|---|
| 1060 | rev, path = chgset[:sep], chgset[sep:]
|
|---|
| 1061 | else:
|
|---|
| 1062 | rev, path = chgset, '/'
|
|---|
| 1063 | try:
|
|---|
| 1064 | reponame, repos, path = rm.get_repository_by_path(path)
|
|---|
| 1065 | if not reponame:
|
|---|
| 1066 | reponame = rm.get_default_repository(formatter.context)
|
|---|
| 1067 | if reponame is not None:
|
|---|
| 1068 | repos = rm.get_repository(reponame)
|
|---|
| 1069 | if path == '/':
|
|---|
| 1070 | path = None
|
|---|
| 1071 |
|
|---|
| 1072 | # rendering changeset link
|
|---|
| 1073 | if repos:
|
|---|
| 1074 | changeset = repos.get_changeset(rev)
|
|---|
| 1075 | if changeset.is_viewable(formatter.perm):
|
|---|
| 1076 | href = formatter.href.changeset(rev,
|
|---|
| 1077 | repos.reponame or None,
|
|---|
| 1078 | path)
|
|---|
| 1079 | return tag.a(label, class_="changeset",
|
|---|
| 1080 | title=shorten_line(changeset.message),
|
|---|
| 1081 | href=href + params + fragment)
|
|---|
| 1082 | errmsg = _("No permission to view changeset %(rev)s "
|
|---|
| 1083 | "on %(repos)s", rev=rev,
|
|---|
| 1084 | repos=reponame or _('(default)'))
|
|---|
| 1085 | elif reponame:
|
|---|
| 1086 | errmsg = _("Repository '%(repo)s' not found", repo=reponame)
|
|---|
| 1087 | else:
|
|---|
| 1088 | errmsg = _("No default repository defined")
|
|---|
| 1089 | except TracError as e:
|
|---|
| 1090 | errmsg = to_unicode(e)
|
|---|
| 1091 | return tag.a(label, class_="missing changeset", title=errmsg)
|
|---|
| 1092 |
|
|---|
| 1093 | def _format_diff_link(self, formatter, ns, target, label):
|
|---|
| 1094 | params, query, fragment = formatter.split_link(target)
|
|---|
| 1095 | def pathrev(path):
|
|---|
| 1096 | if '@' in path:
|
|---|
| 1097 | return path.split('@', 1)
|
|---|
| 1098 | else:
|
|---|
| 1099 | return path, None
|
|---|
| 1100 | if '//' in params:
|
|---|
| 1101 | p1, p2 = params.split('//', 1)
|
|---|
| 1102 | old, new = pathrev(p1), pathrev(p2)
|
|---|
| 1103 | data = {'old_path': old[0], 'old_rev': old[1],
|
|---|
| 1104 | 'new_path': new[0], 'new_rev': new[1]}
|
|---|
| 1105 | else:
|
|---|
| 1106 | old_path, old_rev = pathrev(params)
|
|---|
| 1107 | new_rev = None
|
|---|
| 1108 | if old_rev and ':' in old_rev:
|
|---|
| 1109 | old_rev, new_rev = old_rev.split(':', 1)
|
|---|
| 1110 | data = {'old_path': old_path, 'old_rev': old_rev,
|
|---|
| 1111 | 'new_path': old_path, 'new_rev': new_rev}
|
|---|
| 1112 | title = self.title_for_diff(data)
|
|---|
| 1113 | href = None
|
|---|
| 1114 | if any(data.values()):
|
|---|
| 1115 | if query:
|
|---|
| 1116 | query = '&' + query[1:]
|
|---|
| 1117 | href = formatter.href.changeset(new_path=data['new_path'] or None,
|
|---|
| 1118 | new=data['new_rev'],
|
|---|
| 1119 | old_path=data['old_path'] or None,
|
|---|
| 1120 | old=data['old_rev']) + query
|
|---|
| 1121 | return tag.a(label, class_="changeset", title=title, href=href)
|
|---|
| 1122 |
|
|---|
| 1123 | # ISearchSource methods
|
|---|
| 1124 |
|
|---|
| 1125 | ### FIXME: move this specific implementation into cache.py
|
|---|
| 1126 |
|
|---|
| 1127 | def get_search_filters(self, req):
|
|---|
| 1128 | if 'CHANGESET_VIEW' in req.perm:
|
|---|
| 1129 | yield ('changeset', _('Changesets'))
|
|---|
| 1130 |
|
|---|
| 1131 | def get_search_results(self, req, terms, filters):
|
|---|
| 1132 | if 'changeset' not in filters:
|
|---|
| 1133 | return
|
|---|
| 1134 | rm = RepositoryManager(self.env)
|
|---|
| 1135 | repositories = {repos.params['id']: repos
|
|---|
| 1136 | for repos in rm.get_real_repositories()}
|
|---|
| 1137 | uids_seen = set()
|
|---|
| 1138 | with self.env.db_query as db:
|
|---|
| 1139 | sql, args = search_to_sql(db, ['rev', 'message', 'author'], terms)
|
|---|
| 1140 | for id, rev, ts, author, log in db("""
|
|---|
| 1141 | SELECT repos, rev, time, author, message
|
|---|
| 1142 | FROM revision WHERE """ + sql, args):
|
|---|
| 1143 | repos = repositories.get(id)
|
|---|
| 1144 | if not repos:
|
|---|
| 1145 | continue # revisions for a no longer active repository
|
|---|
| 1146 | try:
|
|---|
| 1147 | rev = repos.normalize_rev(rev)
|
|---|
| 1148 | drev = repos.display_rev(rev)
|
|---|
| 1149 | except NoSuchChangeset:
|
|---|
| 1150 | continue
|
|---|
| 1151 | uid = repos.get_changeset_uid(rev)
|
|---|
| 1152 | if uid in uids_seen:
|
|---|
| 1153 | continue
|
|---|
| 1154 | cset = repos.resource.child(self.realm, rev)
|
|---|
| 1155 | if 'CHANGESET_VIEW' in req.perm(cset):
|
|---|
| 1156 | uids_seen.add(uid)
|
|---|
| 1157 | yield (req.href.changeset(rev, repos.reponame or None),
|
|---|
| 1158 | '[%s]: %s' % (drev, shorten_line(log)),
|
|---|
| 1159 | from_utimestamp(ts), author,
|
|---|
| 1160 | shorten_result(log, terms))
|
|---|
| 1161 |
|
|---|
| 1162 |
|
|---|
| 1163 | class AnyDiffModule(Component):
|
|---|
| 1164 |
|
|---|
| 1165 | implements(IRequestHandler)
|
|---|
| 1166 |
|
|---|
| 1167 | # IRequestHandler methods
|
|---|
| 1168 |
|
|---|
| 1169 | def match_request(self, req):
|
|---|
| 1170 | return req.path_info == '/diff'
|
|---|
| 1171 |
|
|---|
| 1172 | def process_request(self, req):
|
|---|
| 1173 | rm = RepositoryManager(self.env)
|
|---|
| 1174 |
|
|---|
| 1175 | if req.is_xhr:
|
|---|
| 1176 | dirname, prefix = posixpath.split(req.args.get('term'))
|
|---|
| 1177 | prefix = prefix.lower()
|
|---|
| 1178 | reponame, repos, path = rm.get_repository_by_path(dirname)
|
|---|
| 1179 | # an entry is a (isdir, name, path) tuple
|
|---|
| 1180 | def kind_order(entry):
|
|---|
| 1181 | return not entry[0], embedded_numbers(entry[1])
|
|---|
| 1182 |
|
|---|
| 1183 | entries = []
|
|---|
| 1184 | if repos:
|
|---|
| 1185 | entries.extend((e.isdir, e.name,
|
|---|
| 1186 | '/' + pathjoin(repos.reponame, e.path))
|
|---|
| 1187 | for e in repos.get_node(path).get_entries()
|
|---|
| 1188 | if e.is_viewable(req.perm))
|
|---|
| 1189 | if not reponame:
|
|---|
| 1190 | entries.extend((True, repos.reponame, '/' + repos.reponame)
|
|---|
| 1191 | for repos in rm.get_real_repositories()
|
|---|
| 1192 | if repos.is_viewable(req.perm))
|
|---|
| 1193 |
|
|---|
| 1194 | paths = [{'label': path + ('/' if isdir else ''), 'value': path,
|
|---|
| 1195 | 'isdir': isdir}
|
|---|
| 1196 | for isdir, name, path in sorted(entries, key=kind_order)
|
|---|
| 1197 | if name.lower().startswith(prefix)]
|
|---|
| 1198 |
|
|---|
| 1199 | content = to_json(paths)
|
|---|
| 1200 | req.send(content, 'application/json', 200)
|
|---|
| 1201 |
|
|---|
| 1202 | # -- retrieve arguments
|
|---|
| 1203 | new_path = req.args.get('new_path')
|
|---|
| 1204 | new_rev = req.args.get('new_rev')
|
|---|
| 1205 | old_path = req.args.get('old_path')
|
|---|
| 1206 | old_rev = req.args.get('old_rev')
|
|---|
| 1207 |
|
|---|
| 1208 | # -- normalize and prepare rendering
|
|---|
| 1209 | new_reponame, new_repos, new_path = \
|
|---|
| 1210 | rm.get_repository_by_path(new_path)
|
|---|
| 1211 | old_reponame, old_repos, old_path = \
|
|---|
| 1212 | rm.get_repository_by_path(old_path)
|
|---|
| 1213 |
|
|---|
| 1214 | data = {}
|
|---|
| 1215 | if new_repos:
|
|---|
| 1216 | data.update(new_path='/' + pathjoin(new_repos.reponame, new_path),
|
|---|
| 1217 | new_rev=new_repos.normalize_rev(new_rev))
|
|---|
| 1218 | else:
|
|---|
| 1219 | data.update(new_path=req.args.get('new_path'), new_rev=new_rev)
|
|---|
| 1220 | if old_repos:
|
|---|
| 1221 | data.update(old_path='/' + pathjoin(old_repos.reponame, old_path),
|
|---|
| 1222 | old_rev=old_repos.normalize_rev(old_rev))
|
|---|
| 1223 | else:
|
|---|
| 1224 | data.update(old_path=req.args.get('old_path'), old_rev=old_rev)
|
|---|
| 1225 |
|
|---|
| 1226 | Chrome(self.env).add_jquery_ui(req)
|
|---|
| 1227 | add_stylesheet(req, 'common/css/diff.css')
|
|---|
| 1228 | return 'diff_form.html', data
|
|---|
| 1229 |
|
|---|
| 1230 |
|
|---|
| 1231 | def _read_content(node):
|
|---|
| 1232 | with content_closing(node.get_content()) as content:
|
|---|
| 1233 | return content.read()
|
|---|