Merge lp:~cjwatson/launchpad/side-by-side-diff into lp:launchpad

Proposed by Colin Watson on 2015-06-23
Status: Merged
Merged at revision: 17584
Proposed branch: lp:~cjwatson/launchpad/side-by-side-diff
Merge into: lp:launchpad
Diff against target: 811 lines (+504/-65)
11 files modified
lib/canonical/launchpad/icing/css/modifiers.css (+1/-1)
lib/canonical/launchpad/icing/style.css (+4/-2)
lib/lp/app/browser/stringformatter.py (+153/-33)
lib/lp/app/browser/tests/test_stringformatter.py (+292/-13)
lib/lp/code/javascript/branchmergeproposal.inlinecomments.js (+15/-3)
lib/lp/code/javascript/branchmergeproposal.reviewcomment.js (+12/-8)
lib/lp/code/stories/branches/xx-branchmergeproposals.txt (+1/-1)
lib/lp/code/templates/branchmergeproposal-diff.pt (+10/-1)
lib/lp/code/templates/codereviewcomment-body.pt (+6/-1)
lib/lp/code/templates/codereviewnewrevisions-footer.pt (+6/-1)
lib/lp/services/features/templates/feature-changelog.pt (+4/-1)
To merge this branch: bzr merge lp:~cjwatson/launchpad/side-by-side-diff
Reviewer Review Type Date Requested Status
William Grant code 2015-06-23 Approve on 2015-06-29
Review via email: mp+262768@code.launchpad.net

Commit Message

Add an alternative side-by-side preview diff view to merge proposals.

Description of the Change

Add an alternative side-by-side preview diff view to merge proposals.

A first pass at this, at least, turned out to be much easier than I expected: it's mostly a matter of adding fmt:ssdiff which mangles a unified diff into side-by-side form, and keeping careful track of line numbers. The line-no cells are retained for the sake of inline comment tracking, but hidden from display; I added new ss-line-no cells which show the line numbers in the underlying files, which feels more comprehensible in this view and is more in line with how side-by-side diff views in other web applications tend to work.

I added similar support to other places where fmt:diff is used since it was quick to do so, but for now those other views are unlinked and only accessible via URL-hacking to add ?ss=1. We can add links later if they're needed.

To post a comment you must log in.
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/icing/css/modifiers.css'
2--- lib/canonical/launchpad/icing/css/modifiers.css 2014-05-19 08:03:55 +0000
3+++ lib/canonical/launchpad/icing/css/modifiers.css 2015-06-29 23:03:03 +0000
4@@ -166,7 +166,7 @@
5 }
6
7 pre.changelog,
8-table.diff td.line-no,
9+table.diff td.line-no, table.diff td.ss-line-no,
10 table.diff td.text,
11 .comment-text,
12 .bug-activity {
13
14=== modified file 'lib/canonical/launchpad/icing/style.css'
15--- lib/canonical/launchpad/icing/style.css 2015-06-24 21:22:17 +0000
16+++ lib/canonical/launchpad/icing/style.css 2015-06-29 23:03:03 +0000
17@@ -626,10 +626,12 @@
18 table.diff {
19 white-space: pre-wrap;
20 width: 100%;
21- table-layout: fixed;
22 background-color: white;
23 margin-bottom: 0.5em;
24 }
25+table.unidiff {
26+ table-layout: fixed;
27+}
28
29 table.diff .nav-cursor:before {
30 content: "\0025B6";
31@@ -639,7 +641,7 @@
32 margin-left: 1.25em;
33 }
34 table.diff .text { padding-left: 0.5em; }
35-table.diff .line-no {
36+table.diff .line-no, table.diff .ss-line-no {
37 text-align: right;
38 background-color: #f6f6f6;
39 color: #999;
40
41=== modified file 'lib/lp/app/browser/stringformatter.py'
42--- lib/lp/app/browser/stringformatter.py 2015-04-29 14:30:26 +0000
43+++ lib/lp/app/browser/stringformatter.py 2015-06-29 23:03:03 +0000
44@@ -11,16 +11,21 @@
45 'extract_email_addresses',
46 'FormattersAPI',
47 'linkify_bug_numbers',
48+ 'parse_diff',
49 're_substitute',
50 'split_paragraphs',
51 ]
52
53 from base64 import urlsafe_b64encode
54+from itertools import izip_longest
55 import re
56+import sys
57
58+from bzrlib.patches import hunk_from_header
59 from lxml import html
60 import markdown
61 from zope.component import getUtility
62+from zope.error.interfaces import IErrorReportingUtility
63 from zope.interface import implements
64 from zope.traversing.interfaces import (
65 ITraversable,
66@@ -241,6 +246,59 @@
67 return list(set([match.group() for match in matches]))
68
69
70+def parse_diff(text):
71+ """Parse a string into categorised diff lines.
72+
73+ Yields a sequence of (CSS class, line number in diff, line number in
74+ original file, line number in modified file, line).
75+ """
76+ max_format_lines = config.diff.max_format_lines
77+ header_next = False
78+ orig_row = 0
79+ mod_row = 0
80+ for row, line in enumerate(text.splitlines()[:max_format_lines]):
81+ row += 1
82+ if (line.startswith('===') or
83+ line.startswith('diff') or
84+ line.startswith('index')):
85+ yield 'diff-file text', row, None, None, line
86+ header_next = True
87+ elif (header_next and
88+ (line.startswith('+++') or
89+ line.startswith('---'))):
90+ yield 'diff-header text', row, None, None, line
91+ elif line.startswith('@@'):
92+ try:
93+ hunk = hunk_from_header(line + '\n')
94+ # The positions indicate the per-file line numbers of
95+ # the next row.
96+ orig_row = hunk.orig_pos
97+ mod_row = hunk.mod_pos
98+ except Exception:
99+ getUtility(IErrorReportingUtility).raising(sys.exc_info())
100+ orig_row = 1
101+ mod_row = 1
102+ yield 'diff-chunk text', row, None, None, line
103+ header_next = False
104+ elif line.startswith('+'):
105+ yield 'diff-added text', row, None, mod_row, line
106+ mod_row += 1
107+ elif line.startswith('-'):
108+ yield 'diff-removed text', row, orig_row, None, line
109+ orig_row += 1
110+ elif line.startswith('#'):
111+ # This doesn't occur in normal unified diffs, but does
112+ # appear in merge directives, which use text/x-diff or
113+ # text/x-patch.
114+ yield 'diff-comment text', row, None, None, line
115+ header_next = False
116+ else:
117+ yield 'text', row, orig_row, mod_row, line
118+ orig_row += 1
119+ mod_row += 1
120+ header_next = False
121+
122+
123 class FormattersAPI:
124 """Adapter from strings to HTML formatted text."""
125
126@@ -877,40 +935,11 @@
127 text = self._stringtoformat.rstrip('\n')
128 if len(text) == 0:
129 return text
130- result = ['<table class="diff">']
131+ result = ['<table class="diff unidiff">']
132
133- max_format_lines = config.diff.max_format_lines
134- header_next = False
135- for row, line in enumerate(text.splitlines()[:max_format_lines]):
136- result.append('<tr id="diff-line-%s">' % (row + 1))
137- result.append('<td class="line-no">%s</td>' % (row + 1))
138- if (line.startswith('===') or
139- line.startswith('diff') or
140- line.startswith('index')):
141- css_class = 'diff-file text'
142- header_next = True
143- elif (header_next and
144- (line.startswith('+++') or
145- line.startswith('---'))):
146- css_class = 'diff-header text'
147- elif line.startswith('@@'):
148- css_class = 'diff-chunk text'
149- header_next = False
150- elif line.startswith('+'):
151- css_class = 'diff-added text'
152- header_next = False
153- elif line.startswith('-'):
154- css_class = 'diff-removed text'
155- header_next = False
156- elif line.startswith('#'):
157- # This doesn't occur in normal unified diffs, but does
158- # appear in merge directives, which use text/x-diff or
159- # text/x-patch.
160- css_class = 'diff-comment text'
161- header_next = False
162- else:
163- css_class = 'text'
164- header_next = False
165+ for css_class, row, _, _, line in parse_diff(text):
166+ result.append('<tr id="diff-line-%s">' % row)
167+ result.append('<td class="line-no">%s</td>' % row)
168 result.append(
169 structured(
170 '<td class="%s">%s</td>', css_class, line).escapedtext)
171@@ -919,6 +948,95 @@
172 result.append('</table>')
173 return ''.join(result)
174
175+ def _ssdiff_emit_line(self, result, row, cells):
176+ result.append('<tr id="diff-line-%s">' % row)
177+ # A line-no cell has to be present for the inline comments code to
178+ # work, but displaying it would be confusing since there are also
179+ # per-file line numbers.
180+ result.append(
181+ '<td class="line-no" style="display: none">%s</td>' % row)
182+ result.extend(cells)
183+ result.append('</tr>')
184+
185+ def _ssdiff_emit_queued_lines(self, result, queue_removed, queue_added):
186+ for removed, added in izip_longest(queue_removed, queue_added):
187+ if removed:
188+ removed_diff_row, removed_row, removed_line = removed
189+ else:
190+ removed_diff_row, removed_row, removed_line = 0, '', ''
191+ if added:
192+ added_diff_row, added_row, added_line = added
193+ else:
194+ added_diff_row, added_row, added_line = 0, '', ''
195+ cells = (
196+ '<td class="ss-line-no">%s</td>' % removed_row,
197+ structured(
198+ '<td class="diff-removed text">%s</td>',
199+ removed_line).escapedtext,
200+ '<td class="ss-line-no">%s</td>' % added_row,
201+ structured(
202+ '<td class="diff-added text">%s</td>',
203+ added_line).escapedtext,
204+ )
205+ # Pick a reasonable row of the unified diff to attribute inline
206+ # comments to. Whichever of the removed and added rows is later
207+ # will do.
208+ self._ssdiff_emit_line(
209+ result, max(removed_diff_row, added_diff_row), cells)
210+
211+ def format_ssdiff(self):
212+ """Format the string as a side-by-side diff."""
213+ # Trim off trailing carriage returns.
214+ text = self._stringtoformat.rstrip('\n')
215+ if not text:
216+ return text
217+ result = ['<table class="diff ssdiff">']
218+
219+ queue_orig = []
220+ queue_mod = []
221+ for css_class, row, orig_row, mod_row, line in parse_diff(text):
222+ if orig_row is not None and mod_row is None:
223+ # Text line only in original version. Queue until we find a
224+ # common or non-text line.
225+ queue_orig.append((row, orig_row, line[1:]))
226+ elif mod_row is not None and orig_row is None:
227+ # Text line only in modified version. Queue until we find a
228+ # common or non-text line.
229+ queue_mod.append((row, mod_row, line[1:]))
230+ else:
231+ # Common or non-text line; emit any queued differences, then
232+ # this line.
233+ if queue_orig or queue_mod:
234+ self._ssdiff_emit_queued_lines(
235+ result, queue_orig, queue_mod)
236+ queue_orig = []
237+ queue_mod = []
238+ if orig_row is None and mod_row is None:
239+ # Non-text line.
240+ cells = [
241+ structured(
242+ '<td class="%s" colspan="4">%s</td>',
243+ css_class, line).escapedtext,
244+ ]
245+ else:
246+ # Line common to both versions.
247+ if line.startswith(' '):
248+ line = line[1:]
249+ cells = [
250+ '<td class="ss-line-no">%s</td>' % orig_row,
251+ structured(
252+ '<td class="text">%s</td>', line).escapedtext,
253+ '<td class="ss-line-no">%s</td>' % mod_row,
254+ structured(
255+ '<td class="text">%s</td>', line).escapedtext,
256+ ]
257+ self._ssdiff_emit_line(result, row, cells)
258+ if queue_orig or queue_mod:
259+ self._ssdiff_emit_queued_lines(result, queue_orig, queue_mod)
260+
261+ result.append('</table>')
262+ return ''.join(result)
263+
264 _css_id_strip_pattern = re.compile(r'[^a-zA-Z0-9-_]+')
265 _zope_css_id_strip_pattern = re.compile(r'[^a-zA-Z0-9-_\.]+')
266
267@@ -1030,6 +1148,8 @@
268 return self.ellipsize(maxlength)
269 elif name == 'diff':
270 return self.format_diff()
271+ elif name == 'ssdiff':
272+ return self.format_ssdiff()
273 elif name == 'css-id':
274 if len(furtherPath) > 0:
275 return self.css_id(furtherPath.pop())
276
277=== modified file 'lib/lp/app/browser/tests/test_stringformatter.py'
278--- lib/lp/app/browser/tests/test_stringformatter.py 2015-04-29 14:30:26 +0000
279+++ lib/lp/app/browser/tests/test_stringformatter.py 2015-06-29 23:03:03 +0000
280@@ -18,6 +18,7 @@
281 from lp.app.browser.stringformatter import (
282 FormattersAPI,
283 linkify_bug_numbers,
284+ parse_diff,
285 )
286 from lp.services.config import config
287 from lp.services.features.testing import FeatureFixture
288@@ -27,7 +28,10 @@
289 TestCase,
290 TestCaseWithFactory,
291 )
292-from lp.testing.layers import DatabaseFunctionalLayer
293+from lp.testing.layers import (
294+ DatabaseFunctionalLayer,
295+ ZopelessLayer,
296+ )
297 from lp.testing.pages import find_tags_by_class
298
299
300@@ -299,6 +303,174 @@
301 last_paragraph_class="last"))
302
303
304+class TestParseDiff(TestCase):
305+ """Test the parser for fmt:diff and fmt:ssdiff."""
306+
307+ def test_emptyString(self):
308+ # An empty string yields no lines.
309+ self.assertEqual([], list(parse_diff('')))
310+
311+ def test_almostEmptyString(self):
312+ # White space yields a single line of text.
313+ self.assertEqual([('text', 1, 0, 0, ' ')], list(parse_diff(' ')))
314+
315+ def test_unicode(self):
316+ # Diffs containing Unicode work too.
317+ self.assertEqual(
318+ [('text', 1, 0, 0, u'Unicode \u1010')],
319+ list(parse_diff(u'Unicode \u1010')))
320+
321+ def assertParses(self, expected, diff):
322+ diff_lines = diff.splitlines()
323+ self.assertEqual(
324+ [(css_class, row + 1, orig_row, mod_row, diff_lines[row])
325+ for row, (css_class, orig_row, mod_row) in enumerate(expected)],
326+ list(parse_diff(diff)))
327+
328+ def test_basic_bzr(self):
329+ # A basic Bazaar diff with a few different classes is parsed correctly.
330+ diff = dedent('''\
331+ === modified file 'tales.py'
332+ --- tales.py
333+ +++ tales.py
334+ @@ -2435,7 +2439,8 @@
335+ def method(self):
336+ - removed this line
337+ + added this line
338+ + added another line
339+ something in between
340+ -------- a sql style comment
341+ ++++++++ a line of pluses
342+ ########
343+ # A merge directive comment.
344+ ''')
345+ expected = [
346+ ('diff-file text', None, None),
347+ ('diff-header text', None, None),
348+ ('diff-header text', None, None),
349+ ('diff-chunk text', None, None),
350+ ('text', 2435, 2439),
351+ ('diff-removed text', 2436, None),
352+ ('diff-added text', None, 2440),
353+ ('diff-added text', None, 2441),
354+ ('text', 2437, 2442),
355+ ('diff-removed text', 2438, None),
356+ ('diff-added text', None, 2443),
357+ ('diff-comment text', None, None),
358+ ('diff-comment text', None, None),
359+ ]
360+ self.assertParses(expected, diff)
361+
362+ def test_basic_git(self):
363+ # A basic Git diff with a few different classes is parsed correctly.
364+ diff = dedent('''\
365+ diff --git a/tales.py b/tales.py
366+ index aaaaaaa..bbbbbbb 100644
367+ --- a/tales.py
368+ +++ b/tales.py
369+ @@ -2435,7 +2439,8 @@
370+ def method(self):
371+ - removed this line
372+ + added this line
373+ + added another line
374+ something in between
375+ -------- a sql style comment
376+ ++++++++ a line of pluses
377+ ''')
378+ expected = [
379+ ('diff-file text', None, None),
380+ ('diff-file text', None, None),
381+ ('diff-header text', None, None),
382+ ('diff-header text', None, None),
383+ ('diff-chunk text', None, None),
384+ ('text', 2435, 2439),
385+ ('diff-removed text', 2436, None),
386+ ('diff-added text', None, 2440),
387+ ('diff-added text', None, 2441),
388+ ('text', 2437, 2442),
389+ ('diff-removed text', 2438, None),
390+ ('diff-added text', None, 2443),
391+ ]
392+ self.assertParses(expected, diff)
393+
394+ def test_config_value_limits_line_count(self):
395+ # The config.diff.max_line_format contains the maximum number of
396+ # lines to parse.
397+ diff = dedent('''\
398+ === modified file 'tales.py'
399+ --- tales.py
400+ +++ tales.py
401+ @@ -2435,6 +2435,8 @@
402+ def method(self):
403+ - removed this line
404+ + added this line
405+ ########
406+ # A merge directive comment.
407+ ''')
408+ expected = [
409+ ('diff-file text', None, None),
410+ ('diff-header text', None, None),
411+ ('diff-header text', None, None),
412+ ]
413+ self.pushConfig("diff", max_format_lines=3)
414+ self.assertParses(expected, diff)
415+
416+ def test_multiple_hunks(self):
417+ # Diffs containing multiple hunks are parsed reasonably, and the
418+ # original and modified row numbers are adjusted for each hunk.
419+ diff = dedent('''\
420+ @@ -2,2 +2,3 @@
421+ a
422+ -b
423+ +c
424+ +d
425+ @@ -10,3 +11,2 @@
426+ -e
427+ f
428+ -g
429+ +h
430+ ''')
431+ expected = [
432+ ('diff-chunk text', None, None),
433+ ('text', 2, 2),
434+ ('diff-removed text', 3, None),
435+ ('diff-added text', None, 3),
436+ ('diff-added text', None, 4),
437+ ('diff-chunk text', None, None),
438+ ('diff-removed text', 10, None),
439+ ('text', 11, 11),
440+ ('diff-removed text', 12, None),
441+ ('diff-added text', None, 12),
442+ ]
443+ self.assertParses(expected, diff)
444+
445+
446+class TestParseDiffErrors(TestCaseWithFactory):
447+
448+ layer = ZopelessLayer
449+
450+ def assertParses(self, expected, diff):
451+ diff_lines = diff.splitlines()
452+ self.assertEqual(
453+ [(css_class, row + 1, orig_row, mod_row, diff_lines[row])
454+ for row, (css_class, orig_row, mod_row) in enumerate(expected)],
455+ list(parse_diff(diff)))
456+
457+ def test_bad_hunk_header(self):
458+ # A bad hunk header causes the parser to record an OOPS but continue
459+ # anyway.
460+ diff = dedent('''\
461+ @@ some nonsense @@
462+ def method(self):
463+ ''')
464+ expected = [
465+ ('diff-chunk text', None, None),
466+ ('text', 1, 1),
467+ ]
468+ self.assertParses(expected, diff)
469+ self.assertEqual('MalformedHunkHeader', self.oopses[0]['type'])
470+
471+
472 class TestDiffFormatter(TestCase):
473 """Test the string formatter fmt:diff."""
474
475@@ -308,16 +480,16 @@
476 '', FormattersAPI('').format_diff())
477
478 def test_almostEmptyString(self):
479- # White space doesn't count as empty, and is formtted.
480+ # White space doesn't count as empty, and is formatted.
481 self.assertEqual(
482- '<table class="diff"><tr id="diff-line-1">'
483+ '<table class="diff unidiff"><tr id="diff-line-1">'
484 '<td class="line-no">1</td><td class="text"> </td></tr></table>',
485 FormattersAPI(' ').format_diff())
486
487 def test_format_unicode(self):
488 # Sometimes the strings contain unicode, those should work too.
489 self.assertEqual(
490- u'<table class="diff"><tr id="diff-line-1">'
491+ u'<table class="diff unidiff"><tr id="diff-line-1">'
492 u'<td class="line-no">1</td><td class="text">'
493 u'Unicode \u1010</td></tr></table>',
494 FormattersAPI(u'Unicode \u1010').format_diff())
495@@ -391,24 +563,131 @@
496 'diff-added text'],
497 [str(tag['class']) for tag in text])
498
499- def test_config_value_limits_line_count(self):
500- # The config.diff.max_line_format contains the maximum number of lines
501- # to format.
502+
503+class TestSideBySideDiffFormatter(TestCase):
504+ """Test the string formatter fmt:ssdiff."""
505+
506+ def test_emptyString(self):
507+ # An empty string gives an empty string.
508+ self.assertEqual(
509+ '', FormattersAPI('').format_ssdiff())
510+
511+ def test_almostEmptyString(self):
512+ # White space doesn't count as empty, and is formatted.
513+ self.assertEqual(
514+ '<table class="diff ssdiff"><tr id="diff-line-1">'
515+ '<td class="line-no" style="display: none">1</td>'
516+ '<td class="ss-line-no">0</td>'
517+ '<td class="text"></td>'
518+ '<td class="ss-line-no">0</td>'
519+ '<td class="text"></td>'
520+ '</tr></table>',
521+ FormattersAPI(' ').format_ssdiff())
522+
523+ def test_format_unicode(self):
524+ # Sometimes the strings contain unicode, those should work too.
525+ self.assertEqual(
526+ u'<table class="diff ssdiff"><tr id="diff-line-1">'
527+ u'<td class="line-no" style="display: none">1</td>'
528+ u'<td class="ss-line-no">0</td>'
529+ u'<td class="text">Unicode \u1010</td>'
530+ u'<td class="ss-line-no">0</td>'
531+ u'<td class="text">Unicode \u1010</td>'
532+ u'</tr></table>',
533+ FormattersAPI(u'Unicode \u1010').format_ssdiff())
534+
535+ def test_cssClasses(self):
536+ # Different parts of the diff have different css classes.
537 diff = dedent('''\
538 === modified file 'tales.py'
539 --- tales.py
540 +++ tales.py
541- @@ -2435,6 +2435,8 @@
542- def format_diff(self):
543+ @@ -2435,7 +2439,8 @@
544+ def format_ssdiff(self):
545 - removed this line
546 + added this line
547+ + added another line
548+ something in between
549+ -------- a sql style comment
550+ ++++++++ a line of pluses
551 ########
552 # A merge directive comment.
553 ''')
554- self.pushConfig("diff", max_format_lines=3)
555- html = FormattersAPI(diff).format_diff()
556- line_count = html.count('<td class="line-no">')
557- self.assertEqual(3, line_count)
558+ html = FormattersAPI(diff).format_ssdiff()
559+ line_numbers = find_tags_by_class(html, 'line-no')
560+ self.assertEqual(
561+ ['1', '2', '3', '4', '5', '7', '8', '9', '11', '12', '13'],
562+ [tag.renderContents() for tag in line_numbers])
563+ ss_line_numbers = find_tags_by_class(html, 'ss-line-no')
564+ self.assertEqual(
565+ ['2435', '2439', '2436', '2440', '', '2441', '2437', '2442',
566+ '2438', '2443'],
567+ [tag.renderContents() for tag in ss_line_numbers])
568+ text = find_tags_by_class(html, 'text')
569+ self.assertEqual(
570+ ['diff-file text',
571+ 'diff-header text',
572+ 'diff-header text',
573+ 'diff-chunk text',
574+ 'text',
575+ 'text',
576+ 'diff-removed text',
577+ 'diff-added text',
578+ 'diff-removed text',
579+ 'diff-added text',
580+ 'text',
581+ 'text',
582+ 'diff-removed text',
583+ 'diff-added text',
584+ 'diff-comment text',
585+ 'diff-comment text'],
586+ [str(tag['class']) for tag in text])
587+
588+ def test_cssClasses_git(self):
589+ # Git diffs look slightly different, so check that they also end up
590+ # with the correct CSS classes.
591+ diff = dedent('''\
592+ diff --git a/tales.py b/tales.py
593+ index aaaaaaa..bbbbbbb 100644
594+ --- a/tales.py
595+ +++ b/tales.py
596+ @@ -2435,7 +2439,8 @@
597+ def format_ssdiff(self):
598+ - removed this line
599+ + added this line
600+ + added another line
601+ something in between
602+ -------- a sql style comment
603+ ++++++++ a line of pluses
604+ ''')
605+ html = FormattersAPI(diff).format_ssdiff()
606+ line_numbers = find_tags_by_class(html, 'line-no')
607+ self.assertEqual(
608+ ['1', '2', '3', '4', '5', '6', '8', '9', '10', '12'],
609+ [tag.renderContents() for tag in line_numbers])
610+ ss_line_numbers = find_tags_by_class(html, 'ss-line-no')
611+ self.assertEqual(
612+ ['2435', '2439', '2436', '2440', '', '2441', '2437', '2442',
613+ '2438', '2443'],
614+ [tag.renderContents() for tag in ss_line_numbers])
615+ text = find_tags_by_class(html, 'text')
616+ self.assertEqual(
617+ ['diff-file text',
618+ 'diff-file text',
619+ 'diff-header text',
620+ 'diff-header text',
621+ 'diff-chunk text',
622+ 'text',
623+ 'text',
624+ 'diff-removed text',
625+ 'diff-added text',
626+ 'diff-removed text',
627+ 'diff-added text',
628+ 'text',
629+ 'text',
630+ 'diff-removed text',
631+ 'diff-added text'],
632+ [str(tag['class']) for tag in text])
633
634
635 class TestOOPSFormatter(TestCase):
636
637=== modified file 'lib/lp/code/javascript/branchmergeproposal.inlinecomments.js'
638--- lib/lp/code/javascript/branchmergeproposal.inlinecomments.js 2015-06-02 01:39:48 +0000
639+++ lib/lp/code/javascript/branchmergeproposal.inlinecomments.js 2015-06-29 23:03:03 +0000
640@@ -1,4 +1,4 @@
641-/* Copyright 2014 Canonical Ltd. This software is licensed under the
642+/* Copyright 2014-2015 Canonical Ltd. This software is licensed under the
643 * GNU Affero General Public License version 3 (see the file LICENSE).
644 *
645 * Code for handling inline comments in diffs.
646@@ -161,9 +161,15 @@
647 comment_date;
648
649 if (comments_tr === null) {
650+ if (Y.all('table.ssdiff').size() > 0) {
651+ colspan = 4;
652+ } else {
653+ colspan = 2;
654+ }
655 comments_tr = Y.Node.create(
656 '<tr class="inline-comments">' +
657- '<td class="inline-comment" colspan="2"><div></div></td></tr>')
658+ '<td class="inline-comment" colspan="' + colspan + '">' +
659+ '<div></div></td></tr>')
660 .set('id', 'comments-diff-line-' + comment.line_number);
661 Y.one('#diff-line-' + comment.line_number)
662 .insert(comments_tr, 'after');
663@@ -649,6 +655,11 @@
664 var preview_diff_uri = (
665 LP.cache.context.web_link +
666 '/+preview-diff/' + previewdiff_id + '/+diff');
667+ var qs = window.location.search;
668+ var query = Y.QueryString.parse(qs.replace(/^[?]/, ''));
669+ if (query.ss) {
670+ preview_diff_uri += '?ss=1';
671+ }
672 this._updateDiff(preview_diff_uri);
673 },
674
675@@ -872,5 +883,6 @@
676
677 namespace.DiffNav = DiffNav;
678
679-}, '0.1', {requires: ['datatype-date', 'event', 'io', 'node', 'widget',
680+}, '0.1', {requires: ['datatype-date', 'event', 'io', 'node',
681+ 'querystring-parse', 'widget',
682 'lp.client', 'lp.ui.editor', 'lp.app.date']});
683
684=== modified file 'lib/lp/code/javascript/branchmergeproposal.reviewcomment.js'
685--- lib/lp/code/javascript/branchmergeproposal.reviewcomment.js 2015-06-23 12:33:26 +0000
686+++ lib/lp/code/javascript/branchmergeproposal.reviewcomment.js 2015-06-29 23:03:03 +0000
687@@ -1,4 +1,4 @@
688-/* Copyright 2009 Canonical Ltd. This software is licensed under the
689+/* Copyright 2009-2015 Canonical Ltd. This software is licensed under the
690 * GNU Affero General Public License version 3 (see the file LICENSE).
691 *
692 * Library for code review javascript.
693@@ -270,12 +270,16 @@
694
695 Y.extend(NumberToggle, Y.Widget, {
696 renderUI: function() {
697- var ui = Y.Node.create('<li><label>' +
698- '<input type="checkbox" checked="checked" id="show-no"/>' +
699- '&nbsp;Show line numbers</label></li>');
700- var ul = Y.one('#review-diff div div ul.horizontal');
701- if (ul) {
702- ul.appendChild(ui);
703+ var qs = window.location.search;
704+ var query = Y.QueryString.parse(qs.replace(/^[?]/, ''));
705+ if (!query.ss) {
706+ var ui = Y.Node.create('<li><label>' +
707+ '<input type="checkbox" checked="checked" id="show-no"/>' +
708+ '&nbsp;Show line numbers</label></li>');
709+ var ul = Y.one('#review-diff div div ul.horizontal');
710+ if (ul) {
711+ ul.appendChild(ui);
712+ }
713 }
714 },
715 bindUI: function() {
716@@ -288,5 +292,5 @@
717
718 namespace.NumberToggle = NumberToggle;
719
720-}, "0.1", {"requires": ["base", "widget", "lp.anim",
721+}, "0.1", {"requires": ["base", "querystring-parse", "widget", "lp.anim",
722 "lp.ui.formoverlay", "lp.app.picker", "lp.client"]});
723
724=== modified file 'lib/lp/code/stories/branches/xx-branchmergeproposals.txt'
725--- lib/lp/code/stories/branches/xx-branchmergeproposals.txt 2015-06-05 17:49:22 +0000
726+++ lib/lp/code/stories/branches/xx-branchmergeproposals.txt 2015-06-29 23:03:03 +0000
727@@ -560,7 +560,7 @@
728 The text of the review diff is in the page.
729
730 >>> print repr(extract_text(get_review_diff()))
731- u'Preview Diff\n[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk\nDownload diff\n1\n---\n2\n+++\n3\n@@ -0,0 +1 @@\n4\n+Fake Diff\u1010'
732+ u'Preview Diff\n[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk\nDownload diff\nSide-by-side diff\n1\n---\n2\n+++\n3\n@@ -0,0 +1 @@\n4\n+Fake Diff\u1010'
733
734 There is also a link to the diff URL, which is the preview diff URL plus
735 "+files/preview.diff". It redirects logged in users to the file in the
736
737=== modified file 'lib/lp/code/templates/branchmergeproposal-diff.pt'
738--- lib/lp/code/templates/branchmergeproposal-diff.pt 2015-05-28 08:35:20 +0000
739+++ lib/lp/code/templates/branchmergeproposal-diff.pt 2015-06-29 23:03:03 +0000
740@@ -14,10 +14,19 @@
741 Download diff
742 </a>
743 </li>
744+ <li tal:condition="not: request/ss|nothing">
745+ <a tal:attributes="href string:?ss=1">Side-by-side diff</a>
746+ </li>
747+ <li tal:condition="request/ss|nothing">
748+ <a tal:attributes="href string:?">Unified diff</a>
749+ </li>
750 </ul>
751 </div>
752 <div class="attachmentBody">
753- <tal:diff replace="structure view/preview_diff_text/fmt:diff" />
754+ <tal:diff condition="not: request/ss|nothing"
755+ replace="structure view/preview_diff_text/fmt:diff" />
756+ <tal:ssdiff condition="request/ss|nothing"
757+ replace="structure view/preview_diff_text/fmt:ssdiff" />
758 <tal:diff-not-available condition="not: view/diff_available">
759 The diff is not available at this time. You can reload the
760 page or download it.
761
762=== modified file 'lib/lp/code/templates/codereviewcomment-body.pt'
763--- lib/lp/code/templates/codereviewcomment-body.pt 2014-05-28 21:17:40 +0000
764+++ lib/lp/code/templates/codereviewcomment-body.pt 2015-06-29 23:03:03 +0000
765@@ -7,7 +7,12 @@
766 <tal:good-attachments repeat="attachment view/comment/display_attachments">
767 <div class="boardComment attachment">
768 <div class="boardCommentDetails filename"><a tal:content="attachment/filename" tal:attributes="href attachment/getURL"/></div>
769- <div class="boardCommentBody attachmentBody" tal:content="structure attachment/diff_text/fmt:diff"/>
770+ <div class="boardCommentBody attachmentBody"
771+ tal:condition="not: request/ss|nothing"
772+ tal:content="structure attachment/diff_text/fmt:diff"/>
773+ <div class="boardCommentBody attachmentBody"
774+ tal:condition="request/ss|nothing"
775+ tal:content="structure attachment/diff_text/fmt:ssdiff"/>
776 </div>
777 </tal:good-attachments>
778
779
780=== modified file 'lib/lp/code/templates/codereviewnewrevisions-footer.pt'
781--- lib/lp/code/templates/codereviewnewrevisions-footer.pt 2011-06-30 13:22:07 +0000
782+++ lib/lp/code/templates/codereviewnewrevisions-footer.pt 2015-06-29 23:03:03 +0000
783@@ -8,6 +8,11 @@
784 show_diff_expander python:True;">
785 <metal:landing-target use-macro="branch/@@+macros/branch-revisions"/>
786 </tal:revisions>
787- <tal:diff condition="context/diff" replace="structure context/diff/text/fmt:diff" />
788+ <tal:has-diff condition="context/diff">
789+ <tal:diff condition="not: request/ss|nothing"
790+ replace="structure context/diff/text/fmt:diff" />
791+ <tal:ssdiff condition="request/ss|nothing"
792+ replace="structure context/diff/text/fmt:ssdiff" />
793+ </tal:has-diff>
794
795 </tal:root>
796
797=== modified file 'lib/lp/services/features/templates/feature-changelog.pt'
798--- lib/lp/services/features/templates/feature-changelog.pt 2011-02-22 17:07:35 +0000
799+++ lib/lp/services/features/templates/feature-changelog.pt 2015-06-29 23:03:03 +0000
800@@ -35,7 +35,10 @@
801 <dd class="subordinate">
802 <p tal:content="change/comment">comment</p>
803
804- <div tal:content="structure change/diff/fmt:diff" />
805+ <div tal:condition="not: request/ss|nothing"
806+ tal:content="structure change/diff/fmt:diff" />
807+ <div tal:condition="request/ss|nothing"
808+ tal:content="structure change/diff/fmt:ssdiff" />
809 </dd>
810 </tal:item>
811 </dl>