Merge lp:~cjwatson/launchpad/side-by-side-diff into lp:launchpad
- side-by-side-diff
- Merge into devel
| 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 |
| Related bugs: |
| Reviewer | Review Type | Date Requested | Status |
|---|---|---|---|
| William Grant | code | 2015-06-23 | Approve on 2015-06-29 |
|
Review via email:
|
|||
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.
Preview Diff
| 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 | - ' 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 | + ' 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> |
