##// END OF EJS Templates
annotations: replace annotated source code viewer with renderer...
dan -
r986:e7837355 default
parent child Browse files
Show More
@@ -0,0 +1,147 b''
1 # -*- coding: utf-8 -*-
2
3 # Copyright (C) 2011-2016 RhodeCode GmbH
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
21
22 from itertools import groupby
23
24 from pygments import lex
25 # PYGMENTS_TOKEN_TYPES is used in a hot loop keep attribute lookups to a minimum
26 from pygments.token import STANDARD_TYPES as PYGMENTS_TOKEN_TYPES
27
28 from rhodecode.lib.helpers import get_lexer_for_filenode
29
30 def tokenize_file(content, lexer):
31 """
32 Use pygments to tokenize some content based on a lexer
33 ensuring all original new lines and whitespace is preserved
34 """
35
36 lexer.stripall = False
37 lexer.stripnl = False
38 lexer.ensurenl = False
39 return lex(content, lexer)
40
41
42 def pygment_token_class(token_type):
43 """ Convert a pygments token type to html class name """
44
45 fname = PYGMENTS_TOKEN_TYPES.get(token_type)
46 if fname:
47 return fname
48
49 aname = ''
50 while fname is None:
51 aname = '-' + token_type[-1] + aname
52 token_type = token_type.parent
53 fname = PYGMENTS_TOKEN_TYPES.get(token_type)
54
55 return fname + aname
56
57
58 def tokens_as_lines(tokens, split_string=u'\n'):
59 """
60 Take a list of (TokenType, text) tuples and split them by a string
61
62 eg. [(TEXT, 'some\ntext')] => [(TEXT, 'some'), (TEXT, 'text')]
63 """
64
65 buffer = []
66 for token_type, token_text in tokens:
67 parts = token_text.split(split_string)
68 for part in parts[:-1]:
69 buffer.append((token_type, part))
70 yield buffer
71 buffer = []
72
73 buffer.append((token_type, parts[-1]))
74
75 if buffer:
76 yield buffer
77
78
79 def filenode_as_lines_tokens(filenode):
80 """
81 Return a generator of lines with pygment tokens for a filenode eg:
82
83 [
84 (1, line1_tokens_list),
85 (2, line1_tokens_list]),
86 ]
87 """
88
89 return enumerate(
90 tokens_as_lines(
91 tokenize_file(
92 filenode.content, get_lexer_for_filenode(filenode)
93 )
94 ),
95 1)
96
97
98 def filenode_as_annotated_lines_tokens(filenode):
99 """
100 Take a file node and return a list of annotations => lines, if no annotation
101 is found, it will be None.
102
103 eg:
104
105 [
106 (annotation1, [
107 (1, line1_tokens_list),
108 (2, line2_tokens_list),
109 ]),
110 (annotation2, [
111 (3, line1_tokens_list),
112 ]),
113 (None, [
114 (4, line1_tokens_list),
115 ]),
116 (annotation1, [
117 (5, line1_tokens_list),
118 (6, line2_tokens_list),
119 ])
120 ]
121 """
122
123
124 # cache commit_getter lookups
125 commit_cache = {}
126 def _get_annotation(commit_id, commit_getter):
127 if commit_id not in commit_cache:
128 commit_cache[commit_id] = commit_getter()
129 return commit_cache[commit_id]
130
131 annotation_lookup = {
132 line_no: _get_annotation(commit_id, commit_getter)
133 for line_no, commit_id, commit_getter, line_content
134 in filenode.annotate
135 }
136
137 annotations_lines = ((annotation_lookup.get(line_no), line_no, tokens)
138 for line_no, tokens
139 in filenode_as_lines_tokens(filenode))
140
141 grouped_annotations_lines = groupby(annotations_lines, lambda x: x[0])
142
143 for annotation, group in grouped_annotations_lines:
144 yield (
145 annotation, [(line_no, tokens)
146 for (_, line_no, tokens) in group]
147 )
@@ -0,0 +1,70 b''
1 <%def name="render_line(line_num, tokens,
2 annotation=None,
3 bgcolor=None)">
4 <%
5 # avoid module lookups for performance
6 from rhodecode.lib.codeblocks import pygment_token_class
7 from rhodecode.lib.helpers import html_escape
8 %>
9 <tr class="cb-line cb-line-fresh"
10 %if annotation:
11 data-revision="${annotation.revision}"
12 %endif
13 >
14 <td class="cb-lineno" id="L${line_num}">
15 <a data-line-no="${line_num}" href="#L${line_num}"></a>
16 </td>
17 <td class="cb-content cb-content-fresh"
18 %if bgcolor:
19 style="background: ${bgcolor}"
20 %endif
21 >${
22 ''.join(
23 '<span class="%s">%s</span>' %
24 (pygment_token_class(token_type), html_escape(token_text))
25 for token_type, token_text in tokens) + '\n' | n
26 }</td>
27 ## this ugly list comp is necessary for performance
28 </tr>
29 </%def>
30
31 <%def name="render_annotation_lines(annotation, lines, color_hasher)">
32 <%
33 rowspan = len(lines) + 1 # span the line's <tr> and annotation <tr>
34 %>
35 %if not annotation:
36 <tr class="cb-annotate">
37 <td class="cb-annotate-message" rowspan="${rowspan}"></td>
38 <td class="cb-annotate-revision" rowspan="${rowspan}"></td>
39 </tr>
40 %else:
41 <tr class="cb-annotate">
42 <td class="cb-annotate-info tooltip"
43 rowspan="${rowspan}"
44 title="Author: ${annotation.author | entity}<br>Date: ${annotation.date}<br>Message: ${annotation.message | entity}"
45 >
46 ${h.gravatar_with_user(annotation.author, 16) | n}
47 <strong class="cb-annotate-message">${
48 h.truncate(annotation.message, len(lines) * 30)
49 }</strong>
50 </td>
51 <td
52 class="cb-annotate-revision"
53 rowspan="${rowspan}"
54 data-revision="${annotation.revision}"
55 onclick="$('[data-revision=${annotation.revision}]').toggleClass('cb-line-fresh')"
56 style="background: ${color_hasher(annotation.raw_id)}">
57 <a href="${h.url('changeset_home',repo_name=c.repo_name,revision=annotation.raw_id)}">
58 r${annotation.revision}
59 </a>
60 </td>
61 </tr>
62 %endif
63
64 %for line_num, tokens in lines:
65 ${render_line(line_num, tokens,
66 bgcolor=color_hasher(annotation and annotation.raw_id or ''),
67 annotation=annotation,
68 )}
69 %endfor
70 </%def>
@@ -1,1114 +1,1122 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-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 Files controller for RhodeCode Enterprise
23 23 """
24 24
25 25 import itertools
26 26 import logging
27 27 import os
28 28 import shutil
29 29 import tempfile
30 30
31 31 from pylons import request, response, tmpl_context as c, url
32 32 from pylons.i18n.translation import _
33 33 from pylons.controllers.util import redirect
34 34 from webob.exc import HTTPNotFound, HTTPBadRequest
35 35
36 36 from rhodecode.controllers.utils import parse_path_ref
37 37 from rhodecode.lib import diffs, helpers as h, caches
38 38 from rhodecode.lib.compat import OrderedDict
39 from rhodecode.lib.codeblocks import (
40 filenode_as_lines_tokens, filenode_as_annotated_lines_tokens)
39 41 from rhodecode.lib.utils import jsonify, action_logger
40 42 from rhodecode.lib.utils2 import (
41 43 convert_line_endings, detect_mode, safe_str, str2bool)
42 44 from rhodecode.lib.auth import (
43 45 LoginRequired, HasRepoPermissionAnyDecorator, CSRFRequired, XHRRequired)
44 46 from rhodecode.lib.base import BaseRepoController, render
45 47 from rhodecode.lib.vcs import path as vcspath
46 48 from rhodecode.lib.vcs.backends.base import EmptyCommit
47 49 from rhodecode.lib.vcs.conf import settings
48 50 from rhodecode.lib.vcs.exceptions import (
49 51 RepositoryError, CommitDoesNotExistError, EmptyRepositoryError,
50 52 ImproperArchiveTypeError, VCSError, NodeAlreadyExistsError,
51 53 NodeDoesNotExistError, CommitError, NodeError)
52 54 from rhodecode.lib.vcs.nodes import FileNode
53 55
54 56 from rhodecode.model.repo import RepoModel
55 57 from rhodecode.model.scm import ScmModel
56 58 from rhodecode.model.db import Repository
57 59
58 60 from rhodecode.controllers.changeset import (
59 61 _ignorews_url, _context_url, get_line_ctx, get_ignore_ws)
60 62 from rhodecode.lib.exceptions import NonRelativePathError
61 63
62 64 log = logging.getLogger(__name__)
63 65
64 66
65 67 class FilesController(BaseRepoController):
66 68
67 69 def __before__(self):
68 70 super(FilesController, self).__before__()
69 71 c.cut_off_limit = self.cut_off_limit_file
70 72
71 73 def _get_default_encoding(self):
72 74 enc_list = getattr(c, 'default_encodings', [])
73 75 return enc_list[0] if enc_list else 'UTF-8'
74 76
75 77 def __get_commit_or_redirect(self, commit_id, repo_name,
76 78 redirect_after=True):
77 79 """
78 80 This is a safe way to get commit. If an error occurs it redirects to
79 81 tip with proper message
80 82
81 83 :param commit_id: id of commit to fetch
82 84 :param repo_name: repo name to redirect after
83 85 :param redirect_after: toggle redirection
84 86 """
85 87 try:
86 88 return c.rhodecode_repo.get_commit(commit_id)
87 89 except EmptyRepositoryError:
88 90 if not redirect_after:
89 91 return None
90 92 url_ = url('files_add_home',
91 93 repo_name=c.repo_name,
92 94 revision=0, f_path='', anchor='edit')
93 95 if h.HasRepoPermissionAny(
94 96 'repository.write', 'repository.admin')(c.repo_name):
95 97 add_new = h.link_to(
96 98 _('Click here to add a new file.'),
97 99 url_, class_="alert-link")
98 100 else:
99 101 add_new = ""
100 102 h.flash(h.literal(
101 103 _('There are no files yet. %s') % add_new), category='warning')
102 104 redirect(h.url('summary_home', repo_name=repo_name))
103 105 except (CommitDoesNotExistError, LookupError):
104 106 msg = _('No such commit exists for this repository')
105 107 h.flash(msg, category='error')
106 108 raise HTTPNotFound()
107 109 except RepositoryError as e:
108 110 h.flash(safe_str(e), category='error')
109 111 raise HTTPNotFound()
110 112
111 113 def __get_filenode_or_redirect(self, repo_name, commit, path):
112 114 """
113 115 Returns file_node, if error occurs or given path is directory,
114 116 it'll redirect to top level path
115 117
116 118 :param repo_name: repo_name
117 119 :param commit: given commit
118 120 :param path: path to lookup
119 121 """
120 122 try:
121 123 file_node = commit.get_node(path)
122 124 if file_node.is_dir():
123 125 raise RepositoryError('The given path is a directory')
124 126 except CommitDoesNotExistError:
125 127 msg = _('No such commit exists for this repository')
126 128 log.exception(msg)
127 129 h.flash(msg, category='error')
128 130 raise HTTPNotFound()
129 131 except RepositoryError as e:
130 132 h.flash(safe_str(e), category='error')
131 133 raise HTTPNotFound()
132 134
133 135 return file_node
134 136
135 137 def __get_tree_cache_manager(self, repo_name, namespace_type):
136 138 _namespace = caches.get_repo_namespace_key(namespace_type, repo_name)
137 139 return caches.get_cache_manager('repo_cache_long', _namespace)
138 140
139 141 def _get_tree_at_commit(self, repo_name, commit_id, f_path,
140 142 full_load=False, force=False):
141 143 def _cached_tree():
142 144 log.debug('Generating cached file tree for %s, %s, %s',
143 145 repo_name, commit_id, f_path)
144 146 c.full_load = full_load
145 147 return render('files/files_browser_tree.html')
146 148
147 149 cache_manager = self.__get_tree_cache_manager(
148 150 repo_name, caches.FILE_TREE)
149 151
150 152 cache_key = caches.compute_key_from_params(
151 153 repo_name, commit_id, f_path)
152 154
153 155 if force:
154 156 # we want to force recompute of caches
155 157 cache_manager.remove_value(cache_key)
156 158
157 159 return cache_manager.get(cache_key, createfunc=_cached_tree)
158 160
159 161 def _get_nodelist_at_commit(self, repo_name, commit_id, f_path):
160 162 def _cached_nodes():
161 163 log.debug('Generating cached nodelist for %s, %s, %s',
162 164 repo_name, commit_id, f_path)
163 165 _d, _f = ScmModel().get_nodes(
164 166 repo_name, commit_id, f_path, flat=False)
165 167 return _d + _f
166 168
167 169 cache_manager = self.__get_tree_cache_manager(
168 170 repo_name, caches.FILE_SEARCH_TREE_META)
169 171
170 172 cache_key = caches.compute_key_from_params(
171 173 repo_name, commit_id, f_path)
172 174 return cache_manager.get(cache_key, createfunc=_cached_nodes)
173 175
174 176 @LoginRequired()
175 177 @HasRepoPermissionAnyDecorator(
176 178 'repository.read', 'repository.write', 'repository.admin')
177 179 def index(
178 180 self, repo_name, revision, f_path, annotate=False, rendered=False):
179 181 commit_id = revision
180 182
181 183 # redirect to given commit_id from form if given
182 184 get_commit_id = request.GET.get('at_rev', None)
183 185 if get_commit_id:
184 186 self.__get_commit_or_redirect(get_commit_id, repo_name)
185 187
186 188 c.commit = self.__get_commit_or_redirect(commit_id, repo_name)
187 189 c.branch = request.GET.get('branch', None)
188 190 c.f_path = f_path
189 191 c.annotate = annotate
190 192 # default is false, but .rst/.md files later are autorendered, we can
191 193 # overwrite autorendering by setting this GET flag
192 194 c.renderer = rendered or not request.GET.get('no-render', False)
193 195
194 196 # prev link
195 197 try:
196 198 prev_commit = c.commit.prev(c.branch)
197 199 c.prev_commit = prev_commit
198 200 c.url_prev = url('files_home', repo_name=c.repo_name,
199 201 revision=prev_commit.raw_id, f_path=f_path)
200 202 if c.branch:
201 203 c.url_prev += '?branch=%s' % c.branch
202 204 except (CommitDoesNotExistError, VCSError):
203 205 c.url_prev = '#'
204 206 c.prev_commit = EmptyCommit()
205 207
206 208 # next link
207 209 try:
208 210 next_commit = c.commit.next(c.branch)
209 211 c.next_commit = next_commit
210 212 c.url_next = url('files_home', repo_name=c.repo_name,
211 213 revision=next_commit.raw_id, f_path=f_path)
212 214 if c.branch:
213 215 c.url_next += '?branch=%s' % c.branch
214 216 except (CommitDoesNotExistError, VCSError):
215 217 c.url_next = '#'
216 218 c.next_commit = EmptyCommit()
217 219
218 220 # files or dirs
219 221 try:
220 222 c.file = c.commit.get_node(f_path)
221 223 c.file_author = True
222 224 c.file_tree = ''
223 225 if c.file.is_file():
226 c.file_last_commit = c.file.last_commit
227 if c.annotate: # annotation has precedence over renderer
228 c.annotated_lines = filenode_as_annotated_lines_tokens(
229 c.file)
230 else:
224 231 c.renderer = (
225 232 c.renderer and h.renderer_from_filename(c.file.path))
226 c.file_last_commit = c.file.last_commit
233 if not c.renderer:
234 c.lines = filenode_as_lines_tokens(c.file)
227 235
228 236 c.on_branch_head = self._is_valid_head(
229 237 commit_id, c.rhodecode_repo)
230 238 c.branch_or_raw_id = c.commit.branch or c.commit.raw_id
231 239
232 240 author = c.file_last_commit.author
233 241 c.authors = [(h.email(author),
234 242 h.person(author, 'username_or_name_or_email'))]
235 243 else:
236 244 c.authors = []
237 245 c.file_tree = self._get_tree_at_commit(
238 246 repo_name, c.commit.raw_id, f_path)
239 247
240 248 except RepositoryError as e:
241 249 h.flash(safe_str(e), category='error')
242 250 raise HTTPNotFound()
243 251
244 252 if request.environ.get('HTTP_X_PJAX'):
245 253 return render('files/files_pjax.html')
246 254
247 255 return render('files/files.html')
248 256
249 257 @LoginRequired()
250 258 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
251 259 'repository.admin')
252 260 @jsonify
253 261 def history(self, repo_name, revision, f_path):
254 262 commit = self.__get_commit_or_redirect(revision, repo_name)
255 263 f_path = f_path
256 264 _file = commit.get_node(f_path)
257 265 if _file.is_file():
258 266 file_history, _hist = self._get_node_history(commit, f_path)
259 267
260 268 res = []
261 269 for obj in file_history:
262 270 res.append({
263 271 'text': obj[1],
264 272 'children': [{'id': o[0], 'text': o[1]} for o in obj[0]]
265 273 })
266 274
267 275 data = {
268 276 'more': False,
269 277 'results': res
270 278 }
271 279 return data
272 280
273 281 @LoginRequired()
274 282 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
275 283 'repository.admin')
276 284 def authors(self, repo_name, revision, f_path):
277 285 commit = self.__get_commit_or_redirect(revision, repo_name)
278 286 file_node = commit.get_node(f_path)
279 287 if file_node.is_file():
280 288 c.file_last_commit = file_node.last_commit
281 289 if request.GET.get('annotate') == '1':
282 290 # use _hist from annotation if annotation mode is on
283 291 commit_ids = set(x[1] for x in file_node.annotate)
284 292 _hist = (
285 293 c.rhodecode_repo.get_commit(commit_id)
286 294 for commit_id in commit_ids)
287 295 else:
288 296 _f_history, _hist = self._get_node_history(commit, f_path)
289 297 c.file_author = False
290 298 c.authors = []
291 299 for author in set(commit.author for commit in _hist):
292 300 c.authors.append((
293 301 h.email(author),
294 302 h.person(author, 'username_or_name_or_email')))
295 303 return render('files/file_authors_box.html')
296 304
297 305 @LoginRequired()
298 306 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
299 307 'repository.admin')
300 308 def rawfile(self, repo_name, revision, f_path):
301 309 """
302 310 Action for download as raw
303 311 """
304 312 commit = self.__get_commit_or_redirect(revision, repo_name)
305 313 file_node = self.__get_filenode_or_redirect(repo_name, commit, f_path)
306 314
307 315 response.content_disposition = 'attachment; filename=%s' % \
308 316 safe_str(f_path.split(Repository.NAME_SEP)[-1])
309 317
310 318 response.content_type = file_node.mimetype
311 319 charset = self._get_default_encoding()
312 320 if charset:
313 321 response.charset = charset
314 322
315 323 return file_node.content
316 324
317 325 @LoginRequired()
318 326 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
319 327 'repository.admin')
320 328 def raw(self, repo_name, revision, f_path):
321 329 """
322 330 Action for show as raw, some mimetypes are "rendered",
323 331 those include images, icons.
324 332 """
325 333 commit = self.__get_commit_or_redirect(revision, repo_name)
326 334 file_node = self.__get_filenode_or_redirect(repo_name, commit, f_path)
327 335
328 336 raw_mimetype_mapping = {
329 337 # map original mimetype to a mimetype used for "show as raw"
330 338 # you can also provide a content-disposition to override the
331 339 # default "attachment" disposition.
332 340 # orig_type: (new_type, new_dispo)
333 341
334 342 # show images inline:
335 343 # Do not re-add SVG: it is unsafe and permits XSS attacks. One can
336 344 # for example render an SVG with javascript inside or even render
337 345 # HTML.
338 346 'image/x-icon': ('image/x-icon', 'inline'),
339 347 'image/png': ('image/png', 'inline'),
340 348 'image/gif': ('image/gif', 'inline'),
341 349 'image/jpeg': ('image/jpeg', 'inline'),
342 350 }
343 351
344 352 mimetype = file_node.mimetype
345 353 try:
346 354 mimetype, dispo = raw_mimetype_mapping[mimetype]
347 355 except KeyError:
348 356 # we don't know anything special about this, handle it safely
349 357 if file_node.is_binary:
350 358 # do same as download raw for binary files
351 359 mimetype, dispo = 'application/octet-stream', 'attachment'
352 360 else:
353 361 # do not just use the original mimetype, but force text/plain,
354 362 # otherwise it would serve text/html and that might be unsafe.
355 363 # Note: underlying vcs library fakes text/plain mimetype if the
356 364 # mimetype can not be determined and it thinks it is not
357 365 # binary.This might lead to erroneous text display in some
358 366 # cases, but helps in other cases, like with text files
359 367 # without extension.
360 368 mimetype, dispo = 'text/plain', 'inline'
361 369
362 370 if dispo == 'attachment':
363 371 dispo = 'attachment; filename=%s' % safe_str(
364 372 f_path.split(os.sep)[-1])
365 373
366 374 response.content_disposition = dispo
367 375 response.content_type = mimetype
368 376 charset = self._get_default_encoding()
369 377 if charset:
370 378 response.charset = charset
371 379 return file_node.content
372 380
373 381 @CSRFRequired()
374 382 @LoginRequired()
375 383 @HasRepoPermissionAnyDecorator('repository.write', 'repository.admin')
376 384 def delete(self, repo_name, revision, f_path):
377 385 commit_id = revision
378 386
379 387 repo = c.rhodecode_db_repo
380 388 if repo.enable_locking and repo.locked[0]:
381 389 h.flash(_('This repository has been locked by %s on %s')
382 390 % (h.person_by_id(repo.locked[0]),
383 391 h.format_date(h.time_to_datetime(repo.locked[1]))),
384 392 'warning')
385 393 return redirect(h.url('files_home',
386 394 repo_name=repo_name, revision='tip'))
387 395
388 396 if not self._is_valid_head(commit_id, repo.scm_instance()):
389 397 h.flash(_('You can only delete files with revision '
390 398 'being a valid branch '), category='warning')
391 399 return redirect(h.url('files_home',
392 400 repo_name=repo_name, revision='tip',
393 401 f_path=f_path))
394 402
395 403 c.commit = self.__get_commit_or_redirect(commit_id, repo_name)
396 404 c.file = self.__get_filenode_or_redirect(repo_name, c.commit, f_path)
397 405
398 406 c.default_message = _(
399 407 'Deleted file %s via RhodeCode Enterprise') % (f_path)
400 408 c.f_path = f_path
401 409 node_path = f_path
402 410 author = c.rhodecode_user.full_contact
403 411 message = request.POST.get('message') or c.default_message
404 412 try:
405 413 nodes = {
406 414 node_path: {
407 415 'content': ''
408 416 }
409 417 }
410 418 self.scm_model.delete_nodes(
411 419 user=c.rhodecode_user.user_id, repo=c.rhodecode_db_repo,
412 420 message=message,
413 421 nodes=nodes,
414 422 parent_commit=c.commit,
415 423 author=author,
416 424 )
417 425
418 426 h.flash(_('Successfully deleted file %s') % f_path,
419 427 category='success')
420 428 except Exception:
421 429 msg = _('Error occurred during commit')
422 430 log.exception(msg)
423 431 h.flash(msg, category='error')
424 432 return redirect(url('changeset_home',
425 433 repo_name=c.repo_name, revision='tip'))
426 434
427 435 @LoginRequired()
428 436 @HasRepoPermissionAnyDecorator('repository.write', 'repository.admin')
429 437 def delete_home(self, repo_name, revision, f_path):
430 438 commit_id = revision
431 439
432 440 repo = c.rhodecode_db_repo
433 441 if repo.enable_locking and repo.locked[0]:
434 442 h.flash(_('This repository has been locked by %s on %s')
435 443 % (h.person_by_id(repo.locked[0]),
436 444 h.format_date(h.time_to_datetime(repo.locked[1]))),
437 445 'warning')
438 446 return redirect(h.url('files_home',
439 447 repo_name=repo_name, revision='tip'))
440 448
441 449 if not self._is_valid_head(commit_id, repo.scm_instance()):
442 450 h.flash(_('You can only delete files with revision '
443 451 'being a valid branch '), category='warning')
444 452 return redirect(h.url('files_home',
445 453 repo_name=repo_name, revision='tip',
446 454 f_path=f_path))
447 455
448 456 c.commit = self.__get_commit_or_redirect(commit_id, repo_name)
449 457 c.file = self.__get_filenode_or_redirect(repo_name, c.commit, f_path)
450 458
451 459 c.default_message = _(
452 460 'Deleted file %s via RhodeCode Enterprise') % (f_path)
453 461 c.f_path = f_path
454 462
455 463 return render('files/files_delete.html')
456 464
457 465 @CSRFRequired()
458 466 @LoginRequired()
459 467 @HasRepoPermissionAnyDecorator('repository.write', 'repository.admin')
460 468 def edit(self, repo_name, revision, f_path):
461 469 commit_id = revision
462 470
463 471 repo = c.rhodecode_db_repo
464 472 if repo.enable_locking and repo.locked[0]:
465 473 h.flash(_('This repository has been locked by %s on %s')
466 474 % (h.person_by_id(repo.locked[0]),
467 475 h.format_date(h.time_to_datetime(repo.locked[1]))),
468 476 'warning')
469 477 return redirect(h.url('files_home',
470 478 repo_name=repo_name, revision='tip'))
471 479
472 480 if not self._is_valid_head(commit_id, repo.scm_instance()):
473 481 h.flash(_('You can only edit files with revision '
474 482 'being a valid branch '), category='warning')
475 483 return redirect(h.url('files_home',
476 484 repo_name=repo_name, revision='tip',
477 485 f_path=f_path))
478 486
479 487 c.commit = self.__get_commit_or_redirect(commit_id, repo_name)
480 488 c.file = self.__get_filenode_or_redirect(repo_name, c.commit, f_path)
481 489
482 490 if c.file.is_binary:
483 491 return redirect(url('files_home', repo_name=c.repo_name,
484 492 revision=c.commit.raw_id, f_path=f_path))
485 493 c.default_message = _(
486 494 'Edited file %s via RhodeCode Enterprise') % (f_path)
487 495 c.f_path = f_path
488 496 old_content = c.file.content
489 497 sl = old_content.splitlines(1)
490 498 first_line = sl[0] if sl else ''
491 499
492 500 # modes: 0 - Unix, 1 - Mac, 2 - DOS
493 501 mode = detect_mode(first_line, 0)
494 502 content = convert_line_endings(request.POST.get('content', ''), mode)
495 503
496 504 message = request.POST.get('message') or c.default_message
497 505 org_f_path = c.file.unicode_path
498 506 filename = request.POST['filename']
499 507 org_filename = c.file.name
500 508
501 509 if content == old_content and filename == org_filename:
502 510 h.flash(_('No changes'), category='warning')
503 511 return redirect(url('changeset_home', repo_name=c.repo_name,
504 512 revision='tip'))
505 513 try:
506 514 mapping = {
507 515 org_f_path: {
508 516 'org_filename': org_f_path,
509 517 'filename': os.path.join(c.file.dir_path, filename),
510 518 'content': content,
511 519 'lexer': '',
512 520 'op': 'mod',
513 521 }
514 522 }
515 523
516 524 ScmModel().update_nodes(
517 525 user=c.rhodecode_user.user_id,
518 526 repo=c.rhodecode_db_repo,
519 527 message=message,
520 528 nodes=mapping,
521 529 parent_commit=c.commit,
522 530 )
523 531
524 532 h.flash(_('Successfully committed to %s') % f_path,
525 533 category='success')
526 534 except Exception:
527 535 msg = _('Error occurred during commit')
528 536 log.exception(msg)
529 537 h.flash(msg, category='error')
530 538 return redirect(url('changeset_home',
531 539 repo_name=c.repo_name, revision='tip'))
532 540
533 541 @LoginRequired()
534 542 @HasRepoPermissionAnyDecorator('repository.write', 'repository.admin')
535 543 def edit_home(self, repo_name, revision, f_path):
536 544 commit_id = revision
537 545
538 546 repo = c.rhodecode_db_repo
539 547 if repo.enable_locking and repo.locked[0]:
540 548 h.flash(_('This repository has been locked by %s on %s')
541 549 % (h.person_by_id(repo.locked[0]),
542 550 h.format_date(h.time_to_datetime(repo.locked[1]))),
543 551 'warning')
544 552 return redirect(h.url('files_home',
545 553 repo_name=repo_name, revision='tip'))
546 554
547 555 if not self._is_valid_head(commit_id, repo.scm_instance()):
548 556 h.flash(_('You can only edit files with revision '
549 557 'being a valid branch '), category='warning')
550 558 return redirect(h.url('files_home',
551 559 repo_name=repo_name, revision='tip',
552 560 f_path=f_path))
553 561
554 562 c.commit = self.__get_commit_or_redirect(commit_id, repo_name)
555 563 c.file = self.__get_filenode_or_redirect(repo_name, c.commit, f_path)
556 564
557 565 if c.file.is_binary:
558 566 return redirect(url('files_home', repo_name=c.repo_name,
559 567 revision=c.commit.raw_id, f_path=f_path))
560 568 c.default_message = _(
561 569 'Edited file %s via RhodeCode Enterprise') % (f_path)
562 570 c.f_path = f_path
563 571
564 572 return render('files/files_edit.html')
565 573
566 574 def _is_valid_head(self, commit_id, repo):
567 575 # check if commit is a branch identifier- basically we cannot
568 576 # create multiple heads via file editing
569 577 valid_heads = repo.branches.keys() + repo.branches.values()
570 578
571 579 if h.is_svn(repo) and not repo.is_empty():
572 580 # Note: Subversion only has one head, we add it here in case there
573 581 # is no branch matched.
574 582 valid_heads.append(repo.get_commit(commit_idx=-1).raw_id)
575 583
576 584 # check if commit is a branch name or branch hash
577 585 return commit_id in valid_heads
578 586
579 587 @CSRFRequired()
580 588 @LoginRequired()
581 589 @HasRepoPermissionAnyDecorator('repository.write', 'repository.admin')
582 590 def add(self, repo_name, revision, f_path):
583 591 repo = Repository.get_by_repo_name(repo_name)
584 592 if repo.enable_locking and repo.locked[0]:
585 593 h.flash(_('This repository has been locked by %s on %s')
586 594 % (h.person_by_id(repo.locked[0]),
587 595 h.format_date(h.time_to_datetime(repo.locked[1]))),
588 596 'warning')
589 597 return redirect(h.url('files_home',
590 598 repo_name=repo_name, revision='tip'))
591 599
592 600 r_post = request.POST
593 601
594 602 c.commit = self.__get_commit_or_redirect(
595 603 revision, repo_name, redirect_after=False)
596 604 if c.commit is None:
597 605 c.commit = EmptyCommit(alias=c.rhodecode_repo.alias)
598 606 c.default_message = (_('Added file via RhodeCode Enterprise'))
599 607 c.f_path = f_path
600 608 unix_mode = 0
601 609 content = convert_line_endings(r_post.get('content', ''), unix_mode)
602 610
603 611 message = r_post.get('message') or c.default_message
604 612 filename = r_post.get('filename')
605 613 location = r_post.get('location', '') # dir location
606 614 file_obj = r_post.get('upload_file', None)
607 615
608 616 if file_obj is not None and hasattr(file_obj, 'filename'):
609 617 filename = file_obj.filename
610 618 content = file_obj.file
611 619
612 620 if hasattr(content, 'file'):
613 621 # non posix systems store real file under file attr
614 622 content = content.file
615 623
616 624 # If there's no commit, redirect to repo summary
617 625 if type(c.commit) is EmptyCommit:
618 626 redirect_url = "summary_home"
619 627 else:
620 628 redirect_url = "changeset_home"
621 629
622 630 if not filename:
623 631 h.flash(_('No filename'), category='warning')
624 632 return redirect(url(redirect_url, repo_name=c.repo_name,
625 633 revision='tip'))
626 634
627 635 # extract the location from filename,
628 636 # allows using foo/bar.txt syntax to create subdirectories
629 637 subdir_loc = filename.rsplit('/', 1)
630 638 if len(subdir_loc) == 2:
631 639 location = os.path.join(location, subdir_loc[0])
632 640
633 641 # strip all crap out of file, just leave the basename
634 642 filename = os.path.basename(filename)
635 643 node_path = os.path.join(location, filename)
636 644 author = c.rhodecode_user.full_contact
637 645
638 646 try:
639 647 nodes = {
640 648 node_path: {
641 649 'content': content
642 650 }
643 651 }
644 652 self.scm_model.create_nodes(
645 653 user=c.rhodecode_user.user_id,
646 654 repo=c.rhodecode_db_repo,
647 655 message=message,
648 656 nodes=nodes,
649 657 parent_commit=c.commit,
650 658 author=author,
651 659 )
652 660
653 661 h.flash(_('Successfully committed to %s') % node_path,
654 662 category='success')
655 663 except NonRelativePathError as e:
656 664 h.flash(_(
657 665 'The location specified must be a relative path and must not '
658 666 'contain .. in the path'), category='warning')
659 667 return redirect(url('changeset_home', repo_name=c.repo_name,
660 668 revision='tip'))
661 669 except (NodeError, NodeAlreadyExistsError) as e:
662 670 h.flash(_(e), category='error')
663 671 except Exception:
664 672 msg = _('Error occurred during commit')
665 673 log.exception(msg)
666 674 h.flash(msg, category='error')
667 675 return redirect(url('changeset_home',
668 676 repo_name=c.repo_name, revision='tip'))
669 677
670 678 @LoginRequired()
671 679 @HasRepoPermissionAnyDecorator('repository.write', 'repository.admin')
672 680 def add_home(self, repo_name, revision, f_path):
673 681
674 682 repo = Repository.get_by_repo_name(repo_name)
675 683 if repo.enable_locking and repo.locked[0]:
676 684 h.flash(_('This repository has been locked by %s on %s')
677 685 % (h.person_by_id(repo.locked[0]),
678 686 h.format_date(h.time_to_datetime(repo.locked[1]))),
679 687 'warning')
680 688 return redirect(h.url('files_home',
681 689 repo_name=repo_name, revision='tip'))
682 690
683 691 c.commit = self.__get_commit_or_redirect(
684 692 revision, repo_name, redirect_after=False)
685 693 if c.commit is None:
686 694 c.commit = EmptyCommit(alias=c.rhodecode_repo.alias)
687 695 c.default_message = (_('Added file via RhodeCode Enterprise'))
688 696 c.f_path = f_path
689 697
690 698 return render('files/files_add.html')
691 699
692 700 @LoginRequired()
693 701 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
694 702 'repository.admin')
695 703 def archivefile(self, repo_name, fname):
696 704 fileformat = None
697 705 commit_id = None
698 706 ext = None
699 707 subrepos = request.GET.get('subrepos') == 'true'
700 708
701 709 for a_type, ext_data in settings.ARCHIVE_SPECS.items():
702 710 archive_spec = fname.split(ext_data[1])
703 711 if len(archive_spec) == 2 and archive_spec[1] == '':
704 712 fileformat = a_type or ext_data[1]
705 713 commit_id = archive_spec[0]
706 714 ext = ext_data[1]
707 715
708 716 dbrepo = RepoModel().get_by_repo_name(repo_name)
709 717 if not dbrepo.enable_downloads:
710 718 return _('Downloads disabled')
711 719
712 720 try:
713 721 commit = c.rhodecode_repo.get_commit(commit_id)
714 722 content_type = settings.ARCHIVE_SPECS[fileformat][0]
715 723 except CommitDoesNotExistError:
716 724 return _('Unknown revision %s') % commit_id
717 725 except EmptyRepositoryError:
718 726 return _('Empty repository')
719 727 except KeyError:
720 728 return _('Unknown archive type')
721 729
722 730 # archive cache
723 731 from rhodecode import CONFIG
724 732
725 733 archive_name = '%s-%s%s%s' % (
726 734 safe_str(repo_name.replace('/', '_')),
727 735 '-sub' if subrepos else '',
728 736 safe_str(commit.short_id), ext)
729 737
730 738 use_cached_archive = False
731 739 archive_cache_enabled = CONFIG.get(
732 740 'archive_cache_dir') and not request.GET.get('no_cache')
733 741
734 742 if archive_cache_enabled:
735 743 # check if we it's ok to write
736 744 if not os.path.isdir(CONFIG['archive_cache_dir']):
737 745 os.makedirs(CONFIG['archive_cache_dir'])
738 746 cached_archive_path = os.path.join(
739 747 CONFIG['archive_cache_dir'], archive_name)
740 748 if os.path.isfile(cached_archive_path):
741 749 log.debug('Found cached archive in %s', cached_archive_path)
742 750 fd, archive = None, cached_archive_path
743 751 use_cached_archive = True
744 752 else:
745 753 log.debug('Archive %s is not yet cached', archive_name)
746 754
747 755 if not use_cached_archive:
748 756 # generate new archive
749 757 fd, archive = tempfile.mkstemp()
750 758 log.debug('Creating new temp archive in %s' % (archive,))
751 759 try:
752 760 commit.archive_repo(archive, kind=fileformat, subrepos=subrepos)
753 761 except ImproperArchiveTypeError:
754 762 return _('Unknown archive type')
755 763 if archive_cache_enabled:
756 764 # if we generated the archive and we have cache enabled
757 765 # let's use this for future
758 766 log.debug('Storing new archive in %s' % (cached_archive_path,))
759 767 shutil.move(archive, cached_archive_path)
760 768 archive = cached_archive_path
761 769
762 770 def get_chunked_archive(archive):
763 771 with open(archive, 'rb') as stream:
764 772 while True:
765 773 data = stream.read(16 * 1024)
766 774 if not data:
767 775 if fd: # fd means we used temporary file
768 776 os.close(fd)
769 777 if not archive_cache_enabled:
770 778 log.debug('Destroying temp archive %s', archive)
771 779 os.remove(archive)
772 780 break
773 781 yield data
774 782
775 783 # store download action
776 784 action_logger(user=c.rhodecode_user,
777 785 action='user_downloaded_archive:%s' % archive_name,
778 786 repo=repo_name, ipaddr=self.ip_addr, commit=True)
779 787 response.content_disposition = str(
780 788 'attachment; filename=%s' % archive_name)
781 789 response.content_type = str(content_type)
782 790
783 791 return get_chunked_archive(archive)
784 792
785 793 @LoginRequired()
786 794 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
787 795 'repository.admin')
788 796 def diff(self, repo_name, f_path):
789 797 ignore_whitespace = request.GET.get('ignorews') == '1'
790 798 line_context = request.GET.get('context', 3)
791 799 diff1 = request.GET.get('diff1', '')
792 800
793 801 path1, diff1 = parse_path_ref(diff1, default_path=f_path)
794 802
795 803 diff2 = request.GET.get('diff2', '')
796 804 c.action = request.GET.get('diff')
797 805 c.no_changes = diff1 == diff2
798 806 c.f_path = f_path
799 807 c.big_diff = False
800 808 c.ignorews_url = _ignorews_url
801 809 c.context_url = _context_url
802 810 c.changes = OrderedDict()
803 811 c.changes[diff2] = []
804 812
805 813 if not any((diff1, diff2)):
806 814 h.flash(
807 815 'Need query parameter "diff1" or "diff2" to generate a diff.',
808 816 category='error')
809 817 raise HTTPBadRequest()
810 818
811 819 # special case if we want a show commit_id only, it's impl here
812 820 # to reduce JS and callbacks
813 821
814 822 if request.GET.get('show_rev') and diff1:
815 823 if str2bool(request.GET.get('annotate', 'False')):
816 824 _url = url('files_annotate_home', repo_name=c.repo_name,
817 825 revision=diff1, f_path=path1)
818 826 else:
819 827 _url = url('files_home', repo_name=c.repo_name,
820 828 revision=diff1, f_path=path1)
821 829
822 830 return redirect(_url)
823 831
824 832 try:
825 833 node1 = self._get_file_node(diff1, path1)
826 834 node2 = self._get_file_node(diff2, f_path)
827 835 except (RepositoryError, NodeError):
828 836 log.exception("Exception while trying to get node from repository")
829 837 return redirect(url(
830 838 'files_home', repo_name=c.repo_name, f_path=f_path))
831 839
832 840 if all(isinstance(node.commit, EmptyCommit)
833 841 for node in (node1, node2)):
834 842 raise HTTPNotFound
835 843
836 844 c.commit_1 = node1.commit
837 845 c.commit_2 = node2.commit
838 846
839 847 if c.action == 'download':
840 848 _diff = diffs.get_gitdiff(node1, node2,
841 849 ignore_whitespace=ignore_whitespace,
842 850 context=line_context)
843 851 diff = diffs.DiffProcessor(_diff, format='gitdiff')
844 852
845 853 diff_name = '%s_vs_%s.diff' % (diff1, diff2)
846 854 response.content_type = 'text/plain'
847 855 response.content_disposition = (
848 856 'attachment; filename=%s' % (diff_name,)
849 857 )
850 858 charset = self._get_default_encoding()
851 859 if charset:
852 860 response.charset = charset
853 861 return diff.as_raw()
854 862
855 863 elif c.action == 'raw':
856 864 _diff = diffs.get_gitdiff(node1, node2,
857 865 ignore_whitespace=ignore_whitespace,
858 866 context=line_context)
859 867 diff = diffs.DiffProcessor(_diff, format='gitdiff')
860 868 response.content_type = 'text/plain'
861 869 charset = self._get_default_encoding()
862 870 if charset:
863 871 response.charset = charset
864 872 return diff.as_raw()
865 873
866 874 else:
867 875 fid = h.FID(diff2, node2.path)
868 876 line_context_lcl = get_line_ctx(fid, request.GET)
869 877 ign_whitespace_lcl = get_ignore_ws(fid, request.GET)
870 878
871 879 __, commit1, commit2, diff, st, data = diffs.wrapped_diff(
872 880 filenode_old=node1,
873 881 filenode_new=node2,
874 882 diff_limit=self.cut_off_limit_diff,
875 883 file_limit=self.cut_off_limit_file,
876 884 show_full_diff=request.GET.get('fulldiff'),
877 885 ignore_whitespace=ign_whitespace_lcl,
878 886 line_context=line_context_lcl,)
879 887
880 888 c.lines_added = data['stats']['added'] if data else 0
881 889 c.lines_deleted = data['stats']['deleted'] if data else 0
882 890 c.files = [data]
883 891 c.commit_ranges = [c.commit_1, c.commit_2]
884 892 c.ancestor = None
885 893 c.statuses = []
886 894 c.target_repo = c.rhodecode_db_repo
887 895 c.filename1 = node1.path
888 896 c.filename = node2.path
889 897 c.binary_file = node1.is_binary or node2.is_binary
890 898 operation = data['operation'] if data else ''
891 899
892 900 commit_changes = {
893 901 # TODO: it's passing the old file to the diff to keep the
894 902 # standard but this is not being used for this template,
895 903 # but might need both files in the future or a more standard
896 904 # way to work with that
897 905 'fid': [commit1, commit2, operation,
898 906 c.filename, diff, st, data]
899 907 }
900 908
901 909 c.changes = commit_changes
902 910
903 911 return render('files/file_diff.html')
904 912
905 913 @LoginRequired()
906 914 @HasRepoPermissionAnyDecorator('repository.read', 'repository.write',
907 915 'repository.admin')
908 916 def diff_2way(self, repo_name, f_path):
909 917 diff1 = request.GET.get('diff1', '')
910 918 diff2 = request.GET.get('diff2', '')
911 919
912 920 nodes = []
913 921 unknown_commits = []
914 922 for commit in [diff1, diff2]:
915 923 try:
916 924 nodes.append(self._get_file_node(commit, f_path))
917 925 except (RepositoryError, NodeError):
918 926 log.exception('%(commit)s does not exist' % {'commit': commit})
919 927 unknown_commits.append(commit)
920 928 h.flash(h.literal(
921 929 _('Commit %(commit)s does not exist.') % {'commit': commit}
922 930 ), category='error')
923 931
924 932 if unknown_commits:
925 933 return redirect(url('files_home', repo_name=c.repo_name,
926 934 f_path=f_path))
927 935
928 936 if all(isinstance(node.commit, EmptyCommit) for node in nodes):
929 937 raise HTTPNotFound
930 938
931 939 node1, node2 = nodes
932 940
933 941 f_gitdiff = diffs.get_gitdiff(node1, node2, ignore_whitespace=False)
934 942 diff_processor = diffs.DiffProcessor(f_gitdiff, format='gitdiff')
935 943 diff_data = diff_processor.prepare()
936 944
937 945 if not diff_data or diff_data[0]['raw_diff'] == '':
938 946 h.flash(h.literal(_('%(file_path)s has not changed '
939 947 'between %(commit_1)s and %(commit_2)s.') % {
940 948 'file_path': f_path,
941 949 'commit_1': node1.commit.id,
942 950 'commit_2': node2.commit.id
943 951 }), category='error')
944 952 return redirect(url('files_home', repo_name=c.repo_name,
945 953 f_path=f_path))
946 954
947 955 c.diff_data = diff_data[0]
948 956 c.FID = h.FID(diff2, node2.path)
949 957 # cleanup some unneeded data
950 958 del c.diff_data['raw_diff']
951 959 del c.diff_data['chunks']
952 960
953 961 c.node1 = node1
954 962 c.commit_1 = node1.commit
955 963 c.node2 = node2
956 964 c.commit_2 = node2.commit
957 965
958 966 return render('files/diff_2way.html')
959 967
960 968 def _get_file_node(self, commit_id, f_path):
961 969 if commit_id not in ['', None, 'None', '0' * 12, '0' * 40]:
962 970 commit = c.rhodecode_repo.get_commit(commit_id=commit_id)
963 971 try:
964 972 node = commit.get_node(f_path)
965 973 if node.is_dir():
966 974 raise NodeError('%s path is a %s not a file'
967 975 % (node, type(node)))
968 976 except NodeDoesNotExistError:
969 977 commit = EmptyCommit(
970 978 commit_id=commit_id,
971 979 idx=commit.idx,
972 980 repo=commit.repository,
973 981 alias=commit.repository.alias,
974 982 message=commit.message,
975 983 author=commit.author,
976 984 date=commit.date)
977 985 node = FileNode(f_path, '', commit=commit)
978 986 else:
979 987 commit = EmptyCommit(
980 988 repo=c.rhodecode_repo,
981 989 alias=c.rhodecode_repo.alias)
982 990 node = FileNode(f_path, '', commit=commit)
983 991 return node
984 992
985 993 def _get_node_history(self, commit, f_path, commits=None):
986 994 """
987 995 get commit history for given node
988 996
989 997 :param commit: commit to calculate history
990 998 :param f_path: path for node to calculate history for
991 999 :param commits: if passed don't calculate history and take
992 1000 commits defined in this list
993 1001 """
994 1002 # calculate history based on tip
995 1003 tip = c.rhodecode_repo.get_commit()
996 1004 if commits is None:
997 1005 pre_load = ["author", "branch"]
998 1006 try:
999 1007 commits = tip.get_file_history(f_path, pre_load=pre_load)
1000 1008 except (NodeDoesNotExistError, CommitError):
1001 1009 # this node is not present at tip!
1002 1010 commits = commit.get_file_history(f_path, pre_load=pre_load)
1003 1011
1004 1012 history = []
1005 1013 commits_group = ([], _("Changesets"))
1006 1014 for commit in commits:
1007 1015 branch = ' (%s)' % commit.branch if commit.branch else ''
1008 1016 n_desc = 'r%s:%s%s' % (commit.idx, commit.short_id, branch)
1009 1017 commits_group[0].append((commit.raw_id, n_desc,))
1010 1018 history.append(commits_group)
1011 1019
1012 1020 symbolic_reference = self._symbolic_reference
1013 1021
1014 1022 if c.rhodecode_repo.alias == 'svn':
1015 1023 adjusted_f_path = self._adjust_file_path_for_svn(
1016 1024 f_path, c.rhodecode_repo)
1017 1025 if adjusted_f_path != f_path:
1018 1026 log.debug(
1019 1027 'Recognized svn tag or branch in file "%s", using svn '
1020 1028 'specific symbolic references', f_path)
1021 1029 f_path = adjusted_f_path
1022 1030 symbolic_reference = self._symbolic_reference_svn
1023 1031
1024 1032 branches = self._create_references(
1025 1033 c.rhodecode_repo.branches, symbolic_reference, f_path)
1026 1034 branches_group = (branches, _("Branches"))
1027 1035
1028 1036 tags = self._create_references(
1029 1037 c.rhodecode_repo.tags, symbolic_reference, f_path)
1030 1038 tags_group = (tags, _("Tags"))
1031 1039
1032 1040 history.append(branches_group)
1033 1041 history.append(tags_group)
1034 1042
1035 1043 return history, commits
1036 1044
1037 1045 def _adjust_file_path_for_svn(self, f_path, repo):
1038 1046 """
1039 1047 Computes the relative path of `f_path`.
1040 1048
1041 1049 This is mainly based on prefix matching of the recognized tags and
1042 1050 branches in the underlying repository.
1043 1051 """
1044 1052 tags_and_branches = itertools.chain(
1045 1053 repo.branches.iterkeys(),
1046 1054 repo.tags.iterkeys())
1047 1055 tags_and_branches = sorted(tags_and_branches, key=len, reverse=True)
1048 1056
1049 1057 for name in tags_and_branches:
1050 1058 if f_path.startswith(name + '/'):
1051 1059 f_path = vcspath.relpath(f_path, name)
1052 1060 break
1053 1061 return f_path
1054 1062
1055 1063 def _create_references(
1056 1064 self, branches_or_tags, symbolic_reference, f_path):
1057 1065 items = []
1058 1066 for name, commit_id in branches_or_tags.items():
1059 1067 sym_ref = symbolic_reference(commit_id, name, f_path)
1060 1068 items.append((sym_ref, name))
1061 1069 return items
1062 1070
1063 1071 def _symbolic_reference(self, commit_id, name, f_path):
1064 1072 return commit_id
1065 1073
1066 1074 def _symbolic_reference_svn(self, commit_id, name, f_path):
1067 1075 new_f_path = vcspath.join(name, f_path)
1068 1076 return u'%s@%s' % (new_f_path, commit_id)
1069 1077
1070 1078 @LoginRequired()
1071 1079 @XHRRequired()
1072 1080 @HasRepoPermissionAnyDecorator(
1073 1081 'repository.read', 'repository.write', 'repository.admin')
1074 1082 @jsonify
1075 1083 def nodelist(self, repo_name, revision, f_path):
1076 1084 commit = self.__get_commit_or_redirect(revision, repo_name)
1077 1085
1078 1086 metadata = self._get_nodelist_at_commit(
1079 1087 repo_name, commit.raw_id, f_path)
1080 1088 return {'nodes': metadata}
1081 1089
1082 1090 @LoginRequired()
1083 1091 @XHRRequired()
1084 1092 @HasRepoPermissionAnyDecorator(
1085 1093 'repository.read', 'repository.write', 'repository.admin')
1086 1094 def nodetree_full(self, repo_name, commit_id, f_path):
1087 1095 """
1088 1096 Returns rendered html of file tree that contains commit date,
1089 1097 author, revision for the specified combination of
1090 1098 repo, commit_id and file path
1091 1099
1092 1100 :param repo_name: name of the repository
1093 1101 :param commit_id: commit_id of file tree
1094 1102 :param f_path: file path of the requested directory
1095 1103 """
1096 1104
1097 1105 commit = self.__get_commit_or_redirect(commit_id, repo_name)
1098 1106 try:
1099 1107 dir_node = commit.get_node(f_path)
1100 1108 except RepositoryError as e:
1101 1109 return 'error {}'.format(safe_str(e))
1102 1110
1103 1111 if dir_node.is_file():
1104 1112 return ''
1105 1113
1106 1114 c.file = dir_node
1107 1115 c.commit = commit
1108 1116
1109 1117 # using force=True here, make a little trick. We flush the cache and
1110 1118 # compute it using the same key as without full_load, so the fully
1111 1119 # loaded cached tree is now returned instead of partial
1112 1120 return self._get_tree_at_commit(
1113 1121 repo_name, commit.raw_id, dir_node.path, full_load=True,
1114 1122 force=True)
@@ -1,2001 +1,1995 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-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 Helper functions
23 23
24 24 Consists of functions to typically be used within templates, but also
25 25 available to Controllers. This module is available to both as 'h'.
26 26 """
27 27
28 28 import random
29 29 import hashlib
30 30 import StringIO
31 31 import urllib
32 32 import math
33 33 import logging
34 34 import re
35 35 import urlparse
36 36 import time
37 37 import string
38 38 import hashlib
39 39 import pygments
40 40
41 41 from datetime import datetime
42 42 from functools import partial
43 43 from pygments.formatters.html import HtmlFormatter
44 44 from pygments import highlight as code_highlight
45 45 from pygments.lexers import (
46 46 get_lexer_by_name, get_lexer_for_filename, get_lexer_for_mimetype)
47 47 from pylons import url as pylons_url
48 48 from pylons.i18n.translation import _, ungettext
49 49 from pyramid.threadlocal import get_current_request
50 50
51 51 from webhelpers.html import literal, HTML, escape
52 52 from webhelpers.html.tools import *
53 53 from webhelpers.html.builder import make_tag
54 54 from webhelpers.html.tags import auto_discovery_link, checkbox, css_classes, \
55 55 end_form, file, form as wh_form, hidden, image, javascript_link, link_to, \
56 56 link_to_if, link_to_unless, ol, required_legend, select, stylesheet_link, \
57 57 submit, text, password, textarea, title, ul, xml_declaration, radio
58 58 from webhelpers.html.tools import auto_link, button_to, highlight, \
59 59 js_obfuscate, mail_to, strip_links, strip_tags, tag_re
60 60 from webhelpers.pylonslib import Flash as _Flash
61 61 from webhelpers.text import chop_at, collapse, convert_accented_entities, \
62 62 convert_misc_entities, lchop, plural, rchop, remove_formatting, \
63 63 replace_whitespace, urlify, truncate, wrap_paragraphs
64 64 from webhelpers.date import time_ago_in_words
65 65 from webhelpers.paginate import Page as _Page
66 66 from webhelpers.html.tags import _set_input_attrs, _set_id_attr, \
67 67 convert_boolean_attrs, NotGiven, _make_safe_id_component
68 68 from webhelpers2.number import format_byte_size
69 69
70 from rhodecode.lib.annotate import annotate_highlight
71 70 from rhodecode.lib.action_parser import action_parser
72 71 from rhodecode.lib.ext_json import json
73 72 from rhodecode.lib.utils import repo_name_slug, get_custom_lexer
74 73 from rhodecode.lib.utils2 import str2bool, safe_unicode, safe_str, \
75 74 get_commit_safe, datetime_to_time, time_to_datetime, time_to_utcdatetime, \
76 75 AttributeDict, safe_int, md5, md5_safe
77 76 from rhodecode.lib.markup_renderer import MarkupRenderer
78 77 from rhodecode.lib.vcs.exceptions import CommitDoesNotExistError
79 78 from rhodecode.lib.vcs.backends.base import BaseChangeset, EmptyCommit
80 79 from rhodecode.config.conf import DATE_FORMAT, DATETIME_FORMAT
81 80 from rhodecode.model.changeset_status import ChangesetStatusModel
82 81 from rhodecode.model.db import Permission, User, Repository
83 82 from rhodecode.model.repo_group import RepoGroupModel
84 83 from rhodecode.model.settings import IssueTrackerSettingsModel
85 84
86 85 log = logging.getLogger(__name__)
87 86
88 87
89 88 DEFAULT_USER = User.DEFAULT_USER
90 89 DEFAULT_USER_EMAIL = User.DEFAULT_USER_EMAIL
91 90
92 91
93 92 def url(*args, **kw):
94 93 return pylons_url(*args, **kw)
95 94
96 95
97 96 def pylons_url_current(*args, **kw):
98 97 """
99 98 This function overrides pylons.url.current() which returns the current
100 99 path so that it will also work from a pyramid only context. This
101 100 should be removed once port to pyramid is complete.
102 101 """
103 102 if not args and not kw:
104 103 request = get_current_request()
105 104 return request.path
106 105 return pylons_url.current(*args, **kw)
107 106
108 107 url.current = pylons_url_current
109 108
110 109
111 110 def asset(path, ver=None):
112 111 """
113 112 Helper to generate a static asset file path for rhodecode assets
114 113
115 114 eg. h.asset('images/image.png', ver='3923')
116 115
117 116 :param path: path of asset
118 117 :param ver: optional version query param to append as ?ver=
119 118 """
120 119 request = get_current_request()
121 120 query = {}
122 121 if ver:
123 122 query = {'ver': ver}
124 123 return request.static_path(
125 124 'rhodecode:public/{}'.format(path), _query=query)
126 125
127 126
128 def html_escape(text, html_escape_table=None):
127 default_html_escape_table = {
128 ord('&'): u'&amp;',
129 ord('<'): u'&lt;',
130 ord('>'): u'&gt;',
131 ord('"'): u'&quot;',
132 ord("'"): u'&#39;',
133 }
134
135
136 def html_escape(text, html_escape_table=default_html_escape_table):
129 137 """Produce entities within text."""
130 if not html_escape_table:
131 html_escape_table = {
132 "&": "&amp;",
133 '"': "&quot;",
134 "'": "&apos;",
135 ">": "&gt;",
136 "<": "&lt;",
137 }
138 return "".join(html_escape_table.get(c, c) for c in text)
138 return text.translate(html_escape_table)
139 139
140 140
141 141 def chop_at_smart(s, sub, inclusive=False, suffix_if_chopped=None):
142 142 """
143 143 Truncate string ``s`` at the first occurrence of ``sub``.
144 144
145 145 If ``inclusive`` is true, truncate just after ``sub`` rather than at it.
146 146 """
147 147 suffix_if_chopped = suffix_if_chopped or ''
148 148 pos = s.find(sub)
149 149 if pos == -1:
150 150 return s
151 151
152 152 if inclusive:
153 153 pos += len(sub)
154 154
155 155 chopped = s[:pos]
156 156 left = s[pos:].strip()
157 157
158 158 if left and suffix_if_chopped:
159 159 chopped += suffix_if_chopped
160 160
161 161 return chopped
162 162
163 163
164 164 def shorter(text, size=20):
165 165 postfix = '...'
166 166 if len(text) > size:
167 167 return text[:size - len(postfix)] + postfix
168 168 return text
169 169
170 170
171 171 def _reset(name, value=None, id=NotGiven, type="reset", **attrs):
172 172 """
173 173 Reset button
174 174 """
175 175 _set_input_attrs(attrs, type, name, value)
176 176 _set_id_attr(attrs, id, name)
177 177 convert_boolean_attrs(attrs, ["disabled"])
178 178 return HTML.input(**attrs)
179 179
180 180 reset = _reset
181 181 safeid = _make_safe_id_component
182 182
183 183
184 184 def branding(name, length=40):
185 185 return truncate(name, length, indicator="")
186 186
187 187
188 188 def FID(raw_id, path):
189 189 """
190 190 Creates a unique ID for filenode based on it's hash of path and commit
191 191 it's safe to use in urls
192 192
193 193 :param raw_id:
194 194 :param path:
195 195 """
196 196
197 197 return 'c-%s-%s' % (short_id(raw_id), md5_safe(path)[:12])
198 198
199 199
200 200 class _GetError(object):
201 201 """Get error from form_errors, and represent it as span wrapped error
202 202 message
203 203
204 204 :param field_name: field to fetch errors for
205 205 :param form_errors: form errors dict
206 206 """
207 207
208 208 def __call__(self, field_name, form_errors):
209 209 tmpl = """<span class="error_msg">%s</span>"""
210 210 if form_errors and field_name in form_errors:
211 211 return literal(tmpl % form_errors.get(field_name))
212 212
213 213 get_error = _GetError()
214 214
215 215
216 216 class _ToolTip(object):
217 217
218 218 def __call__(self, tooltip_title, trim_at=50):
219 219 """
220 220 Special function just to wrap our text into nice formatted
221 221 autowrapped text
222 222
223 223 :param tooltip_title:
224 224 """
225 225 tooltip_title = escape(tooltip_title)
226 226 tooltip_title = tooltip_title.replace('<', '&lt;').replace('>', '&gt;')
227 227 return tooltip_title
228 228 tooltip = _ToolTip()
229 229
230 230
231 231 def files_breadcrumbs(repo_name, commit_id, file_path):
232 232 if isinstance(file_path, str):
233 233 file_path = safe_unicode(file_path)
234 234
235 235 # TODO: johbo: Is this always a url like path, or is this operating
236 236 # system dependent?
237 237 path_segments = file_path.split('/')
238 238
239 239 repo_name_html = escape(repo_name)
240 240 if len(path_segments) == 1 and path_segments[0] == '':
241 241 url_segments = [repo_name_html]
242 242 else:
243 243 url_segments = [
244 244 link_to(
245 245 repo_name_html,
246 246 url('files_home',
247 247 repo_name=repo_name,
248 248 revision=commit_id,
249 249 f_path=''),
250 250 class_='pjax-link')]
251 251
252 252 last_cnt = len(path_segments) - 1
253 253 for cnt, segment in enumerate(path_segments):
254 254 if not segment:
255 255 continue
256 256 segment_html = escape(segment)
257 257
258 258 if cnt != last_cnt:
259 259 url_segments.append(
260 260 link_to(
261 261 segment_html,
262 262 url('files_home',
263 263 repo_name=repo_name,
264 264 revision=commit_id,
265 265 f_path='/'.join(path_segments[:cnt + 1])),
266 266 class_='pjax-link'))
267 267 else:
268 268 url_segments.append(segment_html)
269 269
270 270 return literal('/'.join(url_segments))
271 271
272 272
273 273 class CodeHtmlFormatter(HtmlFormatter):
274 274 """
275 275 My code Html Formatter for source codes
276 276 """
277 277
278 278 def wrap(self, source, outfile):
279 279 return self._wrap_div(self._wrap_pre(self._wrap_code(source)))
280 280
281 281 def _wrap_code(self, source):
282 282 for cnt, it in enumerate(source):
283 283 i, t = it
284 284 t = '<div id="L%s">%s</div>' % (cnt + 1, t)
285 285 yield i, t
286 286
287 287 def _wrap_tablelinenos(self, inner):
288 288 dummyoutfile = StringIO.StringIO()
289 289 lncount = 0
290 290 for t, line in inner:
291 291 if t:
292 292 lncount += 1
293 293 dummyoutfile.write(line)
294 294
295 295 fl = self.linenostart
296 296 mw = len(str(lncount + fl - 1))
297 297 sp = self.linenospecial
298 298 st = self.linenostep
299 299 la = self.lineanchors
300 300 aln = self.anchorlinenos
301 301 nocls = self.noclasses
302 302 if sp:
303 303 lines = []
304 304
305 305 for i in range(fl, fl + lncount):
306 306 if i % st == 0:
307 307 if i % sp == 0:
308 308 if aln:
309 309 lines.append('<a href="#%s%d" class="special">%*d</a>' %
310 310 (la, i, mw, i))
311 311 else:
312 312 lines.append('<span class="special">%*d</span>' % (mw, i))
313 313 else:
314 314 if aln:
315 315 lines.append('<a href="#%s%d">%*d</a>' % (la, i, mw, i))
316 316 else:
317 317 lines.append('%*d' % (mw, i))
318 318 else:
319 319 lines.append('')
320 320 ls = '\n'.join(lines)
321 321 else:
322 322 lines = []
323 323 for i in range(fl, fl + lncount):
324 324 if i % st == 0:
325 325 if aln:
326 326 lines.append('<a href="#%s%d">%*d</a>' % (la, i, mw, i))
327 327 else:
328 328 lines.append('%*d' % (mw, i))
329 329 else:
330 330 lines.append('')
331 331 ls = '\n'.join(lines)
332 332
333 333 # in case you wonder about the seemingly redundant <div> here: since the
334 334 # content in the other cell also is wrapped in a div, some browsers in
335 335 # some configurations seem to mess up the formatting...
336 336 if nocls:
337 337 yield 0, ('<table class="%stable">' % self.cssclass +
338 338 '<tr><td><div class="linenodiv" '
339 339 'style="background-color: #f0f0f0; padding-right: 10px">'
340 340 '<pre style="line-height: 125%">' +
341 341 ls + '</pre></div></td><td id="hlcode" class="code">')
342 342 else:
343 343 yield 0, ('<table class="%stable">' % self.cssclass +
344 344 '<tr><td class="linenos"><div class="linenodiv"><pre>' +
345 345 ls + '</pre></div></td><td id="hlcode" class="code">')
346 346 yield 0, dummyoutfile.getvalue()
347 347 yield 0, '</td></tr></table>'
348 348
349 349
350 350 class SearchContentCodeHtmlFormatter(CodeHtmlFormatter):
351 351 def __init__(self, **kw):
352 352 # only show these line numbers if set
353 353 self.only_lines = kw.pop('only_line_numbers', [])
354 354 self.query_terms = kw.pop('query_terms', [])
355 355 self.max_lines = kw.pop('max_lines', 5)
356 356 self.line_context = kw.pop('line_context', 3)
357 357 self.url = kw.pop('url', None)
358 358
359 359 super(CodeHtmlFormatter, self).__init__(**kw)
360 360
361 361 def _wrap_code(self, source):
362 362 for cnt, it in enumerate(source):
363 363 i, t = it
364 364 t = '<pre>%s</pre>' % t
365 365 yield i, t
366 366
367 367 def _wrap_tablelinenos(self, inner):
368 368 yield 0, '<table class="code-highlight %stable">' % self.cssclass
369 369
370 370 last_shown_line_number = 0
371 371 current_line_number = 1
372 372
373 373 for t, line in inner:
374 374 if not t:
375 375 yield t, line
376 376 continue
377 377
378 378 if current_line_number in self.only_lines:
379 379 if last_shown_line_number + 1 != current_line_number:
380 380 yield 0, '<tr>'
381 381 yield 0, '<td class="line">...</td>'
382 382 yield 0, '<td id="hlcode" class="code"></td>'
383 383 yield 0, '</tr>'
384 384
385 385 yield 0, '<tr>'
386 386 if self.url:
387 387 yield 0, '<td class="line"><a href="%s#L%i">%i</a></td>' % (
388 388 self.url, current_line_number, current_line_number)
389 389 else:
390 390 yield 0, '<td class="line"><a href="">%i</a></td>' % (
391 391 current_line_number)
392 392 yield 0, '<td id="hlcode" class="code">' + line + '</td>'
393 393 yield 0, '</tr>'
394 394
395 395 last_shown_line_number = current_line_number
396 396
397 397 current_line_number += 1
398 398
399 399
400 400 yield 0, '</table>'
401 401
402 402
403 403 def extract_phrases(text_query):
404 404 """
405 405 Extracts phrases from search term string making sure phrases
406 406 contained in double quotes are kept together - and discarding empty values
407 407 or fully whitespace values eg.
408 408
409 409 'some text "a phrase" more' => ['some', 'text', 'a phrase', 'more']
410 410
411 411 """
412 412
413 413 in_phrase = False
414 414 buf = ''
415 415 phrases = []
416 416 for char in text_query:
417 417 if in_phrase:
418 418 if char == '"': # end phrase
419 419 phrases.append(buf)
420 420 buf = ''
421 421 in_phrase = False
422 422 continue
423 423 else:
424 424 buf += char
425 425 continue
426 426 else:
427 427 if char == '"': # start phrase
428 428 in_phrase = True
429 429 phrases.append(buf)
430 430 buf = ''
431 431 continue
432 432 elif char == ' ':
433 433 phrases.append(buf)
434 434 buf = ''
435 435 continue
436 436 else:
437 437 buf += char
438 438
439 439 phrases.append(buf)
440 440 phrases = [phrase.strip() for phrase in phrases if phrase.strip()]
441 441 return phrases
442 442
443 443
444 444 def get_matching_offsets(text, phrases):
445 445 """
446 446 Returns a list of string offsets in `text` that the list of `terms` match
447 447
448 448 >>> get_matching_offsets('some text here', ['some', 'here'])
449 449 [(0, 4), (10, 14)]
450 450
451 451 """
452 452 offsets = []
453 453 for phrase in phrases:
454 454 for match in re.finditer(phrase, text):
455 455 offsets.append((match.start(), match.end()))
456 456
457 457 return offsets
458 458
459 459
460 460 def normalize_text_for_matching(x):
461 461 """
462 462 Replaces all non alnum characters to spaces and lower cases the string,
463 463 useful for comparing two text strings without punctuation
464 464 """
465 465 return re.sub(r'[^\w]', ' ', x.lower())
466 466
467 467
468 468 def get_matching_line_offsets(lines, terms):
469 469 """ Return a set of `lines` indices (starting from 1) matching a
470 470 text search query, along with `context` lines above/below matching lines
471 471
472 472 :param lines: list of strings representing lines
473 473 :param terms: search term string to match in lines eg. 'some text'
474 474 :param context: number of lines above/below a matching line to add to result
475 475 :param max_lines: cut off for lines of interest
476 476 eg.
477 477
478 478 text = '''
479 479 words words words
480 480 words words words
481 481 some text some
482 482 words words words
483 483 words words words
484 484 text here what
485 485 '''
486 486 get_matching_line_offsets(text, 'text', context=1)
487 487 {3: [(5, 9)], 6: [(0, 4)]]
488 488
489 489 """
490 490 matching_lines = {}
491 491 phrases = [normalize_text_for_matching(phrase)
492 492 for phrase in extract_phrases(terms)]
493 493
494 494 for line_index, line in enumerate(lines, start=1):
495 495 match_offsets = get_matching_offsets(
496 496 normalize_text_for_matching(line), phrases)
497 497 if match_offsets:
498 498 matching_lines[line_index] = match_offsets
499 499
500 500 return matching_lines
501 501
502 502
503 def hsv_to_rgb(h, s, v):
504 """ Convert hsv color values to rgb """
505
506 if s == 0.0:
507 return v, v, v
508 i = int(h * 6.0) # XXX assume int() truncates!
509 f = (h * 6.0) - i
510 p = v * (1.0 - s)
511 q = v * (1.0 - s * f)
512 t = v * (1.0 - s * (1.0 - f))
513 i = i % 6
514 if i == 0:
515 return v, t, p
516 if i == 1:
517 return q, v, p
518 if i == 2:
519 return p, v, t
520 if i == 3:
521 return p, q, v
522 if i == 4:
523 return t, p, v
524 if i == 5:
525 return v, p, q
526
527
528 def unique_color_generator(n=10000, saturation=0.10, lightness=0.95):
529 """
530 Generator for getting n of evenly distributed colors using
531 hsv color and golden ratio. It always return same order of colors
532
533 :param n: number of colors to generate
534 :param saturation: saturation of returned colors
535 :param lightness: lightness of returned colors
536 :returns: RGB tuple
537 """
538
539 golden_ratio = 0.618033988749895
540 h = 0.22717784590367374
541
542 for _ in xrange(n):
543 h += golden_ratio
544 h %= 1
545 HSV_tuple = [h, saturation, lightness]
546 RGB_tuple = hsv_to_rgb(*HSV_tuple)
547 yield map(lambda x: str(int(x * 256)), RGB_tuple)
548
549
550 def color_hasher(n=10000, saturation=0.10, lightness=0.95):
551 """
552 Returns a function which when called with an argument returns a unique
553 color for that argument, eg.
554
555 :param n: number of colors to generate
556 :param saturation: saturation of returned colors
557 :param lightness: lightness of returned colors
558 :returns: css RGB string
559
560 >>> color_hash = color_hasher()
561 >>> color_hash('hello')
562 'rgb(34, 12, 59)'
563 >>> color_hash('hello')
564 'rgb(34, 12, 59)'
565 >>> color_hash('other')
566 'rgb(90, 224, 159)'
567 """
568
569 color_dict = {}
570 cgenerator = unique_color_generator(
571 saturation=saturation, lightness=lightness)
572
573 def get_color_string(thing):
574 if thing in color_dict:
575 col = color_dict[thing]
576 else:
577 col = color_dict[thing] = cgenerator.next()
578 return "rgb(%s)" % (', '.join(col))
579
580 return get_color_string
581
582
503 583 def get_lexer_safe(mimetype=None, filepath=None):
504 584 """
505 585 Tries to return a relevant pygments lexer using mimetype/filepath name,
506 586 defaulting to plain text if none could be found
507 587 """
508 588 lexer = None
509 589 try:
510 590 if mimetype:
511 591 lexer = get_lexer_for_mimetype(mimetype)
512 592 if not lexer:
513 593 lexer = get_lexer_for_filename(filepath)
514 594 except pygments.util.ClassNotFound:
515 595 pass
516 596
517 597 if not lexer:
518 598 lexer = get_lexer_by_name('text')
519 599
520 600 return lexer
521 601
522 602
523 603 def get_lexer_for_filenode(filenode):
524 604 lexer = get_custom_lexer(filenode.extension) or filenode.lexer
525 605 return lexer
526 606
527 607
528 608 def pygmentize(filenode, **kwargs):
529 609 """
530 610 pygmentize function using pygments
531 611
532 612 :param filenode:
533 613 """
534 614 lexer = get_lexer_for_filenode(filenode)
535 615 return literal(code_highlight(filenode.content, lexer,
536 616 CodeHtmlFormatter(**kwargs)))
537 617
538 618
539 def pygmentize_annotation(repo_name, filenode, **kwargs):
540 """
541 pygmentize function for annotation
542
543 :param filenode:
544 """
545
546 color_dict = {}
547
548 def gen_color(n=10000):
549 """generator for getting n of evenly distributed colors using
550 hsv color and golden ratio. It always return same order of colors
551
552 :returns: RGB tuple
553 """
554
555 def hsv_to_rgb(h, s, v):
556 if s == 0.0:
557 return v, v, v
558 i = int(h * 6.0) # XXX assume int() truncates!
559 f = (h * 6.0) - i
560 p = v * (1.0 - s)
561 q = v * (1.0 - s * f)
562 t = v * (1.0 - s * (1.0 - f))
563 i = i % 6
564 if i == 0:
565 return v, t, p
566 if i == 1:
567 return q, v, p
568 if i == 2:
569 return p, v, t
570 if i == 3:
571 return p, q, v
572 if i == 4:
573 return t, p, v
574 if i == 5:
575 return v, p, q
576
577 golden_ratio = 0.618033988749895
578 h = 0.22717784590367374
579
580 for _ in xrange(n):
581 h += golden_ratio
582 h %= 1
583 HSV_tuple = [h, 0.95, 0.95]
584 RGB_tuple = hsv_to_rgb(*HSV_tuple)
585 yield map(lambda x: str(int(x * 256)), RGB_tuple)
586
587 cgenerator = gen_color()
588
589 def get_color_string(commit_id):
590 if commit_id in color_dict:
591 col = color_dict[commit_id]
592 else:
593 col = color_dict[commit_id] = cgenerator.next()
594 return "color: rgb(%s)! important;" % (', '.join(col))
595
596 def url_func(repo_name):
597
598 def _url_func(commit):
599 author = commit.author
600 date = commit.date
601 message = tooltip(commit.message)
602
603 tooltip_html = ("<div style='font-size:0.8em'><b>Author:</b>"
604 " %s<br/><b>Date:</b> %s</b><br/><b>Message:"
605 "</b> %s<br/></div>")
606
607 tooltip_html = tooltip_html % (author, date, message)
608 lnk_format = '%5s:%s' % ('r%s' % commit.idx, commit.short_id)
609 uri = link_to(
610 lnk_format,
611 url('changeset_home', repo_name=repo_name,
612 revision=commit.raw_id),
613 style=get_color_string(commit.raw_id),
614 class_='tooltip',
615 title=tooltip_html
616 )
617
618 uri += '\n'
619 return uri
620 return _url_func
621
622 return literal(annotate_highlight(filenode, url_func(repo_name), **kwargs))
623
624
625 619 def is_following_repo(repo_name, user_id):
626 620 from rhodecode.model.scm import ScmModel
627 621 return ScmModel().is_following_repo(repo_name, user_id)
628 622
629 623
630 624 class _Message(object):
631 625 """A message returned by ``Flash.pop_messages()``.
632 626
633 627 Converting the message to a string returns the message text. Instances
634 628 also have the following attributes:
635 629
636 630 * ``message``: the message text.
637 631 * ``category``: the category specified when the message was created.
638 632 """
639 633
640 634 def __init__(self, category, message):
641 635 self.category = category
642 636 self.message = message
643 637
644 638 def __str__(self):
645 639 return self.message
646 640
647 641 __unicode__ = __str__
648 642
649 643 def __html__(self):
650 644 return escape(safe_unicode(self.message))
651 645
652 646
653 647 class Flash(_Flash):
654 648
655 649 def pop_messages(self):
656 650 """Return all accumulated messages and delete them from the session.
657 651
658 652 The return value is a list of ``Message`` objects.
659 653 """
660 654 from pylons import session
661 655
662 656 messages = []
663 657
664 658 # Pop the 'old' pylons flash messages. They are tuples of the form
665 659 # (category, message)
666 660 for cat, msg in session.pop(self.session_key, []):
667 661 messages.append(_Message(cat, msg))
668 662
669 663 # Pop the 'new' pyramid flash messages for each category as list
670 664 # of strings.
671 665 for cat in self.categories:
672 666 for msg in session.pop_flash(queue=cat):
673 667 messages.append(_Message(cat, msg))
674 668 # Map messages from the default queue to the 'notice' category.
675 669 for msg in session.pop_flash():
676 670 messages.append(_Message('notice', msg))
677 671
678 672 session.save()
679 673 return messages
680 674
681 675 def json_alerts(self):
682 676 payloads = []
683 677 messages = flash.pop_messages()
684 678 if messages:
685 679 for message in messages:
686 680 subdata = {}
687 681 if hasattr(message.message, 'rsplit'):
688 682 flash_data = message.message.rsplit('|DELIM|', 1)
689 683 org_message = flash_data[0]
690 684 if len(flash_data) > 1:
691 685 subdata = json.loads(flash_data[1])
692 686 else:
693 687 org_message = message.message
694 688 payloads.append({
695 689 'message': {
696 690 'message': u'{}'.format(org_message),
697 691 'level': message.category,
698 692 'force': True,
699 693 'subdata': subdata
700 694 }
701 695 })
702 696 return json.dumps(payloads)
703 697
704 698 flash = Flash()
705 699
706 700 #==============================================================================
707 701 # SCM FILTERS available via h.
708 702 #==============================================================================
709 703 from rhodecode.lib.vcs.utils import author_name, author_email
710 704 from rhodecode.lib.utils2 import credentials_filter, age as _age
711 705 from rhodecode.model.db import User, ChangesetStatus
712 706
713 707 age = _age
714 708 capitalize = lambda x: x.capitalize()
715 709 email = author_email
716 710 short_id = lambda x: x[:12]
717 711 hide_credentials = lambda x: ''.join(credentials_filter(x))
718 712
719 713
720 714 def age_component(datetime_iso, value=None, time_is_local=False):
721 715 title = value or format_date(datetime_iso)
722 716
723 717 # detect if we have a timezone info, otherwise, add it
724 718 if isinstance(datetime_iso, datetime) and not datetime_iso.tzinfo:
725 719 tzinfo = '+00:00'
726 720
727 721 if time_is_local:
728 722 tzinfo = time.strftime("+%H:%M",
729 723 time.gmtime(
730 724 (datetime.now() - datetime.utcnow()).seconds + 1
731 725 )
732 726 )
733 727
734 728 return literal(
735 729 '<time class="timeago tooltip" '
736 730 'title="{1}" datetime="{0}{2}">{1}</time>'.format(
737 731 datetime_iso, title, tzinfo))
738 732
739 733
740 734 def _shorten_commit_id(commit_id):
741 735 from rhodecode import CONFIG
742 736 def_len = safe_int(CONFIG.get('rhodecode_show_sha_length', 12))
743 737 return commit_id[:def_len]
744 738
745 739
746 740 def show_id(commit):
747 741 """
748 742 Configurable function that shows ID
749 743 by default it's r123:fffeeefffeee
750 744
751 745 :param commit: commit instance
752 746 """
753 747 from rhodecode import CONFIG
754 748 show_idx = str2bool(CONFIG.get('rhodecode_show_revision_number', True))
755 749
756 750 raw_id = _shorten_commit_id(commit.raw_id)
757 751 if show_idx:
758 752 return 'r%s:%s' % (commit.idx, raw_id)
759 753 else:
760 754 return '%s' % (raw_id, )
761 755
762 756
763 757 def format_date(date):
764 758 """
765 759 use a standardized formatting for dates used in RhodeCode
766 760
767 761 :param date: date/datetime object
768 762 :return: formatted date
769 763 """
770 764
771 765 if date:
772 766 _fmt = "%a, %d %b %Y %H:%M:%S"
773 767 return safe_unicode(date.strftime(_fmt))
774 768
775 769 return u""
776 770
777 771
778 772 class _RepoChecker(object):
779 773
780 774 def __init__(self, backend_alias):
781 775 self._backend_alias = backend_alias
782 776
783 777 def __call__(self, repository):
784 778 if hasattr(repository, 'alias'):
785 779 _type = repository.alias
786 780 elif hasattr(repository, 'repo_type'):
787 781 _type = repository.repo_type
788 782 else:
789 783 _type = repository
790 784 return _type == self._backend_alias
791 785
792 786 is_git = _RepoChecker('git')
793 787 is_hg = _RepoChecker('hg')
794 788 is_svn = _RepoChecker('svn')
795 789
796 790
797 791 def get_repo_type_by_name(repo_name):
798 792 repo = Repository.get_by_repo_name(repo_name)
799 793 return repo.repo_type
800 794
801 795
802 796 def is_svn_without_proxy(repository):
803 797 if is_svn(repository):
804 798 from rhodecode.model.settings import VcsSettingsModel
805 799 conf = VcsSettingsModel().get_ui_settings_as_config_obj()
806 800 return not str2bool(conf.get('vcs_svn_proxy', 'http_requests_enabled'))
807 801 return False
808 802
809 803
810 804 def discover_user(author):
811 805 """
812 806 Tries to discover RhodeCode User based on the autho string. Author string
813 807 is typically `FirstName LastName <email@address.com>`
814 808 """
815 809
816 810 # if author is already an instance use it for extraction
817 811 if isinstance(author, User):
818 812 return author
819 813
820 814 # Valid email in the attribute passed, see if they're in the system
821 815 _email = author_email(author)
822 816 if _email != '':
823 817 user = User.get_by_email(_email, case_insensitive=True, cache=True)
824 818 if user is not None:
825 819 return user
826 820
827 821 # Maybe it's a username, we try to extract it and fetch by username ?
828 822 _author = author_name(author)
829 823 user = User.get_by_username(_author, case_insensitive=True, cache=True)
830 824 if user is not None:
831 825 return user
832 826
833 827 return None
834 828
835 829
836 830 def email_or_none(author):
837 831 # extract email from the commit string
838 832 _email = author_email(author)
839 833
840 834 # If we have an email, use it, otherwise
841 835 # see if it contains a username we can get an email from
842 836 if _email != '':
843 837 return _email
844 838 else:
845 839 user = User.get_by_username(
846 840 author_name(author), case_insensitive=True, cache=True)
847 841
848 842 if user is not None:
849 843 return user.email
850 844
851 845 # No valid email, not a valid user in the system, none!
852 846 return None
853 847
854 848
855 849 def link_to_user(author, length=0, **kwargs):
856 850 user = discover_user(author)
857 851 # user can be None, but if we have it already it means we can re-use it
858 852 # in the person() function, so we save 1 intensive-query
859 853 if user:
860 854 author = user
861 855
862 856 display_person = person(author, 'username_or_name_or_email')
863 857 if length:
864 858 display_person = shorter(display_person, length)
865 859
866 860 if user:
867 861 return link_to(
868 862 escape(display_person),
869 863 url('user_profile', username=user.username),
870 864 **kwargs)
871 865 else:
872 866 return escape(display_person)
873 867
874 868
875 869 def person(author, show_attr="username_and_name"):
876 870 user = discover_user(author)
877 871 if user:
878 872 return getattr(user, show_attr)
879 873 else:
880 874 _author = author_name(author)
881 875 _email = email(author)
882 876 return _author or _email
883 877
884 878
885 879 def author_string(email):
886 880 if email:
887 881 user = User.get_by_email(email, case_insensitive=True, cache=True)
888 882 if user:
889 883 if user.firstname or user.lastname:
890 884 return '%s %s &lt;%s&gt;' % (user.firstname, user.lastname, email)
891 885 else:
892 886 return email
893 887 else:
894 888 return email
895 889 else:
896 890 return None
897 891
898 892
899 893 def person_by_id(id_, show_attr="username_and_name"):
900 894 # attr to return from fetched user
901 895 person_getter = lambda usr: getattr(usr, show_attr)
902 896
903 897 #maybe it's an ID ?
904 898 if str(id_).isdigit() or isinstance(id_, int):
905 899 id_ = int(id_)
906 900 user = User.get(id_)
907 901 if user is not None:
908 902 return person_getter(user)
909 903 return id_
910 904
911 905
912 906 def gravatar_with_user(author, show_disabled=False):
913 907 from rhodecode.lib.utils import PartialRenderer
914 908 _render = PartialRenderer('base/base.html')
915 909 return _render('gravatar_with_user', author, show_disabled=show_disabled)
916 910
917 911
918 912 def desc_stylize(value):
919 913 """
920 914 converts tags from value into html equivalent
921 915
922 916 :param value:
923 917 """
924 918 if not value:
925 919 return ''
926 920
927 921 value = re.sub(r'\[see\ \=\>\ *([a-zA-Z0-9\/\=\?\&\ \:\/\.\-]*)\]',
928 922 '<div class="metatag" tag="see">see =&gt; \\1 </div>', value)
929 923 value = re.sub(r'\[license\ \=\>\ *([a-zA-Z0-9\/\=\?\&\ \:\/\.\-]*)\]',
930 924 '<div class="metatag" tag="license"><a href="http:\/\/www.opensource.org/licenses/\\1">\\1</a></div>', value)
931 925 value = re.sub(r'\[(requires|recommends|conflicts|base)\ \=\>\ *([a-zA-Z0-9\-\/]*)\]',
932 926 '<div class="metatag" tag="\\1">\\1 =&gt; <a href="/\\2">\\2</a></div>', value)
933 927 value = re.sub(r'\[(lang|language)\ \=\>\ *([a-zA-Z\-\/\#\+]*)\]',
934 928 '<div class="metatag" tag="lang">\\2</div>', value)
935 929 value = re.sub(r'\[([a-z]+)\]',
936 930 '<div class="metatag" tag="\\1">\\1</div>', value)
937 931
938 932 return value
939 933
940 934
941 935 def escaped_stylize(value):
942 936 """
943 937 converts tags from value into html equivalent, but escaping its value first
944 938 """
945 939 if not value:
946 940 return ''
947 941
948 942 # Using default webhelper escape method, but has to force it as a
949 943 # plain unicode instead of a markup tag to be used in regex expressions
950 944 value = unicode(escape(safe_unicode(value)))
951 945
952 946 value = re.sub(r'\[see\ \=\&gt;\ *([a-zA-Z0-9\/\=\?\&amp;\ \:\/\.\-]*)\]',
953 947 '<div class="metatag" tag="see">see =&gt; \\1 </div>', value)
954 948 value = re.sub(r'\[license\ \=\&gt;\ *([a-zA-Z0-9\/\=\?\&amp;\ \:\/\.\-]*)\]',
955 949 '<div class="metatag" tag="license"><a href="http:\/\/www.opensource.org/licenses/\\1">\\1</a></div>', value)
956 950 value = re.sub(r'\[(requires|recommends|conflicts|base)\ \=\&gt;\ *([a-zA-Z0-9\-\/]*)\]',
957 951 '<div class="metatag" tag="\\1">\\1 =&gt; <a href="/\\2">\\2</a></div>', value)
958 952 value = re.sub(r'\[(lang|language)\ \=\&gt;\ *([a-zA-Z\-\/\#\+]*)\]',
959 953 '<div class="metatag" tag="lang">\\2</div>', value)
960 954 value = re.sub(r'\[([a-z]+)\]',
961 955 '<div class="metatag" tag="\\1">\\1</div>', value)
962 956
963 957 return value
964 958
965 959
966 960 def bool2icon(value):
967 961 """
968 962 Returns boolean value of a given value, represented as html element with
969 963 classes that will represent icons
970 964
971 965 :param value: given value to convert to html node
972 966 """
973 967
974 968 if value: # does bool conversion
975 969 return HTML.tag('i', class_="icon-true")
976 970 else: # not true as bool
977 971 return HTML.tag('i', class_="icon-false")
978 972
979 973
980 974 #==============================================================================
981 975 # PERMS
982 976 #==============================================================================
983 977 from rhodecode.lib.auth import HasPermissionAny, HasPermissionAll, \
984 978 HasRepoPermissionAny, HasRepoPermissionAll, HasRepoGroupPermissionAll, \
985 979 HasRepoGroupPermissionAny, HasRepoPermissionAnyApi, get_csrf_token, \
986 980 csrf_token_key
987 981
988 982
989 983 #==============================================================================
990 984 # GRAVATAR URL
991 985 #==============================================================================
992 986 class InitialsGravatar(object):
993 987 def __init__(self, email_address, first_name, last_name, size=30,
994 988 background=None, text_color='#fff'):
995 989 self.size = size
996 990 self.first_name = first_name
997 991 self.last_name = last_name
998 992 self.email_address = email_address
999 993 self.background = background or self.str2color(email_address)
1000 994 self.text_color = text_color
1001 995
1002 996 def get_color_bank(self):
1003 997 """
1004 998 returns a predefined list of colors that gravatars can use.
1005 999 Those are randomized distinct colors that guarantee readability and
1006 1000 uniqueness.
1007 1001
1008 1002 generated with: http://phrogz.net/css/distinct-colors.html
1009 1003 """
1010 1004 return [
1011 1005 '#bf3030', '#a67f53', '#00ff00', '#5989b3', '#392040', '#d90000',
1012 1006 '#402910', '#204020', '#79baf2', '#a700b3', '#bf6060', '#7f5320',
1013 1007 '#008000', '#003059', '#ee00ff', '#ff0000', '#8c4b00', '#007300',
1014 1008 '#005fb3', '#de73e6', '#ff4040', '#ffaa00', '#3df255', '#203140',
1015 1009 '#47004d', '#591616', '#664400', '#59b365', '#0d2133', '#83008c',
1016 1010 '#592d2d', '#bf9f60', '#73e682', '#1d3f73', '#73006b', '#402020',
1017 1011 '#b2862d', '#397341', '#597db3', '#e600d6', '#a60000', '#736039',
1018 1012 '#00b318', '#79aaf2', '#330d30', '#ff8080', '#403010', '#16591f',
1019 1013 '#002459', '#8c4688', '#e50000', '#ffbf40', '#00732e', '#102340',
1020 1014 '#bf60ac', '#8c4646', '#cc8800', '#00a642', '#1d3473', '#b32d98',
1021 1015 '#660e00', '#ffd580', '#80ffb2', '#7391e6', '#733967', '#d97b6c',
1022 1016 '#8c5e00', '#59b389', '#3967e6', '#590047', '#73281d', '#665200',
1023 1017 '#00e67a', '#2d50b3', '#8c2377', '#734139', '#b2982d', '#16593a',
1024 1018 '#001859', '#ff00aa', '#a65e53', '#ffcc00', '#0d3321', '#2d3959',
1025 1019 '#731d56', '#401610', '#4c3d00', '#468c6c', '#002ca6', '#d936a3',
1026 1020 '#d94c36', '#403920', '#36d9a3', '#0d1733', '#592d4a', '#993626',
1027 1021 '#cca300', '#00734d', '#46598c', '#8c005e', '#7f1100', '#8c7000',
1028 1022 '#00a66f', '#7382e6', '#b32d74', '#d9896c', '#ffe680', '#1d7362',
1029 1023 '#364cd9', '#73003d', '#d93a00', '#998a4d', '#59b3a1', '#5965b3',
1030 1024 '#e5007a', '#73341d', '#665f00', '#00b38f', '#0018b3', '#59163a',
1031 1025 '#b2502d', '#bfb960', '#00ffcc', '#23318c', '#a6537f', '#734939',
1032 1026 '#b2a700', '#104036', '#3d3df2', '#402031', '#e56739', '#736f39',
1033 1027 '#79f2ea', '#000059', '#401029', '#4c1400', '#ffee00', '#005953',
1034 1028 '#101040', '#990052', '#402820', '#403d10', '#00ffee', '#0000d9',
1035 1029 '#ff80c4', '#a66953', '#eeff00', '#00ccbe', '#8080ff', '#e673a1',
1036 1030 '#a62c00', '#474d00', '#1a3331', '#46468c', '#733950', '#662900',
1037 1031 '#858c23', '#238c85', '#0f0073', '#b20047', '#d9986c', '#becc00',
1038 1032 '#396f73', '#281d73', '#ff0066', '#ff6600', '#dee673', '#59adb3',
1039 1033 '#6559b3', '#590024', '#b2622d', '#98b32d', '#36ced9', '#332d59',
1040 1034 '#40001a', '#733f1d', '#526600', '#005359', '#242040', '#bf6079',
1041 1035 '#735039', '#cef23d', '#007780', '#5630bf', '#66001b', '#b24700',
1042 1036 '#acbf60', '#1d6273', '#25008c', '#731d34', '#a67453', '#50592d',
1043 1037 '#00ccff', '#6600ff', '#ff0044', '#4c1f00', '#8a994d', '#79daf2',
1044 1038 '#a173e6', '#d93662', '#402310', '#aaff00', '#2d98b3', '#8c40ff',
1045 1039 '#592d39', '#ff8c40', '#354020', '#103640', '#1a0040', '#331a20',
1046 1040 '#331400', '#334d00', '#1d5673', '#583973', '#7f0022', '#4c3626',
1047 1041 '#88cc00', '#36a3d9', '#3d0073', '#d9364c', '#33241a', '#698c23',
1048 1042 '#5995b3', '#300059', '#e57382', '#7f3300', '#366600', '#00aaff',
1049 1043 '#3a1659', '#733941', '#663600', '#74b32d', '#003c59', '#7f53a6',
1050 1044 '#73000f', '#ff8800', '#baf279', '#79caf2', '#291040', '#a6293a',
1051 1045 '#b2742d', '#587339', '#0077b3', '#632699', '#400009', '#d9a66c',
1052 1046 '#294010', '#2d4a59', '#aa00ff', '#4c131b', '#b25f00', '#5ce600',
1053 1047 '#267399', '#a336d9', '#990014', '#664e33', '#86bf60', '#0088ff',
1054 1048 '#7700b3', '#593a16', '#073300', '#1d4b73', '#ac60bf', '#e59539',
1055 1049 '#4f8c46', '#368dd9', '#5c0073'
1056 1050 ]
1057 1051
1058 1052 def rgb_to_hex_color(self, rgb_tuple):
1059 1053 """
1060 1054 Converts an rgb_tuple passed to an hex color.
1061 1055
1062 1056 :param rgb_tuple: tuple with 3 ints represents rgb color space
1063 1057 """
1064 1058 return '#' + ("".join(map(chr, rgb_tuple)).encode('hex'))
1065 1059
1066 1060 def email_to_int_list(self, email_str):
1067 1061 """
1068 1062 Get every byte of the hex digest value of email and turn it to integer.
1069 1063 It's going to be always between 0-255
1070 1064 """
1071 1065 digest = md5_safe(email_str.lower())
1072 1066 return [int(digest[i * 2:i * 2 + 2], 16) for i in range(16)]
1073 1067
1074 1068 def pick_color_bank_index(self, email_str, color_bank):
1075 1069 return self.email_to_int_list(email_str)[0] % len(color_bank)
1076 1070
1077 1071 def str2color(self, email_str):
1078 1072 """
1079 1073 Tries to map in a stable algorithm an email to color
1080 1074
1081 1075 :param email_str:
1082 1076 """
1083 1077 color_bank = self.get_color_bank()
1084 1078 # pick position (module it's length so we always find it in the
1085 1079 # bank even if it's smaller than 256 values
1086 1080 pos = self.pick_color_bank_index(email_str, color_bank)
1087 1081 return color_bank[pos]
1088 1082
1089 1083 def normalize_email(self, email_address):
1090 1084 import unicodedata
1091 1085 # default host used to fill in the fake/missing email
1092 1086 default_host = u'localhost'
1093 1087
1094 1088 if not email_address:
1095 1089 email_address = u'%s@%s' % (User.DEFAULT_USER, default_host)
1096 1090
1097 1091 email_address = safe_unicode(email_address)
1098 1092
1099 1093 if u'@' not in email_address:
1100 1094 email_address = u'%s@%s' % (email_address, default_host)
1101 1095
1102 1096 if email_address.endswith(u'@'):
1103 1097 email_address = u'%s%s' % (email_address, default_host)
1104 1098
1105 1099 email_address = unicodedata.normalize('NFKD', email_address)\
1106 1100 .encode('ascii', 'ignore')
1107 1101 return email_address
1108 1102
1109 1103 def get_initials(self):
1110 1104 """
1111 1105 Returns 2 letter initials calculated based on the input.
1112 1106 The algorithm picks first given email address, and takes first letter
1113 1107 of part before @, and then the first letter of server name. In case
1114 1108 the part before @ is in a format of `somestring.somestring2` it replaces
1115 1109 the server letter with first letter of somestring2
1116 1110
1117 1111 In case function was initialized with both first and lastname, this
1118 1112 overrides the extraction from email by first letter of the first and
1119 1113 last name. We add special logic to that functionality, In case Full name
1120 1114 is compound, like Guido Von Rossum, we use last part of the last name
1121 1115 (Von Rossum) picking `R`.
1122 1116
1123 1117 Function also normalizes the non-ascii characters to they ascii
1124 1118 representation, eg Ą => A
1125 1119 """
1126 1120 import unicodedata
1127 1121 # replace non-ascii to ascii
1128 1122 first_name = unicodedata.normalize(
1129 1123 'NFKD', safe_unicode(self.first_name)).encode('ascii', 'ignore')
1130 1124 last_name = unicodedata.normalize(
1131 1125 'NFKD', safe_unicode(self.last_name)).encode('ascii', 'ignore')
1132 1126
1133 1127 # do NFKD encoding, and also make sure email has proper format
1134 1128 email_address = self.normalize_email(self.email_address)
1135 1129
1136 1130 # first push the email initials
1137 1131 prefix, server = email_address.split('@', 1)
1138 1132
1139 1133 # check if prefix is maybe a 'firstname.lastname' syntax
1140 1134 _dot_split = prefix.rsplit('.', 1)
1141 1135 if len(_dot_split) == 2:
1142 1136 initials = [_dot_split[0][0], _dot_split[1][0]]
1143 1137 else:
1144 1138 initials = [prefix[0], server[0]]
1145 1139
1146 1140 # then try to replace either firtname or lastname
1147 1141 fn_letter = (first_name or " ")[0].strip()
1148 1142 ln_letter = (last_name.split(' ', 1)[-1] or " ")[0].strip()
1149 1143
1150 1144 if fn_letter:
1151 1145 initials[0] = fn_letter
1152 1146
1153 1147 if ln_letter:
1154 1148 initials[1] = ln_letter
1155 1149
1156 1150 return ''.join(initials).upper()
1157 1151
1158 1152 def get_img_data_by_type(self, font_family, img_type):
1159 1153 default_user = """
1160 1154 <svg xmlns="http://www.w3.org/2000/svg"
1161 1155 version="1.1" x="0px" y="0px" width="{size}" height="{size}"
1162 1156 viewBox="-15 -10 439.165 429.164"
1163 1157
1164 1158 xml:space="preserve"
1165 1159 style="background:{background};" >
1166 1160
1167 1161 <path d="M204.583,216.671c50.664,0,91.74-48.075,
1168 1162 91.74-107.378c0-82.237-41.074-107.377-91.74-107.377
1169 1163 c-50.668,0-91.74,25.14-91.74,107.377C112.844,
1170 1164 168.596,153.916,216.671,
1171 1165 204.583,216.671z" fill="{text_color}"/>
1172 1166 <path d="M407.164,374.717L360.88,
1173 1167 270.454c-2.117-4.771-5.836-8.728-10.465-11.138l-71.83-37.392
1174 1168 c-1.584-0.823-3.502-0.663-4.926,0.415c-20.316,
1175 1169 15.366-44.203,23.488-69.076,23.488c-24.877,
1176 1170 0-48.762-8.122-69.078-23.488
1177 1171 c-1.428-1.078-3.346-1.238-4.93-0.415L58.75,
1178 1172 259.316c-4.631,2.41-8.346,6.365-10.465,11.138L2.001,374.717
1179 1173 c-3.191,7.188-2.537,15.412,1.75,22.005c4.285,
1180 1174 6.592,11.537,10.526,19.4,10.526h362.861c7.863,0,15.117-3.936,
1181 1175 19.402-10.527 C409.699,390.129,
1182 1176 410.355,381.902,407.164,374.717z" fill="{text_color}"/>
1183 1177 </svg>""".format(
1184 1178 size=self.size,
1185 1179 background='#979797', # @grey4
1186 1180 text_color=self.text_color,
1187 1181 font_family=font_family)
1188 1182
1189 1183 return {
1190 1184 "default_user": default_user
1191 1185 }[img_type]
1192 1186
1193 1187 def get_img_data(self, svg_type=None):
1194 1188 """
1195 1189 generates the svg metadata for image
1196 1190 """
1197 1191
1198 1192 font_family = ','.join([
1199 1193 'proximanovaregular',
1200 1194 'Proxima Nova Regular',
1201 1195 'Proxima Nova',
1202 1196 'Arial',
1203 1197 'Lucida Grande',
1204 1198 'sans-serif'
1205 1199 ])
1206 1200 if svg_type:
1207 1201 return self.get_img_data_by_type(font_family, svg_type)
1208 1202
1209 1203 initials = self.get_initials()
1210 1204 img_data = """
1211 1205 <svg xmlns="http://www.w3.org/2000/svg" pointer-events="none"
1212 1206 width="{size}" height="{size}"
1213 1207 style="width: 100%; height: 100%; background-color: {background}"
1214 1208 viewBox="0 0 {size} {size}">
1215 1209 <text text-anchor="middle" y="50%" x="50%" dy="0.35em"
1216 1210 pointer-events="auto" fill="{text_color}"
1217 1211 font-family="{font_family}"
1218 1212 style="font-weight: 400; font-size: {f_size}px;">{text}
1219 1213 </text>
1220 1214 </svg>""".format(
1221 1215 size=self.size,
1222 1216 f_size=self.size/1.85, # scale the text inside the box nicely
1223 1217 background=self.background,
1224 1218 text_color=self.text_color,
1225 1219 text=initials.upper(),
1226 1220 font_family=font_family)
1227 1221
1228 1222 return img_data
1229 1223
1230 1224 def generate_svg(self, svg_type=None):
1231 1225 img_data = self.get_img_data(svg_type)
1232 1226 return "data:image/svg+xml;base64,%s" % img_data.encode('base64')
1233 1227
1234 1228
1235 1229 def initials_gravatar(email_address, first_name, last_name, size=30):
1236 1230 svg_type = None
1237 1231 if email_address == User.DEFAULT_USER_EMAIL:
1238 1232 svg_type = 'default_user'
1239 1233 klass = InitialsGravatar(email_address, first_name, last_name, size)
1240 1234 return klass.generate_svg(svg_type=svg_type)
1241 1235
1242 1236
1243 1237 def gravatar_url(email_address, size=30):
1244 1238 # doh, we need to re-import those to mock it later
1245 1239 from pylons import tmpl_context as c
1246 1240
1247 1241 _use_gravatar = c.visual.use_gravatar
1248 1242 _gravatar_url = c.visual.gravatar_url or User.DEFAULT_GRAVATAR_URL
1249 1243
1250 1244 email_address = email_address or User.DEFAULT_USER_EMAIL
1251 1245 if isinstance(email_address, unicode):
1252 1246 # hashlib crashes on unicode items
1253 1247 email_address = safe_str(email_address)
1254 1248
1255 1249 # empty email or default user
1256 1250 if not email_address or email_address == User.DEFAULT_USER_EMAIL:
1257 1251 return initials_gravatar(User.DEFAULT_USER_EMAIL, '', '', size=size)
1258 1252
1259 1253 if _use_gravatar:
1260 1254 # TODO: Disuse pyramid thread locals. Think about another solution to
1261 1255 # get the host and schema here.
1262 1256 request = get_current_request()
1263 1257 tmpl = safe_str(_gravatar_url)
1264 1258 tmpl = tmpl.replace('{email}', email_address)\
1265 1259 .replace('{md5email}', md5_safe(email_address.lower())) \
1266 1260 .replace('{netloc}', request.host)\
1267 1261 .replace('{scheme}', request.scheme)\
1268 1262 .replace('{size}', safe_str(size))
1269 1263 return tmpl
1270 1264 else:
1271 1265 return initials_gravatar(email_address, '', '', size=size)
1272 1266
1273 1267
1274 1268 class Page(_Page):
1275 1269 """
1276 1270 Custom pager to match rendering style with paginator
1277 1271 """
1278 1272
1279 1273 def _get_pos(self, cur_page, max_page, items):
1280 1274 edge = (items / 2) + 1
1281 1275 if (cur_page <= edge):
1282 1276 radius = max(items / 2, items - cur_page)
1283 1277 elif (max_page - cur_page) < edge:
1284 1278 radius = (items - 1) - (max_page - cur_page)
1285 1279 else:
1286 1280 radius = items / 2
1287 1281
1288 1282 left = max(1, (cur_page - (radius)))
1289 1283 right = min(max_page, cur_page + (radius))
1290 1284 return left, cur_page, right
1291 1285
1292 1286 def _range(self, regexp_match):
1293 1287 """
1294 1288 Return range of linked pages (e.g. '1 2 [3] 4 5 6 7 8').
1295 1289
1296 1290 Arguments:
1297 1291
1298 1292 regexp_match
1299 1293 A "re" (regular expressions) match object containing the
1300 1294 radius of linked pages around the current page in
1301 1295 regexp_match.group(1) as a string
1302 1296
1303 1297 This function is supposed to be called as a callable in
1304 1298 re.sub.
1305 1299
1306 1300 """
1307 1301 radius = int(regexp_match.group(1))
1308 1302
1309 1303 # Compute the first and last page number within the radius
1310 1304 # e.g. '1 .. 5 6 [7] 8 9 .. 12'
1311 1305 # -> leftmost_page = 5
1312 1306 # -> rightmost_page = 9
1313 1307 leftmost_page, _cur, rightmost_page = self._get_pos(self.page,
1314 1308 self.last_page,
1315 1309 (radius * 2) + 1)
1316 1310 nav_items = []
1317 1311
1318 1312 # Create a link to the first page (unless we are on the first page
1319 1313 # or there would be no need to insert '..' spacers)
1320 1314 if self.page != self.first_page and self.first_page < leftmost_page:
1321 1315 nav_items.append(self._pagerlink(self.first_page, self.first_page))
1322 1316
1323 1317 # Insert dots if there are pages between the first page
1324 1318 # and the currently displayed page range
1325 1319 if leftmost_page - self.first_page > 1:
1326 1320 # Wrap in a SPAN tag if nolink_attr is set
1327 1321 text = '..'
1328 1322 if self.dotdot_attr:
1329 1323 text = HTML.span(c=text, **self.dotdot_attr)
1330 1324 nav_items.append(text)
1331 1325
1332 1326 for thispage in xrange(leftmost_page, rightmost_page + 1):
1333 1327 # Hilight the current page number and do not use a link
1334 1328 if thispage == self.page:
1335 1329 text = '%s' % (thispage,)
1336 1330 # Wrap in a SPAN tag if nolink_attr is set
1337 1331 if self.curpage_attr:
1338 1332 text = HTML.span(c=text, **self.curpage_attr)
1339 1333 nav_items.append(text)
1340 1334 # Otherwise create just a link to that page
1341 1335 else:
1342 1336 text = '%s' % (thispage,)
1343 1337 nav_items.append(self._pagerlink(thispage, text))
1344 1338
1345 1339 # Insert dots if there are pages between the displayed
1346 1340 # page numbers and the end of the page range
1347 1341 if self.last_page - rightmost_page > 1:
1348 1342 text = '..'
1349 1343 # Wrap in a SPAN tag if nolink_attr is set
1350 1344 if self.dotdot_attr:
1351 1345 text = HTML.span(c=text, **self.dotdot_attr)
1352 1346 nav_items.append(text)
1353 1347
1354 1348 # Create a link to the very last page (unless we are on the last
1355 1349 # page or there would be no need to insert '..' spacers)
1356 1350 if self.page != self.last_page and rightmost_page < self.last_page:
1357 1351 nav_items.append(self._pagerlink(self.last_page, self.last_page))
1358 1352
1359 1353 ## prerender links
1360 1354 #_page_link = url.current()
1361 1355 #nav_items.append(literal('<link rel="prerender" href="%s?page=%s">' % (_page_link, str(int(self.page)+1))))
1362 1356 #nav_items.append(literal('<link rel="prefetch" href="%s?page=%s">' % (_page_link, str(int(self.page)+1))))
1363 1357 return self.separator.join(nav_items)
1364 1358
1365 1359 def pager(self, format='~2~', page_param='page', partial_param='partial',
1366 1360 show_if_single_page=False, separator=' ', onclick=None,
1367 1361 symbol_first='<<', symbol_last='>>',
1368 1362 symbol_previous='<', symbol_next='>',
1369 1363 link_attr={'class': 'pager_link', 'rel': 'prerender'},
1370 1364 curpage_attr={'class': 'pager_curpage'},
1371 1365 dotdot_attr={'class': 'pager_dotdot'}, **kwargs):
1372 1366
1373 1367 self.curpage_attr = curpage_attr
1374 1368 self.separator = separator
1375 1369 self.pager_kwargs = kwargs
1376 1370 self.page_param = page_param
1377 1371 self.partial_param = partial_param
1378 1372 self.onclick = onclick
1379 1373 self.link_attr = link_attr
1380 1374 self.dotdot_attr = dotdot_attr
1381 1375
1382 1376 # Don't show navigator if there is no more than one page
1383 1377 if self.page_count == 0 or (self.page_count == 1 and not show_if_single_page):
1384 1378 return ''
1385 1379
1386 1380 from string import Template
1387 1381 # Replace ~...~ in token format by range of pages
1388 1382 result = re.sub(r'~(\d+)~', self._range, format)
1389 1383
1390 1384 # Interpolate '%' variables
1391 1385 result = Template(result).safe_substitute({
1392 1386 'first_page': self.first_page,
1393 1387 'last_page': self.last_page,
1394 1388 'page': self.page,
1395 1389 'page_count': self.page_count,
1396 1390 'items_per_page': self.items_per_page,
1397 1391 'first_item': self.first_item,
1398 1392 'last_item': self.last_item,
1399 1393 'item_count': self.item_count,
1400 1394 'link_first': self.page > self.first_page and \
1401 1395 self._pagerlink(self.first_page, symbol_first) or '',
1402 1396 'link_last': self.page < self.last_page and \
1403 1397 self._pagerlink(self.last_page, symbol_last) or '',
1404 1398 'link_previous': self.previous_page and \
1405 1399 self._pagerlink(self.previous_page, symbol_previous) \
1406 1400 or HTML.span(symbol_previous, class_="pg-previous disabled"),
1407 1401 'link_next': self.next_page and \
1408 1402 self._pagerlink(self.next_page, symbol_next) \
1409 1403 or HTML.span(symbol_next, class_="pg-next disabled")
1410 1404 })
1411 1405
1412 1406 return literal(result)
1413 1407
1414 1408
1415 1409 #==============================================================================
1416 1410 # REPO PAGER, PAGER FOR REPOSITORY
1417 1411 #==============================================================================
1418 1412 class RepoPage(Page):
1419 1413
1420 1414 def __init__(self, collection, page=1, items_per_page=20,
1421 1415 item_count=None, url=None, **kwargs):
1422 1416
1423 1417 """Create a "RepoPage" instance. special pager for paging
1424 1418 repository
1425 1419 """
1426 1420 self._url_generator = url
1427 1421
1428 1422 # Safe the kwargs class-wide so they can be used in the pager() method
1429 1423 self.kwargs = kwargs
1430 1424
1431 1425 # Save a reference to the collection
1432 1426 self.original_collection = collection
1433 1427
1434 1428 self.collection = collection
1435 1429
1436 1430 # The self.page is the number of the current page.
1437 1431 # The first page has the number 1!
1438 1432 try:
1439 1433 self.page = int(page) # make it int() if we get it as a string
1440 1434 except (ValueError, TypeError):
1441 1435 self.page = 1
1442 1436
1443 1437 self.items_per_page = items_per_page
1444 1438
1445 1439 # Unless the user tells us how many items the collections has
1446 1440 # we calculate that ourselves.
1447 1441 if item_count is not None:
1448 1442 self.item_count = item_count
1449 1443 else:
1450 1444 self.item_count = len(self.collection)
1451 1445
1452 1446 # Compute the number of the first and last available page
1453 1447 if self.item_count > 0:
1454 1448 self.first_page = 1
1455 1449 self.page_count = int(math.ceil(float(self.item_count) /
1456 1450 self.items_per_page))
1457 1451 self.last_page = self.first_page + self.page_count - 1
1458 1452
1459 1453 # Make sure that the requested page number is the range of
1460 1454 # valid pages
1461 1455 if self.page > self.last_page:
1462 1456 self.page = self.last_page
1463 1457 elif self.page < self.first_page:
1464 1458 self.page = self.first_page
1465 1459
1466 1460 # Note: the number of items on this page can be less than
1467 1461 # items_per_page if the last page is not full
1468 1462 self.first_item = max(0, (self.item_count) - (self.page *
1469 1463 items_per_page))
1470 1464 self.last_item = ((self.item_count - 1) - items_per_page *
1471 1465 (self.page - 1))
1472 1466
1473 1467 self.items = list(self.collection[self.first_item:self.last_item + 1])
1474 1468
1475 1469 # Links to previous and next page
1476 1470 if self.page > self.first_page:
1477 1471 self.previous_page = self.page - 1
1478 1472 else:
1479 1473 self.previous_page = None
1480 1474
1481 1475 if self.page < self.last_page:
1482 1476 self.next_page = self.page + 1
1483 1477 else:
1484 1478 self.next_page = None
1485 1479
1486 1480 # No items available
1487 1481 else:
1488 1482 self.first_page = None
1489 1483 self.page_count = 0
1490 1484 self.last_page = None
1491 1485 self.first_item = None
1492 1486 self.last_item = None
1493 1487 self.previous_page = None
1494 1488 self.next_page = None
1495 1489 self.items = []
1496 1490
1497 1491 # This is a subclass of the 'list' type. Initialise the list now.
1498 1492 list.__init__(self, reversed(self.items))
1499 1493
1500 1494
1501 1495 def changed_tooltip(nodes):
1502 1496 """
1503 1497 Generates a html string for changed nodes in commit page.
1504 1498 It limits the output to 30 entries
1505 1499
1506 1500 :param nodes: LazyNodesGenerator
1507 1501 """
1508 1502 if nodes:
1509 1503 pref = ': <br/> '
1510 1504 suf = ''
1511 1505 if len(nodes) > 30:
1512 1506 suf = '<br/>' + _(' and %s more') % (len(nodes) - 30)
1513 1507 return literal(pref + '<br/> '.join([safe_unicode(x.path)
1514 1508 for x in nodes[:30]]) + suf)
1515 1509 else:
1516 1510 return ': ' + _('No Files')
1517 1511
1518 1512
1519 1513 def breadcrumb_repo_link(repo):
1520 1514 """
1521 1515 Makes a breadcrumbs path link to repo
1522 1516
1523 1517 ex::
1524 1518 group >> subgroup >> repo
1525 1519
1526 1520 :param repo: a Repository instance
1527 1521 """
1528 1522
1529 1523 path = [
1530 1524 link_to(group.name, url('repo_group_home', group_name=group.group_name))
1531 1525 for group in repo.groups_with_parents
1532 1526 ] + [
1533 1527 link_to(repo.just_name, url('summary_home', repo_name=repo.repo_name))
1534 1528 ]
1535 1529
1536 1530 return literal(' &raquo; '.join(path))
1537 1531
1538 1532
1539 1533 def format_byte_size_binary(file_size):
1540 1534 """
1541 1535 Formats file/folder sizes to standard.
1542 1536 """
1543 1537 formatted_size = format_byte_size(file_size, binary=True)
1544 1538 return formatted_size
1545 1539
1546 1540
1547 1541 def fancy_file_stats(stats):
1548 1542 """
1549 1543 Displays a fancy two colored bar for number of added/deleted
1550 1544 lines of code on file
1551 1545
1552 1546 :param stats: two element list of added/deleted lines of code
1553 1547 """
1554 1548 from rhodecode.lib.diffs import NEW_FILENODE, DEL_FILENODE, \
1555 1549 MOD_FILENODE, RENAMED_FILENODE, CHMOD_FILENODE, BIN_FILENODE
1556 1550
1557 1551 def cgen(l_type, a_v, d_v):
1558 1552 mapping = {'tr': 'top-right-rounded-corner-mid',
1559 1553 'tl': 'top-left-rounded-corner-mid',
1560 1554 'br': 'bottom-right-rounded-corner-mid',
1561 1555 'bl': 'bottom-left-rounded-corner-mid'}
1562 1556 map_getter = lambda x: mapping[x]
1563 1557
1564 1558 if l_type == 'a' and d_v:
1565 1559 #case when added and deleted are present
1566 1560 return ' '.join(map(map_getter, ['tl', 'bl']))
1567 1561
1568 1562 if l_type == 'a' and not d_v:
1569 1563 return ' '.join(map(map_getter, ['tr', 'br', 'tl', 'bl']))
1570 1564
1571 1565 if l_type == 'd' and a_v:
1572 1566 return ' '.join(map(map_getter, ['tr', 'br']))
1573 1567
1574 1568 if l_type == 'd' and not a_v:
1575 1569 return ' '.join(map(map_getter, ['tr', 'br', 'tl', 'bl']))
1576 1570
1577 1571 a, d = stats['added'], stats['deleted']
1578 1572 width = 100
1579 1573
1580 1574 if stats['binary']: # binary operations like chmod/rename etc
1581 1575 lbl = []
1582 1576 bin_op = 0 # undefined
1583 1577
1584 1578 # prefix with bin for binary files
1585 1579 if BIN_FILENODE in stats['ops']:
1586 1580 lbl += ['bin']
1587 1581
1588 1582 if NEW_FILENODE in stats['ops']:
1589 1583 lbl += [_('new file')]
1590 1584 bin_op = NEW_FILENODE
1591 1585 elif MOD_FILENODE in stats['ops']:
1592 1586 lbl += [_('mod')]
1593 1587 bin_op = MOD_FILENODE
1594 1588 elif DEL_FILENODE in stats['ops']:
1595 1589 lbl += [_('del')]
1596 1590 bin_op = DEL_FILENODE
1597 1591 elif RENAMED_FILENODE in stats['ops']:
1598 1592 lbl += [_('rename')]
1599 1593 bin_op = RENAMED_FILENODE
1600 1594
1601 1595 # chmod can go with other operations, so we add a + to lbl if needed
1602 1596 if CHMOD_FILENODE in stats['ops']:
1603 1597 lbl += [_('chmod')]
1604 1598 if bin_op == 0:
1605 1599 bin_op = CHMOD_FILENODE
1606 1600
1607 1601 lbl = '+'.join(lbl)
1608 1602 b_a = '<div class="bin bin%s %s" style="width:100%%">%s</div>' \
1609 1603 % (bin_op, cgen('a', a_v='', d_v=0), lbl)
1610 1604 b_d = '<div class="bin bin1" style="width:0%%"></div>'
1611 1605 return literal('<div style="width:%spx">%s%s</div>' % (width, b_a, b_d))
1612 1606
1613 1607 t = stats['added'] + stats['deleted']
1614 1608 unit = float(width) / (t or 1)
1615 1609
1616 1610 # needs > 9% of width to be visible or 0 to be hidden
1617 1611 a_p = max(9, unit * a) if a > 0 else 0
1618 1612 d_p = max(9, unit * d) if d > 0 else 0
1619 1613 p_sum = a_p + d_p
1620 1614
1621 1615 if p_sum > width:
1622 1616 #adjust the percentage to be == 100% since we adjusted to 9
1623 1617 if a_p > d_p:
1624 1618 a_p = a_p - (p_sum - width)
1625 1619 else:
1626 1620 d_p = d_p - (p_sum - width)
1627 1621
1628 1622 a_v = a if a > 0 else ''
1629 1623 d_v = d if d > 0 else ''
1630 1624
1631 1625 d_a = '<div class="added %s" style="width:%s%%">%s</div>' % (
1632 1626 cgen('a', a_v, d_v), a_p, a_v
1633 1627 )
1634 1628 d_d = '<div class="deleted %s" style="width:%s%%">%s</div>' % (
1635 1629 cgen('d', a_v, d_v), d_p, d_v
1636 1630 )
1637 1631 return literal('<div style="width:%spx">%s%s</div>' % (width, d_a, d_d))
1638 1632
1639 1633
1640 1634 def urlify_text(text_, safe=True):
1641 1635 """
1642 1636 Extrac urls from text and make html links out of them
1643 1637
1644 1638 :param text_:
1645 1639 """
1646 1640
1647 1641 url_pat = re.compile(r'''(http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@#.&+]'''
1648 1642 '''|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+)''')
1649 1643
1650 1644 def url_func(match_obj):
1651 1645 url_full = match_obj.groups()[0]
1652 1646 return '<a href="%(url)s">%(url)s</a>' % ({'url': url_full})
1653 1647 _newtext = url_pat.sub(url_func, text_)
1654 1648 if safe:
1655 1649 return literal(_newtext)
1656 1650 return _newtext
1657 1651
1658 1652
1659 1653 def urlify_commits(text_, repository):
1660 1654 """
1661 1655 Extract commit ids from text and make link from them
1662 1656
1663 1657 :param text_:
1664 1658 :param repository: repo name to build the URL with
1665 1659 """
1666 1660 from pylons import url # doh, we need to re-import url to mock it later
1667 1661 URL_PAT = re.compile(r'(^|\s)([0-9a-fA-F]{12,40})($|\s)')
1668 1662
1669 1663 def url_func(match_obj):
1670 1664 commit_id = match_obj.groups()[1]
1671 1665 pref = match_obj.groups()[0]
1672 1666 suf = match_obj.groups()[2]
1673 1667
1674 1668 tmpl = (
1675 1669 '%(pref)s<a class="%(cls)s" href="%(url)s">'
1676 1670 '%(commit_id)s</a>%(suf)s'
1677 1671 )
1678 1672 return tmpl % {
1679 1673 'pref': pref,
1680 1674 'cls': 'revision-link',
1681 1675 'url': url('changeset_home', repo_name=repository,
1682 1676 revision=commit_id, qualified=True),
1683 1677 'commit_id': commit_id,
1684 1678 'suf': suf
1685 1679 }
1686 1680
1687 1681 newtext = URL_PAT.sub(url_func, text_)
1688 1682
1689 1683 return newtext
1690 1684
1691 1685
1692 1686 def _process_url_func(match_obj, repo_name, uid, entry,
1693 1687 return_raw_data=False):
1694 1688 pref = ''
1695 1689 if match_obj.group().startswith(' '):
1696 1690 pref = ' '
1697 1691
1698 1692 issue_id = ''.join(match_obj.groups())
1699 1693 tmpl = (
1700 1694 '%(pref)s<a class="%(cls)s" href="%(url)s">'
1701 1695 '%(issue-prefix)s%(id-repr)s'
1702 1696 '</a>')
1703 1697
1704 1698 (repo_name_cleaned,
1705 1699 parent_group_name) = RepoGroupModel().\
1706 1700 _get_group_name_and_parent(repo_name)
1707 1701
1708 1702 # variables replacement
1709 1703 named_vars = {
1710 1704 'id': issue_id,
1711 1705 'repo': repo_name,
1712 1706 'repo_name': repo_name_cleaned,
1713 1707 'group_name': parent_group_name
1714 1708 }
1715 1709 # named regex variables
1716 1710 named_vars.update(match_obj.groupdict())
1717 1711 _url = string.Template(entry['url']).safe_substitute(**named_vars)
1718 1712
1719 1713 data = {
1720 1714 'pref': pref,
1721 1715 'cls': 'issue-tracker-link',
1722 1716 'url': _url,
1723 1717 'id-repr': issue_id,
1724 1718 'issue-prefix': entry['pref'],
1725 1719 'serv': entry['url'],
1726 1720 }
1727 1721 if return_raw_data:
1728 1722 return {
1729 1723 'id': issue_id,
1730 1724 'url': _url
1731 1725 }
1732 1726 return tmpl % data
1733 1727
1734 1728
1735 1729 def process_patterns(text_string, repo_name, config=None):
1736 1730 repo = None
1737 1731 if repo_name:
1738 1732 # Retrieving repo_name to avoid invalid repo_name to explode on
1739 1733 # IssueTrackerSettingsModel but still passing invalid name further down
1740 1734 repo = Repository.get_by_repo_name(repo_name, cache=True)
1741 1735
1742 1736 settings_model = IssueTrackerSettingsModel(repo=repo)
1743 1737 active_entries = settings_model.get_settings(cache=True)
1744 1738
1745 1739 issues_data = []
1746 1740 newtext = text_string
1747 1741 for uid, entry in active_entries.items():
1748 1742 log.debug('found issue tracker entry with uid %s' % (uid,))
1749 1743
1750 1744 if not (entry['pat'] and entry['url']):
1751 1745 log.debug('skipping due to missing data')
1752 1746 continue
1753 1747
1754 1748 log.debug('issue tracker entry: uid: `%s` PAT:%s URL:%s PREFIX:%s'
1755 1749 % (uid, entry['pat'], entry['url'], entry['pref']))
1756 1750
1757 1751 try:
1758 1752 pattern = re.compile(r'%s' % entry['pat'])
1759 1753 except re.error:
1760 1754 log.exception(
1761 1755 'issue tracker pattern: `%s` failed to compile',
1762 1756 entry['pat'])
1763 1757 continue
1764 1758
1765 1759 data_func = partial(
1766 1760 _process_url_func, repo_name=repo_name, entry=entry, uid=uid,
1767 1761 return_raw_data=True)
1768 1762
1769 1763 for match_obj in pattern.finditer(text_string):
1770 1764 issues_data.append(data_func(match_obj))
1771 1765
1772 1766 url_func = partial(
1773 1767 _process_url_func, repo_name=repo_name, entry=entry, uid=uid)
1774 1768
1775 1769 newtext = pattern.sub(url_func, newtext)
1776 1770 log.debug('processed prefix:uid `%s`' % (uid,))
1777 1771
1778 1772 return newtext, issues_data
1779 1773
1780 1774
1781 1775 def urlify_commit_message(commit_text, repository=None):
1782 1776 """
1783 1777 Parses given text message and makes proper links.
1784 1778 issues are linked to given issue-server, and rest is a commit link
1785 1779
1786 1780 :param commit_text:
1787 1781 :param repository:
1788 1782 """
1789 1783 from pylons import url # doh, we need to re-import url to mock it later
1790 1784
1791 1785 def escaper(string):
1792 1786 return string.replace('<', '&lt;').replace('>', '&gt;')
1793 1787
1794 1788 newtext = escaper(commit_text)
1795 1789
1796 1790 # extract http/https links and make them real urls
1797 1791 newtext = urlify_text(newtext, safe=False)
1798 1792
1799 1793 # urlify commits - extract commit ids and make link out of them, if we have
1800 1794 # the scope of repository present.
1801 1795 if repository:
1802 1796 newtext = urlify_commits(newtext, repository)
1803 1797
1804 1798 # process issue tracker patterns
1805 1799 newtext, issues = process_patterns(newtext, repository or '')
1806 1800
1807 1801 return literal(newtext)
1808 1802
1809 1803
1810 1804 def rst(source, mentions=False):
1811 1805 return literal('<div class="rst-block">%s</div>' %
1812 1806 MarkupRenderer.rst(source, mentions=mentions))
1813 1807
1814 1808
1815 1809 def markdown(source, mentions=False):
1816 1810 return literal('<div class="markdown-block">%s</div>' %
1817 1811 MarkupRenderer.markdown(source, flavored=True,
1818 1812 mentions=mentions))
1819 1813
1820 1814 def renderer_from_filename(filename, exclude=None):
1821 1815 return MarkupRenderer.renderer_from_filename(filename, exclude=exclude)
1822 1816
1823 1817
1824 1818 def render(source, renderer='rst', mentions=False):
1825 1819 if renderer == 'rst':
1826 1820 return rst(source, mentions=mentions)
1827 1821 if renderer == 'markdown':
1828 1822 return markdown(source, mentions=mentions)
1829 1823
1830 1824
1831 1825 def commit_status(repo, commit_id):
1832 1826 return ChangesetStatusModel().get_status(repo, commit_id)
1833 1827
1834 1828
1835 1829 def commit_status_lbl(commit_status):
1836 1830 return dict(ChangesetStatus.STATUSES).get(commit_status)
1837 1831
1838 1832
1839 1833 def commit_time(repo_name, commit_id):
1840 1834 repo = Repository.get_by_repo_name(repo_name)
1841 1835 commit = repo.get_commit(commit_id=commit_id)
1842 1836 return commit.date
1843 1837
1844 1838
1845 1839 def get_permission_name(key):
1846 1840 return dict(Permission.PERMS).get(key)
1847 1841
1848 1842
1849 1843 def journal_filter_help():
1850 1844 return _(
1851 1845 'Example filter terms:\n' +
1852 1846 ' repository:vcs\n' +
1853 1847 ' username:marcin\n' +
1854 1848 ' action:*push*\n' +
1855 1849 ' ip:127.0.0.1\n' +
1856 1850 ' date:20120101\n' +
1857 1851 ' date:[20120101100000 TO 20120102]\n' +
1858 1852 '\n' +
1859 1853 'Generate wildcards using \'*\' character:\n' +
1860 1854 ' "repository:vcs*" - search everything starting with \'vcs\'\n' +
1861 1855 ' "repository:*vcs*" - search for repository containing \'vcs\'\n' +
1862 1856 '\n' +
1863 1857 'Optional AND / OR operators in queries\n' +
1864 1858 ' "repository:vcs OR repository:test"\n' +
1865 1859 ' "username:test AND repository:test*"\n'
1866 1860 )
1867 1861
1868 1862
1869 1863 def not_mapped_error(repo_name):
1870 1864 flash(_('%s repository is not mapped to db perhaps'
1871 1865 ' it was created or renamed from the filesystem'
1872 1866 ' please run the application again'
1873 1867 ' in order to rescan repositories') % repo_name, category='error')
1874 1868
1875 1869
1876 1870 def ip_range(ip_addr):
1877 1871 from rhodecode.model.db import UserIpMap
1878 1872 s, e = UserIpMap._get_ip_range(ip_addr)
1879 1873 return '%s - %s' % (s, e)
1880 1874
1881 1875
1882 1876 def form(url, method='post', needs_csrf_token=True, **attrs):
1883 1877 """Wrapper around webhelpers.tags.form to prevent CSRF attacks."""
1884 1878 if method.lower() != 'get' and needs_csrf_token:
1885 1879 raise Exception(
1886 1880 'Forms to POST/PUT/DELETE endpoints should have (in general) a ' +
1887 1881 'CSRF token. If the endpoint does not require such token you can ' +
1888 1882 'explicitly set the parameter needs_csrf_token to false.')
1889 1883
1890 1884 return wh_form(url, method=method, **attrs)
1891 1885
1892 1886
1893 1887 def secure_form(url, method="POST", multipart=False, **attrs):
1894 1888 """Start a form tag that points the action to an url. This
1895 1889 form tag will also include the hidden field containing
1896 1890 the auth token.
1897 1891
1898 1892 The url options should be given either as a string, or as a
1899 1893 ``url()`` function. The method for the form defaults to POST.
1900 1894
1901 1895 Options:
1902 1896
1903 1897 ``multipart``
1904 1898 If set to True, the enctype is set to "multipart/form-data".
1905 1899 ``method``
1906 1900 The method to use when submitting the form, usually either
1907 1901 "GET" or "POST". If "PUT", "DELETE", or another verb is used, a
1908 1902 hidden input with name _method is added to simulate the verb
1909 1903 over POST.
1910 1904
1911 1905 """
1912 1906 from webhelpers.pylonslib.secure_form import insecure_form
1913 1907 form = insecure_form(url, method, multipart, **attrs)
1914 1908 token = csrf_input()
1915 1909 return literal("%s\n%s" % (form, token))
1916 1910
1917 1911 def csrf_input():
1918 1912 return literal(
1919 1913 '<input type="hidden" id="{}" name="{}" value="{}">'.format(
1920 1914 csrf_token_key, csrf_token_key, get_csrf_token()))
1921 1915
1922 1916 def dropdownmenu(name, selected, options, enable_filter=False, **attrs):
1923 1917 select_html = select(name, selected, options, **attrs)
1924 1918 select2 = """
1925 1919 <script>
1926 1920 $(document).ready(function() {
1927 1921 $('#%s').select2({
1928 1922 containerCssClass: 'drop-menu',
1929 1923 dropdownCssClass: 'drop-menu-dropdown',
1930 1924 dropdownAutoWidth: true%s
1931 1925 });
1932 1926 });
1933 1927 </script>
1934 1928 """
1935 1929 filter_option = """,
1936 1930 minimumResultsForSearch: -1
1937 1931 """
1938 1932 input_id = attrs.get('id') or name
1939 1933 filter_enabled = "" if enable_filter else filter_option
1940 1934 select_script = literal(select2 % (input_id, filter_enabled))
1941 1935
1942 1936 return literal(select_html+select_script)
1943 1937
1944 1938
1945 1939 def get_visual_attr(tmpl_context_var, attr_name):
1946 1940 """
1947 1941 A safe way to get a variable from visual variable of template context
1948 1942
1949 1943 :param tmpl_context_var: instance of tmpl_context, usually present as `c`
1950 1944 :param attr_name: name of the attribute we fetch from the c.visual
1951 1945 """
1952 1946 visual = getattr(tmpl_context_var, 'visual', None)
1953 1947 if not visual:
1954 1948 return
1955 1949 else:
1956 1950 return getattr(visual, attr_name, None)
1957 1951
1958 1952
1959 1953 def get_last_path_part(file_node):
1960 1954 if not file_node.path:
1961 1955 return u''
1962 1956
1963 1957 path = safe_unicode(file_node.path.split('/')[-1])
1964 1958 return u'../' + path
1965 1959
1966 1960
1967 1961 def route_path(*args, **kwds):
1968 1962 """
1969 1963 Wrapper around pyramids `route_path` function. It is used to generate
1970 1964 URLs from within pylons views or templates. This will be removed when
1971 1965 pyramid migration if finished.
1972 1966 """
1973 1967 req = get_current_request()
1974 1968 return req.route_path(*args, **kwds)
1975 1969
1976 1970
1977 1971 def route_path_or_none(*args, **kwargs):
1978 1972 try:
1979 1973 return route_path(*args, **kwargs)
1980 1974 except KeyError:
1981 1975 return None
1982 1976
1983 1977
1984 1978 def static_url(*args, **kwds):
1985 1979 """
1986 1980 Wrapper around pyramids `route_path` function. It is used to generate
1987 1981 URLs from within pylons views or templates. This will be removed when
1988 1982 pyramid migration if finished.
1989 1983 """
1990 1984 req = get_current_request()
1991 1985 return req.static_url(*args, **kwds)
1992 1986
1993 1987
1994 1988 def resource_path(*args, **kwds):
1995 1989 """
1996 1990 Wrapper around pyramids `route_path` function. It is used to generate
1997 1991 URLs from within pylons views or templates. This will be removed when
1998 1992 pyramid migration if finished.
1999 1993 """
2000 1994 req = get_current_request()
2001 1995 return req.resource_path(*args, **kwds)
@@ -1,856 +1,857 b''
1 1 //
2 2 // Variables
3 3 // --------------------------------------------------
4 4
5 5
6 6 //== Colors
7 7 //
8 8 //## Gray and brand colors for use across Bootstrap.
9 9
10 10 @gray-base: #000;
11 11 @gray-darker: lighten(@gray-base, 13.5%); // #222
12 12 @gray-dark: lighten(@gray-base, 20%); // #333
13 13 @gray: lighten(@gray-base, 33.5%); // #555
14 14 @gray-light: lighten(@gray-base, 46.7%); // #777
15 15 @gray-lighter: lighten(@gray-base, 93.5%); // #eee
16 16
17 17 @brand-primary: darken(#428bca, 6.5%);
18 18 @brand-success: #5cb85c;
19 19 @brand-info: #5bc0de;
20 20 @brand-warning: #f0ad4e;
21 21 @brand-danger: #d9534f;
22 22
23 23
24 24 //== Scaffolding
25 25 //
26 26 //## Settings for some of the most global styles.
27 27
28 28 //** Background color for `<body>`.
29 29 @body-bg: #fff;
30 30 //** Global text color on `<body>`.
31 31 @text-color: @gray-dark;
32 32
33 33 //** Global textual link color.
34 34 @link-color: @brand-primary;
35 35 //** Link hover color set via `darken()` function.
36 36 @link-hover-color: darken(@link-color, 15%);
37 37 //** Link hover decoration.
38 38 @link-hover-decoration: underline;
39 39
40 40
41 41 //== Typography
42 42 //
43 43 //## Font, line-height, and color for body text, headings, and more.
44 44
45 45 @font-family-sans-serif: "Helvetica Neue", Helvetica, Arial, sans-serif;
46 46 @font-family-serif: Georgia, "Times New Roman", Times, serif;
47 47 //** Default monospace fonts for `<code>`, `<kbd>`, and `<pre>`.
48 @font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
48 @font-family-monospace: Consolas, "Liberation Mono", Menlo, Monaco, Courier, monospace;
49
49 50 @font-family-base: @font-family-sans-serif;
50 51
51 52 @font-size-base: 14px;
52 53 @font-size-large: ceil((@font-size-base * 1.25)); // ~18px
53 54 @font-size-small: ceil((@font-size-base * 0.85)); // ~12px
54 55
55 56 @font-size-h1: floor((@font-size-base * 2.6)); // ~36px
56 57 @font-size-h2: floor((@font-size-base * 2.15)); // ~30px
57 58 @font-size-h3: ceil((@font-size-base * 1.7)); // ~24px
58 59 @font-size-h4: ceil((@font-size-base * 1.25)); // ~18px
59 60 @font-size-h5: @font-size-base;
60 61 @font-size-h6: ceil((@font-size-base * 0.85)); // ~12px
61 62
62 63 //** Unit-less `line-height` for use in components like buttons.
63 64 @line-height-base: 1.428571429; // 20/14
64 65 //** Computed "line-height" (`font-size` * `line-height`) for use with `margin`, `padding`, etc.
65 66 @line-height-computed: floor((@font-size-base * @line-height-base)); // ~20px
66 67
67 68 //** By default, this inherits from the `<body>`.
68 69 @headings-font-family: inherit;
69 70 @headings-font-weight: 500;
70 71 @headings-line-height: 1.1;
71 72 @headings-color: inherit;
72 73
73 74
74 75 //== Iconography
75 76 //
76 77 //## Specify custom location and filename of the included Glyphicons icon font. Useful for those including Bootstrap via Bower.
77 78
78 79 //** Load fonts from this directory.
79 80 @icon-font-path: "../fonts/";
80 81 //** File name for all font files.
81 82 @icon-font-name: "glyphicons-halflings-regular";
82 83 //** Element ID within SVG icon file.
83 84 @icon-font-svg-id: "glyphicons_halflingsregular";
84 85
85 86
86 87 //== Components
87 88 //
88 89 //## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start).
89 90
90 91 @padding-base-vertical: 6px;
91 92 @padding-base-horizontal: 12px;
92 93
93 94 @padding-large-vertical: 10px;
94 95 @padding-large-horizontal: 16px;
95 96
96 97 @padding-small-vertical: 5px;
97 98 @padding-small-horizontal: 10px;
98 99
99 100 @padding-xs-vertical: 1px;
100 101 @padding-xs-horizontal: 5px;
101 102
102 103 @line-height-large: 1.33;
103 104 @line-height-small: 1.5;
104 105
105 106 @border-radius-base: @border-radius;
106 107 @border-radius-large: 6px;
107 108 @border-radius-small: 3px;
108 109
109 110 //** Global color for active items (e.g., navs or dropdowns).
110 111 @component-active-color: #fff;
111 112 //** Global background color for active items (e.g., navs or dropdowns).
112 113 @component-active-bg: @brand-primary;
113 114
114 115 //** Width of the `border` for generating carets that indicator dropdowns.
115 116 @caret-width-base: 4px;
116 117 //** Carets increase slightly in size for larger components.
117 118 @caret-width-large: 5px;
118 119
119 120
120 121 //== Tables
121 122 //
122 123 //## Customizes the `.table` component with basic values, each used across all table variations.
123 124
124 125 //** Padding for `<th>`s and `<td>`s.
125 126 @table-cell-padding: 8px;
126 127 //** Padding for cells in `.table-condensed`.
127 128 @table-condensed-cell-padding: 5px;
128 129
129 130 //** Default background color used for all tables.
130 131 @table-bg: transparent;
131 132 //** Background color used for `.table-striped`.
132 133 @table-bg-accent: #f9f9f9;
133 134 //** Background color used for `.table-hover`.
134 135 @table-bg-hover: #f5f5f5;
135 136 @table-bg-active: @table-bg-hover;
136 137
137 138 //** Border color for table and cell borders.
138 139 @table-border-color: #ddd;
139 140
140 141
141 142 //== Buttons
142 143 //
143 144 //## For each of Bootstrap's buttons, define text, background and border color.
144 145
145 146 @btn-font-weight: normal;
146 147
147 148 @btn-default-color: #333;
148 149 @btn-default-bg: #fff;
149 150 @btn-default-border: #ccc;
150 151
151 152 @btn-primary-color: #fff;
152 153 @btn-primary-bg: @brand-primary;
153 154 @btn-primary-border: darken(@btn-primary-bg, 5%);
154 155
155 156 @btn-success-color: #fff;
156 157 @btn-success-bg: @brand-success;
157 158 @btn-success-border: darken(@btn-success-bg, 5%);
158 159
159 160 @btn-info-color: #fff;
160 161 @btn-info-bg: @brand-info;
161 162 @btn-info-border: darken(@btn-info-bg, 5%);
162 163
163 164 @btn-warning-color: #fff;
164 165 @btn-warning-bg: @brand-warning;
165 166 @btn-warning-border: darken(@btn-warning-bg, 5%);
166 167
167 168 @btn-danger-color: #fff;
168 169 @btn-danger-bg: @brand-danger;
169 170 @btn-danger-border: darken(@btn-danger-bg, 5%);
170 171
171 172 @btn-link-disabled-color: @gray-light;
172 173
173 174
174 175 //== Forms
175 176 //
176 177 //##
177 178
178 179 //** `<input>` background color
179 180 @input-bg: #fff;
180 181 //** `<input disabled>` background color
181 182 @input-bg-disabled: @gray-lighter;
182 183
183 184 //** Text color for `<input>`s
184 185 @input-color: @gray;
185 186 //** `<input>` border color
186 187 @input-border: #ccc;
187 188
188 189 // TODO: Rename `@input-border-radius` to `@input-border-radius-base` in v4
189 190 //** Default `.form-control` border radius
190 191 @input-border-radius: @border-radius-base;
191 192 //** Large `.form-control` border radius
192 193 @input-border-radius-large: @border-radius-large;
193 194 //** Small `.form-control` border radius
194 195 @input-border-radius-small: @border-radius-small;
195 196
196 197 //** Border color for inputs on focus
197 198 @input-border-focus: #66afe9;
198 199
199 200 //** Placeholder text color
200 201 @input-color-placeholder: #999;
201 202
202 203 //** Default `.form-control` height
203 204 @input-height-base: (@line-height-computed + (@padding-base-vertical * 2) + 2);
204 205 //** Large `.form-control` height
205 206 @input-height-large: (ceil(@font-size-large * @line-height-large) + (@padding-large-vertical * 2) + 2);
206 207 //** Small `.form-control` height
207 208 @input-height-small: (floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2);
208 209
209 210 @legend-color: @gray-dark;
210 211 @legend-border-color: #e5e5e5;
211 212
212 213 //** Background color for textual input addons
213 214 @input-group-addon-bg: @gray-lighter;
214 215 //** Border color for textual input addons
215 216 @input-group-addon-border-color: @input-border;
216 217
217 218 //** Disabled cursor for form controls and buttons.
218 219 @cursor-disabled: not-allowed;
219 220
220 221
221 222 //== Dropdowns
222 223 //
223 224 //## Dropdown menu container and contents.
224 225
225 226 //** Background for the dropdown menu.
226 227 @dropdown-bg: #fff;
227 228 //** Dropdown menu `border-color`.
228 229 @dropdown-border: rgba(0,0,0,.15);
229 230 //** Dropdown menu `border-color` **for IE8**.
230 231 @dropdown-fallback-border: #ccc;
231 232 //** Divider color for between dropdown items.
232 233 @dropdown-divider-bg: #e5e5e5;
233 234
234 235 //** Dropdown link text color.
235 236 @dropdown-link-color: @gray-dark;
236 237 //** Hover color for dropdown links.
237 238 @dropdown-link-hover-color: darken(@gray-dark, 5%);
238 239 //** Hover background for dropdown links.
239 240 @dropdown-link-hover-bg: #f5f5f5;
240 241
241 242 //** Active dropdown menu item text color.
242 243 @dropdown-link-active-color: @component-active-color;
243 244 //** Active dropdown menu item background color.
244 245 @dropdown-link-active-bg: @component-active-bg;
245 246
246 247 //** Disabled dropdown menu item background color.
247 248 @dropdown-link-disabled-color: @gray-light;
248 249
249 250 //** Text color for headers within dropdown menus.
250 251 @dropdown-header-color: @gray-light;
251 252
252 253 //** Deprecated `@dropdown-caret-color` as of v3.1.0
253 254 @dropdown-caret-color: #000;
254 255
255 256
256 257 //-- Z-index master list
257 258 //
258 259 // Warning: Avoid customizing these values. They're used for a bird's eye view
259 260 // of components dependent on the z-axis and are designed to all work together.
260 261 //
261 262 // Note: These variables are not generated into the Customizer.
262 263
263 264 @zindex-navbar: 1000;
264 265 @zindex-dropdown: 1000;
265 266 @zindex-popover: 1060;
266 267 @zindex-tooltip: 1070;
267 268 @zindex-navbar-fixed: 1030;
268 269 @zindex-modal: 1040;
269 270
270 271
271 272 //== Media queries breakpoints
272 273 //
273 274 //## Define the breakpoints at which your layout will change, adapting to different screen sizes.
274 275
275 276 // Extra small screen / phone
276 277 //** Deprecated `@screen-xs` as of v3.0.1
277 278 @screen-xs: 480px;
278 279 //** Deprecated `@screen-xs-min` as of v3.2.0
279 280 @screen-xs-min: @screen-xs;
280 281 //** Deprecated `@screen-phone` as of v3.0.1
281 282 @screen-phone: @screen-xs-min;
282 283
283 284 // Small screen / tablet
284 285 //** Deprecated `@screen-sm` as of v3.0.1
285 286 @screen-sm: 768px;
286 287 @screen-sm-min: @screen-sm;
287 288 //** Deprecated `@screen-tablet` as of v3.0.1
288 289 @screen-tablet: @screen-sm-min;
289 290
290 291 // Medium screen / desktop
291 292 //** Deprecated `@screen-md` as of v3.0.1
292 293 @screen-md: 992px;
293 294 @screen-md-min: @screen-md;
294 295 //** Deprecated `@screen-desktop` as of v3.0.1
295 296 @screen-desktop: @screen-md-min;
296 297
297 298 // Large screen / wide desktop
298 299 //** Deprecated `@screen-lg` as of v3.0.1
299 300 @screen-lg: 1200px;
300 301 @screen-lg-min: @screen-lg;
301 302 //** Deprecated `@screen-lg-desktop` as of v3.0.1
302 303 @screen-lg-desktop: @screen-lg-min;
303 304
304 305 // So media queries don't overlap when required, provide a maximum
305 306 @screen-xs-max: (@screen-sm-min - 1);
306 307 @screen-sm-max: (@screen-md-min - 1);
307 308 @screen-md-max: (@screen-lg-min - 1);
308 309
309 310
310 311 //== Grid system
311 312 //
312 313 //## Define your custom responsive grid.
313 314
314 315 //** Number of columns in the grid.
315 316 @grid-columns: 12;
316 317 //** Padding between columns. Gets divided in half for the left and right.
317 318 @grid-gutter-width: 30px;
318 319 // Navbar collapse
319 320 //** Point at which the navbar becomes uncollapsed.
320 321 @grid-float-breakpoint: @screen-sm-min;
321 322 //** Point at which the navbar begins collapsing.
322 323 @grid-float-breakpoint-max: (@grid-float-breakpoint - 1);
323 324
324 325
325 326 //== Container sizes
326 327 //
327 328 //## Define the maximum width of `.container` for different screen sizes.
328 329
329 330 // Small screen / tablet
330 331 @container-tablet: (720px + @grid-gutter-width);
331 332 //** For `@screen-sm-min` and up.
332 333 @container-sm: @container-tablet;
333 334
334 335 // Medium screen / desktop
335 336 @container-desktop: (940px + @grid-gutter-width);
336 337 //** For `@screen-md-min` and up.
337 338 @container-md: @container-desktop;
338 339
339 340 // Large screen / wide desktop
340 341 @container-large-desktop: (1140px + @grid-gutter-width);
341 342 //** For `@screen-lg-min` and up.
342 343 @container-lg: @container-large-desktop;
343 344
344 345
345 346 //== Navbar
346 347 //
347 348 //##
348 349
349 350 // Basics of a navbar
350 351 @navbar-height: 50px;
351 352 @navbar-margin-bottom: @line-height-computed;
352 353 @navbar-border-radius: @border-radius-base;
353 354 @navbar-padding-horizontal: floor((@grid-gutter-width / 2));
354 355 @navbar-padding-vertical: ((@navbar-height - @line-height-computed) / 2);
355 356 @navbar-collapse-max-height: 340px;
356 357
357 358 @navbar-default-color: #777;
358 359 @navbar-default-bg: #f8f8f8;
359 360 @navbar-default-border: darken(@navbar-default-bg, 6.5%);
360 361
361 362 // Navbar links
362 363 @navbar-default-link-color: #777;
363 364 @navbar-default-link-hover-color: #333;
364 365 @navbar-default-link-hover-bg: transparent;
365 366 @navbar-default-link-active-color: #555;
366 367 @navbar-default-link-active-bg: darken(@navbar-default-bg, 6.5%);
367 368 @navbar-default-link-disabled-color: #ccc;
368 369 @navbar-default-link-disabled-bg: transparent;
369 370
370 371 // Navbar brand label
371 372 @navbar-default-brand-color: @navbar-default-link-color;
372 373 @navbar-default-brand-hover-color: darken(@navbar-default-brand-color, 10%);
373 374 @navbar-default-brand-hover-bg: transparent;
374 375
375 376 // Navbar toggle
376 377 @navbar-default-toggle-hover-bg: #ddd;
377 378 @navbar-default-toggle-icon-bar-bg: #888;
378 379 @navbar-default-toggle-border-color: #ddd;
379 380
380 381
381 382 // Inverted navbar
382 383 // Reset inverted navbar basics
383 384 @navbar-inverse-color: lighten(@gray-light, 15%);
384 385 @navbar-inverse-bg: #222;
385 386 @navbar-inverse-border: darken(@navbar-inverse-bg, 10%);
386 387
387 388 // Inverted navbar links
388 389 @navbar-inverse-link-color: lighten(@gray-light, 15%);
389 390 @navbar-inverse-link-hover-color: #fff;
390 391 @navbar-inverse-link-hover-bg: transparent;
391 392 @navbar-inverse-link-active-color: @navbar-inverse-link-hover-color;
392 393 @navbar-inverse-link-active-bg: darken(@navbar-inverse-bg, 10%);
393 394 @navbar-inverse-link-disabled-color: #444;
394 395 @navbar-inverse-link-disabled-bg: transparent;
395 396
396 397 // Inverted navbar brand label
397 398 @navbar-inverse-brand-color: @navbar-inverse-link-color;
398 399 @navbar-inverse-brand-hover-color: #fff;
399 400 @navbar-inverse-brand-hover-bg: transparent;
400 401
401 402 // Inverted navbar toggle
402 403 @navbar-inverse-toggle-hover-bg: #333;
403 404 @navbar-inverse-toggle-icon-bar-bg: #fff;
404 405 @navbar-inverse-toggle-border-color: #333;
405 406
406 407
407 408 //== Navs
408 409 //
409 410 //##
410 411
411 412 //=== Shared nav styles
412 413 @nav-link-padding: 10px 15px;
413 414 @nav-link-hover-bg: @gray-lighter;
414 415
415 416 @nav-disabled-link-color: @gray-light;
416 417 @nav-disabled-link-hover-color: @gray-light;
417 418
418 419 //== Tabs
419 420 @nav-tabs-border-color: #ddd;
420 421
421 422 @nav-tabs-link-hover-border-color: @gray-lighter;
422 423
423 424 @nav-tabs-active-link-hover-bg: @body-bg;
424 425 @nav-tabs-active-link-hover-color: @gray;
425 426 @nav-tabs-active-link-hover-border-color: #ddd;
426 427
427 428 @nav-tabs-justified-link-border-color: #ddd;
428 429 @nav-tabs-justified-active-link-border-color: @body-bg;
429 430
430 431 //== Pills
431 432 @nav-pills-border-radius: @border-radius-base;
432 433 @nav-pills-active-link-hover-bg: @component-active-bg;
433 434 @nav-pills-active-link-hover-color: @component-active-color;
434 435
435 436
436 437 //== Pagination
437 438 //
438 439 //##
439 440
440 441 @pagination-color: @link-color;
441 442 @pagination-bg: #fff;
442 443 @pagination-border: #ddd;
443 444
444 445 @pagination-hover-color: @link-hover-color;
445 446 @pagination-hover-bg: @gray-lighter;
446 447 @pagination-hover-border: #ddd;
447 448
448 449 @pagination-active-color: #fff;
449 450 @pagination-active-bg: @brand-primary;
450 451 @pagination-active-border: @brand-primary;
451 452
452 453 @pagination-disabled-color: @gray-light;
453 454 @pagination-disabled-bg: #fff;
454 455 @pagination-disabled-border: #ddd;
455 456
456 457
457 458 //== Pager
458 459 //
459 460 //##
460 461
461 462 @pager-bg: @pagination-bg;
462 463 @pager-border: @pagination-border;
463 464 @pager-border-radius: 15px;
464 465
465 466 @pager-hover-bg: @pagination-hover-bg;
466 467
467 468 @pager-active-bg: @pagination-active-bg;
468 469 @pager-active-color: @pagination-active-color;
469 470
470 471 @pager-disabled-color: @pagination-disabled-color;
471 472
472 473
473 474 //== Jumbotron
474 475 //
475 476 //##
476 477
477 478 @jumbotron-padding: 30px;
478 479 @jumbotron-color: inherit;
479 480 @jumbotron-bg: @gray-lighter;
480 481 @jumbotron-heading-color: inherit;
481 482 @jumbotron-font-size: ceil((@font-size-base * 1.5));
482 483
483 484
484 485 //== Form states and alerts
485 486 //
486 487 //## Define colors for form feedback states and, by default, alerts.
487 488
488 489 @state-success-text: #3c763d;
489 490 @state-success-bg: #dff0d8;
490 491 @state-success-border: darken(spin(@state-success-bg, -10), 5%);
491 492
492 493 @state-info-text: #31708f;
493 494 @state-info-bg: #d9edf7;
494 495 @state-info-border: darken(spin(@state-info-bg, -10), 7%);
495 496
496 497 @state-warning-text: #8a6d3b;
497 498 @state-warning-bg: #fcf8e3;
498 499 @state-warning-border: darken(spin(@state-warning-bg, -10), 5%);
499 500
500 501 @state-danger-text: #a94442;
501 502 @state-danger-bg: #f2dede;
502 503 @state-danger-border: darken(spin(@state-danger-bg, -10), 5%);
503 504
504 505
505 506 //== Tooltips
506 507 //
507 508 //##
508 509
509 510 //** Tooltip max width
510 511 @tooltip-max-width: 200px;
511 512 //** Tooltip text color
512 513 @tooltip-color: #fff;
513 514 //** Tooltip background color
514 515 @tooltip-bg: #000;
515 516 @tooltip-opacity: .9;
516 517
517 518 //** Tooltip arrow width
518 519 @tooltip-arrow-width: 5px;
519 520 //** Tooltip arrow color
520 521 @tooltip-arrow-color: @tooltip-bg;
521 522
522 523
523 524 //== Popovers
524 525 //
525 526 //##
526 527
527 528 //** Popover body background color
528 529 @popover-bg: #fff;
529 530 //** Popover maximum width
530 531 @popover-max-width: 276px;
531 532 //** Popover border color
532 533 @popover-border-color: rgba(0,0,0,.2);
533 534 //** Popover fallback border color
534 535 @popover-fallback-border-color: #ccc;
535 536
536 537 //** Popover title background color
537 538 @popover-title-bg: darken(@popover-bg, 3%);
538 539
539 540 //** Popover arrow width
540 541 @popover-arrow-width: 10px;
541 542 //** Popover arrow color
542 543 @popover-arrow-color: @popover-bg;
543 544
544 545 //** Popover outer arrow width
545 546 @popover-arrow-outer-width: (@popover-arrow-width + 1);
546 547 //** Popover outer arrow color
547 548 @popover-arrow-outer-color: fadein(@popover-border-color, 5%);
548 549 //** Popover outer arrow fallback color
549 550 @popover-arrow-outer-fallback-color: darken(@popover-fallback-border-color, 20%);
550 551
551 552
552 553 //== Labels
553 554 //
554 555 //##
555 556
556 557 //** Default label background color
557 558 @label-default-bg: @gray-light;
558 559 //** Primary label background color
559 560 @label-primary-bg: @brand-primary;
560 561 //** Success label background color
561 562 @label-success-bg: @brand-success;
562 563 //** Info label background color
563 564 @label-info-bg: @brand-info;
564 565 //** Warning label background color
565 566 @label-warning-bg: @brand-warning;
566 567 //** Danger label background color
567 568 @label-danger-bg: @brand-danger;
568 569
569 570 //** Default label text color
570 571 @label-color: #fff;
571 572 //** Default text color of a linked label
572 573 @label-link-hover-color: #fff;
573 574
574 575
575 576 //== Modals
576 577 //
577 578 //##
578 579
579 580 //** Padding applied to the modal body
580 581 @modal-inner-padding: 15px;
581 582
582 583 //** Padding applied to the modal title
583 584 @modal-title-padding: 15px;
584 585 //** Modal title line-height
585 586 @modal-title-line-height: @line-height-base;
586 587
587 588 //** Background color of modal content area
588 589 @modal-content-bg: #fff;
589 590 //** Modal content border color
590 591 @modal-content-border-color: rgba(0,0,0,.2);
591 592 //** Modal content border color **for IE8**
592 593 @modal-content-fallback-border-color: #999;
593 594
594 595 //** Modal backdrop background color
595 596 @modal-backdrop-bg: #000;
596 597 //** Modal backdrop opacity
597 598 @modal-backdrop-opacity: .5;
598 599 //** Modal header border color
599 600 @modal-header-border-color: #e5e5e5;
600 601 //** Modal footer border color
601 602 @modal-footer-border-color: @modal-header-border-color;
602 603
603 604 @modal-lg: 900px;
604 605 @modal-md: 600px;
605 606 @modal-sm: 300px;
606 607
607 608
608 609 //== Alerts
609 610 //
610 611 //## Define alert colors, border radius, and padding.
611 612
612 613 @alert-padding: 15px;
613 614 @alert-border-radius: @border-radius-base;
614 615 @alert-link-font-weight: bold;
615 616
616 617 @alert-success-bg: @state-success-bg;
617 618 @alert-success-text: @state-success-text;
618 619 @alert-success-border: @state-success-border;
619 620
620 621 @alert-info-bg: @state-info-bg;
621 622 @alert-info-text: @state-info-text;
622 623 @alert-info-border: @state-info-border;
623 624
624 625 @alert-warning-bg: @state-warning-bg;
625 626 @alert-warning-text: @state-warning-text;
626 627 @alert-warning-border: @state-warning-border;
627 628
628 629 @alert-danger-bg: @state-danger-bg;
629 630 @alert-danger-text: @state-danger-text;
630 631 @alert-danger-border: @state-danger-border;
631 632
632 633
633 634 //== Progress bars
634 635 //
635 636 //##
636 637
637 638 //** Background color of the whole progress component
638 639 @progress-bg: #f5f5f5;
639 640 //** Progress bar text color
640 641 @progress-bar-color: #fff;
641 642 //** Variable for setting rounded corners on progress bar.
642 643 @progress-border-radius: @border-radius-base;
643 644
644 645 //** Default progress bar color
645 646 @progress-bar-bg: @brand-primary;
646 647 //** Success progress bar color
647 648 @progress-bar-success-bg: @brand-success;
648 649 //** Warning progress bar color
649 650 @progress-bar-warning-bg: @brand-warning;
650 651 //** Danger progress bar color
651 652 @progress-bar-danger-bg: @brand-danger;
652 653 //** Info progress bar color
653 654 @progress-bar-info-bg: @brand-info;
654 655
655 656
656 657 //== List group
657 658 //
658 659 //##
659 660
660 661 //** Background color on `.list-group-item`
661 662 @list-group-bg: #fff;
662 663 //** `.list-group-item` border color
663 664 @list-group-border: #ddd;
664 665 //** List group border radius
665 666 @list-group-border-radius: @border-radius-base;
666 667
667 668 //** Background color of single list items on hover
668 669 @list-group-hover-bg: #f5f5f5;
669 670 //** Text color of active list items
670 671 @list-group-active-color: @component-active-color;
671 672 //** Background color of active list items
672 673 @list-group-active-bg: @component-active-bg;
673 674 //** Border color of active list elements
674 675 @list-group-active-border: @list-group-active-bg;
675 676 //** Text color for content within active list items
676 677 @list-group-active-text-color: lighten(@list-group-active-bg, 40%);
677 678
678 679 //** Text color of disabled list items
679 680 @list-group-disabled-color: @gray-light;
680 681 //** Background color of disabled list items
681 682 @list-group-disabled-bg: @gray-lighter;
682 683 //** Text color for content within disabled list items
683 684 @list-group-disabled-text-color: @list-group-disabled-color;
684 685
685 686 @list-group-link-color: #555;
686 687 @list-group-link-hover-color: @list-group-link-color;
687 688 @list-group-link-heading-color: #333;
688 689
689 690
690 691 //== Panels
691 692 //
692 693 //##
693 694
694 695 @panel-bg: #fff;
695 696 @panel-body-padding: @padding;
696 697 @panel-heading-padding: 10px 15px;
697 698 @panel-footer-padding: @panel-heading-padding;
698 699 @panel-border-radius: @border-radius-base;
699 700
700 701 //** Border color for elements within panels
701 702 @panel-inner-border: #ddd;
702 703 @panel-footer-bg: #fff;
703 704
704 705 @panel-default-text: @text-color;
705 706 @panel-default-border: @grey5;
706 707 @panel-default-heading-bg: @grey6;
707 708
708 709 @panel-primary-text: #fff;
709 710 @panel-primary-border: @brand-primary;
710 711 @panel-primary-heading-bg: @brand-primary;
711 712
712 713 @panel-success-text: @state-success-text;
713 714 @panel-success-border: @state-success-border;
714 715 @panel-success-heading-bg: @state-success-bg;
715 716
716 717 @panel-info-text: @state-info-text;
717 718 @panel-info-border: @state-info-border;
718 719 @panel-info-heading-bg: @state-info-bg;
719 720
720 721 @panel-warning-text: @state-warning-text;
721 722 @panel-warning-border: @state-warning-border;
722 723 @panel-warning-heading-bg: @state-warning-bg;
723 724
724 725 @panel-danger-text: @state-danger-text;
725 726 @panel-danger-border: @state-danger-border;
726 727 @panel-danger-heading-bg: @state-danger-bg;
727 728
728 729
729 730 //== Thumbnails
730 731 //
731 732 //##
732 733
733 734 //** Padding around the thumbnail image
734 735 @thumbnail-padding: 4px;
735 736 //** Thumbnail background color
736 737 @thumbnail-bg: @body-bg;
737 738 //** Thumbnail border color
738 739 @thumbnail-border: #ddd;
739 740 //** Thumbnail border radius
740 741 @thumbnail-border-radius: @border-radius-base;
741 742
742 743 //** Custom text color for thumbnail captions
743 744 @thumbnail-caption-color: @text-color;
744 745 //** Padding around the thumbnail caption
745 746 @thumbnail-caption-padding: 9px;
746 747
747 748
748 749 //== Wells
749 750 //
750 751 //##
751 752
752 753 @well-bg: #f5f5f5;
753 754 @well-border: darken(@well-bg, 7%);
754 755
755 756
756 757 //== Badges
757 758 //
758 759 //##
759 760
760 761 @badge-color: #fff;
761 762 //** Linked badge text color on hover
762 763 @badge-link-hover-color: #fff;
763 764 @badge-bg: @gray-light;
764 765
765 766 //** Badge text color in active nav link
766 767 @badge-active-color: @link-color;
767 768 //** Badge background color in active nav link
768 769 @badge-active-bg: #fff;
769 770
770 771 @badge-font-weight: bold;
771 772 @badge-line-height: 1;
772 773 @badge-border-radius: 10px;
773 774
774 775
775 776 //== Breadcrumbs
776 777 //
777 778 //##
778 779
779 780 @breadcrumb-padding-vertical: 8px;
780 781 @breadcrumb-padding-horizontal: 15px;
781 782 //** Breadcrumb background color
782 783 @breadcrumb-bg: #f5f5f5;
783 784 //** Breadcrumb text color
784 785 @breadcrumb-color: #ccc;
785 786 //** Text color of current page in the breadcrumb
786 787 @breadcrumb-active-color: @gray-light;
787 788 //** Textual separator for between breadcrumb elements
788 789 @breadcrumb-separator: "/";
789 790
790 791
791 792 //== Carousel
792 793 //
793 794 //##
794 795
795 796 @carousel-text-shadow: 0 1px 2px rgba(0,0,0,.6);
796 797
797 798 @carousel-control-color: #fff;
798 799 @carousel-control-width: 15%;
799 800 @carousel-control-opacity: .5;
800 801 @carousel-control-font-size: 20px;
801 802
802 803 @carousel-indicator-active-bg: #fff;
803 804 @carousel-indicator-border-color: #fff;
804 805
805 806 @carousel-caption-color: #fff;
806 807
807 808
808 809 //== Close
809 810 //
810 811 //##
811 812
812 813 @close-font-weight: bold;
813 814 @close-color: #000;
814 815 @close-text-shadow: 0 1px 0 #fff;
815 816
816 817
817 818 //== Code
818 819 //
819 820 //##
820 821
821 822 @code-color: #c7254e;
822 823 @code-bg: #f9f2f4;
823 824
824 825 @kbd-color: #fff;
825 826 @kbd-bg: #333;
826 827
827 828 @pre-bg: #f5f5f5;
828 829 @pre-color: @gray-dark;
829 830 @pre-border-color: #ccc;
830 831 @pre-scrollable-max-height: 340px;
831 832
832 833
833 834 //== Type
834 835 //
835 836 //##
836 837
837 838 //** Horizontal offset for forms and lists.
838 839 @component-offset-horizontal: 180px;
839 840 //** Text muted color
840 841 @text-muted: @grey4;
841 842 //** Abbreviations and acronyms border color
842 843 @abbr-border-color: @gray-light;
843 844 //** Headings small color
844 845 @headings-small-color: @gray-light;
845 846 //** Blockquote small color
846 847 @blockquote-small-color: @gray-light;
847 848 //** Blockquote font size
848 849 @blockquote-font-size: (@font-size-base * 1.25);
849 850 //** Blockquote border color
850 851 @blockquote-border-color: @gray-lighter;
851 852 //** Page header border color
852 853 @page-header-border-color: @gray-lighter;
853 854 //** Width of horizontal description list titles
854 855 @dl-horizontal-offset: @component-offset-horizontal;
855 856 //** Horizontal line color.
856 857 @hr-border: @gray-lighter;
@@ -1,643 +1,749 b''
1 1 // Default styles
2 2
3 3 .diff-collapse {
4 4 margin: @padding 0;
5 5 text-align: right;
6 6 }
7 7
8 8 .diff-container {
9 9 margin-bottom: @space;
10 10
11 11 .diffblock {
12 12 margin-bottom: @space;
13 13 }
14 14
15 15 &.hidden {
16 16 display: none;
17 17 overflow: hidden;
18 18 }
19 19 }
20 20
21 21 .compare_view_files {
22 22
23 23 .diff-container {
24 24
25 25 .diffblock {
26 26 margin-bottom: 0;
27 27 }
28 28 }
29 29 }
30 30
31 31 div.diffblock .sidebyside {
32 32 background: #ffffff;
33 33 }
34 34
35 35 div.diffblock {
36 36 overflow-x: auto;
37 37 overflow-y: hidden;
38 38 clear: both;
39 39 padding: 0px;
40 40 background: @grey6;
41 41 border: @border-thickness solid @grey5;
42 42 -webkit-border-radius: @border-radius @border-radius 0px 0px;
43 43 border-radius: @border-radius @border-radius 0px 0px;
44 44
45 45
46 46 .comments-number {
47 47 float: right;
48 48 }
49 49
50 50 // BEGIN CODE-HEADER STYLES
51 51
52 52 .code-header {
53 53 background: @grey6;
54 54 padding: 10px 0 10px 0;
55 55 height: auto;
56 56 width: 100%;
57 57
58 58 .hash {
59 59 float: left;
60 60 padding: 2px 0 0 2px;
61 61 }
62 62
63 63 .date {
64 64 float: left;
65 65 text-transform: uppercase;
66 66 padding: 4px 0px 0px 2px;
67 67 }
68 68
69 69 div {
70 70 margin-left: 4px;
71 71 }
72 72
73 73 div.compare_header {
74 74 min-height: 40px;
75 75 margin: 0;
76 76 padding: 0 @padding;
77 77
78 78 .drop-menu {
79 79 float:left;
80 80 display: block;
81 81 margin:0 0 @padding 0;
82 82 }
83 83
84 84 .compare-label {
85 85 float: left;
86 86 clear: both;
87 87 display: inline-block;
88 88 min-width: 5em;
89 89 margin: 0;
90 90 padding: @button-padding @button-padding @button-padding 0;
91 91 font-family: @text-semibold;
92 92 }
93 93
94 94 .compare-buttons {
95 95 float: left;
96 96 margin: 0;
97 97 padding: 0 0 @padding;
98 98
99 99 .btn {
100 100 margin: 0 @padding 0 0;
101 101 }
102 102 }
103 103 }
104 104
105 105 }
106 106
107 107 .parents {
108 108 float: left;
109 109 width: 100px;
110 110 font-weight: 400;
111 111 vertical-align: middle;
112 112 padding: 0px 2px 0px 2px;
113 113 background-color: @grey6;
114 114
115 115 #parent_link {
116 116 margin: 00px 2px;
117 117
118 118 &.double {
119 119 margin: 0px 2px;
120 120 }
121 121
122 122 &.disabled{
123 123 margin-right: @padding;
124 124 }
125 125 }
126 126 }
127 127
128 128 .children {
129 129 float: right;
130 130 width: 100px;
131 131 font-weight: 400;
132 132 vertical-align: middle;
133 133 text-align: right;
134 134 padding: 0px 2px 0px 2px;
135 135 background-color: @grey6;
136 136
137 137 #child_link {
138 138 margin: 0px 2px;
139 139
140 140 &.double {
141 141 margin: 0px 2px;
142 142 }
143 143
144 144 &.disabled{
145 145 margin-right: @padding;
146 146 }
147 147 }
148 148 }
149 149
150 150 .changeset_header {
151 151 height: 16px;
152 152
153 153 & > div{
154 154 margin-right: @padding;
155 155 }
156 156 }
157 157
158 158 .changeset_file {
159 159 text-align: left;
160 160 float: left;
161 161 padding: 0;
162 162
163 163 a{
164 164 display: inline-block;
165 165 margin-right: 0.5em;
166 166 }
167 167
168 168 #selected_mode{
169 169 margin-left: 0;
170 170 }
171 171 }
172 172
173 173 .diff-menu-wrapper {
174 174 float: left;
175 175 }
176 176
177 177 .diff-menu {
178 178 position: absolute;
179 179 background: none repeat scroll 0 0 #FFFFFF;
180 180 border-color: #003367 @grey3 @grey3;
181 181 border-right: 1px solid @grey3;
182 182 border-style: solid solid solid;
183 183 border-width: @border-thickness;
184 184 box-shadow: 2px 8px 4px rgba(0, 0, 0, 0.2);
185 185 margin-top: 5px;
186 186 margin-left: 1px;
187 187 }
188 188
189 189 .diff-actions, .editor-actions {
190 190 float: left;
191 191
192 192 input{
193 193 margin: 0 0.5em 0 0;
194 194 }
195 195 }
196 196
197 197 // END CODE-HEADER STYLES
198 198
199 199 // BEGIN CODE-BODY STYLES
200 200
201 201 .code-body {
202 202 background: white;
203 203 padding: 0;
204 204 background-color: #ffffff;
205 205 position: relative;
206 206 max-width: none;
207 207 box-sizing: border-box;
208 208 // TODO: johbo: Parent has overflow: auto, this forces the child here
209 209 // to have the intended size and to scroll. Should be simplified.
210 210 width: 100%;
211 211 overflow-x: auto;
212 212 }
213 213
214 214 pre.raw {
215 215 background: white;
216 216 color: @grey1;
217 217 }
218 218 // END CODE-BODY STYLES
219 219
220 220 }
221 221
222 222
223 223 table.code-difftable {
224 224 border-collapse: collapse;
225 225 width: 99%;
226 226 border-radius: 0px !important;
227 227
228 228 td {
229 229 padding: 0 !important;
230 230 background: none !important;
231 231 border: 0 !important;
232 232 }
233 233
234 234 .context {
235 235 background: none repeat scroll 0 0 #DDE7EF;
236 236 }
237 237
238 238 .add {
239 239 background: none repeat scroll 0 0 #DDFFDD;
240 240
241 241 ins {
242 242 background: none repeat scroll 0 0 #AAFFAA;
243 243 text-decoration: none;
244 244 }
245 245 }
246 246
247 247 .del {
248 248 background: none repeat scroll 0 0 #FFDDDD;
249 249
250 250 del {
251 251 background: none repeat scroll 0 0 #FFAAAA;
252 252 text-decoration: none;
253 253 }
254 254 }
255 255
256 256 /** LINE NUMBERS **/
257 257 .lineno {
258 258 padding-left: 2px !important;
259 259 padding-right: 2px;
260 260 text-align: right;
261 261 width: 32px;
262 262 -moz-user-select: none;
263 263 -webkit-user-select: none;
264 264 border-right: @border-thickness solid @grey5 !important;
265 265 border-left: 0px solid #CCC !important;
266 266 border-top: 0px solid #CCC !important;
267 267 border-bottom: none !important;
268 268
269 269 a {
270 270 &:extend(pre);
271 271 text-align: right;
272 272 padding-right: 2px;
273 273 cursor: pointer;
274 274 display: block;
275 275 width: 32px;
276 276 }
277 277 }
278 278
279 279 .context {
280 280 cursor: auto;
281 281 &:extend(pre);
282 282 }
283 283
284 284 .lineno-inline {
285 285 background: none repeat scroll 0 0 #FFF !important;
286 286 padding-left: 2px;
287 287 padding-right: 2px;
288 288 text-align: right;
289 289 width: 30px;
290 290 -moz-user-select: none;
291 291 -webkit-user-select: none;
292 292 }
293 293
294 294 /** CODE **/
295 295 .code {
296 296 display: block;
297 297 width: 100%;
298 298
299 299 td {
300 300 margin: 0;
301 301 padding: 0;
302 302 }
303 303
304 304 pre {
305 305 margin: 0;
306 306 padding: 0;
307 307 margin-left: .5em;
308 308 }
309 309 }
310 310 }
311 311
312 312
313 313 // Comments
314 314
315 315 div.comment:target {
316 316 border-left: 6px solid @comment-highlight-color;
317 317 padding-left: 3px;
318 318 margin-left: -9px;
319 319 }
320 320
321 321 //TODO: anderson: can't get an absolute number out of anything, so had to put the
322 322 //current values that might change. But to make it clear I put as a calculation
323 323 @comment-max-width: 1065px;
324 324 @pr-extra-margin: 34px;
325 325 @pr-border-spacing: 4px;
326 326 @pr-comment-width: @comment-max-width - @pr-extra-margin - @pr-border-spacing;
327 327
328 328 // Pull Request
329 329 .cs_files .code-difftable {
330 330 border: @border-thickness solid @grey5; //borders only on PRs
331 331
332 332 .comment-inline-form,
333 333 div.comment {
334 334 width: @pr-comment-width;
335 335 }
336 336 }
337 337
338 338 // Changeset
339 339 .code-difftable {
340 340 .comment-inline-form,
341 341 div.comment {
342 342 width: @comment-max-width;
343 343 }
344 344 }
345 345
346 346 //Style page
347 347 @style-extra-margin: @sidebar-width + (@sidebarpadding * 3) + @padding;
348 348 #style-page .code-difftable{
349 349 .comment-inline-form,
350 350 div.comment {
351 351 width: @comment-max-width - @style-extra-margin;
352 352 }
353 353 }
354 354
355 355 #context-bar > h2 {
356 356 font-size: 20px;
357 357 }
358 358
359 359 #context-bar > h2> a {
360 360 font-size: 20px;
361 361 }
362 362 // end of defaults
363 363
364 364 .file_diff_buttons {
365 365 padding: 0 0 @padding;
366 366
367 367 .drop-menu {
368 368 float: left;
369 369 margin: 0 @padding 0 0;
370 370 }
371 371 .btn {
372 372 margin: 0 @padding 0 0;
373 373 }
374 374 }
375 375
376 376 .code-body.textarea.editor {
377 377 max-width: none;
378 378 padding: 15px;
379 379 }
380 380
381 381 td.injected_diff{
382 382 max-width: 1178px;
383 383 overflow-x: auto;
384 384 overflow-y: hidden;
385 385
386 386 div.diff-container,
387 387 div.diffblock{
388 388 max-width: 100%;
389 389 }
390 390
391 391 div.code-body {
392 392 max-width: 1124px;
393 393 overflow-x: auto;
394 394 overflow-y: hidden;
395 395 padding: 0;
396 396 }
397 397 div.diffblock {
398 398 border: none;
399 399 }
400 400
401 401 &.inline-form {
402 402 width: 99%
403 403 }
404 404 }
405 405
406 406
407 407 table.code-difftable {
408 408 width: 100%;
409 409 }
410 410
411 411 /** PYGMENTS COLORING **/
412 412 div.codeblock {
413 413
414 414 // TODO: johbo: Added interim to get rid of the margin around
415 415 // Select2 widgets. This needs further cleanup.
416 416 margin-top: @padding;
417 417
418 418 overflow: auto;
419 419 padding: 0px;
420 420 border: @border-thickness solid @grey5;
421 421 background: @grey6;
422 422 .border-radius(@border-radius);
423 423
424 424 #remove_gist {
425 425 float: right;
426 426 }
427 427
428 428 .author {
429 429 clear: both;
430 430 vertical-align: middle;
431 431 font-family: @text-bold;
432 432 }
433 433
434 434 .btn-mini {
435 435 float: left;
436 436 margin: 0 5px 0 0;
437 437 }
438 438
439 439 .code-header {
440 440 padding: @padding;
441 441 border-bottom: @border-thickness solid @grey5;
442 442
443 443 .rc-user {
444 444 min-width: 0;
445 445 margin-right: .5em;
446 446 }
447 447
448 448 .stats {
449 449 clear: both;
450 450 margin: 0 0 @padding 0;
451 451 padding: 0;
452 452 .left {
453 453 float: left;
454 454 clear: left;
455 455 max-width: 75%;
456 456 margin: 0 0 @padding 0;
457 457
458 458 &.item {
459 459 margin-right: @padding;
460 460 &.last { border-right: none; }
461 461 }
462 462 }
463 463 .buttons { float: right; }
464 464 .author {
465 465 height: 25px; margin-left: 15px; font-weight: bold;
466 466 }
467 467 }
468 468
469 469 .commit {
470 470 margin: 5px 0 0 26px;
471 471 font-weight: normal;
472 472 white-space: pre-wrap;
473 473 }
474 474 }
475 475
476 476 .message {
477 477 position: relative;
478 478 margin: @padding;
479 479
480 480 .codeblock-label {
481 481 margin: 0 0 1em 0;
482 482 }
483 483 }
484 484
485 485 .code-body {
486 486 padding: @padding;
487 487 background-color: #ffffff;
488 488 min-width: 100%;
489 489 box-sizing: border-box;
490 490 // TODO: johbo: Parent has overflow: auto, this forces the child here
491 491 // to have the intended size and to scroll. Should be simplified.
492 492 width: 100%;
493 493 overflow-x: auto;
494 494 }
495 495 }
496 496
497 497 .code-highlighttable,
498 498 div.codeblock {
499 499
500 500 &.readme {
501 501 background-color: white;
502 502 }
503 503
504 504 .markdown-block table {
505 505 border-collapse: collapse;
506 506
507 507 th,
508 508 td {
509 padding: .5em !important;
510 border: @border-thickness solid @border-default-color !important;
509 padding: .5em;
510 border: @border-thickness solid @border-default-color;
511 511 }
512 512 }
513 513
514 514 table {
515 width: 0 !important;
516 border: 0px !important;
515 border: 0px;
517 516 margin: 0;
518 517 letter-spacing: normal;
519 518
520 519
521 520 td {
522 border: 0px !important;
521 border: 0px;
523 522 vertical-align: top;
524 523 }
525 524 }
526 525 }
527 526
528 527 div.codeblock .code-header .search-path { padding: 0 0 0 10px; }
529 528 div.search-code-body {
530 529 background-color: #ffffff; padding: 5px 0 5px 10px;
531 530 pre {
532 531 .match { background-color: #faffa6;}
533 532 .break { display: block; width: 100%; background-color: #DDE7EF; color: #747474; }
534 533 }
535 534 .code-highlighttable {
536 535 border-collapse: collapse;
537 536
538 537 tr:hover {
539 538 background: #fafafa;
540 539 }
541 540 td.code {
542 541 padding-left: 10px;
543 542 }
544 543 td.line {
545 544 border-right: 1px solid #ccc !important;
546 545 padding-right: 10px;
547 546 text-align: right;
548 547 font-family: "Lucida Console",Monaco,monospace;
549 548 span {
550 549 white-space: pre-wrap;
551 550 color: #666666;
552 551 }
553 552 }
554 553 }
555 554 }
556 555
557 556 div.annotatediv { margin-left: 2px; margin-right: 4px; }
558 557 .code-highlight {
559 558 margin: 0; padding: 0; border-left: @border-thickness solid @grey5;
560 559 pre, .linenodiv pre { padding: 0 5px; margin: 0; }
561 560 pre div:target {background-color: @comment-highlight-color !important;}
562 561 }
563 562
564 563 .linenos a { text-decoration: none; }
565 564
566 565 .CodeMirror-selected { background: @rchighlightblue; }
567 566 .CodeMirror-focused .CodeMirror-selected { background: @rchighlightblue; }
568 567 .CodeMirror ::selection { background: @rchighlightblue; }
569 568 .CodeMirror ::-moz-selection { background: @rchighlightblue; }
570 569
571 570 .code { display: block; border:0px !important; }
572 .code-highlight,
571 .code-highlight, /* TODO: dan: merge codehilite into code-highlight */
573 572 .codehilite {
574 573 .hll { background-color: #ffffcc }
575 574 .c { color: #408080; font-style: italic } /* Comment */
576 575 .err, .codehilite .err { border: @border-thickness solid #FF0000 } /* Error */
577 576 .k { color: #008000; font-weight: bold } /* Keyword */
578 577 .o { color: #666666 } /* Operator */
579 578 .cm { color: #408080; font-style: italic } /* Comment.Multiline */
580 579 .cp { color: #BC7A00 } /* Comment.Preproc */
581 580 .c1 { color: #408080; font-style: italic } /* Comment.Single */
582 581 .cs { color: #408080; font-style: italic } /* Comment.Special */
583 582 .gd { color: #A00000 } /* Generic.Deleted */
584 583 .ge { font-style: italic } /* Generic.Emph */
585 584 .gr { color: #FF0000 } /* Generic.Error */
586 585 .gh { color: #000080; font-weight: bold } /* Generic.Heading */
587 586 .gi { color: #00A000 } /* Generic.Inserted */
588 587 .go { color: #808080 } /* Generic.Output */
589 588 .gp { color: #000080; font-weight: bold } /* Generic.Prompt */
590 589 .gs { font-weight: bold } /* Generic.Strong */
591 590 .gu { color: #800080; font-weight: bold } /* Generic.Subheading */
592 591 .gt { color: #0040D0 } /* Generic.Traceback */
593 592 .kc { color: #008000; font-weight: bold } /* Keyword.Constant */
594 593 .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */
595 594 .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */
596 595 .kp { color: #008000 } /* Keyword.Pseudo */
597 596 .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */
598 597 .kt { color: #B00040 } /* Keyword.Type */
599 598 .m { color: #666666 } /* Literal.Number */
600 599 .s { color: #BA2121 } /* Literal.String */
601 600 .na { color: #7D9029 } /* Name.Attribute */
602 601 .nb { color: #008000 } /* Name.Builtin */
603 602 .nc { color: #0000FF; font-weight: bold } /* Name.Class */
604 603 .no { color: #880000 } /* Name.Constant */
605 604 .nd { color: #AA22FF } /* Name.Decorator */
606 605 .ni { color: #999999; font-weight: bold } /* Name.Entity */
607 606 .ne { color: #D2413A; font-weight: bold } /* Name.Exception */
608 607 .nf { color: #0000FF } /* Name.Function */
609 608 .nl { color: #A0A000 } /* Name.Label */
610 609 .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */
611 610 .nt { color: #008000; font-weight: bold } /* Name.Tag */
612 611 .nv { color: #19177C } /* Name.Variable */
613 612 .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */
614 613 .w { color: #bbbbbb } /* Text.Whitespace */
615 614 .mf { color: #666666 } /* Literal.Number.Float */
616 615 .mh { color: #666666 } /* Literal.Number.Hex */
617 616 .mi { color: #666666 } /* Literal.Number.Integer */
618 617 .mo { color: #666666 } /* Literal.Number.Oct */
619 618 .sb { color: #BA2121 } /* Literal.String.Backtick */
620 619 .sc { color: #BA2121 } /* Literal.String.Char */
621 620 .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */
622 621 .s2 { color: #BA2121 } /* Literal.String.Double */
623 622 .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */
624 623 .sh { color: #BA2121 } /* Literal.String.Heredoc */
625 624 .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */
626 625 .sx { color: #008000 } /* Literal.String.Other */
627 626 .sr { color: #BB6688 } /* Literal.String.Regex */
628 627 .s1 { color: #BA2121 } /* Literal.String.Single */
629 628 .ss { color: #19177C } /* Literal.String.Symbol */
630 629 .bp { color: #008000 } /* Name.Builtin.Pseudo */
631 630 .vc { color: #19177C } /* Name.Variable.Class */
632 631 .vg { color: #19177C } /* Name.Variable.Global */
633 632 .vi { color: #19177C } /* Name.Variable.Instance */
634 633 .il { color: #666666 } /* Literal.Number.Integer.Long */
635 634 }
636 635
637 636 /* customized pre blocks for markdown/rst */
638 637 pre.literal-block, .codehilite pre{
639 638 padding: @padding;
640 639 border: 1px solid @grey6;
641 640 .border-radius(@border-radius);
642 641 background-color: @grey7;
643 642 }
643
644
645 /* START NEW CODE BLOCK CSS */
646
647 table.cb {
648 width: 100%;
649 border-collapse: collapse;
650 margin-bottom: 10px;
651
652 * {
653 box-sizing: border-box;
654 }
655
656 /* intentionally general selector since .cb-line-selected must override it
657 and they both use !important since the td itself may have a random color
658 generated by annotation blocks. TLDR: if you change it, make sure
659 annotated block selection and line selection in file view still work */
660 .cb-line-fresh .cb-content {
661 background: white !important;
662 }
663
664 tr.cb-annotate {
665 border-top: 1px solid #eee;
666
667 &+ .cb-line {
668 border-top: 1px solid #eee;
669 }
670
671 &:first-child {
672 border-top: none;
673 &+ .cb-line {
674 border-top: none;
675 }
676 }
677 }
678
679 td {
680 vertical-align: top;
681 padding: 2px 10px;
682
683 &.cb-content {
684 white-space: pre-wrap;
685 font-family: @font-family-monospace;
686 font-size: 12.35px;
687
688 span {
689 word-break: break-word;
690 }
691 }
692
693 &.cb-lineno {
694 padding: 0;
695 height: 1px; /* this allows the <a> link to fill to 100% height of the td */
696 width: 50px;
697 color: rgba(0, 0, 0, 0.3);
698 text-align: right;
699 border-right: 1px solid #eee;
700 font-family: @font-family-monospace;
701
702 a::before {
703 content: attr(data-line-no);
704 }
705 &.cb-line-selected {
706 background: @comment-highlight-color !important;
707 }
708
709 a {
710 display: block;
711 height: 100%;
712 color: rgba(0, 0, 0, 0.3);
713 padding: 0 10px; /* vertical padding is 0 so that height: 100% works */
714 line-height: 18px; /* use this instead of vertical padding */
715 }
716 }
717
718 &.cb-content {
719 &.cb-line-selected {
720 background: @comment-highlight-color !important;
721 }
722 }
723
724 &.cb-annotate-info {
725 width: 320px;
726 min-width: 320px;
727 max-width: 320px;
728 padding: 5px 2px;
729 font-size: 13px;
730
731 strong.cb-annotate-message {
732 padding: 5px 0;
733 white-space: pre-line;
734 display: inline-block;
735 }
736 .rc-user {
737 float: none;
738 padding: 0 6px 0 17px;
739 min-width: auto;
740 min-height: auto;
741 }
742 }
743
744 &.cb-annotate-revision {
745 cursor: pointer;
746 text-align: right;
747 }
748 }
749 }
@@ -1,388 +1,476 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 /**
20 20 RhodeCode JS Files
21 21 **/
22 22
23 23 if (typeof console == "undefined" || typeof console.log == "undefined"){
24 24 console = { log: function() {} }
25 25 }
26 26
27 27 // TODO: move the following function to submodules
28 28
29 29 /**
30 30 * show more
31 31 */
32 32 var show_more_event = function(){
33 33 $('table .show_more').click(function(e) {
34 34 var cid = e.target.id.substring(1);
35 35 var button = $(this);
36 36 if (button.hasClass('open')) {
37 37 $('#'+cid).hide();
38 38 button.removeClass('open');
39 39 } else {
40 40 $('#'+cid).show();
41 41 button.addClass('open one');
42 42 }
43 43 });
44 44 };
45 45
46 46 var compare_radio_buttons = function(repo_name, compare_ref_type){
47 47 $('#compare_action').on('click', function(e){
48 48 e.preventDefault();
49 49
50 50 var source = $('input[name=compare_source]:checked').val();
51 51 var target = $('input[name=compare_target]:checked').val();
52 52 if(source && target){
53 53 var url_data = {
54 54 repo_name: repo_name,
55 55 source_ref: source,
56 56 source_ref_type: compare_ref_type,
57 57 target_ref: target,
58 58 target_ref_type: compare_ref_type,
59 59 merge: 1
60 60 };
61 61 window.location = pyroutes.url('compare_url', url_data);
62 62 }
63 63 });
64 64 $('.compare-radio-button').on('click', function(e){
65 65 var source = $('input[name=compare_source]:checked').val();
66 66 var target = $('input[name=compare_target]:checked').val();
67 67 if(source && target){
68 68 $('#compare_action').removeAttr("disabled");
69 69 $('#compare_action').removeClass("disabled");
70 70 }
71 71 })
72 72 };
73 73
74 74 var showRepoSize = function(target, repo_name, commit_id, callback) {
75 75 var container = $('#' + target);
76 76 var url = pyroutes.url('repo_stats',
77 77 {"repo_name": repo_name, "commit_id": commit_id});
78 78
79 79 if (!container.hasClass('loaded')) {
80 80 $.ajax({url: url})
81 81 .complete(function (data) {
82 82 var responseJSON = data.responseJSON;
83 83 container.addClass('loaded');
84 84 container.html(responseJSON.size);
85 85 callback(responseJSON.code_stats)
86 86 })
87 87 .fail(function (data) {
88 88 console.log('failed to load repo stats');
89 89 });
90 90 }
91 91
92 92 };
93 93
94 94 var showRepoStats = function(target, data){
95 95 var container = $('#' + target);
96 96
97 97 if (container.hasClass('loaded')) {
98 98 return
99 99 }
100 100
101 101 var total = 0;
102 102 var no_data = true;
103 103 var tbl = document.createElement('table');
104 104 tbl.setAttribute('class', 'trending_language_tbl');
105 105
106 106 $.each(data, function(key, val){
107 107 total += val.count;
108 108 });
109 109
110 110 var sortedStats = [];
111 111 for (var obj in data){
112 112 sortedStats.push([obj, data[obj]])
113 113 }
114 114 var sortedData = sortedStats.sort(function (a, b) {
115 115 return b[1].count - a[1].count
116 116 });
117 117 var cnt = 0;
118 118 $.each(sortedData, function(idx, val){
119 119 cnt += 1;
120 120 no_data = false;
121 121
122 122 var hide = cnt > 2;
123 123 var tr = document.createElement('tr');
124 124 if (hide) {
125 125 tr.setAttribute('style', 'display:none');
126 126 tr.setAttribute('class', 'stats_hidden');
127 127 }
128 128
129 129 var key = val[0];
130 130 var obj = {"desc": val[1].desc, "count": val[1].count};
131 131
132 132 var percentage = Math.round((obj.count / total * 100), 2);
133 133
134 134 var td1 = document.createElement('td');
135 135 td1.width = 300;
136 136 var trending_language_label = document.createElement('div');
137 137 trending_language_label.innerHTML = obj.desc + " (.{0})".format(key);
138 138 td1.appendChild(trending_language_label);
139 139
140 140 var td2 = document.createElement('td');
141 141 var trending_language = document.createElement('div');
142 142 var nr_files = obj.count +" "+ _ngettext('file', 'files', obj.count);
143 143
144 144 trending_language.title = key + " " + nr_files;
145 145
146 146 trending_language.innerHTML = "<span>" + percentage + "% " + nr_files
147 147 + "</span><b>" + percentage + "% " + nr_files + "</b>";
148 148
149 149 trending_language.setAttribute("class", 'trending_language');
150 150 $('b', trending_language)[0].style.width = percentage + "%";
151 151 td2.appendChild(trending_language);
152 152
153 153 tr.appendChild(td1);
154 154 tr.appendChild(td2);
155 155 tbl.appendChild(tr);
156 156 if (cnt == 3) {
157 157 var show_more = document.createElement('tr');
158 158 var td = document.createElement('td');
159 159 lnk = document.createElement('a');
160 160
161 161 lnk.href = '#';
162 162 lnk.innerHTML = _gettext('Show more');
163 163 lnk.id = 'code_stats_show_more';
164 164 td.appendChild(lnk);
165 165
166 166 show_more.appendChild(td);
167 167 show_more.appendChild(document.createElement('td'));
168 168 tbl.appendChild(show_more);
169 169 }
170 170 });
171 171
172 172 $(container).html(tbl);
173 173 $(container).addClass('loaded');
174 174
175 175 $('#code_stats_show_more').on('click', function (e) {
176 176 e.preventDefault();
177 177 $('.stats_hidden').each(function (idx) {
178 178 $(this).css("display", "");
179 179 });
180 180 $('#code_stats_show_more').hide();
181 181 });
182 182
183 183 };
184 184
185 185
186 186 // Toggle Collapsable Content
187 187 function collapsableContent() {
188 188
189 189 $('.collapsable-content').not('.no-hide').hide();
190 190
191 191 $('.btn-collapse').unbind(); //in case we've been here before
192 192 $('.btn-collapse').click(function() {
193 193 var button = $(this);
194 194 var togglename = $(this).data("toggle");
195 195 $('.collapsable-content[data-toggle='+togglename+']').toggle();
196 196 if ($(this).html()=="Show Less")
197 197 $(this).html("Show More");
198 198 else
199 199 $(this).html("Show Less");
200 200 });
201 201 };
202 202
203 203 var timeagoActivate = function() {
204 204 $("time.timeago").timeago();
205 205 };
206 206
207 207 // Formatting values in a Select2 dropdown of commit references
208 208 var formatSelect2SelectionRefs = function(commit_ref){
209 209 var tmpl = '';
210 210 if (!commit_ref.text || commit_ref.type === 'sha'){
211 211 return commit_ref.text;
212 212 }
213 213 if (commit_ref.type === 'branch'){
214 214 tmpl = tmpl.concat('<i class="icon-branch"></i> ');
215 215 } else if (commit_ref.type === 'tag'){
216 216 tmpl = tmpl.concat('<i class="icon-tag"></i> ');
217 217 } else if (commit_ref.type === 'book'){
218 218 tmpl = tmpl.concat('<i class="icon-bookmark"></i> ');
219 219 }
220 220 return tmpl.concat(commit_ref.text);
221 221 };
222 222
223 223 // takes a given html element and scrolls it down offset pixels
224 224 function offsetScroll(element, offset){
225 225 setTimeout(function(){
226 226 var location = element.offset().top;
227 227 // some browsers use body, some use html
228 228 $('html, body').animate({ scrollTop: (location - offset) });
229 229 }, 100);
230 230 }
231 231
232 232 /**
233 233 * global hooks after DOM is loaded
234 234 */
235 235 $(document).ready(function() {
236 236 firefoxAnchorFix();
237 237
238 238 $('.navigation a.menulink').on('click', function(e){
239 239 var menuitem = $(this).parent('li');
240 240 if (menuitem.hasClass('open')) {
241 241 menuitem.removeClass('open');
242 242 } else {
243 243 menuitem.addClass('open');
244 244 $(document).on('click', function(event) {
245 245 if (!$(event.target).closest(menuitem).length) {
246 246 menuitem.removeClass('open');
247 247 }
248 248 });
249 249 }
250 250 });
251 251 $('.compare_view_files').on(
252 252 'mouseenter mouseleave', 'tr.line .lineno a',function(event) {
253 253 if (event.type === "mouseenter") {
254 254 $(this).parents('tr.line').addClass('hover');
255 255 } else {
256 256 $(this).parents('tr.line').removeClass('hover');
257 257 }
258 258 });
259 259
260 260 $('.compare_view_files').on(
261 261 'mouseenter mouseleave', 'tr.line .add-comment-line a',function(event){
262 262 if (event.type === "mouseenter") {
263 263 $(this).parents('tr.line').addClass('commenting');
264 264 } else {
265 265 $(this).parents('tr.line').removeClass('commenting');
266 266 }
267 267 });
268 268
269 $('.compare_view_files').on(
269 $('body').on( /* TODO: replace the $('.compare_view_files').on('click') below
270 when new diffs are integrated */
271 'click', '.cb-lineno a', function(event) {
272
273 if ($(this).attr('data-line-no') !== ""){
274 $('.cb-line-selected').removeClass('cb-line-selected');
275 var td = $(this).parent();
276 td.addClass('cb-line-selected'); // line number td
277 td.next().addClass('cb-line-selected'); // line content td
278
279 // Replace URL without jumping to it if browser supports.
280 // Default otherwise
281 if (history.pushState) {
282 var new_location = location.href.rstrip('#');
283 if (location.hash) {
284 new_location = new_location.replace(location.hash, "");
285 }
286
287 // Make new anchor url
288 new_location = new_location + $(this).attr('href');
289 history.pushState(true, document.title, new_location);
290
291 return false;
292 }
293 }
294 });
295
296 $('.compare_view_files').on( /* TODO: replace this with .cb function above
297 when new diffs are integrated */
270 298 'click', 'tr.line .lineno a',function(event) {
271 299 if ($(this).text() != ""){
272 300 $('tr.line').removeClass('selected');
273 301 $(this).parents("tr.line").addClass('selected');
274 302
275 303 // Replace URL without jumping to it if browser supports.
276 304 // Default otherwise
277 305 if (history.pushState) {
278 306 var new_location = location.href;
279 307 if (location.hash){
280 308 new_location = new_location.replace(location.hash, "");
281 309 }
282 310
283 311 // Make new anchor url
284 312 var new_location = new_location+$(this).attr('href');
285 313 history.pushState(true, document.title, new_location);
286 314
287 315 return false;
288 316 }
289 317 }
290 318 });
291 319
292 320 $('.compare_view_files').on(
293 321 'click', 'tr.line .add-comment-line a',function(event) {
294 322 var tr = $(event.currentTarget).parents('tr.line')[0];
295 323 injectInlineForm(tr);
296 324 return false;
297 325 });
298 326
299 327 $('.collapse_file').on('click', function(e) {
300 328 e.stopPropagation();
301 329 if ($(e.target).is('a')) { return; }
302 330 var node = $(e.delegateTarget).first();
303 331 var icon = $($(node.children().first()).children().first());
304 332 var id = node.attr('fid');
305 333 var target = $('#'+id);
306 334 var tr = $('#tr_'+id);
307 335 var diff = $('#diff_'+id);
308 336 if(node.hasClass('expand_file')){
309 337 node.removeClass('expand_file');
310 338 icon.removeClass('expand_file_icon');
311 339 node.addClass('collapse_file');
312 340 icon.addClass('collapse_file_icon');
313 341 diff.show();
314 342 tr.show();
315 343 target.show();
316 344 } else {
317 345 node.removeClass('collapse_file');
318 346 icon.removeClass('collapse_file_icon');
319 347 node.addClass('expand_file');
320 348 icon.addClass('expand_file_icon');
321 349 diff.hide();
322 350 tr.hide();
323 351 target.hide();
324 352 }
325 353 });
326 354
327 355 $('#expand_all_files').click(function() {
328 356 $('.expand_file').each(function() {
329 357 var node = $(this);
330 358 var icon = $($(node.children().first()).children().first());
331 359 var id = $(this).attr('fid');
332 360 var target = $('#'+id);
333 361 var tr = $('#tr_'+id);
334 362 var diff = $('#diff_'+id);
335 363 node.removeClass('expand_file');
336 364 icon.removeClass('expand_file_icon');
337 365 node.addClass('collapse_file');
338 366 icon.addClass('collapse_file_icon');
339 367 diff.show();
340 368 tr.show();
341 369 target.show();
342 370 });
343 371 });
344 372
345 373 $('#collapse_all_files').click(function() {
346 374 $('.collapse_file').each(function() {
347 375 var node = $(this);
348 376 var icon = $($(node.children().first()).children().first());
349 377 var id = $(this).attr('fid');
350 378 var target = $('#'+id);
351 379 var tr = $('#tr_'+id);
352 380 var diff = $('#diff_'+id);
353 381 node.removeClass('collapse_file');
354 382 icon.removeClass('collapse_file_icon');
355 383 node.addClass('expand_file');
356 384 icon.addClass('expand_file_icon');
357 385 diff.hide();
358 386 tr.hide();
359 387 target.hide();
360 388 });
361 389 });
362 390
363 391 // Mouse over behavior for comments and line selection
364 392
365 393 // Select the line that comes from the url anchor
366 394 // At the time of development, Chrome didn't seem to support jquery's :target
367 395 // element, so I had to scroll manually
368 if (location.hash) {
396
397 if (location.hash) { /* TODO: dan: remove this and replace with code block
398 below when new diffs are ready */
369 399 var result = splitDelimitedHash(location.hash);
370 400 var loc = result.loc;
371 var remainder = result.remainder;
372 401 if (loc.length > 1){
373 402 var lineno = $(loc+'.lineno');
374 403 if (lineno.length > 0){
375 404 var tr = lineno.parents('tr.line');
376 405 tr.addClass('selected');
377 406
378 407 tr[0].scrollIntoView();
379 408
380 409 $.Topic('/ui/plugins/code/anchor_focus').prepareOrPublish({
381 410 tr:tr,
382 remainder:remainder});
411 remainder: result.remainder});
383 412 }
384 413 }
385 414 }
386 415
416 if (location.hash) { /* TODO: dan: use this to replace the code block above
417 when new diffs are ready */
418 var result = splitDelimitedHash(location.hash);
419 var loc = result.loc;
420 if (loc.length > 1) {
421 var page_highlights = loc.substring(
422 loc.indexOf('#') + 1).split('L');
423
424 if (page_highlights.length > 1) {
425 var highlight_ranges = page_highlights[1].split(",");
426 var h_lines = [];
427 for (var pos in highlight_ranges) {
428 var _range = highlight_ranges[pos].split('-');
429 if (_range.length === 2) {
430 var start = parseInt(_range[0]);
431 var end = parseInt(_range[1]);
432 if (start < end) {
433 for (var i = start; i <= end; i++) {
434 h_lines.push(i);
435 }
436 }
437 }
438 else {
439 h_lines.push(parseInt(highlight_ranges[pos]));
440 }
441 }
442 for (pos in h_lines) {
443 var line_td = $('td.cb-lineno#L' + h_lines[pos]);
444 if (line_td.length) {
445 line_td.addClass('cb-line-selected'); // line number td
446 line_td.next().addClass('cb-line-selected'); // line content
447 }
448 }
449 var first_line_td = $('td.cb-lineno#L' + h_lines[0]);
450 if (first_line_td.length) {
451 var elOffset = first_line_td.offset().top;
452 var elHeight = first_line_td.height();
453 var windowHeight = $(window).height();
454 var offset;
455
456 if (elHeight < windowHeight) {
457 offset = elOffset - ((windowHeight / 4) - (elHeight / 2));
458 }
459 else {
460 offset = elOffset;
461 }
462 $(function() { // let browser scroll to hash first, then
463 // scroll the line to the middle of page
464 setTimeout(function() {
465 $('html, body').animate({ scrollTop: offset });
466 }, 100);
467 });
468 $.Topic('/ui/plugins/code/anchor_focus').prepareOrPublish({
469 lineno: first_line_td,
470 remainder: result.remainder});
471 }
472 }
473 }
474 }
387 475 collapsableContent();
388 476 });
@@ -1,324 +1,287 b''
1 1 <%inherit file="/base/base.html"/>
2 2
3 3 <%def name="title(*args)">
4 4 ${_('%s Files') % c.repo_name}
5 5 %if hasattr(c,'file'):
6 6 &middot; ${h.safe_unicode(c.file.path) or '\\'}
7 7 %endif
8 8
9 9 %if c.rhodecode_name:
10 10 &middot; ${h.branding(c.rhodecode_name)}
11 11 %endif
12 12 </%def>
13 13
14 14 <%def name="breadcrumbs_links()">
15 15 ${_('Files')}
16 16 %if c.file:
17 17 @ ${h.show_id(c.commit)}
18 18 %endif
19 19 </%def>
20 20
21 21 <%def name="menu_bar_nav()">
22 22 ${self.menu_items(active='repositories')}
23 23 </%def>
24 24
25 25 <%def name="menu_bar_subnav()">
26 26 ${self.repo_menu(active='files')}
27 27 </%def>
28 28
29 29 <%def name="main()">
30 30 <div class="title">
31 31 ${self.repo_page_title(c.rhodecode_db_repo)}
32 32 </div>
33 33
34 34 <div id="pjax-container" class="summary">
35 35 <div id="files_data">
36 36 <%include file='files_pjax.html'/>
37 37 </div>
38 38 </div>
39 39 <script>
40 40 var curState = {
41 41 commit_id: "${c.commit.raw_id}"
42 42 };
43 43
44 44 var getState = function(context) {
45 45 var url = $(location).attr('href');
46 46 var _base_url = '${h.url("files_home",repo_name=c.repo_name,revision='',f_path='')}';
47 47 var _annotate_url = '${h.url("files_annotate_home",repo_name=c.repo_name,revision='',f_path='')}';
48 48 _base_url = _base_url.replace('//', '/');
49 49 _annotate_url = _annotate_url.replace('//', '/');
50 50
51 51 //extract f_path from url.
52 52 var parts = url.split(_base_url);
53 53 if (parts.length != 2) {
54 54 parts = url.split(_annotate_url);
55 55 if (parts.length != 2) {
56 56 var rev = "tip";
57 57 var f_path = "";
58 58 } else {
59 59 var parts2 = parts[1].split('/');
60 60 var rev = parts2.shift(); // pop the first element which is the revision
61 61 var f_path = parts2.join('/');
62 62 }
63 63
64 64 } else {
65 65 var parts2 = parts[1].split('/');
66 66 var rev = parts2.shift(); // pop the first element which is the revision
67 67 var f_path = parts2.join('/');
68 68 }
69 69
70 70 var _node_list_url = pyroutes.url('files_nodelist_home',
71 71 {repo_name: templateContext.repo_name,
72 72 revision: rev, f_path: f_path});
73 73 var _url_base = pyroutes.url('files_home',
74 74 {repo_name: templateContext.repo_name,
75 75 revision: rev, f_path:'__FPATH__'});
76 76 return {
77 77 url: url,
78 78 f_path: f_path,
79 79 rev: rev,
80 80 commit_id: curState.commit_id,
81 81 node_list_url: _node_list_url,
82 82 url_base: _url_base
83 83 };
84 84 };
85 85
86 86 var metadataRequest = null;
87 87 var getFilesMetadata = function() {
88 88 if (metadataRequest && metadataRequest.readyState != 4) {
89 89 metadataRequest.abort();
90 90 }
91 91 if (source_page) {
92 92 return false;
93 93 }
94 94
95 95 if ($('#file-tree-wrapper').hasClass('full-load')) {
96 96 // in case our HTML wrapper has full-load class we don't
97 97 // trigger the async load of metadata
98 98 return false;
99 99 }
100 100
101 101 var state = getState('metadata');
102 102 var url_data = {
103 103 'repo_name': templateContext.repo_name,
104 104 'commit_id': state.commit_id,
105 105 'f_path': state.f_path
106 106 };
107 107
108 108 var url = pyroutes.url('files_nodetree_full', url_data);
109 109
110 110 metadataRequest = $.ajax({url: url});
111 111
112 112 metadataRequest.done(function(data) {
113 113 $('#file-tree').html(data);
114 114 timeagoActivate();
115 115 });
116 116 metadataRequest.fail(function (data, textStatus, errorThrown) {
117 117 console.log(data);
118 118 if (data.status != 0) {
119 119 alert("Error while fetching metadata.\nError code {0} ({1}).Please consider reloading the page".format(data.status,data.statusText));
120 120 }
121 121 });
122 122 };
123 123
124 124 var callbacks = function() {
125 125 var state = getState('callbacks');
126 126 timeagoActivate();
127 127
128 128 // used for history, and switch to
129 129 var initialCommitData = {
130 130 id: null,
131 131 text: '${_("Switch To Commit")}',
132 132 type: 'sha',
133 133 raw_id: null,
134 134 files_url: null
135 135 };
136 136
137 137 if ($('#trimmed_message_box').height() < 50) {
138 138 $('#message_expand').hide();
139 139 }
140 140
141 141 $('#message_expand').on('click', function(e) {
142 142 $('#trimmed_message_box').css('max-height', 'none');
143 143 $(this).hide();
144 144 });
145 145
146 146
147 147 if (source_page) {
148 148 // variants for with source code, not tree view
149 149
150 if (location.href.indexOf('#') != -1) {
151 page_highlights = location.href.substring(location.href.indexOf('#') + 1).split('L');
152 if (page_highlights.length == 2) {
153 highlight_ranges = page_highlights[1].split(",");
154
155 var h_lines = [];
156 for (pos in highlight_ranges) {
157 var _range = highlight_ranges[pos].split('-');
158 if (_range.length == 2) {
159 var start = parseInt(_range[0]);
160 var end = parseInt(_range[1]);
161 if (start < end) {
162 for (var i = start; i <= end; i++) {
163 h_lines.push(i);
164 }
165 }
166 }
167 else {
168 h_lines.push(parseInt(highlight_ranges[pos]));
169 }
170 }
171
172 for (pos in h_lines) {
173 // @comment-highlight-color
174 $('#L' + h_lines[pos]).css('background-color', '#ffd887');
175 }
176
177 var _first_line = $('#L' + h_lines[0]).get(0);
178 if (_first_line) {
179 var line = $('#L' + h_lines[0]);
180 if (line.length > 0){
181 offsetScroll(line, 70);
182 }
183 }
184 }
185 }
186
187 150 // select code link event
188 151 $("#hlcode").mouseup(getSelectionLink);
189 152
190 153 // file history select2
191 154 select2FileHistorySwitcher('#diff1', initialCommitData, state);
192 155 $('#diff1').on('change', function(e) {
193 156 $('#diff').removeClass('disabled').removeAttr("disabled");
194 157 $('#show_rev').removeClass('disabled').removeAttr("disabled");
195 158 });
196 159
197 160 // show more authors
198 161 $('#show_authors').on('click', function(e) {
199 162 e.preventDefault();
200 163 var url = pyroutes.url('files_authors_home',
201 164 {'repo_name': templateContext.repo_name,
202 165 'revision': state.rev, 'f_path': state.f_path});
203 166
204 167 $.pjax({
205 168 url: url,
206 169 data: 'annotate=${"1" if c.annotate else "0"}',
207 170 container: '#file_authors',
208 171 push: false,
209 172 timeout: pjaxTimeout
210 173 }).complete(function(){
211 174 $('#show_authors').hide();
212 175 })
213 176 });
214 177
215 178 // load file short history
216 179 $('#file_history_overview').on('click', function(e) {
217 180 e.preventDefault();
218 181 path = state.f_path;
219 182 if (path.indexOf("#") >= 0) {
220 183 path = path.slice(0, path.indexOf("#"));
221 184 }
222 185 var url = pyroutes.url('changelog_file_home',
223 186 {'repo_name': templateContext.repo_name,
224 187 'revision': state.rev, 'f_path': path, 'limit': 6});
225 188 $('#file_history_container').show();
226 189 $('#file_history_container').html('<div class="file-history-inner">{0}</div>'.format(_gettext('Loading ...')));
227 190
228 191 $.pjax({
229 192 url: url,
230 193 container: '#file_history_container',
231 194 push: false,
232 195 timeout: pjaxTimeout
233 196 })
234 197 });
235 198
236 199 }
237 200 else {
238 201 getFilesMetadata();
239 202
240 203 // fuzzy file filter
241 204 fileBrowserListeners(state.node_list_url, state.url_base);
242 205
243 206 // switch to widget
244 207 select2RefSwitcher('#refs_filter', initialCommitData);
245 208 $('#refs_filter').on('change', function(e) {
246 209 var data = $('#refs_filter').select2('data');
247 210 curState.commit_id = data.raw_id;
248 211 $.pjax({url: data.files_url, container: '#pjax-container', timeout: pjaxTimeout});
249 212 });
250 213
251 214 $("#prev_commit_link").on('click', function(e) {
252 215 var data = $(this).data();
253 216 curState.commit_id = data.commitId;
254 217 });
255 218
256 219 $("#next_commit_link").on('click', function(e) {
257 220 var data = $(this).data();
258 221 curState.commit_id = data.commitId;
259 222 });
260 223
261 224 $('#at_rev').on("keypress", function(e) {
262 225 /* ENTER PRESSED */
263 226 if (e.keyCode === 13) {
264 227 var rev = $('#at_rev').val();
265 228 // explicit reload page here. with pjax entering bad input
266 229 // produces not so nice results
267 230 window.location = pyroutes.url('files_home',
268 231 {'repo_name': templateContext.repo_name,
269 232 'revision': rev, 'f_path': state.f_path});
270 233 }
271 234 });
272 235 }
273 236 };
274 237
275 238 var pjaxTimeout = 5000;
276 239
277 240 $(document).pjax(".pjax-link", "#pjax-container", {
278 241 "fragment": "#pjax-content",
279 242 "maxCacheLength": 1000,
280 243 "timeout": pjaxTimeout
281 244 });
282 245
283 246 // define global back/forward states
284 247 var isPjaxPopState = false;
285 248 $(document).on('pjax:popstate', function() {
286 249 isPjaxPopState = true;
287 250 });
288 251
289 252 $(document).on('pjax:end', function(xhr, options) {
290 253 if (isPjaxPopState) {
291 254 isPjaxPopState = false;
292 255 callbacks();
293 256 _NODEFILTER.resetFilter();
294 257 }
295 258
296 259 // run callback for tracking if defined for google analytics etc.
297 260 // this is used to trigger tracking on pjax
298 261 if (typeof window.rhodecode_statechange_callback !== 'undefined') {
299 262 var state = getState('statechange');
300 263 rhodecode_statechange_callback(state.url, null)
301 264 }
302 265 });
303 266
304 267 $(document).on('pjax:success', function(event, xhr, options) {
305 268 if (event.target.id == "file_history_container") {
306 269 $('#file_history_overview').hide();
307 270 $('#file_history_overview_full').show();
308 271 timeagoActivate();
309 272 } else {
310 273 callbacks();
311 274 }
312 275 });
313 276
314 277 $(document).ready(function() {
315 278 callbacks();
316 279 var search_GET = "${request.GET.get('search','')}";
317 280 if (search_GET == "1") {
318 281 _NODEFILTER.initFilter();
319 282 }
320 283 });
321 284
322 285 </script>
323 286
324 287 </%def>
@@ -1,71 +1,82 b''
1 <%namespace name="sourceblock" file="/codeblocks/source.html"/>
1 2
2 3 <div id="codeblock" class="codeblock">
3 4 <div class="codeblock-header">
4 5 <div class="stats">
5 6 <span> <strong>${c.file}</strong></span>
6 7 <span> | ${c.file.lines()[0]} ${ungettext('line', 'lines', c.file.lines()[0])}</span>
7 8 <span> | ${h.format_byte_size_binary(c.file.size)}</span>
8 9 <span> | ${c.file.mimetype} </span>
9 10 <span class="item last"> | ${h.get_lexer_for_filenode(c.file).__class__.__name__}</span>
10 11 </div>
11 12 <div class="buttons">
12 13 <a id="file_history_overview" href="#">
13 14 ${_('History')}
14 15 </a>
15 16 <a id="file_history_overview_full" style="display: none" href="${h.url('changelog_file_home',repo_name=c.repo_name, revision=c.commit.raw_id, f_path=c.f_path)}">
16 17 ${_('Show Full History')}
17 18 </a> |
18 19 %if c.annotate:
19 20 ${h.link_to(_('Source'), h.url('files_home', repo_name=c.repo_name,revision=c.commit.raw_id,f_path=c.f_path))}
20 21 %else:
21 22 ${h.link_to(_('Annotation'), h.url('files_annotate_home',repo_name=c.repo_name,revision=c.commit.raw_id,f_path=c.f_path))}
22 23 %endif
23 24 | ${h.link_to(_('Raw'), h.url('files_raw_home',repo_name=c.repo_name,revision=c.commit.raw_id,f_path=c.f_path))}
24 25 | <a href="${h.url('files_rawfile_home',repo_name=c.repo_name,revision=c.commit.raw_id,f_path=c.f_path)}">
25 26 ${_('Download')}
26 27 </a>
27 28
28 29 %if h.HasRepoPermissionAny('repository.write','repository.admin')(c.repo_name):
29 30 |
30 31 %if c.on_branch_head and c.branch_or_raw_id and not c.file.is_binary:
31 32 <a href="${h.url('files_edit_home',repo_name=c.repo_name,revision=c.branch_or_raw_id,f_path=c.f_path, anchor='edit')}">
32 33 ${_('Edit on Branch:%s') % c.branch_or_raw_id}
33 34 </a>
34 35 | <a class="btn-danger btn-link" href="${h.url('files_delete_home',repo_name=c.repo_name,revision=c.branch_or_raw_id,f_path=c.f_path, anchor='edit')}">${_('Delete')}
35 36 </a>
36 37 %elif c.on_branch_head and c.branch_or_raw_id and c.file.is_binary:
37 38 ${h.link_to(_('Edit'), '#', class_="btn btn-link disabled tooltip", title=_('Editing binary files not allowed'))}
38 39 | ${h.link_to(_('Delete'), h.url('files_delete_home',repo_name=c.repo_name,revision=c.branch_or_raw_id,f_path=c.f_path, anchor='edit'),class_="btn-danger btn-link")}
39 40 %else:
40 41 ${h.link_to(_('Edit'), '#', class_="btn btn-link disabled tooltip", title=_('Editing files allowed only when on branch head commit'))}
41 42 | ${h.link_to(_('Delete'), '#', class_="btn btn-danger btn-link disabled tooltip", title=_('Deleting files allowed only when on branch head commit'))}
42 43 %endif
43 44 %endif
44 45 </div>
45 46 </div>
46 47 <div id="file_history_container"></div>
47 48 <div class="code-body">
48 49 %if c.file.is_binary:
49 50 <div>
50 51 ${_('Binary file (%s)') % c.file.mimetype}
51 52 </div>
52 53 %else:
53 54 % if c.file.size < c.cut_off_limit:
54 %if c.annotate:
55 ${h.pygmentize_annotation(c.repo_name,c.file,linenos=True,anchorlinenos=True,lineanchors='L',cssclass="code-highlight")}
56 %elif c.renderer:
55 %if c.renderer and not c.annotate:
57 56 ${h.render(c.file.content, renderer=c.renderer)}
58 57 %else:
59 ${h.pygmentize(c.file,linenos=True,anchorlinenos=True,lineanchors='L',cssclass="code-highlight")}
58 <table class="cb codehilite">
59 %if c.annotate:
60 <% color_hasher = h.color_hasher() %>
61 %for annotation, lines in c.annotated_lines:
62 ${sourceblock.render_annotation_lines(annotation, lines, color_hasher)}
63 %endfor
64 %else:
65 %for line_num, tokens in c.lines:
66 ${sourceblock.render_line(line_num, tokens)}
67 %endfor
68 %endif
69 </table>
70 </div>
60 71 %endif
61 72 %else:
62 73 ${_('File is too big to display')} ${h.link_to(_('Show as raw'),
63 74 h.url('files_raw_home',repo_name=c.repo_name,revision=c.commit.raw_id,f_path=c.f_path))}
64 75 %endif
65 76 %endif
66 77 </div>
67 78 </div>
68 79
69 80 <script>
70 81 var source_page = true;
71 82 </script>
@@ -1,1078 +1,1078 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-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 import os
22 22
23 23 import mock
24 24 import pytest
25 25
26 26 from rhodecode.controllers.files import FilesController
27 27 from rhodecode.lib import helpers as h
28 28 from rhodecode.lib.compat import OrderedDict
29 29 from rhodecode.lib.ext_json import json
30 30 from rhodecode.lib.vcs import nodes
31 31 from rhodecode.lib.vcs.backends.base import EmptyCommit
32 32 from rhodecode.lib.vcs.conf import settings
33 33 from rhodecode.lib.vcs.nodes import FileNode
34 34 from rhodecode.model.db import Repository
35 35 from rhodecode.model.scm import ScmModel
36 36 from rhodecode.tests import (
37 37 url, TEST_USER_ADMIN_LOGIN, assert_session_flash, assert_not_in_session_flash)
38 38 from rhodecode.tests.fixture import Fixture
39 39 from rhodecode.tests.utils import AssertResponse
40 40
41 41 fixture = Fixture()
42 42
43 43 NODE_HISTORY = {
44 44 'hg': json.loads(fixture.load_resource('hg_node_history_response.json')),
45 45 'git': json.loads(fixture.load_resource('git_node_history_response.json')),
46 46 'svn': json.loads(fixture.load_resource('svn_node_history_response.json')),
47 47 }
48 48
49 49
50 50
51 51 def _commit_change(
52 52 repo, filename, content, message, vcs_type, parent=None,
53 53 newfile=False):
54 54 repo = Repository.get_by_repo_name(repo)
55 55 _commit = parent
56 56 if not parent:
57 57 _commit = EmptyCommit(alias=vcs_type)
58 58
59 59 if newfile:
60 60 nodes = {
61 61 filename: {
62 62 'content': content
63 63 }
64 64 }
65 65 commit = ScmModel().create_nodes(
66 66 user=TEST_USER_ADMIN_LOGIN, repo=repo,
67 67 message=message,
68 68 nodes=nodes,
69 69 parent_commit=_commit,
70 70 author=TEST_USER_ADMIN_LOGIN,
71 71 )
72 72 else:
73 73 commit = ScmModel().commit_change(
74 74 repo=repo.scm_instance(), repo_name=repo.repo_name,
75 75 commit=parent, user=TEST_USER_ADMIN_LOGIN,
76 76 author=TEST_USER_ADMIN_LOGIN,
77 77 message=message,
78 78 content=content,
79 79 f_path=filename
80 80 )
81 81 return commit
82 82
83 83
84 84
85 85 @pytest.mark.usefixtures("app")
86 86 class TestFilesController:
87 87
88 88 def test_index(self, backend):
89 89 response = self.app.get(url(
90 90 controller='files', action='index',
91 91 repo_name=backend.repo_name, revision='tip', f_path='/'))
92 92 commit = backend.repo.get_commit()
93 93
94 94 params = {
95 95 'repo_name': backend.repo_name,
96 96 'commit_id': commit.raw_id,
97 97 'date': commit.date
98 98 }
99 99 assert_dirs_in_response(response, ['docs', 'vcs'], params)
100 100 files = [
101 101 '.gitignore',
102 102 '.hgignore',
103 103 '.hgtags',
104 104 # TODO: missing in Git
105 105 # '.travis.yml',
106 106 'MANIFEST.in',
107 107 'README.rst',
108 108 # TODO: File is missing in svn repository
109 109 # 'run_test_and_report.sh',
110 110 'setup.cfg',
111 111 'setup.py',
112 112 'test_and_report.sh',
113 113 'tox.ini',
114 114 ]
115 115 assert_files_in_response(response, files, params)
116 116 assert_timeago_in_response(response, files, params)
117 117
118 118 def test_index_links_submodules_with_absolute_url(self, backend_hg):
119 119 repo = backend_hg['subrepos']
120 120 response = self.app.get(url(
121 121 controller='files', action='index',
122 122 repo_name=repo.repo_name, revision='tip', f_path='/'))
123 123 assert_response = AssertResponse(response)
124 124 assert_response.contains_one_link(
125 125 'absolute-path @ 000000000000', 'http://example.com/absolute-path')
126 126
127 127 def test_index_links_submodules_with_absolute_url_subpaths(
128 128 self, backend_hg):
129 129 repo = backend_hg['subrepos']
130 130 response = self.app.get(url(
131 131 controller='files', action='index',
132 132 repo_name=repo.repo_name, revision='tip', f_path='/'))
133 133 assert_response = AssertResponse(response)
134 134 assert_response.contains_one_link(
135 135 'subpaths-path @ 000000000000',
136 136 'http://sub-base.example.com/subpaths-path')
137 137
138 138 @pytest.mark.xfail_backends("svn", reason="Depends on branch support")
139 139 def test_files_menu(self, backend):
140 140 new_branch = "temp_branch_name"
141 141 commits = [
142 142 {'message': 'a'},
143 143 {'message': 'b', 'branch': new_branch}
144 144 ]
145 145 backend.create_repo(commits)
146 146
147 147 backend.repo.landing_rev = "branch:%s" % new_branch
148 148
149 149 # get response based on tip and not new revision
150 150 response = self.app.get(url(
151 151 controller='files', action='index',
152 152 repo_name=backend.repo_name, revision='tip', f_path='/'),
153 153 status=200)
154 154
155 155 # make sure Files menu url is not tip but new revision
156 156 landing_rev = backend.repo.landing_rev[1]
157 157 files_url = url('files_home', repo_name=backend.repo_name,
158 158 revision=landing_rev)
159 159
160 160 assert landing_rev != 'tip'
161 161 response.mustcontain('<li class="active"><a class="menulink" href="%s">' % files_url)
162 162
163 163 def test_index_commit(self, backend):
164 164 commit = backend.repo.get_commit(commit_idx=32)
165 165
166 166 response = self.app.get(url(
167 167 controller='files', action='index',
168 168 repo_name=backend.repo_name,
169 169 revision=commit.raw_id,
170 170 f_path='/')
171 171 )
172 172
173 173 dirs = ['docs', 'tests']
174 174 files = ['README.rst']
175 175 params = {
176 176 'repo_name': backend.repo_name,
177 177 'commit_id': commit.raw_id,
178 178 }
179 179 assert_dirs_in_response(response, dirs, params)
180 180 assert_files_in_response(response, files, params)
181 181
182 182 @pytest.mark.xfail_backends("git", reason="Missing branches in git repo")
183 183 @pytest.mark.xfail_backends("svn", reason="Depends on branch support")
184 184 def test_index_different_branch(self, backend):
185 185 # TODO: Git test repository does not contain branches
186 186 # TODO: Branch support in Subversion
187 187
188 188 commit = backend.repo.get_commit(commit_idx=150)
189 189 response = self.app.get(url(
190 190 controller='files', action='index',
191 191 repo_name=backend.repo_name,
192 192 revision=commit.raw_id,
193 193 f_path='/'))
194 194 assert_response = AssertResponse(response)
195 195 assert_response.element_contains(
196 196 '.tags .branchtag', 'git')
197 197
198 198 def test_index_paging(self, backend):
199 199 repo = backend.repo
200 200 indexes = [73, 92, 109, 1, 0]
201 201 idx_map = [(rev, repo.get_commit(commit_idx=rev).raw_id)
202 202 for rev in indexes]
203 203
204 204 for idx in idx_map:
205 205 response = self.app.get(url(
206 206 controller='files', action='index',
207 207 repo_name=backend.repo_name,
208 208 revision=idx[1],
209 209 f_path='/'))
210 210
211 211 response.mustcontain("""r%s:%s""" % (idx[0], idx[1][:8]))
212 212
213 213 def test_file_source(self, backend):
214 214 commit = backend.repo.get_commit(commit_idx=167)
215 215 response = self.app.get(url(
216 216 controller='files', action='index',
217 217 repo_name=backend.repo_name,
218 218 revision=commit.raw_id,
219 219 f_path='vcs/nodes.py'))
220 220
221 221 msgbox = """<div class="commit right-content">%s</div>"""
222 222 response.mustcontain(msgbox % (commit.message, ))
223 223
224 224 assert_response = AssertResponse(response)
225 225 if commit.branch:
226 226 assert_response.element_contains('.tags.tags-main .branchtag', commit.branch)
227 227 if commit.tags:
228 228 for tag in commit.tags:
229 229 assert_response.element_contains('.tags.tags-main .tagtag', tag)
230 230
231 231 def test_file_source_history(self, backend):
232 232 response = self.app.get(
233 233 url(
234 234 controller='files', action='history',
235 235 repo_name=backend.repo_name,
236 236 revision='tip',
237 237 f_path='vcs/nodes.py'),
238 238 extra_environ={'HTTP_X_PARTIAL_XHR': '1'})
239 239 assert NODE_HISTORY[backend.alias] == json.loads(response.body)
240 240
241 241 def test_file_source_history_svn(self, backend_svn):
242 242 simple_repo = backend_svn['svn-simple-layout']
243 243 response = self.app.get(
244 244 url(
245 245 controller='files', action='history',
246 246 repo_name=simple_repo.repo_name,
247 247 revision='tip',
248 248 f_path='trunk/example.py'),
249 249 extra_environ={'HTTP_X_PARTIAL_XHR': '1'})
250 250
251 251 expected_data = json.loads(
252 252 fixture.load_resource('svn_node_history_branches.json'))
253 253 assert expected_data == response.json
254 254
255 255 def test_file_annotation_history(self, backend):
256 256 response = self.app.get(
257 257 url(
258 258 controller='files', action='history',
259 259 repo_name=backend.repo_name,
260 260 revision='tip',
261 261 f_path='vcs/nodes.py',
262 262 annotate=True),
263 263 extra_environ={'HTTP_X_PARTIAL_XHR': '1'})
264 264 assert NODE_HISTORY[backend.alias] == json.loads(response.body)
265 265
266 266 def test_file_annotation(self, backend):
267 267 response = self.app.get(url(
268 268 controller='files', action='index',
269 269 repo_name=backend.repo_name, revision='tip', f_path='vcs/nodes.py',
270 270 annotate=True))
271 271
272 272 expected_revisions = {
273 'hg': 'r356:25213a5fbb04',
274 'git': 'r345:c994f0de03b2',
275 'svn': 'r208:209',
273 'hg': 'r356',
274 'git': 'r345',
275 'svn': 'r208',
276 276 }
277 277 response.mustcontain(expected_revisions[backend.alias])
278 278
279 279 def test_file_authors(self, backend):
280 280 response = self.app.get(url(
281 281 controller='files', action='authors',
282 282 repo_name=backend.repo_name,
283 283 revision='tip',
284 284 f_path='vcs/nodes.py',
285 285 annotate=True))
286 286
287 287 expected_authors = {
288 288 'hg': ('Marcin Kuzminski', 'Lukasz Balcerzak'),
289 289 'git': ('Marcin Kuzminski', 'Lukasz Balcerzak'),
290 290 'svn': ('marcin', 'lukasz'),
291 291 }
292 292
293 293 for author in expected_authors[backend.alias]:
294 294 response.mustcontain(author)
295 295
296 296 def test_tree_search_top_level(self, backend, xhr_header):
297 297 commit = backend.repo.get_commit(commit_idx=173)
298 298 response = self.app.get(
299 299 url('files_nodelist_home', repo_name=backend.repo_name,
300 300 revision=commit.raw_id, f_path='/'),
301 301 extra_environ=xhr_header)
302 302 assert 'nodes' in response.json
303 303 assert {'name': 'docs', 'type': 'dir'} in response.json['nodes']
304 304
305 305 def test_tree_search_at_path(self, backend, xhr_header):
306 306 commit = backend.repo.get_commit(commit_idx=173)
307 307 response = self.app.get(
308 308 url('files_nodelist_home', repo_name=backend.repo_name,
309 309 revision=commit.raw_id, f_path='/docs'),
310 310 extra_environ=xhr_header)
311 311 assert 'nodes' in response.json
312 312 nodes = response.json['nodes']
313 313 assert {'name': 'docs/api', 'type': 'dir'} in nodes
314 314 assert {'name': 'docs/index.rst', 'type': 'file'} in nodes
315 315
316 316 def test_tree_search_at_path_missing_xhr(self, backend):
317 317 self.app.get(
318 318 url('files_nodelist_home', repo_name=backend.repo_name,
319 319 revision='tip', f_path=''), status=400)
320 320
321 321 def test_tree_view_list(self, backend, xhr_header):
322 322 commit = backend.repo.get_commit(commit_idx=173)
323 323 response = self.app.get(
324 324 url('files_nodelist_home', repo_name=backend.repo_name,
325 325 f_path='/', revision=commit.raw_id),
326 326 extra_environ=xhr_header,
327 327 )
328 328 response.mustcontain("vcs/web/simplevcs/views/repository.py")
329 329
330 330 def test_tree_view_list_at_path(self, backend, xhr_header):
331 331 commit = backend.repo.get_commit(commit_idx=173)
332 332 response = self.app.get(
333 333 url('files_nodelist_home', repo_name=backend.repo_name,
334 334 f_path='/docs', revision=commit.raw_id),
335 335 extra_environ=xhr_header,
336 336 )
337 337 response.mustcontain("docs/index.rst")
338 338
339 339 def test_tree_view_list_missing_xhr(self, backend):
340 340 self.app.get(
341 341 url('files_nodelist_home', repo_name=backend.repo_name,
342 342 f_path='/', revision='tip'), status=400)
343 343
344 344 def test_nodetree_full_success(self, backend, xhr_header):
345 345 commit = backend.repo.get_commit(commit_idx=173)
346 346 response = self.app.get(
347 347 url('files_nodetree_full', repo_name=backend.repo_name,
348 348 f_path='/', commit_id=commit.raw_id),
349 349 extra_environ=xhr_header)
350 350
351 351 assert_response = AssertResponse(response)
352 352
353 353 for attr in ['data-commit-id', 'data-date', 'data-author']:
354 354 elements = assert_response.get_elements('[{}]'.format(attr))
355 355 assert len(elements) > 1
356 356
357 357 for element in elements:
358 358 assert element.get(attr)
359 359
360 360 def test_nodetree_full_if_file(self, backend, xhr_header):
361 361 commit = backend.repo.get_commit(commit_idx=173)
362 362 response = self.app.get(
363 363 url('files_nodetree_full', repo_name=backend.repo_name,
364 364 f_path='README.rst', commit_id=commit.raw_id),
365 365 extra_environ=xhr_header)
366 366 assert response.body == ''
367 367
368 368 def test_tree_metadata_list_missing_xhr(self, backend):
369 369 self.app.get(
370 370 url('files_nodetree_full', repo_name=backend.repo_name,
371 371 f_path='/', commit_id='tip'), status=400)
372 372
373 373 def test_access_empty_repo_redirect_to_summary_with_alert_write_perms(
374 374 self, app, backend_stub, autologin_regular_user, user_regular,
375 375 user_util):
376 376 repo = backend_stub.create_repo()
377 377 user_util.grant_user_permission_to_repo(
378 378 repo, user_regular, 'repository.write')
379 379 response = self.app.get(url(
380 380 controller='files', action='index',
381 381 repo_name=repo.repo_name, revision='tip', f_path='/'))
382 382 assert_session_flash(
383 383 response,
384 384 'There are no files yet. <a class="alert-link" '
385 385 'href="/%s/add/0/#edit">Click here to add a new file.</a>'
386 386 % (repo.repo_name))
387 387
388 388 def test_access_empty_repo_redirect_to_summary_with_alert_no_write_perms(
389 389 self, backend_stub, user_util):
390 390 repo = backend_stub.create_repo()
391 391 repo_file_url = url(
392 392 'files_add_home',
393 393 repo_name=repo.repo_name,
394 394 revision=0, f_path='', anchor='edit')
395 395 response = self.app.get(url(
396 396 controller='files', action='index',
397 397 repo_name=repo.repo_name, revision='tip', f_path='/'))
398 398 assert_not_in_session_flash(response, repo_file_url)
399 399
400 400
401 401 # TODO: johbo: Think about a better place for these tests. Either controller
402 402 # specific unit tests or we move down the whole logic further towards the vcs
403 403 # layer
404 404 class TestAdjustFilePathForSvn:
405 405 """SVN specific adjustments of node history in FileController."""
406 406
407 407 def test_returns_path_relative_to_matched_reference(self):
408 408 repo = self._repo(branches=['trunk'])
409 409 self.assert_file_adjustment('trunk/file', 'file', repo)
410 410
411 411 def test_does_not_modify_file_if_no_reference_matches(self):
412 412 repo = self._repo(branches=['trunk'])
413 413 self.assert_file_adjustment('notes/file', 'notes/file', repo)
414 414
415 415 def test_does_not_adjust_partial_directory_names(self):
416 416 repo = self._repo(branches=['trun'])
417 417 self.assert_file_adjustment('trunk/file', 'trunk/file', repo)
418 418
419 419 def test_is_robust_to_patterns_which_prefix_other_patterns(self):
420 420 repo = self._repo(branches=['trunk', 'trunk/new', 'trunk/old'])
421 421 self.assert_file_adjustment('trunk/new/file', 'file', repo)
422 422
423 423 def assert_file_adjustment(self, f_path, expected, repo):
424 424 controller = FilesController()
425 425 result = controller._adjust_file_path_for_svn(f_path, repo)
426 426 assert result == expected
427 427
428 428 def _repo(self, branches=None):
429 429 repo = mock.Mock()
430 430 repo.branches = OrderedDict((name, '0') for name in branches or [])
431 431 repo.tags = {}
432 432 return repo
433 433
434 434
435 435 @pytest.mark.usefixtures("app")
436 436 class TestRepositoryArchival:
437 437
438 438 def test_archival(self, backend):
439 439 backend.enable_downloads()
440 440 commit = backend.repo.get_commit(commit_idx=173)
441 441 for archive, info in settings.ARCHIVE_SPECS.items():
442 442 mime_type, arch_ext = info
443 443 short = commit.short_id + arch_ext
444 444 fname = commit.raw_id + arch_ext
445 445 filename = '%s-%s' % (backend.repo_name, short)
446 446 response = self.app.get(url(controller='files',
447 447 action='archivefile',
448 448 repo_name=backend.repo_name,
449 449 fname=fname))
450 450
451 451 assert response.status == '200 OK'
452 452 headers = {
453 453 'Pragma': 'no-cache',
454 454 'Cache-Control': 'no-cache',
455 455 'Content-Disposition': 'attachment; filename=%s' % filename,
456 456 'Content-Type': '%s; charset=utf-8' % mime_type,
457 457 }
458 458 if 'Set-Cookie' in response.response.headers:
459 459 del response.response.headers['Set-Cookie']
460 460 assert response.response.headers == headers
461 461
462 462 def test_archival_wrong_ext(self, backend):
463 463 backend.enable_downloads()
464 464 commit = backend.repo.get_commit(commit_idx=173)
465 465 for arch_ext in ['tar', 'rar', 'x', '..ax', '.zipz']:
466 466 fname = commit.raw_id + arch_ext
467 467
468 468 response = self.app.get(url(controller='files',
469 469 action='archivefile',
470 470 repo_name=backend.repo_name,
471 471 fname=fname))
472 472 response.mustcontain('Unknown archive type')
473 473
474 474 def test_archival_wrong_commit_id(self, backend):
475 475 backend.enable_downloads()
476 476 for commit_id in ['00x000000', 'tar', 'wrong', '@##$@$42413232',
477 477 '232dffcd']:
478 478 fname = '%s.zip' % commit_id
479 479
480 480 response = self.app.get(url(controller='files',
481 481 action='archivefile',
482 482 repo_name=backend.repo_name,
483 483 fname=fname))
484 484 response.mustcontain('Unknown revision')
485 485
486 486
487 487 @pytest.mark.usefixtures("app", "autologin_user")
488 488 class TestRawFileHandling:
489 489
490 490 def test_raw_file_ok(self, backend):
491 491 commit = backend.repo.get_commit(commit_idx=173)
492 492 response = self.app.get(url(controller='files', action='rawfile',
493 493 repo_name=backend.repo_name,
494 494 revision=commit.raw_id,
495 495 f_path='vcs/nodes.py'))
496 496
497 497 assert response.content_disposition == "attachment; filename=nodes.py"
498 498 assert response.content_type == "text/x-python"
499 499
500 500 def test_raw_file_wrong_cs(self, backend):
501 501 commit_id = u'ERRORce30c96924232dffcd24178a07ffeb5dfc'
502 502 f_path = 'vcs/nodes.py'
503 503
504 504 response = self.app.get(url(controller='files', action='rawfile',
505 505 repo_name=backend.repo_name,
506 506 revision=commit_id,
507 507 f_path=f_path), status=404)
508 508
509 509 msg = """No such commit exists for this repository"""
510 510 response.mustcontain(msg)
511 511
512 512 def test_raw_file_wrong_f_path(self, backend):
513 513 commit = backend.repo.get_commit(commit_idx=173)
514 514 f_path = 'vcs/ERRORnodes.py'
515 515 response = self.app.get(url(controller='files', action='rawfile',
516 516 repo_name=backend.repo_name,
517 517 revision=commit.raw_id,
518 518 f_path=f_path), status=404)
519 519
520 520 msg = (
521 521 "There is no file nor directory at the given path: "
522 522 "&#39;%s&#39; at commit %s" % (f_path, commit.short_id))
523 523 response.mustcontain(msg)
524 524
525 525 def test_raw_ok(self, backend):
526 526 commit = backend.repo.get_commit(commit_idx=173)
527 527 response = self.app.get(url(controller='files', action='raw',
528 528 repo_name=backend.repo_name,
529 529 revision=commit.raw_id,
530 530 f_path='vcs/nodes.py'))
531 531
532 532 assert response.content_type == "text/plain"
533 533
534 534 def test_raw_wrong_cs(self, backend):
535 535 commit_id = u'ERRORcce30c96924232dffcd24178a07ffeb5dfc'
536 536 f_path = 'vcs/nodes.py'
537 537
538 538 response = self.app.get(url(controller='files', action='raw',
539 539 repo_name=backend.repo_name,
540 540 revision=commit_id,
541 541 f_path=f_path), status=404)
542 542
543 543 msg = """No such commit exists for this repository"""
544 544 response.mustcontain(msg)
545 545
546 546 def test_raw_wrong_f_path(self, backend):
547 547 commit = backend.repo.get_commit(commit_idx=173)
548 548 f_path = 'vcs/ERRORnodes.py'
549 549 response = self.app.get(url(controller='files', action='raw',
550 550 repo_name=backend.repo_name,
551 551 revision=commit.raw_id,
552 552 f_path=f_path), status=404)
553 553 msg = (
554 554 "There is no file nor directory at the given path: "
555 555 "&#39;%s&#39; at commit %s" % (f_path, commit.short_id))
556 556 response.mustcontain(msg)
557 557
558 558 def test_raw_svg_should_not_be_rendered(self, backend):
559 559 backend.create_repo()
560 560 backend.ensure_file("xss.svg")
561 561 response = self.app.get(url(controller='files', action='raw',
562 562 repo_name=backend.repo_name,
563 563 revision='tip',
564 564 f_path='xss.svg'))
565 565
566 566 # If the content type is image/svg+xml then it allows to render HTML
567 567 # and malicious SVG.
568 568 assert response.content_type == "text/plain"
569 569
570 570
571 571 @pytest.mark.usefixtures("app")
572 572 class TestFilesDiff:
573 573
574 574 @pytest.mark.parametrize("diff", ['diff', 'download', 'raw'])
575 575 def test_file_full_diff(self, backend, diff):
576 576 commit1 = backend.repo.get_commit(commit_idx=-1)
577 577 commit2 = backend.repo.get_commit(commit_idx=-2)
578 578 response = self.app.get(
579 579 url(
580 580 controller='files',
581 581 action='diff',
582 582 repo_name=backend.repo_name,
583 583 f_path='README'),
584 584 params={
585 585 'diff1': commit1.raw_id,
586 586 'diff2': commit2.raw_id,
587 587 'fulldiff': '1',
588 588 'diff': diff,
589 589 })
590 590 response.mustcontain('README.rst')
591 591 response.mustcontain('No newline at end of file')
592 592
593 593 def test_file_binary_diff(self, backend):
594 594 commits = [
595 595 {'message': 'First commit'},
596 596 {'message': 'Commit with binary',
597 597 'added': [nodes.FileNode('file.bin', content='\0BINARY\0')]},
598 598 ]
599 599 repo = backend.create_repo(commits=commits)
600 600
601 601 response = self.app.get(
602 602 url(
603 603 controller='files',
604 604 action='diff',
605 605 repo_name=backend.repo_name,
606 606 f_path='file.bin'),
607 607 params={
608 608 'diff1': repo.get_commit(commit_idx=0).raw_id,
609 609 'diff2': repo.get_commit(commit_idx=1).raw_id,
610 610 'fulldiff': '1',
611 611 'diff': 'diff',
612 612 })
613 613 response.mustcontain('Cannot diff binary files')
614 614
615 615 def test_diff_2way(self, backend):
616 616 commit1 = backend.repo.get_commit(commit_idx=-1)
617 617 commit2 = backend.repo.get_commit(commit_idx=-2)
618 618 response = self.app.get(
619 619 url(
620 620 controller='files',
621 621 action='diff_2way',
622 622 repo_name=backend.repo_name,
623 623 f_path='README'),
624 624 params={
625 625 'diff1': commit1.raw_id,
626 626 'diff2': commit2.raw_id,
627 627 })
628 628
629 629 # Expecting links to both variants of the file. Links are used
630 630 # to load the content dynamically.
631 631 response.mustcontain('/%s/README' % commit1.raw_id)
632 632 response.mustcontain('/%s/README' % commit2.raw_id)
633 633
634 634 def test_requires_one_commit_id(self, backend, autologin_user):
635 635 response = self.app.get(
636 636 url(
637 637 controller='files',
638 638 action='diff',
639 639 repo_name=backend.repo_name,
640 640 f_path='README.rst'),
641 641 status=400)
642 642 response.mustcontain(
643 643 'Need query parameter', 'diff1', 'diff2', 'to generate a diff.')
644 644
645 645 def test_returns_not_found_if_file_does_not_exist(self, vcsbackend):
646 646 repo = vcsbackend.repo
647 647 self.app.get(
648 648 url(
649 649 controller='files',
650 650 action='diff',
651 651 repo_name=repo.name,
652 652 f_path='does-not-exist-in-any-commit',
653 653 diff1=repo[0].raw_id,
654 654 diff2=repo[1].raw_id),
655 655 status=404)
656 656
657 657 def test_returns_redirect_if_file_not_changed(self, backend):
658 658 commit = backend.repo.get_commit(commit_idx=-1)
659 659 f_path= 'README'
660 660 response = self.app.get(
661 661 url(
662 662 controller='files',
663 663 action='diff_2way',
664 664 repo_name=backend.repo_name,
665 665 f_path=f_path,
666 666 diff1=commit.raw_id,
667 667 diff2=commit.raw_id,
668 668 ),
669 669 status=302
670 670 )
671 671 assert response.headers['Location'].endswith(f_path)
672 672 redirected = response.follow()
673 673 redirected.mustcontain('has not changed between')
674 674
675 675 def test_supports_diff_to_different_path_svn(self, backend_svn):
676 676 repo = backend_svn['svn-simple-layout'].scm_instance()
677 677 commit_id = repo[-1].raw_id
678 678 response = self.app.get(
679 679 url(
680 680 controller='files',
681 681 action='diff',
682 682 repo_name=repo.name,
683 683 f_path='trunk/example.py',
684 684 diff1='tags/v0.2/example.py@' + commit_id,
685 685 diff2=commit_id),
686 686 status=200)
687 687 response.mustcontain(
688 688 "Will print out a useful message on invocation.")
689 689
690 690 # Note: Expecting that we indicate the user what's being compared
691 691 response.mustcontain("trunk/example.py")
692 692 response.mustcontain("tags/v0.2/example.py")
693 693
694 694 def test_show_rev_redirects_to_svn_path(self, backend_svn):
695 695 repo = backend_svn['svn-simple-layout'].scm_instance()
696 696 commit_id = repo[-1].raw_id
697 697 response = self.app.get(
698 698 url(
699 699 controller='files',
700 700 action='diff',
701 701 repo_name=repo.name,
702 702 f_path='trunk/example.py',
703 703 diff1='branches/argparse/example.py@' + commit_id,
704 704 diff2=commit_id),
705 705 params={'show_rev': 'Show at Revision'},
706 706 status=302)
707 707 assert response.headers['Location'].endswith(
708 708 'svn-svn-simple-layout/files/26/branches/argparse/example.py')
709 709
710 710 def test_show_rev_and_annotate_redirects_to_svn_path(self, backend_svn):
711 711 repo = backend_svn['svn-simple-layout'].scm_instance()
712 712 commit_id = repo[-1].raw_id
713 713 response = self.app.get(
714 714 url(
715 715 controller='files',
716 716 action='diff',
717 717 repo_name=repo.name,
718 718 f_path='trunk/example.py',
719 719 diff1='branches/argparse/example.py@' + commit_id,
720 720 diff2=commit_id),
721 721 params={
722 722 'show_rev': 'Show at Revision',
723 723 'annotate': 'true',
724 724 },
725 725 status=302)
726 726 assert response.headers['Location'].endswith(
727 727 'svn-svn-simple-layout/annotate/26/branches/argparse/example.py')
728 728
729 729
730 730 @pytest.mark.usefixtures("app", "autologin_user")
731 731 class TestChangingFiles:
732 732
733 733 def test_add_file_view(self, backend):
734 734 self.app.get(url(
735 735 'files_add_home',
736 736 repo_name=backend.repo_name,
737 737 revision='tip', f_path='/'))
738 738
739 739 @pytest.mark.xfail_backends("svn", reason="Depends on online editing")
740 740 def test_add_file_into_repo_missing_content(self, backend, csrf_token):
741 741 repo = backend.create_repo()
742 742 filename = 'init.py'
743 743 response = self.app.post(
744 744 url(
745 745 'files_add',
746 746 repo_name=repo.repo_name,
747 747 revision='tip', f_path='/'),
748 748 params={
749 749 'content': "",
750 750 'filename': filename,
751 751 'location': "",
752 752 'csrf_token': csrf_token,
753 753 },
754 754 status=302)
755 755 assert_session_flash(
756 756 response, 'Successfully committed to %s'
757 757 % os.path.join(filename))
758 758
759 759 def test_add_file_into_repo_missing_filename(self, backend, csrf_token):
760 760 response = self.app.post(
761 761 url(
762 762 'files_add',
763 763 repo_name=backend.repo_name,
764 764 revision='tip', f_path='/'),
765 765 params={
766 766 'content': "foo",
767 767 'csrf_token': csrf_token,
768 768 },
769 769 status=302)
770 770
771 771 assert_session_flash(response, 'No filename')
772 772
773 773 def test_add_file_into_repo_errors_and_no_commits(
774 774 self, backend, csrf_token):
775 775 repo = backend.create_repo()
776 776 # Create a file with no filename, it will display an error but
777 777 # the repo has no commits yet
778 778 response = self.app.post(
779 779 url(
780 780 'files_add',
781 781 repo_name=repo.repo_name,
782 782 revision='tip', f_path='/'),
783 783 params={
784 784 'content': "foo",
785 785 'csrf_token': csrf_token,
786 786 },
787 787 status=302)
788 788
789 789 assert_session_flash(response, 'No filename')
790 790
791 791 # Not allowed, redirect to the summary
792 792 redirected = response.follow()
793 793 summary_url = url('summary_home', repo_name=repo.repo_name)
794 794
795 795 # As there are no commits, displays the summary page with the error of
796 796 # creating a file with no filename
797 797 assert redirected.req.path == summary_url
798 798
799 799 @pytest.mark.parametrize("location, filename", [
800 800 ('/abs', 'foo'),
801 801 ('../rel', 'foo'),
802 802 ('file/../foo', 'foo'),
803 803 ])
804 804 def test_add_file_into_repo_bad_filenames(
805 805 self, location, filename, backend, csrf_token):
806 806 response = self.app.post(
807 807 url(
808 808 'files_add',
809 809 repo_name=backend.repo_name,
810 810 revision='tip', f_path='/'),
811 811 params={
812 812 'content': "foo",
813 813 'filename': filename,
814 814 'location': location,
815 815 'csrf_token': csrf_token,
816 816 },
817 817 status=302)
818 818
819 819 assert_session_flash(
820 820 response,
821 821 'The location specified must be a relative path and must not '
822 822 'contain .. in the path')
823 823
824 824 @pytest.mark.parametrize("cnt, location, filename", [
825 825 (1, '', 'foo.txt'),
826 826 (2, 'dir', 'foo.rst'),
827 827 (3, 'rel/dir', 'foo.bar'),
828 828 ])
829 829 def test_add_file_into_repo(self, cnt, location, filename, backend,
830 830 csrf_token):
831 831 repo = backend.create_repo()
832 832 response = self.app.post(
833 833 url(
834 834 'files_add',
835 835 repo_name=repo.repo_name,
836 836 revision='tip', f_path='/'),
837 837 params={
838 838 'content': "foo",
839 839 'filename': filename,
840 840 'location': location,
841 841 'csrf_token': csrf_token,
842 842 },
843 843 status=302)
844 844 assert_session_flash(
845 845 response, 'Successfully committed to %s'
846 846 % os.path.join(location, filename))
847 847
848 848 def test_edit_file_view(self, backend):
849 849 response = self.app.get(
850 850 url(
851 851 'files_edit_home',
852 852 repo_name=backend.repo_name,
853 853 revision=backend.default_head_id,
854 854 f_path='vcs/nodes.py'),
855 855 status=200)
856 856 response.mustcontain("Module holding everything related to vcs nodes.")
857 857
858 858 def test_edit_file_view_not_on_branch(self, backend):
859 859 repo = backend.create_repo()
860 860 backend.ensure_file("vcs/nodes.py")
861 861
862 862 response = self.app.get(
863 863 url(
864 864 'files_edit_home',
865 865 repo_name=repo.repo_name,
866 866 revision='tip', f_path='vcs/nodes.py'),
867 867 status=302)
868 868 assert_session_flash(
869 869 response,
870 870 'You can only edit files with revision being a valid branch')
871 871
872 872 def test_edit_file_view_commit_changes(self, backend, csrf_token):
873 873 repo = backend.create_repo()
874 874 backend.ensure_file("vcs/nodes.py", content="print 'hello'")
875 875
876 876 response = self.app.post(
877 877 url(
878 878 'files_edit',
879 879 repo_name=repo.repo_name,
880 880 revision=backend.default_head_id,
881 881 f_path='vcs/nodes.py'),
882 882 params={
883 883 'content': "print 'hello world'",
884 884 'message': 'I committed',
885 885 'filename': "vcs/nodes.py",
886 886 'csrf_token': csrf_token,
887 887 },
888 888 status=302)
889 889 assert_session_flash(
890 890 response, 'Successfully committed to vcs/nodes.py')
891 891 tip = repo.get_commit(commit_idx=-1)
892 892 assert tip.message == 'I committed'
893 893
894 894 def test_edit_file_view_commit_changes_default_message(self, backend,
895 895 csrf_token):
896 896 repo = backend.create_repo()
897 897 backend.ensure_file("vcs/nodes.py", content="print 'hello'")
898 898
899 899 commit_id = (
900 900 backend.default_branch_name or
901 901 backend.repo.scm_instance().commit_ids[-1])
902 902
903 903 response = self.app.post(
904 904 url(
905 905 'files_edit',
906 906 repo_name=repo.repo_name,
907 907 revision=commit_id,
908 908 f_path='vcs/nodes.py'),
909 909 params={
910 910 'content': "print 'hello world'",
911 911 'message': '',
912 912 'filename': "vcs/nodes.py",
913 913 'csrf_token': csrf_token,
914 914 },
915 915 status=302)
916 916 assert_session_flash(
917 917 response, 'Successfully committed to vcs/nodes.py')
918 918 tip = repo.get_commit(commit_idx=-1)
919 919 assert tip.message == 'Edited file vcs/nodes.py via RhodeCode Enterprise'
920 920
921 921 def test_delete_file_view(self, backend):
922 922 self.app.get(url(
923 923 'files_delete_home',
924 924 repo_name=backend.repo_name,
925 925 revision='tip', f_path='vcs/nodes.py'))
926 926
927 927 def test_delete_file_view_not_on_branch(self, backend):
928 928 repo = backend.create_repo()
929 929 backend.ensure_file('vcs/nodes.py')
930 930
931 931 response = self.app.get(
932 932 url(
933 933 'files_delete_home',
934 934 repo_name=repo.repo_name,
935 935 revision='tip', f_path='vcs/nodes.py'),
936 936 status=302)
937 937 assert_session_flash(
938 938 response,
939 939 'You can only delete files with revision being a valid branch')
940 940
941 941 def test_delete_file_view_commit_changes(self, backend, csrf_token):
942 942 repo = backend.create_repo()
943 943 backend.ensure_file("vcs/nodes.py")
944 944
945 945 response = self.app.post(
946 946 url(
947 947 'files_delete_home',
948 948 repo_name=repo.repo_name,
949 949 revision=backend.default_head_id,
950 950 f_path='vcs/nodes.py'),
951 951 params={
952 952 'message': 'i commited',
953 953 'csrf_token': csrf_token,
954 954 },
955 955 status=302)
956 956 assert_session_flash(
957 957 response, 'Successfully deleted file vcs/nodes.py')
958 958
959 959
960 960 def assert_files_in_response(response, files, params):
961 961 template = (
962 962 'href="/%(repo_name)s/files/%(commit_id)s/%(name)s"')
963 963 _assert_items_in_response(response, files, template, params)
964 964
965 965
966 966 def assert_dirs_in_response(response, dirs, params):
967 967 template = (
968 968 'href="/%(repo_name)s/files/%(commit_id)s/%(name)s"')
969 969 _assert_items_in_response(response, dirs, template, params)
970 970
971 971
972 972 def _assert_items_in_response(response, items, template, params):
973 973 for item in items:
974 974 item_params = {'name': item}
975 975 item_params.update(params)
976 976 response.mustcontain(template % item_params)
977 977
978 978
979 979 def assert_timeago_in_response(response, items, params):
980 980 for item in items:
981 981 response.mustcontain(h.age_component(params['date']))
982 982
983 983
984 984
985 985 @pytest.mark.usefixtures("autologin_user", "app")
986 986 class TestSideBySideDiff:
987 987
988 988 def test_diff2way(self, app, backend, backend_stub):
989 989 f_path = 'content'
990 990 commit1_content = 'content-25d7e49c18b159446c'
991 991 commit2_content = 'content-603d6c72c46d953420'
992 992 repo = backend.create_repo()
993 993
994 994 commit1 = _commit_change(
995 995 repo.repo_name, filename=f_path, content=commit1_content,
996 996 message='A', vcs_type=backend.alias, parent=None, newfile=True)
997 997
998 998 commit2 = _commit_change(
999 999 repo.repo_name, filename=f_path, content=commit2_content,
1000 1000 message='B, child of A', vcs_type=backend.alias, parent=commit1)
1001 1001
1002 1002 response = self.app.get(url(
1003 1003 controller='files', action='diff_2way',
1004 1004 repo_name=repo.repo_name,
1005 1005 diff1=commit1.raw_id,
1006 1006 diff2=commit2.raw_id,
1007 1007 f_path=f_path))
1008 1008
1009 1009 assert_response = AssertResponse(response)
1010 1010 response.mustcontain(
1011 1011 ('Side-by-side Diff r0:%s ... r1:%s') % ( commit1.short_id, commit2.short_id ))
1012 1012 response.mustcontain('id="compare"')
1013 1013 response.mustcontain((
1014 1014 "var orig1_url = '/%s/raw/%s/%s';\n"
1015 1015 "var orig2_url = '/%s/raw/%s/%s';") %
1016 1016 ( repo.repo_name, commit1.raw_id, f_path,
1017 1017 repo.repo_name, commit2.raw_id, f_path))
1018 1018
1019 1019
1020 1020 def test_diff2way_with_empty_file(self, app, backend, backend_stub):
1021 1021 commits = [
1022 1022 {'message': 'First commit'},
1023 1023 {'message': 'Commit with binary',
1024 1024 'added': [nodes.FileNode('file.empty', content='')]},
1025 1025 ]
1026 1026 f_path='file.empty'
1027 1027 repo = backend.create_repo(commits=commits)
1028 1028 commit_id1 = repo.get_commit(commit_idx=0).raw_id
1029 1029 commit_id2 = repo.get_commit(commit_idx=1).raw_id
1030 1030
1031 1031 response = self.app.get(url(
1032 1032 controller='files', action='diff_2way',
1033 1033 repo_name=repo.repo_name,
1034 1034 diff1=commit_id1,
1035 1035 diff2=commit_id2,
1036 1036 f_path=f_path))
1037 1037
1038 1038 assert_response = AssertResponse(response)
1039 1039 if backend.alias == 'svn':
1040 1040 assert_session_flash( response,
1041 1041 ('%(file_path)s has not changed') % { 'file_path': 'file.empty' })
1042 1042 else:
1043 1043 response.mustcontain(
1044 1044 ('Side-by-side Diff r0:%s ... r1:%s') % ( repo.get_commit(commit_idx=0).short_id, repo.get_commit(commit_idx=1).short_id ))
1045 1045 response.mustcontain('id="compare"')
1046 1046 response.mustcontain((
1047 1047 "var orig1_url = '/%s/raw/%s/%s';\n"
1048 1048 "var orig2_url = '/%s/raw/%s/%s';") %
1049 1049 ( repo.repo_name, commit_id1, f_path,
1050 1050 repo.repo_name, commit_id2, f_path))
1051 1051
1052 1052
1053 1053 def test_empty_diff_2way_redirect_to_summary_with_alert(self, app, backend):
1054 1054 commit_id_range = {
1055 1055 'hg': (
1056 1056 '25d7e49c18b159446cadfa506a5cf8ad1cb04067',
1057 1057 '603d6c72c46d953420c89d36372f08d9f305f5dd'),
1058 1058 'git': (
1059 1059 '6fc9270775aaf5544c1deb014f4ddd60c952fcbb',
1060 1060 '03fa803d7e9fb14daa9a3089e0d1494eda75d986'),
1061 1061 'svn': (
1062 1062 '335',
1063 1063 '337'),
1064 1064 }
1065 1065 f_path = 'setup.py'
1066 1066
1067 1067 commit_ids = commit_id_range[backend.alias]
1068 1068
1069 1069 response = self.app.get(url(
1070 1070 controller='files', action='diff_2way',
1071 1071 repo_name=backend.repo_name,
1072 1072 diff2=commit_ids[0],
1073 1073 diff1=commit_ids[1],
1074 1074 f_path=f_path))
1075 1075
1076 1076 assert_response = AssertResponse(response)
1077 1077 assert_session_flash( response,
1078 1078 ('%(file_path)s has not changed') % { 'file_path': f_path })
1 NO CONTENT: file was removed
General Comments 0
You need to be logged in to leave comments. Login now