widgets: move PlainTextLabel and RichTextLabel to the text module
[git-cola.git] / cola / widgets / diff.py
blobf7a6c2e93a623b10f27142578e2de0541a5284ba
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 metrics = QtGui.QFontMetrics(font)
226 width = metrics.width('M')
227 self.setCursorWidth(width)
229 def _cursor_changed(self):
230 """Update the line number display when the cursor changes"""
231 line_number = max(0, self.textCursor().blockNumber())
232 if self.numbers is not None:
233 self.numbers.set_highlighted(line_number)
235 def _selection_changed(self):
236 """Respond to selection changes"""
237 selected = bool(self.selected_text())
238 self.copy_diff_action.setEnabled(selected)
240 def resizeEvent(self, event):
241 super().resizeEvent(event)
242 if self.numbers:
243 self.numbers.refresh_size()
245 def save_scrollbar(self):
246 """Save the scrollbar value, but only on the first call"""
247 if self.scrollvalue is None:
248 scrollbar = self.verticalScrollBar()
249 if scrollbar:
250 scrollvalue = get(scrollbar)
251 else:
252 scrollvalue = None
253 self.scrollvalue = scrollvalue
255 def restore_scrollbar(self):
256 """Restore the scrollbar and clear state"""
257 scrollbar = self.verticalScrollBar()
258 scrollvalue = self.scrollvalue
259 if scrollbar and scrollvalue is not None:
260 scrollbar.setValue(scrollvalue)
261 self.scrollvalue = None
263 def set_loading_message(self):
264 """Add a pending loading message in the diff view"""
265 self.hint.set_value('+++ ' + N_('Loading...'))
266 self.set_value('')
268 def set_diff(self, diff):
269 """Set the diff text, but save the scrollbar"""
270 diff = diff.rstrip('\n') # diffs include two empty newlines
271 self.save_scrollbar()
273 self.hint.set_value('')
274 if self.numbers:
275 self.numbers.set_diff(diff)
276 self.set_value(diff)
278 self.restore_scrollbar()
280 def selected_diff_stripped(self):
281 """Return the selected diff stripped of any diff characters"""
282 sep, selection = self.selected_text_lines()
283 return sep.join(_strip_diff(line) for line in selection)
285 def copy_diff(self):
286 """Copy the selected diff text stripped of any diff prefix characters"""
287 text = self.selected_diff_stripped()
288 qtutils.set_clipboard(text)
290 def selected_lines(self):
291 """Return selected lines"""
292 cursor = self.textCursor()
293 selection_start = cursor.selectionStart()
294 selection_end = max(selection_start, cursor.selectionEnd() - 1)
296 first_line_idx = -1
297 last_line_idx = -1
298 line_idx = 0
299 line_start = 0
301 for line_idx, line in enumerate(get(self, default='').splitlines()):
302 line_end = line_start + len(line)
303 if line_start <= selection_start <= line_end:
304 first_line_idx = line_idx
305 if line_start <= selection_end <= line_end:
306 last_line_idx = line_idx
307 break
308 line_start = line_end + 1
310 if first_line_idx == -1:
311 first_line_idx = line_idx
313 if last_line_idx == -1:
314 last_line_idx = line_idx
316 return first_line_idx, last_line_idx
318 def selected_text_lines(self):
319 """Return selected lines and the CRLF / LF separator"""
320 first_line_idx, last_line_idx = self.selected_lines()
321 text = get(self, default='')
322 sep = _get_sep(text)
323 lines = []
324 for line_idx, line in enumerate(text.split(sep)):
325 if first_line_idx <= line_idx <= last_line_idx:
326 lines.append(line)
327 return sep, lines
330 def _get_sep(text):
331 """Return either CRLF or LF based on the content"""
332 if '\r\n' in text:
333 sep = '\r\n'
334 else:
335 sep = '\n'
336 return sep
339 def _strip_diff(value):
340 """Remove +/-/<space> from a selection"""
341 if value.startswith(('+', '-', ' ')):
342 return value[1:]
343 return value
346 class DiffLineNumbers(TextDecorator):
347 def __init__(self, context, parent):
348 TextDecorator.__init__(self, parent)
349 self.highlight_line = -1
350 self.lines = None
351 self.parser = diffparse.DiffLines()
352 self.formatter = diffparse.FormatDigits()
354 self.setFont(qtutils.diff_font(context))
355 self._char_width = self.fontMetrics().width('0')
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 = qtutils.label(fmt=Qt.PlainText)
484 self.text = DiffEditor(context, options, self)
485 self.image = imageview.ImageView(parent=self)
486 self.image.setFocusPolicy(Qt.NoFocus)
487 self.search_widget = TextSearchWidget(self.text, self)
488 self.search_widget.hide()
489 self._drag_has_patches = False
491 self.setAcceptDrops(True)
492 self.setFocusProxy(self.text)
494 stack = self.stack = QtWidgets.QStackedWidget(self)
495 stack.addWidget(self.text)
496 stack.addWidget(self.image)
498 self.main_layout = qtutils.vbox(
499 defs.no_margin,
500 defs.no_spacing,
501 self.stack,
502 self.search_widget,
504 self.setLayout(self.main_layout)
506 # Observe images
507 model.images_changed.connect(self.set_images, type=Qt.QueuedConnection)
509 # Observe the diff type
510 model.diff_type_changed.connect(self.set_diff_type, type=Qt.QueuedConnection)
512 # Observe the file type
513 model.file_type_changed.connect(self.set_file_type, type=Qt.QueuedConnection)
515 # Observe the image mode combo box
516 options.image_mode.currentIndexChanged.connect(lambda _: self.render())
517 options.zoom_mode.currentIndexChanged.connect(lambda _: self.render())
519 self.search_action = qtutils.add_action(
520 self,
521 N_('Search in Diff'),
522 self.show_search_diff,
523 hotkeys.SEARCH,
526 def dragEnterEvent(self, event):
527 """Accepts drops if the mimedata contains patches"""
528 super().dragEnterEvent(event)
529 patches = get_patches_from_mimedata(event.mimeData())
530 if patches:
531 event.acceptProposedAction()
532 self._drag_has_patches = True
534 def dragLeaveEvent(self, event):
535 """End the drag+drop interaction"""
536 super().dragLeaveEvent(event)
537 if self._drag_has_patches:
538 event.accept()
539 else:
540 event.ignore()
541 self._drag_has_patches = False
543 def dropEvent(self, event):
544 """Apply patches when dropped onto the widget"""
545 if not self._drag_has_patches:
546 event.ignore()
547 return
548 event.setDropAction(Qt.CopyAction)
549 super().dropEvent(event)
550 self._drag_has_patches = False
552 patches = get_patches_from_mimedata(event.mimeData())
553 if patches:
554 apply_patches(self.context, patches=patches)
556 event.accept() # must be called after dropEvent()
558 def show_search_diff(self):
559 """Show a dialog for searching diffs"""
560 # The diff search is only active in text mode.
561 if self.stack.currentIndex() != self.INDEX_TEXT:
562 return
563 if not self.search_widget.isVisible():
564 self.search_widget.show()
565 self.search_widget.setFocus()
567 def export_state(self, state):
568 state['show_diff_line_numbers'] = self.options.show_line_numbers.isChecked()
569 state['show_diff_filenames'] = self.options.show_filenames.isChecked()
570 state['image_diff_mode'] = self.options.image_mode.currentIndex()
571 state['image_zoom_mode'] = self.options.zoom_mode.currentIndex()
572 state['word_wrap'] = self.options.enable_word_wrapping.isChecked()
573 return state
575 def apply_state(self, state):
576 diff_numbers = bool(state.get('show_diff_line_numbers', False))
577 self.set_line_numbers(diff_numbers, update=True)
579 show_filenames = bool(state.get('show_diff_filenames', True))
580 self.set_show_filenames(show_filenames, update=True)
582 image_mode = utils.asint(state.get('image_diff_mode', 0))
583 self.options.image_mode.set_index(image_mode)
585 zoom_mode = utils.asint(state.get('image_zoom_mode', 0))
586 self.options.zoom_mode.set_index(zoom_mode)
588 word_wrap = bool(state.get('word_wrap', True))
589 self.set_word_wrapping(word_wrap, update=True)
590 return True
592 def set_diff_type(self, diff_type):
593 """Manage the image and text diff views when selection changes"""
594 # The "diff type" is whether the diff viewer is displaying an image.
595 self.options.set_diff_type(diff_type)
596 if diff_type == main.Types.IMAGE:
597 self.stack.setCurrentWidget(self.image)
598 self.search_widget.hide()
599 self.render()
600 else:
601 self.stack.setCurrentWidget(self.text)
603 def set_file_type(self, file_type):
604 """Manage the diff options when the file type changes"""
605 # The "file type" is whether the file itself is an image.
606 self.options.set_file_type(file_type)
608 def enable_filename_tracking(self):
609 """Enable displaying the currently selected filename"""
610 self.context.selection.selection_changed.connect(
611 self.update_filename, type=Qt.QueuedConnection
614 def update_filename(self):
615 """Update the filename display when the selection changes"""
616 filename = self.context.selection.filename()
617 self.filename.set_text(filename or '')
619 def update_options(self):
620 """Emit a signal indicating that options have changed"""
621 self.text.update_options()
622 show_filenames = get(self.options.show_filenames)
623 self.set_show_filenames(show_filenames)
625 def set_show_filenames(self, enabled, update=False):
626 """Enable/disable displaying the selected filename"""
627 self.filename.setVisible(enabled)
628 if update:
629 with qtutils.BlockSignals(self.options.show_filenames):
630 self.options.show_filenames.setChecked(enabled)
632 def set_line_numbers(self, enabled, update=False):
633 """Enable/disable line numbers in the text widget"""
634 self.text.set_line_numbers(enabled, update=update)
636 def set_word_wrapping(self, enabled, update=False):
637 """Enable/disable word wrapping in the text widget"""
638 self.text.set_word_wrapping(enabled, update=update)
640 def reset(self):
641 self.image.pixmap = QtGui.QPixmap()
642 self.cleanup()
644 def cleanup(self):
645 for image, unlink in self.images:
646 if unlink and core.exists(image):
647 os.unlink(image)
648 self.images = []
650 def set_images(self, images):
651 self.images = images
652 self.pixmaps = []
653 if not images:
654 self.reset()
655 return False
657 # In order to comp, we first have to load all the images
658 all_pixmaps = [QtGui.QPixmap(image[0]) for image in images]
659 pixmaps = [pixmap for pixmap in all_pixmaps if not pixmap.isNull()]
660 if not pixmaps:
661 self.reset()
662 return False
664 self.pixmaps = pixmaps
665 self.render()
666 self.cleanup()
667 return True
669 def render(self):
670 # Update images
671 if self.pixmaps:
672 mode = self.options.image_mode.currentIndex()
673 if mode == self.options.SIDE_BY_SIDE:
674 image = self.render_side_by_side()
675 elif mode == self.options.DIFF:
676 image = self.render_diff()
677 elif mode == self.options.XOR:
678 image = self.render_xor()
679 elif mode == self.options.PIXEL_XOR:
680 image = self.render_pixel_xor()
681 else:
682 image = self.render_side_by_side()
683 else:
684 image = QtGui.QPixmap()
685 self.image.pixmap = image
687 # Apply zoom
688 zoom_mode = self.options.zoom_mode.currentIndex()
689 zoom_factor = self.options.zoom_factors[zoom_mode][1]
690 if zoom_factor > 0.0:
691 self.image.resetTransform()
692 self.image.scale(zoom_factor, zoom_factor)
693 poly = self.image.mapToScene(self.image.viewport().rect())
694 self.image.last_scene_roi = poly.boundingRect()
696 def render_side_by_side(self):
697 # Side-by-side lineup comp
698 pixmaps = self.pixmaps
699 width = sum(pixmap.width() for pixmap in pixmaps)
700 height = max(pixmap.height() for pixmap in pixmaps)
701 image = create_image(width, height)
703 # Paint each pixmap
704 painter = create_painter(image)
705 x = 0
706 for pixmap in pixmaps:
707 painter.drawPixmap(x, 0, pixmap)
708 x += pixmap.width()
709 painter.end()
711 return image
713 def render_comp(self, comp_mode):
714 # Get the max size to use as the render canvas
715 pixmaps = self.pixmaps
716 if len(pixmaps) == 1:
717 return pixmaps[0]
719 width = max(pixmap.width() for pixmap in pixmaps)
720 height = max(pixmap.height() for pixmap in pixmaps)
721 image = create_image(width, height)
723 painter = create_painter(image)
724 for pixmap in (pixmaps[0], pixmaps[-1]):
725 x = (width - pixmap.width()) // 2
726 y = (height - pixmap.height()) // 2
727 painter.drawPixmap(x, y, pixmap)
728 painter.setCompositionMode(comp_mode)
729 painter.end()
731 return image
733 def render_diff(self):
734 comp_mode = QtGui.QPainter.CompositionMode_Difference
735 return self.render_comp(comp_mode)
737 def render_xor(self):
738 comp_mode = QtGui.QPainter.CompositionMode_Xor
739 return self.render_comp(comp_mode)
741 def render_pixel_xor(self):
742 comp_mode = QtGui.QPainter.RasterOp_SourceXorDestination
743 return self.render_comp(comp_mode)
746 def create_image(width, height):
747 size = QtCore.QSize(width, height)
748 image = QtGui.QImage(size, QtGui.QImage.Format_ARGB32_Premultiplied)
749 image.fill(Qt.transparent)
750 return image
753 def create_painter(image):
754 painter = QtGui.QPainter(image)
755 painter.fillRect(image.rect(), Qt.transparent)
756 return painter
759 class Options(QtWidgets.QWidget):
760 """Provide the options widget used by the editor
762 Actions are registered on the parent widget.
766 # mode combobox indexes
767 SIDE_BY_SIDE = 0
768 DIFF = 1
769 XOR = 2
770 PIXEL_XOR = 3
772 def __init__(self, parent):
773 super().__init__(parent)
774 # Create widgets
775 self.widget = parent
776 self.ignore_space_at_eol = self.add_option(
777 N_('Ignore changes in whitespace at EOL')
779 self.ignore_space_change = self.add_option(
780 N_('Ignore changes in amount of whitespace')
782 self.ignore_all_space = self.add_option(N_('Ignore all whitespace'))
783 self.function_context = self.add_option(
784 N_('Show whole surrounding functions of changes')
786 self.show_line_numbers = qtutils.add_action_bool(
787 self, N_('Show line numbers'), self.set_line_numbers, True
789 self.show_filenames = self.add_option(N_('Show filenames'))
790 self.enable_word_wrapping = qtutils.add_action_bool(
791 self, N_('Enable word wrapping'), self.set_word_wrapping, True
794 self.options = qtutils.create_action_button(
795 tooltip=N_('Diff Options'), icon=icons.configure()
798 self.toggle_image_diff = qtutils.create_action_button(
799 tooltip=N_('Toggle image diff'), icon=icons.visualize()
801 self.toggle_image_diff.hide()
803 self.image_mode = qtutils.combo(
804 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
807 self.zoom_factors = (
808 (N_('Zoom to Fit'), 0.0),
809 (N_('25%'), 0.25),
810 (N_('50%'), 0.5),
811 (N_('100%'), 1.0),
812 (N_('200%'), 2.0),
813 (N_('400%'), 4.0),
814 (N_('800%'), 8.0),
816 zoom_modes = [factor[0] for factor in self.zoom_factors]
817 self.zoom_mode = qtutils.combo(zoom_modes, parent=self)
819 self.menu = menu = qtutils.create_menu(N_('Diff Options'), self.options)
820 self.options.setMenu(menu)
821 menu.addAction(self.ignore_space_at_eol)
822 menu.addAction(self.ignore_space_change)
823 menu.addAction(self.ignore_all_space)
824 menu.addSeparator()
825 menu.addAction(self.function_context)
826 menu.addAction(self.show_line_numbers)
827 menu.addAction(self.show_filenames)
828 menu.addSeparator()
829 menu.addAction(self.enable_word_wrapping)
831 # Layouts
832 layout = qtutils.hbox(
833 defs.no_margin,
834 defs.button_spacing,
835 self.image_mode,
836 self.zoom_mode,
837 self.options,
838 self.toggle_image_diff,
840 self.setLayout(layout)
842 # Policies
843 self.image_mode.setFocusPolicy(Qt.NoFocus)
844 self.zoom_mode.setFocusPolicy(Qt.NoFocus)
845 self.options.setFocusPolicy(Qt.NoFocus)
846 self.toggle_image_diff.setFocusPolicy(Qt.NoFocus)
847 self.setFocusPolicy(Qt.NoFocus)
849 def set_file_type(self, file_type):
850 """Set whether we are viewing an image file type"""
851 is_image = file_type == main.Types.IMAGE
852 self.toggle_image_diff.setVisible(is_image)
854 def set_diff_type(self, diff_type):
855 """Toggle between image and text diffs"""
856 is_text = diff_type == main.Types.TEXT
857 is_image = diff_type == main.Types.IMAGE
858 self.options.setVisible(is_text)
859 self.image_mode.setVisible(is_image)
860 self.zoom_mode.setVisible(is_image)
861 if is_image:
862 self.toggle_image_diff.setIcon(icons.diff())
863 else:
864 self.toggle_image_diff.setIcon(icons.visualize())
866 def add_option(self, title):
867 """Add a diff option which calls update_options() on change"""
868 action = qtutils.add_action(self, title, self.update_options)
869 action.setCheckable(True)
870 return action
872 def update_options(self):
873 """Update diff options in response to UI events"""
874 space_at_eol = get(self.ignore_space_at_eol)
875 space_change = get(self.ignore_space_change)
876 all_space = get(self.ignore_all_space)
877 function_context = get(self.function_context)
878 gitcmds.update_diff_overrides(
879 space_at_eol, space_change, all_space, function_context
881 self.widget.update_options()
883 def set_line_numbers(self, value):
884 """Enable / disable line numbers"""
885 self.widget.set_line_numbers(value, update=False)
887 def set_word_wrapping(self, value):
888 """Respond to Qt action callbacks"""
889 self.widget.set_word_wrapping(value, update=False)
891 def hide_advanced_options(self):
892 """Hide advanced options that are not applicable to the DiffWidget"""
893 self.show_filenames.setVisible(False)
894 self.show_line_numbers.setVisible(False)
895 self.ignore_space_at_eol.setVisible(False)
896 self.ignore_space_change.setVisible(False)
897 self.ignore_all_space.setVisible(False)
898 self.function_context.setVisible(False)
901 # pylint: disable=too-many-ancestors
902 class DiffEditor(DiffTextEdit):
903 up = Signal()
904 down = Signal()
905 options_changed = Signal()
907 def __init__(self, context, options, parent):
908 DiffTextEdit.__init__(self, context, parent, numbers=True)
909 self.context = context
910 self.model = model = context.model
911 self.selection_model = selection_model = context.selection
913 # "Diff Options" tool menu
914 self.options = options
916 self.action_apply_selection = qtutils.add_action(
917 self,
918 'Apply',
919 self.apply_selection,
920 hotkeys.STAGE_DIFF,
921 hotkeys.STAGE_DIFF_ALT,
924 self.action_revert_selection = qtutils.add_action(
925 self, 'Revert', self.revert_selection, hotkeys.REVERT, hotkeys.REVERT_ALT
927 self.action_revert_selection.setIcon(icons.undo())
929 self.action_edit_and_apply_selection = qtutils.add_action(
930 self,
931 'Edit and Apply',
932 partial(self.apply_selection, edit=True),
933 hotkeys.EDIT_AND_STAGE_DIFF,
936 self.action_edit_and_revert_selection = qtutils.add_action(
937 self,
938 'Edit and Revert',
939 partial(self.revert_selection, edit=True),
940 hotkeys.EDIT_AND_REVERT,
942 self.action_edit_and_revert_selection.setIcon(icons.undo())
943 self.launch_editor = actions.launch_editor_at_line(
944 context, self, hotkeys.EDIT_SHORT, *hotkeys.ACCEPT
946 self.launch_difftool = actions.launch_difftool(context, self)
947 self.stage_or_unstage = actions.stage_or_unstage(context, self)
949 # Emit up/down signals so that they can be routed by the main widget
950 self.move_up = actions.move_up(self)
951 self.move_down = actions.move_down(self)
953 model.diff_text_updated.connect(self.set_diff, type=Qt.QueuedConnection)
955 selection_model.selection_changed.connect(
956 self.refresh, type=Qt.QueuedConnection
958 # Update the selection model when the cursor changes
959 self.cursorPositionChanged.connect(self._update_line_number)
961 qtutils.connect_button(options.toggle_image_diff, self.toggle_diff_type)
963 def toggle_diff_type(self):
964 cmds.do(cmds.ToggleDiffType, self.context)
966 def refresh(self):
967 enabled = False
968 s = self.selection_model.selection()
969 model = self.model
970 if model.is_partially_stageable():
971 item = s.modified[0] if s.modified else None
972 if item in model.submodules:
973 pass
974 elif item not in model.unstaged_deleted:
975 enabled = True
976 self.action_revert_selection.setEnabled(enabled)
978 def set_line_numbers(self, enabled, update=False):
979 """Enable/disable the diff line number display"""
980 self.numbers.setVisible(enabled)
981 if update:
982 with qtutils.BlockSignals(self.options.show_line_numbers):
983 self.options.show_line_numbers.setChecked(enabled)
984 # Refresh the display. Not doing this results in the display not
985 # correctly displaying the line numbers widget until the text scrolls.
986 self.set_value(self.value())
988 def update_options(self):
989 self.options_changed.emit()
991 def create_context_menu(self, event_pos):
992 """Override create_context_menu() to display a completely custom menu"""
993 menu = super().create_context_menu(event_pos)
994 context = self.context
995 model = self.model
996 s = self.selection_model.selection()
997 filename = self.selection_model.filename()
999 # These menu actions will be inserted at the start of the widget.
1000 current_actions = menu.actions()
1001 menu_actions = []
1002 add_action = menu_actions.append
1003 edit_actions_added = False
1004 stage_action_added = False
1006 if s.staged and model.is_unstageable():
1007 item = s.staged[0]
1008 if item not in model.submodules and item not in model.staged_deleted:
1009 if self.has_selection():
1010 apply_text = N_('Unstage Selected Lines')
1011 else:
1012 apply_text = N_('Unstage Diff Hunk')
1013 self.action_apply_selection.setText(apply_text)
1014 self.action_apply_selection.setIcon(icons.remove())
1015 add_action(self.action_apply_selection)
1016 stage_action_added = self._add_stage_or_unstage_action(
1017 menu, add_action, stage_action_added
1020 if model.is_partially_stageable():
1021 item = s.modified[0] if s.modified else None
1022 if item in model.submodules:
1023 path = core.abspath(item)
1024 action = qtutils.add_action_with_icon(
1025 menu,
1026 icons.add(),
1027 cmds.Stage.name(),
1028 cmds.run(cmds.Stage, context, s.modified),
1029 hotkeys.STAGE_SELECTION,
1031 add_action(action)
1032 stage_action_added = self._add_stage_or_unstage_action(
1033 menu, add_action, stage_action_added
1036 action = qtutils.add_action_with_icon(
1037 menu,
1038 icons.cola(),
1039 N_('Launch git-cola'),
1040 cmds.run(cmds.OpenRepo, context, path),
1042 add_action(action)
1043 elif item and item not in model.unstaged_deleted:
1044 if self.has_selection():
1045 apply_text = N_('Stage Selected Lines')
1046 edit_and_apply_text = N_('Edit Selected Lines to Stage...')
1047 revert_text = N_('Revert Selected Lines...')
1048 edit_and_revert_text = N_('Edit Selected Lines to Revert...')
1049 else:
1050 apply_text = N_('Stage Diff Hunk')
1051 edit_and_apply_text = N_('Edit Diff Hunk to Stage...')
1052 revert_text = N_('Revert Diff Hunk...')
1053 edit_and_revert_text = N_('Edit Diff Hunk to Revert...')
1055 self.action_apply_selection.setText(apply_text)
1056 self.action_apply_selection.setIcon(icons.add())
1057 add_action(self.action_apply_selection)
1059 self.action_revert_selection.setText(revert_text)
1060 add_action(self.action_revert_selection)
1062 stage_action_added = self._add_stage_or_unstage_action(
1063 menu, add_action, stage_action_added
1065 # Do not show the "edit" action when the file does not exist.
1066 add_action(qtutils.menu_separator(menu))
1067 if filename and core.exists(filename):
1068 add_action(self.launch_editor)
1069 # Removed files can still be diffed.
1070 add_action(self.launch_difftool)
1071 edit_actions_added = True
1073 add_action(qtutils.menu_separator(menu))
1074 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1075 self.action_edit_and_apply_selection.setIcon(icons.add())
1076 add_action(self.action_edit_and_apply_selection)
1078 self.action_edit_and_revert_selection.setText(edit_and_revert_text)
1079 add_action(self.action_edit_and_revert_selection)
1081 if s.staged and model.is_unstageable():
1082 item = s.staged[0]
1083 if item in model.submodules:
1084 path = core.abspath(item)
1085 action = qtutils.add_action_with_icon(
1086 menu,
1087 icons.remove(),
1088 cmds.Unstage.name(),
1089 cmds.run(cmds.Unstage, context, s.staged),
1090 hotkeys.STAGE_SELECTION,
1092 add_action(action)
1094 stage_action_added = self._add_stage_or_unstage_action(
1095 menu, add_action, stage_action_added
1098 qtutils.add_action_with_icon(
1099 menu,
1100 icons.cola(),
1101 N_('Launch git-cola'),
1102 cmds.run(cmds.OpenRepo, context, path),
1104 add_action(action)
1106 elif item not in model.staged_deleted:
1107 # Do not show the "edit" action when the file does not exist.
1108 add_action(qtutils.menu_separator(menu))
1109 if filename and core.exists(filename):
1110 add_action(self.launch_editor)
1111 # Removed files can still be diffed.
1112 add_action(self.launch_difftool)
1113 add_action(qtutils.menu_separator(menu))
1114 edit_actions_added = True
1116 if self.has_selection():
1117 edit_and_apply_text = N_('Edit Selected Lines to Unstage...')
1118 else:
1119 edit_and_apply_text = N_('Edit Diff Hunk to Unstage...')
1120 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1121 self.action_edit_and_apply_selection.setIcon(icons.remove())
1122 add_action(self.action_edit_and_apply_selection)
1124 if not edit_actions_added and (model.is_stageable() or model.is_unstageable()):
1125 add_action(qtutils.menu_separator(menu))
1126 # Do not show the "edit" action when the file does not exist.
1127 # Untracked files exist by definition.
1128 if filename and core.exists(filename):
1129 add_action(self.launch_editor)
1131 # Removed files can still be diffed.
1132 add_action(self.launch_difftool)
1134 add_action(qtutils.menu_separator(menu))
1135 _add_patch_actions(self, self.context, menu)
1137 # Add the Previous/Next File actions, which improves discoverability
1138 # of their associated shortcuts
1139 add_action(qtutils.menu_separator(menu))
1140 add_action(self.move_up)
1141 add_action(self.move_down)
1142 add_action(qtutils.menu_separator(menu))
1144 if current_actions:
1145 first_action = current_actions[0]
1146 else:
1147 first_action = None
1148 menu.insertActions(first_action, menu_actions)
1150 return menu
1152 def _add_stage_or_unstage_action(self, menu, add_action, already_added):
1153 """Add the Stage / Unstage menu action"""
1154 if already_added:
1155 return True
1156 model = self.context.model
1157 s = self.selection_model.selection()
1158 if model.is_stageable() or model.is_unstageable():
1159 if (model.is_amend_mode() and s.staged) or not self.model.is_stageable():
1160 self.stage_or_unstage.setText(N_('Unstage'))
1161 self.stage_or_unstage.setIcon(icons.remove())
1162 else:
1163 self.stage_or_unstage.setText(N_('Stage'))
1164 self.stage_or_unstage.setIcon(icons.add())
1165 add_action(qtutils.menu_separator(menu))
1166 add_action(self.stage_or_unstage)
1167 return True
1169 def mousePressEvent(self, event):
1170 if event.button() == Qt.RightButton:
1171 # Intercept right-click to move the cursor to the current position.
1172 # setTextCursor() clears the selection so this is only done when
1173 # nothing is selected.
1174 if not self.has_selection():
1175 cursor = self.cursorForPosition(event.pos())
1176 self.setTextCursor(cursor)
1178 return super().mousePressEvent(event)
1180 def setPlainText(self, text):
1181 """setPlainText(str) while retaining scrollbar positions"""
1182 model = self.model
1183 mode = model.mode
1184 highlight = mode not in (
1185 model.mode_none,
1186 model.mode_display,
1187 model.mode_untracked,
1189 self.highlighter.set_enabled(highlight)
1191 scrollbar = self.verticalScrollBar()
1192 if scrollbar:
1193 scrollvalue = get(scrollbar)
1194 else:
1195 scrollvalue = None
1197 if text is None:
1198 return
1200 DiffTextEdit.setPlainText(self, text)
1202 if scrollbar and scrollvalue is not None:
1203 scrollbar.setValue(scrollvalue)
1205 def apply_selection(self, *, edit=False):
1206 model = self.model
1207 s = self.selection_model.single_selection()
1208 if model.is_partially_stageable() and (s.modified or s.untracked):
1209 self.process_diff_selection(edit=edit)
1210 elif model.is_unstageable():
1211 self.process_diff_selection(reverse=True, edit=edit)
1213 def revert_selection(self, *, edit=False):
1214 """Destructively revert selected lines or hunk from a worktree file."""
1216 if not edit:
1217 if self.has_selection():
1218 title = N_('Revert Selected Lines?')
1219 ok_text = N_('Revert Selected Lines')
1220 else:
1221 title = N_('Revert Diff Hunk?')
1222 ok_text = N_('Revert Diff Hunk')
1224 if not Interaction.confirm(
1225 title,
1227 'This operation drops uncommitted changes.\n'
1228 'These changes cannot be recovered.'
1230 N_('Revert the uncommitted changes?'),
1231 ok_text,
1232 default=True,
1233 icon=icons.undo(),
1235 return
1236 self.process_diff_selection(reverse=True, apply_to_worktree=True, edit=edit)
1238 def extract_patch(self, reverse=False):
1239 first_line_idx, last_line_idx = self.selected_lines()
1240 patch = diffparse.Patch.parse(self.model.filename, self.model.diff_text)
1241 if self.has_selection():
1242 return patch.extract_subset(first_line_idx, last_line_idx, reverse=reverse)
1243 return patch.extract_hunk(first_line_idx, reverse=reverse)
1245 def patch_encoding(self):
1246 if isinstance(self.model.diff_text, core.UStr):
1247 # original encoding must prevail
1248 return self.model.diff_text.encoding
1249 return self.context.cfg.file_encoding(self.model.filename)
1251 def process_diff_selection(
1252 self, reverse=False, apply_to_worktree=False, edit=False
1254 """Implement un/staging of the selected line(s) or hunk."""
1255 if self.selection_model.is_empty():
1256 return
1257 patch = self.extract_patch(reverse)
1258 if not patch.has_changes():
1259 return
1260 patch_encoding = self.patch_encoding()
1262 if edit:
1263 patch = edit_patch(
1264 patch,
1265 patch_encoding,
1266 self.context,
1267 reverse=reverse,
1268 apply_to_worktree=apply_to_worktree,
1270 if not patch.has_changes():
1271 return
1273 cmds.do(
1274 cmds.ApplyPatch,
1275 self.context,
1276 patch,
1277 patch_encoding,
1278 apply_to_worktree,
1281 def _update_line_number(self):
1282 """Update the selection model when the cursor changes"""
1283 self.selection_model.line_number = self.numbers.current_line()
1286 def _add_patch_actions(widget, context, menu):
1287 """Add actions for manipulating patch files"""
1288 patches_menu = menu.addMenu(N_('Patches'))
1289 patches_menu.setIcon(icons.diff())
1290 export_action = qtutils.add_action(
1291 patches_menu,
1292 N_('Export Patch'),
1293 lambda: _export_patch(widget, context),
1295 export_action.setIcon(icons.save())
1296 patches_menu.addAction(export_action)
1298 # Build the "Append Patch" menu dynamically.
1299 append_menu = patches_menu.addMenu(N_('Append Patch'))
1300 append_menu.setIcon(icons.add())
1301 append_menu.aboutToShow.connect(
1302 lambda: _build_patch_append_menu(widget, context, append_menu)
1306 def _build_patch_append_menu(widget, context, menu):
1307 """Build the "Append Patch" submenu"""
1308 # Build the menu when first displayed only. This initial check avoids
1309 # re-populating the menu with duplicate actions.
1310 menu_actions = menu.actions()
1311 if menu_actions:
1312 return
1314 choose_patch_action = qtutils.add_action(
1315 menu,
1316 N_('Choose Patch...'),
1317 lambda: _export_patch(widget, context, append=True),
1319 choose_patch_action.setIcon(icons.diff())
1320 menu.addAction(choose_patch_action)
1322 subdir_menus = {}
1323 path = prefs.patches_directory(context)
1324 patches = get_patches_from_dir(path)
1325 for patch in patches:
1326 relpath = os.path.relpath(patch, start=path)
1327 sub_menu = _add_patch_subdirs(menu, subdir_menus, relpath)
1328 patch_basename = os.path.basename(relpath)
1329 append_action = qtutils.add_action(
1330 sub_menu,
1331 patch_basename,
1332 lambda patch_file=patch: _append_patch(widget, patch_file),
1334 append_action.setIcon(icons.save())
1335 sub_menu.addAction(append_action)
1338 def _add_patch_subdirs(menu, subdir_menus, relpath):
1339 """Build menu leading up to the patch"""
1340 # If the path contains no directory separators then add it to the
1341 # root of the menu.
1342 if os.sep not in relpath:
1343 return menu
1345 # Loop over each directory component and build a menu if it doesn't already exist.
1346 components = []
1347 for dirname in os.path.dirname(relpath).split(os.sep):
1348 components.append(dirname)
1349 current_dir = os.sep.join(components)
1350 try:
1351 menu = subdir_menus[current_dir]
1352 except KeyError:
1353 menu = subdir_menus[current_dir] = menu.addMenu(dirname)
1354 menu.setIcon(icons.folder())
1356 return menu
1359 def _export_patch(diff_editor, context, append=False):
1360 """Export the selected diff to a patch file"""
1361 if diff_editor.selection_model.is_empty():
1362 return
1363 patch = diff_editor.extract_patch(reverse=False)
1364 if not patch.has_changes():
1365 return
1366 directory = prefs.patches_directory(context)
1367 if append:
1368 filename = qtutils.existing_file(directory, title=N_('Append Patch...'))
1369 else:
1370 default_filename = os.path.join(directory, 'diff.patch')
1371 filename = qtutils.save_as(default_filename)
1372 if not filename:
1373 return
1374 _write_patch_to_file(diff_editor, patch, filename, append=append)
1377 def _append_patch(diff_editor, filename):
1378 """Append diffs to the specified patch file"""
1379 if diff_editor.selection_model.is_empty():
1380 return
1381 patch = diff_editor.extract_patch(reverse=False)
1382 if not patch.has_changes():
1383 return
1384 _write_patch_to_file(diff_editor, patch, filename, append=True)
1387 def _write_patch_to_file(diff_editor, patch, filename, append=False):
1388 """Write diffs from the Diff Editor to the specified patch file"""
1389 encoding = diff_editor.patch_encoding()
1390 content = patch.as_text()
1391 try:
1392 core.write(filename, content, encoding=encoding, append=append)
1393 except OSError as exc:
1394 _, details = utils.format_exception(exc)
1395 title = N_('Error writing patch')
1396 msg = N_('Unable to write patch to "%s". Check permissions?' % filename)
1397 Interaction.critical(title, message=msg, details=details)
1398 return
1399 Interaction.log('Patch written to "%s"' % filename)
1402 class DiffWidget(QtWidgets.QWidget):
1403 """Display commit metadata and text diffs"""
1405 def __init__(self, context, parent, is_commit=False, options=None):
1406 QtWidgets.QWidget.__init__(self, parent)
1408 self.context = context
1409 self.oid = 'HEAD'
1410 self.oid_start = None
1411 self.oid_end = None
1412 self.options = options
1414 author_font = QtGui.QFont(self.font())
1415 author_font.setPointSize(int(author_font.pointSize() * 1.1))
1417 summary_font = QtGui.QFont(author_font)
1418 summary_font.setWeight(QtGui.QFont.Bold)
1420 self.gravatar_label = gravatar.GravatarLabel(self.context, parent=self)
1422 self.oid_label = PlainTextLabel(parent=self)
1423 self.oid_label.setAlignment(Qt.AlignBottom)
1424 self.oid_label.elide()
1426 self.author_label = RichTextLabel(parent=self)
1427 self.author_label.setFont(author_font)
1428 self.author_label.setAlignment(Qt.AlignTop)
1429 self.author_label.elide()
1431 self.date_label = PlainTextLabel(parent=self)
1432 self.date_label.setAlignment(Qt.AlignTop)
1433 self.date_label.elide()
1435 self.summary_label = PlainTextLabel(parent=self)
1436 self.summary_label.setFont(summary_font)
1437 self.summary_label.setAlignment(Qt.AlignTop)
1438 self.summary_label.elide()
1440 self.diff = DiffTextEdit(context, self, is_commit=is_commit, whitespace=False)
1441 self.setFocusProxy(self.diff)
1443 self.info_layout = qtutils.vbox(
1444 defs.no_margin,
1445 defs.no_spacing,
1446 self.oid_label,
1447 self.author_label,
1448 self.date_label,
1449 self.summary_label,
1452 self.logo_layout = qtutils.hbox(
1453 defs.no_margin, defs.button_spacing, self.gravatar_label, self.info_layout
1455 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
1457 self.main_layout = qtutils.vbox(
1458 defs.no_margin, defs.spacing, self.logo_layout, self.diff
1460 self.setLayout(self.main_layout)
1462 self.set_tabwidth(prefs.tabwidth(context))
1464 def set_tabwidth(self, width):
1465 self.diff.set_tabwidth(width)
1467 def set_word_wrapping(self, enabled, update=False):
1468 """Enable and disable word wrapping"""
1469 self.diff.set_word_wrapping(enabled, update=update)
1471 def set_options(self, options):
1472 """Register an options widget"""
1473 self.options = options
1474 self.diff.set_options(options)
1476 def start_diff_task(self, task):
1477 """Clear the display and start a diff-gathering task"""
1478 self.diff.save_scrollbar()
1479 self.diff.set_loading_message()
1480 self.context.runtask.start(task, result=self.set_diff)
1482 def set_diff_oid(self, oid, filename=None):
1483 """Set the diff from a single commit object ID"""
1484 task = DiffInfoTask(self.context, oid, filename)
1485 self.start_diff_task(task)
1487 def set_diff_range(self, start, end, filename=None):
1488 task = DiffRangeTask(self.context, start + '~', end, filename)
1489 self.start_diff_task(task)
1491 def commits_selected(self, commits):
1492 """Display an appropriate diff when commits are selected"""
1493 if not commits:
1494 self.clear()
1495 return
1496 commit = commits[-1]
1497 oid = commit.oid
1498 author = commit.author or ''
1499 email = commit.email or ''
1500 date = commit.authdate or ''
1501 summary = commit.summary or ''
1502 self.set_details(oid, author, email, date, summary)
1503 self.oid = oid
1505 if len(commits) > 1:
1506 start, end = commits[0], commits[-1]
1507 self.set_diff_range(start.oid, end.oid)
1508 self.oid_start = start
1509 self.oid_end = end
1510 else:
1511 self.set_diff_oid(oid)
1512 self.oid_start = None
1513 self.oid_end = None
1515 def set_diff(self, diff):
1516 """Set the diff text"""
1517 self.diff.set_diff(diff)
1519 def set_details(self, oid, author, email, date, summary):
1520 template_args = {'author': author, 'email': email, 'summary': summary}
1521 author_text = (
1522 """%(author)s &lt;"""
1523 """<a href="mailto:%(email)s">"""
1524 """%(email)s</a>&gt;""" % template_args
1526 author_template = '%(author)s <%(email)s>' % template_args
1528 self.date_label.set_text(date)
1529 self.date_label.setVisible(bool(date))
1530 self.oid_label.set_text(oid)
1531 self.author_label.set_template(author_text, author_template)
1532 self.summary_label.set_text(summary)
1533 self.gravatar_label.set_email(email)
1535 def clear(self):
1536 self.date_label.set_text('')
1537 self.oid_label.set_text('')
1538 self.author_label.set_text('')
1539 self.summary_label.set_text('')
1540 self.gravatar_label.clear()
1541 self.diff.clear()
1543 def files_selected(self, filenames):
1544 """Update the view when a filename is selected"""
1545 if not filenames:
1546 return
1547 oid_start = self.oid_start
1548 oid_end = self.oid_end
1549 if oid_start and oid_end:
1550 self.set_diff_range(oid_start.oid, oid_end.oid, filename=filenames[0])
1551 else:
1552 self.set_diff_oid(self.oid, filename=filenames[0])
1555 class DiffPanel(QtWidgets.QWidget):
1556 """A combined diff + search panel"""
1558 def __init__(self, diff_widget, text_widget, parent):
1559 super().__init__(parent)
1560 self.diff_widget = diff_widget
1561 self.search_widget = TextSearchWidget(text_widget, self)
1562 self.search_widget.hide()
1563 layout = qtutils.vbox(
1564 defs.no_margin, defs.spacing, self.diff_widget, self.search_widget
1566 self.setLayout(layout)
1567 self.setFocusProxy(self.diff_widget)
1569 self.search_action = qtutils.add_action(
1570 self,
1571 N_('Search in Diff'),
1572 self.show_search,
1573 hotkeys.SEARCH,
1576 def show_search(self):
1577 """Show a dialog for searching diffs"""
1578 # The diff search is only active in text mode.
1579 if not self.search_widget.isVisible():
1580 self.search_widget.show()
1581 self.search_widget.setFocus()
1584 class DiffInfoTask(qtutils.Task):
1585 """Gather diffs for a single commit"""
1587 def __init__(self, context, oid, filename):
1588 qtutils.Task.__init__(self)
1589 self.context = context
1590 self.oid = oid
1591 self.filename = filename
1593 def task(self):
1594 context = self.context
1595 oid = self.oid
1596 return gitcmds.diff_info(context, oid, filename=self.filename)
1599 class DiffRangeTask(qtutils.Task):
1600 """Gather diffs for a range of commits"""
1602 def __init__(self, context, start, end, filename):
1603 qtutils.Task.__init__(self)
1604 self.context = context
1605 self.start = start
1606 self.end = end
1607 self.filename = filename
1609 def task(self):
1610 context = self.context
1611 return gitcmds.diff_range(context, self.start, self.end, filename=self.filename)
1614 def apply_patches(context, patches=None):
1615 """Open the ApplyPatches dialog"""
1616 parent = qtutils.active_window()
1617 dlg = new_apply_patches(context, patches=patches, parent=parent)
1618 dlg.show()
1619 dlg.raise_()
1620 return dlg
1623 def new_apply_patches(context, patches=None, parent=None):
1624 """Create a new instances of the ApplyPatches dialog"""
1625 dlg = ApplyPatches(context, parent=parent)
1626 if patches:
1627 dlg.add_paths(patches)
1628 return dlg
1631 def get_patches_from_paths(paths):
1632 """Returns all patches benath a given path"""
1633 paths = [core.decode(p) for p in paths]
1634 patches = [p for p in paths if core.isfile(p) and p.endswith(('.patch', '.mbox'))]
1635 dirs = [p for p in paths if core.isdir(p)]
1636 dirs.sort()
1637 for d in dirs:
1638 patches.extend(get_patches_from_dir(d))
1639 return patches
1642 def get_patches_from_mimedata(mimedata):
1643 """Extract path files from a QMimeData payload"""
1644 urls = mimedata.urls()
1645 if not urls:
1646 return []
1647 paths = [x.path() for x in urls]
1648 return get_patches_from_paths(paths)
1651 def get_patches_from_dir(path):
1652 """Find patches in a subdirectory"""
1653 patches = []
1654 for root, _, files in core.walk(path):
1655 for name in [f for f in files if f.endswith(('.patch', '.mbox'))]:
1656 patches.append(core.decode(os.path.join(root, name)))
1657 return patches
1660 class ApplyPatches(standard.Dialog):
1661 def __init__(self, context, parent=None):
1662 super().__init__(parent=parent)
1663 self.context = context
1664 self.setWindowTitle(N_('Apply Patches'))
1665 self.setAcceptDrops(True)
1666 if parent is not None:
1667 self.setWindowModality(Qt.WindowModal)
1669 self.curdir = core.getcwd()
1670 self.inner_drag = False
1672 self.usage = QtWidgets.QLabel()
1673 self.usage.setText(
1677 Drag and drop or use the <strong>Add</strong> button to add
1678 patches to the list
1679 </p>
1684 self.tree = PatchTreeWidget(parent=self)
1685 self.tree.setHeaderHidden(True)
1686 # pylint: disable=no-member
1687 self.tree.itemSelectionChanged.connect(self._tree_selection_changed)
1689 self.diffwidget = DiffWidget(context, self, is_commit=True)
1691 self.add_button = qtutils.create_toolbutton(
1692 text=N_('Add'), icon=icons.add(), tooltip=N_('Add patches (+)')
1695 self.remove_button = qtutils.create_toolbutton(
1696 text=N_('Remove'),
1697 icon=icons.remove(),
1698 tooltip=N_('Remove selected (Delete)'),
1701 self.apply_button = qtutils.create_button(text=N_('Apply'), icon=icons.ok())
1703 self.close_button = qtutils.close_button()
1705 self.add_action = qtutils.add_action(
1706 self, N_('Add'), self.add_files, hotkeys.ADD_ITEM
1709 self.remove_action = qtutils.add_action(
1710 self,
1711 N_('Remove'),
1712 self.tree.remove_selected,
1713 hotkeys.DELETE,
1714 hotkeys.BACKSPACE,
1715 hotkeys.REMOVE_ITEM,
1718 self.top_layout = qtutils.hbox(
1719 defs.no_margin,
1720 defs.button_spacing,
1721 self.add_button,
1722 self.remove_button,
1723 qtutils.STRETCH,
1724 self.usage,
1727 self.bottom_layout = qtutils.hbox(
1728 defs.no_margin,
1729 defs.button_spacing,
1730 qtutils.STRETCH,
1731 self.close_button,
1732 self.apply_button,
1735 self.splitter = qtutils.splitter(Qt.Vertical, self.tree, self.diffwidget)
1737 self.main_layout = qtutils.vbox(
1738 defs.margin,
1739 defs.spacing,
1740 self.top_layout,
1741 self.splitter,
1742 self.bottom_layout,
1744 self.setLayout(self.main_layout)
1746 qtutils.connect_button(self.add_button, self.add_files)
1747 qtutils.connect_button(self.remove_button, self.tree.remove_selected)
1748 qtutils.connect_button(self.apply_button, self.apply_patches)
1749 qtutils.connect_button(self.close_button, self.close)
1751 self.init_state(None, self.resize, 666, 420)
1753 def apply_patches(self):
1754 items = self.tree.items()
1755 if not items:
1756 return
1757 context = self.context
1758 patches = [i.data(0, Qt.UserRole) for i in items]
1759 cmds.do(cmds.ApplyPatches, context, patches)
1760 self.accept()
1762 def add_files(self):
1763 files = qtutils.open_files(
1764 N_('Select patch file(s)...'),
1765 directory=self.curdir,
1766 filters='Patches (*.patch *.mbox)',
1768 if not files:
1769 return
1770 self.curdir = os.path.dirname(files[0])
1771 self.add_paths([core.relpath(f) for f in files])
1773 def dragEnterEvent(self, event):
1774 """Accepts drops if the mimedata contains patches"""
1775 super().dragEnterEvent(event)
1776 patches = get_patches_from_mimedata(event.mimeData())
1777 if patches:
1778 event.acceptProposedAction()
1780 def dropEvent(self, event):
1781 """Add dropped patches"""
1782 event.accept()
1783 patches = get_patches_from_mimedata(event.mimeData())
1784 if not patches:
1785 return
1786 self.add_paths(patches)
1788 def add_paths(self, paths):
1789 self.tree.add_paths(paths)
1791 def _tree_selection_changed(self):
1792 items = self.tree.selected_items()
1793 if not items:
1794 return
1795 item = items[-1] # take the last item
1796 path = item.data(0, Qt.UserRole)
1797 if not core.exists(path):
1798 return
1799 commit = parse_patch(path)
1800 self.diffwidget.set_details(
1801 commit.oid, commit.author, commit.email, commit.date, commit.summary
1803 self.diffwidget.set_diff(commit.diff)
1805 def export_state(self):
1806 """Export persistent settings"""
1807 state = super().export_state()
1808 state['sizes'] = get(self.splitter)
1809 return state
1811 def apply_state(self, state):
1812 """Apply persistent settings"""
1813 result = super().apply_state(state)
1814 try:
1815 self.splitter.setSizes(state['sizes'])
1816 except (AttributeError, KeyError, ValueError, TypeError):
1817 pass
1818 return result
1821 # pylint: disable=too-many-ancestors
1822 class PatchTreeWidget(standard.DraggableTreeWidget):
1823 def add_paths(self, paths):
1824 patches = get_patches_from_paths(paths)
1825 if not patches:
1826 return
1827 items = []
1828 icon = icons.file_text()
1829 for patch in patches:
1830 item = QtWidgets.QTreeWidgetItem()
1831 flags = item.flags() & ~Qt.ItemIsDropEnabled
1832 item.setFlags(flags)
1833 item.setIcon(0, icon)
1834 item.setText(0, os.path.basename(patch))
1835 item.setData(0, Qt.UserRole, patch)
1836 item.setToolTip(0, patch)
1837 items.append(item)
1838 self.addTopLevelItems(items)
1840 def remove_selected(self):
1841 idxs = self.selectedIndexes()
1842 rows = [idx.row() for idx in idxs]
1843 for row in reversed(sorted(rows)):
1844 self.invisibleRootItem().takeChild(row)
1847 class Commit:
1848 """Container for commit details"""
1850 def __init__(self):
1851 self.content = ''
1852 self.author = ''
1853 self.email = ''
1854 self.oid = ''
1855 self.summary = ''
1856 self.diff = ''
1857 self.date = ''
1860 def parse_patch(path):
1861 content = core.read(path)
1862 commit = Commit()
1863 parse(content, commit)
1864 return commit
1867 def parse(content, commit):
1868 """Parse commit details from a patch"""
1869 from_rgx = re.compile(r'^From (?P<oid>[a-f0-9]{40}) .*$')
1870 author_rgx = re.compile(r'^From: (?P<author>[^<]+) <(?P<email>[^>]+)>$')
1871 date_rgx = re.compile(r'^Date: (?P<date>.*)$')
1872 subject_rgx = re.compile(r'^Subject: (?P<summary>.*)$')
1874 commit.content = content
1876 lines = content.splitlines()
1877 for idx, line in enumerate(lines):
1878 match = from_rgx.match(line)
1879 if match:
1880 commit.oid = match.group('oid')
1881 continue
1883 match = author_rgx.match(line)
1884 if match:
1885 commit.author = match.group('author')
1886 commit.email = match.group('email')
1887 continue
1889 match = date_rgx.match(line)
1890 if match:
1891 commit.date = match.group('date')
1892 continue
1894 match = subject_rgx.match(line)
1895 if match:
1896 commit.summary = match.group('summary')
1897 commit.diff = '\n'.join(lines[idx + 1 :])
1898 break