1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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
56 """Render node properties in TracBrowser and TracChangeset views."""
57
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
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
85 """Default version control property difference renderer."""
86
87 implements(IPropertyDiffRenderer)
88
91
94 old, new = old_props[name], new_props[name]
95
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
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
169
172
175
176
177
179 return ['CHANGESET_VIEW']
180
181
182
183 _request_re = re.compile(r"/changeset(?:/([^/]+)(/.*)?)?$")
184
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
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
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
223
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
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:
263 old_path = old = None
264
265 style, options, diff_data = get_diff_options(req)
266 diff_opts = diff_data['options']
267
268
269 chgset = not old and old_path is None
270 if chgset:
271 restricted = new_path not in ('', '/')
272 else:
273 restricted = old_path == new_path
274
275
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
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
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
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
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
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:
388 path, rev = data['new_path'], data['new_rev']
389
390
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
396 npath.startswith(path + '/') or
397 path.startswith(npath + '/'))):
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
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
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:
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:
470
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
494
495
496
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 not k in new_props:
510 old = v
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 not k 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
577
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.