core: make getcwd() fail-safe
[git-cola.git] / cola / widgets / diff.py
blobaa7cd937e941efd8d7574d00a693d953b1be18db
1 from __future__ import division, absolute_import, unicode_literals
2 import os
3 import re
5 from qtpy import QtCore
6 from qtpy import QtGui
7 from qtpy import QtWidgets
8 from qtpy.QtCore import Qt
9 from qtpy.QtCore import Signal
11 from ..i18n import N_
12 from ..interaction import Interaction
13 from ..models import prefs
14 from ..qtutils import get
15 from .. import actions
16 from .. import cmds
17 from .. import core
18 from .. import diffparse
19 from .. import gitcmds
20 from .. import gravatar
21 from .. import hotkeys
22 from .. import icons
23 from .. import utils
24 from .. import qtutils
25 from .text import TextDecorator
26 from .text import VimHintedPlainTextEdit
27 from . import defs
28 from . import imageview
31 COMMITS_SELECTED = 'COMMITS_SELECTED'
32 FILES_SELECTED = 'FILES_SELECTED'
35 class DiffSyntaxHighlighter(QtGui.QSyntaxHighlighter):
36 """Implements the diff syntax highlighting"""
38 INITIAL_STATE = -1
39 DEFAULT_STATE = 0
40 DIFFSTAT_STATE = 1
41 DIFF_FILE_HEADER_STATE = 2
42 DIFF_STATE = 3
43 SUBMODULE_STATE = 4
44 END_STATE = 5
46 DIFF_FILE_HEADER_START_RGX = re.compile(r'diff --git a/.* b/.*')
47 DIFF_HUNK_HEADER_RGX = re.compile(r'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|'
48 r'(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)')
49 BAD_WHITESPACE_RGX = re.compile(r'\s+$')
51 def __init__(self, context, doc, whitespace=True, is_commit=False):
52 QtGui.QSyntaxHighlighter.__init__(self, doc)
53 self.whitespace = whitespace
54 self.enabled = True
55 self.is_commit = is_commit
57 QPalette = QtGui.QPalette
58 cfg = context.cfg
59 palette = QPalette()
60 disabled = palette.color(QPalette.Disabled, QPalette.Text)
61 header = qtutils.rgb_hex(disabled)
63 dark = palette.color(QPalette.Base).lightnessF() < 0.5
65 self.color_text = qtutils.RGB(cfg.color('text', '030303'))
66 self.color_add = qtutils.RGB(
67 cfg.color('add', '77aa77' if dark else 'd2ffe4')
69 self.color_remove = qtutils.RGB(
70 cfg.color('remove', 'aa7777' if dark else 'fee0e4')
72 self.color_header = qtutils.RGB(cfg.color('header', header))
74 self.diff_header_fmt = qtutils.make_format(fg=self.color_header)
75 self.bold_diff_header_fmt = qtutils.make_format(
76 fg=self.color_header, bold=True)
78 self.diff_add_fmt = qtutils.make_format(
79 fg=self.color_text, bg=self.color_add)
80 self.diff_remove_fmt = qtutils.make_format(
81 fg=self.color_text, bg=self.color_remove)
82 self.bad_whitespace_fmt = qtutils.make_format(bg=Qt.red)
83 self.setCurrentBlockState(self.INITIAL_STATE)
85 def set_enabled(self, enabled):
86 self.enabled = enabled
88 def highlightBlock(self, text):
89 if not self.enabled or not text:
90 return
91 # Aliases for quick local access
92 initial_state = self.INITIAL_STATE
93 default_state = self.DEFAULT_STATE
94 diff_state = self.DIFF_STATE
95 diffstat_state = self.DIFFSTAT_STATE
96 diff_file_header_state = self.DIFF_FILE_HEADER_STATE
97 submodule_state = self.SUBMODULE_STATE
98 end_state = self.END_STATE
100 diff_file_header_start_rgx = self.DIFF_FILE_HEADER_START_RGX
101 diff_hunk_header_rgx = self.DIFF_HUNK_HEADER_RGX
102 bad_whitespace_rgx = self.BAD_WHITESPACE_RGX
104 diff_header_fmt = self.diff_header_fmt
105 bold_diff_header_fmt = self.bold_diff_header_fmt
106 diff_add_fmt = self.diff_add_fmt
107 diff_remove_fmt = self.diff_remove_fmt
108 bad_whitespace_fmt = self.bad_whitespace_fmt
110 state = self.previousBlockState()
111 if state == initial_state:
112 if text.startswith('Submodule '):
113 state = submodule_state
114 elif text.startswith('diff --git '):
115 state = diffstat_state
116 elif self.is_commit:
117 state = default_state
118 else:
119 state = diffstat_state
121 if state == diffstat_state:
122 if diff_file_header_start_rgx.match(text):
123 state = diff_file_header_state
124 self.setFormat(0, len(text), diff_header_fmt)
125 elif diff_hunk_header_rgx.match(text):
126 state = diff_state
127 self.setFormat(0, len(text), bold_diff_header_fmt)
128 elif '|' in text:
129 i = text.index('|')
130 self.setFormat(0, i, bold_diff_header_fmt)
131 self.setFormat(i, len(text) - i, diff_header_fmt)
132 else:
133 self.setFormat(0, len(text), diff_header_fmt)
134 elif state == diff_file_header_state:
135 if diff_hunk_header_rgx.match(text):
136 state = diff_state
137 self.setFormat(0, len(text), bold_diff_header_fmt)
138 else:
139 self.setFormat(0, len(text), diff_header_fmt)
140 elif state == diff_state:
141 if diff_file_header_start_rgx.match(text):
142 state = diff_file_header_state
143 self.setFormat(0, len(text), diff_header_fmt)
144 elif diff_hunk_header_rgx.match(text):
145 self.setFormat(0, len(text), bold_diff_header_fmt)
146 elif text.startswith('-'):
147 if text == '-- ':
148 state = end_state
149 else:
150 self.setFormat(0, len(text), diff_remove_fmt)
151 elif text.startswith('+'):
152 self.setFormat(0, len(text), diff_add_fmt)
153 if self.whitespace:
154 m = bad_whitespace_rgx.search(text)
155 if m is not None:
156 i = m.start()
157 self.setFormat(i, len(text) - i, bad_whitespace_fmt)
159 self.setCurrentBlockState(state)
162 class DiffTextEdit(VimHintedPlainTextEdit):
164 def __init__(self, context, parent,
165 is_commit=False, whitespace=True, numbers=False):
166 VimHintedPlainTextEdit.__init__(self, context, '', parent=parent)
167 # Diff/patch syntax highlighter
168 self.highlighter = DiffSyntaxHighlighter(
169 context, self.document(),
170 is_commit=is_commit, whitespace=whitespace)
171 if numbers:
172 self.numbers = DiffLineNumbers(context, self)
173 self.numbers.hide()
174 else:
175 self.numbers = None
176 self.scrollvalue = None
178 self.cursorPositionChanged.connect(self._cursor_changed)
180 def _cursor_changed(self):
181 """Update the line number display when the cursor changes"""
182 line_number = max(0, self.textCursor().blockNumber())
183 if self.numbers:
184 self.numbers.set_highlighted(line_number)
186 def resizeEvent(self, event):
187 super(DiffTextEdit, self).resizeEvent(event)
188 if self.numbers:
189 self.numbers.refresh_size()
191 def save_scrollbar(self):
192 """Save the scrollbar value, but only on the first call"""
193 if self.scrollvalue is None:
194 scrollbar = self.verticalScrollBar()
195 if scrollbar:
196 scrollvalue = get(scrollbar)
197 else:
198 scrollvalue = None
199 self.scrollvalue = scrollvalue
201 def restore_scrollbar(self):
202 """Restore the scrollbar and clear state"""
203 scrollbar = self.verticalScrollBar()
204 scrollvalue = self.scrollvalue
205 if scrollbar and scrollvalue is not None:
206 scrollbar.setValue(scrollvalue)
207 self.scrollvalue = None
209 def set_loading_message(self):
210 self.hint.set_value('+++ ' + N_('Loading...'))
211 self.set_value('')
213 def set_diff(self, diff):
214 """Set the diff text, but save the scrollbar"""
215 diff = diff.rstrip('\n') # diffs include two empty newlines
216 self.save_scrollbar()
218 self.hint.set_value('')
219 if self.numbers:
220 self.numbers.set_diff(diff)
221 self.set_value(diff)
223 self.restore_scrollbar()
226 class DiffLineNumbers(TextDecorator):
228 def __init__(self, context, parent):
229 TextDecorator.__init__(self, parent)
230 self.highlight_line = -1
231 self.lines = None
232 self.parser = diffparse.DiffLines()
233 self.formatter = diffparse.FormatDigits()
235 self.setFont(qtutils.diff_font(context))
236 self._char_width = self.fontMetrics().width('0')
238 QPalette = QtGui.QPalette
239 self._palette = palette = self.palette()
240 self._base = palette.color(QtGui.QPalette.Base)
241 self._highlight = palette.color(QPalette.Highlight)
242 self._highlight_text = palette.color(QPalette.HighlightedText)
243 self._window = palette.color(QPalette.Window)
244 self._disabled = palette.color(QPalette.Disabled, QPalette.Text)
246 def set_diff(self, diff):
247 parser = self.parser
248 lines = parser.parse(diff)
249 if parser.valid:
250 self.lines = lines
251 self.formatter.set_digits(self.parser.digits())
252 else:
253 self.lines = None
255 def set_lines(self, lines):
256 self.lines = lines
258 def width_hint(self):
259 if not self.isVisible():
260 return 0
261 parser = self.parser
263 if parser.merge:
264 columns = 3
265 extra = 3 # one space in-between, one space after
266 else:
267 columns = 2
268 extra = 2 # one space in-between, one space after
270 if parser.valid:
271 digits = parser.digits() * columns
272 else:
273 digits = 4
275 return defs.margin + (self._char_width * (digits + extra))
277 def set_highlighted(self, line_number):
278 """Set the line to highlight"""
279 self.highlight_line = line_number
281 def current_line(self):
282 if self.lines and self.highlight_line >= 0:
283 # Find the next valid line
284 for line in self.lines[self.highlight_line:]:
285 # take the "new" line number: last value in tuple
286 line_number = line[-1]
287 if line_number > 0:
288 return line_number
290 # Find the previous valid line
291 for line in self.lines[self.highlight_line-1::-1]:
292 # take the "new" line number: last value in tuple
293 line_number = line[-1]
294 if line_number > 0:
295 return line_number
296 return None
298 def paintEvent(self, event):
299 """Paint the line number"""
300 if not self.lines:
301 return
303 painter = QtGui.QPainter(self)
304 painter.fillRect(event.rect(), self._base)
306 editor = self.editor
307 content_offset = editor.contentOffset()
308 block = editor.firstVisibleBlock()
309 width = self.width()
310 event_rect_bottom = event.rect().bottom()
312 highlight = self._highlight
313 highlight.setAlphaF(0.3)
314 highlight_text = self._highlight_text
315 disabled = self._disabled
317 fmt = self.formatter
318 lines = self.lines
319 num_lines = len(self.lines)
320 painter.setPen(disabled)
321 text = ''
323 while block.isValid():
324 block_number = block.blockNumber()
325 if block_number >= num_lines:
326 break
327 block_geom = editor.blockBoundingGeometry(block)
328 block_top = block_geom.translated(content_offset).top()
329 if not block.isVisible() or block_top >= event_rect_bottom:
330 break
332 rect = block_geom.translated(content_offset).toRect()
333 if block_number == self.highlight_line:
334 painter.fillRect(rect.x(), rect.y(),
335 width, rect.height(), highlight)
336 painter.setPen(highlight_text)
337 else:
338 painter.setPen(disabled)
340 line = lines[block_number]
341 if len(line) == 2:
342 a, b = line
343 text = fmt.value(a, b)
344 elif len(line) == 3:
345 old, base, new = line
346 text = fmt.merge_value(old, base, new)
348 painter.drawText(rect.x(), rect.y(),
349 self.width() - (defs.margin * 2), rect.height(),
350 Qt.AlignRight | Qt.AlignVCenter, text)
352 block = block.next() # pylint: disable=next-method-called
355 class Viewer(QtWidgets.QFrame):
356 """Text and image diff viewers"""
358 images_changed = Signal(object)
359 type_changed = Signal(object)
361 def __init__(self, context, parent=None):
362 super(Viewer, self).__init__(parent)
364 self.context = context
365 self.model = model = context.model
366 self.images = []
367 self.pixmaps = []
368 self.options = options = Options(self)
369 self.text = DiffEditor(context, options, self)
370 self.image = imageview.ImageView(parent=self)
371 self.image.setFocusPolicy(Qt.NoFocus)
373 stack = self.stack = QtWidgets.QStackedWidget(self)
374 stack.addWidget(self.text)
375 stack.addWidget(self.image)
377 self.main_layout = qtutils.vbox(
378 defs.no_margin, defs.no_spacing, self.stack)
379 self.setLayout(self.main_layout)
381 # Observe images
382 images_msg = model.message_images_changed
383 model.add_observer(images_msg, self.images_changed.emit)
384 self.images_changed.connect(self.set_images, type=Qt.QueuedConnection)
386 # Observe the diff type
387 diff_type_msg = model.message_diff_type_changed
388 model.add_observer(diff_type_msg, self.type_changed.emit)
389 self.type_changed.connect(self.set_diff_type, type=Qt.QueuedConnection)
391 # Observe the image mode combo box
392 options.image_mode.currentIndexChanged.connect(lambda _: self.render())
393 options.zoom_mode.currentIndexChanged.connect(lambda _: self.render())
395 self.setFocusProxy(self.text)
397 def export_state(self, state):
398 state['show_diff_line_numbers'] = self.text.show_line_numbers()
399 state['image_diff_mode'] = self.options.image_mode.currentIndex()
400 state['image_zoom_mode'] = self.options.zoom_mode.currentIndex()
401 return state
403 def apply_state(self, state):
404 diff_numbers = bool(state.get('show_diff_line_numbers', False))
405 self.text.enable_line_numbers(diff_numbers)
407 image_mode = utils.asint(state.get('image_diff_mode', 0))
408 self.options.image_mode.set_index(image_mode)
410 zoom_mode = utils.asint(state.get('image_zoom_mode', 0))
411 self.options.zoom_mode.set_index(zoom_mode)
412 return True
414 def set_diff_type(self, diff_type):
415 """Manage the image and text diff views when selection changes"""
416 self.options.set_diff_type(diff_type)
417 if diff_type == 'image':
418 self.stack.setCurrentWidget(self.image)
419 self.render()
420 else:
421 self.stack.setCurrentWidget(self.text)
423 def update_options(self):
424 self.text.update_options()
426 def reset(self):
427 self.image.pixmap = QtGui.QPixmap()
428 self.cleanup()
430 def cleanup(self):
431 for (image, unlink) in self.images:
432 if unlink and core.exists(image):
433 os.unlink(image)
434 self.images = []
436 def set_images(self, images):
437 self.images = images
438 self.pixmaps = []
439 if not images:
440 self.reset()
441 return False
443 # In order to comp, we first have to load all the images
444 all_pixmaps = [QtGui.QPixmap(image[0]) for image in images]
445 pixmaps = [pixmap for pixmap in all_pixmaps if not pixmap.isNull()]
446 if not pixmaps:
447 self.reset()
448 return False
450 self.pixmaps = pixmaps
451 self.render()
452 self.cleanup()
453 return True
455 def render(self):
456 # Update images
457 if self.pixmaps:
458 mode = self.options.image_mode.currentIndex()
459 if mode == self.options.SIDE_BY_SIDE:
460 image = self.render_side_by_side()
461 elif mode == self.options.DIFF:
462 image = self.render_diff()
463 elif mode == self.options.XOR:
464 image = self.render_xor()
465 elif mode == self.options.PIXEL_XOR:
466 image = self.render_pixel_xor()
467 else:
468 image = self.render_side_by_side()
469 else:
470 image = QtGui.QPixmap()
471 self.image.pixmap = image
473 # Apply zoom
474 zoom_mode = self.options.zoom_mode.currentIndex()
475 zoom_factor = self.options.zoom_factors[zoom_mode][1]
476 if zoom_factor > 0.0:
477 self.image.resetTransform()
478 self.image.scale(zoom_factor, zoom_factor)
479 poly = self.image.mapToScene(self.image.viewport().rect())
480 self.image.last_scene_roi = poly.boundingRect()
482 def render_side_by_side(self):
483 # Side-by-side lineup comp
484 pixmaps = self.pixmaps
485 width = sum([pixmap.width() for pixmap in pixmaps])
486 height = max([pixmap.height() for pixmap in pixmaps])
487 image = create_image(width, height)
489 # Paint each pixmap
490 painter = create_painter(image)
491 x = 0
492 for pixmap in pixmaps:
493 painter.drawPixmap(x, 0, pixmap)
494 x += pixmap.width()
495 painter.end()
497 return image
499 def render_comp(self, comp_mode):
500 # Get the max size to use as the render canvas
501 pixmaps = self.pixmaps
502 if len(pixmaps) == 1:
503 return pixmaps[0]
505 width = max([pixmap.width() for pixmap in pixmaps])
506 height = max([pixmap.height() for pixmap in pixmaps])
507 image = create_image(width, height)
509 painter = create_painter(image)
510 for pixmap in (pixmaps[0], pixmaps[-1]):
511 x = (width - pixmap.width()) // 2
512 y = (height - pixmap.height()) // 2
513 painter.drawPixmap(x, y, pixmap)
514 painter.setCompositionMode(comp_mode)
515 painter.end()
517 return image
519 def render_diff(self):
520 comp_mode = QtGui.QPainter.CompositionMode_Difference
521 return self.render_comp(comp_mode)
523 def render_xor(self):
524 comp_mode = QtGui.QPainter.CompositionMode_Xor
525 return self.render_comp(comp_mode)
527 def render_pixel_xor(self):
528 comp_mode = QtGui.QPainter.RasterOp_SourceXorDestination
529 return self.render_comp(comp_mode)
532 def create_image(width, height):
533 size = QtCore.QSize(width, height)
534 image = QtGui.QImage(size, QtGui.QImage.Format_ARGB32_Premultiplied)
535 image.fill(Qt.transparent)
536 return image
539 def create_painter(image):
540 painter = QtGui.QPainter(image)
541 painter.fillRect(image.rect(), Qt.transparent)
542 return painter
545 class Options(QtWidgets.QWidget):
546 """Provide the options widget used by the editor
548 Actions are registered on the parent widget.
552 # mode combobox indexes
553 SIDE_BY_SIDE = 0
554 DIFF = 1
555 XOR = 2
556 PIXEL_XOR = 3
558 def __init__(self, parent):
559 super(Options, self).__init__(parent)
560 self.widget = parent
561 self.ignore_space_at_eol = self.add_option(
562 N_('Ignore changes in whitespace at EOL'))
564 self.ignore_space_change = self.add_option(
565 N_('Ignore changes in amount of whitespace'))
567 self.ignore_all_space = self.add_option(
568 N_('Ignore all whitespace'))
570 self.function_context = self.add_option(
571 N_('Show whole surrounding functions of changes'))
573 self.show_line_numbers = self.add_option(
574 N_('Show line numbers'))
576 self.options = options = qtutils.create_action_button(
577 tooltip=N_('Diff Options'), icon=icons.configure())
579 self.image_mode = qtutils.combo([
580 N_('Side by side'),
581 N_('Diff'),
582 N_('XOR'),
583 N_('Pixel XOR'),
586 self.zoom_factors = (
587 (N_('Zoom to Fit'), 0.0),
588 (N_('25%'), 0.25),
589 (N_('50%'), 0.5),
590 (N_('100%'), 1.0),
591 (N_('200%'), 2.0),
592 (N_('400%'), 4.0),
593 (N_('800%'), 8.0),
595 zoom_modes = [factor[0] for factor in self.zoom_factors]
596 self.zoom_mode = qtutils.combo(zoom_modes, parent=self)
598 self.menu = menu = qtutils.create_menu(N_('Diff Options'), options)
599 options.setMenu(menu)
601 menu.addAction(self.ignore_space_at_eol)
602 menu.addAction(self.ignore_space_change)
603 menu.addAction(self.ignore_all_space)
604 menu.addAction(self.show_line_numbers)
605 menu.addAction(self.function_context)
607 layout = qtutils.hbox(
608 defs.no_margin, defs.no_spacing,
609 self.image_mode, defs.button_spacing, self.zoom_mode, options)
610 self.setLayout(layout)
612 self.image_mode.setFocusPolicy(Qt.NoFocus)
613 self.zoom_mode.setFocusPolicy(Qt.NoFocus)
614 self.options.setFocusPolicy(Qt.NoFocus)
615 self.setFocusPolicy(Qt.NoFocus)
617 def set_diff_type(self, diff_type):
618 is_text = diff_type == 'text'
619 is_image = diff_type == 'image'
620 self.options.setVisible(is_text)
621 self.image_mode.setVisible(is_image)
622 self.zoom_mode.setVisible(is_image)
624 def add_option(self, title):
625 action = qtutils.add_action(self, title, self.update_options)
626 action.setCheckable(True)
627 return action
629 def update_options(self):
630 space_at_eol = get(self.ignore_space_at_eol)
631 space_change = get(self.ignore_space_change)
632 all_space = get(self.ignore_all_space)
633 function_context = get(self.function_context)
634 gitcmds.update_diff_overrides(
635 space_at_eol, space_change, all_space, function_context)
636 self.widget.update_options()
639 class DiffEditor(DiffTextEdit):
641 up = Signal()
642 down = Signal()
643 options_changed = Signal()
644 updated = Signal()
645 diff_text_updated = Signal(object)
647 def __init__(self, context, options, parent):
648 DiffTextEdit.__init__(self, context, parent, numbers=True)
649 self.context = context
650 self.model = model = context.model
651 self.selection_model = selection_model = context.selection
653 # "Diff Options" tool menu
654 self.options = options
655 self.action_apply_selection = qtutils.add_action(
656 self, 'Apply', self.apply_selection, hotkeys.STAGE_DIFF)
658 self.action_revert_selection = qtutils.add_action(
659 self, 'Revert', self.revert_selection, hotkeys.REVERT)
660 self.action_revert_selection.setIcon(icons.undo())
662 self.launch_editor = actions.launch_editor_at_line(
663 context, self, *hotkeys.ACCEPT)
664 self.launch_difftool = actions.launch_difftool(context, self)
665 self.stage_or_unstage = actions.stage_or_unstage(context, self)
667 # Emit up/down signals so that they can be routed by the main widget
668 self.move_up = actions.move_up(self)
669 self.move_down = actions.move_down(self)
671 diff_text_updated = model.message_diff_text_updated
672 model.add_observer(diff_text_updated, self.diff_text_updated.emit)
673 self.diff_text_updated.connect(self.set_diff, type=Qt.QueuedConnection)
675 selection_model.add_observer(
676 selection_model.message_selection_changed, self.updated.emit)
677 self.updated.connect(self.refresh, type=Qt.QueuedConnection)
678 # Update the selection model when the cursor changes
679 self.cursorPositionChanged.connect(self._update_line_number)
681 def refresh(self):
682 enabled = False
683 s = self.selection_model.selection()
684 model = self.model
685 if s.modified and model.stageable():
686 if s.modified[0] in model.submodules:
687 pass
688 elif s.modified[0] not in model.unstaged_deleted:
689 enabled = True
690 self.action_revert_selection.setEnabled(enabled)
692 def enable_line_numbers(self, enabled):
693 """Enable/disable the diff line number display"""
694 self.numbers.setVisible(enabled)
695 self.options.show_line_numbers.setChecked(enabled)
697 def show_line_numbers(self):
698 """Return True if we should show line numbers"""
699 return get(self.options.show_line_numbers)
701 def update_options(self):
702 self.numbers.setVisible(self.show_line_numbers())
703 self.options_changed.emit()
705 # Qt overrides
706 def contextMenuEvent(self, event):
707 """Create the context menu for the diff display."""
708 menu = qtutils.create_menu(N_('Actions'), self)
709 context = self.context
710 model = self.model
711 s = self.selection_model.selection()
712 filename = self.selection_model.filename()
714 if model.stageable() or model.unstageable():
715 if model.stageable():
716 self.stage_or_unstage.setText(N_('Stage'))
717 else:
718 self.stage_or_unstage.setText(N_('Unstage'))
719 menu.addAction(self.stage_or_unstage)
721 if s.modified and model.stageable():
722 item = s.modified[0]
723 if item in model.submodules:
724 path = core.abspath(item)
725 action = menu.addAction(
726 icons.add(), cmds.Stage.name(),
727 cmds.run(cmds.Stage, context, s.modified))
728 action.setShortcut(hotkeys.STAGE_SELECTION)
729 menu.addAction(icons.cola(), N_('Launch git-cola'),
730 cmds.run(cmds.OpenRepo, context, path))
731 elif item not in model.unstaged_deleted:
732 if self.has_selection():
733 apply_text = N_('Stage Selected Lines')
734 revert_text = N_('Revert Selected Lines...')
735 else:
736 apply_text = N_('Stage Diff Hunk')
737 revert_text = N_('Revert Diff Hunk...')
739 self.action_apply_selection.setText(apply_text)
740 self.action_apply_selection.setIcon(icons.add())
742 self.action_revert_selection.setText(revert_text)
744 menu.addAction(self.action_apply_selection)
745 menu.addAction(self.action_revert_selection)
747 if s.staged and model.unstageable():
748 item = s.staged[0]
749 if item in model.submodules:
750 path = core.abspath(item)
751 action = menu.addAction(
752 icons.remove(), cmds.Unstage.name(),
753 cmds.run(cmds.Unstage, context, s.staged))
754 action.setShortcut(hotkeys.STAGE_SELECTION)
755 menu.addAction(icons.cola(), N_('Launch git-cola'),
756 cmds.run(cmds.OpenRepo, context, path))
757 elif item not in model.staged_deleted:
758 if self.has_selection():
759 apply_text = N_('Unstage Selected Lines')
760 else:
761 apply_text = N_('Unstage Diff Hunk')
763 self.action_apply_selection.setText(apply_text)
764 self.action_apply_selection.setIcon(icons.remove())
765 menu.addAction(self.action_apply_selection)
767 if model.stageable() or model.unstageable():
768 # Do not show the "edit" action when the file does not exist.
769 # Untracked files exist by definition.
770 if filename and core.exists(filename):
771 menu.addSeparator()
772 menu.addAction(self.launch_editor)
774 # Removed files can still be diffed.
775 menu.addAction(self.launch_difftool)
777 # Add the Previous/Next File actions, which improves discoverability
778 # of their associated shortcuts
779 menu.addSeparator()
780 menu.addAction(self.move_up)
781 menu.addAction(self.move_down)
783 menu.addSeparator()
784 action = menu.addAction(icons.copy(), N_('Copy'), self.copy)
785 action.setShortcut(QtGui.QKeySequence.Copy)
787 action = menu.addAction(icons.select_all(), N_('Select All'),
788 self.selectAll)
789 action.setShortcut(QtGui.QKeySequence.SelectAll)
790 menu.exec_(self.mapToGlobal(event.pos()))
792 def mousePressEvent(self, event):
793 if event.button() == Qt.RightButton:
794 # Intercept right-click to move the cursor to the current position.
795 # setTextCursor() clears the selection so this is only done when
796 # nothing is selected.
797 if not self.has_selection():
798 cursor = self.cursorForPosition(event.pos())
799 self.setTextCursor(cursor)
801 return super(DiffEditor, self).mousePressEvent(event)
803 def setPlainText(self, text):
804 """setPlainText(str) while retaining scrollbar positions"""
805 model = self.model
806 mode = model.mode
807 highlight = mode not in (model.mode_none, model.mode_untracked)
808 self.highlighter.set_enabled(highlight)
810 scrollbar = self.verticalScrollBar()
811 if scrollbar:
812 scrollvalue = get(scrollbar)
813 else:
814 scrollvalue = None
816 if text is None:
817 return
819 DiffTextEdit.setPlainText(self, text)
821 if scrollbar and scrollvalue is not None:
822 scrollbar.setValue(scrollvalue)
824 def selected_lines(self):
825 cursor = self.textCursor()
826 selection_start = cursor.selectionStart()
827 selection_end = max(selection_start, cursor.selectionEnd() - 1)
829 first_line_idx = -1
830 last_line_idx = -1
831 line_idx = 0
832 line_start = 0
834 for line_idx, line in enumerate(get(self).splitlines()):
835 line_end = line_start + len(line)
836 if line_start <= selection_start <= line_end:
837 first_line_idx = line_idx
838 if line_start <= selection_end <= line_end:
839 last_line_idx = line_idx
840 break
841 line_start = line_end + 1
843 if first_line_idx == -1:
844 first_line_idx = line_idx
846 if last_line_idx == -1:
847 last_line_idx = line_idx
849 return first_line_idx, last_line_idx
851 def apply_selection(self):
852 model = self.model
853 s = self.selection_model.single_selection()
854 if model.stageable() and s.modified:
855 self.process_diff_selection()
856 elif model.unstageable():
857 self.process_diff_selection(reverse=True)
859 def revert_selection(self):
860 """Destructively revert selected lines or hunk from a worktree file."""
862 if self.has_selection():
863 title = N_('Revert Selected Lines?')
864 ok_text = N_('Revert Selected Lines')
865 else:
866 title = N_('Revert Diff Hunk?')
867 ok_text = N_('Revert Diff Hunk')
869 if not Interaction.confirm(
870 title,
871 N_('This operation drops uncommitted changes.\n'
872 'These changes cannot be recovered.'),
873 N_('Revert the uncommitted changes?'),
874 ok_text, default=True, icon=icons.undo()):
875 return
876 self.process_diff_selection(reverse=True, apply_to_worktree=True)
878 def process_diff_selection(self, reverse=False, apply_to_worktree=False):
879 """Implement un/staging of the selected line(s) or hunk."""
880 if self.selection_model.is_empty():
881 return
882 context = self.context
883 first_line_idx, last_line_idx = self.selected_lines()
884 cmds.do(cmds.ApplyDiffSelection, context,
885 first_line_idx, last_line_idx,
886 self.has_selection(), reverse, apply_to_worktree)
888 def _update_line_number(self):
889 """Update the selection model when the cursor changes"""
890 self.selection_model.line_number = self.numbers.current_line()
893 class DiffWidget(QtWidgets.QWidget):
895 def __init__(self, context, notifier, parent, is_commit=False):
896 QtWidgets.QWidget.__init__(self, parent)
898 self.context = context
899 self.oid = 'HEAD'
901 author_font = QtGui.QFont(self.font())
902 author_font.setPointSize(int(author_font.pointSize() * 1.1))
904 summary_font = QtGui.QFont(author_font)
905 summary_font.setWeight(QtGui.QFont.Bold)
907 policy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding,
908 QtWidgets.QSizePolicy.Minimum)
910 self.gravatar_label = gravatar.GravatarLabel()
912 self.author_label = TextLabel()
913 self.author_label.setTextFormat(Qt.RichText)
914 self.author_label.setFont(author_font)
915 self.author_label.setSizePolicy(policy)
916 self.author_label.setAlignment(Qt.AlignBottom)
917 self.author_label.elide()
919 self.date_label = TextLabel()
920 self.date_label.setTextFormat(Qt.PlainText)
921 self.date_label.setSizePolicy(policy)
922 self.date_label.setAlignment(Qt.AlignTop)
923 self.date_label.hide()
925 self.summary_label = TextLabel()
926 self.summary_label.setTextFormat(Qt.PlainText)
927 self.summary_label.setFont(summary_font)
928 self.summary_label.setSizePolicy(policy)
929 self.summary_label.setAlignment(Qt.AlignTop)
930 self.summary_label.elide()
932 self.oid_label = TextLabel()
933 self.oid_label.setTextFormat(Qt.PlainText)
934 self.oid_label.setSizePolicy(policy)
935 self.oid_label.setAlignment(Qt.AlignTop)
936 self.oid_label.elide()
938 self.diff = DiffTextEdit(
939 context, self, is_commit=is_commit, whitespace=False)
941 self.info_layout = qtutils.vbox(defs.no_margin, defs.no_spacing,
942 self.author_label, self.date_label,
943 self.summary_label, self.oid_label)
945 self.logo_layout = qtutils.hbox(defs.no_margin, defs.button_spacing,
946 self.gravatar_label, self.info_layout)
947 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
949 self.main_layout = qtutils.vbox(defs.no_margin, defs.spacing,
950 self.logo_layout, self.diff)
951 self.setLayout(self.main_layout)
953 notifier.add_observer(COMMITS_SELECTED, self.commits_selected)
954 notifier.add_observer(FILES_SELECTED, self.files_selected)
955 self.set_tabwidth(prefs.tabwidth(context))
957 def set_tabwidth(self, width):
958 self.diff.set_tabwidth(width)
960 def set_diff_oid(self, oid, filename=None):
961 context = self.context
962 self.diff.save_scrollbar()
963 self.diff.set_loading_message()
964 task = DiffInfoTask(context, oid, filename, self)
965 self.context.runtask.start(task, result=self.set_diff)
967 def commits_selected(self, commits):
968 if len(commits) != 1:
969 return
970 commit = commits[0]
971 oid = self.oid = commit.oid
972 email = commit.email or ''
973 summary = commit.summary or ''
974 author = commit.author or ''
976 self.set_details(oid, author, email, '', summary)
977 self.set_diff_oid(oid)
979 def set_diff(self, diff):
980 self.diff.set_diff(diff)
982 def set_details(self, oid, author, email, date, summary):
983 template_args = {
984 'author': author,
985 'email': email,
986 'summary': summary
988 author_text = ("""%(author)s &lt;"""
989 """<a href="mailto:%(email)s">"""
990 """%(email)s</a>&gt;"""
991 % template_args)
992 author_template = '%(author)s <%(email)s>' % template_args
994 self.date_label.set_text(date)
995 self.date_label.setVisible(bool(date))
996 self.oid_label.set_text(oid)
997 self.author_label.set_template(author_text, author_template)
998 self.summary_label.set_text(summary)
999 self.gravatar_label.set_email(email)
1001 def files_selected(self, filenames):
1002 if not filenames:
1003 return
1004 self.set_diff_oid(self.oid, filenames[0])
1007 class TextLabel(QtWidgets.QLabel):
1009 def __init__(self, parent=None):
1010 QtWidgets.QLabel.__init__(self, parent)
1011 self.setTextInteractionFlags(Qt.TextSelectableByMouse |
1012 Qt.LinksAccessibleByMouse)
1013 self._display = ''
1014 self._template = ''
1015 self._text = ''
1016 self._elide = False
1017 self._metrics = QtGui.QFontMetrics(self.font())
1018 self.setOpenExternalLinks(True)
1020 def elide(self):
1021 self._elide = True
1023 def set_text(self, text):
1024 self.set_template(text, text)
1026 def set_template(self, text, template):
1027 self._display = text
1028 self._text = text
1029 self._template = template
1030 self.update_text(self.width())
1031 self.setText(self._display)
1033 def update_text(self, width):
1034 self._display = self._text
1035 if not self._elide:
1036 return
1037 text = self._metrics.elidedText(self._template,
1038 Qt.ElideRight, width-2)
1039 if text != self._template:
1040 self._display = text
1042 # Qt overrides
1043 def setFont(self, font):
1044 self._metrics = QtGui.QFontMetrics(font)
1045 QtWidgets.QLabel.setFont(self, font)
1047 def resizeEvent(self, event):
1048 if self._elide:
1049 self.update_text(event.size().width())
1050 block = self.blockSignals(True)
1051 self.setText(self._display)
1052 self.blockSignals(block)
1053 QtWidgets.QLabel.resizeEvent(self, event)
1056 class DiffInfoTask(qtutils.Task):
1058 def __init__(self, context, oid, filename, parent):
1059 qtutils.Task.__init__(self, parent)
1060 self.context = context
1061 self.oid = oid
1062 self.filename = filename
1064 def task(self):
1065 context = self.context
1066 oid = self.oid
1067 return gitcmds.diff_info(context, oid, filename=self.filename)