tree-wide: spelling corrections
[git-cola.git] / cola / widgets / diff.py
blob8b7a28fd415fb36ef7fcd26f5ffe4cbe446ecf59
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 PlainTextLabel
30 from .text import RichTextLabel
31 from .text import TextSearchWidget
32 from . import defs
33 from . import standard
34 from . import imageview
37 class DiffSyntaxHighlighter(QtGui.QSyntaxHighlighter):
38 """Implements the diff syntax highlighting"""
40 INITIAL_STATE = -1
41 DEFAULT_STATE = 0
42 DIFFSTAT_STATE = 1
43 DIFF_FILE_HEADER_STATE = 2
44 DIFF_STATE = 3
45 SUBMODULE_STATE = 4
46 END_STATE = 5
48 DIFF_FILE_HEADER_START_RGX = re.compile(r'diff --git a/.* b/.*')
49 DIFF_HUNK_HEADER_RGX = re.compile(
50 r'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)'
52 BAD_WHITESPACE_RGX = re.compile(r'\s+$')
54 def __init__(self, context, doc, whitespace=True, is_commit=False):
55 QtGui.QSyntaxHighlighter.__init__(self, doc)
56 self.whitespace = whitespace
57 self.enabled = True
58 self.is_commit = is_commit
60 QPalette = QtGui.QPalette
61 cfg = context.cfg
62 palette = QPalette()
63 disabled = palette.color(QPalette.Disabled, QPalette.Text)
64 header = qtutils.rgb_hex(disabled)
66 dark = palette.color(QPalette.Base).lightnessF() < 0.5
68 self.color_text = qtutils.rgb_triple(cfg.color('text', '030303'))
69 self.color_add = qtutils.rgb_triple(
70 cfg.color('add', '77aa77' if dark else 'd2ffe4')
72 self.color_remove = qtutils.rgb_triple(
73 cfg.color('remove', 'aa7777' if dark else 'fee0e4')
75 self.color_header = qtutils.rgb_triple(cfg.color('header', header))
77 self.diff_header_fmt = qtutils.make_format(foreground=self.color_header)
78 self.bold_diff_header_fmt = qtutils.make_format(
79 foreground=self.color_header, bold=True
82 self.diff_add_fmt = qtutils.make_format(
83 foreground=self.color_text, background=self.color_add
85 self.diff_remove_fmt = qtutils.make_format(
86 foreground=self.color_text, background=self.color_remove
88 self.bad_whitespace_fmt = qtutils.make_format(background=Qt.red)
89 self.setCurrentBlockState(self.INITIAL_STATE)
91 def set_enabled(self, enabled):
92 self.enabled = enabled
94 def highlightBlock(self, text):
95 """Highlight the current text block"""
96 if not self.enabled or not text:
97 return
98 formats = []
99 state = self.get_next_state(text)
100 if state == self.DIFFSTAT_STATE:
101 state, formats = self.get_formats_for_diffstat(state, text)
102 elif state == self.DIFF_FILE_HEADER_STATE:
103 state, formats = self.get_formats_for_diff_header(state, text)
104 elif state == self.DIFF_STATE:
105 state, formats = self.get_formats_for_diff_text(state, text)
107 for start, end, fmt in formats:
108 self.setFormat(start, end, fmt)
110 self.setCurrentBlockState(state)
112 def get_next_state(self, text):
113 """Transition to the next state based on the input text"""
114 state = self.previousBlockState()
115 if state == DiffSyntaxHighlighter.INITIAL_STATE:
116 if text.startswith('Submodule '):
117 state = DiffSyntaxHighlighter.SUBMODULE_STATE
118 elif text.startswith('diff --git '):
119 state = DiffSyntaxHighlighter.DIFFSTAT_STATE
120 elif self.is_commit:
121 state = DiffSyntaxHighlighter.DEFAULT_STATE
122 else:
123 state = DiffSyntaxHighlighter.DIFFSTAT_STATE
125 return state
127 def get_formats_for_diffstat(self, state, text):
128 """Returns (state, [(start, end, fmt), ...]) for highlighting diffstat text"""
129 formats = []
130 if self.DIFF_FILE_HEADER_START_RGX.match(text):
131 state = self.DIFF_FILE_HEADER_STATE
132 end = len(text)
133 fmt = self.diff_header_fmt
134 formats.append((0, end, fmt))
135 elif self.DIFF_HUNK_HEADER_RGX.match(text):
136 state = self.DIFF_STATE
137 end = len(text)
138 fmt = self.bold_diff_header_fmt
139 formats.append((0, end, fmt))
140 elif '|' in text:
141 offset = text.index('|')
142 formats.append((0, offset, self.bold_diff_header_fmt))
143 formats.append((offset, len(text) - offset, self.diff_header_fmt))
144 else:
145 formats.append((0, len(text), self.diff_header_fmt))
147 return state, formats
149 def get_formats_for_diff_header(self, state, text):
150 """Returns (state, [(start, end, fmt), ...]) for highlighting diff headers"""
151 formats = []
152 if self.DIFF_HUNK_HEADER_RGX.match(text):
153 state = self.DIFF_STATE
154 formats.append((0, len(text), self.bold_diff_header_fmt))
155 else:
156 formats.append((0, len(text), self.diff_header_fmt))
158 return state, formats
160 def get_formats_for_diff_text(self, state, text):
161 """Return (state, [(start, end fmt), ...]) for highlighting diff text"""
162 formats = []
164 if self.DIFF_FILE_HEADER_START_RGX.match(text):
165 state = self.DIFF_FILE_HEADER_STATE
166 formats.append((0, len(text), self.diff_header_fmt))
168 elif self.DIFF_HUNK_HEADER_RGX.match(text):
169 formats.append((0, len(text), self.bold_diff_header_fmt))
171 elif text.startswith('-'):
172 if text == '-- ':
173 state = self.END_STATE
174 else:
175 formats.append((0, len(text), self.diff_remove_fmt))
177 elif text.startswith('+'):
178 formats.append((0, len(text), self.diff_add_fmt))
179 if self.whitespace:
180 match = self.BAD_WHITESPACE_RGX.search(text)
181 if match is not None:
182 start = match.start()
183 formats.append((start, len(text) - start, self.bad_whitespace_fmt))
185 return state, formats
188 # pylint: disable=too-many-ancestors
189 class DiffTextEdit(VimHintedPlainTextEdit):
190 """A textedit for interacting with diff text"""
192 def __init__(
193 self, context, parent, is_commit=False, whitespace=True, numbers=False
195 VimHintedPlainTextEdit.__init__(self, context, '', parent=parent)
196 # Diff/patch syntax highlighter
197 self.highlighter = DiffSyntaxHighlighter(
198 context, self.document(), is_commit=is_commit, whitespace=whitespace
200 if numbers:
201 self.numbers = DiffLineNumbers(context, self)
202 self.numbers.hide()
203 else:
204 self.numbers = None
205 self.scrollvalue = None
207 self.copy_diff_action = qtutils.add_action(
208 self,
209 N_('Copy Diff'),
210 self.copy_diff,
211 hotkeys.COPY_DIFF,
213 self.copy_diff_action.setIcon(icons.copy())
214 self.copy_diff_action.setEnabled(False)
215 self.menu_actions.append(self.copy_diff_action)
217 # pylint: disable=no-member
218 self.cursorPositionChanged.connect(self._cursor_changed)
219 self.selectionChanged.connect(self._selection_changed)
221 def setFont(self, font):
222 """Override setFont() so that we can use a custom "block" cursor"""
223 super().setFont(font)
224 if prefs.block_cursor(self.context):
225 width = qtutils.text_width(font, '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().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 font = qtutils.diff_font(context)
354 self.setFont(font)
355 self._char_width = qtutils.text_width(font, 'M')
357 QPalette = QtGui.QPalette
358 self._palette = palette = self.palette()
359 self._base = palette.color(QtGui.QPalette.Base)
360 self._highlight = palette.color(QPalette.Highlight)
361 self._highlight.setAlphaF(0.3)
362 self._highlight_text = palette.color(QPalette.HighlightedText)
363 self._window = palette.color(QPalette.Window)
364 self._disabled = palette.color(QPalette.Disabled, QPalette.Text)
366 def set_diff(self, diff):
367 self.lines = self.parser.parse(diff)
368 self.formatter.set_digits(self.parser.digits())
370 def width_hint(self):
371 if not self.isVisible():
372 return 0
373 parser = self.parser
375 if parser.merge:
376 columns = 3
377 extra = 3 # one space in-between, one space after
378 else:
379 columns = 2
380 extra = 2 # one space in-between, one space after
382 digits = parser.digits() * columns
384 return defs.margin + (self._char_width * (digits + extra))
386 def set_highlighted(self, line_number):
387 """Set the line to highlight"""
388 self.highlight_line = line_number
390 def current_line(self):
391 lines = self.lines
392 if lines and self.highlight_line >= 0:
393 # Find the next valid line
394 for i in range(self.highlight_line, len(lines)):
395 # take the "new" line number: last value in tuple
396 line_number = lines[i][-1]
397 if line_number > 0:
398 return line_number
400 # Find the previous valid line
401 for i in range(self.highlight_line - 1, -1, -1):
402 # take the "new" line number: last value in tuple
403 if i < len(lines):
404 line_number = lines[i][-1]
405 if line_number > 0:
406 return line_number
407 return None
409 def paintEvent(self, event):
410 """Paint the line number"""
411 if not self.lines:
412 return
414 painter = QtGui.QPainter(self)
415 painter.fillRect(event.rect(), self._base)
417 editor = self.editor
418 content_offset = editor.contentOffset()
419 block = editor.firstVisibleBlock()
420 width = self.width()
421 text_width = width - (defs.margin * 2)
422 text_flags = Qt.AlignRight | Qt.AlignVCenter
423 event_rect_bottom = event.rect().bottom()
425 highlight_line = self.highlight_line
426 highlight = self._highlight
427 highlight_text = self._highlight_text
428 disabled = self._disabled
430 fmt = self.formatter
431 lines = self.lines
432 num_lines = len(lines)
434 while block.isValid():
435 block_number = block.blockNumber()
436 if block_number >= num_lines:
437 break
438 block_geom = editor.blockBoundingGeometry(block)
439 rect = block_geom.translated(content_offset).toRect()
440 if not block.isVisible() or rect.top() >= event_rect_bottom:
441 break
443 if block_number == highlight_line:
444 painter.fillRect(rect.x(), rect.y(), width, rect.height(), highlight)
445 painter.setPen(highlight_text)
446 else:
447 painter.setPen(disabled)
449 line = lines[block_number]
450 if len(line) == 2:
451 a, b = line
452 text = fmt.value(a, b)
453 elif len(line) == 3:
454 old, base, new = line
455 text = fmt.merge_value(old, base, new)
457 painter.drawText(
458 rect.x(),
459 rect.y(),
460 text_width,
461 rect.height(),
462 text_flags,
463 text,
466 block = block.next()
469 class Viewer(QtWidgets.QFrame):
470 """Text and image diff viewers"""
472 INDEX_TEXT = 0
473 INDEX_IMAGE = 1
475 def __init__(self, context, parent=None):
476 super().__init__(parent)
478 self.context = context
479 self.model = model = context.model
480 self.images = []
481 self.pixmaps = []
482 self.options = options = Options(self)
483 self.filename = PlainTextLabel(parent=self)
484 self.filename.setAlignment(Qt.AlignVCenter | Qt.AlignLeft)
485 font = self.font()
486 font.setItalic(True)
487 self.filename.setFont(font)
488 self.filename.elide()
489 self.text = DiffEditor(context, options, self)
490 self.image = imageview.ImageView(parent=self)
491 self.image.setFocusPolicy(Qt.NoFocus)
492 self.search_widget = TextSearchWidget(self.text, self)
493 self.search_widget.hide()
494 self._drag_has_patches = False
496 self.setAcceptDrops(True)
497 self.setFocusProxy(self.text)
499 stack = self.stack = QtWidgets.QStackedWidget(self)
500 stack.addWidget(self.text)
501 stack.addWidget(self.image)
503 self.main_layout = qtutils.vbox(
504 defs.no_margin,
505 defs.no_spacing,
506 self.stack,
507 self.search_widget,
509 self.setLayout(self.main_layout)
511 # Observe images
512 model.images_changed.connect(self.set_images, type=Qt.QueuedConnection)
514 # Observe the diff type
515 model.diff_type_changed.connect(self.set_diff_type, type=Qt.QueuedConnection)
517 # Observe the file type
518 model.file_type_changed.connect(self.set_file_type, type=Qt.QueuedConnection)
520 # Observe the image mode combo box
521 options.image_mode.currentIndexChanged.connect(lambda _: self.render())
522 options.zoom_mode.currentIndexChanged.connect(lambda _: self.render())
524 self.search_action = qtutils.add_action(
525 self,
526 N_('Search in Diff'),
527 self.show_search_diff,
528 hotkeys.SEARCH,
531 def dragEnterEvent(self, event):
532 """Accepts drops if the mimedata contains patches"""
533 super().dragEnterEvent(event)
534 patches = get_patches_from_mimedata(event.mimeData())
535 if patches:
536 event.acceptProposedAction()
537 self._drag_has_patches = True
539 def dragLeaveEvent(self, event):
540 """End the drag+drop interaction"""
541 super().dragLeaveEvent(event)
542 if self._drag_has_patches:
543 event.accept()
544 else:
545 event.ignore()
546 self._drag_has_patches = False
548 def dropEvent(self, event):
549 """Apply patches when dropped onto the widget"""
550 if not self._drag_has_patches:
551 event.ignore()
552 return
553 event.setDropAction(Qt.CopyAction)
554 super().dropEvent(event)
555 self._drag_has_patches = False
557 patches = get_patches_from_mimedata(event.mimeData())
558 if patches:
559 apply_patches(self.context, patches=patches)
561 event.accept() # must be called after dropEvent()
563 def show_search_diff(self):
564 """Show a dialog for searching diffs"""
565 # The diff search is only active in text mode.
566 if self.stack.currentIndex() != self.INDEX_TEXT:
567 return
568 if not self.search_widget.isVisible():
569 self.search_widget.show()
570 self.search_widget.setFocus()
572 def export_state(self, state):
573 state['show_diff_line_numbers'] = self.options.show_line_numbers.isChecked()
574 state['show_diff_filenames'] = self.options.show_filenames.isChecked()
575 state['image_diff_mode'] = self.options.image_mode.currentIndex()
576 state['image_zoom_mode'] = self.options.zoom_mode.currentIndex()
577 state['word_wrap'] = self.options.enable_word_wrapping.isChecked()
578 return state
580 def apply_state(self, state):
581 diff_numbers = bool(state.get('show_diff_line_numbers', False))
582 self.set_line_numbers(diff_numbers, update=True)
584 show_filenames = bool(state.get('show_diff_filenames', True))
585 self.set_show_filenames(show_filenames, update=True)
587 image_mode = utils.asint(state.get('image_diff_mode', 0))
588 self.options.image_mode.set_index(image_mode)
590 zoom_mode = utils.asint(state.get('image_zoom_mode', 0))
591 self.options.zoom_mode.set_index(zoom_mode)
593 word_wrap = bool(state.get('word_wrap', True))
594 self.set_word_wrapping(word_wrap, update=True)
595 return True
597 def set_diff_type(self, diff_type):
598 """Manage the image and text diff views when selection changes"""
599 # The "diff type" is whether the diff viewer is displaying an image.
600 self.options.set_diff_type(diff_type)
601 if diff_type == main.Types.IMAGE:
602 self.stack.setCurrentWidget(self.image)
603 self.search_widget.hide()
604 self.render()
605 else:
606 self.stack.setCurrentWidget(self.text)
608 def set_file_type(self, file_type):
609 """Manage the diff options when the file type changes"""
610 # The "file type" is whether the file itself is an image.
611 self.options.set_file_type(file_type)
613 def enable_filename_tracking(self):
614 """Enable displaying the currently selected filename"""
615 self.context.selection.selection_changed.connect(
616 self.update_filename, type=Qt.QueuedConnection
619 def update_filename(self):
620 """Update the filename display when the selection changes"""
621 filename = self.context.selection.filename()
622 self.filename.set_text(filename or '')
624 def update_options(self):
625 """Emit a signal indicating that options have changed"""
626 self.text.update_options()
627 show_filenames = get(self.options.show_filenames)
628 self.set_show_filenames(show_filenames)
630 def set_show_filenames(self, enabled, update=False):
631 """Enable/disable displaying the selected filename"""
632 self.filename.setVisible(enabled)
633 if update:
634 with qtutils.BlockSignals(self.options.show_filenames):
635 self.options.show_filenames.setChecked(enabled)
637 def set_line_numbers(self, enabled, update=False):
638 """Enable/disable line numbers in the text widget"""
639 self.text.set_line_numbers(enabled, update=update)
641 def set_word_wrapping(self, enabled, update=False):
642 """Enable/disable word wrapping in the text widget"""
643 self.text.set_word_wrapping(enabled, update=update)
645 def reset(self):
646 self.image.pixmap = QtGui.QPixmap()
647 self.cleanup()
649 def cleanup(self):
650 for image, unlink in self.images:
651 if unlink and core.exists(image):
652 os.unlink(image)
653 self.images = []
655 def set_images(self, images):
656 self.images = images
657 self.pixmaps = []
658 if not images:
659 self.reset()
660 return False
662 # In order to comp, we first have to load all the images
663 all_pixmaps = [QtGui.QPixmap(image[0]) for image in images]
664 pixmaps = [pixmap for pixmap in all_pixmaps if not pixmap.isNull()]
665 if not pixmaps:
666 self.reset()
667 return False
669 self.pixmaps = pixmaps
670 self.render()
671 self.cleanup()
672 return True
674 def render(self):
675 # Update images
676 if self.pixmaps:
677 mode = self.options.image_mode.currentIndex()
678 if mode == self.options.SIDE_BY_SIDE:
679 image = self.render_side_by_side()
680 elif mode == self.options.DIFF:
681 image = self.render_diff()
682 elif mode == self.options.XOR:
683 image = self.render_xor()
684 elif mode == self.options.PIXEL_XOR:
685 image = self.render_pixel_xor()
686 else:
687 image = self.render_side_by_side()
688 else:
689 image = QtGui.QPixmap()
690 self.image.pixmap = image
692 # Apply zoom
693 zoom_mode = self.options.zoom_mode.currentIndex()
694 zoom_factor = self.options.zoom_factors[zoom_mode][1]
695 if zoom_factor > 0.0:
696 self.image.resetTransform()
697 self.image.scale(zoom_factor, zoom_factor)
698 poly = self.image.mapToScene(self.image.viewport().rect())
699 self.image.last_scene_roi = poly.boundingRect()
701 def render_side_by_side(self):
702 # Side-by-side lineup comp
703 pixmaps = self.pixmaps
704 width = sum(pixmap.width() for pixmap in pixmaps)
705 height = max(pixmap.height() for pixmap in pixmaps)
706 image = create_image(width, height)
708 # Paint each pixmap
709 painter = create_painter(image)
710 x = 0
711 for pixmap in pixmaps:
712 painter.drawPixmap(x, 0, pixmap)
713 x += pixmap.width()
714 painter.end()
716 return image
718 def render_comp(self, comp_mode):
719 # Get the max size to use as the render canvas
720 pixmaps = self.pixmaps
721 if len(pixmaps) == 1:
722 return pixmaps[0]
724 width = max(pixmap.width() for pixmap in pixmaps)
725 height = max(pixmap.height() for pixmap in pixmaps)
726 image = create_image(width, height)
728 painter = create_painter(image)
729 for pixmap in (pixmaps[0], pixmaps[-1]):
730 x = (width - pixmap.width()) // 2
731 y = (height - pixmap.height()) // 2
732 painter.drawPixmap(x, y, pixmap)
733 painter.setCompositionMode(comp_mode)
734 painter.end()
736 return image
738 def render_diff(self):
739 comp_mode = QtGui.QPainter.CompositionMode_Difference
740 return self.render_comp(comp_mode)
742 def render_xor(self):
743 comp_mode = QtGui.QPainter.CompositionMode_Xor
744 return self.render_comp(comp_mode)
746 def render_pixel_xor(self):
747 comp_mode = QtGui.QPainter.RasterOp_SourceXorDestination
748 return self.render_comp(comp_mode)
751 def create_image(width, height):
752 size = QtCore.QSize(width, height)
753 image = QtGui.QImage(size, QtGui.QImage.Format_ARGB32_Premultiplied)
754 image.fill(Qt.transparent)
755 return image
758 def create_painter(image):
759 painter = QtGui.QPainter(image)
760 painter.fillRect(image.rect(), Qt.transparent)
761 return painter
764 class Options(QtWidgets.QWidget):
765 """Provide the options widget used by the editor
767 Actions are registered on the parent widget.
771 # mode combobox indexes
772 SIDE_BY_SIDE = 0
773 DIFF = 1
774 XOR = 2
775 PIXEL_XOR = 3
777 def __init__(self, parent):
778 super().__init__(parent)
779 # Create widgets
780 self.widget = parent
781 self.ignore_space_at_eol = self.add_option(
782 N_('Ignore changes in whitespace at EOL')
784 self.ignore_space_change = self.add_option(
785 N_('Ignore changes in amount of whitespace')
787 self.ignore_all_space = self.add_option(N_('Ignore all whitespace'))
788 self.function_context = self.add_option(
789 N_('Show whole surrounding functions of changes')
791 self.show_line_numbers = qtutils.add_action_bool(
792 self, N_('Show line numbers'), self.set_line_numbers, True
794 self.show_filenames = self.add_option(N_('Show filenames'))
795 self.enable_word_wrapping = qtutils.add_action_bool(
796 self, N_('Enable word wrapping'), self.set_word_wrapping, True
799 self.options = qtutils.create_action_button(
800 tooltip=N_('Diff Options'), icon=icons.configure()
803 self.toggle_image_diff = qtutils.create_action_button(
804 tooltip=N_('Toggle image diff'), icon=icons.visualize()
806 self.toggle_image_diff.hide()
808 self.image_mode = qtutils.combo(
809 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
812 self.zoom_factors = (
813 (N_('Zoom to Fit'), 0.0),
814 (N_('25%'), 0.25),
815 (N_('50%'), 0.5),
816 (N_('100%'), 1.0),
817 (N_('200%'), 2.0),
818 (N_('400%'), 4.0),
819 (N_('800%'), 8.0),
821 zoom_modes = [factor[0] for factor in self.zoom_factors]
822 self.zoom_mode = qtutils.combo(zoom_modes, parent=self)
824 self.menu = menu = qtutils.create_menu(N_('Diff Options'), self.options)
825 self.options.setMenu(menu)
826 menu.addAction(self.ignore_space_at_eol)
827 menu.addAction(self.ignore_space_change)
828 menu.addAction(self.ignore_all_space)
829 menu.addSeparator()
830 menu.addAction(self.function_context)
831 menu.addAction(self.show_line_numbers)
832 menu.addAction(self.show_filenames)
833 menu.addSeparator()
834 menu.addAction(self.enable_word_wrapping)
836 # Layouts
837 layout = qtutils.hbox(
838 defs.no_margin,
839 defs.button_spacing,
840 self.image_mode,
841 self.zoom_mode,
842 self.options,
843 self.toggle_image_diff,
845 self.setLayout(layout)
847 # Policies
848 self.image_mode.setFocusPolicy(Qt.NoFocus)
849 self.zoom_mode.setFocusPolicy(Qt.NoFocus)
850 self.options.setFocusPolicy(Qt.NoFocus)
851 self.toggle_image_diff.setFocusPolicy(Qt.NoFocus)
852 self.setFocusPolicy(Qt.NoFocus)
854 def set_file_type(self, file_type):
855 """Set whether we are viewing an image file type"""
856 is_image = file_type == main.Types.IMAGE
857 self.toggle_image_diff.setVisible(is_image)
859 def set_diff_type(self, diff_type):
860 """Toggle between image and text diffs"""
861 is_text = diff_type == main.Types.TEXT
862 is_image = diff_type == main.Types.IMAGE
863 self.options.setVisible(is_text)
864 self.image_mode.setVisible(is_image)
865 self.zoom_mode.setVisible(is_image)
866 if is_image:
867 self.toggle_image_diff.setIcon(icons.diff())
868 else:
869 self.toggle_image_diff.setIcon(icons.visualize())
871 def add_option(self, title):
872 """Add a diff option which calls update_options() on change"""
873 action = qtutils.add_action(self, title, self.update_options)
874 action.setCheckable(True)
875 return action
877 def update_options(self):
878 """Update diff options in response to UI events"""
879 space_at_eol = get(self.ignore_space_at_eol)
880 space_change = get(self.ignore_space_change)
881 all_space = get(self.ignore_all_space)
882 function_context = get(self.function_context)
883 gitcmds.update_diff_overrides(
884 space_at_eol, space_change, all_space, function_context
886 self.widget.update_options()
888 def set_line_numbers(self, value):
889 """Enable / disable line numbers"""
890 self.widget.set_line_numbers(value, update=False)
892 def set_word_wrapping(self, value):
893 """Respond to Qt action callbacks"""
894 self.widget.set_word_wrapping(value, update=False)
896 def hide_advanced_options(self):
897 """Hide advanced options that are not applicable to the DiffWidget"""
898 self.show_filenames.setVisible(False)
899 self.show_line_numbers.setVisible(False)
900 self.ignore_space_at_eol.setVisible(False)
901 self.ignore_space_change.setVisible(False)
902 self.ignore_all_space.setVisible(False)
903 self.function_context.setVisible(False)
906 # pylint: disable=too-many-ancestors
907 class DiffEditor(DiffTextEdit):
908 up = Signal()
909 down = Signal()
910 options_changed = Signal()
912 def __init__(self, context, options, parent):
913 DiffTextEdit.__init__(self, context, parent, numbers=True)
914 self.context = context
915 self.model = model = context.model
916 self.selection_model = selection_model = context.selection
918 # "Diff Options" tool menu
919 self.options = options
921 self.action_apply_selection = qtutils.add_action(
922 self,
923 'Apply',
924 self.apply_selection,
925 hotkeys.STAGE_DIFF,
926 hotkeys.STAGE_DIFF_ALT,
929 self.action_revert_selection = qtutils.add_action(
930 self, 'Revert', self.revert_selection, hotkeys.REVERT, hotkeys.REVERT_ALT
932 self.action_revert_selection.setIcon(icons.undo())
934 self.action_edit_and_apply_selection = qtutils.add_action(
935 self,
936 'Edit and Apply',
937 partial(self.apply_selection, edit=True),
938 hotkeys.EDIT_AND_STAGE_DIFF,
941 self.action_edit_and_revert_selection = qtutils.add_action(
942 self,
943 'Edit and Revert',
944 partial(self.revert_selection, edit=True),
945 hotkeys.EDIT_AND_REVERT,
947 self.action_edit_and_revert_selection.setIcon(icons.undo())
948 self.launch_editor = actions.launch_editor_at_line(
949 context, self, hotkeys.EDIT_SHORT, *hotkeys.ACCEPT
951 self.launch_difftool = actions.launch_difftool(context, self)
952 self.stage_or_unstage = actions.stage_or_unstage(context, self)
954 # Emit up/down signals so that they can be routed by the main widget
955 self.move_up = actions.move_up(self)
956 self.move_down = actions.move_down(self)
958 model.diff_text_updated.connect(self.set_diff, type=Qt.QueuedConnection)
960 selection_model.selection_changed.connect(
961 self.refresh, type=Qt.QueuedConnection
963 # Update the selection model when the cursor changes
964 self.cursorPositionChanged.connect(self._update_line_number)
966 qtutils.connect_button(options.toggle_image_diff, self.toggle_diff_type)
968 def toggle_diff_type(self):
969 cmds.do(cmds.ToggleDiffType, self.context)
971 def refresh(self):
972 enabled = False
973 s = self.selection_model.selection()
974 model = self.model
975 if model.is_partially_stageable():
976 item = s.modified[0] if s.modified else None
977 if item in model.submodules:
978 pass
979 elif item not in model.unstaged_deleted:
980 enabled = True
981 self.action_revert_selection.setEnabled(enabled)
983 def set_line_numbers(self, enabled, update=False):
984 """Enable/disable the diff line number display"""
985 self.numbers.setVisible(enabled)
986 if update:
987 with qtutils.BlockSignals(self.options.show_line_numbers):
988 self.options.show_line_numbers.setChecked(enabled)
989 # Refresh the display. Not doing this results in the display not
990 # correctly displaying the line numbers widget until the text scrolls.
991 self.set_value(self.value())
993 def update_options(self):
994 self.options_changed.emit()
996 def create_context_menu(self, event_pos):
997 """Override create_context_menu() to display a completely custom menu"""
998 menu = super().create_context_menu(event_pos)
999 context = self.context
1000 model = self.model
1001 s = self.selection_model.selection()
1002 filename = self.selection_model.filename()
1004 # These menu actions will be inserted at the start of the widget.
1005 current_actions = menu.actions()
1006 menu_actions = []
1007 add_action = menu_actions.append
1008 edit_actions_added = False
1009 stage_action_added = False
1011 if s.staged and model.is_unstageable():
1012 item = s.staged[0]
1013 if item not in model.submodules and item not in model.staged_deleted:
1014 if self.has_selection():
1015 apply_text = N_('Unstage Selected Lines')
1016 else:
1017 apply_text = N_('Unstage Diff Hunk')
1018 self.action_apply_selection.setText(apply_text)
1019 self.action_apply_selection.setIcon(icons.remove())
1020 add_action(self.action_apply_selection)
1021 stage_action_added = self._add_stage_or_unstage_action(
1022 menu, add_action, stage_action_added
1025 if model.is_partially_stageable():
1026 item = s.modified[0] if s.modified else None
1027 if item in model.submodules:
1028 path = core.abspath(item)
1029 action = qtutils.add_action_with_icon(
1030 menu,
1031 icons.add(),
1032 cmds.Stage.name(),
1033 cmds.run(cmds.Stage, context, s.modified),
1034 hotkeys.STAGE_SELECTION,
1036 add_action(action)
1037 stage_action_added = self._add_stage_or_unstage_action(
1038 menu, add_action, stage_action_added
1041 action = qtutils.add_action_with_icon(
1042 menu,
1043 icons.cola(),
1044 N_('Launch git-cola'),
1045 cmds.run(cmds.OpenRepo, context, path),
1047 add_action(action)
1048 elif item and item not in model.unstaged_deleted:
1049 if self.has_selection():
1050 apply_text = N_('Stage Selected Lines')
1051 edit_and_apply_text = N_('Edit Selected Lines to Stage...')
1052 revert_text = N_('Revert Selected Lines...')
1053 edit_and_revert_text = N_('Edit Selected Lines to Revert...')
1054 else:
1055 apply_text = N_('Stage Diff Hunk')
1056 edit_and_apply_text = N_('Edit Diff Hunk to Stage...')
1057 revert_text = N_('Revert Diff Hunk...')
1058 edit_and_revert_text = N_('Edit Diff Hunk to Revert...')
1060 self.action_apply_selection.setText(apply_text)
1061 self.action_apply_selection.setIcon(icons.add())
1062 add_action(self.action_apply_selection)
1064 self.action_revert_selection.setText(revert_text)
1065 add_action(self.action_revert_selection)
1067 stage_action_added = self._add_stage_or_unstage_action(
1068 menu, add_action, stage_action_added
1070 # Do not show the "edit" action when the file does not exist.
1071 add_action(qtutils.menu_separator(menu))
1072 if filename and core.exists(filename):
1073 add_action(self.launch_editor)
1074 # Removed files can still be diffed.
1075 add_action(self.launch_difftool)
1076 edit_actions_added = True
1078 add_action(qtutils.menu_separator(menu))
1079 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1080 self.action_edit_and_apply_selection.setIcon(icons.add())
1081 add_action(self.action_edit_and_apply_selection)
1083 self.action_edit_and_revert_selection.setText(edit_and_revert_text)
1084 add_action(self.action_edit_and_revert_selection)
1086 if s.staged and model.is_unstageable():
1087 item = s.staged[0]
1088 if item in model.submodules:
1089 path = core.abspath(item)
1090 action = qtutils.add_action_with_icon(
1091 menu,
1092 icons.remove(),
1093 cmds.Unstage.name(),
1094 cmds.run(cmds.Unstage, context, s.staged),
1095 hotkeys.STAGE_SELECTION,
1097 add_action(action)
1099 stage_action_added = self._add_stage_or_unstage_action(
1100 menu, add_action, stage_action_added
1103 qtutils.add_action_with_icon(
1104 menu,
1105 icons.cola(),
1106 N_('Launch git-cola'),
1107 cmds.run(cmds.OpenRepo, context, path),
1109 add_action(action)
1111 elif item not in model.staged_deleted:
1112 # Do not show the "edit" action when the file does not exist.
1113 add_action(qtutils.menu_separator(menu))
1114 if filename and core.exists(filename):
1115 add_action(self.launch_editor)
1116 # Removed files can still be diffed.
1117 add_action(self.launch_difftool)
1118 add_action(qtutils.menu_separator(menu))
1119 edit_actions_added = True
1121 if self.has_selection():
1122 edit_and_apply_text = N_('Edit Selected Lines to Unstage...')
1123 else:
1124 edit_and_apply_text = N_('Edit Diff Hunk to Unstage...')
1125 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1126 self.action_edit_and_apply_selection.setIcon(icons.remove())
1127 add_action(self.action_edit_and_apply_selection)
1129 if not edit_actions_added and (model.is_stageable() or model.is_unstageable()):
1130 add_action(qtutils.menu_separator(menu))
1131 # Do not show the "edit" action when the file does not exist.
1132 # Untracked files exist by definition.
1133 if filename and core.exists(filename):
1134 add_action(self.launch_editor)
1136 # Removed files can still be diffed.
1137 add_action(self.launch_difftool)
1139 add_action(qtutils.menu_separator(menu))
1140 _add_patch_actions(self, self.context, menu)
1142 # Add the Previous/Next File actions, which improves discoverability
1143 # of their associated shortcuts
1144 add_action(qtutils.menu_separator(menu))
1145 add_action(self.move_up)
1146 add_action(self.move_down)
1147 add_action(qtutils.menu_separator(menu))
1149 if current_actions:
1150 first_action = current_actions[0]
1151 else:
1152 first_action = None
1153 menu.insertActions(first_action, menu_actions)
1155 return menu
1157 def _add_stage_or_unstage_action(self, menu, add_action, already_added):
1158 """Add the Stage / Unstage menu action"""
1159 if already_added:
1160 return True
1161 model = self.context.model
1162 s = self.selection_model.selection()
1163 if model.is_stageable() or model.is_unstageable():
1164 if (model.is_amend_mode() and s.staged) or not self.model.is_stageable():
1165 self.stage_or_unstage.setText(N_('Unstage'))
1166 self.stage_or_unstage.setIcon(icons.remove())
1167 else:
1168 self.stage_or_unstage.setText(N_('Stage'))
1169 self.stage_or_unstage.setIcon(icons.add())
1170 add_action(qtutils.menu_separator(menu))
1171 add_action(self.stage_or_unstage)
1172 return True
1174 def mousePressEvent(self, event):
1175 if event.button() == Qt.RightButton:
1176 # Intercept right-click to move the cursor to the current position.
1177 # setTextCursor() clears the selection so this is only done when
1178 # nothing is selected.
1179 if not self.has_selection():
1180 cursor = self.cursorForPosition(event.pos())
1181 self.setTextCursor(cursor)
1183 return super().mousePressEvent(event)
1185 def setPlainText(self, text):
1186 """setPlainText(str) while retaining scrollbar positions"""
1187 model = self.model
1188 mode = model.mode
1189 highlight = mode not in (
1190 model.mode_none,
1191 model.mode_display,
1192 model.mode_untracked,
1194 self.highlighter.set_enabled(highlight)
1196 scrollbar = self.verticalScrollBar()
1197 if scrollbar:
1198 scrollvalue = get(scrollbar)
1199 else:
1200 scrollvalue = None
1202 if text is None:
1203 return
1205 DiffTextEdit.setPlainText(self, text)
1207 if scrollbar and scrollvalue is not None:
1208 scrollbar.setValue(scrollvalue)
1210 def apply_selection(self, *, edit=False):
1211 model = self.model
1212 s = self.selection_model.single_selection()
1213 if model.is_partially_stageable() and (s.modified or s.untracked):
1214 self.process_diff_selection(edit=edit)
1215 elif model.is_unstageable():
1216 self.process_diff_selection(reverse=True, edit=edit)
1218 def revert_selection(self, *, edit=False):
1219 """Destructively revert selected lines or hunk from a worktree file."""
1221 if not edit:
1222 if self.has_selection():
1223 title = N_('Revert Selected Lines?')
1224 ok_text = N_('Revert Selected Lines')
1225 else:
1226 title = N_('Revert Diff Hunk?')
1227 ok_text = N_('Revert Diff Hunk')
1229 if not Interaction.confirm(
1230 title,
1232 'This operation drops uncommitted changes.\n'
1233 'These changes cannot be recovered.'
1235 N_('Revert the uncommitted changes?'),
1236 ok_text,
1237 default=True,
1238 icon=icons.undo(),
1240 return
1241 self.process_diff_selection(reverse=True, apply_to_worktree=True, edit=edit)
1243 def extract_patch(self, reverse=False):
1244 first_line_idx, last_line_idx = self.selected_lines()
1245 patch = diffparse.Patch.parse(self.model.filename, self.model.diff_text)
1246 if self.has_selection():
1247 return patch.extract_subset(first_line_idx, last_line_idx, reverse=reverse)
1248 return patch.extract_hunk(first_line_idx, reverse=reverse)
1250 def patch_encoding(self):
1251 if isinstance(self.model.diff_text, core.UStr):
1252 # original encoding must prevail
1253 return self.model.diff_text.encoding
1254 return self.context.cfg.file_encoding(self.model.filename)
1256 def process_diff_selection(
1257 self, reverse=False, apply_to_worktree=False, edit=False
1259 """Implement un/staging of the selected line(s) or hunk."""
1260 if self.selection_model.is_empty():
1261 return
1262 patch = self.extract_patch(reverse)
1263 if not patch.has_changes():
1264 return
1265 patch_encoding = self.patch_encoding()
1267 if edit:
1268 patch = edit_patch(
1269 patch,
1270 patch_encoding,
1271 self.context,
1272 reverse=reverse,
1273 apply_to_worktree=apply_to_worktree,
1275 if not patch.has_changes():
1276 return
1278 cmds.do(
1279 cmds.ApplyPatch,
1280 self.context,
1281 patch,
1282 patch_encoding,
1283 apply_to_worktree,
1286 def _update_line_number(self):
1287 """Update the selection model when the cursor changes"""
1288 self.selection_model.line_number = self.numbers.current_line()
1291 def _add_patch_actions(widget, context, menu):
1292 """Add actions for manipulating patch files"""
1293 patches_menu = menu.addMenu(N_('Patches'))
1294 patches_menu.setIcon(icons.diff())
1295 export_action = qtutils.add_action(
1296 patches_menu,
1297 N_('Export Patch'),
1298 lambda: _export_patch(widget, context),
1300 export_action.setIcon(icons.save())
1301 patches_menu.addAction(export_action)
1303 # Build the "Append Patch" menu dynamically.
1304 append_menu = patches_menu.addMenu(N_('Append Patch'))
1305 append_menu.setIcon(icons.add())
1306 append_menu.aboutToShow.connect(
1307 lambda: _build_patch_append_menu(widget, context, append_menu)
1311 def _build_patch_append_menu(widget, context, menu):
1312 """Build the "Append Patch" sub-menu"""
1313 # Build the menu when first displayed only. This initial check avoids
1314 # re-populating the menu with duplicate actions.
1315 menu_actions = menu.actions()
1316 if menu_actions:
1317 return
1319 choose_patch_action = qtutils.add_action(
1320 menu,
1321 N_('Choose Patch...'),
1322 lambda: _export_patch(widget, context, append=True),
1324 choose_patch_action.setIcon(icons.diff())
1325 menu.addAction(choose_patch_action)
1327 subdir_menus = {}
1328 path = prefs.patches_directory(context)
1329 patches = get_patches_from_dir(path)
1330 for patch in patches:
1331 relpath = os.path.relpath(patch, start=path)
1332 sub_menu = _add_patch_subdirs(menu, subdir_menus, relpath)
1333 patch_basename = os.path.basename(relpath)
1334 append_action = qtutils.add_action(
1335 sub_menu,
1336 patch_basename,
1337 lambda patch_file=patch: _append_patch(widget, patch_file),
1339 append_action.setIcon(icons.save())
1340 sub_menu.addAction(append_action)
1343 def _add_patch_subdirs(menu, subdir_menus, relpath):
1344 """Build menu leading up to the patch"""
1345 # If the path contains no directory separators then add it to the
1346 # root of the menu.
1347 if os.sep not in relpath:
1348 return menu
1350 # Loop over each directory component and build a menu if it doesn't already exist.
1351 components = []
1352 for dirname in os.path.dirname(relpath).split(os.sep):
1353 components.append(dirname)
1354 current_dir = os.sep.join(components)
1355 try:
1356 menu = subdir_menus[current_dir]
1357 except KeyError:
1358 menu = subdir_menus[current_dir] = menu.addMenu(dirname)
1359 menu.setIcon(icons.folder())
1361 return menu
1364 def _export_patch(diff_editor, context, append=False):
1365 """Export the selected diff to a patch file"""
1366 if diff_editor.selection_model.is_empty():
1367 return
1368 patch = diff_editor.extract_patch(reverse=False)
1369 if not patch.has_changes():
1370 return
1371 directory = prefs.patches_directory(context)
1372 if append:
1373 filename = qtutils.existing_file(directory, title=N_('Append Patch...'))
1374 else:
1375 default_filename = os.path.join(directory, 'diff.patch')
1376 filename = qtutils.save_as(default_filename)
1377 if not filename:
1378 return
1379 _write_patch_to_file(diff_editor, patch, filename, append=append)
1382 def _append_patch(diff_editor, filename):
1383 """Append diffs to the specified patch file"""
1384 if diff_editor.selection_model.is_empty():
1385 return
1386 patch = diff_editor.extract_patch(reverse=False)
1387 if not patch.has_changes():
1388 return
1389 _write_patch_to_file(diff_editor, patch, filename, append=True)
1392 def _write_patch_to_file(diff_editor, patch, filename, append=False):
1393 """Write diffs from the Diff Editor to the specified patch file"""
1394 encoding = diff_editor.patch_encoding()
1395 content = patch.as_text()
1396 try:
1397 core.write(filename, content, encoding=encoding, append=append)
1398 except OSError as exc:
1399 _, details = utils.format_exception(exc)
1400 title = N_('Error writing patch')
1401 msg = N_('Unable to write patch to "%s". Check permissions?' % filename)
1402 Interaction.critical(title, message=msg, details=details)
1403 return
1404 Interaction.log('Patch written to "%s"' % filename)
1407 class DiffWidget(QtWidgets.QWidget):
1408 """Display commit metadata and text diffs"""
1410 def __init__(self, context, parent, is_commit=False, options=None):
1411 QtWidgets.QWidget.__init__(self, parent)
1413 self.context = context
1414 self.oid = 'HEAD'
1415 self.oid_start = None
1416 self.oid_end = None
1417 self.options = options
1419 author_font = QtGui.QFont(self.font())
1420 author_font.setPointSize(int(author_font.pointSize() * 1.1))
1422 summary_font = QtGui.QFont(author_font)
1423 summary_font.setWeight(QtGui.QFont.Bold)
1425 self.gravatar_label = gravatar.GravatarLabel(self.context, parent=self)
1427 self.oid_label = PlainTextLabel(parent=self)
1428 self.oid_label.setAlignment(Qt.AlignBottom)
1429 self.oid_label.elide()
1431 self.author_label = RichTextLabel(parent=self)
1432 self.author_label.setFont(author_font)
1433 self.author_label.setAlignment(Qt.AlignTop)
1434 self.author_label.elide()
1436 self.date_label = PlainTextLabel(parent=self)
1437 self.date_label.setAlignment(Qt.AlignTop)
1438 self.date_label.elide()
1440 self.summary_label = PlainTextLabel(parent=self)
1441 self.summary_label.setFont(summary_font)
1442 self.summary_label.setAlignment(Qt.AlignTop)
1443 self.summary_label.elide()
1445 self.diff = DiffTextEdit(context, self, is_commit=is_commit, whitespace=False)
1446 self.setFocusProxy(self.diff)
1448 self.info_layout = qtutils.vbox(
1449 defs.no_margin,
1450 defs.no_spacing,
1451 self.oid_label,
1452 self.author_label,
1453 self.date_label,
1454 self.summary_label,
1457 self.logo_layout = qtutils.hbox(
1458 defs.no_margin, defs.button_spacing, self.gravatar_label, self.info_layout
1460 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
1462 self.main_layout = qtutils.vbox(
1463 defs.no_margin, defs.spacing, self.logo_layout, self.diff
1465 self.setLayout(self.main_layout)
1467 self.set_tabwidth(prefs.tabwidth(context))
1469 def set_tabwidth(self, width):
1470 self.diff.set_tabwidth(width)
1472 def set_word_wrapping(self, enabled, update=False):
1473 """Enable and disable word wrapping"""
1474 self.diff.set_word_wrapping(enabled, update=update)
1476 def set_options(self, options):
1477 """Register an options widget"""
1478 self.options = options
1479 self.diff.set_options(options)
1481 def start_diff_task(self, task):
1482 """Clear the display and start a diff-gathering task"""
1483 self.diff.save_scrollbar()
1484 self.diff.set_loading_message()
1485 self.context.runtask.start(task, result=self.set_diff)
1487 def set_diff_oid(self, oid, filename=None):
1488 """Set the diff from a single commit object ID"""
1489 task = DiffInfoTask(self.context, oid, filename)
1490 self.start_diff_task(task)
1492 def set_diff_range(self, start, end, filename=None):
1493 task = DiffRangeTask(self.context, start + '~', end, filename)
1494 self.start_diff_task(task)
1496 def commits_selected(self, commits):
1497 """Display an appropriate diff when commits are selected"""
1498 if not commits:
1499 self.clear()
1500 return
1501 commit = commits[-1]
1502 oid = commit.oid
1503 author = commit.author or ''
1504 email = commit.email or ''
1505 date = commit.authdate or ''
1506 summary = commit.summary or ''
1507 self.set_details(oid, author, email, date, summary)
1508 self.oid = oid
1510 if len(commits) > 1:
1511 start, end = commits[0], commits[-1]
1512 self.set_diff_range(start.oid, end.oid)
1513 self.oid_start = start
1514 self.oid_end = end
1515 else:
1516 self.set_diff_oid(oid)
1517 self.oid_start = None
1518 self.oid_end = None
1520 def set_diff(self, diff):
1521 """Set the diff text"""
1522 self.diff.set_diff(diff)
1524 def set_details(self, oid, author, email, date, summary):
1525 template_args = {'author': author, 'email': email, 'summary': summary}
1526 author_text = (
1527 """%(author)s &lt;"""
1528 """<a href="mailto:%(email)s">"""
1529 """%(email)s</a>&gt;""" % template_args
1531 author_template = '%(author)s <%(email)s>' % template_args
1533 self.date_label.set_text(date)
1534 self.date_label.setVisible(bool(date))
1535 self.oid_label.set_text(oid)
1536 self.author_label.set_template(author_text, author_template)
1537 self.summary_label.set_text(summary)
1538 self.gravatar_label.set_email(email)
1540 def clear(self):
1541 self.date_label.set_text('')
1542 self.oid_label.set_text('')
1543 self.author_label.set_text('')
1544 self.summary_label.set_text('')
1545 self.gravatar_label.clear()
1546 self.diff.clear()
1548 def files_selected(self, filenames):
1549 """Update the view when a filename is selected"""
1550 if not filenames:
1551 return
1552 oid_start = self.oid_start
1553 oid_end = self.oid_end
1554 if oid_start and oid_end:
1555 self.set_diff_range(oid_start.oid, oid_end.oid, filename=filenames[0])
1556 else:
1557 self.set_diff_oid(self.oid, filename=filenames[0])
1560 class DiffPanel(QtWidgets.QWidget):
1561 """A combined diff + search panel"""
1563 def __init__(self, diff_widget, text_widget, parent):
1564 super().__init__(parent)
1565 self.diff_widget = diff_widget
1566 self.search_widget = TextSearchWidget(text_widget, self)
1567 self.search_widget.hide()
1568 layout = qtutils.vbox(
1569 defs.no_margin, defs.spacing, self.diff_widget, self.search_widget
1571 self.setLayout(layout)
1572 self.setFocusProxy(self.diff_widget)
1574 self.search_action = qtutils.add_action(
1575 self,
1576 N_('Search in Diff'),
1577 self.show_search,
1578 hotkeys.SEARCH,
1581 def show_search(self):
1582 """Show a dialog for searching diffs"""
1583 # The diff search is only active in text mode.
1584 if not self.search_widget.isVisible():
1585 self.search_widget.show()
1586 self.search_widget.setFocus()
1589 class DiffInfoTask(qtutils.Task):
1590 """Gather diffs for a single commit"""
1592 def __init__(self, context, oid, filename):
1593 qtutils.Task.__init__(self)
1594 self.context = context
1595 self.oid = oid
1596 self.filename = filename
1598 def task(self):
1599 context = self.context
1600 oid = self.oid
1601 return gitcmds.diff_info(context, oid, filename=self.filename)
1604 class DiffRangeTask(qtutils.Task):
1605 """Gather diffs for a range of commits"""
1607 def __init__(self, context, start, end, filename):
1608 qtutils.Task.__init__(self)
1609 self.context = context
1610 self.start = start
1611 self.end = end
1612 self.filename = filename
1614 def task(self):
1615 context = self.context
1616 return gitcmds.diff_range(context, self.start, self.end, filename=self.filename)
1619 def apply_patches(context, patches=None):
1620 """Open the ApplyPatches dialog"""
1621 parent = qtutils.active_window()
1622 dlg = new_apply_patches(context, patches=patches, parent=parent)
1623 dlg.show()
1624 dlg.raise_()
1625 return dlg
1628 def new_apply_patches(context, patches=None, parent=None):
1629 """Create a new instances of the ApplyPatches dialog"""
1630 dlg = ApplyPatches(context, parent=parent)
1631 if patches:
1632 dlg.add_paths(patches)
1633 return dlg
1636 def get_patches_from_paths(paths):
1637 """Returns all patches beneath a given path"""
1638 paths = [core.decode(p) for p in paths]
1639 patches = [p for p in paths if core.isfile(p) and p.endswith(('.patch', '.mbox'))]
1640 dirs = [p for p in paths if core.isdir(p)]
1641 dirs.sort()
1642 for d in dirs:
1643 patches.extend(get_patches_from_dir(d))
1644 return patches
1647 def get_patches_from_mimedata(mimedata):
1648 """Extract path files from a QMimeData payload"""
1649 urls = mimedata.urls()
1650 if not urls:
1651 return []
1652 paths = [x.path() for x in urls]
1653 return get_patches_from_paths(paths)
1656 def get_patches_from_dir(path):
1657 """Find patches in a subdirectory"""
1658 patches = []
1659 for root, _, files in core.walk(path):
1660 for name in [f for f in files if f.endswith(('.patch', '.mbox'))]:
1661 patches.append(core.decode(os.path.join(root, name)))
1662 return patches
1665 class ApplyPatches(standard.Dialog):
1666 def __init__(self, context, parent=None):
1667 super().__init__(parent=parent)
1668 self.context = context
1669 self.setWindowTitle(N_('Apply Patches'))
1670 self.setAcceptDrops(True)
1671 if parent is not None:
1672 self.setWindowModality(Qt.WindowModal)
1674 self.curdir = core.getcwd()
1675 self.inner_drag = False
1677 self.usage = QtWidgets.QLabel()
1678 self.usage.setText(
1682 Drag and drop or use the <strong>Add</strong> button to add
1683 patches to the list
1684 </p>
1689 self.tree = PatchTreeWidget(parent=self)
1690 self.tree.setHeaderHidden(True)
1691 # pylint: disable=no-member
1692 self.tree.itemSelectionChanged.connect(self._tree_selection_changed)
1694 self.diffwidget = DiffWidget(context, self, is_commit=True)
1696 self.add_button = qtutils.create_toolbutton(
1697 text=N_('Add'), icon=icons.add(), tooltip=N_('Add patches (+)')
1700 self.remove_button = qtutils.create_toolbutton(
1701 text=N_('Remove'),
1702 icon=icons.remove(),
1703 tooltip=N_('Remove selected (Delete)'),
1706 self.apply_button = qtutils.create_button(text=N_('Apply'), icon=icons.ok())
1708 self.close_button = qtutils.close_button()
1710 self.add_action = qtutils.add_action(
1711 self, N_('Add'), self.add_files, hotkeys.ADD_ITEM
1714 self.remove_action = qtutils.add_action(
1715 self,
1716 N_('Remove'),
1717 self.tree.remove_selected,
1718 hotkeys.DELETE,
1719 hotkeys.BACKSPACE,
1720 hotkeys.REMOVE_ITEM,
1723 self.top_layout = qtutils.hbox(
1724 defs.no_margin,
1725 defs.button_spacing,
1726 self.add_button,
1727 self.remove_button,
1728 qtutils.STRETCH,
1729 self.usage,
1732 self.bottom_layout = qtutils.hbox(
1733 defs.no_margin,
1734 defs.button_spacing,
1735 qtutils.STRETCH,
1736 self.close_button,
1737 self.apply_button,
1740 self.splitter = qtutils.splitter(Qt.Vertical, self.tree, self.diffwidget)
1742 self.main_layout = qtutils.vbox(
1743 defs.margin,
1744 defs.spacing,
1745 self.top_layout,
1746 self.splitter,
1747 self.bottom_layout,
1749 self.setLayout(self.main_layout)
1751 qtutils.connect_button(self.add_button, self.add_files)
1752 qtutils.connect_button(self.remove_button, self.tree.remove_selected)
1753 qtutils.connect_button(self.apply_button, self.apply_patches)
1754 qtutils.connect_button(self.close_button, self.close)
1756 self.init_state(None, self.resize, 666, 420)
1758 def apply_patches(self):
1759 items = self.tree.items()
1760 if not items:
1761 return
1762 context = self.context
1763 patches = [i.data(0, Qt.UserRole) for i in items]
1764 cmds.do(cmds.ApplyPatches, context, patches)
1765 self.accept()
1767 def add_files(self):
1768 files = qtutils.open_files(
1769 N_('Select patch file(s)...'),
1770 directory=self.curdir,
1771 filters='Patches (*.patch *.mbox)',
1773 if not files:
1774 return
1775 self.curdir = os.path.dirname(files[0])
1776 self.add_paths([core.relpath(f) for f in files])
1778 def dragEnterEvent(self, event):
1779 """Accepts drops if the mimedata contains patches"""
1780 super().dragEnterEvent(event)
1781 patches = get_patches_from_mimedata(event.mimeData())
1782 if patches:
1783 event.acceptProposedAction()
1785 def dropEvent(self, event):
1786 """Add dropped patches"""
1787 event.accept()
1788 patches = get_patches_from_mimedata(event.mimeData())
1789 if not patches:
1790 return
1791 self.add_paths(patches)
1793 def add_paths(self, paths):
1794 self.tree.add_paths(paths)
1796 def _tree_selection_changed(self):
1797 items = self.tree.selected_items()
1798 if not items:
1799 return
1800 item = items[-1] # take the last item
1801 path = item.data(0, Qt.UserRole)
1802 if not core.exists(path):
1803 return
1804 commit = parse_patch(path)
1805 self.diffwidget.set_details(
1806 commit.oid, commit.author, commit.email, commit.date, commit.summary
1808 self.diffwidget.set_diff(commit.diff)
1810 def export_state(self):
1811 """Export persistent settings"""
1812 state = super().export_state()
1813 state['sizes'] = get(self.splitter)
1814 return state
1816 def apply_state(self, state):
1817 """Apply persistent settings"""
1818 result = super().apply_state(state)
1819 try:
1820 self.splitter.setSizes(state['sizes'])
1821 except (AttributeError, KeyError, ValueError, TypeError):
1822 pass
1823 return result
1826 # pylint: disable=too-many-ancestors
1827 class PatchTreeWidget(standard.DraggableTreeWidget):
1828 def add_paths(self, paths):
1829 patches = get_patches_from_paths(paths)
1830 if not patches:
1831 return
1832 items = []
1833 icon = icons.file_text()
1834 for patch in patches:
1835 item = QtWidgets.QTreeWidgetItem()
1836 flags = item.flags() & ~Qt.ItemIsDropEnabled
1837 item.setFlags(flags)
1838 item.setIcon(0, icon)
1839 item.setText(0, os.path.basename(patch))
1840 item.setData(0, Qt.UserRole, patch)
1841 item.setToolTip(0, patch)
1842 items.append(item)
1843 self.addTopLevelItems(items)
1845 def remove_selected(self):
1846 idxs = self.selectedIndexes()
1847 rows = [idx.row() for idx in idxs]
1848 for row in reversed(sorted(rows)):
1849 self.invisibleRootItem().takeChild(row)
1852 class Commit:
1853 """Container for commit details"""
1855 def __init__(self):
1856 self.content = ''
1857 self.author = ''
1858 self.email = ''
1859 self.oid = ''
1860 self.summary = ''
1861 self.diff = ''
1862 self.date = ''
1865 def parse_patch(path):
1866 content = core.read(path)
1867 commit = Commit()
1868 parse(content, commit)
1869 return commit
1872 def parse(content, commit):
1873 """Parse commit details from a patch"""
1874 from_rgx = re.compile(r'^From (?P<oid>[a-f0-9]{40}) .*$')
1875 author_rgx = re.compile(r'^From: (?P<author>[^<]+) <(?P<email>[^>]+)>$')
1876 date_rgx = re.compile(r'^Date: (?P<date>.*)$')
1877 subject_rgx = re.compile(r'^Subject: (?P<summary>.*)$')
1879 commit.content = content
1881 lines = content.splitlines()
1882 for idx, line in enumerate(lines):
1883 match = from_rgx.match(line)
1884 if match:
1885 commit.oid = match.group('oid')
1886 continue
1888 match = author_rgx.match(line)
1889 if match:
1890 commit.author = match.group('author')
1891 commit.email = match.group('email')
1892 continue
1894 match = date_rgx.match(line)
1895 if match:
1896 commit.date = match.group('date')
1897 continue
1899 match = subject_rgx.match(line)
1900 if match:
1901 commit.summary = match.group('summary')
1902 commit.diff = '\n'.join(lines[idx + 1 :])
1903 break