diffs: replace compare controller with new html based diffs:

* side/side + unified support
* redesign of diff changes/operations
* added button to see file before the change
* auto collapses large diffs

refs #4232
This commit is contained in:
Daniel Dourvaris 2016-10-19 14:29:51 +03:00
parent 067756822f
commit 12ebe4382a
15 changed files with 3405 additions and 149 deletions

View file

@ -14,6 +14,8 @@ permission notice:
file:licenses/tornado_license.txt
Copyright (c) 2015 - pygments-markdown-lexer
file:licenses/pygments_markdown_lexer_license.txt
Copyright 2006 - diff_match_patch
file:licenses/diff_match_patch_license.txt
All licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

View file

@ -0,0 +1,14 @@
Copyright 2006 Google Inc.
http://code.google.com/p/google-diff-match-patch/
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -31,13 +31,14 @@ from pylons.i18n.translation import _
from rhodecode.controllers.utils import parse_path_ref, get_commit_from_ref_name
from rhodecode.lib import helpers as h
from rhodecode.lib import diffs
from rhodecode.lib import diffs, codeblocks
from rhodecode.lib.auth import LoginRequired, HasRepoPermissionAnyDecorator
from rhodecode.lib.base import BaseRepoController, render
from rhodecode.lib.utils import safe_str
from rhodecode.lib.utils2 import safe_unicode, str2bool
from rhodecode.lib.vcs.exceptions import (
EmptyRepositoryError, RepositoryError, RepositoryRequirementError)
EmptyRepositoryError, RepositoryError, RepositoryRequirementError,
NodeDoesNotExistError)
from rhodecode.model.db import Repository, ChangesetStatus
log = logging.getLogger(__name__)
@ -78,7 +79,7 @@ class CompareController(BaseRepoController):
def index(self, repo_name):
c.compare_home = True
c.commit_ranges = []
c.files = []
c.diffset = None
c.limited_diff = False
source_repo = c.rhodecode_db_repo.repo_name
target_repo = request.GET.get('target_repo', source_repo)
@ -239,28 +240,23 @@ class CompareController(BaseRepoController):
commit1=source_commit, commit2=target_commit,
path1=source_path, path=target_path)
diff_processor = diffs.DiffProcessor(
txtdiff, format='gitdiff', diff_limit=diff_limit,
txtdiff, format='newdiff', diff_limit=diff_limit,
file_limit=file_limit, show_full_diff=c.fulldiff)
_parsed = diff_processor.prepare()
c.limited_diff = False
if isinstance(_parsed, diffs.LimitedDiffContainer):
c.limited_diff = True
def _node_getter(commit):
""" Returns a function that returns a node for a commit or None """
def get_node(fname):
try:
return commit.get_node(fname)
except NodeDoesNotExistError:
return None
return get_node
c.files = []
c.changes = {}
c.lines_added = 0
c.lines_deleted = 0
for f in _parsed:
st = f['stats']
if not st['binary']:
c.lines_added += st['added']
c.lines_deleted += st['deleted']
fid = h.FID('', f['filename'])
c.files.append([fid, f['operation'], f['filename'], f['stats'], f])
htmldiff = diff_processor.as_html(
enable_comments=False, parsed_lines=[f])
c.changes[fid] = [f['operation'], f['filename'], htmldiff, f]
c.diffset = codeblocks.DiffSet(
source_node_getter=_node_getter(source_commit),
target_node_getter=_node_getter(target_commit),
).render_patchset(_parsed, source_ref, target_ref)
c.preview_mode = merge

View file

@ -19,13 +19,17 @@
# and proprietary license terms, please see https://rhodecode.com/licenses/
import logging
import difflib
from itertools import groupby
from pygments import lex
from pygments.formatters.html import _get_ttype_class as pygment_token_class
from rhodecode.lib.helpers import get_lexer_for_filenode, html_escape
from rhodecode.lib.helpers import (
get_lexer_for_filenode, get_lexer_safe, html_escape)
from rhodecode.lib.utils2 import AttributeDict
from rhodecode.lib.vcs.nodes import FileNode
from rhodecode.lib.diff_match_patch import diff_match_patch
from rhodecode.lib.diffs import LimitedDiffContainer
from pygments.lexers import get_lexer_by_name
plain_text_lexer = get_lexer_by_name(
@ -38,7 +42,7 @@ log = logging.getLogger()
def filenode_as_lines_tokens(filenode, lexer=None):
lexer = lexer or get_lexer_for_filenode(filenode)
log.debug('Generating file node pygment tokens for %s, %s', lexer, filenode)
tokens = tokenize_string(filenode.content, get_lexer_for_filenode(filenode))
tokens = tokenize_string(filenode.content, lexer)
lines = split_token_stream(tokens, split_string='\n')
rv = list(lines)
return rv
@ -146,7 +150,11 @@ def render_tokenstream(tokenstream):
result.append(u'<%s>' % op_tag)
escaped_text = html_escape(token_text)
escaped_text = escaped_text.replace('\n', '<nl>\n</nl>')
# TODO: dan: investigate showing hidden characters like space/nl/tab
# escaped_text = escaped_text.replace(' ', '<sp> </sp>')
# escaped_text = escaped_text.replace('\n', '<nl>\n</nl>')
# escaped_text = escaped_text.replace('\t', '<tab>\t</tab>')
result.append(escaped_text)
@ -212,3 +220,416 @@ def rollup_tokenstream(tokenstream):
ops.append((token_op, ''.join(text_buffer)))
result.append((token_class, ops))
return result
def tokens_diff(old_tokens, new_tokens, use_diff_match_patch=True):
"""
Converts a list of (token_class, token_text) tuples to a list of
(token_class, token_op, token_text) tuples where token_op is one of
('ins', 'del', '')
:param old_tokens: list of (token_class, token_text) tuples of old line
:param new_tokens: list of (token_class, token_text) tuples of new line
:param use_diff_match_patch: boolean, will use google's diff match patch
library which has options to 'smooth' out the character by character
differences making nicer ins/del blocks
"""
old_tokens_result = []
new_tokens_result = []
similarity = difflib.SequenceMatcher(None,
''.join(token_text for token_class, token_text in old_tokens),
''.join(token_text for token_class, token_text in new_tokens)
).ratio()
if similarity < 0.6: # return, the blocks are too different
for token_class, token_text in old_tokens:
old_tokens_result.append((token_class, '', token_text))
for token_class, token_text in new_tokens:
new_tokens_result.append((token_class, '', token_text))
return old_tokens_result, new_tokens_result, similarity
token_sequence_matcher = difflib.SequenceMatcher(None,
[x[1] for x in old_tokens],
[x[1] for x in new_tokens])
for tag, o1, o2, n1, n2 in token_sequence_matcher.get_opcodes():
# check the differences by token block types first to give a more
# nicer "block" level replacement vs character diffs
if tag == 'equal':
for token_class, token_text in old_tokens[o1:o2]:
old_tokens_result.append((token_class, '', token_text))
for token_class, token_text in new_tokens[n1:n2]:
new_tokens_result.append((token_class, '', token_text))
elif tag == 'delete':
for token_class, token_text in old_tokens[o1:o2]:
old_tokens_result.append((token_class, 'del', token_text))
elif tag == 'insert':
for token_class, token_text in new_tokens[n1:n2]:
new_tokens_result.append((token_class, 'ins', token_text))
elif tag == 'replace':
# if same type token blocks must be replaced, do a diff on the
# characters in the token blocks to show individual changes
old_char_tokens = []
new_char_tokens = []
for token_class, token_text in old_tokens[o1:o2]:
for char in token_text:
old_char_tokens.append((token_class, char))
for token_class, token_text in new_tokens[n1:n2]:
for char in token_text:
new_char_tokens.append((token_class, char))
old_string = ''.join([token_text for
token_class, token_text in old_char_tokens])
new_string = ''.join([token_text for
token_class, token_text in new_char_tokens])
char_sequence = difflib.SequenceMatcher(
None, old_string, new_string)
copcodes = char_sequence.get_opcodes()
obuffer, nbuffer = [], []
if use_diff_match_patch:
dmp = diff_match_patch()
dmp.Diff_EditCost = 11 # TODO: dan: extract this to a setting
reps = dmp.diff_main(old_string, new_string)
dmp.diff_cleanupEfficiency(reps)
a, b = 0, 0
for op, rep in reps:
l = len(rep)
if op == 0:
for i, c in enumerate(rep):
obuffer.append((old_char_tokens[a+i][0], '', c))
nbuffer.append((new_char_tokens[b+i][0], '', c))
a += l
b += l
elif op == -1:
for i, c in enumerate(rep):
obuffer.append((old_char_tokens[a+i][0], 'del', c))
a += l
elif op == 1:
for i, c in enumerate(rep):
nbuffer.append((new_char_tokens[b+i][0], 'ins', c))
b += l
else:
for ctag, co1, co2, cn1, cn2 in copcodes:
if ctag == 'equal':
for token_class, token_text in old_char_tokens[co1:co2]:
obuffer.append((token_class, '', token_text))
for token_class, token_text in new_char_tokens[cn1:cn2]:
nbuffer.append((token_class, '', token_text))
elif ctag == 'delete':
for token_class, token_text in old_char_tokens[co1:co2]:
obuffer.append((token_class, 'del', token_text))
elif ctag == 'insert':
for token_class, token_text in new_char_tokens[cn1:cn2]:
nbuffer.append((token_class, 'ins', token_text))
elif ctag == 'replace':
for token_class, token_text in old_char_tokens[co1:co2]:
obuffer.append((token_class, 'del', token_text))
for token_class, token_text in new_char_tokens[cn1:cn2]:
nbuffer.append((token_class, 'ins', token_text))
old_tokens_result.extend(obuffer)
new_tokens_result.extend(nbuffer)
return old_tokens_result, new_tokens_result, similarity
class DiffSet(object):
"""
An object for parsing the diff result from diffs.DiffProcessor and
adding highlighting, side by side/unified renderings and line diffs
"""
HL_REAL = 'REAL' # highlights using original file, slow
HL_FAST = 'FAST' # highlights using just the line, fast but not correct
# in the case of multiline code
HL_NONE = 'NONE' # no highlighting, fastest
def __init__(self, highlight_mode=HL_REAL,
source_node_getter=lambda filename: None,
target_node_getter=lambda filename: None,
source_nodes=None, target_nodes=None,
max_file_size_limit=150 * 1024, # files over this size will
# use fast highlighting
):
self.highlight_mode = highlight_mode
self.highlighted_filenodes = {}
self.source_node_getter = source_node_getter
self.target_node_getter = target_node_getter
self.source_nodes = source_nodes or {}
self.target_nodes = target_nodes or {}
self.max_file_size_limit = max_file_size_limit
def render_patchset(self, patchset, source_ref=None, target_ref=None):
diffset = AttributeDict(dict(
lines_added=0,
lines_deleted=0,
changed_files=0,
files=[],
limited_diff=isinstance(patchset, LimitedDiffContainer),
source_ref=source_ref,
target_ref=target_ref,
))
for patch in patchset:
filediff = self.render_patch(patch)
filediff.diffset = diffset
diffset.files.append(filediff)
diffset.changed_files += 1
if not patch['stats']['binary']:
diffset.lines_added += patch['stats']['added']
diffset.lines_deleted += patch['stats']['deleted']
return diffset
_lexer_cache = {}
def _get_lexer_for_filename(self, filename):
# cached because we might need to call it twice for source/target
if filename not in self._lexer_cache:
self._lexer_cache[filename] = get_lexer_safe(filepath=filename)
return self._lexer_cache[filename]
def render_patch(self, patch):
log.debug('rendering diff for %r' % patch['filename'])
source_filename = patch['original_filename']
target_filename = patch['filename']
source_lexer = plain_text_lexer
target_lexer = plain_text_lexer
if not patch['stats']['binary']:
if self.highlight_mode == self.HL_REAL:
if (source_filename and patch['operation'] in ('D', 'M')
and source_filename not in self.source_nodes):
self.source_nodes[source_filename] = (
self.source_node_getter(source_filename))
if (target_filename and patch['operation'] in ('A', 'M')
and target_filename not in self.target_nodes):
self.target_nodes[target_filename] = (
self.target_node_getter(target_filename))
elif self.highlight_mode == self.HL_FAST:
source_lexer = self._get_lexer_for_filename(source_filename)
target_lexer = self._get_lexer_for_filename(target_filename)
source_file = self.source_nodes.get(source_filename, source_filename)
target_file = self.target_nodes.get(target_filename, target_filename)
source_filenode, target_filenode = None, None
# TODO: dan: FileNode.lexer works on the content of the file - which
# can be slow - issue #4289 explains a lexer clean up - which once
# done can allow caching a lexer for a filenode to avoid the file lookup
if isinstance(source_file, FileNode):
source_filenode = source_file
source_lexer = source_file.lexer
if isinstance(target_file, FileNode):
target_filenode = target_file
target_lexer = target_file.lexer
source_file_path, target_file_path = None, None
if source_filename != '/dev/null':
source_file_path = source_filename
if target_filename != '/dev/null':
target_file_path = target_filename
source_file_type = source_lexer.name
target_file_type = target_lexer.name
op_hunks = patch['chunks'][0]
hunks = patch['chunks'][1:]
filediff = AttributeDict({
'source_file_path': source_file_path,
'target_file_path': target_file_path,
'source_filenode': source_filenode,
'target_filenode': target_filenode,
'hunks': [],
'source_file_type': target_file_type,
'target_file_type': source_file_type,
'patch': patch,
'source_mode': patch['stats']['old_mode'],
'target_mode': patch['stats']['new_mode'],
'limited_diff': isinstance(patch, LimitedDiffContainer),
'diffset': self,
})
for hunk in hunks:
hunkbit = self.parse_hunk(hunk, source_file, target_file)
hunkbit.filediff = filediff
filediff.hunks.append(hunkbit)
return filediff
def parse_hunk(self, hunk, source_file, target_file):
result = AttributeDict(dict(
source_start=hunk['source_start'],
source_length=hunk['source_length'],
target_start=hunk['target_start'],
target_length=hunk['target_length'],
section_header=hunk['section_header'],
lines=[],
))
before, after = [], []
for line in hunk['lines']:
if line['action'] == 'unmod':
result.lines.extend(
self.parse_lines(before, after, source_file, target_file))
after.append(line)
before.append(line)
elif line['action'] == 'add':
after.append(line)
elif line['action'] == 'del':
before.append(line)
elif line['action'] == 'context-old':
before.append(line)
elif line['action'] == 'context-new':
after.append(line)
result.lines.extend(
self.parse_lines(before, after, source_file, target_file))
result.unified = self.as_unified(result.lines)
result.sideside = result.lines
return result
def parse_lines(self, before_lines, after_lines, source_file, target_file):
# TODO: dan: investigate doing the diff comparison and fast highlighting
# on the entire before and after buffered block lines rather than by
# line, this means we can get better 'fast' highlighting if the context
# allows it - eg.
# line 4: """
# line 5: this gets highlighted as a string
# line 6: """
lines = []
while before_lines or after_lines:
before, after = None, None
before_tokens, after_tokens = None, None
if before_lines:
before = before_lines.pop(0)
if after_lines:
after = after_lines.pop(0)
original = AttributeDict()
modified = AttributeDict()
if before:
before_tokens = self.get_line_tokens(
line_text=before['line'], line_number=before['old_lineno'],
file=source_file)
original.lineno = before['old_lineno']
original.content = before['line']
original.action = self.action_to_op(before['action'])
if after:
after_tokens = self.get_line_tokens(
line_text=after['line'], line_number=after['new_lineno'],
file=target_file)
modified.lineno = after['new_lineno']
modified.content = after['line']
modified.action = self.action_to_op(after['action'])
# diff the lines
if before_tokens and after_tokens:
o_tokens, m_tokens, similarity = tokens_diff(before_tokens, after_tokens)
original.content = render_tokenstream(o_tokens)
modified.content = render_tokenstream(m_tokens)
elif before_tokens:
original.content = render_tokenstream(
[(x[0], '', x[1]) for x in before_tokens])
elif after_tokens:
modified.content = render_tokenstream(
[(x[0], '', x[1]) for x in after_tokens])
lines.append(AttributeDict({
'original': original,
'modified': modified,
}))
return lines
def get_line_tokens(self, line_text, line_number, file=None):
filenode = None
filename = None
if isinstance(file, basestring):
filename = file
elif isinstance(file, FileNode):
filenode = file
filename = file.unicode_path
if self.highlight_mode == self.HL_REAL and filenode:
if line_number and file.size < self.max_file_size_limit:
return self.get_tokenized_filenode_line(file, line_number)
if self.highlight_mode in (self.HL_REAL, self.HL_FAST) and filename:
lexer = self._get_lexer_for_filename(filename)
return list(tokenize_string(line_text, lexer))
return list(tokenize_string(line_text, plain_text_lexer))
def get_tokenized_filenode_line(self, filenode, line_number):
if filenode not in self.highlighted_filenodes:
tokenized_lines = filenode_as_lines_tokens(filenode, filenode.lexer)
self.highlighted_filenodes[filenode] = tokenized_lines
return self.highlighted_filenodes[filenode][line_number - 1]
def action_to_op(self, action):
return {
'add': '+',
'del': '-',
'unmod': ' ',
'context-old': ' ',
'context-new': ' ',
}.get(action, action)
def as_unified(self, lines):
""" Return a generator that yields the lines of a diff in unified order """
def generator():
buf = []
for line in lines:
if buf and not line.original or line.original.action == ' ':
for b in buf:
yield b
buf = []
if line.original:
if line.original.action == ' ':
yield (line.original.lineno, line.modified.lineno,
line.original.action, line.original.content)
continue
if line.original.action == '-':
yield (line.original.lineno, None,
line.original.action, line.original.content)
if line.modified.action == '+':
buf.append((
None, line.modified.lineno,
line.modified.action, line.modified.content))
continue
if line.modified:
yield (None, line.modified.lineno,
line.modified.action, line.modified.content)
for b in buf:
yield b
return generator()

File diff suppressed because it is too large Load diff

View file

@ -180,6 +180,8 @@ class Action(object):
UNMODIFIED = 'unmod'
CONTEXT = 'context'
CONTEXT_OLD = 'context-old'
CONTEXT_NEW = 'context-new'
class DiffProcessor(object):
@ -227,7 +229,7 @@ class DiffProcessor(object):
self._parser = self._parse_gitdiff
else:
self.differ = self._highlight_line_udiff
self._parser = self._parse_udiff
self._parser = self._new_parse_gitdiff
def _copy_iterator(self):
"""
@ -491,9 +493,181 @@ class DiffProcessor(object):
return diff_container(sorted(_files, key=sorter))
def _parse_udiff(self, inline_diff=True):
raise NotImplementedError()
# FIXME: NEWDIFFS: dan: this replaces the old _escaper function
def _process_line(self, string):
"""
Process a diff line, checks the diff limit
:param string:
"""
self.cur_diff_size += len(string)
if not self.show_full_diff and (self.cur_diff_size > self.diff_limit):
raise DiffLimitExceeded('Diff Limit Exceeded')
return safe_unicode(string)
# FIXME: NEWDIFFS: dan: this replaces _parse_gitdiff
def _new_parse_gitdiff(self, inline_diff=True):
_files = []
diff_container = lambda arg: arg
for chunk in self._diff.chunks():
head = chunk.header
log.debug('parsing diff %r' % head)
diff = imap(self._process_line, chunk.diff.splitlines(1))
raw_diff = chunk.raw
limited_diff = False
exceeds_limit = False
# if 'empty_file_to_modify_and_rename' in head['a_path']:
# 1/0
op = None
stats = {
'added': 0,
'deleted': 0,
'binary': False,
'old_mode': None,
'new_mode': None,
'ops': {},
}
if head['old_mode']:
stats['old_mode'] = head['old_mode']
if head['new_mode']:
stats['new_mode'] = head['new_mode']
if head['b_mode']:
stats['new_mode'] = head['b_mode']
if head['deleted_file_mode']:
op = OPS.DEL
stats['binary'] = True
stats['ops'][DEL_FILENODE] = 'deleted file'
elif head['new_file_mode']:
op = OPS.ADD
stats['binary'] = True
stats['old_mode'] = None
stats['new_mode'] = head['new_file_mode']
stats['ops'][NEW_FILENODE] = 'new file %s' % head['new_file_mode']
else: # modify operation, can be copy, rename or chmod
# CHMOD
if head['new_mode'] and head['old_mode']:
op = OPS.MOD
stats['binary'] = True
stats['ops'][CHMOD_FILENODE] = (
'modified file chmod %s => %s' % (
head['old_mode'], head['new_mode']))
# RENAME
if head['rename_from'] != head['rename_to']:
op = OPS.MOD
stats['binary'] = True
stats['renamed'] = (head['rename_from'], head['rename_to'])
stats['ops'][RENAMED_FILENODE] = (
'file renamed from %s to %s' % (
head['rename_from'], head['rename_to']))
# COPY
if head.get('copy_from') and head.get('copy_to'):
op = OPS.MOD
stats['binary'] = True
stats['copied'] = (head['copy_from'], head['copy_to'])
stats['ops'][COPIED_FILENODE] = (
'file copied from %s to %s' % (
head['copy_from'], head['copy_to']))
# If our new parsed headers didn't match anything fallback to
# old style detection
if op is None:
if not head['a_file'] and head['b_file']:
op = OPS.ADD
stats['binary'] = True
stats['new_file'] = True
stats['ops'][NEW_FILENODE] = 'new file'
elif head['a_file'] and not head['b_file']:
op = OPS.DEL
stats['binary'] = True
stats['ops'][DEL_FILENODE] = 'deleted file'
# it's not ADD not DELETE
if op is None:
op = OPS.MOD
stats['binary'] = True
stats['ops'][MOD_FILENODE] = 'modified file'
# a real non-binary diff
if head['a_file'] or head['b_file']:
try:
raw_diff, chunks, _stats = self._new_parse_lines(diff)
stats['binary'] = False
stats['added'] = _stats[0]
stats['deleted'] = _stats[1]
# explicit mark that it's a modified file
if op == OPS.MOD:
stats['ops'][MOD_FILENODE] = 'modified file'
exceeds_limit = len(raw_diff) > self.file_limit
# changed from _escaper function so we validate size of
# each file instead of the whole diff
# diff will hide big files but still show small ones
# from my tests, big files are fairly safe to be parsed
# but the browser is the bottleneck
if not self.show_full_diff and exceeds_limit:
raise DiffLimitExceeded('File Limit Exceeded')
except DiffLimitExceeded:
diff_container = lambda _diff: \
LimitedDiffContainer(
self.diff_limit, self.cur_diff_size, _diff)
exceeds_limit = len(raw_diff) > self.file_limit
limited_diff = True
chunks = []
else: # GIT format binary patch, or possibly empty diff
if head['bin_patch']:
# we have operation already extracted, but we mark simply
# it's a diff we wont show for binary files
stats['ops'][BIN_FILENODE] = 'binary diff hidden'
chunks = []
if chunks and not self.show_full_diff and op == OPS.DEL:
# if not full diff mode show deleted file contents
# TODO: anderson: if the view is not too big, there is no way
# to see the content of the file
chunks = []
chunks.insert(0, [{
'old_lineno': '',
'new_lineno': '',
'action': Action.CONTEXT,
'line': msg,
} for _op, msg in stats['ops'].iteritems()
if _op not in [MOD_FILENODE]])
original_filename = safe_unicode(head['a_path'])
_files.append({
'original_filename': original_filename,
'filename': safe_unicode(head['b_path']),
'old_revision': head['a_blob_id'],
'new_revision': head['b_blob_id'],
'chunks': chunks,
'raw_diff': safe_unicode(raw_diff),
'operation': op,
'stats': stats,
'exceeds_limit': exceeds_limit,
'is_limited_diff': limited_diff,
})
sorter = lambda info: {OPS.ADD: 0, OPS.MOD: 1,
OPS.DEL: 2}.get(info['operation'])
return diff_container(sorted(_files, key=sorter))
# FIXME: NEWDIFFS: dan: this gets replaced by _new_parse_lines
def _parse_lines(self, diff):
"""
Parse the diff an return data for the template.
@ -588,6 +762,107 @@ class DiffProcessor(object):
pass
return ''.join(raw_diff), chunks, stats
# FIXME: NEWDIFFS: dan: this replaces _parse_lines
def _new_parse_lines(self, diff):
"""
Parse the diff an return data for the template.
"""
lineiter = iter(diff)
stats = [0, 0]
chunks = []
raw_diff = []
try:
line = lineiter.next()
while line:
raw_diff.append(line)
match = self._chunk_re.match(line)
if not match:
break
gr = match.groups()
(old_line, old_end,
new_line, new_end) = [int(x or 1) for x in gr[:-1]]
lines = []
hunk = {
'section_header': gr[-1],
'source_start': old_line,
'source_length': old_end,
'target_start': new_line,
'target_length': new_end,
'lines': lines,
}
chunks.append(hunk)
old_line -= 1
new_line -= 1
context = len(gr) == 5
old_end += old_line
new_end += new_line
line = lineiter.next()
while old_line < old_end or new_line < new_end:
command = ' '
if line:
command = line[0]
affects_old = affects_new = False
# ignore those if we don't expect them
if command in '#@':
continue
elif command == '+':
affects_new = True
action = Action.ADD
stats[0] += 1
elif command == '-':
affects_old = True
action = Action.DELETE
stats[1] += 1
else:
affects_old = affects_new = True
action = Action.UNMODIFIED
if not self._newline_marker.match(line):
old_line += affects_old
new_line += affects_new
lines.append({
'old_lineno': affects_old and old_line or '',
'new_lineno': affects_new and new_line or '',
'action': action,
'line': self._clean_line(line, command)
})
raw_diff.append(line)
line = lineiter.next()
if self._newline_marker.match(line):
# we need to append to lines, since this is not
# counted in the line specs of diff
if affects_old:
action = Action.CONTEXT_OLD
elif affects_new:
action = Action.CONTEXT_NEW
else:
raise Exception('invalid context for no newline')
lines.append({
'old_lineno': None,
'new_lineno': None,
'action': action,
'line': self._clean_line(line, command)
})
except StopIteration:
pass
return ''.join(raw_diff), chunks, stats
def _safe_id(self, idstring):
"""Make a string safe for including in an id attribute.

View file

@ -32,11 +32,13 @@ class GitDiff(base.Diff):
_header_re = re.compile(r"""
#^diff[ ]--git
[ ]"?a/(?P<a_path>.+?)"?[ ]"?b/(?P<b_path>.+?)"?\n
(?:^similarity[ ]index[ ](?P<similarity_index>\d+)%\n
^rename[ ]from[ ](?P<rename_from>[^\r\n]+)\n
^rename[ ]to[ ](?P<rename_to>[^\r\n]+)(?:\n|$))?
(?:^old[ ]mode[ ](?P<old_mode>\d+)\n
^new[ ]mode[ ](?P<new_mode>\d+)(?:\n|$))?
(?:^similarity[ ]index[ ](?P<similarity_index>\d+)%(?:\n|$))?
(?:^rename[ ]from[ ](?P<rename_from>[^\r\n]+)\n
^rename[ ]to[ ](?P<rename_to>[^\r\n]+)(?:\n|$))?
(?:^copy[ ]from[ ](?P<copy_from>[^\r\n]+)\n
^copy[ ]to[ ](?P<copy_to>[^\r\n]+)(?:\n|$))?
(?:^new[ ]file[ ]mode[ ](?P<new_file_mode>.+)(?:\n|$))?
(?:^deleted[ ]file[ ]mode[ ](?P<deleted_file_mode>.+)(?:\n|$))?
(?:^index[ ](?P<a_blob_id>[0-9A-Fa-f]+)

View file

@ -188,6 +188,14 @@ input[type="button"] {
padding: @padding * 1.2;
}
.btn-group {
display: inline-block;
.btn {
float: left;
margin: 0 0 0 -1px;
}
}
.btn-link {
background: transparent;
border: none;

View file

@ -646,15 +646,211 @@ pre.literal-block, .codehilite pre{
@cb-line-height: 18px;
@cb-line-code-padding: 10px;
@cb-text-padding: 5px;
table.cb {
width: 100%;
border-collapse: collapse;
margin-bottom: 10px;
@diff-pill-padding: 2px 7px;
input.diff-collapse-state {
display: none;
&:checked + .diff { /* file diff is collapsed */
.cb {
display: none
}
.diff-collapse-indicator {
border-width: 9px 0 9px 15.6px;
border-color: transparent transparent transparent #ccc;
}
.diff-menu {
display: none;
}
margin: -1px 0 0 0;
}
&+ .diff { /* file diff is expanded */
.diff-collapse-indicator {
border-width: 15.6px 9px 0 9px;
border-color: #ccc transparent transparent transparent;
}
.diff-menu {
display: block;
}
margin: 20px 0;
}
}
.diff {
border: 1px solid @grey5;
/* START OVERRIDES */
.code-highlight {
border: none; // TODO: remove this border from the global
// .code-highlight, it doesn't belong there
}
label {
margin: 0; // TODO: remove this margin definition from global label
// it doesn't belong there - if margin on labels
// are needed for a form they should be defined
// in the form's class
}
/* END OVERRIDES */
* {
box-sizing: border-box;
}
.diff-anchor {
visibility: hidden;
}
&:hover {
.diff-anchor {
visibility: visible;
}
}
.diff-collapse-indicator {
width: 0;
height: 0;
border-style: solid;
float: left;
margin: 2px 2px 0 0;
cursor: pointer;
}
.diff-heading {
background: @grey7;
cursor: pointer;
display: block;
padding: 5px 10px;
}
.diff-heading:after {
content: "";
display: table;
clear: both;
}
.diff-heading:hover {
background: #e1e9f4 !important;
}
.diff-menu {
float: right;
a, button {
padding: 5px;
display: block;
float: left
}
}
.diff-pill {
display: block;
float: left;
padding: @diff-pill-padding;
}
.diff-pill-group {
.diff-pill {
opacity: .8;
&:first-child {
border-radius: @border-radius 0 0 @border-radius;
}
&:last-child {
border-radius: 0 @border-radius @border-radius 0;
}
&:only-child {
border-radius: @border-radius;
}
}
}
.diff-pill {
&[op="name"] {
background: none;
color: @grey2;
opacity: 1;
color: white;
}
&[op="limited"] {
background: @grey2;
color: white;
}
&[op="binary"] {
background: @color7;
color: white;
}
&[op="modified"] {
background: @alert1;
color: white;
}
&[op="renamed"] {
background: @color4;
color: white;
}
&[op="mode"] {
background: @grey3;
color: white;
}
&[op="symlink"] {
background: @color8;
color: white;
}
&[op="added"] { /* added lines */
background: @alert1;
color: white;
}
&[op="deleted"] { /* deleted lines */
background: @alert2;
color: white;
}
&[op="created"] { /* created file */
background: @alert1;
color: white;
}
&[op="removed"] { /* deleted file */
background: @color5;
color: white;
}
}
.diff-collapse-button, .diff-expand-button {
cursor: pointer;
}
.diff-collapse-button {
display: inline;
}
.diff-expand-button {
display: none;
}
.diff-collapsed .diff-collapse-button {
display: none;
}
.diff-collapsed .diff-expand-button {
display: inline;
}
}
table.cb {
width: 100%;
border-collapse: collapse;
.cb-text {
padding: @cb-text-padding;
}
.cb-hunk {
padding: @cb-text-padding;
}
.cb-expand {
display: none;
}
.cb-collapse {
display: inline;
}
&.cb-collapsed {
.cb-line {
display: none;
}
.cb-expand {
display: inline;
}
.cb-collapse {
display: none;
}
}
/* intentionally general selector since .cb-line-selected must override it
and they both use !important since the td itself may have a random color
@ -663,18 +859,45 @@ table.cb {
.cb-line-fresh .cb-content {
background: white !important;
}
.cb-warning {
background: #fff4dd;
}
tr.cb-annotate {
border-top: 1px solid #eee;
&.cb-diff-sideside {
td {
&.cb-content {
width: 50%;
}
}
}
&+ .cb-line {
tr {
&.cb-annotate {
border-top: 1px solid #eee;
&+ .cb-line {
border-top: 1px solid #eee;
}
&:first-child {
border-top: none;
&+ .cb-line {
border-top: none;
}
}
}
&:first-child {
border-top: none;
&+ .cb-line {
border-top: none;
&.cb-hunk {
font-family: @font-family-monospace;
color: rgba(0, 0, 0, 0.3);
td {
&:first-child {
background: #edf2f9;
}
&:last-child {
background: #f4f7fb;
}
}
}
}
@ -686,9 +909,14 @@ table.cb {
&.cb-content {
font-size: 12.35px;
&.cb-line-selected .cb-code {
background: @comment-highlight-color !important;
}
span.cb-code {
line-height: @cb-line-height;
padding-left: @cb-line-code-padding;
padding-right: @cb-line-code-padding;
display: block;
white-space: pre-wrap;
font-family: @font-family-monospace;
@ -714,14 +942,38 @@ table.cb {
a {
display: block;
padding-right: @cb-line-code-padding;
padding-left: @cb-line-code-padding;
line-height: @cb-line-height;
color: rgba(0, 0, 0, 0.3);
}
}
&.cb-content {
&.cb-line-selected .cb-code {
background: @comment-highlight-color !important;
&.cb-empty {
background: @grey7;
}
ins {
color: black;
background: #a6f3a6;
text-decoration: none;
}
del {
color: black;
background: #f8cbcb;
text-decoration: none;
}
&.cb-addition {
background: #ecffec;
&.blob-lineno {
background: #ddffdd;
}
}
&.cb-deletion {
background: #ffecec;
&.blob-lineno {
background: #ffdddd;
}
}

View file

@ -221,14 +221,32 @@ var formatSelect2SelectionRefs = function(commit_ref){
};
// takes a given html element and scrolls it down offset pixels
function offsetScroll(element, offset){
setTimeout(function(){
function offsetScroll(element, offset) {
setTimeout(function() {
var location = element.offset().top;
// some browsers use body, some use html
$('html, body').animate({ scrollTop: (location - offset) });
}, 100);
}
// scroll an element `percent`% from the top of page in `time` ms
function scrollToElement(element, percent, time) {
percent = (percent === undefined ? 25 : percent);
time = (time === undefined ? 100 : time);
var $element = $(element);
var elOffset = $element.offset().top;
var elHeight = $element.height();
var windowHeight = $(window).height();
var offset = elOffset;
if (elHeight < windowHeight) {
offset = elOffset - ((windowHeight / (100 / percent)) - (elHeight / 2));
}
setTimeout(function() {
$('html, body').animate({ scrollTop: offset});
}, time);
}
/**
* global hooks after DOM is loaded
*/
@ -418,6 +436,10 @@ $(document).ready(function() {
var result = splitDelimitedHash(location.hash);
var loc = result.loc;
if (loc.length > 1) {
var highlightable_line_tds = [];
// source code line format
var page_highlights = loc.substring(
loc.indexOf('#') + 1).split('L');
@ -442,33 +464,27 @@ $(document).ready(function() {
for (pos in h_lines) {
var line_td = $('td.cb-lineno#L' + h_lines[pos]);
if (line_td.length) {
line_td.addClass('cb-line-selected'); // line number td
line_td.next().addClass('cb-line-selected'); // line content
highlightable_line_tds.push(line_td);
}
}
var first_line_td = $('td.cb-lineno#L' + h_lines[0]);
if (first_line_td.length) {
var elOffset = first_line_td.offset().top;
var elHeight = first_line_td.height();
var windowHeight = $(window).height();
var offset;
}
if (elHeight < windowHeight) {
offset = elOffset - ((windowHeight / 4) - (elHeight / 2));
}
else {
offset = elOffset;
}
$(function() { // let browser scroll to hash first, then
// scroll the line to the middle of page
setTimeout(function() {
$('html, body').animate({ scrollTop: offset });
}, 100);
});
$.Topic('/ui/plugins/code/anchor_focus').prepareOrPublish({
lineno: first_line_td,
remainder: result.remainder});
}
// now check a direct id reference (diff page)
if ($(loc).length && $(loc).hasClass('cb-lineno')) {
highlightable_line_tds.push($(loc));
}
$.each(highlightable_line_tds, function (i, $td) {
$td.addClass('cb-line-selected'); // line number td
$td.next().addClass('cb-line-selected'); // line content
});
if (highlightable_line_tds.length) {
var $first_line_td = highlightable_line_tds[0];
scrollToElement($first_line_td);
$.Topic('/ui/plugins/code/anchor_focus').prepareOrPublish({
lineno: $first_line_td,
remainder: result.remainder
});
}
}
}

View file

@ -0,0 +1,398 @@
<%def name="diff_line_anchor(filename, line, type)"><%
return '%s_%s_%i' % (h.safeid(filename), type, line)
%></%def>
<%def name="action_class(action)"><%
return {
'-': 'cb-deletion',
'+': 'cb-addition',
' ': 'cb-context',
}.get(action, 'cb-empty')
%></%def>
<%def name="op_class(op_id)"><%
return {
DEL_FILENODE: 'deletion', # file deleted
BIN_FILENODE: 'warning' # binary diff hidden
}.get(op_id, 'addition')
%></%def>
<%def name="link_for(**kw)"><%
new_args = request.GET.mixed()
new_args.update(kw)
return h.url('', **new_args)
%></%def>
<%def name="render_diffset(diffset,
# collapse all file diff entries when there are more than this amount of files in the diff
collapse_when_files_over=20,
# collapse lines in the diff when more than this amount of lines changed in the file diff
lines_changed_limit=500,
)">
<%
# TODO: dan: move this to an argument - and set a cookie so that it is saved
# default option for future requests
diff_mode = request.GET.get('diffmode', 'sideside')
if diff_mode not in ('sideside', 'unified'):
diff_mode = 'sideside'
collapse_all = len(diffset.files) > collapse_when_files_over
%>
%if diff_mode == 'sideside':
<style>
.wrapper {
max-width: 1600px !important;
}
</style>
%endif
% if diffset.limited_diff:
<div class="alert alert-warning">
${_('The requested commit is too big and content was truncated.')} <a href="${link_for(fulldiff=1)}" onclick="return confirm('${_("Showing a big diff might take some time and resources, continue?")}')">${_('Show full diff')}</a>
</div>
% endif
<div class="cs_files">
<div class="cs_files_title">
%if diffset.files:
<div class="pull-right">
<div class="btn-group">
<a
class="btn ${diff_mode == 'sideside' and 'btn-primary'} tooltip"
title="${_('View side by side')}"
href="${link_for(diffmode='sideside')}">
<span>${_('Side by Side')}</span>
</a>
<a
class="btn ${diff_mode == 'unified' and 'btn-primary'} tooltip"
title="${_('View unified')}" href="${link_for(diffmode='unified')}">
<span>${_('Unified')}</span>
</a>
</div>
</div>
<div class="pull-left">
<div class="btn-group">
<a
class="btn"
href="#"
onclick="$('input[class=diff-collapse-state]').prop('checked', false); return false">${_('Expand All')}</a>
<a
class="btn"
href="#"
onclick="$('input[class=diff-collapse-state]').prop('checked', true); return false">${_('Collapse All')}</a>
</div>
</div>
%endif
<h2 style="padding: 5px; text-align: center;">
%if diffset.limited_diff:
${ungettext('%(num)s file changed', '%(num)s files changed', diffset.changed_files) % {'num': diffset.changed_files}}
%else:
${ungettext('%(num)s file changed: %(linesadd)s inserted, ''%(linesdel)s deleted',
'%(num)s files changed: %(linesadd)s inserted, %(linesdel)s deleted', diffset.changed_files) % {'num': diffset.changed_files, 'linesadd': diffset.lines_added, 'linesdel': diffset.lines_deleted}}
%endif
</h2>
</div>
%if not diffset.files:
<p class="empty_data">${_('No files')}</p>
%endif
<div class="filediffs">
%for i, filediff in enumerate(diffset.files):
<%
lines_changed = filediff['patch']['stats']['added'] + filediff['patch']['stats']['deleted']
over_lines_changed_limit = lines_changed > lines_changed_limit
%>
<input ${collapse_all and 'checked' or ''} class="diff-collapse-state" id="diff-collapse-${i}" type="checkbox">
<div
class="diff"
data-f-path="${filediff['patch']['filename']}"
id="a_${h.FID('', filediff['patch']['filename'])}">
<label for="diff-collapse-${i}" class="diff-heading">
<div class="diff-collapse-indicator"></div>
${diff_ops(filediff)}
</label>
${diff_menu(filediff)}
<table class="cb cb-diff-${diff_mode} code-highlight ${over_lines_changed_limit and 'cb-collapsed' or ''}">
%if not filediff.hunks:
%for op_id, op_text in filediff['patch']['stats']['ops'].items():
<tr>
<td class="cb-text cb-${op_class(op_id)}" ${diff_mode == 'unified' and 'colspan=3' or 'colspan=4'}>
%if op_id == DEL_FILENODE:
${_('File was deleted')}
%elif op_id == BIN_FILENODE:
${_('Binary file hidden')}
%else:
${op_text}
%endif
</td>
</tr>
%endfor
%endif
%if over_lines_changed_limit:
<tr class="cb-warning cb-collapser">
<td class="cb-text" ${diff_mode == 'unified' and 'colspan=3' or 'colspan=4'}>
${_('This diff has been collapsed as it changes many lines, (%i lines changed)' % lines_changed)}
<a href="#" class="cb-expand"
onclick="$(this).closest('table').removeClass('cb-collapsed'); return false;">${_('Show them')}
</a>
<a href="#" class="cb-collapse"
onclick="$(this).closest('table').addClass('cb-collapsed'); return false;">${_('Hide them')}
</a>
</td>
</tr>
%endif
%if filediff.patch['is_limited_diff']:
<tr class="cb-warning cb-collapser">
<td class="cb-text" ${diff_mode == 'unified' and 'colspan=3' or 'colspan=4'}>
${_('The requested commit is too big and content was truncated.')} <a href="${link_for(fulldiff=1)}" onclick="return confirm('${_("Showing a big diff might take some time and resources, continue?")}')">${_('Show full diff')}</a>
</td>
</tr>
%endif
%for hunk in filediff.hunks:
<tr class="cb-hunk">
<td ${diff_mode == 'unified' and 'colspan=2' or ''}>
## TODO: dan: add ajax loading of more context here
## <a href="#">
<i class="icon-more"></i>
## </a>
</td>
<td ${diff_mode == 'sideside' and 'colspan=3' or ''}>
@@
-${hunk.source_start},${hunk.source_length}
+${hunk.target_start},${hunk.target_length}
${hunk.section_header}
</td>
</tr>
%if diff_mode == 'unified':
${render_hunk_lines_unified(hunk)}
%elif diff_mode == 'sideside':
${render_hunk_lines_sideside(hunk)}
%else:
<tr class="cb-line">
<td>unknown diff mode</td>
</tr>
%endif
%endfor
</table>
</div>
%endfor
</div>
</div>
</%def>
<%def name="diff_ops(filediff)">
<%
stats = filediff['patch']['stats']
from rhodecode.lib.diffs import NEW_FILENODE, DEL_FILENODE, \
MOD_FILENODE, RENAMED_FILENODE, CHMOD_FILENODE, BIN_FILENODE
%>
<span class="diff-pill">
%if filediff.source_file_path and filediff.target_file_path:
%if filediff.source_file_path != filediff.target_file_path: # file was renamed
<strong>${filediff.target_file_path}</strong><del>${filediff.source_file_path}</del>
%else:
## file was modified
<strong>${filediff.source_file_path}</strong>
%endif
%else:
%if filediff.source_file_path:
## file was deleted
<strong>${filediff.source_file_path}</strong>
%else:
## file was added
<strong>${filediff.target_file_path}</strong>
%endif
%endif
</span>
<span class="diff-pill-group" style="float: left">
%if filediff.patch['is_limited_diff']:
<span class="diff-pill tooltip" op="limited" title="The stats for this diff are not complete">limited diff</span>
%endif
%if RENAMED_FILENODE in stats['ops']:
<span class="diff-pill" op="renamed">renamed</span>
%endif
%if NEW_FILENODE in stats['ops']:
<span class="diff-pill" op="created">created</span>
%if filediff['target_mode'].startswith('120'):
<span class="diff-pill" op="symlink">symlink</span>
%else:
<span class="diff-pill" op="mode">${nice_mode(filediff['target_mode'])}</span>
%endif
%endif
%if DEL_FILENODE in stats['ops']:
<span class="diff-pill" op="removed">removed</span>
%endif
%if CHMOD_FILENODE in stats['ops']:
<span class="diff-pill" op="mode">
${nice_mode(filediff['source_mode'])} ➡ ${nice_mode(filediff['target_mode'])}
</span>
%endif
</span>
<a class="diff-pill diff-anchor" href="#a_${h.FID('', filediff.patch['filename'])}"></a>
<span class="diff-pill-group" style="float: right">
%if BIN_FILENODE in stats['ops']:
<span class="diff-pill" op="binary">binary</span>
%if MOD_FILENODE in stats['ops']:
<span class="diff-pill" op="modified">modified</span>
%endif
%endif
%if stats['deleted']:
<span class="diff-pill" op="deleted">-${stats['deleted']}</span>
%endif
%if stats['added']:
<span class="diff-pill" op="added">+${stats['added']}</span>
%endif
</span>
</%def>
<%def name="nice_mode(filemode)">
${filemode.startswith('100') and filemode[3:] or filemode}
</%def>
<%def name="diff_menu(filediff)">
<div class="diff-menu">
%if filediff.diffset.source_ref:
%if filediff.patch['operation'] in ['D', 'M']:
<a
class="tooltip"
href="${h.url('files_home',repo_name=c.repo_name,f_path=filediff.source_file_path,revision=filediff.diffset.source_ref)}"
title="${h.tooltip(_('Show file at commit: %(commit_id)s') % {'commit_id': filediff.diffset.source_ref[:12]})}"
>
${_('Show file before')}
</a>
%else:
<a
disabled
class="tooltip"
title="${h.tooltip(_('File no longer present at commit: %(commit_id)s') % {'commit_id': filediff.diffset.source_ref[:12]})}"
>
${_('Show file before')}
</a>
%endif
%if filediff.patch['operation'] in ['A', 'M']:
<a
class="tooltip"
href="${h.url('files_home',repo_name=c.repo_name,f_path=filediff.target_file_path,revision=filediff.diffset.target_ref)}"
title="${h.tooltip(_('Show file at commit: %(commit_id)s') % {'commit_id': filediff.diffset.target_ref[:12]})}"
>
${_('Show file after')}
</a>
%else:
<a
disabled
class="tooltip"
title="${h.tooltip(_('File no longer present at commit: %(commit_id)s') % {'commit_id': filediff.diffset.target_ref[:12]})}"
>
${_('Show file after')}
</a>
%endif
<a
class="tooltip"
title="${h.tooltip(_('Raw diff'))}"
href="${h.url('files_diff_home',repo_name=c.repo_name,f_path=filediff.target_file_path,diff2=filediff.diffset.target_ref,diff1=filediff.diffset.source_ref,diff='raw')}"
>
${_('Raw diff')}
</a>
<a
class="tooltip"
title="${h.tooltip(_('Download diff'))}"
href="${h.url('files_diff_home',repo_name=c.repo_name,f_path=filediff.target_file_path,diff2=filediff.diffset.target_ref,diff1=filediff.diffset.source_ref,diff='download')}"
>
${_('Download diff')}
</a>
%endif
</div>
</%def>
<%def name="render_hunk_lines_sideside(hunk)">
%for i, line in enumerate(hunk.sideside):
<%
old_line_anchor, new_line_anchor = None, None
if line.original.lineno:
old_line_anchor = diff_line_anchor(hunk.filediff.source_file_path, line.original.lineno, 'o')
if line.modified.lineno:
new_line_anchor = diff_line_anchor(hunk.filediff.target_file_path, line.modified.lineno, 'n')
%>
<tr class="cb-line">
<td class="cb-lineno ${action_class(line.original.action)}"
data-line-number="${line.original.lineno}"
%if old_line_anchor:
id="${old_line_anchor}"
%endif
>
%if line.original.lineno:
<a name="${old_line_anchor}" href="#${old_line_anchor}">${line.original.lineno}</a>
%endif
</td>
<td class="cb-content ${action_class(line.original.action)}"
data-line-number="o${line.original.lineno}"
><span class="cb-code">${line.original.action} ${line.original.content or '' | n}</span>
</td>
<td class="cb-lineno ${action_class(line.modified.action)}"
data-line-number="${line.modified.lineno}"
%if new_line_anchor:
id="${new_line_anchor}"
%endif
>
%if line.modified.lineno:
<a name="${new_line_anchor}" href="#${new_line_anchor}">${line.modified.lineno}</a>
%endif
</td>
<td class="cb-content ${action_class(line.modified.action)}"
data-line-number="n${line.modified.lineno}"
>
<span class="cb-code">${line.modified.action} ${line.modified.content or '' | n}</span>
</td>
</tr>
%endfor
</%def>
<%def name="render_hunk_lines_unified(hunk)">
%for old_line_no, new_line_no, action, content in hunk.unified:
<%
old_line_anchor, new_line_anchor = None, None
if old_line_no:
old_line_anchor = diff_line_anchor(hunk.filediff.source_file_path, old_line_no, 'o')
if new_line_no:
new_line_anchor = diff_line_anchor(hunk.filediff.target_file_path, new_line_no, 'n')
%>
<tr class="cb-line">
<td class="cb-lineno ${action_class(action)}"
data-line-number="${old_line_no}"
%if old_line_anchor:
id="${old_line_anchor}"
%endif
>
%if old_line_anchor:
<a name="${old_line_anchor}" href="#${old_line_anchor}">${old_line_no}</a>
%endif
</td>
<td class="cb-lineno ${action_class(action)}"
data-line-number="${new_line_no}"
%if new_line_anchor:
id="${new_line_anchor}"
%endif
>
%if new_line_anchor:
<a name="${new_line_anchor}" href="#${new_line_anchor}">${new_line_no}</a>
%endif
</td>
<td class="cb-content ${action_class(action)}"
data-line-number="${new_line_no and 'n' or 'o'}${new_line_no or old_line_no}"
><span class="cb-code">${action} ${content or '' | n}</span>
</td>
</tr>
%endfor
</%def>

View file

@ -1,5 +1,6 @@
## -*- coding: utf-8 -*-
<%inherit file="/base/base.html"/>
<%namespace name="cbdiffs" file="/codeblocks/diffs.html"/>
<%def name="title()">
%if c.compare_home:
@ -53,7 +54,7 @@
<a id="btn-swap" class="btn btn-primary" href="${c.swap_url}"><i class="icon-refresh"></i> ${_('Swap')}</a>
%endif
<div id="compare_revs" class="btn btn-primary"><i class ="icon-loop"></i> ${_('Compare Commits')}</div>
%if c.files:
%if c.diffset and c.diffset.files:
<div id="compare_changeset_status_toggle" class="btn btn-primary">${_('Comment')}</div>
%endif
</div>
@ -248,72 +249,7 @@
<div id="changeset_compare_view_content">
##CS
<%include file="compare_commits.html"/>
## FILES
<div class="cs_files_title">
<span class="cs_files_expand">
<span id="expand_all_files">${_('Expand All')}</span> | <span id="collapse_all_files">${_('Collapse All')}</span>
</span>
<h2>
${diff_block.diff_summary_text(len(c.files), c.lines_added, c.lines_deleted, c.limited_diff)}
</h2>
</div>
<div class="cs_files">
%if not c.files:
<p class="empty_data">${_('No files')}</p>
%endif
<table class="compare_view_files">
<%namespace name="diff_block" file="/changeset/diff_block.html"/>
%for FID, change, path, stats, file in c.files:
<tr class="cs_${change} collapse_file" fid="${FID}">
<td class="cs_icon_td">
<span class="collapse_file_icon" fid="${FID}"></span>
</td>
<td class="cs_icon_td">
<div class="flag_status not_reviewed hidden"></div>
</td>
<td class="cs_${change}" id="a_${FID}">
<div class="node">
<a href="#a_${FID}">
<i class="icon-file-${change.lower()}"></i>
${h.safe_unicode(path)}
</a>
</div>
</td>
<td>
<div class="changes pull-right">${h.fancy_file_stats(stats)}</div>
<div class="comment-bubble pull-right" data-path="${path}">
<i class="icon-comment"></i>
</div>
</td>
</tr>
<tr fid="${FID}" id="diff_${FID}" class="diff_links">
<td></td>
<td></td>
<td class="cs_${change}">
%if c.target_repo.repo_name == c.repo_name:
${diff_block.diff_menu(c.repo_name, h.safe_unicode(path), c.source_ref, c.target_ref, change, file)}
%else:
## this is slightly different case later, since the target repo can have this
## file in target state than the source repo
${diff_block.diff_menu(c.target_repo.repo_name, h.safe_unicode(path), c.source_ref, c.target_ref, change, file)}
%endif
</td>
<td class="td-actions rc-form">
</td>
</tr>
<tr id="tr_${FID}">
<td></td>
<td></td>
<td class="injected_diff" colspan="2">
${diff_block.diff_block_simple([c.changes[FID]])}
</td>
</tr>
%endfor
</table>
% if c.limited_diff:
${diff_block.changeset_message()}
% endif
${cbdiffs.render_diffset(c.diffset)}
</div>
%endif
</div>

View file

@ -158,7 +158,7 @@ class TestChangesetController(object):
response.mustcontain('Added docstrings to vcs.cli') # commit msg
response.mustcontain('Changed theme to ADC theme') # commit msg
self._check_diff_menus(response)
self._check_new_diff_menus(response)
def test_changeset_range(self, backend):
self._check_changeset_range(
@ -273,7 +273,7 @@ Added a symlink
""" + diffs['svn'],
}
def _check_diff_menus(self, response, right_menu=False):
def _check_diff_menus(self, response, right_menu=False,):
# diff menus
for elem in ['Show File', 'Unified Diff', 'Side-by-side Diff',
'Raw Diff', 'Download Diff']:
@ -284,3 +284,16 @@ Added a symlink
for elem in ['Ignore whitespace', 'Increase context',
'Hide comments']:
response.mustcontain(elem)
def _check_new_diff_menus(self, response, right_menu=False,):
# diff menus
for elem in ['Show file before', 'Show file after',
'Raw diff', 'Download diff']:
response.mustcontain(elem)
# right pane diff menus
if right_menu:
for elem in ['Ignore whitespace', 'Increase context',
'Hide comments']:
response.mustcontain(elem)

View file

@ -20,6 +20,7 @@
import mock
import pytest
import lxml.html
from rhodecode.lib.vcs.backends.base import EmptyCommit
from rhodecode.lib.vcs.exceptions import RepositoryRequirementError
@ -609,9 +610,12 @@ class ComparePage(AssertResponse):
"""
def contains_file_links_and_anchors(self, files):
doc = lxml.html.fromstring(self.response.body)
for filename, file_id in files:
self.contains_one_link(filename, '#' + file_id)
self.contains_one_anchor(file_id)
diffblock = doc.cssselect('[data-f-path="%s"]' % filename)
assert len(diffblock) == 1
assert len(diffblock[0].cssselect('a[href="#%s"]' % file_id)) == 1
def contains_change_summary(self, files_changed, inserted, deleted):
template = (

View file

@ -264,19 +264,19 @@ class TestRenderTokenStream(object):
),
(
[('A', '', u'two\n'), ('A', '', u'lines')],
'<span class="A">two<nl>\n</nl>lines</span>',
'<span class="A">two\nlines</span>',
),
(
[('A', '', u'\nthree\n'), ('A', '', u'lines')],
'<span class="A"><nl>\n</nl>three<nl>\n</nl>lines</span>',
'<span class="A">\nthree\nlines</span>',
),
(
[('', '', u'\n'), ('A', '', u'line')],
'<span><nl>\n</nl></span><span class="A">line</span>',
'<span>\n</span><span class="A">line</span>',
),
(
[('', 'ins', u'\n'), ('A', '', u'line')],
'<span><ins><nl>\n</nl></ins></span><span class="A">line</span>',
'<span><ins>\n</ins></span><span class="A">line</span>',
),
(
[('A', '', u'hel'), ('A', 'ins', u'lo')],