diff: open the "Apply Patches" dialog when dragging and dropping patches
[git-cola.git] / cola / widgets / diff.py
blob8e1a4cf4e81eafc524cd008cc3ad61a4bc5dddbe
1 from __future__ import absolute_import, division, print_function, unicode_literals
2 from functools import partial
3 import os
4 import re
6 from qtpy import QtCore
7 from qtpy import QtGui
8 from qtpy import QtWidgets
9 from qtpy.QtCore import Qt
10 from qtpy.QtCore import Signal
12 from ..i18n import N_
13 from ..editpatch import edit_patch
14 from ..interaction import Interaction
15 from ..models import main
16 from ..models import prefs
17 from ..qtutils import get
18 from .. import actions
19 from .. import cmds
20 from .. import core
21 from .. import diffparse
22 from .. import gitcmds
23 from .. import gravatar
24 from .. import hotkeys
25 from .. import icons
26 from .. import utils
27 from .. import qtutils
28 from .text import TextDecorator
29 from .text import VimHintedPlainTextEdit
30 from .text import TextSearchWidget
31 from . import defs
32 from . import patch as patch_mod
33 from . import imageview
36 class DiffSyntaxHighlighter(QtGui.QSyntaxHighlighter):
37 """Implements the diff syntax highlighting"""
39 INITIAL_STATE = -1
40 DEFAULT_STATE = 0
41 DIFFSTAT_STATE = 1
42 DIFF_FILE_HEADER_STATE = 2
43 DIFF_STATE = 3
44 SUBMODULE_STATE = 4
45 END_STATE = 5
47 DIFF_FILE_HEADER_START_RGX = re.compile(r'diff --git a/.* b/.*')
48 DIFF_HUNK_HEADER_RGX = re.compile(
49 r'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)'
51 BAD_WHITESPACE_RGX = re.compile(r'\s+$')
53 def __init__(self, context, doc, whitespace=True, is_commit=False):
54 QtGui.QSyntaxHighlighter.__init__(self, doc)
55 self.whitespace = whitespace
56 self.enabled = True
57 self.is_commit = is_commit
59 QPalette = QtGui.QPalette
60 cfg = context.cfg
61 palette = QPalette()
62 disabled = palette.color(QPalette.Disabled, QPalette.Text)
63 header = qtutils.rgb_hex(disabled)
65 dark = palette.color(QPalette.Base).lightnessF() < 0.5
67 self.color_text = qtutils.rgb_triple(cfg.color('text', '030303'))
68 self.color_add = qtutils.rgb_triple(
69 cfg.color('add', '77aa77' if dark else 'd2ffe4')
71 self.color_remove = qtutils.rgb_triple(
72 cfg.color('remove', 'aa7777' if dark else 'fee0e4')
74 self.color_header = qtutils.rgb_triple(cfg.color('header', header))
76 self.diff_header_fmt = qtutils.make_format(foreground=self.color_header)
77 self.bold_diff_header_fmt = qtutils.make_format(
78 foreground=self.color_header, bold=True
81 self.diff_add_fmt = qtutils.make_format(
82 foreground=self.color_text, background=self.color_add
84 self.diff_remove_fmt = qtutils.make_format(
85 foreground=self.color_text, background=self.color_remove
87 self.bad_whitespace_fmt = qtutils.make_format(background=Qt.red)
88 self.setCurrentBlockState(self.INITIAL_STATE)
90 def set_enabled(self, enabled):
91 self.enabled = enabled
93 def highlightBlock(self, text):
94 """Highlight the current text block"""
95 if not self.enabled or not text:
96 return
97 formats = []
98 state = self.get_next_state(text)
99 if state == self.DIFFSTAT_STATE:
100 state, formats = self.get_formats_for_diffstat(state, text)
101 elif state == self.DIFF_FILE_HEADER_STATE:
102 state, formats = self.get_formats_for_diff_header(state, text)
103 elif state == self.DIFF_STATE:
104 state, formats = self.get_formats_for_diff_text(state, text)
106 for start, end, fmt in formats:
107 self.setFormat(start, end, fmt)
109 self.setCurrentBlockState(state)
111 def get_next_state(self, text):
112 """Transition to the next state based on the input text"""
113 state = self.previousBlockState()
114 if state == DiffSyntaxHighlighter.INITIAL_STATE:
115 if text.startswith('Submodule '):
116 state = DiffSyntaxHighlighter.SUBMODULE_STATE
117 elif text.startswith('diff --git '):
118 state = DiffSyntaxHighlighter.DIFFSTAT_STATE
119 elif self.is_commit:
120 state = DiffSyntaxHighlighter.DEFAULT_STATE
121 else:
122 state = DiffSyntaxHighlighter.DIFFSTAT_STATE
124 return state
126 def get_formats_for_diffstat(self, state, text):
127 """Returns (state, [(start, end, fmt), ...]) for highlighting diffstat text"""
128 formats = []
129 if self.DIFF_FILE_HEADER_START_RGX.match(text):
130 state = self.DIFF_FILE_HEADER_STATE
131 end = len(text)
132 fmt = self.diff_header_fmt
133 formats.append((0, end, fmt))
134 elif self.DIFF_HUNK_HEADER_RGX.match(text):
135 state = self.DIFF_STATE
136 end = len(text)
137 fmt = self.bold_diff_header_fmt
138 formats.append((0, end, fmt))
139 elif '|' in text:
140 offset = text.index('|')
141 formats.append((0, offset, self.bold_diff_header_fmt))
142 formats.append((offset, len(text) - offset, self.diff_header_fmt))
143 else:
144 formats.append((0, len(text), self.diff_header_fmt))
146 return state, formats
148 def get_formats_for_diff_header(self, state, text):
149 """Returns (state, [(start, end, fmt), ...]) for highlighting diff headers"""
150 formats = []
151 if self.DIFF_HUNK_HEADER_RGX.match(text):
152 state = self.DIFF_STATE
153 formats.append((0, len(text), self.bold_diff_header_fmt))
154 else:
155 formats.append((0, len(text), self.diff_header_fmt))
157 return state, formats
159 def get_formats_for_diff_text(self, state, text):
160 """Return (state, [(start, end fmt), ...]) for highlighting diff text"""
161 formats = []
163 if self.DIFF_FILE_HEADER_START_RGX.match(text):
164 state = self.DIFF_FILE_HEADER_STATE
165 formats.append((0, len(text), self.diff_header_fmt))
167 elif self.DIFF_HUNK_HEADER_RGX.match(text):
168 formats.append((0, len(text), self.bold_diff_header_fmt))
170 elif text.startswith('-'):
171 if text == '-- ':
172 state = self.END_STATE
173 else:
174 formats.append((0, len(text), self.diff_remove_fmt))
176 elif text.startswith('+'):
177 formats.append((0, len(text), self.diff_add_fmt))
178 if self.whitespace:
179 match = self.BAD_WHITESPACE_RGX.search(text)
180 if match is not None:
181 start = match.start()
182 formats.append((start, len(text) - start, self.bad_whitespace_fmt))
184 return state, formats
187 # pylint: disable=too-many-ancestors
188 class DiffTextEdit(VimHintedPlainTextEdit):
189 """A textedit for interacting with diff text"""
191 def __init__(
192 self, context, parent, is_commit=False, whitespace=True, numbers=False
194 VimHintedPlainTextEdit.__init__(self, context, '', parent=parent)
195 # Diff/patch syntax highlighter
196 self.highlighter = DiffSyntaxHighlighter(
197 context, self.document(), is_commit=is_commit, whitespace=whitespace
199 if numbers:
200 self.numbers = DiffLineNumbers(context, self)
201 self.numbers.hide()
202 else:
203 self.numbers = None
204 self.scrollvalue = None
206 self.copy_diff_action = qtutils.add_action(
207 self,
208 N_('Copy Diff'),
209 self.copy_diff,
210 hotkeys.COPY_DIFF,
212 self.copy_diff_action.setIcon(icons.copy())
213 self.copy_diff_action.setEnabled(False)
214 self.menu_actions.append(self.copy_diff_action)
216 # pylint: disable=no-member
217 self.cursorPositionChanged.connect(self._cursor_changed)
218 self.selectionChanged.connect(self._selection_changed)
220 def setFont(self, font):
221 """Override setFont() so that we can use a custom "block" cursor"""
222 super(DiffTextEdit, self).setFont(font)
223 if prefs.block_cursor(self.context):
224 metrics = QtGui.QFontMetrics(font)
225 width = metrics.width('M')
226 self.setCursorWidth(width)
228 def _cursor_changed(self):
229 """Update the line number display when the cursor changes"""
230 line_number = max(0, self.textCursor().blockNumber())
231 if self.numbers is not None:
232 self.numbers.set_highlighted(line_number)
234 def _selection_changed(self):
235 """Respond to selection changes"""
236 selected = bool(self.selected_text())
237 self.copy_diff_action.setEnabled(selected)
239 def resizeEvent(self, event):
240 super(DiffTextEdit, self).resizeEvent(event)
241 if self.numbers:
242 self.numbers.refresh_size()
244 def save_scrollbar(self):
245 """Save the scrollbar value, but only on the first call"""
246 if self.scrollvalue is None:
247 scrollbar = self.verticalScrollBar()
248 if scrollbar:
249 scrollvalue = get(scrollbar)
250 else:
251 scrollvalue = None
252 self.scrollvalue = scrollvalue
254 def restore_scrollbar(self):
255 """Restore the scrollbar and clear state"""
256 scrollbar = self.verticalScrollBar()
257 scrollvalue = self.scrollvalue
258 if scrollbar and scrollvalue is not None:
259 scrollbar.setValue(scrollvalue)
260 self.scrollvalue = None
262 def set_loading_message(self):
263 """Add a pending loading message in the diff view"""
264 self.hint.set_value('+++ ' + N_('Loading...'))
265 self.set_value('')
267 def set_diff(self, diff):
268 """Set the diff text, but save the scrollbar"""
269 diff = diff.rstrip('\n') # diffs include two empty newlines
270 self.save_scrollbar()
272 self.hint.set_value('')
273 if self.numbers:
274 self.numbers.set_diff(diff)
275 self.set_value(diff)
277 self.restore_scrollbar()
279 def selected_diff_stripped(self):
280 """Return the selected diff stripped of any diff characters"""
281 sep, selection = self.selected_text_lines()
282 return sep.join(_strip_diff(line) for line in selection)
284 def copy_diff(self):
285 """Copy the selected diff text stripped of any diff prefix characters"""
286 text = self.selected_diff_stripped()
287 qtutils.set_clipboard(text)
289 def selected_lines(self):
290 """Return selected lines"""
291 cursor = self.textCursor()
292 selection_start = cursor.selectionStart()
293 selection_end = max(selection_start, cursor.selectionEnd() - 1)
295 first_line_idx = -1
296 last_line_idx = -1
297 line_idx = 0
298 line_start = 0
300 for line_idx, line in enumerate(get(self, default='').splitlines()):
301 line_end = line_start + len(line)
302 if line_start <= selection_start <= line_end:
303 first_line_idx = line_idx
304 if line_start <= selection_end <= line_end:
305 last_line_idx = line_idx
306 break
307 line_start = line_end + 1
309 if first_line_idx == -1:
310 first_line_idx = line_idx
312 if last_line_idx == -1:
313 last_line_idx = line_idx
315 return first_line_idx, last_line_idx
317 def selected_text_lines(self):
318 """Return selected lines and the CRLF / LF separator"""
319 first_line_idx, last_line_idx = self.selected_lines()
320 text = get(self, default='')
321 sep = _get_sep(text)
322 lines = []
323 for line_idx, line in enumerate(text.split(sep)):
324 if first_line_idx <= line_idx <= last_line_idx:
325 lines.append(line)
326 return sep, lines
329 def _get_sep(text):
330 """Return either CRLF or LF based on the content"""
331 if '\r\n' in text:
332 sep = '\r\n'
333 else:
334 sep = '\n'
335 return sep
338 def _strip_diff(value):
339 """Remove +/-/<space> from a selection"""
340 if value.startswith(('+', '-', ' ')):
341 return value[1:]
342 return value
345 class DiffLineNumbers(TextDecorator):
346 def __init__(self, context, parent):
347 TextDecorator.__init__(self, parent)
348 self.highlight_line = -1
349 self.lines = None
350 self.parser = diffparse.DiffLines()
351 self.formatter = diffparse.FormatDigits()
353 self.setFont(qtutils.diff_font(context))
354 self._char_width = self.fontMetrics().width('0')
356 QPalette = QtGui.QPalette
357 self._palette = palette = self.palette()
358 self._base = palette.color(QtGui.QPalette.Base)
359 self._highlight = palette.color(QPalette.Highlight)
360 self._highlight.setAlphaF(0.3)
361 self._highlight_text = palette.color(QPalette.HighlightedText)
362 self._window = palette.color(QPalette.Window)
363 self._disabled = palette.color(QPalette.Disabled, QPalette.Text)
365 def set_diff(self, diff):
366 self.lines = self.parser.parse(diff)
367 self.formatter.set_digits(self.parser.digits())
369 def width_hint(self):
370 if not self.isVisible():
371 return 0
372 parser = self.parser
374 if parser.merge:
375 columns = 3
376 extra = 3 # one space in-between, one space after
377 else:
378 columns = 2
379 extra = 2 # one space in-between, one space after
381 digits = parser.digits() * columns
383 return defs.margin + (self._char_width * (digits + extra))
385 def set_highlighted(self, line_number):
386 """Set the line to highlight"""
387 self.highlight_line = line_number
389 def current_line(self):
390 lines = self.lines
391 if lines and self.highlight_line >= 0:
392 # Find the next valid line
393 for i in range(self.highlight_line, len(lines)):
394 # take the "new" line number: last value in tuple
395 line_number = lines[i][-1]
396 if line_number > 0:
397 return line_number
399 # Find the previous valid line
400 for i in range(self.highlight_line - 1, -1, -1):
401 # take the "new" line number: last value in tuple
402 if i < len(lines):
403 line_number = lines[i][-1]
404 if line_number > 0:
405 return line_number
406 return None
408 def paintEvent(self, event):
409 """Paint the line number"""
410 if not self.lines:
411 return
413 painter = QtGui.QPainter(self)
414 painter.fillRect(event.rect(), self._base)
416 editor = self.editor
417 content_offset = editor.contentOffset()
418 block = editor.firstVisibleBlock()
419 width = self.width()
420 text_width = width - (defs.margin * 2)
421 text_flags = Qt.AlignRight | Qt.AlignVCenter
422 event_rect_bottom = event.rect().bottom()
424 highlight_line = self.highlight_line
425 highlight = self._highlight
426 highlight_text = self._highlight_text
427 disabled = self._disabled
429 fmt = self.formatter
430 lines = self.lines
431 num_lines = len(lines)
433 while block.isValid():
434 block_number = block.blockNumber()
435 if block_number >= num_lines:
436 break
437 block_geom = editor.blockBoundingGeometry(block)
438 rect = block_geom.translated(content_offset).toRect()
439 if not block.isVisible() or rect.top() >= event_rect_bottom:
440 break
442 if block_number == highlight_line:
443 painter.fillRect(rect.x(), rect.y(), width, rect.height(), highlight)
444 painter.setPen(highlight_text)
445 else:
446 painter.setPen(disabled)
448 line = lines[block_number]
449 if len(line) == 2:
450 a, b = line
451 text = fmt.value(a, b)
452 elif len(line) == 3:
453 old, base, new = line
454 text = fmt.merge_value(old, base, new)
456 painter.drawText(
457 rect.x(),
458 rect.y(),
459 text_width,
460 rect.height(),
461 text_flags,
462 text,
465 block = block.next()
468 class Viewer(QtWidgets.QFrame):
469 """Text and image diff viewers"""
471 INDEX_TEXT = 0
472 INDEX_IMAGE = 1
474 def __init__(self, context, parent=None):
475 super(Viewer, self).__init__(parent)
477 self.context = context
478 self.model = model = context.model
479 self.images = []
480 self.pixmaps = []
481 self.options = options = Options(self)
482 self.text = DiffEditor(context, options, self)
483 self.image = imageview.ImageView(parent=self)
484 self.image.setFocusPolicy(Qt.NoFocus)
485 self.search_widget = TextSearchWidget(self.text, self)
486 self.search_widget.hide()
487 self._drag_has_patches = False
489 self.setAcceptDrops(True)
490 self.setFocusProxy(self.text)
492 stack = self.stack = QtWidgets.QStackedWidget(self)
493 stack.addWidget(self.text)
494 stack.addWidget(self.image)
496 self.main_layout = qtutils.vbox(
497 defs.no_margin,
498 defs.no_spacing,
499 self.stack,
500 self.search_widget,
502 self.setLayout(self.main_layout)
504 # Observe images
505 model.images_changed.connect(self.set_images, type=Qt.QueuedConnection)
507 # Observe the diff type
508 model.diff_type_changed.connect(self.set_diff_type, type=Qt.QueuedConnection)
510 # Observe the file type
511 model.file_type_changed.connect(self.set_file_type, type=Qt.QueuedConnection)
513 # Observe the image mode combo box
514 options.image_mode.currentIndexChanged.connect(lambda _: self.render())
515 options.zoom_mode.currentIndexChanged.connect(lambda _: self.render())
517 self.search_action = qtutils.add_action(
518 self,
519 N_('Search in Diff'),
520 self.show_search_diff,
521 hotkeys.SEARCH,
524 def dragEnterEvent(self, event):
525 """Accepts drops if the mimedata contains patches"""
526 super(Viewer, self).dragEnterEvent(event)
527 patches = patch_mod.get_patches_from_mimedata(event.mimeData())
528 if patches:
529 event.acceptProposedAction()
530 self._drag_has_patches = True
532 def dragLeaveEvent(self, event):
533 """End the drag+drop interaction"""
534 super(Viewer, self).dragLeaveEvent(event)
535 if self._drag_has_patches:
536 event.accept()
537 else:
538 event.ignore()
539 self._drag_has_patches = False
541 def dropEvent(self, event):
542 """Apply patches when dropped onto the widget"""
543 if not self._drag_has_patches:
544 event.ignore()
545 return
546 event.setDropAction(Qt.CopyAction)
547 super(Viewer, self).dropEvent(event)
548 self._drag_has_patches = False
550 patches = patch_mod.get_patches_from_mimedata(event.mimeData())
551 if patches:
552 patch_mod.apply_patches(self.context, patches=patches)
554 event.accept() # must be called after dropEvent()
556 def show_search_diff(self):
557 """Show a dialog for searching diffs"""
558 # The diff search is only active in text mode.
559 if self.stack.currentIndex() != self.INDEX_TEXT:
560 return
561 if not self.search_widget.isVisible():
562 self.search_widget.show()
563 self.search_widget.setFocus(True)
565 def export_state(self, state):
566 state['show_diff_line_numbers'] = self.options.show_line_numbers.isChecked()
567 state['image_diff_mode'] = self.options.image_mode.currentIndex()
568 state['image_zoom_mode'] = self.options.zoom_mode.currentIndex()
569 state['word_wrap'] = self.options.enable_word_wrapping.isChecked()
570 return state
572 def apply_state(self, state):
573 diff_numbers = bool(state.get('show_diff_line_numbers', False))
574 self.set_line_numbers(diff_numbers, update=True)
576 image_mode = utils.asint(state.get('image_diff_mode', 0))
577 self.options.image_mode.set_index(image_mode)
579 zoom_mode = utils.asint(state.get('image_zoom_mode', 0))
580 self.options.zoom_mode.set_index(zoom_mode)
582 word_wrap = bool(state.get('word_wrap', True))
583 self.set_word_wrapping(word_wrap, update=True)
584 return True
586 def set_diff_type(self, diff_type):
587 """Manage the image and text diff views when selection changes"""
588 # The "diff type" is whether the diff viewer is displaying an image.
589 self.options.set_diff_type(diff_type)
590 if diff_type == main.Types.IMAGE:
591 self.stack.setCurrentWidget(self.image)
592 self.search_widget.hide()
593 self.render()
594 else:
595 self.stack.setCurrentWidget(self.text)
597 def set_file_type(self, file_type):
598 """Manage the diff options when the file type changes"""
599 # The "file type" is whether the file itself is an image.
600 self.options.set_file_type(file_type)
602 def update_options(self):
603 """Emit a signal indicating that options have changed"""
604 self.text.update_options()
606 def set_line_numbers(self, enabled, update=False):
607 """Enable/disable line numbers in the text widget"""
608 self.text.set_line_numbers(enabled, update=update)
610 def set_word_wrapping(self, enabled, update=False):
611 """Enable/disable word wrapping in the text widget"""
612 self.text.set_word_wrapping(enabled, update=update)
614 def reset(self):
615 self.image.pixmap = QtGui.QPixmap()
616 self.cleanup()
618 def cleanup(self):
619 for (image, unlink) in self.images:
620 if unlink and core.exists(image):
621 os.unlink(image)
622 self.images = []
624 def set_images(self, images):
625 self.images = images
626 self.pixmaps = []
627 if not images:
628 self.reset()
629 return False
631 # In order to comp, we first have to load all the images
632 all_pixmaps = [QtGui.QPixmap(image[0]) for image in images]
633 pixmaps = [pixmap for pixmap in all_pixmaps if not pixmap.isNull()]
634 if not pixmaps:
635 self.reset()
636 return False
638 self.pixmaps = pixmaps
639 self.render()
640 self.cleanup()
641 return True
643 def render(self):
644 # Update images
645 if self.pixmaps:
646 mode = self.options.image_mode.currentIndex()
647 if mode == self.options.SIDE_BY_SIDE:
648 image = self.render_side_by_side()
649 elif mode == self.options.DIFF:
650 image = self.render_diff()
651 elif mode == self.options.XOR:
652 image = self.render_xor()
653 elif mode == self.options.PIXEL_XOR:
654 image = self.render_pixel_xor()
655 else:
656 image = self.render_side_by_side()
657 else:
658 image = QtGui.QPixmap()
659 self.image.pixmap = image
661 # Apply zoom
662 zoom_mode = self.options.zoom_mode.currentIndex()
663 zoom_factor = self.options.zoom_factors[zoom_mode][1]
664 if zoom_factor > 0.0:
665 self.image.resetTransform()
666 self.image.scale(zoom_factor, zoom_factor)
667 poly = self.image.mapToScene(self.image.viewport().rect())
668 self.image.last_scene_roi = poly.boundingRect()
670 def render_side_by_side(self):
671 # Side-by-side lineup comp
672 pixmaps = self.pixmaps
673 width = sum(pixmap.width() for pixmap in pixmaps)
674 height = max(pixmap.height() for pixmap in pixmaps)
675 image = create_image(width, height)
677 # Paint each pixmap
678 painter = create_painter(image)
679 x = 0
680 for pixmap in pixmaps:
681 painter.drawPixmap(x, 0, pixmap)
682 x += pixmap.width()
683 painter.end()
685 return image
687 def render_comp(self, comp_mode):
688 # Get the max size to use as the render canvas
689 pixmaps = self.pixmaps
690 if len(pixmaps) == 1:
691 return pixmaps[0]
693 width = max(pixmap.width() for pixmap in pixmaps)
694 height = max(pixmap.height() for pixmap in pixmaps)
695 image = create_image(width, height)
697 painter = create_painter(image)
698 for pixmap in (pixmaps[0], pixmaps[-1]):
699 x = (width - pixmap.width()) // 2
700 y = (height - pixmap.height()) // 2
701 painter.drawPixmap(x, y, pixmap)
702 painter.setCompositionMode(comp_mode)
703 painter.end()
705 return image
707 def render_diff(self):
708 comp_mode = QtGui.QPainter.CompositionMode_Difference
709 return self.render_comp(comp_mode)
711 def render_xor(self):
712 comp_mode = QtGui.QPainter.CompositionMode_Xor
713 return self.render_comp(comp_mode)
715 def render_pixel_xor(self):
716 comp_mode = QtGui.QPainter.RasterOp_SourceXorDestination
717 return self.render_comp(comp_mode)
720 def create_image(width, height):
721 size = QtCore.QSize(width, height)
722 image = QtGui.QImage(size, QtGui.QImage.Format_ARGB32_Premultiplied)
723 image.fill(Qt.transparent)
724 return image
727 def create_painter(image):
728 painter = QtGui.QPainter(image)
729 painter.fillRect(image.rect(), Qt.transparent)
730 return painter
733 class Options(QtWidgets.QWidget):
734 """Provide the options widget used by the editor
736 Actions are registered on the parent widget.
740 # mode combobox indexes
741 SIDE_BY_SIDE = 0
742 DIFF = 1
743 XOR = 2
744 PIXEL_XOR = 3
746 def __init__(self, parent):
747 super(Options, self).__init__(parent)
748 # Create widgets
749 self.widget = parent
750 self.ignore_space_at_eol = self.add_option(
751 N_('Ignore changes in whitespace at EOL')
754 self.ignore_space_change = self.add_option(
755 N_('Ignore changes in amount of whitespace')
758 self.ignore_all_space = self.add_option(N_('Ignore all whitespace'))
760 self.function_context = self.add_option(
761 N_('Show whole surrounding functions of changes')
764 self.show_line_numbers = qtutils.add_action_bool(
765 self, N_('Show line numbers'), self.set_line_numbers, True
767 self.enable_word_wrapping = qtutils.add_action_bool(
768 self, N_('Enable word wrapping'), self.set_word_wrapping, True
771 self.options = qtutils.create_action_button(
772 tooltip=N_('Diff Options'), icon=icons.configure()
775 self.toggle_image_diff = qtutils.create_action_button(
776 tooltip=N_('Toggle image diff'), icon=icons.visualize()
778 self.toggle_image_diff.hide()
780 self.image_mode = qtutils.combo(
781 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
784 self.zoom_factors = (
785 (N_('Zoom to Fit'), 0.0),
786 (N_('25%'), 0.25),
787 (N_('50%'), 0.5),
788 (N_('100%'), 1.0),
789 (N_('200%'), 2.0),
790 (N_('400%'), 4.0),
791 (N_('800%'), 8.0),
793 zoom_modes = [factor[0] for factor in self.zoom_factors]
794 self.zoom_mode = qtutils.combo(zoom_modes, parent=self)
796 self.menu = menu = qtutils.create_menu(N_('Diff Options'), self.options)
797 self.options.setMenu(menu)
798 menu.addAction(self.ignore_space_at_eol)
799 menu.addAction(self.ignore_space_change)
800 menu.addAction(self.ignore_all_space)
801 menu.addSeparator()
802 menu.addAction(self.function_context)
803 menu.addAction(self.show_line_numbers)
804 menu.addSeparator()
805 menu.addAction(self.enable_word_wrapping)
807 # Layouts
808 layout = qtutils.hbox(
809 defs.no_margin,
810 defs.button_spacing,
811 self.image_mode,
812 self.zoom_mode,
813 self.options,
814 self.toggle_image_diff,
816 self.setLayout(layout)
818 # Policies
819 self.image_mode.setFocusPolicy(Qt.NoFocus)
820 self.zoom_mode.setFocusPolicy(Qt.NoFocus)
821 self.options.setFocusPolicy(Qt.NoFocus)
822 self.toggle_image_diff.setFocusPolicy(Qt.NoFocus)
823 self.setFocusPolicy(Qt.NoFocus)
825 def set_file_type(self, file_type):
826 """Set whether we are viewing an image file type"""
827 is_image = file_type == main.Types.IMAGE
828 self.toggle_image_diff.setVisible(is_image)
830 def set_diff_type(self, diff_type):
831 """Toggle between image and text diffs"""
832 is_text = diff_type == main.Types.TEXT
833 is_image = diff_type == main.Types.IMAGE
834 self.options.setVisible(is_text)
835 self.image_mode.setVisible(is_image)
836 self.zoom_mode.setVisible(is_image)
837 if is_image:
838 self.toggle_image_diff.setIcon(icons.diff())
839 else:
840 self.toggle_image_diff.setIcon(icons.visualize())
842 def add_option(self, title):
843 """Add a diff option which calls update_options() on change"""
844 action = qtutils.add_action(self, title, self.update_options)
845 action.setCheckable(True)
846 return action
848 def update_options(self):
849 """Update diff options in response to UI events"""
850 space_at_eol = get(self.ignore_space_at_eol)
851 space_change = get(self.ignore_space_change)
852 all_space = get(self.ignore_all_space)
853 function_context = get(self.function_context)
854 gitcmds.update_diff_overrides(
855 space_at_eol, space_change, all_space, function_context
857 self.widget.update_options()
859 def set_line_numbers(self, value):
860 """Enable / disable line numbers"""
861 self.widget.set_line_numbers(value, update=False)
863 def set_word_wrapping(self, value):
864 """Respond to Qt action callbacks"""
865 self.widget.set_word_wrapping(value, update=False)
867 def hide_advanced_options(self):
868 """Hide advanced options that are not applicable to the DiffWidget"""
869 self.show_line_numbers.setVisible(False)
870 self.ignore_space_at_eol.setVisible(False)
871 self.ignore_space_change.setVisible(False)
872 self.ignore_all_space.setVisible(False)
873 self.function_context.setVisible(False)
876 # pylint: disable=too-many-ancestors
877 class DiffEditor(DiffTextEdit):
879 up = Signal()
880 down = Signal()
881 options_changed = Signal()
883 def __init__(self, context, options, parent):
884 DiffTextEdit.__init__(self, context, parent, numbers=True)
885 self.context = context
886 self.model = model = context.model
887 self.selection_model = selection_model = context.selection
889 # "Diff Options" tool menu
890 self.options = options
892 self.action_apply_selection = qtutils.add_action(
893 self,
894 'Apply',
895 self.apply_selection,
896 hotkeys.STAGE_DIFF,
897 hotkeys.STAGE_DIFF_ALT,
900 self.action_revert_selection = qtutils.add_action(
901 self, 'Revert', self.revert_selection, hotkeys.REVERT
903 self.action_revert_selection.setIcon(icons.undo())
905 self.action_edit_and_apply_selection = qtutils.add_action(
906 self,
907 'Edit and Apply',
908 partial(self.apply_selection, edit=True),
909 hotkeys.EDIT_AND_STAGE_DIFF,
912 self.action_edit_and_revert_selection = qtutils.add_action(
913 self,
914 'Edit and Revert',
915 partial(self.revert_selection, edit=True),
916 hotkeys.EDIT_AND_REVERT,
918 self.action_edit_and_revert_selection.setIcon(icons.undo())
919 self.launch_editor = actions.launch_editor_at_line(
920 context, self, hotkeys.EDIT_SHORT, *hotkeys.ACCEPT
922 self.launch_difftool = actions.launch_difftool(context, self)
923 self.stage_or_unstage = actions.stage_or_unstage(context, self)
925 # Emit up/down signals so that they can be routed by the main widget
926 self.move_up = actions.move_up(self)
927 self.move_down = actions.move_down(self)
929 model.diff_text_updated.connect(self.set_diff, type=Qt.QueuedConnection)
931 selection_model.selection_changed.connect(
932 self.refresh, type=Qt.QueuedConnection
934 # Update the selection model when the cursor changes
935 self.cursorPositionChanged.connect(self._update_line_number)
937 qtutils.connect_button(options.toggle_image_diff, self.toggle_diff_type)
939 def toggle_diff_type(self):
940 cmds.do(cmds.ToggleDiffType, self.context)
942 def refresh(self):
943 enabled = False
944 s = self.selection_model.selection()
945 model = self.model
946 if model.is_partially_stageable():
947 item = s.modified[0] if s.modified else None
948 if item in model.submodules:
949 pass
950 elif item not in model.unstaged_deleted:
951 enabled = True
952 self.action_revert_selection.setEnabled(enabled)
954 def set_line_numbers(self, enabled, update=False):
955 """Enable/disable the diff line number display"""
956 self.numbers.setVisible(enabled)
957 if update:
958 with qtutils.BlockSignals(self.options.show_line_numbers):
959 self.options.show_line_numbers.setChecked(enabled)
960 # Refresh the display. Not doing this results in the display not
961 # correctly displaying the line numbers widget until the text scrolls.
962 self.set_value(self.value())
964 def update_options(self):
965 self.options_changed.emit()
967 def create_context_menu(self, event_pos):
968 """Override create_context_menu() to display a completely custom menu"""
969 menu = super(DiffEditor, self).create_context_menu(event_pos)
970 context = self.context
971 model = self.model
972 s = self.selection_model.selection()
973 filename = self.selection_model.filename()
975 # These menu actions will be inserted at the start of the widget.
976 current_actions = menu.actions()
977 menu_actions = []
978 add_action = menu_actions.append
980 if model.is_stageable() or model.is_unstageable():
981 if model.is_stageable():
982 self.stage_or_unstage.setText(N_('Stage'))
983 self.stage_or_unstage.setIcon(icons.add())
984 else:
985 self.stage_or_unstage.setText(N_('Unstage'))
986 self.stage_or_unstage.setIcon(icons.remove())
987 add_action(self.stage_or_unstage)
989 if model.is_partially_stageable():
990 item = s.modified[0] if s.modified else None
991 if item in model.submodules:
992 path = core.abspath(item)
993 action = qtutils.add_action_with_icon(
994 menu,
995 icons.add(),
996 cmds.Stage.name(),
997 cmds.run(cmds.Stage, context, s.modified),
998 hotkeys.STAGE_SELECTION,
1000 add_action(action)
1002 action = qtutils.add_action_with_icon(
1003 menu,
1004 icons.cola(),
1005 N_('Launch git-cola'),
1006 cmds.run(cmds.OpenRepo, context, path),
1008 add_action(action)
1009 elif item not in model.unstaged_deleted:
1010 if self.has_selection():
1011 apply_text = N_('Stage Selected Lines')
1012 edit_and_apply_text = N_('Edit Selected Lines to Stage...')
1013 revert_text = N_('Revert Selected Lines...')
1014 edit_and_revert_text = N_('Edit Selected Lines to Revert...')
1015 else:
1016 apply_text = N_('Stage Diff Hunk')
1017 edit_and_apply_text = N_('Edit Diff Hunk to Stage...')
1018 revert_text = N_('Revert Diff Hunk...')
1019 edit_and_revert_text = N_('Edit Diff Hunk to Revert...')
1021 self.action_apply_selection.setText(apply_text)
1022 self.action_apply_selection.setIcon(icons.add())
1023 add_action(self.action_apply_selection)
1025 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1026 self.action_edit_and_apply_selection.setIcon(icons.add())
1027 add_action(self.action_edit_and_apply_selection)
1029 self.action_revert_selection.setText(revert_text)
1030 add_action(self.action_revert_selection)
1032 self.action_edit_and_revert_selection.setText(edit_and_revert_text)
1033 add_action(self.action_edit_and_revert_selection)
1035 if s.staged and model.is_unstageable():
1036 item = s.staged[0]
1037 if item in model.submodules:
1038 path = core.abspath(item)
1039 action = qtutils.add_action_with_icon(
1040 menu,
1041 icons.remove(),
1042 cmds.Unstage.name(),
1043 cmds.run(cmds.Unstage, context, s.staged),
1044 hotkeys.STAGE_SELECTION,
1046 add_action(action)
1048 qtutils.add_action_with_icon(
1049 menu,
1050 icons.cola(),
1051 N_('Launch git-cola'),
1052 cmds.run(cmds.OpenRepo, context, path),
1054 add_action(action)
1056 elif item not in model.staged_deleted:
1057 if self.has_selection():
1058 apply_text = N_('Unstage Selected Lines')
1059 edit_and_apply_text = N_('Edit Selected Lines to Unstage...')
1060 else:
1061 apply_text = N_('Unstage Diff Hunk')
1062 edit_and_apply_text = N_('Edit Diff Hunk to Unstage...')
1064 self.action_apply_selection.setText(apply_text)
1065 self.action_apply_selection.setIcon(icons.remove())
1066 add_action(self.action_apply_selection)
1068 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1069 self.action_edit_and_apply_selection.setIcon(icons.remove())
1070 add_action(self.action_edit_and_apply_selection)
1072 if model.is_stageable() or model.is_unstageable():
1073 # Do not show the "edit" action when the file does not exist.
1074 # Untracked files exist by definition.
1075 if filename and core.exists(filename):
1076 add_action(qtutils.menu_separator(menu))
1077 add_action(self.launch_editor)
1079 # Removed files can still be diffed.
1080 add_action(self.launch_difftool)
1082 add_action(qtutils.menu_separator(menu))
1083 _add_patch_actions(self, self.context, menu)
1085 # Add the Previous/Next File actions, which improves discoverability
1086 # of their associated shortcuts
1087 add_action(qtutils.menu_separator(menu))
1088 add_action(self.move_up)
1089 add_action(self.move_down)
1090 add_action(qtutils.menu_separator(menu))
1092 if current_actions:
1093 first_action = current_actions[0]
1094 else:
1095 first_action = None
1096 menu.insertActions(first_action, menu_actions)
1098 return menu
1100 def mousePressEvent(self, event):
1101 if event.button() == Qt.RightButton:
1102 # Intercept right-click to move the cursor to the current position.
1103 # setTextCursor() clears the selection so this is only done when
1104 # nothing is selected.
1105 if not self.has_selection():
1106 cursor = self.cursorForPosition(event.pos())
1107 self.setTextCursor(cursor)
1109 return super(DiffEditor, self).mousePressEvent(event)
1111 def setPlainText(self, text):
1112 """setPlainText(str) while retaining scrollbar positions"""
1113 model = self.model
1114 mode = model.mode
1115 highlight = mode not in (
1116 model.mode_none,
1117 model.mode_display,
1118 model.mode_untracked,
1120 self.highlighter.set_enabled(highlight)
1122 scrollbar = self.verticalScrollBar()
1123 if scrollbar:
1124 scrollvalue = get(scrollbar)
1125 else:
1126 scrollvalue = None
1128 if text is None:
1129 return
1131 DiffTextEdit.setPlainText(self, text)
1133 if scrollbar and scrollvalue is not None:
1134 scrollbar.setValue(scrollvalue)
1136 def apply_selection(self, *, edit=False):
1137 model = self.model
1138 s = self.selection_model.single_selection()
1139 if model.is_partially_stageable() and (s.modified or s.untracked):
1140 self.process_diff_selection(edit=edit)
1141 elif model.is_unstageable():
1142 self.process_diff_selection(reverse=True, edit=edit)
1144 def revert_selection(self, *, edit=False):
1145 """Destructively revert selected lines or hunk from a worktree file."""
1147 if not edit:
1148 if self.has_selection():
1149 title = N_('Revert Selected Lines?')
1150 ok_text = N_('Revert Selected Lines')
1151 else:
1152 title = N_('Revert Diff Hunk?')
1153 ok_text = N_('Revert Diff Hunk')
1155 if not Interaction.confirm(
1156 title,
1158 'This operation drops uncommitted changes.\n'
1159 'These changes cannot be recovered.'
1161 N_('Revert the uncommitted changes?'),
1162 ok_text,
1163 default=True,
1164 icon=icons.undo(),
1166 return
1167 self.process_diff_selection(reverse=True, apply_to_worktree=True, edit=edit)
1169 def extract_patch(self, reverse=False):
1170 first_line_idx, last_line_idx = self.selected_lines()
1171 patch = diffparse.Patch.parse(self.model.filename, self.model.diff_text)
1172 if self.has_selection():
1173 return patch.extract_subset(first_line_idx, last_line_idx, reverse=reverse)
1174 return patch.extract_hunk(first_line_idx, reverse=reverse)
1176 def patch_encoding(self):
1177 if isinstance(self.model.diff_text, core.UStr):
1178 # original encoding must prevail
1179 return self.model.diff_text.encoding
1180 return self.context.cfg.file_encoding(self.model.filename)
1182 def process_diff_selection(
1183 self, reverse=False, apply_to_worktree=False, edit=False
1185 """Implement un/staging of the selected line(s) or hunk."""
1186 if self.selection_model.is_empty():
1187 return
1188 patch = self.extract_patch(reverse)
1189 if not patch.has_changes():
1190 return
1191 patch_encoding = self.patch_encoding()
1193 if edit:
1194 patch = edit_patch(
1195 patch,
1196 patch_encoding,
1197 self.context,
1198 reverse=reverse,
1199 apply_to_worktree=apply_to_worktree,
1201 if not patch.has_changes():
1202 return
1204 cmds.do(
1205 cmds.ApplyPatch,
1206 self.context,
1207 patch,
1208 patch_encoding,
1209 apply_to_worktree,
1212 def _update_line_number(self):
1213 """Update the selection model when the cursor changes"""
1214 self.selection_model.line_number = self.numbers.current_line()
1217 def _add_patch_actions(widget, context, menu):
1218 """Add actions for manipulating patch files"""
1219 patches_menu = menu.addMenu(N_('Patches'))
1220 patches_menu.setIcon(icons.diff())
1221 export_action = qtutils.add_action(
1222 patches_menu,
1223 N_('Export Patch'),
1224 lambda: _export_patch(widget, context),
1226 export_action.setIcon(icons.save())
1227 patches_menu.addAction(export_action)
1229 # Build the "Append Patch" menu dynamically.
1230 append_menu = patches_menu.addMenu(N_('Append Patch'))
1231 append_menu.setIcon(icons.add())
1232 append_menu.aboutToShow.connect(
1233 lambda: _build_patch_append_menu(widget, context, append_menu)
1237 def _build_patch_append_menu(widget, context, menu):
1238 """Build the "Append Patch" submenu"""
1239 # Build the menu when first displayed only. This initial check avoids
1240 # re-populating the menu with duplicate actions.
1241 menu_actions = menu.actions()
1242 if menu_actions:
1243 return
1245 choose_patch_action = qtutils.add_action(
1246 menu,
1247 N_('Choose Patch...'),
1248 lambda: _export_patch(widget, context, append=True),
1250 choose_patch_action.setIcon(icons.diff())
1251 menu.addAction(choose_patch_action)
1253 subdir_menus = {}
1254 path = prefs.patches_directory(context)
1255 patches = patch_mod.get_patches_from_dir(path)
1256 for patch in patches:
1257 relpath = os.path.relpath(patch, start=path)
1258 sub_menu = _add_patch_subdirs(menu, subdir_menus, relpath)
1259 patch_basename = os.path.basename(relpath)
1260 append_action = qtutils.add_action(
1261 sub_menu,
1262 patch_basename,
1263 lambda patch_file=patch: _append_patch(widget, patch_file),
1265 append_action.setIcon(icons.save())
1266 sub_menu.addAction(append_action)
1269 def _add_patch_subdirs(menu, subdir_menus, relpath):
1270 """Build menu leading up to the patch"""
1271 # If the path contains no directory separators then add it to the
1272 # root of the menu.
1273 if os.sep not in relpath:
1274 return menu
1276 # Loop over each directory component and build a menu if it doesn't already exist.
1277 components = []
1278 for dirname in os.path.dirname(relpath).split(os.sep):
1279 components.append(dirname)
1280 current_dir = os.sep.join(components)
1281 try:
1282 menu = subdir_menus[current_dir]
1283 except KeyError:
1284 menu = subdir_menus[current_dir] = menu.addMenu(dirname)
1285 menu.setIcon(icons.folder())
1287 return menu
1290 def _export_patch(diff_editor, context, append=False):
1291 """Export the selected diff to a patch file"""
1292 if diff_editor.selection_model.is_empty():
1293 return
1294 patch = diff_editor.extract_patch(reverse=False)
1295 if not patch.has_changes():
1296 return
1297 directory = prefs.patches_directory(context)
1298 if append:
1299 filename = qtutils.existing_file(directory, title=N_('Append Patch...'))
1300 else:
1301 default_filename = os.path.join(directory, 'diff.patch')
1302 filename = qtutils.save_as(default_filename)
1303 if not filename:
1304 return
1305 _write_patch_to_file(diff_editor, patch, filename, append=append)
1308 def _append_patch(diff_editor, filename):
1309 """Append diffs to the specified patch file"""
1310 if diff_editor.selection_model.is_empty():
1311 return
1312 patch = diff_editor.extract_patch(reverse=False)
1313 if not patch.has_changes():
1314 return
1315 _write_patch_to_file(diff_editor, patch, filename, append=True)
1318 def _write_patch_to_file(diff_editor, patch, filename, append=False):
1319 """Write diffs from the Diff Editor to the specified patch file"""
1320 encoding = diff_editor.patch_encoding()
1321 content = patch.as_text()
1322 try:
1323 core.write(filename, content, encoding=encoding, append=append)
1324 except (IOError, OSError) as exc:
1325 _, details = utils.format_exception(exc)
1326 title = N_('Error writing patch')
1327 msg = N_('Unable to write patch to "%s". Check permissions?' % filename)
1328 Interaction.critical(title, message=msg, details=details)
1329 return
1330 Interaction.log('Patch written to "%s"' % filename)
1333 class DiffWidget(QtWidgets.QWidget):
1334 """Display commit metadata and text diffs"""
1336 def __init__(self, context, parent, is_commit=False, options=None):
1337 QtWidgets.QWidget.__init__(self, parent)
1339 self.context = context
1340 self.oid = 'HEAD'
1341 self.oid_start = None
1342 self.oid_end = None
1343 self.options = options
1345 author_font = QtGui.QFont(self.font())
1346 author_font.setPointSize(int(author_font.pointSize() * 1.1))
1348 summary_font = QtGui.QFont(author_font)
1349 summary_font.setWeight(QtGui.QFont.Bold)
1351 policy = QtWidgets.QSizePolicy(
1352 QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Minimum
1355 self.gravatar_label = gravatar.GravatarLabel(self.context, parent=self)
1357 self.oid_label = TextLabel()
1358 self.oid_label.setTextFormat(Qt.PlainText)
1359 self.oid_label.setSizePolicy(policy)
1360 self.oid_label.setAlignment(Qt.AlignBottom)
1361 self.oid_label.elide()
1363 self.author_label = TextLabel()
1364 self.author_label.setTextFormat(Qt.RichText)
1365 self.author_label.setFont(author_font)
1366 self.author_label.setSizePolicy(policy)
1367 self.author_label.setAlignment(Qt.AlignTop)
1368 self.author_label.elide()
1370 self.date_label = TextLabel()
1371 self.date_label.setTextFormat(Qt.PlainText)
1372 self.date_label.setSizePolicy(policy)
1373 self.date_label.setAlignment(Qt.AlignTop)
1374 self.date_label.elide()
1376 self.summary_label = TextLabel()
1377 self.summary_label.setTextFormat(Qt.PlainText)
1378 self.summary_label.setFont(summary_font)
1379 self.summary_label.setSizePolicy(policy)
1380 self.summary_label.setAlignment(Qt.AlignTop)
1381 self.summary_label.elide()
1383 self.diff = DiffTextEdit(context, self, is_commit=is_commit, whitespace=False)
1384 self.setFocusProxy(self.diff)
1386 self.info_layout = qtutils.vbox(
1387 defs.no_margin,
1388 defs.no_spacing,
1389 self.oid_label,
1390 self.author_label,
1391 self.date_label,
1392 self.summary_label,
1395 self.logo_layout = qtutils.hbox(
1396 defs.no_margin, defs.button_spacing, self.gravatar_label, self.info_layout
1398 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
1400 self.main_layout = qtutils.vbox(
1401 defs.no_margin, defs.spacing, self.logo_layout, self.diff
1403 self.setLayout(self.main_layout)
1405 self.set_tabwidth(prefs.tabwidth(context))
1407 def set_tabwidth(self, width):
1408 self.diff.set_tabwidth(width)
1410 def set_word_wrapping(self, enabled, update=False):
1411 """Enable and disable word wrapping"""
1412 self.diff.set_word_wrapping(enabled, update=update)
1414 def set_options(self, options):
1415 """Register an options widget"""
1416 self.options = options
1417 self.diff.set_options(options)
1419 def start_diff_task(self, task):
1420 """Clear the display and start a diff-gathering task"""
1421 self.diff.save_scrollbar()
1422 self.diff.set_loading_message()
1423 self.context.runtask.start(task, result=self.set_diff)
1425 def set_diff_oid(self, oid, filename=None):
1426 """Set the diff from a single commit object ID"""
1427 task = DiffInfoTask(self.context, oid, filename)
1428 self.start_diff_task(task)
1430 def set_diff_range(self, start, end, filename=None):
1431 task = DiffRangeTask(self.context, start + '~', end, filename)
1432 self.start_diff_task(task)
1434 def commits_selected(self, commits):
1435 """Display an appropriate diff when commits are selected"""
1436 if not commits:
1437 self.clear()
1438 return
1439 commit = commits[-1]
1440 oid = commit.oid
1441 author = commit.author or ''
1442 email = commit.email or ''
1443 date = commit.authdate or ''
1444 summary = commit.summary or ''
1445 self.set_details(oid, author, email, date, summary)
1446 self.oid = oid
1448 if len(commits) > 1:
1449 start, end = commits[0], commits[-1]
1450 self.set_diff_range(start.oid, end.oid)
1451 self.oid_start = start
1452 self.oid_end = end
1453 else:
1454 self.set_diff_oid(oid)
1455 self.oid_start = None
1456 self.oid_end = None
1458 def set_diff(self, diff):
1459 """Set the diff text"""
1460 self.diff.set_diff(diff)
1462 def set_details(self, oid, author, email, date, summary):
1463 template_args = {'author': author, 'email': email, 'summary': summary}
1464 author_text = (
1465 """%(author)s &lt;"""
1466 """<a href="mailto:%(email)s">"""
1467 """%(email)s</a>&gt;""" % template_args
1469 author_template = '%(author)s <%(email)s>' % template_args
1471 self.date_label.set_text(date)
1472 self.date_label.setVisible(bool(date))
1473 self.oid_label.set_text(oid)
1474 self.author_label.set_template(author_text, author_template)
1475 self.summary_label.set_text(summary)
1476 self.gravatar_label.set_email(email)
1478 def clear(self):
1479 self.date_label.set_text('')
1480 self.oid_label.set_text('')
1481 self.author_label.set_text('')
1482 self.summary_label.set_text('')
1483 self.gravatar_label.clear()
1484 self.diff.clear()
1486 def files_selected(self, filenames):
1487 """Update the view when a filename is selected"""
1488 if not filenames:
1489 return
1490 oid_start = self.oid_start
1491 oid_end = self.oid_end
1492 if oid_start and oid_end:
1493 self.set_diff_range(oid_start.oid, oid_end.oid, filename=filenames[0])
1494 else:
1495 self.set_diff_oid(self.oid, filename=filenames[0])
1498 class DiffPanel(QtWidgets.QWidget):
1499 """A combined diff + search panel"""
1501 def __init__(self, diff_widget, text_widget, parent):
1502 super(DiffPanel, self).__init__(parent)
1503 self.diff_widget = diff_widget
1504 self.search_widget = TextSearchWidget(text_widget, self)
1505 self.search_widget.hide()
1506 layout = qtutils.vbox(
1507 defs.no_margin, defs.spacing, self.diff_widget, self.search_widget
1509 self.setLayout(layout)
1510 self.setFocusProxy(self.diff_widget)
1512 self.search_action = qtutils.add_action(
1513 self,
1514 N_('Search in Diff'),
1515 self.show_search,
1516 hotkeys.SEARCH,
1519 def show_search(self):
1520 """Show a dialog for searching diffs"""
1521 # The diff search is only active in text mode.
1522 if not self.search_widget.isVisible():
1523 self.search_widget.show()
1524 self.search_widget.setFocus(True)
1527 class TextLabel(QtWidgets.QLabel):
1528 def __init__(self, parent=None):
1529 QtWidgets.QLabel.__init__(self, parent)
1530 self.setTextInteractionFlags(
1531 Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse
1533 self._display = ''
1534 self._template = ''
1535 self._text = ''
1536 self._elide = False
1537 self._metrics = QtGui.QFontMetrics(self.font())
1538 self.setOpenExternalLinks(True)
1540 def elide(self):
1541 self._elide = True
1543 def set_text(self, text):
1544 self.set_template(text, text)
1546 def set_template(self, text, template):
1547 self._display = text
1548 self._text = text
1549 self._template = template
1550 self.update_text(self.width())
1551 self.setText(self._display)
1553 def update_text(self, width):
1554 self._display = self._text
1555 if not self._elide:
1556 return
1557 text = self._metrics.elidedText(self._template, Qt.ElideRight, width - 2)
1558 if text != self._template:
1559 self._display = text
1561 # Qt overrides
1562 def setFont(self, font):
1563 self._metrics = QtGui.QFontMetrics(font)
1564 QtWidgets.QLabel.setFont(self, font)
1566 def resizeEvent(self, event):
1567 if self._elide:
1568 self.update_text(event.size().width())
1569 with qtutils.BlockSignals(self):
1570 self.setText(self._display)
1571 QtWidgets.QLabel.resizeEvent(self, event)
1574 class DiffInfoTask(qtutils.Task):
1575 """Gather diffs for a single commit"""
1577 def __init__(self, context, oid, filename):
1578 qtutils.Task.__init__(self)
1579 self.context = context
1580 self.oid = oid
1581 self.filename = filename
1583 def task(self):
1584 context = self.context
1585 oid = self.oid
1586 return gitcmds.diff_info(context, oid, filename=self.filename)
1589 class DiffRangeTask(qtutils.Task):
1590 """Gather diffs for a range of commits"""
1592 def __init__(self, context, start, end, filename):
1593 qtutils.Task.__init__(self)
1594 self.context = context
1595 self.start = start
1596 self.end = end
1597 self.filename = filename
1599 def task(self):
1600 context = self.context
1601 return gitcmds.diff_range(context, self.start, self.end, filename=self.filename)