resources: remove unnecessary os.path.dirname import
[git-cola.git] / cola / widgets / diff.py
blob89bd2b5262d904a70981107991b8d11e4db4450e
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 class DiffTextEdit(VimHintedPlainTextEdit):
189 """A textedit for interacting with diff text"""
191 def __init__(
192 self, context, parent, is_commit=False, whitespace=True, numbers=False
194 VimHintedPlainTextEdit.__init__(self, context, '', parent=parent)
195 # Diff/patch syntax highlighter
196 self.highlighter = DiffSyntaxHighlighter(
197 context, self.document(), is_commit=is_commit, whitespace=whitespace
199 if numbers:
200 self.numbers = DiffLineNumbers(context, self)
201 self.numbers.hide()
202 else:
203 self.numbers = None
204 self.scrollvalue = None
206 self.copy_diff_action = qtutils.add_action(
207 self,
208 N_('Copy Diff'),
209 self.copy_diff,
210 hotkeys.COPY_DIFF,
212 self.copy_diff_action.setIcon(icons.copy())
213 self.copy_diff_action.setEnabled(False)
214 self.menu_actions.append(self.copy_diff_action)
215 self.cursorPositionChanged.connect(self._cursor_changed)
216 self.selectionChanged.connect(self._selection_changed)
218 def setFont(self, font):
219 """Override setFont() so that we can use a custom "block" cursor"""
220 super().setFont(font)
221 if prefs.block_cursor(self.context):
222 width = qtutils.text_width(font, 'M')
223 self.setCursorWidth(width)
225 def _cursor_changed(self):
226 """Update the line number display when the cursor changes"""
227 line_number = max(0, self.textCursor().blockNumber())
228 if self.numbers is not None:
229 self.numbers.set_highlighted(line_number)
231 def _selection_changed(self):
232 """Respond to selection changes"""
233 selected = bool(self.selected_text())
234 self.copy_diff_action.setEnabled(selected)
236 def resizeEvent(self, event):
237 super().resizeEvent(event)
238 if self.numbers:
239 self.numbers.refresh_size()
241 def save_scrollbar(self):
242 """Save the scrollbar value, but only on the first call"""
243 if self.scrollvalue is None:
244 scrollbar = self.verticalScrollBar()
245 if scrollbar:
246 scrollvalue = get(scrollbar)
247 else:
248 scrollvalue = None
249 self.scrollvalue = scrollvalue
251 def restore_scrollbar(self):
252 """Restore the scrollbar and clear state"""
253 scrollbar = self.verticalScrollBar()
254 scrollvalue = self.scrollvalue
255 if scrollbar and scrollvalue is not None:
256 scrollbar.setValue(scrollvalue)
257 self.scrollvalue = None
259 def set_loading_message(self):
260 """Add a pending loading message in the diff view"""
261 self.hint.set_value('+++ ' + N_('Loading...'))
262 self.set_value('')
264 def set_diff(self, diff):
265 """Set the diff text, but save the scrollbar"""
266 diff = diff.rstrip('\n') # diffs include two empty newlines
267 self.save_scrollbar()
269 self.hint.set_value('')
270 if self.numbers:
271 self.numbers.set_diff(diff)
272 self.set_value(diff)
274 self.restore_scrollbar()
276 def selected_diff_stripped(self):
277 """Return the selected diff stripped of any diff characters"""
278 sep, selection = self.selected_text_lines()
279 return sep.join(_strip_diff(line) for line in selection)
281 def copy_diff(self):
282 """Copy the selected diff text stripped of any diff prefix characters"""
283 text = self.selected_diff_stripped()
284 qtutils.set_clipboard(text)
286 def selected_lines(self):
287 """Return selected lines"""
288 cursor = self.textCursor()
289 selection_start = cursor.selectionStart()
290 selection_end = max(selection_start, cursor.selectionEnd() - 1)
292 first_line_idx = -1
293 last_line_idx = -1
294 line_idx = 0
295 line_start = 0
297 for line_idx, line in enumerate(get(self, default='').splitlines()):
298 line_end = line_start + len(line)
299 if line_start <= selection_start <= line_end:
300 first_line_idx = line_idx
301 if line_start <= selection_end <= line_end:
302 last_line_idx = line_idx
303 break
304 line_start = line_end + 1
306 if first_line_idx == -1:
307 first_line_idx = line_idx
309 if last_line_idx == -1:
310 last_line_idx = line_idx
312 return first_line_idx, last_line_idx
314 def selected_text_lines(self):
315 """Return selected lines and the CRLF / LF separator"""
316 first_line_idx, last_line_idx = self.selected_lines()
317 text = get(self, default='')
318 sep = _get_sep(text)
319 lines = []
320 for line_idx, line in enumerate(text.split(sep)):
321 if first_line_idx <= line_idx <= last_line_idx:
322 lines.append(line)
323 return sep, lines
326 def _get_sep(text):
327 """Return either CRLF or LF based on the content"""
328 if '\r\n' in text:
329 sep = '\r\n'
330 else:
331 sep = '\n'
332 return sep
335 def _strip_diff(value):
336 """Remove +/-/<space> from a selection"""
337 if value.startswith(('+', '-', ' ')):
338 return value[1:]
339 return value
342 class DiffLineNumbers(TextDecorator):
343 def __init__(self, context, parent):
344 TextDecorator.__init__(self, parent)
345 self.highlight_line = -1
346 self.lines = None
347 self.parser = diffparse.DiffLines()
348 self.formatter = diffparse.FormatDigits()
350 font = qtutils.diff_font(context)
351 self.setFont(font)
352 self._char_width = qtutils.text_width(font, 'M')
354 QPalette = QtGui.QPalette
355 self._palette = palette = self.palette()
356 self._base = palette.color(QtGui.QPalette.Base)
357 self._highlight = palette.color(QPalette.Highlight)
358 self._highlight.setAlphaF(0.3)
359 self._highlight_text = palette.color(QPalette.HighlightedText)
360 self._window = palette.color(QPalette.Window)
361 self._disabled = palette.color(QPalette.Disabled, QPalette.Text)
363 def set_diff(self, diff):
364 self.lines = self.parser.parse(diff)
365 self.formatter.set_digits(self.parser.digits())
367 def width_hint(self):
368 if not self.isVisible():
369 return 0
370 parser = self.parser
372 if parser.merge:
373 columns = 3
374 extra = 3 # one space in-between, one space after
375 else:
376 columns = 2
377 extra = 2 # one space in-between, one space after
379 digits = parser.digits() * columns
381 return defs.margin + (self._char_width * (digits + extra))
383 def set_highlighted(self, line_number):
384 """Set the line to highlight"""
385 self.highlight_line = line_number
387 def current_line(self):
388 lines = self.lines
389 if lines and self.highlight_line >= 0:
390 # Find the next valid line
391 for i in range(self.highlight_line, len(lines)):
392 # take the "new" line number: last value in tuple
393 line_number = lines[i][-1]
394 if line_number > 0:
395 return line_number
397 # Find the previous valid line
398 for i in range(self.highlight_line - 1, -1, -1):
399 # take the "new" line number: last value in tuple
400 if i < len(lines):
401 line_number = lines[i][-1]
402 if line_number > 0:
403 return line_number
404 return None
406 def paintEvent(self, event):
407 """Paint the line number"""
408 if not self.lines:
409 return
411 painter = QtGui.QPainter(self)
412 painter.fillRect(event.rect(), self._base)
414 editor = self.editor
415 content_offset = editor.contentOffset()
416 block = editor.firstVisibleBlock()
417 width = self.width()
418 text_width = width - (defs.margin * 2)
419 text_flags = Qt.AlignRight | Qt.AlignVCenter
420 event_rect_bottom = event.rect().bottom()
422 highlight_line = self.highlight_line
423 highlight = self._highlight
424 highlight_text = self._highlight_text
425 disabled = self._disabled
427 fmt = self.formatter
428 lines = self.lines
429 num_lines = len(lines)
431 while block.isValid():
432 block_number = block.blockNumber()
433 if block_number >= num_lines:
434 break
435 block_geom = editor.blockBoundingGeometry(block)
436 rect = block_geom.translated(content_offset).toRect()
437 if not block.isVisible() or rect.top() >= event_rect_bottom:
438 break
440 if block_number == highlight_line:
441 painter.fillRect(rect.x(), rect.y(), width, rect.height(), highlight)
442 painter.setPen(highlight_text)
443 else:
444 painter.setPen(disabled)
446 line = lines[block_number]
447 if len(line) == 2:
448 a, b = line
449 text = fmt.value(a, b)
450 elif len(line) == 3:
451 old, base, new = line
452 text = fmt.merge_value(old, base, new)
454 painter.drawText(
455 rect.x(),
456 rect.y(),
457 text_width,
458 rect.height(),
459 text_flags,
460 text,
463 block = block.next()
466 class Viewer(QtWidgets.QFrame):
467 """Text and image diff viewers"""
469 INDEX_TEXT = 0
470 INDEX_IMAGE = 1
472 def __init__(self, context, parent=None):
473 super().__init__(parent)
475 self.context = context
476 self.model = model = context.model
477 self.images = []
478 self.pixmaps = []
479 self.options = options = Options(self)
480 self.filename = PlainTextLabel(parent=self)
481 self.filename.setAlignment(Qt.AlignVCenter | Qt.AlignLeft)
482 font = self.font()
483 font.setItalic(True)
484 self.filename.setFont(font)
485 self.filename.elide()
486 self.text = DiffEditor(context, options, self)
487 self.image = imageview.ImageView(parent=self)
488 self.image.setFocusPolicy(Qt.NoFocus)
489 self.search_widget = TextSearchWidget(self.text, self)
490 self.search_widget.hide()
491 self._drag_has_patches = False
493 self.setAcceptDrops(True)
494 self.setFocusProxy(self.text)
496 stack = self.stack = QtWidgets.QStackedWidget(self)
497 stack.addWidget(self.text)
498 stack.addWidget(self.image)
500 self.main_layout = qtutils.vbox(
501 defs.no_margin,
502 defs.no_spacing,
503 self.stack,
504 self.search_widget,
506 self.setLayout(self.main_layout)
508 # Observe images
509 model.images_changed.connect(self.set_images, type=Qt.QueuedConnection)
511 # Observe the diff type
512 model.diff_type_changed.connect(self.set_diff_type, type=Qt.QueuedConnection)
514 # Observe the file type
515 model.file_type_changed.connect(self.set_file_type, type=Qt.QueuedConnection)
517 # Observe the image mode combo box
518 options.image_mode.currentIndexChanged.connect(lambda _: self.render())
519 options.zoom_mode.currentIndexChanged.connect(lambda _: self.render())
521 self.search_action = qtutils.add_action(
522 self,
523 N_('Search in Diff'),
524 self.show_search_diff,
525 hotkeys.SEARCH,
528 def dragEnterEvent(self, event):
529 """Accepts drops if the mimedata contains patches"""
530 super().dragEnterEvent(event)
531 patches = get_patches_from_mimedata(event.mimeData())
532 if patches:
533 event.acceptProposedAction()
534 self._drag_has_patches = True
536 def dragLeaveEvent(self, event):
537 """End the drag+drop interaction"""
538 super().dragLeaveEvent(event)
539 if self._drag_has_patches:
540 event.accept()
541 else:
542 event.ignore()
543 self._drag_has_patches = False
545 def dropEvent(self, event):
546 """Apply patches when dropped onto the widget"""
547 if not self._drag_has_patches:
548 event.ignore()
549 return
550 event.setDropAction(Qt.CopyAction)
551 super().dropEvent(event)
552 self._drag_has_patches = False
554 patches = get_patches_from_mimedata(event.mimeData())
555 if patches:
556 apply_patches(self.context, patches=patches)
558 event.accept() # must be called after dropEvent()
560 def show_search_diff(self):
561 """Show a dialog for searching diffs"""
562 # The diff search is only active in text mode.
563 if self.stack.currentIndex() != self.INDEX_TEXT:
564 return
565 if not self.search_widget.isVisible():
566 self.search_widget.show()
567 self.search_widget.setFocus()
569 def export_state(self, state):
570 state['show_diff_line_numbers'] = self.options.show_line_numbers.isChecked()
571 state['show_diff_filenames'] = self.options.show_filenames.isChecked()
572 state['image_diff_mode'] = self.options.image_mode.currentIndex()
573 state['image_zoom_mode'] = self.options.zoom_mode.currentIndex()
574 state['word_wrap'] = self.options.enable_word_wrapping.isChecked()
575 return state
577 def apply_state(self, state):
578 diff_numbers = bool(state.get('show_diff_line_numbers', False))
579 self.set_line_numbers(diff_numbers, update=True)
581 show_filenames = bool(state.get('show_diff_filenames', True))
582 self.set_show_filenames(show_filenames, update=True)
584 image_mode = utils.asint(state.get('image_diff_mode', 0))
585 self.options.image_mode.set_index(image_mode)
587 zoom_mode = utils.asint(state.get('image_zoom_mode', 0))
588 self.options.zoom_mode.set_index(zoom_mode)
590 word_wrap = bool(state.get('word_wrap', True))
591 self.set_word_wrapping(word_wrap, update=True)
592 return True
594 def set_diff_type(self, diff_type):
595 """Manage the image and text diff views when selection changes"""
596 # The "diff type" is whether the diff viewer is displaying an image.
597 self.options.set_diff_type(diff_type)
598 if diff_type == main.Types.IMAGE:
599 self.stack.setCurrentWidget(self.image)
600 self.search_widget.hide()
601 self.render()
602 else:
603 self.stack.setCurrentWidget(self.text)
605 def set_file_type(self, file_type):
606 """Manage the diff options when the file type changes"""
607 # The "file type" is whether the file itself is an image.
608 self.options.set_file_type(file_type)
610 def enable_filename_tracking(self):
611 """Enable displaying the currently selected filename"""
612 self.context.selection.selection_changed.connect(
613 self.update_filename, type=Qt.QueuedConnection
616 def update_filename(self):
617 """Update the filename display when the selection changes"""
618 filename = self.context.selection.filename()
619 self.filename.set_text(filename or '')
621 def update_options(self):
622 """Emit a signal indicating that options have changed"""
623 self.text.update_options()
624 show_filenames = get(self.options.show_filenames)
625 self.set_show_filenames(show_filenames)
627 def set_show_filenames(self, enabled, update=False):
628 """Enable/disable displaying the selected filename"""
629 self.filename.setVisible(enabled)
630 if update:
631 with qtutils.BlockSignals(self.options.show_filenames):
632 self.options.show_filenames.setChecked(enabled)
634 def set_line_numbers(self, enabled, update=False):
635 """Enable/disable line numbers in the text widget"""
636 self.text.set_line_numbers(enabled, update=update)
638 def set_word_wrapping(self, enabled, update=False):
639 """Enable/disable word wrapping in the text widget"""
640 self.text.set_word_wrapping(enabled, update=update)
642 def reset(self):
643 self.image.pixmap = QtGui.QPixmap()
644 self.cleanup()
646 def cleanup(self):
647 for image, unlink in self.images:
648 if unlink and core.exists(image):
649 os.unlink(image)
650 self.images = []
652 def set_images(self, images):
653 self.images = images
654 self.pixmaps = []
655 if not images:
656 self.reset()
657 return False
659 # In order to comp, we first have to load all the images
660 all_pixmaps = [QtGui.QPixmap(image[0]) for image in images]
661 pixmaps = [pixmap for pixmap in all_pixmaps if not pixmap.isNull()]
662 if not pixmaps:
663 self.reset()
664 return False
666 self.pixmaps = pixmaps
667 self.render()
668 self.cleanup()
669 return True
671 def render(self):
672 # Update images
673 if self.pixmaps:
674 mode = self.options.image_mode.currentIndex()
675 if mode == self.options.SIDE_BY_SIDE:
676 image = self.render_side_by_side()
677 elif mode == self.options.DIFF:
678 image = self.render_diff()
679 elif mode == self.options.XOR:
680 image = self.render_xor()
681 elif mode == self.options.PIXEL_XOR:
682 image = self.render_pixel_xor()
683 else:
684 image = self.render_side_by_side()
685 else:
686 image = QtGui.QPixmap()
687 self.image.pixmap = image
689 # Apply zoom
690 zoom_mode = self.options.zoom_mode.currentIndex()
691 zoom_factor = self.options.zoom_factors[zoom_mode][1]
692 if zoom_factor > 0.0:
693 self.image.resetTransform()
694 self.image.scale(zoom_factor, zoom_factor)
695 poly = self.image.mapToScene(self.image.viewport().rect())
696 self.image.last_scene_roi = poly.boundingRect()
698 def render_side_by_side(self):
699 # Side-by-side lineup comp
700 pixmaps = self.pixmaps
701 width = sum(pixmap.width() for pixmap in pixmaps)
702 height = max(pixmap.height() for pixmap in pixmaps)
703 image = create_image(width, height)
705 # Paint each pixmap
706 painter = create_painter(image)
707 x = 0
708 for pixmap in pixmaps:
709 painter.drawPixmap(x, 0, pixmap)
710 x += pixmap.width()
711 painter.end()
713 return image
715 def render_comp(self, comp_mode):
716 # Get the max size to use as the render canvas
717 pixmaps = self.pixmaps
718 if len(pixmaps) == 1:
719 return pixmaps[0]
721 width = max(pixmap.width() for pixmap in pixmaps)
722 height = max(pixmap.height() for pixmap in pixmaps)
723 image = create_image(width, height)
725 painter = create_painter(image)
726 for pixmap in (pixmaps[0], pixmaps[-1]):
727 x = (width - pixmap.width()) // 2
728 y = (height - pixmap.height()) // 2
729 painter.drawPixmap(x, y, pixmap)
730 painter.setCompositionMode(comp_mode)
731 painter.end()
733 return image
735 def render_diff(self):
736 comp_mode = QtGui.QPainter.CompositionMode_Difference
737 return self.render_comp(comp_mode)
739 def render_xor(self):
740 comp_mode = QtGui.QPainter.CompositionMode_Xor
741 return self.render_comp(comp_mode)
743 def render_pixel_xor(self):
744 comp_mode = QtGui.QPainter.RasterOp_SourceXorDestination
745 return self.render_comp(comp_mode)
748 def create_image(width, height):
749 size = QtCore.QSize(width, height)
750 image = QtGui.QImage(size, QtGui.QImage.Format_ARGB32_Premultiplied)
751 image.fill(Qt.transparent)
752 return image
755 def create_painter(image):
756 painter = QtGui.QPainter(image)
757 painter.fillRect(image.rect(), Qt.transparent)
758 return painter
761 class Options(QtWidgets.QWidget):
762 """Provide the options widget used by the editor
764 Actions are registered on the parent widget.
768 # mode combobox indexes
769 SIDE_BY_SIDE = 0
770 DIFF = 1
771 XOR = 2
772 PIXEL_XOR = 3
774 def __init__(self, parent):
775 super().__init__(parent)
776 # Create widgets
777 self.widget = parent
778 self.ignore_space_at_eol = self.add_option(
779 N_('Ignore changes in whitespace at EOL')
781 self.ignore_space_change = self.add_option(
782 N_('Ignore changes in amount of whitespace')
784 self.ignore_all_space = self.add_option(N_('Ignore all whitespace'))
785 self.function_context = self.add_option(
786 N_('Show whole surrounding functions of changes')
788 self.show_line_numbers = qtutils.add_action_bool(
789 self, N_('Show line numbers'), self.set_line_numbers, True
791 self.show_filenames = self.add_option(N_('Show filenames'))
792 self.enable_word_wrapping = qtutils.add_action_bool(
793 self, N_('Enable word wrapping'), self.set_word_wrapping, True
796 self.options = qtutils.create_action_button(
797 tooltip=N_('Diff Options'), icon=icons.configure()
800 self.toggle_image_diff = qtutils.create_action_button(
801 tooltip=N_('Toggle image diff'), icon=icons.visualize()
803 self.toggle_image_diff.hide()
805 self.image_mode = qtutils.combo(
806 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
809 self.zoom_factors = (
810 (N_('Zoom to Fit'), 0.0),
811 (N_('25%'), 0.25),
812 (N_('50%'), 0.5),
813 (N_('100%'), 1.0),
814 (N_('200%'), 2.0),
815 (N_('400%'), 4.0),
816 (N_('800%'), 8.0),
818 zoom_modes = [factor[0] for factor in self.zoom_factors]
819 self.zoom_mode = qtutils.combo(zoom_modes, parent=self)
821 self.menu = menu = qtutils.create_menu(N_('Diff Options'), self.options)
822 self.options.setMenu(menu)
823 menu.addAction(self.ignore_space_at_eol)
824 menu.addAction(self.ignore_space_change)
825 menu.addAction(self.ignore_all_space)
826 menu.addSeparator()
827 menu.addAction(self.function_context)
828 menu.addAction(self.show_line_numbers)
829 menu.addAction(self.show_filenames)
830 menu.addSeparator()
831 menu.addAction(self.enable_word_wrapping)
833 # Layouts
834 layout = qtutils.hbox(
835 defs.no_margin,
836 defs.button_spacing,
837 self.image_mode,
838 self.zoom_mode,
839 self.options,
840 self.toggle_image_diff,
842 self.setLayout(layout)
844 # Policies
845 self.image_mode.setFocusPolicy(Qt.NoFocus)
846 self.zoom_mode.setFocusPolicy(Qt.NoFocus)
847 self.options.setFocusPolicy(Qt.NoFocus)
848 self.toggle_image_diff.setFocusPolicy(Qt.NoFocus)
849 self.setFocusPolicy(Qt.NoFocus)
851 def set_file_type(self, file_type):
852 """Set whether we are viewing an image file type"""
853 is_image = file_type == main.Types.IMAGE
854 self.toggle_image_diff.setVisible(is_image)
856 def set_diff_type(self, diff_type):
857 """Toggle between image and text diffs"""
858 is_text = diff_type == main.Types.TEXT
859 is_image = diff_type == main.Types.IMAGE
860 self.options.setVisible(is_text)
861 self.image_mode.setVisible(is_image)
862 self.zoom_mode.setVisible(is_image)
863 if is_image:
864 self.toggle_image_diff.setIcon(icons.diff())
865 else:
866 self.toggle_image_diff.setIcon(icons.visualize())
868 def add_option(self, title):
869 """Add a diff option which calls update_options() on change"""
870 action = qtutils.add_action(self, title, self.update_options)
871 action.setCheckable(True)
872 return action
874 def update_options(self):
875 """Update diff options in response to UI events"""
876 space_at_eol = get(self.ignore_space_at_eol)
877 space_change = get(self.ignore_space_change)
878 all_space = get(self.ignore_all_space)
879 function_context = get(self.function_context)
880 gitcmds.update_diff_overrides(
881 space_at_eol, space_change, all_space, function_context
883 self.widget.update_options()
885 def set_line_numbers(self, value):
886 """Enable / disable line numbers"""
887 self.widget.set_line_numbers(value, update=False)
889 def set_word_wrapping(self, value):
890 """Respond to Qt action callbacks"""
891 self.widget.set_word_wrapping(value, update=False)
893 def hide_advanced_options(self):
894 """Hide advanced options that are not applicable to the DiffWidget"""
895 self.show_filenames.setVisible(False)
896 self.show_line_numbers.setVisible(False)
897 self.ignore_space_at_eol.setVisible(False)
898 self.ignore_space_change.setVisible(False)
899 self.ignore_all_space.setVisible(False)
900 self.function_context.setVisible(False)
903 class DiffEditor(DiffTextEdit):
904 up = Signal()
905 down = Signal()
906 options_changed = Signal()
908 def __init__(self, context, options, parent):
909 DiffTextEdit.__init__(self, context, parent, numbers=True)
910 self.context = context
911 self.model = model = context.model
912 self.selection_model = selection_model = context.selection
914 # "Diff Options" tool menu
915 self.options = options
917 self.action_apply_selection = qtutils.add_action(
918 self,
919 'Apply',
920 self.apply_selection,
921 hotkeys.STAGE_DIFF,
922 hotkeys.STAGE_DIFF_ALT,
925 self.action_revert_selection = qtutils.add_action(
926 self, 'Revert', self.revert_selection, hotkeys.REVERT, hotkeys.REVERT_ALT
928 self.action_revert_selection.setIcon(icons.undo())
930 self.action_edit_and_apply_selection = qtutils.add_action(
931 self,
932 'Edit and Apply',
933 partial(self.apply_selection, edit=True),
934 hotkeys.EDIT_AND_STAGE_DIFF,
937 self.action_edit_and_revert_selection = qtutils.add_action(
938 self,
939 'Edit and Revert',
940 partial(self.revert_selection, edit=True),
941 hotkeys.EDIT_AND_REVERT,
943 self.action_edit_and_revert_selection.setIcon(icons.undo())
944 self.launch_editor = actions.launch_editor_at_line(
945 context, self, hotkeys.EDIT_SHORT, *hotkeys.ACCEPT
947 self.launch_difftool = actions.launch_difftool(context, self)
948 self.stage_or_unstage = actions.stage_or_unstage(context, self)
950 # Emit up/down signals so that they can be routed by the main widget
951 self.move_up = actions.move_up(self)
952 self.move_down = actions.move_down(self)
954 model.diff_text_updated.connect(self.set_diff, type=Qt.QueuedConnection)
956 selection_model.selection_changed.connect(
957 self.refresh, type=Qt.QueuedConnection
959 # Update the selection model when the cursor changes
960 self.cursorPositionChanged.connect(self._update_line_number)
962 qtutils.connect_button(options.toggle_image_diff, self.toggle_diff_type)
964 def toggle_diff_type(self):
965 cmds.do(cmds.ToggleDiffType, self.context)
967 def refresh(self):
968 enabled = False
969 s = self.selection_model.selection()
970 model = self.model
971 if model.is_partially_stageable():
972 item = s.modified[0] if s.modified else None
973 if item in model.submodules:
974 pass
975 elif item not in model.unstaged_deleted:
976 enabled = True
977 self.action_revert_selection.setEnabled(enabled)
979 def set_line_numbers(self, enabled, update=False):
980 """Enable/disable the diff line number display"""
981 self.numbers.setVisible(enabled)
982 if update:
983 with qtutils.BlockSignals(self.options.show_line_numbers):
984 self.options.show_line_numbers.setChecked(enabled)
985 # Refresh the display. Not doing this results in the display not
986 # correctly displaying the line numbers widget until the text scrolls.
987 self.set_value(self.value())
989 def update_options(self):
990 self.options_changed.emit()
992 def create_context_menu(self, event_pos):
993 """Override create_context_menu() to display a completely custom menu"""
994 menu = super().create_context_menu(event_pos)
995 context = self.context
996 model = self.model
997 s = self.selection_model.selection()
998 filename = self.selection_model.filename()
1000 # These menu actions will be inserted at the start of the widget.
1001 current_actions = menu.actions()
1002 menu_actions = []
1003 add_action = menu_actions.append
1004 edit_actions_added = False
1005 stage_action_added = False
1007 if s.staged and model.is_unstageable():
1008 item = s.staged[0]
1009 if item not in model.submodules and item not in model.staged_deleted:
1010 if self.has_selection():
1011 apply_text = N_('Unstage Selected Lines')
1012 else:
1013 apply_text = N_('Unstage Diff Hunk')
1014 self.action_apply_selection.setText(apply_text)
1015 self.action_apply_selection.setIcon(icons.remove())
1016 add_action(self.action_apply_selection)
1017 stage_action_added = self._add_stage_or_unstage_action(
1018 menu, add_action, stage_action_added
1021 if model.is_partially_stageable():
1022 item = s.modified[0] if s.modified else None
1023 if item in model.submodules:
1024 path = core.abspath(item)
1025 action = qtutils.add_action_with_icon(
1026 menu,
1027 icons.add(),
1028 cmds.Stage.name(),
1029 cmds.run(cmds.Stage, context, s.modified),
1030 hotkeys.STAGE_SELECTION,
1032 add_action(action)
1033 stage_action_added = self._add_stage_or_unstage_action(
1034 menu, add_action, stage_action_added
1037 action = qtutils.add_action_with_icon(
1038 menu,
1039 icons.cola(),
1040 N_('Launch git-cola'),
1041 cmds.run(cmds.OpenRepo, context, path),
1043 add_action(action)
1044 elif item and item not in model.unstaged_deleted:
1045 if self.has_selection():
1046 apply_text = N_('Stage Selected Lines')
1047 edit_and_apply_text = N_('Edit Selected Lines to Stage...')
1048 revert_text = N_('Revert Selected Lines...')
1049 edit_and_revert_text = N_('Edit Selected Lines to Revert...')
1050 else:
1051 apply_text = N_('Stage Diff Hunk')
1052 edit_and_apply_text = N_('Edit Diff Hunk to Stage...')
1053 revert_text = N_('Revert Diff Hunk...')
1054 edit_and_revert_text = N_('Edit Diff Hunk to Revert...')
1056 self.action_apply_selection.setText(apply_text)
1057 self.action_apply_selection.setIcon(icons.add())
1058 add_action(self.action_apply_selection)
1060 self.action_revert_selection.setText(revert_text)
1061 add_action(self.action_revert_selection)
1063 stage_action_added = self._add_stage_or_unstage_action(
1064 menu, add_action, stage_action_added
1066 # Do not show the "edit" action when the file does not exist.
1067 add_action(qtutils.menu_separator(menu))
1068 if filename and core.exists(filename):
1069 add_action(self.launch_editor)
1070 # Removed files can still be diffed.
1071 add_action(self.launch_difftool)
1072 edit_actions_added = True
1074 add_action(qtutils.menu_separator(menu))
1075 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1076 self.action_edit_and_apply_selection.setIcon(icons.add())
1077 add_action(self.action_edit_and_apply_selection)
1079 self.action_edit_and_revert_selection.setText(edit_and_revert_text)
1080 add_action(self.action_edit_and_revert_selection)
1082 if s.staged and model.is_unstageable():
1083 item = s.staged[0]
1084 if item in model.submodules:
1085 path = core.abspath(item)
1086 action = qtutils.add_action_with_icon(
1087 menu,
1088 icons.remove(),
1089 cmds.Unstage.name(),
1090 cmds.run(cmds.Unstage, context, s.staged),
1091 hotkeys.STAGE_SELECTION,
1093 add_action(action)
1095 stage_action_added = self._add_stage_or_unstage_action(
1096 menu, add_action, stage_action_added
1099 qtutils.add_action_with_icon(
1100 menu,
1101 icons.cola(),
1102 N_('Launch git-cola'),
1103 cmds.run(cmds.OpenRepo, context, path),
1105 add_action(action)
1107 elif item not in model.staged_deleted:
1108 # Do not show the "edit" action when the file does not exist.
1109 add_action(qtutils.menu_separator(menu))
1110 if filename and core.exists(filename):
1111 add_action(self.launch_editor)
1112 # Removed files can still be diffed.
1113 add_action(self.launch_difftool)
1114 add_action(qtutils.menu_separator(menu))
1115 edit_actions_added = True
1117 if self.has_selection():
1118 edit_and_apply_text = N_('Edit Selected Lines to Unstage...')
1119 else:
1120 edit_and_apply_text = N_('Edit Diff Hunk to Unstage...')
1121 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1122 self.action_edit_and_apply_selection.setIcon(icons.remove())
1123 add_action(self.action_edit_and_apply_selection)
1125 if not edit_actions_added and (model.is_stageable() or model.is_unstageable()):
1126 add_action(qtutils.menu_separator(menu))
1127 # Do not show the "edit" action when the file does not exist.
1128 # Untracked files exist by definition.
1129 if filename and core.exists(filename):
1130 add_action(self.launch_editor)
1132 # Removed files can still be diffed.
1133 add_action(self.launch_difftool)
1135 add_action(qtutils.menu_separator(menu))
1136 _add_patch_actions(self, self.context, menu)
1138 # Add the Previous/Next File actions, which improves discoverability
1139 # of their associated shortcuts
1140 add_action(qtutils.menu_separator(menu))
1141 add_action(self.move_up)
1142 add_action(self.move_down)
1143 add_action(qtutils.menu_separator(menu))
1145 if current_actions:
1146 first_action = current_actions[0]
1147 else:
1148 first_action = None
1149 menu.insertActions(first_action, menu_actions)
1151 return menu
1153 def _add_stage_or_unstage_action(self, menu, add_action, already_added):
1154 """Add the Stage / Unstage menu action"""
1155 if already_added:
1156 return True
1157 model = self.context.model
1158 s = self.selection_model.selection()
1159 if model.is_stageable() or model.is_unstageable():
1160 if (model.is_amend_mode() and s.staged) or not self.model.is_stageable():
1161 self.stage_or_unstage.setText(N_('Unstage'))
1162 self.stage_or_unstage.setIcon(icons.remove())
1163 else:
1164 self.stage_or_unstage.setText(N_('Stage'))
1165 self.stage_or_unstage.setIcon(icons.add())
1166 add_action(qtutils.menu_separator(menu))
1167 add_action(self.stage_or_unstage)
1168 return True
1170 def mousePressEvent(self, event):
1171 if event.button() == Qt.RightButton:
1172 # Intercept right-click to move the cursor to the current position.
1173 # setTextCursor() clears the selection so this is only done when
1174 # nothing is selected.
1175 if not self.has_selection():
1176 cursor = self.cursorForPosition(event.pos())
1177 self.setTextCursor(cursor)
1179 return super().mousePressEvent(event)
1181 def setPlainText(self, text):
1182 """setPlainText(str) while retaining scrollbar positions"""
1183 model = self.model
1184 mode = model.mode
1185 highlight = mode not in (
1186 model.mode_none,
1187 model.mode_display,
1188 model.mode_untracked,
1190 self.highlighter.set_enabled(highlight)
1192 scrollbar = self.verticalScrollBar()
1193 if scrollbar:
1194 scrollvalue = get(scrollbar)
1195 else:
1196 scrollvalue = None
1198 if text is None:
1199 return
1201 DiffTextEdit.setPlainText(self, text)
1203 if scrollbar and scrollvalue is not None:
1204 scrollbar.setValue(scrollvalue)
1206 def apply_selection(self, *, edit=False):
1207 model = self.model
1208 s = self.selection_model.single_selection()
1209 if model.is_partially_stageable() and (s.modified or s.untracked):
1210 self.process_diff_selection(edit=edit)
1211 elif model.is_unstageable():
1212 self.process_diff_selection(reverse=True, edit=edit)
1214 def revert_selection(self, *, edit=False):
1215 """Destructively revert selected lines or hunk from a worktree file."""
1217 if not edit:
1218 if self.has_selection():
1219 title = N_('Revert Selected Lines?')
1220 ok_text = N_('Revert Selected Lines')
1221 else:
1222 title = N_('Revert Diff Hunk?')
1223 ok_text = N_('Revert Diff Hunk')
1225 if not Interaction.confirm(
1226 title,
1228 'This operation drops uncommitted changes.\n'
1229 'These changes cannot be recovered.'
1231 N_('Revert the uncommitted changes?'),
1232 ok_text,
1233 default=True,
1234 icon=icons.undo(),
1236 return
1237 self.process_diff_selection(reverse=True, apply_to_worktree=True, edit=edit)
1239 def extract_patch(self, reverse=False):
1240 first_line_idx, last_line_idx = self.selected_lines()
1241 patch = diffparse.Patch.parse(self.model.filename, self.model.diff_text)
1242 if self.has_selection():
1243 return patch.extract_subset(first_line_idx, last_line_idx, reverse=reverse)
1244 return patch.extract_hunk(first_line_idx, reverse=reverse)
1246 def patch_encoding(self):
1247 if isinstance(self.model.diff_text, core.UStr):
1248 # original encoding must prevail
1249 return self.model.diff_text.encoding
1250 return self.context.cfg.file_encoding(self.model.filename)
1252 def process_diff_selection(
1253 self, reverse=False, apply_to_worktree=False, edit=False
1255 """Implement un/staging of the selected line(s) or hunk."""
1256 if self.selection_model.is_empty():
1257 return
1258 patch = self.extract_patch(reverse)
1259 if not patch.has_changes():
1260 return
1261 patch_encoding = self.patch_encoding()
1263 if edit:
1264 patch = edit_patch(
1265 patch,
1266 patch_encoding,
1267 self.context,
1268 reverse=reverse,
1269 apply_to_worktree=apply_to_worktree,
1271 if not patch.has_changes():
1272 return
1274 cmds.do(
1275 cmds.ApplyPatch,
1276 self.context,
1277 patch,
1278 patch_encoding,
1279 apply_to_worktree,
1282 def _update_line_number(self):
1283 """Update the selection model when the cursor changes"""
1284 self.selection_model.line_number = self.numbers.current_line()
1287 def _add_patch_actions(widget, context, menu):
1288 """Add actions for manipulating patch files"""
1289 patches_menu = menu.addMenu(N_('Patches'))
1290 patches_menu.setIcon(icons.diff())
1291 export_action = qtutils.add_action(
1292 patches_menu,
1293 N_('Export Patch'),
1294 lambda: _export_patch(widget, context),
1296 export_action.setIcon(icons.save())
1297 patches_menu.addAction(export_action)
1299 # Build the "Append Patch" menu dynamically.
1300 append_menu = patches_menu.addMenu(N_('Append Patch'))
1301 append_menu.setIcon(icons.add())
1302 append_menu.aboutToShow.connect(
1303 lambda: _build_patch_append_menu(widget, context, append_menu)
1307 def _build_patch_append_menu(widget, context, menu):
1308 """Build the "Append Patch" sub-menu"""
1309 # Build the menu when first displayed only. This initial check avoids
1310 # re-populating the menu with duplicate actions.
1311 menu_actions = menu.actions()
1312 if menu_actions:
1313 return
1315 choose_patch_action = qtutils.add_action(
1316 menu,
1317 N_('Choose Patch...'),
1318 lambda: _export_patch(widget, context, append=True),
1320 choose_patch_action.setIcon(icons.diff())
1321 menu.addAction(choose_patch_action)
1323 subdir_menus = {}
1324 path = prefs.patches_directory(context)
1325 patches = get_patches_from_dir(path)
1326 for patch in patches:
1327 relpath = os.path.relpath(patch, start=path)
1328 sub_menu = _add_patch_subdirs(menu, subdir_menus, relpath)
1329 patch_basename = os.path.basename(relpath)
1330 append_action = qtutils.add_action(
1331 sub_menu,
1332 patch_basename,
1333 lambda patch_file=patch: _append_patch(widget, patch_file),
1335 append_action.setIcon(icons.save())
1336 sub_menu.addAction(append_action)
1339 def _add_patch_subdirs(menu, subdir_menus, relpath):
1340 """Build menu leading up to the patch"""
1341 # If the path contains no directory separators then add it to the
1342 # root of the menu.
1343 if os.sep not in relpath:
1344 return menu
1346 # Loop over each directory component and build a menu if it doesn't already exist.
1347 components = []
1348 for dirname in os.path.dirname(relpath).split(os.sep):
1349 components.append(dirname)
1350 current_dir = os.sep.join(components)
1351 try:
1352 menu = subdir_menus[current_dir]
1353 except KeyError:
1354 menu = subdir_menus[current_dir] = menu.addMenu(dirname)
1355 menu.setIcon(icons.folder())
1357 return menu
1360 def _export_patch(diff_editor, context, append=False):
1361 """Export the selected diff to a patch file"""
1362 if diff_editor.selection_model.is_empty():
1363 return
1364 patch = diff_editor.extract_patch(reverse=False)
1365 if not patch.has_changes():
1366 return
1367 directory = prefs.patches_directory(context)
1368 if append:
1369 filename = qtutils.existing_file(directory, title=N_('Append Patch...'))
1370 else:
1371 default_filename = os.path.join(directory, 'diff.patch')
1372 filename = qtutils.save_as(default_filename)
1373 if not filename:
1374 return
1375 _write_patch_to_file(diff_editor, patch, filename, append=append)
1378 def _append_patch(diff_editor, filename):
1379 """Append diffs to the specified patch file"""
1380 if diff_editor.selection_model.is_empty():
1381 return
1382 patch = diff_editor.extract_patch(reverse=False)
1383 if not patch.has_changes():
1384 return
1385 _write_patch_to_file(diff_editor, patch, filename, append=True)
1388 def _write_patch_to_file(diff_editor, patch, filename, append=False):
1389 """Write diffs from the Diff Editor to the specified patch file"""
1390 encoding = diff_editor.patch_encoding()
1391 content = patch.as_text()
1392 try:
1393 core.write(filename, content, encoding=encoding, append=append)
1394 except OSError as exc:
1395 _, details = utils.format_exception(exc)
1396 title = N_('Error writing patch')
1397 msg = N_('Unable to write patch to "%s". Check permissions?' % filename)
1398 Interaction.critical(title, message=msg, details=details)
1399 return
1400 Interaction.log('Patch written to "%s"' % filename)
1403 class DiffWidget(QtWidgets.QWidget):
1404 """Display commit metadata and text diffs"""
1406 def __init__(self, context, parent, is_commit=False, options=None):
1407 QtWidgets.QWidget.__init__(self, parent)
1409 self.context = context
1410 self.oid = 'HEAD'
1411 self.oid_start = None
1412 self.oid_end = None
1413 self.options = options
1415 author_font = QtGui.QFont(self.font())
1416 author_font.setPointSize(int(author_font.pointSize() * 1.1))
1418 summary_font = QtGui.QFont(author_font)
1419 summary_font.setWeight(QtGui.QFont.Bold)
1421 self.gravatar_label = gravatar.GravatarLabel(self.context, parent=self)
1423 self.oid_label = PlainTextLabel(parent=self)
1424 self.oid_label.setAlignment(Qt.AlignBottom)
1425 self.oid_label.elide()
1427 self.author_label = RichTextLabel(parent=self)
1428 self.author_label.setFont(author_font)
1429 self.author_label.setAlignment(Qt.AlignTop)
1430 self.author_label.elide()
1432 self.date_label = PlainTextLabel(parent=self)
1433 self.date_label.setAlignment(Qt.AlignTop)
1434 self.date_label.elide()
1436 self.summary_label = PlainTextLabel(parent=self)
1437 self.summary_label.setFont(summary_font)
1438 self.summary_label.setAlignment(Qt.AlignTop)
1439 self.summary_label.elide()
1441 self.diff = DiffTextEdit(context, self, is_commit=is_commit, whitespace=False)
1442 self.setFocusProxy(self.diff)
1444 self.info_layout = qtutils.vbox(
1445 defs.no_margin,
1446 defs.no_spacing,
1447 self.oid_label,
1448 self.author_label,
1449 self.date_label,
1450 self.summary_label,
1453 self.logo_layout = qtutils.hbox(
1454 defs.no_margin, defs.button_spacing, self.gravatar_label, self.info_layout
1456 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
1458 self.main_layout = qtutils.vbox(
1459 defs.no_margin, defs.spacing, self.logo_layout, self.diff
1461 self.setLayout(self.main_layout)
1463 self.set_tabwidth(prefs.tabwidth(context))
1465 def set_tabwidth(self, width):
1466 self.diff.set_tabwidth(width)
1468 def set_word_wrapping(self, enabled, update=False):
1469 """Enable and disable word wrapping"""
1470 self.diff.set_word_wrapping(enabled, update=update)
1472 def set_options(self, options):
1473 """Register an options widget"""
1474 self.options = options
1475 self.diff.set_options(options)
1477 def start_diff_task(self, task):
1478 """Clear the display and start a diff-gathering task"""
1479 self.diff.save_scrollbar()
1480 self.diff.set_loading_message()
1481 self.context.runtask.start(task, result=self.set_diff)
1483 def set_diff_oid(self, oid, filename=None):
1484 """Set the diff from a single commit object ID"""
1485 task = DiffInfoTask(self.context, oid, filename)
1486 self.start_diff_task(task)
1488 def set_diff_range(self, start, end, filename=None):
1489 task = DiffRangeTask(self.context, start + '~', end, filename)
1490 self.start_diff_task(task)
1492 def commits_selected(self, commits):
1493 """Display an appropriate diff when commits are selected"""
1494 if not commits:
1495 self.clear()
1496 return
1497 commit = commits[-1]
1498 oid = commit.oid
1499 author = commit.author or ''
1500 email = commit.email or ''
1501 date = commit.authdate or ''
1502 summary = commit.summary or ''
1503 self.set_details(oid, author, email, date, summary)
1504 self.oid = oid
1506 if len(commits) > 1:
1507 start, end = commits[0], commits[-1]
1508 self.set_diff_range(start.oid, end.oid)
1509 self.oid_start = start
1510 self.oid_end = end
1511 else:
1512 self.set_diff_oid(oid)
1513 self.oid_start = None
1514 self.oid_end = None
1516 def set_diff(self, diff):
1517 """Set the diff text"""
1518 self.diff.set_diff(diff)
1520 def set_details(self, oid, author, email, date, summary):
1521 template_args = {'author': author, 'email': email, 'summary': summary}
1522 author_text = (
1523 """%(author)s &lt;"""
1524 """<a href="mailto:%(email)s">"""
1525 """%(email)s</a>&gt;""" % template_args
1527 author_template = '%(author)s <%(email)s>' % template_args
1529 self.date_label.set_text(date)
1530 self.date_label.setVisible(bool(date))
1531 self.oid_label.set_text(oid)
1532 self.author_label.set_template(author_text, author_template)
1533 self.summary_label.set_text(summary)
1534 self.gravatar_label.set_email(email)
1536 def clear(self):
1537 self.date_label.set_text('')
1538 self.oid_label.set_text('')
1539 self.author_label.set_text('')
1540 self.summary_label.set_text('')
1541 self.gravatar_label.clear()
1542 self.diff.clear()
1544 def files_selected(self, filenames):
1545 """Update the view when a filename is selected"""
1546 oid_start = self.oid_start
1547 oid_end = self.oid_end
1548 extra_args = {}
1549 if filenames:
1550 extra_args['filename'] = filenames[0]
1551 if oid_start and oid_end:
1552 self.set_diff_range(oid_start.oid, oid_end.oid, **extra_args)
1553 else:
1554 self.set_diff_oid(self.oid, **extra_args)
1557 class DiffPanel(QtWidgets.QWidget):
1558 """A combined diff + search panel"""
1560 def __init__(self, diff_widget, text_widget, parent):
1561 super().__init__(parent)
1562 self.diff_widget = diff_widget
1563 self.search_widget = TextSearchWidget(text_widget, self)
1564 self.search_widget.hide()
1565 layout = qtutils.vbox(
1566 defs.no_margin, defs.spacing, self.diff_widget, self.search_widget
1568 self.setLayout(layout)
1569 self.setFocusProxy(self.diff_widget)
1571 self.search_action = qtutils.add_action(
1572 self,
1573 N_('Search in Diff'),
1574 self.show_search,
1575 hotkeys.SEARCH,
1578 def show_search(self):
1579 """Show a dialog for searching diffs"""
1580 # The diff search is only active in text mode.
1581 if not self.search_widget.isVisible():
1582 self.search_widget.show()
1583 self.search_widget.setFocus()
1586 class DiffInfoTask(qtutils.Task):
1587 """Gather diffs for a single commit"""
1589 def __init__(self, context, oid, filename):
1590 qtutils.Task.__init__(self)
1591 self.context = context
1592 self.oid = oid
1593 self.filename = filename
1595 def task(self):
1596 context = self.context
1597 oid = self.oid
1598 return gitcmds.diff_info(context, oid, filename=self.filename)
1601 class DiffRangeTask(qtutils.Task):
1602 """Gather diffs for a range of commits"""
1604 def __init__(self, context, start, end, filename):
1605 qtutils.Task.__init__(self)
1606 self.context = context
1607 self.start = start
1608 self.end = end
1609 self.filename = filename
1611 def task(self):
1612 context = self.context
1613 return gitcmds.diff_range(context, self.start, self.end, filename=self.filename)
1616 def apply_patches(context, patches=None):
1617 """Open the ApplyPatches dialog"""
1618 parent = qtutils.active_window()
1619 dlg = new_apply_patches(context, patches=patches, parent=parent)
1620 dlg.show()
1621 dlg.raise_()
1622 return dlg
1625 def new_apply_patches(context, patches=None, parent=None):
1626 """Create a new instances of the ApplyPatches dialog"""
1627 dlg = ApplyPatches(context, parent=parent)
1628 if patches:
1629 dlg.add_paths(patches)
1630 return dlg
1633 def get_patches_from_paths(paths):
1634 """Returns all patches beneath a given path"""
1635 paths = [core.decode(p) for p in paths]
1636 patches = [p for p in paths if core.isfile(p) and p.endswith(('.patch', '.mbox'))]
1637 dirs = [p for p in paths if core.isdir(p)]
1638 dirs.sort()
1639 for d in dirs:
1640 patches.extend(get_patches_from_dir(d))
1641 return patches
1644 def get_patches_from_mimedata(mimedata):
1645 """Extract path files from a QMimeData payload"""
1646 urls = mimedata.urls()
1647 if not urls:
1648 return []
1649 paths = [x.path() for x in urls]
1650 return get_patches_from_paths(paths)
1653 def get_patches_from_dir(path):
1654 """Find patches in a subdirectory"""
1655 patches = []
1656 for root, _, files in core.walk(path):
1657 for name in [f for f in files if f.endswith(('.patch', '.mbox'))]:
1658 patches.append(core.decode(os.path.join(root, name)))
1659 return patches
1662 class ApplyPatches(standard.Dialog):
1663 def __init__(self, context, parent=None):
1664 super().__init__(parent=parent)
1665 self.context = context
1666 self.setWindowTitle(N_('Apply Patches'))
1667 self.setAcceptDrops(True)
1668 if parent is not None:
1669 self.setWindowModality(Qt.WindowModal)
1671 self.curdir = core.getcwd()
1672 self.inner_drag = False
1674 self.usage = QtWidgets.QLabel()
1675 self.usage.setText(
1679 Drag and drop or use the <strong>Add</strong> button to add
1680 patches to the list
1681 </p>
1686 self.tree = PatchTreeWidget(parent=self)
1687 self.tree.setHeaderHidden(True)
1688 self.tree.itemSelectionChanged.connect(self._tree_selection_changed)
1690 self.diffwidget = DiffWidget(context, self, is_commit=True)
1692 self.add_button = qtutils.create_toolbutton(
1693 text=N_('Add'), icon=icons.add(), tooltip=N_('Add patches (+)')
1696 self.remove_button = qtutils.create_toolbutton(
1697 text=N_('Remove'),
1698 icon=icons.remove(),
1699 tooltip=N_('Remove selected (Delete)'),
1702 self.apply_button = qtutils.create_button(text=N_('Apply'), icon=icons.ok())
1704 self.close_button = qtutils.close_button()
1706 self.add_action = qtutils.add_action(
1707 self, N_('Add'), self.add_files, hotkeys.ADD_ITEM
1710 self.remove_action = qtutils.add_action(
1711 self,
1712 N_('Remove'),
1713 self.tree.remove_selected,
1714 hotkeys.DELETE,
1715 hotkeys.BACKSPACE,
1716 hotkeys.REMOVE_ITEM,
1719 self.top_layout = qtutils.hbox(
1720 defs.no_margin,
1721 defs.button_spacing,
1722 self.add_button,
1723 self.remove_button,
1724 qtutils.STRETCH,
1725 self.usage,
1728 self.bottom_layout = qtutils.hbox(
1729 defs.no_margin,
1730 defs.button_spacing,
1731 qtutils.STRETCH,
1732 self.close_button,
1733 self.apply_button,
1736 self.splitter = qtutils.splitter(Qt.Vertical, self.tree, self.diffwidget)
1738 self.main_layout = qtutils.vbox(
1739 defs.margin,
1740 defs.spacing,
1741 self.top_layout,
1742 self.splitter,
1743 self.bottom_layout,
1745 self.setLayout(self.main_layout)
1747 qtutils.connect_button(self.add_button, self.add_files)
1748 qtutils.connect_button(self.remove_button, self.tree.remove_selected)
1749 qtutils.connect_button(self.apply_button, self.apply_patches)
1750 qtutils.connect_button(self.close_button, self.close)
1752 self.init_state(None, self.resize, 720, 480)
1754 def apply_patches(self):
1755 items = self.tree.items()
1756 if not items:
1757 return
1758 context = self.context
1759 patches = [i.data(0, Qt.UserRole) for i in items]
1760 cmds.do(cmds.ApplyPatches, context, patches)
1761 self.accept()
1763 def add_files(self):
1764 files = qtutils.open_files(
1765 N_('Select patch file(s)...'),
1766 directory=self.curdir,
1767 filters='Patches (*.patch *.mbox)',
1769 if not files:
1770 return
1771 self.curdir = os.path.dirname(files[0])
1772 self.add_paths([core.relpath(f) for f in files])
1774 def dragEnterEvent(self, event):
1775 """Accepts drops if the mimedata contains patches"""
1776 super().dragEnterEvent(event)
1777 patches = get_patches_from_mimedata(event.mimeData())
1778 if patches:
1779 event.acceptProposedAction()
1781 def dropEvent(self, event):
1782 """Add dropped patches"""
1783 event.accept()
1784 patches = get_patches_from_mimedata(event.mimeData())
1785 if not patches:
1786 return
1787 self.add_paths(patches)
1789 def add_paths(self, paths):
1790 self.tree.add_paths(paths)
1792 def _tree_selection_changed(self):
1793 items = self.tree.selected_items()
1794 if not items:
1795 return
1796 item = items[-1] # take the last item
1797 path = item.data(0, Qt.UserRole)
1798 if not core.exists(path):
1799 return
1800 commit = parse_patch(path)
1801 self.diffwidget.set_details(
1802 commit.oid, commit.author, commit.email, commit.date, commit.summary
1804 self.diffwidget.set_diff(commit.diff)
1806 def export_state(self):
1807 """Export persistent settings"""
1808 state = super().export_state()
1809 state['sizes'] = get(self.splitter)
1810 return state
1812 def apply_state(self, state):
1813 """Apply persistent settings"""
1814 result = super().apply_state(state)
1815 try:
1816 self.splitter.setSizes(state['sizes'])
1817 except (AttributeError, KeyError, ValueError, TypeError):
1818 pass
1819 return result
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