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()
|
---|