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