requirements: install newer versions of send2trash
[git-cola.git] / cola / widgets / diff.py
blobdf98afb92e17f9c043e13e6e1fb183cc24c584ef
1 from functools import partial
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 ..editpatch import edit_patch
13 from ..interaction import Interaction
14 from ..models import main
15 from ..models import prefs
16 from ..qtutils import get
17 from .. import actions
18 from .. import cmds
19 from .. import core
20 from .. import diffparse
21 from .. import gitcmds
22 from .. import gravatar
23 from .. import hotkeys
24 from .. import icons
25 from .. import utils
26 from .. import qtutils
27 from .text import TextDecorator
28 from .text import VimHintedPlainTextEdit
29 from .text import TextSearchWidget
30 from . import defs
31 from . import standard
32 from . import imageview
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(
48 r'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)'
50 BAD_WHITESPACE_RGX = re.compile(r'\s+$')
52 def __init__(self, context, doc, whitespace=True, is_commit=False):
53 QtGui.QSyntaxHighlighter.__init__(self, doc)
54 self.whitespace = whitespace
55 self.enabled = True
56 self.is_commit = is_commit
58 QPalette = QtGui.QPalette
59 cfg = context.cfg
60 palette = QPalette()
61 disabled = palette.color(QPalette.Disabled, QPalette.Text)
62 header = qtutils.rgb_hex(disabled)
64 dark = palette.color(QPalette.Base).lightnessF() < 0.5
66 self.color_text = qtutils.rgb_triple(cfg.color('text', '030303'))
67 self.color_add = qtutils.rgb_triple(
68 cfg.color('add', '77aa77' if dark else 'd2ffe4')
70 self.color_remove = qtutils.rgb_triple(
71 cfg.color('remove', 'aa7777' if dark else 'fee0e4')
73 self.color_header = qtutils.rgb_triple(cfg.color('header', header))
75 self.diff_header_fmt = qtutils.make_format(foreground=self.color_header)
76 self.bold_diff_header_fmt = qtutils.make_format(
77 foreground=self.color_header, bold=True
80 self.diff_add_fmt = qtutils.make_format(
81 foreground=self.color_text, background=self.color_add
83 self.diff_remove_fmt = qtutils.make_format(
84 foreground=self.color_text, background=self.color_remove
86 self.bad_whitespace_fmt = qtutils.make_format(background=Qt.red)
87 self.setCurrentBlockState(self.INITIAL_STATE)
89 def set_enabled(self, enabled):
90 self.enabled = enabled
92 def highlightBlock(self, text):
93 """Highlight the current text block"""
94 if not self.enabled or not text:
95 return
96 formats = []
97 state = self.get_next_state(text)
98 if state == self.DIFFSTAT_STATE:
99 state, formats = self.get_formats_for_diffstat(state, text)
100 elif state == self.DIFF_FILE_HEADER_STATE:
101 state, formats = self.get_formats_for_diff_header(state, text)
102 elif state == self.DIFF_STATE:
103 state, formats = self.get_formats_for_diff_text(state, text)
105 for start, end, fmt in formats:
106 self.setFormat(start, end, fmt)
108 self.setCurrentBlockState(state)
110 def get_next_state(self, text):
111 """Transition to the next state based on the input text"""
112 state = self.previousBlockState()
113 if state == DiffSyntaxHighlighter.INITIAL_STATE:
114 if text.startswith('Submodule '):
115 state = DiffSyntaxHighlighter.SUBMODULE_STATE
116 elif text.startswith('diff --git '):
117 state = DiffSyntaxHighlighter.DIFFSTAT_STATE
118 elif self.is_commit:
119 state = DiffSyntaxHighlighter.DEFAULT_STATE
120 else:
121 state = DiffSyntaxHighlighter.DIFFSTAT_STATE
123 return state
125 def get_formats_for_diffstat(self, state, text):
126 """Returns (state, [(start, end, fmt), ...]) for highlighting diffstat text"""
127 formats = []
128 if self.DIFF_FILE_HEADER_START_RGX.match(text):
129 state = self.DIFF_FILE_HEADER_STATE
130 end = len(text)
131 fmt = self.diff_header_fmt
132 formats.append((0, end, fmt))
133 elif self.DIFF_HUNK_HEADER_RGX.match(text):
134 state = self.DIFF_STATE
135 end = len(text)
136 fmt = self.bold_diff_header_fmt
137 formats.append((0, end, fmt))
138 elif '|' in text:
139 offset = text.index('|')
140 formats.append((0, offset, self.bold_diff_header_fmt))
141 formats.append((offset, len(text) - offset, self.diff_header_fmt))
142 else:
143 formats.append((0, len(text), self.diff_header_fmt))
145 return state, formats
147 def get_formats_for_diff_header(self, state, text):
148 """Returns (state, [(start, end, fmt), ...]) for highlighting diff headers"""
149 formats = []
150 if self.DIFF_HUNK_HEADER_RGX.match(text):
151 state = self.DIFF_STATE
152 formats.append((0, len(text), self.bold_diff_header_fmt))
153 else:
154 formats.append((0, len(text), self.diff_header_fmt))
156 return state, formats
158 def get_formats_for_diff_text(self, state, text):
159 """Return (state, [(start, end fmt), ...]) for highlighting diff text"""
160 formats = []
162 if self.DIFF_FILE_HEADER_START_RGX.match(text):
163 state = self.DIFF_FILE_HEADER_STATE
164 formats.append((0, len(text), self.diff_header_fmt))
166 elif self.DIFF_HUNK_HEADER_RGX.match(text):
167 formats.append((0, len(text), self.bold_diff_header_fmt))
169 elif text.startswith('-'):
170 if text == '-- ':
171 state = self.END_STATE
172 else:
173 formats.append((0, len(text), self.diff_remove_fmt))
175 elif text.startswith('+'):
176 formats.append((0, len(text), self.diff_add_fmt))
177 if self.whitespace:
178 match = self.BAD_WHITESPACE_RGX.search(text)
179 if match is not None:
180 start = match.start()
181 formats.append((start, len(text) - start, self.bad_whitespace_fmt))
183 return state, formats
186 # pylint: disable=too-many-ancestors
187 class DiffTextEdit(VimHintedPlainTextEdit):
188 """A textedit for interacting with diff text"""
190 def __init__(
191 self, context, parent, is_commit=False, whitespace=True, numbers=False
193 VimHintedPlainTextEdit.__init__(self, context, '', parent=parent)
194 # Diff/patch syntax highlighter
195 self.highlighter = DiffSyntaxHighlighter(
196 context, self.document(), is_commit=is_commit, whitespace=whitespace
198 if numbers:
199 self.numbers = DiffLineNumbers(context, self)
200 self.numbers.hide()
201 else:
202 self.numbers = None
203 self.scrollvalue = None
205 self.copy_diff_action = qtutils.add_action(
206 self,
207 N_('Copy Diff'),
208 self.copy_diff,
209 hotkeys.COPY_DIFF,
211 self.copy_diff_action.setIcon(icons.copy())
212 self.copy_diff_action.setEnabled(False)
213 self.menu_actions.append(self.copy_diff_action)
215 # pylint: disable=no-member
216 self.cursorPositionChanged.connect(self._cursor_changed)
217 self.selectionChanged.connect(self._selection_changed)
219 def setFont(self, font):
220 """Override setFont() so that we can use a custom "block" cursor"""
221 super().setFont(font)
222 if prefs.block_cursor(self.context):
223 metrics = QtGui.QFontMetrics(font)
224 width = metrics.width('M')
225 self.setCursorWidth(width)
227 def _cursor_changed(self):
228 """Update the line number display when the cursor changes"""
229 line_number = max(0, self.textCursor().blockNumber())
230 if self.numbers is not None:
231 self.numbers.set_highlighted(line_number)
233 def _selection_changed(self):
234 """Respond to selection changes"""
235 selected = bool(self.selected_text())
236 self.copy_diff_action.setEnabled(selected)
238 def resizeEvent(self, event):
239 super().resizeEvent(event)
240 if self.numbers:
241 self.numbers.refresh_size()
243 def save_scrollbar(self):
244 """Save the scrollbar value, but only on the first call"""
245 if self.scrollvalue is None:
246 scrollbar = self.verticalScrollBar()
247 if scrollbar:
248 scrollvalue = get(scrollbar)
249 else:
250 scrollvalue = None
251 self.scrollvalue = scrollvalue
253 def restore_scrollbar(self):
254 """Restore the scrollbar and clear state"""
255 scrollbar = self.verticalScrollBar()
256 scrollvalue = self.scrollvalue
257 if scrollbar and scrollvalue is not None:
258 scrollbar.setValue(scrollvalue)
259 self.scrollvalue = None
261 def set_loading_message(self):
262 """Add a pending loading message in the diff view"""
263 self.hint.set_value('+++ ' + N_('Loading...'))
264 self.set_value('')
266 def set_diff(self, diff):
267 """Set the diff text, but save the scrollbar"""
268 diff = diff.rstrip('\n') # diffs include two empty newlines
269 self.save_scrollbar()
271 self.hint.set_value('')
272 if self.numbers:
273 self.numbers.set_diff(diff)
274 self.set_value(diff)
276 self.restore_scrollbar()
278 def selected_diff_stripped(self):
279 """Return the selected diff stripped of any diff characters"""
280 sep, selection = self.selected_text_lines()
281 return sep.join(_strip_diff(line) for line in selection)
283 def copy_diff(self):
284 """Copy the selected diff text stripped of any diff prefix characters"""
285 text = self.selected_diff_stripped()
286 qtutils.set_clipboard(text)
288 def selected_lines(self):
289 """Return selected lines"""
290 cursor = self.textCursor()
291 selection_start = cursor.selectionStart()
292 selection_end = max(selection_start, cursor.selectionEnd() - 1)
294 first_line_idx = -1
295 last_line_idx = -1
296 line_idx = 0
297 line_start = 0
299 for line_idx, line in enumerate(get(self, default='').splitlines()):
300 line_end = line_start + len(line)
301 if line_start <= selection_start <= line_end:
302 first_line_idx = line_idx
303 if line_start <= selection_end <= line_end:
304 last_line_idx = line_idx
305 break
306 line_start = line_end + 1
308 if first_line_idx == -1:
309 first_line_idx = line_idx
311 if last_line_idx == -1:
312 last_line_idx = line_idx
314 return first_line_idx, last_line_idx
316 def selected_text_lines(self):
317 """Return selected lines and the CRLF / LF separator"""
318 first_line_idx, last_line_idx = self.selected_lines()
319 text = get(self, default='')
320 sep = _get_sep(text)
321 lines = []
322 for line_idx, line in enumerate(text.split(sep)):
323 if first_line_idx <= line_idx <= last_line_idx:
324 lines.append(line)
325 return sep, lines
328 def _get_sep(text):
329 """Return either CRLF or LF based on the content"""
330 if '\r\n' in text:
331 sep = '\r\n'
332 else:
333 sep = '\n'
334 return sep
337 def _strip_diff(value):
338 """Remove +/-/<space> from a selection"""
339 if value.startswith(('+', '-', ' ')):
340 return value[1:]
341 return value
344 class DiffLineNumbers(TextDecorator):
345 def __init__(self, context, parent):
346 TextDecorator.__init__(self, parent)
347 self.highlight_line = -1
348 self.lines = None
349 self.parser = diffparse.DiffLines()
350 self.formatter = diffparse.FormatDigits()
352 self.setFont(qtutils.diff_font(context))
353 self._char_width = self.fontMetrics().width('0')
355 QPalette = QtGui.QPalette
356 self._palette = palette = self.palette()
357 self._base = palette.color(QtGui.QPalette.Base)
358 self._highlight = palette.color(QPalette.Highlight)
359 self._highlight.setAlphaF(0.3)
360 self._highlight_text = palette.color(QPalette.HighlightedText)
361 self._window = palette.color(QPalette.Window)
362 self._disabled = palette.color(QPalette.Disabled, QPalette.Text)
364 def set_diff(self, diff):
365 self.lines = self.parser.parse(diff)
366 self.formatter.set_digits(self.parser.digits())
368 def width_hint(self):
369 if not self.isVisible():
370 return 0
371 parser = self.parser
373 if parser.merge:
374 columns = 3
375 extra = 3 # one space in-between, one space after
376 else:
377 columns = 2
378 extra = 2 # one space in-between, one space after
380 digits = parser.digits() * columns
382 return defs.margin + (self._char_width * (digits + extra))
384 def set_highlighted(self, line_number):
385 """Set the line to highlight"""
386 self.highlight_line = line_number
388 def current_line(self):
389 lines = self.lines
390 if lines and self.highlight_line >= 0:
391 # Find the next valid line
392 for i in range(self.highlight_line, len(lines)):
393 # take the "new" line number: last value in tuple
394 line_number = lines[i][-1]
395 if line_number > 0:
396 return line_number
398 # Find the previous valid line
399 for i in range(self.highlight_line - 1, -1, -1):
400 # take the "new" line number: last value in tuple
401 if i < len(lines):
402 line_number = lines[i][-1]
403 if line_number > 0:
404 return line_number
405 return None
407 def paintEvent(self, event):
408 """Paint the line number"""
409 if not self.lines:
410 return
412 painter = QtGui.QPainter(self)
413 painter.fillRect(event.rect(), self._base)
415 editor = self.editor
416 content_offset = editor.contentOffset()
417 block = editor.firstVisibleBlock()
418 width = self.width()
419 text_width = width - (defs.margin * 2)
420 text_flags = Qt.AlignRight | Qt.AlignVCenter
421 event_rect_bottom = event.rect().bottom()
423 highlight_line = self.highlight_line
424 highlight = self._highlight
425 highlight_text = self._highlight_text
426 disabled = self._disabled
428 fmt = self.formatter
429 lines = self.lines
430 num_lines = len(lines)
432 while block.isValid():
433 block_number = block.blockNumber()
434 if block_number >= num_lines:
435 break
436 block_geom = editor.blockBoundingGeometry(block)
437 rect = block_geom.translated(content_offset).toRect()
438 if not block.isVisible() or rect.top() >= event_rect_bottom:
439 break
441 if block_number == highlight_line:
442 painter.fillRect(rect.x(), rect.y(), width, rect.height(), highlight)
443 painter.setPen(highlight_text)
444 else:
445 painter.setPen(disabled)
447 line = lines[block_number]
448 if len(line) == 2:
449 a, b = line
450 text = fmt.value(a, b)
451 elif len(line) == 3:
452 old, base, new = line
453 text = fmt.merge_value(old, base, new)
455 painter.drawText(
456 rect.x(),
457 rect.y(),
458 text_width,
459 rect.height(),
460 text_flags,
461 text,
464 block = block.next()
467 class Viewer(QtWidgets.QFrame):
468 """Text and image diff viewers"""
470 INDEX_TEXT = 0
471 INDEX_IMAGE = 1
473 def __init__(self, context, parent=None):
474 super().__init__(parent)
476 self.context = context
477 self.model = model = context.model
478 self.images = []
479 self.pixmaps = []
480 self.options = options = Options(self)
481 self.text = DiffEditor(context, options, self)
482 self.image = imageview.ImageView(parent=self)
483 self.image.setFocusPolicy(Qt.NoFocus)
484 self.search_widget = TextSearchWidget(self.text, self)
485 self.search_widget.hide()
486 self._drag_has_patches = False
488 self.setAcceptDrops(True)
489 self.setFocusProxy(self.text)
491 stack = self.stack = QtWidgets.QStackedWidget(self)
492 stack.addWidget(self.text)
493 stack.addWidget(self.image)
495 self.main_layout = qtutils.vbox(
496 defs.no_margin,
497 defs.no_spacing,
498 self.stack,
499 self.search_widget,
501 self.setLayout(self.main_layout)
503 # Observe images
504 model.images_changed.connect(self.set_images, type=Qt.QueuedConnection)
506 # Observe the diff type
507 model.diff_type_changed.connect(self.set_diff_type, type=Qt.QueuedConnection)
509 # Observe the file type
510 model.file_type_changed.connect(self.set_file_type, type=Qt.QueuedConnection)
512 # Observe the image mode combo box
513 options.image_mode.currentIndexChanged.connect(lambda _: self.render())
514 options.zoom_mode.currentIndexChanged.connect(lambda _: self.render())
516 self.search_action = qtutils.add_action(
517 self,
518 N_('Search in Diff'),
519 self.show_search_diff,
520 hotkeys.SEARCH,
523 def dragEnterEvent(self, event):
524 """Accepts drops if the mimedata contains patches"""
525 super().dragEnterEvent(event)
526 patches = get_patches_from_mimedata(event.mimeData())
527 if patches:
528 event.acceptProposedAction()
529 self._drag_has_patches = True
531 def dragLeaveEvent(self, event):
532 """End the drag+drop interaction"""
533 super().dragLeaveEvent(event)
534 if self._drag_has_patches:
535 event.accept()
536 else:
537 event.ignore()
538 self._drag_has_patches = False
540 def dropEvent(self, event):
541 """Apply patches when dropped onto the widget"""
542 if not self._drag_has_patches:
543 event.ignore()
544 return
545 event.setDropAction(Qt.CopyAction)
546 super().dropEvent(event)
547 self._drag_has_patches = False
549 patches = get_patches_from_mimedata(event.mimeData())
550 if patches:
551 apply_patches(self.context, patches=patches)
553 event.accept() # must be called after dropEvent()
555 def show_search_diff(self):
556 """Show a dialog for searching diffs"""
557 # The diff search is only active in text mode.
558 if self.stack.currentIndex() != self.INDEX_TEXT:
559 return
560 if not self.search_widget.isVisible():
561 self.search_widget.show()
562 self.search_widget.setFocus()
564 def export_state(self, state):
565 state['show_diff_line_numbers'] = self.options.show_line_numbers.isChecked()
566 state['image_diff_mode'] = self.options.image_mode.currentIndex()
567 state['image_zoom_mode'] = self.options.zoom_mode.currentIndex()
568 state['word_wrap'] = self.options.enable_word_wrapping.isChecked()
569 return state
571 def apply_state(self, state):
572 diff_numbers = bool(state.get('show_diff_line_numbers', False))
573 self.set_line_numbers(diff_numbers, update=True)
575 image_mode = utils.asint(state.get('image_diff_mode', 0))
576 self.options.image_mode.set_index(image_mode)
578 zoom_mode = utils.asint(state.get('image_zoom_mode', 0))
579 self.options.zoom_mode.set_index(zoom_mode)
581 word_wrap = bool(state.get('word_wrap', True))
582 self.set_word_wrapping(word_wrap, update=True)
583 return True
585 def set_diff_type(self, diff_type):
586 """Manage the image and text diff views when selection changes"""
587 # The "diff type" is whether the diff viewer is displaying an image.
588 self.options.set_diff_type(diff_type)
589 if diff_type == main.Types.IMAGE:
590 self.stack.setCurrentWidget(self.image)
591 self.search_widget.hide()
592 self.render()
593 else:
594 self.stack.setCurrentWidget(self.text)
596 def set_file_type(self, file_type):
597 """Manage the diff options when the file type changes"""
598 # The "file type" is whether the file itself is an image.
599 self.options.set_file_type(file_type)
601 def update_options(self):
602 """Emit a signal indicating that options have changed"""
603 self.text.update_options()
605 def set_line_numbers(self, enabled, update=False):
606 """Enable/disable line numbers in the text widget"""
607 self.text.set_line_numbers(enabled, update=update)
609 def set_word_wrapping(self, enabled, update=False):
610 """Enable/disable word wrapping in the text widget"""
611 self.text.set_word_wrapping(enabled, update=update)
613 def reset(self):
614 self.image.pixmap = QtGui.QPixmap()
615 self.cleanup()
617 def cleanup(self):
618 for image, unlink in self.images:
619 if unlink and core.exists(image):
620 os.unlink(image)
621 self.images = []
623 def set_images(self, images):
624 self.images = images
625 self.pixmaps = []
626 if not images:
627 self.reset()
628 return False
630 # In order to comp, we first have to load all the images
631 all_pixmaps = [QtGui.QPixmap(image[0]) for image in images]
632 pixmaps = [pixmap for pixmap in all_pixmaps if not pixmap.isNull()]
633 if not pixmaps:
634 self.reset()
635 return False
637 self.pixmaps = pixmaps
638 self.render()
639 self.cleanup()
640 return True
642 def render(self):
643 # Update images
644 if self.pixmaps:
645 mode = self.options.image_mode.currentIndex()
646 if mode == self.options.SIDE_BY_SIDE:
647 image = self.render_side_by_side()
648 elif mode == self.options.DIFF:
649 image = self.render_diff()
650 elif mode == self.options.XOR:
651 image = self.render_xor()
652 elif mode == self.options.PIXEL_XOR:
653 image = self.render_pixel_xor()
654 else:
655 image = self.render_side_by_side()
656 else:
657 image = QtGui.QPixmap()
658 self.image.pixmap = image
660 # Apply zoom
661 zoom_mode = self.options.zoom_mode.currentIndex()
662 zoom_factor = self.options.zoom_factors[zoom_mode][1]
663 if zoom_factor > 0.0:
664 self.image.resetTransform()
665 self.image.scale(zoom_factor, zoom_factor)
666 poly = self.image.mapToScene(self.image.viewport().rect())
667 self.image.last_scene_roi = poly.boundingRect()
669 def render_side_by_side(self):
670 # Side-by-side lineup comp
671 pixmaps = self.pixmaps
672 width = sum(pixmap.width() for pixmap in pixmaps)
673 height = max(pixmap.height() for pixmap in pixmaps)
674 image = create_image(width, height)
676 # Paint each pixmap
677 painter = create_painter(image)
678 x = 0
679 for pixmap in pixmaps:
680 painter.drawPixmap(x, 0, pixmap)
681 x += pixmap.width()
682 painter.end()
684 return image
686 def render_comp(self, comp_mode):
687 # Get the max size to use as the render canvas
688 pixmaps = self.pixmaps
689 if len(pixmaps) == 1:
690 return pixmaps[0]
692 width = max(pixmap.width() for pixmap in pixmaps)
693 height = max(pixmap.height() for pixmap in pixmaps)
694 image = create_image(width, height)
696 painter = create_painter(image)
697 for pixmap in (pixmaps[0], pixmaps[-1]):
698 x = (width - pixmap.width()) // 2
699 y = (height - pixmap.height()) // 2
700 painter.drawPixmap(x, y, pixmap)
701 painter.setCompositionMode(comp_mode)
702 painter.end()
704 return image
706 def render_diff(self):
707 comp_mode = QtGui.QPainter.CompositionMode_Difference
708 return self.render_comp(comp_mode)
710 def render_xor(self):
711 comp_mode = QtGui.QPainter.CompositionMode_Xor
712 return self.render_comp(comp_mode)
714 def render_pixel_xor(self):
715 comp_mode = QtGui.QPainter.RasterOp_SourceXorDestination
716 return self.render_comp(comp_mode)
719 def create_image(width, height):
720 size = QtCore.QSize(width, height)
721 image = QtGui.QImage(size, QtGui.QImage.Format_ARGB32_Premultiplied)
722 image.fill(Qt.transparent)
723 return image
726 def create_painter(image):
727 painter = QtGui.QPainter(image)
728 painter.fillRect(image.rect(), Qt.transparent)
729 return painter
732 class Options(QtWidgets.QWidget):
733 """Provide the options widget used by the editor
735 Actions are registered on the parent widget.
739 # mode combobox indexes
740 SIDE_BY_SIDE = 0
741 DIFF = 1
742 XOR = 2
743 PIXEL_XOR = 3
745 def __init__(self, parent):
746 super().__init__(parent)
747 # Create widgets
748 self.widget = parent
749 self.ignore_space_at_eol = self.add_option(
750 N_('Ignore changes in whitespace at EOL')
753 self.ignore_space_change = self.add_option(
754 N_('Ignore changes in amount of whitespace')
757 self.ignore_all_space = self.add_option(N_('Ignore all whitespace'))
759 self.function_context = self.add_option(
760 N_('Show whole surrounding functions of changes')
763 self.show_line_numbers = qtutils.add_action_bool(
764 self, N_('Show line numbers'), self.set_line_numbers, True
766 self.enable_word_wrapping = qtutils.add_action_bool(
767 self, N_('Enable word wrapping'), self.set_word_wrapping, True
770 self.options = qtutils.create_action_button(
771 tooltip=N_('Diff Options'), icon=icons.configure()
774 self.toggle_image_diff = qtutils.create_action_button(
775 tooltip=N_('Toggle image diff'), icon=icons.visualize()
777 self.toggle_image_diff.hide()
779 self.image_mode = qtutils.combo(
780 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
783 self.zoom_factors = (
784 (N_('Zoom to Fit'), 0.0),
785 (N_('25%'), 0.25),
786 (N_('50%'), 0.5),
787 (N_('100%'), 1.0),
788 (N_('200%'), 2.0),
789 (N_('400%'), 4.0),
790 (N_('800%'), 8.0),
792 zoom_modes = [factor[0] for factor in self.zoom_factors]
793 self.zoom_mode = qtutils.combo(zoom_modes, parent=self)
795 self.menu = menu = qtutils.create_menu(N_('Diff Options'), self.options)
796 self.options.setMenu(menu)
797 menu.addAction(self.ignore_space_at_eol)
798 menu.addAction(self.ignore_space_change)
799 menu.addAction(self.ignore_all_space)
800 menu.addSeparator()
801 menu.addAction(self.function_context)
802 menu.addAction(self.show_line_numbers)
803 menu.addSeparator()
804 menu.addAction(self.enable_word_wrapping)
806 # Layouts
807 layout = qtutils.hbox(
808 defs.no_margin,
809 defs.button_spacing,
810 self.image_mode,
811 self.zoom_mode,
812 self.options,
813 self.toggle_image_diff,
815 self.setLayout(layout)
817 # Policies
818 self.image_mode.setFocusPolicy(Qt.NoFocus)
819 self.zoom_mode.setFocusPolicy(Qt.NoFocus)
820 self.options.setFocusPolicy(Qt.NoFocus)
821 self.toggle_image_diff.setFocusPolicy(Qt.NoFocus)
822 self.setFocusPolicy(Qt.NoFocus)
824 def set_file_type(self, file_type):
825 """Set whether we are viewing an image file type"""
826 is_image = file_type == main.Types.IMAGE
827 self.toggle_image_diff.setVisible(is_image)
829 def set_diff_type(self, diff_type):
830 """Toggle between image and text diffs"""
831 is_text = diff_type == main.Types.TEXT
832 is_image = diff_type == main.Types.IMAGE
833 self.options.setVisible(is_text)
834 self.image_mode.setVisible(is_image)
835 self.zoom_mode.setVisible(is_image)
836 if is_image:
837 self.toggle_image_diff.setIcon(icons.diff())
838 else:
839 self.toggle_image_diff.setIcon(icons.visualize())
841 def add_option(self, title):
842 """Add a diff option which calls update_options() on change"""
843 action = qtutils.add_action(self, title, self.update_options)
844 action.setCheckable(True)
845 return action
847 def update_options(self):
848 """Update diff options in response to UI events"""
849 space_at_eol = get(self.ignore_space_at_eol)
850 space_change = get(self.ignore_space_change)
851 all_space = get(self.ignore_all_space)
852 function_context = get(self.function_context)
853 gitcmds.update_diff_overrides(
854 space_at_eol, space_change, all_space, function_context
856 self.widget.update_options()
858 def set_line_numbers(self, value):
859 """Enable / disable line numbers"""
860 self.widget.set_line_numbers(value, update=False)
862 def set_word_wrapping(self, value):
863 """Respond to Qt action callbacks"""
864 self.widget.set_word_wrapping(value, update=False)
866 def hide_advanced_options(self):
867 """Hide advanced options that are not applicable to the DiffWidget"""
868 self.show_line_numbers.setVisible(False)
869 self.ignore_space_at_eol.setVisible(False)
870 self.ignore_space_change.setVisible(False)
871 self.ignore_all_space.setVisible(False)
872 self.function_context.setVisible(False)
875 # pylint: disable=too-many-ancestors
876 class DiffEditor(DiffTextEdit):
877 up = Signal()
878 down = Signal()
879 options_changed = Signal()
881 def __init__(self, context, options, parent):
882 DiffTextEdit.__init__(self, context, parent, numbers=True)
883 self.context = context
884 self.model = model = context.model
885 self.selection_model = selection_model = context.selection
887 # "Diff Options" tool menu
888 self.options = options
890 self.action_apply_selection = qtutils.add_action(
891 self,
892 'Apply',
893 self.apply_selection,
894 hotkeys.STAGE_DIFF,
895 hotkeys.STAGE_DIFF_ALT,
898 self.action_revert_selection = qtutils.add_action(
899 self, 'Revert', self.revert_selection, hotkeys.REVERT, hotkeys.REVERT_ALT
901 self.action_revert_selection.setIcon(icons.undo())
903 self.action_edit_and_apply_selection = qtutils.add_action(
904 self,
905 'Edit and Apply',
906 partial(self.apply_selection, edit=True),
907 hotkeys.EDIT_AND_STAGE_DIFF,
910 self.action_edit_and_revert_selection = qtutils.add_action(
911 self,
912 'Edit and Revert',
913 partial(self.revert_selection, edit=True),
914 hotkeys.EDIT_AND_REVERT,
916 self.action_edit_and_revert_selection.setIcon(icons.undo())
917 self.launch_editor = actions.launch_editor_at_line(
918 context, self, hotkeys.EDIT_SHORT, *hotkeys.ACCEPT
920 self.launch_difftool = actions.launch_difftool(context, self)
921 self.stage_or_unstage = actions.stage_or_unstage(context, self)
923 # Emit up/down signals so that they can be routed by the main widget
924 self.move_up = actions.move_up(self)
925 self.move_down = actions.move_down(self)
927 model.diff_text_updated.connect(self.set_diff, type=Qt.QueuedConnection)
929 selection_model.selection_changed.connect(
930 self.refresh, type=Qt.QueuedConnection
932 # Update the selection model when the cursor changes
933 self.cursorPositionChanged.connect(self._update_line_number)
935 qtutils.connect_button(options.toggle_image_diff, self.toggle_diff_type)
937 def toggle_diff_type(self):
938 cmds.do(cmds.ToggleDiffType, self.context)
940 def refresh(self):
941 enabled = False
942 s = self.selection_model.selection()
943 model = self.model
944 if model.is_partially_stageable():
945 item = s.modified[0] if s.modified else None
946 if item in model.submodules:
947 pass
948 elif item not in model.unstaged_deleted:
949 enabled = True
950 self.action_revert_selection.setEnabled(enabled)
952 def set_line_numbers(self, enabled, update=False):
953 """Enable/disable the diff line number display"""
954 self.numbers.setVisible(enabled)
955 if update:
956 with qtutils.BlockSignals(self.options.show_line_numbers):
957 self.options.show_line_numbers.setChecked(enabled)
958 # Refresh the display. Not doing this results in the display not
959 # correctly displaying the line numbers widget until the text scrolls.
960 self.set_value(self.value())
962 def update_options(self):
963 self.options_changed.emit()
965 def create_context_menu(self, event_pos):
966 """Override create_context_menu() to display a completely custom menu"""
967 menu = super().create_context_menu(event_pos)
968 context = self.context
969 model = self.model
970 s = self.selection_model.selection()
971 filename = self.selection_model.filename()
973 # These menu actions will be inserted at the start of the widget.
974 current_actions = menu.actions()
975 menu_actions = []
976 add_action = menu_actions.append
977 edit_actions_added = False
978 stage_action_added = False
980 if s.staged and model.is_unstageable():
981 item = s.staged[0]
982 if item not in model.submodules and item not in model.staged_deleted:
983 if self.has_selection():
984 apply_text = N_('Unstage Selected Lines')
985 else:
986 apply_text = N_('Unstage Diff Hunk')
987 self.action_apply_selection.setText(apply_text)
988 self.action_apply_selection.setIcon(icons.remove())
989 add_action(self.action_apply_selection)
990 stage_action_added = self._add_stage_or_unstage_action(
991 menu, add_action, stage_action_added
994 if model.is_partially_stageable():
995 item = s.modified[0] if s.modified else None
996 if item in model.submodules:
997 path = core.abspath(item)
998 action = qtutils.add_action_with_icon(
999 menu,
1000 icons.add(),
1001 cmds.Stage.name(),
1002 cmds.run(cmds.Stage, context, s.modified),
1003 hotkeys.STAGE_SELECTION,
1005 add_action(action)
1006 stage_action_added = self._add_stage_or_unstage_action(
1007 menu, add_action, stage_action_added
1010 action = qtutils.add_action_with_icon(
1011 menu,
1012 icons.cola(),
1013 N_('Launch git-cola'),
1014 cmds.run(cmds.OpenRepo, context, path),
1016 add_action(action)
1017 elif item and item not in model.unstaged_deleted:
1018 if self.has_selection():
1019 apply_text = N_('Stage Selected Lines')
1020 edit_and_apply_text = N_('Edit Selected Lines to Stage...')
1021 revert_text = N_('Revert Selected Lines...')
1022 edit_and_revert_text = N_('Edit Selected Lines to Revert...')
1023 else:
1024 apply_text = N_('Stage Diff Hunk')
1025 edit_and_apply_text = N_('Edit Diff Hunk to Stage...')
1026 revert_text = N_('Revert Diff Hunk...')
1027 edit_and_revert_text = N_('Edit Diff Hunk to Revert...')
1029 self.action_apply_selection.setText(apply_text)
1030 self.action_apply_selection.setIcon(icons.add())
1031 add_action(self.action_apply_selection)
1033 self.action_revert_selection.setText(revert_text)
1034 add_action(self.action_revert_selection)
1036 stage_action_added = self._add_stage_or_unstage_action(
1037 menu, add_action, stage_action_added
1039 # Do not show the "edit" action when the file does not exist.
1040 add_action(qtutils.menu_separator(menu))
1041 if filename and core.exists(filename):
1042 add_action(self.launch_editor)
1043 # Removed files can still be diffed.
1044 add_action(self.launch_difftool)
1045 edit_actions_added = True
1047 add_action(qtutils.menu_separator(menu))
1048 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1049 self.action_edit_and_apply_selection.setIcon(icons.add())
1050 add_action(self.action_edit_and_apply_selection)
1052 self.action_edit_and_revert_selection.setText(edit_and_revert_text)
1053 add_action(self.action_edit_and_revert_selection)
1055 if s.staged and model.is_unstageable():
1056 item = s.staged[0]
1057 if item in model.submodules:
1058 path = core.abspath(item)
1059 action = qtutils.add_action_with_icon(
1060 menu,
1061 icons.remove(),
1062 cmds.Unstage.name(),
1063 cmds.run(cmds.Unstage, context, s.staged),
1064 hotkeys.STAGE_SELECTION,
1066 add_action(action)
1068 stage_action_added = self._add_stage_or_unstage_action(
1069 menu, add_action, stage_action_added
1072 qtutils.add_action_with_icon(
1073 menu,
1074 icons.cola(),
1075 N_('Launch git-cola'),
1076 cmds.run(cmds.OpenRepo, context, path),
1078 add_action(action)
1080 elif item not in model.staged_deleted:
1081 # Do not show the "edit" action when the file does not exist.
1082 add_action(qtutils.menu_separator(menu))
1083 if filename and core.exists(filename):
1084 add_action(self.launch_editor)
1085 # Removed files can still be diffed.
1086 add_action(self.launch_difftool)
1087 add_action(qtutils.menu_separator(menu))
1088 edit_actions_added = True
1090 if self.has_selection():
1091 edit_and_apply_text = N_('Edit Selected Lines to Unstage...')
1092 else:
1093 edit_and_apply_text = N_('Edit Diff Hunk to Unstage...')
1094 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1095 self.action_edit_and_apply_selection.setIcon(icons.remove())
1096 add_action(self.action_edit_and_apply_selection)
1098 if not edit_actions_added and (model.is_stageable() or model.is_unstageable()):
1099 add_action(qtutils.menu_separator(menu))
1100 # Do not show the "edit" action when the file does not exist.
1101 # Untracked files exist by definition.
1102 if filename and core.exists(filename):
1103 add_action(self.launch_editor)
1105 # Removed files can still be diffed.
1106 add_action(self.launch_difftool)
1108 add_action(qtutils.menu_separator(menu))
1109 _add_patch_actions(self, self.context, menu)
1111 # Add the Previous/Next File actions, which improves discoverability
1112 # of their associated shortcuts
1113 add_action(qtutils.menu_separator(menu))
1114 add_action(self.move_up)
1115 add_action(self.move_down)
1116 add_action(qtutils.menu_separator(menu))
1118 if current_actions:
1119 first_action = current_actions[0]
1120 else:
1121 first_action = None
1122 menu.insertActions(first_action, menu_actions)
1124 return menu
1126 def _add_stage_or_unstage_action(self, menu, add_action, already_added):
1127 """Add the Stage / Unstage menu action"""
1128 if already_added:
1129 return True
1130 model = self.context.model
1131 s = self.selection_model.selection()
1132 if model.is_stageable() or model.is_unstageable():
1133 if (model.is_amend_mode() and s.staged) or not self.model.is_stageable():
1134 self.stage_or_unstage.setText(N_('Unstage'))
1135 self.stage_or_unstage.setIcon(icons.remove())
1136 else:
1137 self.stage_or_unstage.setText(N_('Stage'))
1138 self.stage_or_unstage.setIcon(icons.add())
1139 add_action(qtutils.menu_separator(menu))
1140 add_action(self.stage_or_unstage)
1141 return True
1143 def mousePressEvent(self, event):
1144 if event.button() == Qt.RightButton:
1145 # Intercept right-click to move the cursor to the current position.
1146 # setTextCursor() clears the selection so this is only done when
1147 # nothing is selected.
1148 if not self.has_selection():
1149 cursor = self.cursorForPosition(event.pos())
1150 self.setTextCursor(cursor)
1152 return super().mousePressEvent(event)
1154 def setPlainText(self, text):
1155 """setPlainText(str) while retaining scrollbar positions"""
1156 model = self.model
1157 mode = model.mode
1158 highlight = mode not in (
1159 model.mode_none,
1160 model.mode_display,
1161 model.mode_untracked,
1163 self.highlighter.set_enabled(highlight)
1165 scrollbar = self.verticalScrollBar()
1166 if scrollbar:
1167 scrollvalue = get(scrollbar)
1168 else:
1169 scrollvalue = None
1171 if text is None:
1172 return
1174 DiffTextEdit.setPlainText(self, text)
1176 if scrollbar and scrollvalue is not None:
1177 scrollbar.setValue(scrollvalue)
1179 def apply_selection(self, *, edit=False):
1180 model = self.model
1181 s = self.selection_model.single_selection()
1182 if model.is_partially_stageable() and (s.modified or s.untracked):
1183 self.process_diff_selection(edit=edit)
1184 elif model.is_unstageable():
1185 self.process_diff_selection(reverse=True, edit=edit)
1187 def revert_selection(self, *, edit=False):
1188 """Destructively revert selected lines or hunk from a worktree file."""
1190 if not edit:
1191 if self.has_selection():
1192 title = N_('Revert Selected Lines?')
1193 ok_text = N_('Revert Selected Lines')
1194 else:
1195 title = N_('Revert Diff Hunk?')
1196 ok_text = N_('Revert Diff Hunk')
1198 if not Interaction.confirm(
1199 title,
1201 'This operation drops uncommitted changes.\n'
1202 'These changes cannot be recovered.'
1204 N_('Revert the uncommitted changes?'),
1205 ok_text,
1206 default=True,
1207 icon=icons.undo(),
1209 return
1210 self.process_diff_selection(reverse=True, apply_to_worktree=True, edit=edit)
1212 def extract_patch(self, reverse=False):
1213 first_line_idx, last_line_idx = self.selected_lines()
1214 patch = diffparse.Patch.parse(self.model.filename, self.model.diff_text)
1215 if self.has_selection():
1216 return patch.extract_subset(first_line_idx, last_line_idx, reverse=reverse)
1217 return patch.extract_hunk(first_line_idx, reverse=reverse)
1219 def patch_encoding(self):
1220 if isinstance(self.model.diff_text, core.UStr):
1221 # original encoding must prevail
1222 return self.model.diff_text.encoding
1223 return self.context.cfg.file_encoding(self.model.filename)
1225 def process_diff_selection(
1226 self, reverse=False, apply_to_worktree=False, edit=False
1228 """Implement un/staging of the selected line(s) or hunk."""
1229 if self.selection_model.is_empty():
1230 return
1231 patch = self.extract_patch(reverse)
1232 if not patch.has_changes():
1233 return
1234 patch_encoding = self.patch_encoding()
1236 if edit:
1237 patch = edit_patch(
1238 patch,
1239 patch_encoding,
1240 self.context,
1241 reverse=reverse,
1242 apply_to_worktree=apply_to_worktree,
1244 if not patch.has_changes():
1245 return
1247 cmds.do(
1248 cmds.ApplyPatch,
1249 self.context,
1250 patch,
1251 patch_encoding,
1252 apply_to_worktree,
1255 def _update_line_number(self):
1256 """Update the selection model when the cursor changes"""
1257 self.selection_model.line_number = self.numbers.current_line()
1260 def _add_patch_actions(widget, context, menu):
1261 """Add actions for manipulating patch files"""
1262 patches_menu = menu.addMenu(N_('Patches'))
1263 patches_menu.setIcon(icons.diff())
1264 export_action = qtutils.add_action(
1265 patches_menu,
1266 N_('Export Patch'),
1267 lambda: _export_patch(widget, context),
1269 export_action.setIcon(icons.save())
1270 patches_menu.addAction(export_action)
1272 # Build the "Append Patch" menu dynamically.
1273 append_menu = patches_menu.addMenu(N_('Append Patch'))
1274 append_menu.setIcon(icons.add())
1275 append_menu.aboutToShow.connect(
1276 lambda: _build_patch_append_menu(widget, context, append_menu)
1280 def _build_patch_append_menu(widget, context, menu):
1281 """Build the "Append Patch" submenu"""
1282 # Build the menu when first displayed only. This initial check avoids
1283 # re-populating the menu with duplicate actions.
1284 menu_actions = menu.actions()
1285 if menu_actions:
1286 return
1288 choose_patch_action = qtutils.add_action(
1289 menu,
1290 N_('Choose Patch...'),
1291 lambda: _export_patch(widget, context, append=True),
1293 choose_patch_action.setIcon(icons.diff())
1294 menu.addAction(choose_patch_action)
1296 subdir_menus = {}
1297 path = prefs.patches_directory(context)
1298 patches = get_patches_from_dir(path)
1299 for patch in patches:
1300 relpath = os.path.relpath(patch, start=path)
1301 sub_menu = _add_patch_subdirs(menu, subdir_menus, relpath)
1302 patch_basename = os.path.basename(relpath)
1303 append_action = qtutils.add_action(
1304 sub_menu,
1305 patch_basename,
1306 lambda patch_file=patch: _append_patch(widget, patch_file),
1308 append_action.setIcon(icons.save())
1309 sub_menu.addAction(append_action)
1312 def _add_patch_subdirs(menu, subdir_menus, relpath):
1313 """Build menu leading up to the patch"""
1314 # If the path contains no directory separators then add it to the
1315 # root of the menu.
1316 if os.sep not in relpath:
1317 return menu
1319 # Loop over each directory component and build a menu if it doesn't already exist.
1320 components = []
1321 for dirname in os.path.dirname(relpath).split(os.sep):
1322 components.append(dirname)
1323 current_dir = os.sep.join(components)
1324 try:
1325 menu = subdir_menus[current_dir]
1326 except KeyError:
1327 menu = subdir_menus[current_dir] = menu.addMenu(dirname)
1328 menu.setIcon(icons.folder())
1330 return menu
1333 def _export_patch(diff_editor, context, append=False):
1334 """Export the selected diff to a patch file"""
1335 if diff_editor.selection_model.is_empty():
1336 return
1337 patch = diff_editor.extract_patch(reverse=False)
1338 if not patch.has_changes():
1339 return
1340 directory = prefs.patches_directory(context)
1341 if append:
1342 filename = qtutils.existing_file(directory, title=N_('Append Patch...'))
1343 else:
1344 default_filename = os.path.join(directory, 'diff.patch')
1345 filename = qtutils.save_as(default_filename)
1346 if not filename:
1347 return
1348 _write_patch_to_file(diff_editor, patch, filename, append=append)
1351 def _append_patch(diff_editor, filename):
1352 """Append diffs to the specified patch file"""
1353 if diff_editor.selection_model.is_empty():
1354 return
1355 patch = diff_editor.extract_patch(reverse=False)
1356 if not patch.has_changes():
1357 return
1358 _write_patch_to_file(diff_editor, patch, filename, append=True)
1361 def _write_patch_to_file(diff_editor, patch, filename, append=False):
1362 """Write diffs from the Diff Editor to the specified patch file"""
1363 encoding = diff_editor.patch_encoding()
1364 content = patch.as_text()
1365 try:
1366 core.write(filename, content, encoding=encoding, append=append)
1367 except OSError as exc:
1368 _, details = utils.format_exception(exc)
1369 title = N_('Error writing patch')
1370 msg = N_('Unable to write patch to "%s". Check permissions?' % filename)
1371 Interaction.critical(title, message=msg, details=details)
1372 return
1373 Interaction.log('Patch written to "%s"' % filename)
1376 class DiffWidget(QtWidgets.QWidget):
1377 """Display commit metadata and text diffs"""
1379 def __init__(self, context, parent, is_commit=False, options=None):
1380 QtWidgets.QWidget.__init__(self, parent)
1382 self.context = context
1383 self.oid = 'HEAD'
1384 self.oid_start = None
1385 self.oid_end = None
1386 self.options = options
1388 author_font = QtGui.QFont(self.font())
1389 author_font.setPointSize(int(author_font.pointSize() * 1.1))
1391 summary_font = QtGui.QFont(author_font)
1392 summary_font.setWeight(QtGui.QFont.Bold)
1394 policy = QtWidgets.QSizePolicy(
1395 QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Minimum
1398 self.gravatar_label = gravatar.GravatarLabel(self.context, parent=self)
1400 self.oid_label = TextLabel()
1401 self.oid_label.setTextFormat(Qt.PlainText)
1402 self.oid_label.setSizePolicy(policy)
1403 self.oid_label.setAlignment(Qt.AlignBottom)
1404 self.oid_label.elide()
1406 self.author_label = TextLabel()
1407 self.author_label.setTextFormat(Qt.RichText)
1408 self.author_label.setFont(author_font)
1409 self.author_label.setSizePolicy(policy)
1410 self.author_label.setAlignment(Qt.AlignTop)
1411 self.author_label.elide()
1413 self.date_label = TextLabel()
1414 self.date_label.setTextFormat(Qt.PlainText)
1415 self.date_label.setSizePolicy(policy)
1416 self.date_label.setAlignment(Qt.AlignTop)
1417 self.date_label.elide()
1419 self.summary_label = TextLabel()
1420 self.summary_label.setTextFormat(Qt.PlainText)
1421 self.summary_label.setFont(summary_font)
1422 self.summary_label.setSizePolicy(policy)
1423 self.summary_label.setAlignment(Qt.AlignTop)
1424 self.summary_label.elide()
1426 self.diff = DiffTextEdit(context, self, is_commit=is_commit, whitespace=False)
1427 self.setFocusProxy(self.diff)
1429 self.info_layout = qtutils.vbox(
1430 defs.no_margin,
1431 defs.no_spacing,
1432 self.oid_label,
1433 self.author_label,
1434 self.date_label,
1435 self.summary_label,
1438 self.logo_layout = qtutils.hbox(
1439 defs.no_margin, defs.button_spacing, self.gravatar_label, self.info_layout
1441 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
1443 self.main_layout = qtutils.vbox(
1444 defs.no_margin, defs.spacing, self.logo_layout, self.diff
1446 self.setLayout(self.main_layout)
1448 self.set_tabwidth(prefs.tabwidth(context))
1450 def set_tabwidth(self, width):
1451 self.diff.set_tabwidth(width)
1453 def set_word_wrapping(self, enabled, update=False):
1454 """Enable and disable word wrapping"""
1455 self.diff.set_word_wrapping(enabled, update=update)
1457 def set_options(self, options):
1458 """Register an options widget"""
1459 self.options = options
1460 self.diff.set_options(options)
1462 def start_diff_task(self, task):
1463 """Clear the display and start a diff-gathering task"""
1464 self.diff.save_scrollbar()
1465 self.diff.set_loading_message()
1466 self.context.runtask.start(task, result=self.set_diff)
1468 def set_diff_oid(self, oid, filename=None):
1469 """Set the diff from a single commit object ID"""
1470 task = DiffInfoTask(self.context, oid, filename)
1471 self.start_diff_task(task)
1473 def set_diff_range(self, start, end, filename=None):
1474 task = DiffRangeTask(self.context, start + '~', end, filename)
1475 self.start_diff_task(task)
1477 def commits_selected(self, commits):
1478 """Display an appropriate diff when commits are selected"""
1479 if not commits:
1480 self.clear()
1481 return
1482 commit = commits[-1]
1483 oid = commit.oid
1484 author = commit.author or ''
1485 email = commit.email or ''
1486 date = commit.authdate or ''
1487 summary = commit.summary or ''
1488 self.set_details(oid, author, email, date, summary)
1489 self.oid = oid
1491 if len(commits) > 1:
1492 start, end = commits[0], commits[-1]
1493 self.set_diff_range(start.oid, end.oid)
1494 self.oid_start = start
1495 self.oid_end = end
1496 else:
1497 self.set_diff_oid(oid)
1498 self.oid_start = None
1499 self.oid_end = None
1501 def set_diff(self, diff):
1502 """Set the diff text"""
1503 self.diff.set_diff(diff)
1505 def set_details(self, oid, author, email, date, summary):
1506 template_args = {'author': author, 'email': email, 'summary': summary}
1507 author_text = (
1508 """%(author)s &lt;"""
1509 """<a href="mailto:%(email)s">"""
1510 """%(email)s</a>&gt;""" % template_args
1512 author_template = '%(author)s <%(email)s>' % template_args
1514 self.date_label.set_text(date)
1515 self.date_label.setVisible(bool(date))
1516 self.oid_label.set_text(oid)
1517 self.author_label.set_template(author_text, author_template)
1518 self.summary_label.set_text(summary)
1519 self.gravatar_label.set_email(email)
1521 def clear(self):
1522 self.date_label.set_text('')
1523 self.oid_label.set_text('')
1524 self.author_label.set_text('')
1525 self.summary_label.set_text('')
1526 self.gravatar_label.clear()
1527 self.diff.clear()
1529 def files_selected(self, filenames):
1530 """Update the view when a filename is selected"""
1531 if not filenames:
1532 return
1533 oid_start = self.oid_start
1534 oid_end = self.oid_end
1535 if oid_start and oid_end:
1536 self.set_diff_range(oid_start.oid, oid_end.oid, filename=filenames[0])
1537 else:
1538 self.set_diff_oid(self.oid, filename=filenames[0])
1541 class DiffPanel(QtWidgets.QWidget):
1542 """A combined diff + search panel"""
1544 def __init__(self, diff_widget, text_widget, parent):
1545 super().__init__(parent)
1546 self.diff_widget = diff_widget
1547 self.search_widget = TextSearchWidget(text_widget, self)
1548 self.search_widget.hide()
1549 layout = qtutils.vbox(
1550 defs.no_margin, defs.spacing, self.diff_widget, self.search_widget
1552 self.setLayout(layout)
1553 self.setFocusProxy(self.diff_widget)
1555 self.search_action = qtutils.add_action(
1556 self,
1557 N_('Search in Diff'),
1558 self.show_search,
1559 hotkeys.SEARCH,
1562 def show_search(self):
1563 """Show a dialog for searching diffs"""
1564 # The diff search is only active in text mode.
1565 if not self.search_widget.isVisible():
1566 self.search_widget.show()
1567 self.search_widget.setFocus()
1570 class TextLabel(QtWidgets.QLabel):
1571 def __init__(self, parent=None):
1572 QtWidgets.QLabel.__init__(self, parent)
1573 self.setTextInteractionFlags(
1574 Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse
1576 self._display = ''
1577 self._template = ''
1578 self._text = ''
1579 self._elide = False
1580 self._metrics = QtGui.QFontMetrics(self.font())
1581 self.setOpenExternalLinks(True)
1583 def elide(self):
1584 self._elide = True
1586 def set_text(self, text):
1587 self.set_template(text, text)
1589 def set_template(self, text, template):
1590 self._display = text
1591 self._text = text
1592 self._template = template
1593 self.update_text(self.width())
1594 self.setText(self._display)
1596 def update_text(self, width):
1597 self._display = self._text
1598 if not self._elide:
1599 return
1600 text = self._metrics.elidedText(self._template, Qt.ElideRight, width - 2)
1601 if text != self._template:
1602 self._display = text
1604 # Qt overrides
1605 def setFont(self, font):
1606 self._metrics = QtGui.QFontMetrics(font)
1607 QtWidgets.QLabel.setFont(self, font)
1609 def resizeEvent(self, event):
1610 if self._elide:
1611 self.update_text(event.size().width())
1612 with qtutils.BlockSignals(self):
1613 self.setText(self._display)
1614 QtWidgets.QLabel.resizeEvent(self, event)
1617 class DiffInfoTask(qtutils.Task):
1618 """Gather diffs for a single commit"""
1620 def __init__(self, context, oid, filename):
1621 qtutils.Task.__init__(self)
1622 self.context = context
1623 self.oid = oid
1624 self.filename = filename
1626 def task(self):
1627 context = self.context
1628 oid = self.oid
1629 return gitcmds.diff_info(context, oid, filename=self.filename)
1632 class DiffRangeTask(qtutils.Task):
1633 """Gather diffs for a range of commits"""
1635 def __init__(self, context, start, end, filename):
1636 qtutils.Task.__init__(self)
1637 self.context = context
1638 self.start = start
1639 self.end = end
1640 self.filename = filename
1642 def task(self):
1643 context = self.context
1644 return gitcmds.diff_range(context, self.start, self.end, filename=self.filename)
1647 def apply_patches(context, patches=None):
1648 """Open the ApplyPatches dialog"""
1649 parent = qtutils.active_window()
1650 dlg = new_apply_patches(context, patches=patches, parent=parent)
1651 dlg.show()
1652 dlg.raise_()
1653 return dlg
1656 def new_apply_patches(context, patches=None, parent=None):
1657 """Create a new instances of the ApplyPatches dialog"""
1658 dlg = ApplyPatches(context, parent=parent)
1659 if patches:
1660 dlg.add_paths(patches)
1661 return dlg
1664 def get_patches_from_paths(paths):
1665 """Returns all patches benath a given path"""
1666 paths = [core.decode(p) for p in paths]
1667 patches = [p for p in paths if core.isfile(p) and p.endswith(('.patch', '.mbox'))]
1668 dirs = [p for p in paths if core.isdir(p)]
1669 dirs.sort()
1670 for d in dirs:
1671 patches.extend(get_patches_from_dir(d))
1672 return patches
1675 def get_patches_from_mimedata(mimedata):
1676 """Extract path files from a QMimeData payload"""
1677 urls = mimedata.urls()
1678 if not urls:
1679 return []
1680 paths = [x.path() for x in urls]
1681 return get_patches_from_paths(paths)
1684 def get_patches_from_dir(path):
1685 """Find patches in a subdirectory"""
1686 patches = []
1687 for root, _, files in core.walk(path):
1688 for name in [f for f in files if f.endswith(('.patch', '.mbox'))]:
1689 patches.append(core.decode(os.path.join(root, name)))
1690 return patches
1693 class ApplyPatches(standard.Dialog):
1694 def __init__(self, context, parent=None):
1695 super().__init__(parent=parent)
1696 self.context = context
1697 self.setWindowTitle(N_('Apply Patches'))
1698 self.setAcceptDrops(True)
1699 if parent is not None:
1700 self.setWindowModality(Qt.WindowModal)
1702 self.curdir = core.getcwd()
1703 self.inner_drag = False
1705 self.usage = QtWidgets.QLabel()
1706 self.usage.setText(
1710 Drag and drop or use the <strong>Add</strong> button to add
1711 patches to the list
1712 </p>
1717 self.tree = PatchTreeWidget(parent=self)
1718 self.tree.setHeaderHidden(True)
1719 # pylint: disable=no-member
1720 self.tree.itemSelectionChanged.connect(self._tree_selection_changed)
1722 self.diffwidget = DiffWidget(context, self, is_commit=True)
1724 self.add_button = qtutils.create_toolbutton(
1725 text=N_('Add'), icon=icons.add(), tooltip=N_('Add patches (+)')
1728 self.remove_button = qtutils.create_toolbutton(
1729 text=N_('Remove'),
1730 icon=icons.remove(),
1731 tooltip=N_('Remove selected (Delete)'),
1734 self.apply_button = qtutils.create_button(text=N_('Apply'), icon=icons.ok())
1736 self.close_button = qtutils.close_button()
1738 self.add_action = qtutils.add_action(
1739 self, N_('Add'), self.add_files, hotkeys.ADD_ITEM
1742 self.remove_action = qtutils.add_action(
1743 self,
1744 N_('Remove'),
1745 self.tree.remove_selected,
1746 hotkeys.DELETE,
1747 hotkeys.BACKSPACE,
1748 hotkeys.REMOVE_ITEM,
1751 self.top_layout = qtutils.hbox(
1752 defs.no_margin,
1753 defs.button_spacing,
1754 self.add_button,
1755 self.remove_button,
1756 qtutils.STRETCH,
1757 self.usage,
1760 self.bottom_layout = qtutils.hbox(
1761 defs.no_margin,
1762 defs.button_spacing,
1763 qtutils.STRETCH,
1764 self.close_button,
1765 self.apply_button,
1768 self.splitter = qtutils.splitter(Qt.Vertical, self.tree, self.diffwidget)
1770 self.main_layout = qtutils.vbox(
1771 defs.margin,
1772 defs.spacing,
1773 self.top_layout,
1774 self.splitter,
1775 self.bottom_layout,
1777 self.setLayout(self.main_layout)
1779 qtutils.connect_button(self.add_button, self.add_files)
1780 qtutils.connect_button(self.remove_button, self.tree.remove_selected)
1781 qtutils.connect_button(self.apply_button, self.apply_patches)
1782 qtutils.connect_button(self.close_button, self.close)
1784 self.init_state(None, self.resize, 666, 420)
1786 def apply_patches(self):
1787 items = self.tree.items()
1788 if not items:
1789 return
1790 context = self.context
1791 patches = [i.data(0, Qt.UserRole) for i in items]
1792 cmds.do(cmds.ApplyPatches, context, patches)
1793 self.accept()
1795 def add_files(self):
1796 files = qtutils.open_files(
1797 N_('Select patch file(s)...'),
1798 directory=self.curdir,
1799 filters='Patches (*.patch *.mbox)',
1801 if not files:
1802 return
1803 self.curdir = os.path.dirname(files[0])
1804 self.add_paths([core.relpath(f) for f in files])
1806 def dragEnterEvent(self, event):
1807 """Accepts drops if the mimedata contains patches"""
1808 super().dragEnterEvent(event)
1809 patches = get_patches_from_mimedata(event.mimeData())
1810 if patches:
1811 event.acceptProposedAction()
1813 def dropEvent(self, event):
1814 """Add dropped patches"""
1815 event.accept()
1816 patches = get_patches_from_mimedata(event.mimeData())
1817 if not patches:
1818 return
1819 self.add_paths(patches)
1821 def add_paths(self, paths):
1822 self.tree.add_paths(paths)
1824 def _tree_selection_changed(self):
1825 items = self.tree.selected_items()
1826 if not items:
1827 return
1828 item = items[-1] # take the last item
1829 path = item.data(0, Qt.UserRole)
1830 if not core.exists(path):
1831 return
1832 commit = parse_patch(path)
1833 self.diffwidget.set_details(
1834 commit.oid, commit.author, commit.email, commit.date, commit.summary
1836 self.diffwidget.set_diff(commit.diff)
1838 def export_state(self):
1839 """Export persistent settings"""
1840 state = super().export_state()
1841 state['sizes'] = get(self.splitter)
1842 return state
1844 def apply_state(self, state):
1845 """Apply persistent settings"""
1846 result = super().apply_state(state)
1847 try:
1848 self.splitter.setSizes(state['sizes'])
1849 except (AttributeError, KeyError, ValueError, TypeError):
1850 pass
1851 return result
1854 # pylint: disable=too-many-ancestors
1855 class PatchTreeWidget(standard.DraggableTreeWidget):
1856 def add_paths(self, paths):
1857 patches = get_patches_from_paths(paths)
1858 if not patches:
1859 return
1860 items = []
1861 icon = icons.file_text()
1862 for patch in patches:
1863 item = QtWidgets.QTreeWidgetItem()
1864 flags = item.flags() & ~Qt.ItemIsDropEnabled
1865 item.setFlags(flags)
1866 item.setIcon(0, icon)
1867 item.setText(0, os.path.basename(patch))
1868 item.setData(0, Qt.UserRole, patch)
1869 item.setToolTip(0, patch)
1870 items.append(item)
1871 self.addTopLevelItems(items)
1873 def remove_selected(self):
1874 idxs = self.selectedIndexes()
1875 rows = [idx.row() for idx in idxs]
1876 for row in reversed(sorted(rows)):
1877 self.invisibleRootItem().takeChild(row)
1880 class Commit:
1881 """Container for commit details"""
1883 def __init__(self):
1884 self.content = ''
1885 self.author = ''
1886 self.email = ''
1887 self.oid = ''
1888 self.summary = ''
1889 self.diff = ''
1890 self.date = ''
1893 def parse_patch(path):
1894 content = core.read(path)
1895 commit = Commit()
1896 parse(content, commit)
1897 return commit
1900 def parse(content, commit):
1901 """Parse commit details from a patch"""
1902 from_rgx = re.compile(r'^From (?P<oid>[a-f0-9]{40}) .*$')
1903 author_rgx = re.compile(r'^From: (?P<author>[^<]+) <(?P<email>[^>]+)>$')
1904 date_rgx = re.compile(r'^Date: (?P<date>.*)$')
1905 subject_rgx = re.compile(r'^Subject: (?P<summary>.*)$')
1907 commit.content = content
1909 lines = content.splitlines()
1910 for idx, line in enumerate(lines):
1911 match = from_rgx.match(line)
1912 if match:
1913 commit.oid = match.group('oid')
1914 continue
1916 match = author_rgx.match(line)
1917 if match:
1918 commit.author = match.group('author')
1919 commit.email = match.group('email')
1920 continue
1922 match = date_rgx.match(line)
1923 if match:
1924 commit.date = match.group('date')
1925 continue
1927 match = subject_rgx.match(line)
1928 if match:
1929 commit.summary = match.group('summary')
1930 commit.diff = '\n'.join(lines[idx + 1 :])
1931 break