##// END OF EJS Templates
diffs: adding inline comment toggle fixes #2884
lisaq -
r696:d77e3bd5 default
parent child Browse files
Show More
@@ -1,885 +1,886 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2016 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 """
23 23 Set of diffing helpers, previously part of vcs
24 24 """
25 25
26 26 import collections
27 27 import re
28 28 import difflib
29 29 import logging
30 30
31 31 from itertools import tee, imap
32 32
33 33 from pylons.i18n.translation import _
34 34
35 35 from rhodecode.lib.vcs.exceptions import VCSError
36 36 from rhodecode.lib.vcs.nodes import FileNode, SubModuleNode
37 37 from rhodecode.lib.vcs.backends.base import EmptyCommit
38 38 from rhodecode.lib.helpers import escape
39 39 from rhodecode.lib.utils2 import safe_unicode
40 40
41 41 log = logging.getLogger(__name__)
42 42
43 43 # define max context, a file with more than this numbers of lines is unusable
44 44 # in browser anyway
45 45 MAX_CONTEXT = 1024 * 1014
46 46
47 47
48 48 class OPS(object):
49 49 ADD = 'A'
50 50 MOD = 'M'
51 51 DEL = 'D'
52 52
53 53
54 54 def wrap_to_table(str_):
55 55 return '''<table class="code-difftable">
56 56 <tr class="line no-comment">
57 57 <td class="add-comment-line tooltip" title="%s"><span class="add-comment-content"></span></td>
58 <td></td>
58 59 <td class="lineno new"></td>
59 60 <td class="code no-comment"><pre>%s</pre></td>
60 61 </tr>
61 62 </table>''' % (_('Click to comment'), str_)
62 63
63 64
64 65 def wrapped_diff(filenode_old, filenode_new, diff_limit=None, file_limit=None,
65 66 show_full_diff=False, ignore_whitespace=True, line_context=3,
66 67 enable_comments=False):
67 68 """
68 69 returns a wrapped diff into a table, checks for cut_off_limit for file and
69 70 whole diff and presents proper message
70 71 """
71 72
72 73 if filenode_old is None:
73 74 filenode_old = FileNode(filenode_new.path, '', EmptyCommit())
74 75
75 76 if filenode_old.is_binary or filenode_new.is_binary:
76 77 diff = wrap_to_table(_('Binary file'))
77 78 stats = None
78 79 size = 0
79 80 data = None
80 81
81 82 elif diff_limit != -1 and (diff_limit is None or
82 83 (filenode_old.size < diff_limit and filenode_new.size < diff_limit)):
83 84
84 85 f_gitdiff = get_gitdiff(filenode_old, filenode_new,
85 86 ignore_whitespace=ignore_whitespace,
86 87 context=line_context)
87 88 diff_processor = DiffProcessor(
88 89 f_gitdiff, format='gitdiff', diff_limit=diff_limit,
89 90 file_limit=file_limit, show_full_diff=show_full_diff)
90 91 _parsed = diff_processor.prepare()
91 92
92 93 diff = diff_processor.as_html(enable_comments=enable_comments)
93 94 stats = _parsed[0]['stats'] if _parsed else None
94 95 size = len(diff or '')
95 96 data = _parsed[0] if _parsed else None
96 97 else:
97 98 diff = wrap_to_table(_('Changeset was too big and was cut off, use '
98 99 'diff menu to display this diff'))
99 100 stats = None
100 101 size = 0
101 102 data = None
102 103 if not diff:
103 104 submodules = filter(lambda o: isinstance(o, SubModuleNode),
104 105 [filenode_new, filenode_old])
105 106 if submodules:
106 107 diff = wrap_to_table(escape('Submodule %r' % submodules[0]))
107 108 else:
108 109 diff = wrap_to_table(_('No changes detected'))
109 110
110 111 cs1 = filenode_old.commit.raw_id
111 112 cs2 = filenode_new.commit.raw_id
112 113
113 114 return size, cs1, cs2, diff, stats, data
114 115
115 116
116 117 def get_gitdiff(filenode_old, filenode_new, ignore_whitespace=True, context=3):
117 118 """
118 119 Returns git style diff between given ``filenode_old`` and ``filenode_new``.
119 120
120 121 :param ignore_whitespace: ignore whitespaces in diff
121 122 """
122 123 # make sure we pass in default context
123 124 context = context or 3
124 125 # protect against IntOverflow when passing HUGE context
125 126 if context > MAX_CONTEXT:
126 127 context = MAX_CONTEXT
127 128
128 129 submodules = filter(lambda o: isinstance(o, SubModuleNode),
129 130 [filenode_new, filenode_old])
130 131 if submodules:
131 132 return ''
132 133
133 134 for filenode in (filenode_old, filenode_new):
134 135 if not isinstance(filenode, FileNode):
135 136 raise VCSError(
136 137 "Given object should be FileNode object, not %s"
137 138 % filenode.__class__)
138 139
139 140 repo = filenode_new.commit.repository
140 141 old_commit = filenode_old.commit or repo.EMPTY_COMMIT
141 142 new_commit = filenode_new.commit
142 143
143 144 vcs_gitdiff = repo.get_diff(
144 145 old_commit, new_commit, filenode_new.path,
145 146 ignore_whitespace, context, path1=filenode_old.path)
146 147 return vcs_gitdiff
147 148
148 149 NEW_FILENODE = 1
149 150 DEL_FILENODE = 2
150 151 MOD_FILENODE = 3
151 152 RENAMED_FILENODE = 4
152 153 COPIED_FILENODE = 5
153 154 CHMOD_FILENODE = 6
154 155 BIN_FILENODE = 7
155 156
156 157
157 158 class LimitedDiffContainer(object):
158 159
159 160 def __init__(self, diff_limit, cur_diff_size, diff):
160 161 self.diff = diff
161 162 self.diff_limit = diff_limit
162 163 self.cur_diff_size = cur_diff_size
163 164
164 165 def __getitem__(self, key):
165 166 return self.diff.__getitem__(key)
166 167
167 168 def __iter__(self):
168 169 for l in self.diff:
169 170 yield l
170 171
171 172
172 173 class Action(object):
173 174 """
174 175 Contains constants for the action value of the lines in a parsed diff.
175 176 """
176 177
177 178 ADD = 'add'
178 179 DELETE = 'del'
179 180 UNMODIFIED = 'unmod'
180 181
181 182 CONTEXT = 'context'
182 183
183 184
184 185 class DiffProcessor(object):
185 186 """
186 187 Give it a unified or git diff and it returns a list of the files that were
187 188 mentioned in the diff together with a dict of meta information that
188 189 can be used to render it in a HTML template.
189 190
190 191 .. note:: Unicode handling
191 192
192 193 The original diffs are a byte sequence and can contain filenames
193 194 in mixed encodings. This class generally returns `unicode` objects
194 195 since the result is intended for presentation to the user.
195 196
196 197 """
197 198 _chunk_re = re.compile(r'^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)')
198 199 _newline_marker = re.compile(r'^\\ No newline at end of file')
199 200
200 201 # used for inline highlighter word split
201 202 _token_re = re.compile(r'()(&gt;|&lt;|&amp;|\W+?)')
202 203
203 204 def __init__(self, diff, format='gitdiff', diff_limit=None,
204 205 file_limit=None, show_full_diff=True):
205 206 """
206 207 :param diff: A `Diff` object representing a diff from a vcs backend
207 208 :param format: format of diff passed, `udiff` or `gitdiff`
208 209 :param diff_limit: define the size of diff that is considered "big"
209 210 based on that parameter cut off will be triggered, set to None
210 211 to show full diff
211 212 """
212 213 self._diff = diff
213 214 self._format = format
214 215 self.adds = 0
215 216 self.removes = 0
216 217 # calculate diff size
217 218 self.diff_limit = diff_limit
218 219 self.file_limit = file_limit
219 220 self.show_full_diff = show_full_diff
220 221 self.cur_diff_size = 0
221 222 self.parsed = False
222 223 self.parsed_diff = []
223 224
224 225 if format == 'gitdiff':
225 226 self.differ = self._highlight_line_difflib
226 227 self._parser = self._parse_gitdiff
227 228 else:
228 229 self.differ = self._highlight_line_udiff
229 230 self._parser = self._parse_udiff
230 231
231 232 def _copy_iterator(self):
232 233 """
233 234 make a fresh copy of generator, we should not iterate thru
234 235 an original as it's needed for repeating operations on
235 236 this instance of DiffProcessor
236 237 """
237 238 self.__udiff, iterator_copy = tee(self.__udiff)
238 239 return iterator_copy
239 240
240 241 def _escaper(self, string):
241 242 """
242 243 Escaper for diff escapes special chars and checks the diff limit
243 244
244 245 :param string:
245 246 """
246 247
247 248 self.cur_diff_size += len(string)
248 249
249 250 if not self.show_full_diff and (self.cur_diff_size > self.diff_limit):
250 251 raise DiffLimitExceeded('Diff Limit Exceeded')
251 252
252 253 return safe_unicode(string)\
253 254 .replace('&', '&amp;')\
254 255 .replace('<', '&lt;')\
255 256 .replace('>', '&gt;')
256 257
257 258 def _line_counter(self, l):
258 259 """
259 260 Checks each line and bumps total adds/removes for this diff
260 261
261 262 :param l:
262 263 """
263 264 if l.startswith('+') and not l.startswith('+++'):
264 265 self.adds += 1
265 266 elif l.startswith('-') and not l.startswith('---'):
266 267 self.removes += 1
267 268 return safe_unicode(l)
268 269
269 270 def _highlight_line_difflib(self, line, next_):
270 271 """
271 272 Highlight inline changes in both lines.
272 273 """
273 274
274 275 if line['action'] == Action.DELETE:
275 276 old, new = line, next_
276 277 else:
277 278 old, new = next_, line
278 279
279 280 oldwords = self._token_re.split(old['line'])
280 281 newwords = self._token_re.split(new['line'])
281 282 sequence = difflib.SequenceMatcher(None, oldwords, newwords)
282 283
283 284 oldfragments, newfragments = [], []
284 285 for tag, i1, i2, j1, j2 in sequence.get_opcodes():
285 286 oldfrag = ''.join(oldwords[i1:i2])
286 287 newfrag = ''.join(newwords[j1:j2])
287 288 if tag != 'equal':
288 289 if oldfrag:
289 290 oldfrag = '<del>%s</del>' % oldfrag
290 291 if newfrag:
291 292 newfrag = '<ins>%s</ins>' % newfrag
292 293 oldfragments.append(oldfrag)
293 294 newfragments.append(newfrag)
294 295
295 296 old['line'] = "".join(oldfragments)
296 297 new['line'] = "".join(newfragments)
297 298
298 299 def _highlight_line_udiff(self, line, next_):
299 300 """
300 301 Highlight inline changes in both lines.
301 302 """
302 303 start = 0
303 304 limit = min(len(line['line']), len(next_['line']))
304 305 while start < limit and line['line'][start] == next_['line'][start]:
305 306 start += 1
306 307 end = -1
307 308 limit -= start
308 309 while -end <= limit and line['line'][end] == next_['line'][end]:
309 310 end -= 1
310 311 end += 1
311 312 if start or end:
312 313 def do(l):
313 314 last = end + len(l['line'])
314 315 if l['action'] == Action.ADD:
315 316 tag = 'ins'
316 317 else:
317 318 tag = 'del'
318 319 l['line'] = '%s<%s>%s</%s>%s' % (
319 320 l['line'][:start],
320 321 tag,
321 322 l['line'][start:last],
322 323 tag,
323 324 l['line'][last:]
324 325 )
325 326 do(line)
326 327 do(next_)
327 328
328 329 def _clean_line(self, line, command):
329 330 if command in ['+', '-', ' ']:
330 331 # only modify the line if it's actually a diff thing
331 332 line = line[1:]
332 333 return line
333 334
334 335 def _parse_gitdiff(self, inline_diff=True):
335 336 _files = []
336 337 diff_container = lambda arg: arg
337 338
338 339 for chunk in self._diff.chunks():
339 340 head = chunk.header
340 341
341 342 diff = imap(self._escaper, chunk.diff.splitlines(1))
342 343 raw_diff = chunk.raw
343 344 limited_diff = False
344 345 exceeds_limit = False
345 346
346 347 op = None
347 348 stats = {
348 349 'added': 0,
349 350 'deleted': 0,
350 351 'binary': False,
351 352 'ops': {},
352 353 }
353 354
354 355 if head['deleted_file_mode']:
355 356 op = OPS.DEL
356 357 stats['binary'] = True
357 358 stats['ops'][DEL_FILENODE] = 'deleted file'
358 359
359 360 elif head['new_file_mode']:
360 361 op = OPS.ADD
361 362 stats['binary'] = True
362 363 stats['ops'][NEW_FILENODE] = 'new file %s' % head['new_file_mode']
363 364 else: # modify operation, can be copy, rename or chmod
364 365
365 366 # CHMOD
366 367 if head['new_mode'] and head['old_mode']:
367 368 op = OPS.MOD
368 369 stats['binary'] = True
369 370 stats['ops'][CHMOD_FILENODE] = (
370 371 'modified file chmod %s => %s' % (
371 372 head['old_mode'], head['new_mode']))
372 373 # RENAME
373 374 if head['rename_from'] != head['rename_to']:
374 375 op = OPS.MOD
375 376 stats['binary'] = True
376 377 stats['ops'][RENAMED_FILENODE] = (
377 378 'file renamed from %s to %s' % (
378 379 head['rename_from'], head['rename_to']))
379 380 # COPY
380 381 if head.get('copy_from') and head.get('copy_to'):
381 382 op = OPS.MOD
382 383 stats['binary'] = True
383 384 stats['ops'][COPIED_FILENODE] = (
384 385 'file copied from %s to %s' % (
385 386 head['copy_from'], head['copy_to']))
386 387
387 388 # If our new parsed headers didn't match anything fallback to
388 389 # old style detection
389 390 if op is None:
390 391 if not head['a_file'] and head['b_file']:
391 392 op = OPS.ADD
392 393 stats['binary'] = True
393 394 stats['ops'][NEW_FILENODE] = 'new file'
394 395
395 396 elif head['a_file'] and not head['b_file']:
396 397 op = OPS.DEL
397 398 stats['binary'] = True
398 399 stats['ops'][DEL_FILENODE] = 'deleted file'
399 400
400 401 # it's not ADD not DELETE
401 402 if op is None:
402 403 op = OPS.MOD
403 404 stats['binary'] = True
404 405 stats['ops'][MOD_FILENODE] = 'modified file'
405 406
406 407 # a real non-binary diff
407 408 if head['a_file'] or head['b_file']:
408 409 try:
409 410 raw_diff, chunks, _stats = self._parse_lines(diff)
410 411 stats['binary'] = False
411 412 stats['added'] = _stats[0]
412 413 stats['deleted'] = _stats[1]
413 414 # explicit mark that it's a modified file
414 415 if op == OPS.MOD:
415 416 stats['ops'][MOD_FILENODE] = 'modified file'
416 417 exceeds_limit = len(raw_diff) > self.file_limit
417 418
418 419 # changed from _escaper function so we validate size of
419 420 # each file instead of the whole diff
420 421 # diff will hide big files but still show small ones
421 422 # from my tests, big files are fairly safe to be parsed
422 423 # but the browser is the bottleneck
423 424 if not self.show_full_diff and exceeds_limit:
424 425 raise DiffLimitExceeded('File Limit Exceeded')
425 426
426 427 except DiffLimitExceeded:
427 428 diff_container = lambda _diff: \
428 429 LimitedDiffContainer(
429 430 self.diff_limit, self.cur_diff_size, _diff)
430 431
431 432 exceeds_limit = len(raw_diff) > self.file_limit
432 433 limited_diff = True
433 434 chunks = []
434 435
435 436 else: # GIT format binary patch, or possibly empty diff
436 437 if head['bin_patch']:
437 438 # we have operation already extracted, but we mark simply
438 439 # it's a diff we wont show for binary files
439 440 stats['ops'][BIN_FILENODE] = 'binary diff hidden'
440 441 chunks = []
441 442
442 443 if chunks and not self.show_full_diff and op == OPS.DEL:
443 444 # if not full diff mode show deleted file contents
444 445 # TODO: anderson: if the view is not too big, there is no way
445 446 # to see the content of the file
446 447 chunks = []
447 448
448 449 chunks.insert(0, [{
449 450 'old_lineno': '',
450 451 'new_lineno': '',
451 452 'action': Action.CONTEXT,
452 453 'line': msg,
453 454 } for _op, msg in stats['ops'].iteritems()
454 455 if _op not in [MOD_FILENODE]])
455 456
456 457 _files.append({
457 458 'filename': safe_unicode(head['b_path']),
458 459 'old_revision': head['a_blob_id'],
459 460 'new_revision': head['b_blob_id'],
460 461 'chunks': chunks,
461 462 'raw_diff': safe_unicode(raw_diff),
462 463 'operation': op,
463 464 'stats': stats,
464 465 'exceeds_limit': exceeds_limit,
465 466 'is_limited_diff': limited_diff,
466 467 })
467 468
468 469 sorter = lambda info: {OPS.ADD: 0, OPS.MOD: 1,
469 470 OPS.DEL: 2}.get(info['operation'])
470 471
471 472 if not inline_diff:
472 473 return diff_container(sorted(_files, key=sorter))
473 474
474 475 # highlight inline changes
475 476 for diff_data in _files:
476 477 for chunk in diff_data['chunks']:
477 478 lineiter = iter(chunk)
478 479 try:
479 480 while 1:
480 481 line = lineiter.next()
481 482 if line['action'] not in (
482 483 Action.UNMODIFIED, Action.CONTEXT):
483 484 nextline = lineiter.next()
484 485 if nextline['action'] in ['unmod', 'context'] or \
485 486 nextline['action'] == line['action']:
486 487 continue
487 488 self.differ(line, nextline)
488 489 except StopIteration:
489 490 pass
490 491
491 492 return diff_container(sorted(_files, key=sorter))
492 493
493 494 def _parse_udiff(self, inline_diff=True):
494 495 raise NotImplementedError()
495 496
496 497 def _parse_lines(self, diff):
497 498 """
498 499 Parse the diff an return data for the template.
499 500 """
500 501
501 502 lineiter = iter(diff)
502 503 stats = [0, 0]
503 504 chunks = []
504 505 raw_diff = []
505 506
506 507 try:
507 508 line = lineiter.next()
508 509
509 510 while line:
510 511 raw_diff.append(line)
511 512 lines = []
512 513 chunks.append(lines)
513 514
514 515 match = self._chunk_re.match(line)
515 516
516 517 if not match:
517 518 break
518 519
519 520 gr = match.groups()
520 521 (old_line, old_end,
521 522 new_line, new_end) = [int(x or 1) for x in gr[:-1]]
522 523 old_line -= 1
523 524 new_line -= 1
524 525
525 526 context = len(gr) == 5
526 527 old_end += old_line
527 528 new_end += new_line
528 529
529 530 if context:
530 531 # skip context only if it's first line
531 532 if int(gr[0]) > 1:
532 533 lines.append({
533 534 'old_lineno': '...',
534 535 'new_lineno': '...',
535 536 'action': Action.CONTEXT,
536 537 'line': line,
537 538 })
538 539
539 540 line = lineiter.next()
540 541
541 542 while old_line < old_end or new_line < new_end:
542 543 command = ' '
543 544 if line:
544 545 command = line[0]
545 546
546 547 affects_old = affects_new = False
547 548
548 549 # ignore those if we don't expect them
549 550 if command in '#@':
550 551 continue
551 552 elif command == '+':
552 553 affects_new = True
553 554 action = Action.ADD
554 555 stats[0] += 1
555 556 elif command == '-':
556 557 affects_old = True
557 558 action = Action.DELETE
558 559 stats[1] += 1
559 560 else:
560 561 affects_old = affects_new = True
561 562 action = Action.UNMODIFIED
562 563
563 564 if not self._newline_marker.match(line):
564 565 old_line += affects_old
565 566 new_line += affects_new
566 567 lines.append({
567 568 'old_lineno': affects_old and old_line or '',
568 569 'new_lineno': affects_new and new_line or '',
569 570 'action': action,
570 571 'line': self._clean_line(line, command)
571 572 })
572 573 raw_diff.append(line)
573 574
574 575 line = lineiter.next()
575 576
576 577 if self._newline_marker.match(line):
577 578 # we need to append to lines, since this is not
578 579 # counted in the line specs of diff
579 580 lines.append({
580 581 'old_lineno': '...',
581 582 'new_lineno': '...',
582 583 'action': Action.CONTEXT,
583 584 'line': self._clean_line(line, command)
584 585 })
585 586
586 587 except StopIteration:
587 588 pass
588 589 return ''.join(raw_diff), chunks, stats
589 590
590 591 def _safe_id(self, idstring):
591 592 """Make a string safe for including in an id attribute.
592 593
593 594 The HTML spec says that id attributes 'must begin with
594 595 a letter ([A-Za-z]) and may be followed by any number
595 596 of letters, digits ([0-9]), hyphens ("-"), underscores
596 597 ("_"), colons (":"), and periods (".")'. These regexps
597 598 are slightly over-zealous, in that they remove colons
598 599 and periods unnecessarily.
599 600
600 601 Whitespace is transformed into underscores, and then
601 602 anything which is not a hyphen or a character that
602 603 matches \w (alphanumerics and underscore) is removed.
603 604
604 605 """
605 606 # Transform all whitespace to underscore
606 607 idstring = re.sub(r'\s', "_", '%s' % idstring)
607 608 # Remove everything that is not a hyphen or a member of \w
608 609 idstring = re.sub(r'(?!-)\W', "", idstring).lower()
609 610 return idstring
610 611
611 612 def prepare(self, inline_diff=True):
612 613 """
613 614 Prepare the passed udiff for HTML rendering.
614 615
615 616 :return: A list of dicts with diff information.
616 617 """
617 618 parsed = self._parser(inline_diff=inline_diff)
618 619 self.parsed = True
619 620 self.parsed_diff = parsed
620 621 return parsed
621 622
622 623 def as_raw(self, diff_lines=None):
623 624 """
624 625 Returns raw diff as a byte string
625 626 """
626 627 return self._diff.raw
627 628
628 629 def as_html(self, table_class='code-difftable', line_class='line',
629 630 old_lineno_class='lineno old', new_lineno_class='lineno new',
630 631 code_class='code', enable_comments=False, parsed_lines=None):
631 632 """
632 633 Return given diff as html table with customized css classes
633 634 """
634 635 def _link_to_if(condition, label, url):
635 636 """
636 637 Generates a link if condition is meet or just the label if not.
637 638 """
638 639
639 640 if condition:
640 641 return '''<a href="%(url)s" class="tooltip"
641 642 title="%(title)s">%(label)s</a>''' % {
642 643 'title': _('Click to select line'),
643 644 'url': url,
644 645 'label': label
645 646 }
646 647 else:
647 648 return label
648 649 if not self.parsed:
649 650 self.prepare()
650 651
651 652 diff_lines = self.parsed_diff
652 653 if parsed_lines:
653 654 diff_lines = parsed_lines
654 655
655 656 _html_empty = True
656 657 _html = []
657 658 _html.append('''<table class="%(table_class)s">\n''' % {
658 659 'table_class': table_class
659 660 })
660 661
661 662 for diff in diff_lines:
662 663 for line in diff['chunks']:
663 664 _html_empty = False
664 665 for change in line:
665 666 _html.append('''<tr class="%(lc)s %(action)s">\n''' % {
666 667 'lc': line_class,
667 668 'action': change['action']
668 669 })
669 670 anchor_old_id = ''
670 671 anchor_new_id = ''
671 672 anchor_old = "%(filename)s_o%(oldline_no)s" % {
672 673 'filename': self._safe_id(diff['filename']),
673 674 'oldline_no': change['old_lineno']
674 675 }
675 676 anchor_new = "%(filename)s_n%(oldline_no)s" % {
676 677 'filename': self._safe_id(diff['filename']),
677 678 'oldline_no': change['new_lineno']
678 679 }
679 680 cond_old = (change['old_lineno'] != '...' and
680 681 change['old_lineno'])
681 682 cond_new = (change['new_lineno'] != '...' and
682 683 change['new_lineno'])
683 684 if cond_old:
684 685 anchor_old_id = 'id="%s"' % anchor_old
685 686 if cond_new:
686 687 anchor_new_id = 'id="%s"' % anchor_new
687 688
688 689 if change['action'] != Action.CONTEXT:
689 690 anchor_link = True
690 691 else:
691 692 anchor_link = False
692 693
693 694 ###########################################################
694 # COMMENT ICON
695 # COMMENT ICONS
695 696 ###########################################################
696 697 _html.append('''\t<td class="add-comment-line"><span class="add-comment-content">''')
697 698
698 699 if enable_comments and change['action'] != Action.CONTEXT:
699 700 _html.append('''<a href="#"><span class="icon-comment-add"></span></a>''')
700 701
701 _html.append('''</span></td>\n''')
702 _html.append('''</span></td><td class="comment-toggle tooltip" title="Toggle Comments"><i class="icon-comment"></i></td>\n''')
702 703
703 704 ###########################################################
704 705 # OLD LINE NUMBER
705 706 ###########################################################
706 707 _html.append('''\t<td %(a_id)s class="%(olc)s">''' % {
707 708 'a_id': anchor_old_id,
708 709 'olc': old_lineno_class
709 710 })
710 711
711 712 _html.append('''%(link)s''' % {
712 713 'link': _link_to_if(anchor_link, change['old_lineno'],
713 714 '#%s' % anchor_old)
714 715 })
715 716 _html.append('''</td>\n''')
716 717 ###########################################################
717 718 # NEW LINE NUMBER
718 719 ###########################################################
719 720
720 721 _html.append('''\t<td %(a_id)s class="%(nlc)s">''' % {
721 722 'a_id': anchor_new_id,
722 723 'nlc': new_lineno_class
723 724 })
724 725
725 726 _html.append('''%(link)s''' % {
726 727 'link': _link_to_if(anchor_link, change['new_lineno'],
727 728 '#%s' % anchor_new)
728 729 })
729 730 _html.append('''</td>\n''')
730 731 ###########################################################
731 732 # CODE
732 733 ###########################################################
733 734 code_classes = [code_class]
734 735 if (not enable_comments or
735 736 change['action'] == Action.CONTEXT):
736 737 code_classes.append('no-comment')
737 738 _html.append('\t<td class="%s">' % ' '.join(code_classes))
738 739 _html.append('''\n\t\t<pre>%(code)s</pre>\n''' % {
739 740 'code': change['line']
740 741 })
741 742
742 743 _html.append('''\t</td>''')
743 744 _html.append('''\n</tr>\n''')
744 745 _html.append('''</table>''')
745 746 if _html_empty:
746 747 return None
747 748 return ''.join(_html)
748 749
749 750 def stat(self):
750 751 """
751 752 Returns tuple of added, and removed lines for this instance
752 753 """
753 754 return self.adds, self.removes
754 755
755 756 def get_context_of_line(
756 757 self, path, diff_line=None, context_before=3, context_after=3):
757 758 """
758 759 Returns the context lines for the specified diff line.
759 760
760 761 :type diff_line: :class:`DiffLineNumber`
761 762 """
762 763 assert self.parsed, "DiffProcessor is not initialized."
763 764
764 765 if None not in diff_line:
765 766 raise ValueError(
766 767 "Cannot specify both line numbers: {}".format(diff_line))
767 768
768 769 file_diff = self._get_file_diff(path)
769 770 chunk, idx = self._find_chunk_line_index(file_diff, diff_line)
770 771
771 772 first_line_to_include = max(idx - context_before, 0)
772 773 first_line_after_context = idx + context_after + 1
773 774 context_lines = chunk[first_line_to_include:first_line_after_context]
774 775
775 776 line_contents = [
776 777 _context_line(line) for line in context_lines
777 778 if _is_diff_content(line)]
778 779 # TODO: johbo: Interim fixup, the diff chunks drop the final newline.
779 780 # Once they are fixed, we can drop this line here.
780 781 if line_contents:
781 782 line_contents[-1] = (
782 783 line_contents[-1][0], line_contents[-1][1].rstrip('\n') + '\n')
783 784 return line_contents
784 785
785 786 def find_context(self, path, context, offset=0):
786 787 """
787 788 Finds the given `context` inside of the diff.
788 789
789 790 Use the parameter `offset` to specify which offset the target line has
790 791 inside of the given `context`. This way the correct diff line will be
791 792 returned.
792 793
793 794 :param offset: Shall be used to specify the offset of the main line
794 795 within the given `context`.
795 796 """
796 797 if offset < 0 or offset >= len(context):
797 798 raise ValueError(
798 799 "Only positive values up to the length of the context "
799 800 "minus one are allowed.")
800 801
801 802 matches = []
802 803 file_diff = self._get_file_diff(path)
803 804
804 805 for chunk in file_diff['chunks']:
805 806 context_iter = iter(context)
806 807 for line_idx, line in enumerate(chunk):
807 808 try:
808 809 if _context_line(line) == context_iter.next():
809 810 continue
810 811 except StopIteration:
811 812 matches.append((line_idx, chunk))
812 813 context_iter = iter(context)
813 814
814 815 # Increment position and triger StopIteration
815 816 # if we had a match at the end
816 817 line_idx += 1
817 818 try:
818 819 context_iter.next()
819 820 except StopIteration:
820 821 matches.append((line_idx, chunk))
821 822
822 823 effective_offset = len(context) - offset
823 824 found_at_diff_lines = [
824 825 _line_to_diff_line_number(chunk[idx - effective_offset])
825 826 for idx, chunk in matches]
826 827
827 828 return found_at_diff_lines
828 829
829 830 def _get_file_diff(self, path):
830 831 for file_diff in self.parsed_diff:
831 832 if file_diff['filename'] == path:
832 833 break
833 834 else:
834 835 raise FileNotInDiffException("File {} not in diff".format(path))
835 836 return file_diff
836 837
837 838 def _find_chunk_line_index(self, file_diff, diff_line):
838 839 for chunk in file_diff['chunks']:
839 840 for idx, line in enumerate(chunk):
840 841 if line['old_lineno'] == diff_line.old:
841 842 return chunk, idx
842 843 if line['new_lineno'] == diff_line.new:
843 844 return chunk, idx
844 845 raise LineNotInDiffException(
845 846 "The line {} is not part of the diff.".format(diff_line))
846 847
847 848
848 849 def _is_diff_content(line):
849 850 return line['action'] in (
850 851 Action.UNMODIFIED, Action.ADD, Action.DELETE)
851 852
852 853
853 854 def _context_line(line):
854 855 return (line['action'], line['line'])
855 856
856 857
857 858 DiffLineNumber = collections.namedtuple('DiffLineNumber', ['old', 'new'])
858 859
859 860
860 861 def _line_to_diff_line_number(line):
861 862 new_line_no = line['new_lineno'] or None
862 863 old_line_no = line['old_lineno'] or None
863 864 return DiffLineNumber(old=old_line_no, new=new_line_no)
864 865
865 866
866 867 class FileNotInDiffException(Exception):
867 868 """
868 869 Raised when the context for a missing file is requested.
869 870
870 871 If you request the context for a line in a file which is not part of the
871 872 given diff, then this exception is raised.
872 873 """
873 874
874 875
875 876 class LineNotInDiffException(Exception):
876 877 """
877 878 Raised when the context for a missing line is requested.
878 879
879 880 If you request the context for a line in a file and this line is not
880 881 part of the given diff, then this exception is raised.
881 882 """
882 883
883 884
884 885 class DiffLimitExceeded(Exception):
885 886 pass
@@ -1,78 +1,90 b''
1 1 div.diffblock .code-header .changeset_header > div {
2 2 margin: 0 @padding;
3 3 }
4 4
5 5
6 6 // Line select and comment
7 7 div.diffblock.margined.comm tr {
8 8 td {
9 9 position: relative;
10 10 }
11 11
12 12 .add-comment-line {
13 13 // Force td width for Firefox
14 14 width: 20px;
15 15
16 16 // TODO: anderson: fixing mouse-over bug.
17 17 // why was it vertical-align baseline in first place??
18 18 vertical-align: top !important;
19 19 // Force width and display for IE 9
20 20 .add-comment-content {
21 21 display: inline-block;
22 22 width: 20px;
23 23
24 24 a {
25 25 display: none;
26 26 position: absolute;
27 27 top: 2px;
28 28 left: 2px;
29 29 color: @grey3;
30 30 }
31 31 }
32 32 }
33 33
34 .comment-toggle {
35 display: inline-block;
36 visibility: hidden;
37 width: 20px;
38 color: @rcblue;
39
40 &.active {
41 visibility: visible;
42 cursor: pointer;
43 }
44 }
45
34 46 &.line {
35 47 &:hover, &.hover{
36 48 .add-comment-line a{
37 49 display: inline;
38 50 }
39 51 }
40 52
41 53 &.hover, &.selected {
42 54 &, del, ins {
43 55 background-color: lighten(@alert3, 10%) !important;
44 56 }
45 57 }
46 58
47 59 &.commenting {
48 60 &, del, ins {
49 61 background-image: none !important;
50 62 background-color: lighten(@alert4, 10%) !important;
51 63 }
52 64 }
53 65 }
54 66 }
55 67
56 68 .compare-header {
57 69 overflow-x: auto;
58 70 overflow-y: hidden;
59 71 clear: both;
60 72 padding: @padding;
61 73 background: @grey6;
62 74 border: @border-thickness solid @border-default-color;
63 75 .border-radius(@border-radius);
64 76
65 77 .compare-value,
66 78 .compare-label {
67 79 float: left;
68 80 display: inline-block;
69 81 min-width: 5em;
70 82 margin: 0;
71 83 padding: 0.9em 0.9em 0.9em 0;
72 84 }
73 85
74 86 .compare-label {
75 87 clear: both;
76 88 font-family: @text-semibold;
77 89 }
78 90 } No newline at end of file
@@ -1,658 +1,672 b''
1 1 // # Copyright (C) 2010-2016 RhodeCode GmbH
2 2 // #
3 3 // # This program is free software: you can redistribute it and/or modify
4 4 // # it under the terms of the GNU Affero General Public License, version 3
5 5 // # (only), as published by the Free Software Foundation.
6 6 // #
7 7 // # This program is distributed in the hope that it will be useful,
8 8 // # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 // # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 // # GNU General Public License for more details.
11 11 // #
12 12 // # You should have received a copy of the GNU Affero General Public License
13 13 // # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 // #
15 15 // # This program is dual-licensed. If you wish to learn more about the
16 16 // # RhodeCode Enterprise Edition, including its added features, Support services,
17 17 // # and proprietary license terms, please see https://rhodecode.com/licenses/
18 18
19 19 var firefoxAnchorFix = function() {
20 20 // hack to make anchor links behave properly on firefox, in our inline
21 21 // comments generation when comments are injected firefox is misbehaving
22 22 // when jumping to anchor links
23 23 if (location.href.indexOf('#') > -1) {
24 24 location.href += '';
25 25 }
26 26 };
27 27
28 28 // returns a node from given html;
29 29 var fromHTML = function(html){
30 30 var _html = document.createElement('element');
31 31 _html.innerHTML = html;
32 32 return _html;
33 33 };
34 34
35 35 var tableTr = function(cls, body){
36 36 var _el = document.createElement('div');
37 37 var _body = $(body).attr('id');
38 38 var comment_id = fromHTML(body).children[0].id.split('comment-')[1];
39 39 var id = 'comment-tr-{0}'.format(comment_id);
40 40 var _html = ('<table><tbody><tr id="{0}" class="{1}">'+
41 '<td class="add-comment-line"><span class="add-comment-content"></span></td>'+
41 '<td class="add-comment-line tooltip tooltip" title="Add Comment"><span class="add-comment-content"></span></td>'+
42 '<td></td>'+
42 43 '<td></td>'+
43 44 '<td></td>'+
44 45 '<td>{2}</td>'+
45 46 '</tr></tbody></table>').format(id, cls, body);
46 47 $(_el).html(_html);
47 48 return _el.children[0].children[0].children[0];
48 49 };
49 50
50 51 var removeInlineForm = function(form) {
51 52 form.parentNode.removeChild(form);
52 53 };
53 54
54 55 var createInlineForm = function(parent_tr, f_path, line) {
55 56 var tmpl = $('#comment-inline-form-template').html();
56 57 tmpl = tmpl.format(f_path, line);
57 58 var form = tableTr('comment-form-inline', tmpl);
58 59 var form_hide_button = $(form).find('.hide-inline-form');
59 60
60 61 $(form_hide_button).click(function(e) {
61 62 $('.inline-comments').removeClass('hide-comment-button');
62 63 var newtr = e.currentTarget.parentNode.parentNode.parentNode.parentNode.parentNode;
63 64 if ($(newtr.nextElementSibling).hasClass('inline-comments-button')) {
64 65 $(newtr.nextElementSibling).show();
65 66 }
66 67 $(newtr).parents('.comment-form-inline').remove();
67 68 $(parent_tr).removeClass('form-open');
68 69 $(parent_tr).removeClass('hl-comment');
69 70 });
70 71
71 72 return form;
72 73 };
73 74
74 75 var getLineNo = function(tr) {
75 76 var line;
76 77 // Try to get the id and return "" (empty string) if it doesn't exist
77 78 var o = ($(tr).find('.lineno.old').attr('id')||"").split('_');
78 79 var n = ($(tr).find('.lineno.new').attr('id')||"").split('_');
79 80 if (n.length >= 2) {
80 81 line = n[n.length-1];
81 82 } else if (o.length >= 2) {
82 83 line = o[o.length-1];
83 84 }
84 85 return line;
85 86 };
86 87
87 88 /**
88 89 * make a single inline comment and place it inside
89 90 */
90 91 var renderInlineComment = function(json_data, show_add_button) {
91 92 show_add_button = typeof show_add_button !== 'undefined' ? show_add_button : true;
92 93 try {
93 94 var html = json_data.rendered_text;
94 95 var lineno = json_data.line_no;
95 96 var target_id = json_data.target_id;
96 97 placeInline(target_id, lineno, html, show_add_button);
97 98 } catch (e) {
98 99 console.error(e);
99 100 }
100 101 };
101 102
102 103 function bindDeleteCommentButtons() {
103 104 $('.delete-comment').one('click', function() {
104 105 var comment_id = $(this).data("comment-id");
105 106
106 107 if (comment_id){
107 108 deleteComment(comment_id);
108 109 }
109 110 });
110 111 }
111 112
112 113 /**
113 114 * Inject inline comment for on given TR this tr should be always an .line
114 115 * tr containing the line. Code will detect comment, and always put the comment
115 116 * block at the very bottom
116 117 */
117 118 var injectInlineForm = function(tr){
118 119 if (!$(tr).hasClass('line')) {
119 120 return;
120 121 }
121 122
122 123 var _td = $(tr).find('.code').get(0);
123 124 if ($(tr).hasClass('form-open') ||
124 125 $(tr).hasClass('context') ||
125 126 $(_td).hasClass('no-comment')) {
126 127 return;
127 128 }
128 129 $(tr).addClass('form-open');
129 130 $(tr).addClass('hl-comment');
130 131 var node = $(tr.parentNode.parentNode.parentNode).find('.full_f_path').get(0);
131 132 var f_path = $(node).attr('path');
132 133 var lineno = getLineNo(tr);
133 134 var form = createInlineForm(tr, f_path, lineno);
134 135
135 136 var parent = tr;
136 137 while (1) {
137 138 var n = parent.nextElementSibling;
138 139 // next element are comments !
139 140 if ($(n).hasClass('inline-comments')) {
140 141 parent = n;
141 142 }
142 143 else {
143 144 break;
144 145 }
145 146 }
146 147 var _parent = $(parent).get(0);
147 148 $(_parent).after(form);
148 149 $('.comment-form-inline').prev('.inline-comments').addClass('hide-comment-button');
149 150 var f = $(form).get(0);
150 151
151 152 var _form = $(f).find('.inline-form').get(0);
152 153
153 154 var pullRequestId = templateContext.pull_request_data.pull_request_id;
154 155 var commitId = templateContext.commit_data.commit_id;
155 156
156 157 var commentForm = new CommentForm(_form, commitId, pullRequestId, lineno, false);
157 158 var cm = commentForm.getCmInstance();
158 159
159 160 // set a CUSTOM submit handler for inline comments.
160 161 commentForm.setHandleFormSubmit(function(o) {
161 162 var text = commentForm.cm.getValue();
162 163
163 164 if (text === "") {
164 165 return;
165 166 }
166 167
167 168 if (lineno === undefined) {
168 169 alert('missing line !');
169 170 return;
170 171 }
171 172 if (f_path === undefined) {
172 173 alert('missing file path !');
173 174 return;
174 175 }
175 176
176 177 var excludeCancelBtn = false;
177 178 var submitEvent = true;
178 179 commentForm.setActionButtonsDisabled(true, excludeCancelBtn, submitEvent);
179 180 commentForm.cm.setOption("readOnly", true);
180 181 var postData = {
181 182 'text': text,
182 183 'f_path': f_path,
183 184 'line': lineno,
184 185 'csrf_token': CSRF_TOKEN
185 186 };
186 187 var submitSuccessCallback = function(o) {
187 188 $(tr).removeClass('form-open');
188 189 removeInlineForm(f);
189 190 renderInlineComment(o);
190 191 $('.inline-comments').removeClass('hide-comment-button');
191 192
192 193 // re trigger the linkification of next/prev navigation
193 194 linkifyComments($('.inline-comment-injected'));
194 195 timeagoActivate();
195 196 bindDeleteCommentButtons();
196 197 commentForm.setActionButtonsDisabled(false);
197 198
198 199 };
199 200 var submitFailCallback = function(){
200 201 commentForm.resetCommentFormState(text)
201 202 };
202 203 commentForm.submitAjaxPOST(
203 204 commentForm.submitUrl, postData, submitSuccessCallback, submitFailCallback);
204 205 });
205 206
206 207 setTimeout(function() {
207 208 // callbacks
208 209 if (cm !== undefined) {
209 210 cm.focus();
210 211 }
211 212 }, 10);
212 213
213 214 $.Topic('/ui/plugins/code/comment_form_built').prepareOrPublish({
214 215 form:_form,
215 216 parent:_parent,
216 217 lineno: lineno,
217 218 f_path: f_path}
218 219 );
219 220 };
220 221
221 222 var deleteComment = function(comment_id) {
222 223 var url = AJAX_COMMENT_DELETE_URL.replace('__COMMENT_ID__', comment_id);
223 224 var postData = {
224 225 '_method': 'delete',
225 226 'csrf_token': CSRF_TOKEN
226 227 };
227 228
228 229 var success = function(o) {
229 230 window.location.reload();
230 231 };
231 232 ajaxPOST(url, postData, success);
232 233 };
233 234
234 235 var createInlineAddButton = function(tr){
235 236 var label = _gettext('Add another comment');
236 237 var html_el = document.createElement('div');
237 238 $(html_el).addClass('add-comment');
238 239 html_el.innerHTML = '<span class="btn btn-secondary">{0}</span>'.format(label);
239 240 var add = new $(html_el);
240 241 add.on('click', function(e) {
241 242 injectInlineForm(tr);
242 243 });
243 244 return add;
244 245 };
245 246
246 247 var placeAddButton = function(target_tr){
247 248 if(!target_tr){
248 249 return;
249 250 }
250 251 var last_node = target_tr;
251 252 // scan
252 253 while (1){
253 254 var n = last_node.nextElementSibling;
254 255 // next element are comments !
255 256 if($(n).hasClass('inline-comments')){
256 257 last_node = n;
257 258 // also remove the comment button from previous
258 259 var comment_add_buttons = $(last_node).find('.add-comment');
259 260 for(var i=0; i<comment_add_buttons.length; i++){
260 261 var b = comment_add_buttons[i];
261 262 b.parentNode.removeChild(b);
262 263 }
263 264 }
264 265 else{
265 266 break;
266 267 }
267 268 }
268 269 var add = createInlineAddButton(target_tr);
269 270 // get the comment div
270 271 var comment_block = $(last_node).find('.comment')[0];
271 272 // attach add button
272 273 $(add).insertAfter(comment_block);
273 274 };
274 275
275 276 /**
276 277 * Places the inline comment into the changeset block in proper line position
277 278 */
278 279 var placeInline = function(target_container, lineno, html, show_add_button) {
279 280 show_add_button = typeof show_add_button !== 'undefined' ? show_add_button : true;
280 281
281 282 var lineid = "{0}_{1}".format(target_container, lineno);
282 283 var target_line = $('#' + lineid).get(0);
283 284 var comment = new $(tableTr('inline-comments', html));
284 285 // check if there are comments already !
285 286 if (target_line) {
286 287 var parent_node = target_line.parentNode;
287 288 var root_parent = parent_node;
288 289
289 290 while (1) {
290 291 var n = parent_node.nextElementSibling;
291 292 // next element are comments !
292 293 if ($(n).hasClass('inline-comments')) {
293 294 parent_node = n;
294 295 }
295 296 else {
296 297 break;
297 298 }
298 299 }
299 300 // put in the comment at the bottom
300 301 $(comment).insertAfter(parent_node);
301 302 $(comment).find('.comment-inline').addClass('inline-comment-injected');
302 303 // scan nodes, and attach add button to last one
303 304 if (show_add_button) {
304 305 placeAddButton(root_parent);
305 306 }
307 addCommentToggle(target_line);
306 308 }
307 309
308 310 return target_line;
309 311 };
310 312
313 var addCommentToggle = function(target_line) {
314 // exposes comment toggle button
315 $(target_line).siblings('.comment-toggle').addClass('active');
316 return;
317 };
318
319 var bindToggleButtons = function() {
320 $('.comment-toggle').on('click', function() {
321 $(this).parent().nextUntil('tr.line').toggle('inline-comments');
322 });
323 };
324
311 325 var linkifyComments = function(comments) {
312 326
313 327 for (var i = 0; i < comments.length; i++) {
314 328 var comment_id = $(comments[i]).data('comment-id');
315 329 var prev_comment_id = $(comments[i - 1]).data('comment-id');
316 330 var next_comment_id = $(comments[i + 1]).data('comment-id');
317 331
318 332 // place next/prev links
319 333 if (prev_comment_id) {
320 334 $('#prev_c_' + comment_id).show();
321 335 $('#prev_c_' + comment_id + " a.arrow_comment_link").attr(
322 336 'href', '#comment-' + prev_comment_id).removeClass('disabled');
323 337 }
324 338 if (next_comment_id) {
325 339 $('#next_c_' + comment_id).show();
326 340 $('#next_c_' + comment_id + " a.arrow_comment_link").attr(
327 341 'href', '#comment-' + next_comment_id).removeClass('disabled');
328 342 }
329 343 // place a first link to the total counter
330 344 if (i === 0) {
331 345 $('#inline-comments-counter').attr('href', '#comment-' + comment_id);
332 346 }
333 347 }
334 348
335 349 };
336 350
337 351 /**
338 352 * Iterates over all the inlines, and places them inside proper blocks of data
339 353 */
340 354 var renderInlineComments = function(file_comments, show_add_button) {
341 355 show_add_button = typeof show_add_button !== 'undefined' ? show_add_button : true;
342 356
343 357 for (var i = 0; i < file_comments.length; i++) {
344 358 var box = file_comments[i];
345 359
346 360 var target_id = $(box).attr('target_id');
347 361
348 362 // actually comments with line numbers
349 363 var comments = box.children;
350 364
351 365 for (var j = 0; j < comments.length; j++) {
352 366 var data = {
353 367 'rendered_text': comments[j].outerHTML,
354 368 'line_no': $(comments[j]).attr('line'),
355 369 'target_id': target_id
356 370 };
357 371 renderInlineComment(data, show_add_button);
358 372 }
359 373 }
360 374
361 375 // since order of injection is random, we're now re-iterating
362 376 // from correct order and filling in links
363 377 linkifyComments($('.inline-comment-injected'));
364 378 bindDeleteCommentButtons();
365 379 firefoxAnchorFix();
366 380 };
367 381
368 382
369 383 /* Comment form for main and inline comments */
370 384 var CommentForm = (function() {
371 385 "use strict";
372 386
373 387 function CommentForm(formElement, commitId, pullRequestId, lineNo, initAutocompleteActions) {
374 388
375 389 this.withLineNo = function(selector) {
376 390 var lineNo = this.lineNo;
377 391 if (lineNo === undefined) {
378 392 return selector
379 393 } else {
380 394 return selector + '_' + lineNo;
381 395 }
382 396 };
383 397
384 398 this.commitId = commitId;
385 399 this.pullRequestId = pullRequestId;
386 400 this.lineNo = lineNo;
387 401 this.initAutocompleteActions = initAutocompleteActions;
388 402
389 403 this.previewButton = this.withLineNo('#preview-btn');
390 404 this.previewContainer = this.withLineNo('#preview-container');
391 405
392 406 this.previewBoxSelector = this.withLineNo('#preview-box');
393 407
394 408 this.editButton = this.withLineNo('#edit-btn');
395 409 this.editContainer = this.withLineNo('#edit-container');
396 410
397 411 this.cancelButton = this.withLineNo('#cancel-btn');
398 412
399 413 this.statusChange = '#change_status';
400 414 this.cmBox = this.withLineNo('#text');
401 415 this.cm = initCommentBoxCodeMirror(this.cmBox, this.initAutocompleteActions);
402 416
403 417 this.submitForm = formElement;
404 418 this.submitButton = $(this.submitForm).find('input[type="submit"]');
405 419 this.submitButtonText = this.submitButton.val();
406 420
407 421 this.previewUrl = pyroutes.url('changeset_comment_preview',
408 422 {'repo_name': templateContext.repo_name});
409 423
410 424 // based on commitId, or pullReuqestId decide where do we submit
411 425 // out data
412 426 if (this.commitId){
413 427 this.submitUrl = pyroutes.url('changeset_comment',
414 428 {'repo_name': templateContext.repo_name,
415 429 'revision': this.commitId});
416 430
417 431 } else if (this.pullRequestId) {
418 432 this.submitUrl = pyroutes.url('pullrequest_comment',
419 433 {'repo_name': templateContext.repo_name,
420 434 'pull_request_id': this.pullRequestId});
421 435
422 436 } else {
423 437 throw new Error(
424 438 'CommentForm requires pullRequestId, or commitId to be specified.')
425 439 }
426 440
427 441 this.getCmInstance = function(){
428 442 return this.cm
429 443 };
430 444
431 445 var self = this;
432 446
433 447 this.getCommentStatus = function() {
434 448 return $(this.submitForm).find(this.statusChange).val();
435 449 };
436 450
437 451 this.isAllowedToSubmit = function() {
438 452 return !$(this.submitButton).prop('disabled');
439 453 };
440 454
441 455 this.initStatusChangeSelector = function(){
442 456 var formatChangeStatus = function(state, escapeMarkup) {
443 457 var originalOption = state.element;
444 458 return '<div class="flag_status ' + $(originalOption).data('status') + ' pull-left"></div>' +
445 459 '<span>' + escapeMarkup(state.text) + '</span>';
446 460 };
447 461 var formatResult = function(result, container, query, escapeMarkup) {
448 462 return formatChangeStatus(result, escapeMarkup);
449 463 };
450 464
451 465 var formatSelection = function(data, container, escapeMarkup) {
452 466 return formatChangeStatus(data, escapeMarkup);
453 467 };
454 468
455 469 $(this.submitForm).find(this.statusChange).select2({
456 470 placeholder: _gettext('Status Review'),
457 471 formatResult: formatResult,
458 472 formatSelection: formatSelection,
459 473 containerCssClass: "drop-menu status_box_menu",
460 474 dropdownCssClass: "drop-menu-dropdown",
461 475 dropdownAutoWidth: true,
462 476 minimumResultsForSearch: -1
463 477 });
464 478 $(this.submitForm).find(this.statusChange).on('change', function() {
465 479 var status = self.getCommentStatus();
466 480 if (status && !self.lineNo) {
467 481 $(self.submitButton).prop('disabled', false);
468 482 }
469 483 //todo, fix this name
470 484 var placeholderText = _gettext('Comment text will be set automatically based on currently selected status ({0}) ...').format(status);
471 485 self.cm.setOption('placeholder', placeholderText);
472 486 })
473 487 };
474 488
475 489 // reset the comment form into it's original state
476 490 this.resetCommentFormState = function(content) {
477 491 content = content || '';
478 492
479 493 $(this.editContainer).show();
480 494 $(this.editButton).hide();
481 495
482 496 $(this.previewContainer).hide();
483 497 $(this.previewButton).show();
484 498
485 499 this.setActionButtonsDisabled(true);
486 500 self.cm.setValue(content);
487 501 self.cm.setOption("readOnly", false);
488 502 };
489 503
490 504 this.submitAjaxPOST = function(url, postData, successHandler, failHandler) {
491 505 failHandler = failHandler || function() {};
492 506 var postData = toQueryString(postData);
493 507 var request = $.ajax({
494 508 url: url,
495 509 type: 'POST',
496 510 data: postData,
497 511 headers: {'X-PARTIAL-XHR': true}
498 512 })
499 513 .done(function(data) {
500 514 successHandler(data);
501 515 })
502 516 .fail(function(data, textStatus, errorThrown){
503 517 alert(
504 518 "Error while submitting comment.\n" +
505 519 "Error code {0} ({1}).".format(data.status, data.statusText));
506 520 failHandler()
507 521 });
508 522 return request;
509 523 };
510 524
511 525 // overwrite a submitHandler, we need to do it for inline comments
512 526 this.setHandleFormSubmit = function(callback) {
513 527 this.handleFormSubmit = callback;
514 528 };
515 529
516 530 // default handler for for submit for main comments
517 531 this.handleFormSubmit = function() {
518 532 var text = self.cm.getValue();
519 533 var status = self.getCommentStatus();
520 534
521 535 if (text === "" && !status) {
522 536 return;
523 537 }
524 538
525 539 var excludeCancelBtn = false;
526 540 var submitEvent = true;
527 541 self.setActionButtonsDisabled(true, excludeCancelBtn, submitEvent);
528 542 self.cm.setOption("readOnly", true);
529 543 var postData = {
530 544 'text': text,
531 545 'changeset_status': status,
532 546 'csrf_token': CSRF_TOKEN
533 547 };
534 548
535 549 var submitSuccessCallback = function(o) {
536 550 if (status) {
537 551 location.reload(true);
538 552 } else {
539 553 $('#injected_page_comments').append(o.rendered_text);
540 554 self.resetCommentFormState();
541 555 bindDeleteCommentButtons();
542 556 timeagoActivate();
543 557 }
544 558 };
545 559 var submitFailCallback = function(){
546 560 self.resetCommentFormState(text)
547 561 };
548 562 self.submitAjaxPOST(
549 563 self.submitUrl, postData, submitSuccessCallback, submitFailCallback);
550 564 };
551 565
552 566 this.previewSuccessCallback = function(o) {
553 567 $(self.previewBoxSelector).html(o);
554 568 $(self.previewBoxSelector).removeClass('unloaded');
555 569
556 570 // swap buttons
557 571 $(self.previewButton).hide();
558 572 $(self.editButton).show();
559 573
560 574 // unlock buttons
561 575 self.setActionButtonsDisabled(false);
562 576 };
563 577
564 578 this.setActionButtonsDisabled = function(state, excludeCancelBtn, submitEvent) {
565 579 excludeCancelBtn = excludeCancelBtn || false;
566 580 submitEvent = submitEvent || false;
567 581
568 582 $(this.editButton).prop('disabled', state);
569 583 $(this.previewButton).prop('disabled', state);
570 584
571 585 if (!excludeCancelBtn) {
572 586 $(this.cancelButton).prop('disabled', state);
573 587 }
574 588
575 589 var submitState = state;
576 590 if (!submitEvent && this.getCommentStatus() && !this.lineNo) {
577 591 // if the value of commit review status is set, we allow
578 592 // submit button, but only on Main form, lineNo means inline
579 593 submitState = false
580 594 }
581 595 $(this.submitButton).prop('disabled', submitState);
582 596 if (submitEvent) {
583 597 $(this.submitButton).val(_gettext('Submitting...'));
584 598 } else {
585 599 $(this.submitButton).val(this.submitButtonText);
586 600 }
587 601
588 602 };
589 603
590 604 // lock preview/edit/submit buttons on load, but exclude cancel button
591 605 var excludeCancelBtn = true;
592 606 this.setActionButtonsDisabled(true, excludeCancelBtn);
593 607
594 608 // anonymous users don't have access to initialized CM instance
595 609 if (this.cm !== undefined){
596 610 this.cm.on('change', function(cMirror) {
597 611 if (cMirror.getValue() === "") {
598 612 self.setActionButtonsDisabled(true, excludeCancelBtn)
599 613 } else {
600 614 self.setActionButtonsDisabled(false, excludeCancelBtn)
601 615 }
602 616 });
603 617 }
604 618
605 619 $(this.editButton).on('click', function(e) {
606 620 e.preventDefault();
607 621
608 622 $(self.previewButton).show();
609 623 $(self.previewContainer).hide();
610 624 $(self.editButton).hide();
611 625 $(self.editContainer).show();
612 626
613 627 });
614 628
615 629 $(this.previewButton).on('click', function(e) {
616 630 e.preventDefault();
617 631 var text = self.cm.getValue();
618 632
619 633 if (text === "") {
620 634 return;
621 635 }
622 636
623 637 var postData = {
624 638 'text': text,
625 639 'renderer': DEFAULT_RENDERER,
626 640 'csrf_token': CSRF_TOKEN
627 641 };
628 642
629 643 // lock ALL buttons on preview
630 644 self.setActionButtonsDisabled(true);
631 645
632 646 $(self.previewBoxSelector).addClass('unloaded');
633 647 $(self.previewBoxSelector).html(_gettext('Loading ...'));
634 648 $(self.editContainer).hide();
635 649 $(self.previewContainer).show();
636 650
637 651 // by default we reset state of comment preserving the text
638 652 var previewFailCallback = function(){
639 653 self.resetCommentFormState(text)
640 654 };
641 655 self.submitAjaxPOST(
642 656 self.previewUrl, postData, self.previewSuccessCallback, previewFailCallback);
643 657
644 658 });
645 659
646 660 $(this.submitForm).submit(function(e) {
647 661 e.preventDefault();
648 662 var allowedToSubmit = self.isAllowedToSubmit();
649 663 if (!allowedToSubmit){
650 664 return false;
651 665 }
652 666 self.handleFormSubmit();
653 667 });
654 668
655 669 }
656 670
657 671 return CommentForm;
658 672 })();
@@ -1,312 +1,312 b''
1 1 ## -*- coding: utf-8 -*-
2 2 ## usage:
3 3 ## <%namespace name="comment" file="/changeset/changeset_file_comment.html"/>
4 4 ## ${comment.comment_block(comment)}
5 5 ##
6 6 <%namespace name="base" file="/base/base.html"/>
7 7
8 8 <%def name="comment_block(comment, inline=False)">
9 9 <div class="comment ${'comment-inline' if inline else ''}" id="comment-${comment.comment_id}" line="${comment.line_no}" data-comment-id="${comment.comment_id}">
10 10 <div class="meta">
11 11 <div class="author">
12 12 ${base.gravatar_with_user(comment.author.email, 16)}
13 13 </div>
14 14 <div class="date">
15 15 ${h.age_component(comment.modified_at, time_is_local=True)}
16 16 </div>
17 17 <div class="status-change">
18 18 %if comment.pull_request:
19 19 <a href="${h.url('pullrequest_show',repo_name=comment.pull_request.target_repo.repo_name,pull_request_id=comment.pull_request.pull_request_id)}">
20 20 %if comment.status_change:
21 21 ${_('Vote on pull request #%s') % comment.pull_request.pull_request_id}:
22 22 %else:
23 23 ${_('Comment on pull request #%s') % comment.pull_request.pull_request_id}
24 24 %endif
25 25 </a>
26 26 %else:
27 27 %if comment.status_change:
28 28 ${_('Status change on commit')}:
29 29 %else:
30 30 ${_('Comment on commit')}
31 31 %endif
32 32 %endif
33 33 </div>
34 34 %if comment.status_change:
35 35 <div class="${'flag_status %s' % comment.status_change[0].status}"></div>
36 36 <div title="${_('Commit status')}" class="changeset-status-lbl">
37 37 ${comment.status_change[0].status_lbl}
38 38 </div>
39 39 %endif
40 40 <a class="permalink" href="#comment-${comment.comment_id}"> &para;</a>
41 41
42 42
43 43 <div class="comment-links-block">
44 44
45 45 ## show delete comment if it's not a PR (regular comments) or it's PR that is not closed
46 46 ## only super-admin, repo admin OR comment owner can delete
47 47 %if not comment.pull_request or (comment.pull_request and not comment.pull_request.is_closed()):
48 48 %if h.HasPermissionAny('hg.admin')() or h.HasRepoPermissionAny('repository.admin')(c.repo_name) or comment.author.user_id == c.rhodecode_user.user_id:
49 49 <div onClick="deleteComment(${comment.comment_id})" class="delete-comment"> ${_('Delete')}</div>
50 50 %if inline:
51 51 <div class="comment-links-divider"> | </div>
52 52 %endif
53 53 %endif
54 54 %endif
55 55
56 56 %if inline:
57 57
58 58 <div id="prev_c_${comment.comment_id}" class="comment-previous-link" title="${_('Previous comment')}">
59 59 <a class="arrow_comment_link disabled"><i class="icon-left"></i></a>
60 60 </div>
61 61
62 62 <div id="next_c_${comment.comment_id}" class="comment-next-link" title="${_('Next comment')}">
63 63 <a class="arrow_comment_link disabled"><i class="icon-right"></i></a>
64 64 </div>
65 65 %endif
66 66
67 67 </div>
68 68 </div>
69 69 <div class="text">
70 70 ${comment.render(mentions=True)|n}
71 71 </div>
72 72 </div>
73 73 </%def>
74 74
75 75 <%def name="comment_block_outdated(comment)">
76 76 <div class="comments" id="comment-${comment.comment_id}">
77 77 <div class="comment comment-wrapp">
78 78 <div class="meta">
79 79 <div class="author">
80 80 ${base.gravatar_with_user(comment.author.email, 16)}
81 81 </div>
82 82 <div class="date">
83 83 ${h.age_component(comment.modified_at, time_is_local=True)}
84 84 </div>
85 85 %if comment.status_change:
86 86 <span class="changeset-status-container">
87 87 <span class="changeset-status-ico">
88 88 <div class="${'flag_status %s' % comment.status_change[0].status}"></div>
89 89 </span>
90 90 <span title="${_('Commit status')}" class="changeset-status-lbl"> ${comment.status_change[0].status_lbl}</span>
91 91 </span>
92 92 %endif
93 93 <a class="permalink" href="#comment-${comment.comment_id}">&para;</a>
94 94 ## show delete comment if it's not a PR (regular comments) or it's PR that is not closed
95 95 ## only super-admin, repo admin OR comment owner can delete
96 96 %if not comment.pull_request or (comment.pull_request and not comment.pull_request.is_closed()):
97 97 <div class="comment-links-block">
98 98 %if h.HasPermissionAny('hg.admin')() or h.HasRepoPermissionAny('repository.admin')(c.repo_name) or comment.author.user_id == c.rhodecode_user.user_id:
99 99 <div data-comment-id=${comment.comment_id} class="delete-comment">${_('Delete')}</div>
100 100 %endif
101 101 </div>
102 102 %endif
103 103 </div>
104 104 <div class="text">
105 105 ${comment.render(mentions=True)|n}
106 106 </div>
107 107 </div>
108 108 </div>
109 109 </%def>
110 110
111 111 <%def name="comment_inline_form()">
112 112 <div id="comment-inline-form-template" style="display: none;">
113 113 <div class="comment-inline-form ac">
114 114 %if c.rhodecode_user.username != h.DEFAULT_USER:
115 115 ${h.form('#', class_='inline-form', method='get')}
116 116 <div id="edit-container_{1}" class="clearfix">
117 117 <div class="comment-title pull-left">
118 118 ${_('Create a comment on line {1}.')}
119 119 </div>
120 120 <div class="comment-help pull-right">
121 121 ${(_('Comments parsed using %s syntax with %s support.') % (
122 122 ('<a href="%s">%s</a>' % (h.url('%s_help' % c.visual.default_renderer), c.visual.default_renderer.upper())),
123 123 ('<span class="tooltip" title="%s">@mention</span>' % _('Use @username inside this text to send notification to this RhodeCode user'))
124 124 )
125 125 )|n
126 126 }
127 127 </div>
128 128 <div style="clear: both"></div>
129 129 <textarea id="text_{1}" name="text" class="comment-block-ta ac-input"></textarea>
130 130 </div>
131 131 <div id="preview-container_{1}" class="clearfix" style="display: none;">
132 132 <div class="comment-help">
133 133 ${_('Comment preview')}
134 134 </div>
135 135 <div id="preview-box_{1}" class="preview-box"></div>
136 136 </div>
137 137 <div class="comment-footer">
138 138 <div class="comment-button hide-inline-form-button cancel-button">
139 139 ${h.reset('hide-inline-form', _('Cancel'), class_='btn hide-inline-form', id_="cancel-btn_{1}")}
140 140 </div>
141 141 <div class="action-buttons">
142 142 <input type="hidden" name="f_path" value="{0}">
143 143 <input type="hidden" name="line" value="{1}">
144 144 <button id="preview-btn_{1}" class="btn btn-secondary">${_('Preview')}</button>
145 145 <button id="edit-btn_{1}" class="btn btn-secondary" style="display: none;">${_('Edit')}</button>
146 146 ${h.submit('save', _('Comment'), class_='btn btn-success save-inline-form')}
147 147 </div>
148 148 ${h.end_form()}
149 149 </div>
150 150 %else:
151 151 ${h.form('', class_='inline-form comment-form-login', method='get')}
152 152 <div class="pull-left">
153 153 <div class="comment-help pull-right">
154 154 ${_('You need to be logged in to comment.')} <a href="${h.route_path('login', _query={'came_from': h.url.current()})}">${_('Login now')}</a>
155 155 </div>
156 156 </div>
157 157 <div class="comment-button pull-right">
158 158 ${h.reset('hide-inline-form', _('Hide'), class_='btn hide-inline-form')}
159 159 </div>
160 160 <div class="clearfix"></div>
161 161 ${h.end_form()}
162 162 %endif
163 163 </div>
164 164 </div>
165 165 </%def>
166 166
167 167
168 168 ## generates inlines taken from c.comments var
169 169 <%def name="inlines(is_pull_request=False)">
170 170 %if is_pull_request:
171 171 <h2 id="comments">${ungettext("%d Pull Request Comment", "%d Pull Request Comments", len(c.comments)) % len(c.comments)}</h2>
172 172 %else:
173 173 <h2 id="comments">${ungettext("%d Commit Comment", "%d Commit Comments", len(c.comments)) % len(c.comments)}</h2>
174 174 %endif
175 175 %for path, lines_comments in c.inline_comments:
176 176 % for line, comments in lines_comments.iteritems():
177 177 <div style="display: none;" class="inline-comment-placeholder" path="${path}" target_id="${h.safeid(h.safe_unicode(path))}">
178 178 ## for each comment in particular line
179 179 %for comment in comments:
180 180 ${comment_block(comment, inline=True)}
181 181 %endfor
182 182 </div>
183 183 %endfor
184 184 %endfor
185 185
186 186 </%def>
187 187
188 188 ## generate inline comments and the main ones
189 189 <%def name="generate_comments(include_pull_request=False, is_pull_request=False)">
190 190 ## generate inlines for this changeset
191 191 ${inlines(is_pull_request)}
192 192
193 193 %for comment in c.comments:
194 194 <div id="comment-tr-${comment.comment_id}">
195 195 ## only render comments that are not from pull request, or from
196 196 ## pull request and a status change
197 197 %if not comment.pull_request or (comment.pull_request and comment.status_change) or include_pull_request:
198 198 ${comment_block(comment)}
199 199 %endif
200 200 </div>
201 201 %endfor
202 202 ## to anchor ajax comments
203 203 <div id="injected_page_comments"></div>
204 204 </%def>
205 205
206 206 ## MAIN COMMENT FORM
207 207 <%def name="comments(post_url, cur_status, is_pull_request=False, is_compare=False, change_status=True, form_extras=None)">
208 208 %if is_compare:
209 209 <% form_id = "comments_form_compare" %>
210 210 %else:
211 211 <% form_id = "comments_form" %>
212 212 %endif
213 213
214 214
215 215 %if is_pull_request:
216 216 <div class="pull-request-merge">
217 217 %if c.allowed_to_merge:
218 218 <div class="pull-request-wrap">
219 219 <div class="pull-right">
220 220 ${h.secure_form(url('pullrequest_merge', repo_name=c.repo_name, pull_request_id=c.pull_request.pull_request_id), id='merge_pull_request_form')}
221 221 <span data-role="merge-message">${c.pr_merge_msg} ${c.approval_msg if c.approval_msg else ''}</span>
222 222 <% merge_disabled = ' disabled' if c.pr_merge_status is False else '' %>
223 223 <input type="submit" id="merge_pull_request" value="${_('Merge Pull Request')}" class="btn${merge_disabled}"${merge_disabled}>
224 224 ${h.end_form()}
225 225 </div>
226 226 </div>
227 227 %else:
228 228 <div class="pull-request-wrap">
229 229 <div class="pull-right">
230 230 <span>${c.pr_merge_msg} ${c.approval_msg if c.approval_msg else ''}</span>
231 231 </div>
232 232 </div>
233 233 %endif
234 234 </div>
235 235 %endif
236 236 <div class="comments">
237 237 %if c.rhodecode_user.username != h.DEFAULT_USER:
238 238 <div class="comment-form ac">
239 239 ${h.secure_form(post_url, id_=form_id)}
240 240 <div id="edit-container" class="clearfix">
241 241 <div class="comment-title pull-left">
242 242 %if is_pull_request:
243 243 ${(_('Create a comment on this Pull Request.'))}
244 244 %elif is_compare:
245 245 ${(_('Create comments on this Commit range.'))}
246 246 %else:
247 247 ${(_('Create a comment on this Commit.'))}
248 248 %endif
249 249 </div>
250 250 <div class="comment-help pull-right">
251 251 ${(_('Comments parsed using %s syntax with %s support.') % (
252 252 ('<a href="%s">%s</a>' % (h.url('%s_help' % c.visual.default_renderer), c.visual.default_renderer.upper())),
253 253 ('<span class="tooltip" title="%s">@mention</span>' % _('Use @username inside this text to send notification to this RhodeCode user'))
254 254 )
255 255 )|n
256 256 }
257 257 </div>
258 258 <div style="clear: both"></div>
259 259 ${h.textarea('text', class_="comment-block-ta")}
260 260 </div>
261 261
262 262 <div id="preview-container" class="clearfix" style="display: none;">
263 263 <div class="comment-title">
264 264 ${_('Comment preview')}
265 265 </div>
266 266 <div id="preview-box" class="preview-box"></div>
267 267 </div>
268 268
269 269 <div id="comment_form_extras">
270 270 %if form_extras and isinstance(form_extras, (list, tuple)):
271 271 % for form_ex_el in form_extras:
272 272 ${form_ex_el|n}
273 273 % endfor
274 274 %endif
275 275 </div>
276 276 <div class="comment-footer">
277 277 %if change_status:
278 278 <div class="status_box">
279 279 <select id="change_status" name="changeset_status">
280 280 <option></option> # Placeholder
281 281 %for status,lbl in c.commit_statuses:
282 282 <option value="${status}" data-status="${status}">${lbl}</option>
283 283 %if is_pull_request and change_status and status in ('approved', 'rejected'):
284 284 <option value="${status}_closed" data-status="${status}">${lbl} & ${_('Closed')}</option>
285 285 %endif
286 286 %endfor
287 287 </select>
288 288 </div>
289 289 %endif
290 290 <div class="action-buttons">
291 291 <button id="preview-btn" class="btn btn-secondary">${_('Preview')}</button>
292 292 <button id="edit-btn" class="btn btn-secondary" style="display:none;">${_('Edit')}</button>
293 293 <div class="comment-button">${h.submit('save', _('Comment'), class_="btn btn-success comment-button-input")}</div>
294 294 </div>
295 295 </div>
296 296 ${h.end_form()}
297 297 </div>
298 298 %endif
299 299 </div>
300 300 <script>
301 301 // init active elements of commentForm
302 302 var commitId = templateContext.commit_data.commit_id;
303 303 var pullRequestId = templateContext.pull_request_data.pull_request_id;
304 304 var lineNo;
305 305
306 306 var mainCommentForm = new CommentForm(
307 307 "#${form_id}", commitId, pullRequestId, lineNo, true);
308 308
309 309 mainCommentForm.initStatusChangeSelector();
310
310 bindToggleButtons();
311 311 </script>
312 312 </%def>
General Comments 0
You need to be logged in to leave comments. Login now