diff: provide a way to append to arbitrary patch files
[git-cola.git] / cola / widgets / diff.py
blob1340d4a2d20dfabedeafdf48ae73924929439d31
1 from __future__ import absolute_import, division, print_function, unicode_literals
2 from functools import partial
3 import os
4 import re
6 from qtpy import QtCore
7 from qtpy import QtGui
8 from qtpy import QtWidgets
9 from qtpy.QtCore import Qt
10 from qtpy.QtCore import Signal
12 from ..i18n import N_
13 from ..editpatch import edit_patch
14 from ..interaction import Interaction
15 from ..models import main
16 from ..models import prefs
17 from ..qtutils import get
18 from .. import actions
19 from .. import cmds
20 from .. import core
21 from .. import diffparse
22 from .. import gitcmds
23 from .. import gravatar
24 from .. import hotkeys
25 from .. import icons
26 from .. import utils
27 from .. import qtutils
28 from .text import TextDecorator
29 from .text import VimHintedPlainTextEdit
30 from .text import TextSearchWidget
31 from . import defs
32 from . import patch as patch_mod
33 from . import imageview
36 class DiffSyntaxHighlighter(QtGui.QSyntaxHighlighter):
37 """Implements the diff syntax highlighting"""
39 INITIAL_STATE = -1
40 DEFAULT_STATE = 0
41 DIFFSTAT_STATE = 1
42 DIFF_FILE_HEADER_STATE = 2
43 DIFF_STATE = 3
44 SUBMODULE_STATE = 4
45 END_STATE = 5
47 DIFF_FILE_HEADER_START_RGX = re.compile(r'diff --git a/.* b/.*')
48 DIFF_HUNK_HEADER_RGX = re.compile(
49 r'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)'
51 BAD_WHITESPACE_RGX = re.compile(r'\s+$')
53 def __init__(self, context, doc, whitespace=True, is_commit=False):
54 QtGui.QSyntaxHighlighter.__init__(self, doc)
55 self.whitespace = whitespace
56 self.enabled = True
57 self.is_commit = is_commit
59 QPalette = QtGui.QPalette
60 cfg = context.cfg
61 palette = QPalette()
62 disabled = palette.color(QPalette.Disabled, QPalette.Text)
63 header = qtutils.rgb_hex(disabled)
65 dark = palette.color(QPalette.Base).lightnessF() < 0.5
67 self.color_text = qtutils.rgb_triple(cfg.color('text', '030303'))
68 self.color_add = qtutils.rgb_triple(
69 cfg.color('add', '77aa77' if dark else 'd2ffe4')
71 self.color_remove = qtutils.rgb_triple(
72 cfg.color('remove', 'aa7777' if dark else 'fee0e4')
74 self.color_header = qtutils.rgb_triple(cfg.color('header', header))
76 self.diff_header_fmt = qtutils.make_format(foreground=self.color_header)
77 self.bold_diff_header_fmt = qtutils.make_format(
78 foreground=self.color_header, bold=True
81 self.diff_add_fmt = qtutils.make_format(
82 foreground=self.color_text, background=self.color_add
84 self.diff_remove_fmt = qtutils.make_format(
85 foreground=self.color_text, background=self.color_remove
87 self.bad_whitespace_fmt = qtutils.make_format(background=Qt.red)
88 self.setCurrentBlockState(self.INITIAL_STATE)
90 def set_enabled(self, enabled):
91 self.enabled = enabled
93 def highlightBlock(self, text):
94 """Highlight the current text block"""
95 if not self.enabled or not text:
96 return
97 formats = []
98 state = self.get_next_state(text)
99 if state == self.DIFFSTAT_STATE:
100 state, formats = self.get_formats_for_diffstat(state, text)
101 elif state == self.DIFF_FILE_HEADER_STATE:
102 state, formats = self.get_formats_for_diff_header(state, text)
103 elif state == self.DIFF_STATE:
104 state, formats = self.get_formats_for_diff_text(state, text)
106 for start, end, fmt in formats:
107 self.setFormat(start, end, fmt)
109 self.setCurrentBlockState(state)
111 def get_next_state(self, text):
112 """Transition to the next state based on the input text"""
113 state = self.previousBlockState()
114 if state == DiffSyntaxHighlighter.INITIAL_STATE:
115 if text.startswith('Submodule '):
116 state = DiffSyntaxHighlighter.SUBMODULE_STATE
117 elif text.startswith('diff --git '):
118 state = DiffSyntaxHighlighter.DIFFSTAT_STATE
119 elif self.is_commit:
120 state = DiffSyntaxHighlighter.DEFAULT_STATE
121 else:
122 state = DiffSyntaxHighlighter.DIFFSTAT_STATE
124 return state
126 def get_formats_for_diffstat(self, state, text):
127 """Returns (state, [(start, end, fmt), ...]) for highlighting diffstat text"""
128 formats = []
129 if self.DIFF_FILE_HEADER_START_RGX.match(text):
130 state = self.DIFF_FILE_HEADER_STATE
131 end = len(text)
132 fmt = self.diff_header_fmt
133 formats.append((0, end, fmt))
134 elif self.DIFF_HUNK_HEADER_RGX.match(text):
135 state = self.DIFF_STATE
136 end = len(text)
137 fmt = self.bold_diff_header_fmt
138 formats.append((0, end, fmt))
139 elif '|' in text:
140 offset = text.index('|')
141 formats.append((0, offset, self.bold_diff_header_fmt))
142 formats.append((offset, len(text) - offset, self.diff_header_fmt))
143 else:
144 formats.append((0, len(text), self.diff_header_fmt))
146 return state, formats
148 def get_formats_for_diff_header(self, state, text):
149 """Returns (state, [(start, end, fmt), ...]) for highlighting diff headers"""
150 formats = []
151 if self.DIFF_HUNK_HEADER_RGX.match(text):
152 state = self.DIFF_STATE
153 formats.append((0, len(text), self.bold_diff_header_fmt))
154 else:
155 formats.append((0, len(text), self.diff_header_fmt))
157 return state, formats
159 def get_formats_for_diff_text(self, state, text):
160 """Return (state, [(start, end fmt), ...]) for highlighting diff text"""
161 formats = []
163 if self.DIFF_FILE_HEADER_START_RGX.match(text):
164 state = self.DIFF_FILE_HEADER_STATE
165 formats.append((0, len(text), self.diff_header_fmt))
167 elif self.DIFF_HUNK_HEADER_RGX.match(text):
168 formats.append((0, len(text), self.bold_diff_header_fmt))
170 elif text.startswith('-'):
171 if text == '-- ':
172 state = self.END_STATE
173 else:
174 formats.append((0, len(text), self.diff_remove_fmt))
176 elif text.startswith('+'):
177 formats.append((0, len(text), self.diff_add_fmt))
178 if self.whitespace:
179 match = self.BAD_WHITESPACE_RGX.search(text)
180 if match is not None:
181 start = match.start()
182 formats.append((start, len(text) - start, self.bad_whitespace_fmt))
184 return state, formats
187 # pylint: disable=too-many-ancestors
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)
216 # pylint: disable=no-member
217 self.cursorPositionChanged.connect(self._cursor_changed)
218 self.selectionChanged.connect(self._selection_changed)
220 def setFont(self, font):
221 """Override setFont() so that we can use a custom "block" cursor"""
222 super(DiffTextEdit, self).setFont(font)
223 if prefs.block_cursor(self.context):
224 metrics = QtGui.QFontMetrics(font)
225 width = metrics.width('M')
226 self.setCursorWidth(width)
228 def _cursor_changed(self):
229 """Update the line number display when the cursor changes"""
230 line_number = max(0, self.textCursor().blockNumber())
231 if self.numbers is not None:
232 self.numbers.set_highlighted(line_number)
234 def _selection_changed(self):
235 """Respond to selection changes"""
236 selected = bool(self.selected_text())
237 self.copy_diff_action.setEnabled(selected)
239 def resizeEvent(self, event):
240 super(DiffTextEdit, self).resizeEvent(event)
241 if self.numbers:
242 self.numbers.refresh_size()
244 def save_scrollbar(self):
245 """Save the scrollbar value, but only on the first call"""
246 if self.scrollvalue is None:
247 scrollbar = self.verticalScrollBar()
248 if scrollbar:
249 scrollvalue = get(scrollbar)
250 else:
251 scrollvalue = None
252 self.scrollvalue = scrollvalue
254 def restore_scrollbar(self):
255 """Restore the scrollbar and clear state"""
256 scrollbar = self.verticalScrollBar()
257 scrollvalue = self.scrollvalue
258 if scrollbar and scrollvalue is not None:
259 scrollbar.setValue(scrollvalue)
260 self.scrollvalue = None
262 def set_loading_message(self):
263 """Add a pending loading message in the diff view"""
264 self.hint.set_value('+++ ' + N_('Loading...'))
265 self.set_value('')
267 def set_diff(self, diff):
268 """Set the diff text, but save the scrollbar"""
269 diff = diff.rstrip('\n') # diffs include two empty newlines
270 self.save_scrollbar()
272 self.hint.set_value('')
273 if self.numbers:
274 self.numbers.set_diff(diff)
275 self.set_value(diff)
277 self.restore_scrollbar()
279 def selected_diff_stripped(self):
280 """Return the selected diff stripped of any diff characters"""
281 sep, selection = self.selected_text_lines()
282 return sep.join(_strip_diff(line) for line in selection)
284 def copy_diff(self):
285 """Copy the selected diff text stripped of any diff prefix characters"""
286 text = self.selected_diff_stripped()
287 qtutils.set_clipboard(text)
289 def selected_lines(self):
290 """Return selected lines"""
291 cursor = self.textCursor()
292 selection_start = cursor.selectionStart()
293 selection_end = max(selection_start, cursor.selectionEnd() - 1)
295 first_line_idx = -1
296 last_line_idx = -1
297 line_idx = 0
298 line_start = 0
300 for line_idx, line in enumerate(get(self, default='').splitlines()):
301 line_end = line_start + len(line)
302 if line_start <= selection_start <= line_end:
303 first_line_idx = line_idx
304 if line_start <= selection_end <= line_end:
305 last_line_idx = line_idx
306 break
307 line_start = line_end + 1
309 if first_line_idx == -1:
310 first_line_idx = line_idx
312 if last_line_idx == -1:
313 last_line_idx = line_idx
315 return first_line_idx, last_line_idx
317 def selected_text_lines(self):
318 """Return selected lines and the CRLF / LF separator"""
319 first_line_idx, last_line_idx = self.selected_lines()
320 text = get(self, default='')
321 sep = _get_sep(text)
322 lines = []
323 for line_idx, line in enumerate(text.split(sep)):
324 if first_line_idx <= line_idx <= last_line_idx:
325 lines.append(line)
326 return sep, lines
329 def _get_sep(text):
330 """Return either CRLF or LF based on the content"""
331 if '\r\n' in text:
332 sep = '\r\n'
333 else:
334 sep = '\n'
335 return sep
338 def _strip_diff(value):
339 """Remove +/-/<space> from a selection"""
340 if value.startswith(('+', '-', ' ')):
341 return value[1:]
342 return value
345 class DiffLineNumbers(TextDecorator):
346 def __init__(self, context, parent):
347 TextDecorator.__init__(self, parent)
348 self.highlight_line = -1
349 self.lines = None
350 self.parser = diffparse.DiffLines()
351 self.formatter = diffparse.FormatDigits()
353 self.setFont(qtutils.diff_font(context))
354 self._char_width = self.fontMetrics().width('0')
356 QPalette = QtGui.QPalette
357 self._palette = palette = self.palette()
358 self._base = palette.color(QtGui.QPalette.Base)
359 self._highlight = palette.color(QPalette.Highlight)
360 self._highlight.setAlphaF(0.3)
361 self._highlight_text = palette.color(QPalette.HighlightedText)
362 self._window = palette.color(QPalette.Window)
363 self._disabled = palette.color(QPalette.Disabled, QPalette.Text)
365 def set_diff(self, diff):
366 self.lines = self.parser.parse(diff)
367 self.formatter.set_digits(self.parser.digits())
369 def width_hint(self):
370 if not self.isVisible():
371 return 0
372 parser = self.parser
374 if parser.merge:
375 columns = 3
376 extra = 3 # one space in-between, one space after
377 else:
378 columns = 2
379 extra = 2 # one space in-between, one space after
381 digits = parser.digits() * columns
383 return defs.margin + (self._char_width * (digits + extra))
385 def set_highlighted(self, line_number):
386 """Set the line to highlight"""
387 self.highlight_line = line_number
389 def current_line(self):
390 lines = self.lines
391 if lines and self.highlight_line >= 0:
392 # Find the next valid line
393 for i in range(self.highlight_line, len(lines)):
394 # take the "new" line number: last value in tuple
395 line_number = lines[i][-1]
396 if line_number > 0:
397 return line_number
399 # Find the previous valid line
400 for i in range(self.highlight_line - 1, -1, -1):
401 # take the "new" line number: last value in tuple
402 if i < len(lines):
403 line_number = lines[i][-1]
404 if line_number > 0:
405 return line_number
406 return None
408 def paintEvent(self, event):
409 """Paint the line number"""
410 if not self.lines:
411 return
413 painter = QtGui.QPainter(self)
414 painter.fillRect(event.rect(), self._base)
416 editor = self.editor
417 content_offset = editor.contentOffset()
418 block = editor.firstVisibleBlock()
419 width = self.width()
420 text_width = width - (defs.margin * 2)
421 text_flags = Qt.AlignRight | Qt.AlignVCenter
422 event_rect_bottom = event.rect().bottom()
424 highlight_line = self.highlight_line
425 highlight = self._highlight
426 highlight_text = self._highlight_text
427 disabled = self._disabled
429 fmt = self.formatter
430 lines = self.lines
431 num_lines = len(lines)
433 while block.isValid():
434 block_number = block.blockNumber()
435 if block_number >= num_lines:
436 break
437 block_geom = editor.blockBoundingGeometry(block)
438 rect = block_geom.translated(content_offset).toRect()
439 if not block.isVisible() or rect.top() >= event_rect_bottom:
440 break
442 if block_number == highlight_line:
443 painter.fillRect(rect.x(), rect.y(), width, rect.height(), highlight)
444 painter.setPen(highlight_text)
445 else:
446 painter.setPen(disabled)
448 line = lines[block_number]
449 if len(line) == 2:
450 a, b = line
451 text = fmt.value(a, b)
452 elif len(line) == 3:
453 old, base, new = line
454 text = fmt.merge_value(old, base, new)
456 painter.drawText(
457 rect.x(),
458 rect.y(),
459 text_width,
460 rect.height(),
461 text_flags,
462 text,
465 block = block.next()
468 class Viewer(QtWidgets.QFrame):
469 """Text and image diff viewers"""
471 INDEX_TEXT = 0
472 INDEX_IMAGE = 1
474 def __init__(self, context, parent=None):
475 super(Viewer, self).__init__(parent)
477 self.context = context
478 self.model = model = context.model
479 self.images = []
480 self.pixmaps = []
481 self.options = options = Options(self)
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()
488 stack = self.stack = QtWidgets.QStackedWidget(self)
489 stack.addWidget(self.text)
490 stack.addWidget(self.image)
492 self.main_layout = qtutils.vbox(
493 defs.no_margin,
494 defs.no_spacing,
495 self.stack,
496 self.search_widget,
498 self.setLayout(self.main_layout)
500 # Observe images
501 model.images_changed.connect(self.set_images, type=Qt.QueuedConnection)
503 # Observe the diff type
504 model.diff_type_changed.connect(self.set_diff_type, type=Qt.QueuedConnection)
506 # Observe the file type
507 model.file_type_changed.connect(self.set_file_type, type=Qt.QueuedConnection)
509 # Observe the image mode combo box
510 options.image_mode.currentIndexChanged.connect(lambda _: self.render())
511 options.zoom_mode.currentIndexChanged.connect(lambda _: self.render())
513 self.setFocusProxy(self.text)
515 self.search_action = qtutils.add_action(
516 self,
517 N_('Search in Diff'),
518 self.show_search_diff,
519 hotkeys.SEARCH,
522 def show_search_diff(self):
523 """Show a dialog for searching diffs"""
524 # The diff search is only active in text mode.
525 if self.stack.currentIndex() != self.INDEX_TEXT:
526 return
527 if not self.search_widget.isVisible():
528 self.search_widget.show()
529 self.search_widget.setFocus(True)
531 def export_state(self, state):
532 state['show_diff_line_numbers'] = self.options.show_line_numbers.isChecked()
533 state['image_diff_mode'] = self.options.image_mode.currentIndex()
534 state['image_zoom_mode'] = self.options.zoom_mode.currentIndex()
535 state['word_wrap'] = self.options.enable_word_wrapping.isChecked()
536 return state
538 def apply_state(self, state):
539 diff_numbers = bool(state.get('show_diff_line_numbers', False))
540 self.set_line_numbers(diff_numbers, update=True)
542 image_mode = utils.asint(state.get('image_diff_mode', 0))
543 self.options.image_mode.set_index(image_mode)
545 zoom_mode = utils.asint(state.get('image_zoom_mode', 0))
546 self.options.zoom_mode.set_index(zoom_mode)
548 word_wrap = bool(state.get('word_wrap', True))
549 self.set_word_wrapping(word_wrap, update=True)
550 return True
552 def set_diff_type(self, diff_type):
553 """Manage the image and text diff views when selection changes"""
554 # The "diff type" is whether the diff viewer is displaying an image.
555 self.options.set_diff_type(diff_type)
556 if diff_type == main.Types.IMAGE:
557 self.stack.setCurrentWidget(self.image)
558 self.search_widget.hide()
559 self.render()
560 else:
561 self.stack.setCurrentWidget(self.text)
563 def set_file_type(self, file_type):
564 """Manage the diff options when the file type changes"""
565 # The "file type" is whether the file itself is an image.
566 self.options.set_file_type(file_type)
568 def update_options(self):
569 """Emit a signal indicating that options have changed"""
570 self.text.update_options()
572 def set_line_numbers(self, enabled, update=False):
573 """Enable/disable line numbers in the text widget"""
574 self.text.set_line_numbers(enabled, update=update)
576 def set_word_wrapping(self, enabled, update=False):
577 """Enable/disable word wrapping in the text widget"""
578 self.text.set_word_wrapping(enabled, update=update)
580 def reset(self):
581 self.image.pixmap = QtGui.QPixmap()
582 self.cleanup()
584 def cleanup(self):
585 for (image, unlink) in self.images:
586 if unlink and core.exists(image):
587 os.unlink(image)
588 self.images = []
590 def set_images(self, images):
591 self.images = images
592 self.pixmaps = []
593 if not images:
594 self.reset()
595 return False
597 # In order to comp, we first have to load all the images
598 all_pixmaps = [QtGui.QPixmap(image[0]) for image in images]
599 pixmaps = [pixmap for pixmap in all_pixmaps if not pixmap.isNull()]
600 if not pixmaps:
601 self.reset()
602 return False
604 self.pixmaps = pixmaps
605 self.render()
606 self.cleanup()
607 return True
609 def render(self):
610 # Update images
611 if self.pixmaps:
612 mode = self.options.image_mode.currentIndex()
613 if mode == self.options.SIDE_BY_SIDE:
614 image = self.render_side_by_side()
615 elif mode == self.options.DIFF:
616 image = self.render_diff()
617 elif mode == self.options.XOR:
618 image = self.render_xor()
619 elif mode == self.options.PIXEL_XOR:
620 image = self.render_pixel_xor()
621 else:
622 image = self.render_side_by_side()
623 else:
624 image = QtGui.QPixmap()
625 self.image.pixmap = image
627 # Apply zoom
628 zoom_mode = self.options.zoom_mode.currentIndex()
629 zoom_factor = self.options.zoom_factors[zoom_mode][1]
630 if zoom_factor > 0.0:
631 self.image.resetTransform()
632 self.image.scale(zoom_factor, zoom_factor)
633 poly = self.image.mapToScene(self.image.viewport().rect())
634 self.image.last_scene_roi = poly.boundingRect()
636 def render_side_by_side(self):
637 # Side-by-side lineup comp
638 pixmaps = self.pixmaps
639 width = sum(pixmap.width() for pixmap in pixmaps)
640 height = max(pixmap.height() for pixmap in pixmaps)
641 image = create_image(width, height)
643 # Paint each pixmap
644 painter = create_painter(image)
645 x = 0
646 for pixmap in pixmaps:
647 painter.drawPixmap(x, 0, pixmap)
648 x += pixmap.width()
649 painter.end()
651 return image
653 def render_comp(self, comp_mode):
654 # Get the max size to use as the render canvas
655 pixmaps = self.pixmaps
656 if len(pixmaps) == 1:
657 return pixmaps[0]
659 width = max(pixmap.width() for pixmap in pixmaps)
660 height = max(pixmap.height() for pixmap in pixmaps)
661 image = create_image(width, height)
663 painter = create_painter(image)
664 for pixmap in (pixmaps[0], pixmaps[-1]):
665 x = (width - pixmap.width()) // 2
666 y = (height - pixmap.height()) // 2
667 painter.drawPixmap(x, y, pixmap)
668 painter.setCompositionMode(comp_mode)
669 painter.end()
671 return image
673 def render_diff(self):
674 comp_mode = QtGui.QPainter.CompositionMode_Difference
675 return self.render_comp(comp_mode)
677 def render_xor(self):
678 comp_mode = QtGui.QPainter.CompositionMode_Xor
679 return self.render_comp(comp_mode)
681 def render_pixel_xor(self):
682 comp_mode = QtGui.QPainter.RasterOp_SourceXorDestination
683 return self.render_comp(comp_mode)
686 def create_image(width, height):
687 size = QtCore.QSize(width, height)
688 image = QtGui.QImage(size, QtGui.QImage.Format_ARGB32_Premultiplied)
689 image.fill(Qt.transparent)
690 return image
693 def create_painter(image):
694 painter = QtGui.QPainter(image)
695 painter.fillRect(image.rect(), Qt.transparent)
696 return painter
699 class Options(QtWidgets.QWidget):
700 """Provide the options widget used by the editor
702 Actions are registered on the parent widget.
706 # mode combobox indexes
707 SIDE_BY_SIDE = 0
708 DIFF = 1
709 XOR = 2
710 PIXEL_XOR = 3
712 def __init__(self, parent):
713 super(Options, self).__init__(parent)
714 # Create widgets
715 self.widget = parent
716 self.ignore_space_at_eol = self.add_option(
717 N_('Ignore changes in whitespace at EOL')
720 self.ignore_space_change = self.add_option(
721 N_('Ignore changes in amount of whitespace')
724 self.ignore_all_space = self.add_option(N_('Ignore all whitespace'))
726 self.function_context = self.add_option(
727 N_('Show whole surrounding functions of changes')
730 self.show_line_numbers = qtutils.add_action_bool(
731 self, N_('Show line numbers'), self.set_line_numbers, True
733 self.enable_word_wrapping = qtutils.add_action_bool(
734 self, N_('Enable word wrapping'), self.set_word_wrapping, True
737 self.options = qtutils.create_action_button(
738 tooltip=N_('Diff Options'), icon=icons.configure()
741 self.toggle_image_diff = qtutils.create_action_button(
742 tooltip=N_('Toggle image diff'), icon=icons.visualize()
744 self.toggle_image_diff.hide()
746 self.image_mode = qtutils.combo(
747 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
750 self.zoom_factors = (
751 (N_('Zoom to Fit'), 0.0),
752 (N_('25%'), 0.25),
753 (N_('50%'), 0.5),
754 (N_('100%'), 1.0),
755 (N_('200%'), 2.0),
756 (N_('400%'), 4.0),
757 (N_('800%'), 8.0),
759 zoom_modes = [factor[0] for factor in self.zoom_factors]
760 self.zoom_mode = qtutils.combo(zoom_modes, parent=self)
762 self.menu = menu = qtutils.create_menu(N_('Diff Options'), self.options)
763 self.options.setMenu(menu)
764 menu.addAction(self.ignore_space_at_eol)
765 menu.addAction(self.ignore_space_change)
766 menu.addAction(self.ignore_all_space)
767 menu.addSeparator()
768 menu.addAction(self.function_context)
769 menu.addAction(self.show_line_numbers)
770 menu.addSeparator()
771 menu.addAction(self.enable_word_wrapping)
773 # Layouts
774 layout = qtutils.hbox(
775 defs.no_margin,
776 defs.button_spacing,
777 self.image_mode,
778 self.zoom_mode,
779 self.options,
780 self.toggle_image_diff,
782 self.setLayout(layout)
784 # Policies
785 self.image_mode.setFocusPolicy(Qt.NoFocus)
786 self.zoom_mode.setFocusPolicy(Qt.NoFocus)
787 self.options.setFocusPolicy(Qt.NoFocus)
788 self.toggle_image_diff.setFocusPolicy(Qt.NoFocus)
789 self.setFocusPolicy(Qt.NoFocus)
791 def set_file_type(self, file_type):
792 """Set whether we are viewing an image file type"""
793 is_image = file_type == main.Types.IMAGE
794 self.toggle_image_diff.setVisible(is_image)
796 def set_diff_type(self, diff_type):
797 """Toggle between image and text diffs"""
798 is_text = diff_type == main.Types.TEXT
799 is_image = diff_type == main.Types.IMAGE
800 self.options.setVisible(is_text)
801 self.image_mode.setVisible(is_image)
802 self.zoom_mode.setVisible(is_image)
803 if is_image:
804 self.toggle_image_diff.setIcon(icons.diff())
805 else:
806 self.toggle_image_diff.setIcon(icons.visualize())
808 def add_option(self, title):
809 """Add a diff option which calls update_options() on change"""
810 action = qtutils.add_action(self, title, self.update_options)
811 action.setCheckable(True)
812 return action
814 def update_options(self):
815 """Update diff options in response to UI events"""
816 space_at_eol = get(self.ignore_space_at_eol)
817 space_change = get(self.ignore_space_change)
818 all_space = get(self.ignore_all_space)
819 function_context = get(self.function_context)
820 gitcmds.update_diff_overrides(
821 space_at_eol, space_change, all_space, function_context
823 self.widget.update_options()
825 def set_line_numbers(self, value):
826 """Enable / disable line numbers"""
827 self.widget.set_line_numbers(value, update=False)
829 def set_word_wrapping(self, value):
830 """Respond to Qt action callbacks"""
831 self.widget.set_word_wrapping(value, update=False)
833 def hide_advanced_options(self):
834 """Hide advanced options that are not applicable to the DiffWidget"""
835 self.show_line_numbers.setVisible(False)
836 self.ignore_space_at_eol.setVisible(False)
837 self.ignore_space_change.setVisible(False)
838 self.ignore_all_space.setVisible(False)
839 self.function_context.setVisible(False)
842 # pylint: disable=too-many-ancestors
843 class DiffEditor(DiffTextEdit):
845 up = Signal()
846 down = Signal()
847 options_changed = Signal()
849 def __init__(self, context, options, parent):
850 DiffTextEdit.__init__(self, context, parent, numbers=True)
851 self.context = context
852 self.model = model = context.model
853 self.selection_model = selection_model = context.selection
855 # "Diff Options" tool menu
856 self.options = options
858 self.action_apply_selection = qtutils.add_action(
859 self,
860 'Apply',
861 self.apply_selection,
862 hotkeys.STAGE_DIFF,
863 hotkeys.STAGE_DIFF_ALT,
866 self.action_revert_selection = qtutils.add_action(
867 self, 'Revert', self.revert_selection, hotkeys.REVERT
869 self.action_revert_selection.setIcon(icons.undo())
871 self.action_edit_and_apply_selection = qtutils.add_action(
872 self,
873 'Edit and Apply',
874 partial(self.apply_selection, edit=True),
875 hotkeys.EDIT_AND_STAGE_DIFF,
878 self.action_edit_and_revert_selection = qtutils.add_action(
879 self,
880 'Edit and Revert',
881 partial(self.revert_selection, edit=True),
882 hotkeys.EDIT_AND_REVERT,
884 self.action_edit_and_revert_selection.setIcon(icons.undo())
885 self.launch_editor = actions.launch_editor_at_line(
886 context, self, hotkeys.EDIT_SHORT, *hotkeys.ACCEPT
888 self.launch_difftool = actions.launch_difftool(context, self)
889 self.stage_or_unstage = actions.stage_or_unstage(context, self)
891 # Emit up/down signals so that they can be routed by the main widget
892 self.move_up = actions.move_up(self)
893 self.move_down = actions.move_down(self)
895 model.diff_text_updated.connect(self.set_diff, type=Qt.QueuedConnection)
897 selection_model.selection_changed.connect(
898 self.refresh, type=Qt.QueuedConnection
900 # Update the selection model when the cursor changes
901 self.cursorPositionChanged.connect(self._update_line_number)
903 qtutils.connect_button(options.toggle_image_diff, self.toggle_diff_type)
905 def toggle_diff_type(self):
906 cmds.do(cmds.ToggleDiffType, self.context)
908 def refresh(self):
909 enabled = False
910 s = self.selection_model.selection()
911 model = self.model
912 if model.is_partially_stageable():
913 item = s.modified[0] if s.modified else None
914 if item in model.submodules:
915 pass
916 elif item not in model.unstaged_deleted:
917 enabled = True
918 self.action_revert_selection.setEnabled(enabled)
920 def set_line_numbers(self, enabled, update=False):
921 """Enable/disable the diff line number display"""
922 self.numbers.setVisible(enabled)
923 if update:
924 with qtutils.BlockSignals(self.options.show_line_numbers):
925 self.options.show_line_numbers.setChecked(enabled)
926 # Refresh the display. Not doing this results in the display not
927 # correctly displaying the line numbers widget until the text scrolls.
928 self.set_value(self.value())
930 def update_options(self):
931 self.options_changed.emit()
933 def create_context_menu(self, event_pos):
934 """Override create_context_menu() to display a completely custom menu"""
935 menu = super(DiffEditor, self).create_context_menu(event_pos)
936 context = self.context
937 model = self.model
938 s = self.selection_model.selection()
939 filename = self.selection_model.filename()
941 # These menu actions will be inserted at the start of the widget.
942 current_actions = menu.actions()
943 menu_actions = []
944 add_action = menu_actions.append
946 if model.is_stageable() or model.is_unstageable():
947 if model.is_stageable():
948 self.stage_or_unstage.setText(N_('Stage'))
949 self.stage_or_unstage.setIcon(icons.add())
950 else:
951 self.stage_or_unstage.setText(N_('Unstage'))
952 self.stage_or_unstage.setIcon(icons.remove())
953 add_action(self.stage_or_unstage)
955 if model.is_partially_stageable():
956 item = s.modified[0] if s.modified else None
957 if item in model.submodules:
958 path = core.abspath(item)
959 action = qtutils.add_action_with_icon(
960 menu,
961 icons.add(),
962 cmds.Stage.name(),
963 cmds.run(cmds.Stage, context, s.modified),
964 hotkeys.STAGE_SELECTION,
966 add_action(action)
968 action = qtutils.add_action_with_icon(
969 menu,
970 icons.cola(),
971 N_('Launch git-cola'),
972 cmds.run(cmds.OpenRepo, context, path),
974 add_action(action)
975 elif item not in model.unstaged_deleted:
976 if self.has_selection():
977 apply_text = N_('Stage Selected Lines')
978 edit_and_apply_text = N_('Edit Selected Lines to Stage...')
979 revert_text = N_('Revert Selected Lines...')
980 edit_and_revert_text = N_('Edit Selected Lines to Revert...')
981 else:
982 apply_text = N_('Stage Diff Hunk')
983 edit_and_apply_text = N_('Edit Diff Hunk to Stage...')
984 revert_text = N_('Revert Diff Hunk...')
985 edit_and_revert_text = N_('Edit Diff Hunk to Revert...')
987 self.action_apply_selection.setText(apply_text)
988 self.action_apply_selection.setIcon(icons.add())
989 add_action(self.action_apply_selection)
991 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
992 self.action_edit_and_apply_selection.setIcon(icons.add())
993 add_action(self.action_edit_and_apply_selection)
995 self.action_revert_selection.setText(revert_text)
996 add_action(self.action_revert_selection)
998 self.action_edit_and_revert_selection.setText(edit_and_revert_text)
999 add_action(self.action_edit_and_revert_selection)
1001 if s.staged and model.is_unstageable():
1002 item = s.staged[0]
1003 if item in model.submodules:
1004 path = core.abspath(item)
1005 action = qtutils.add_action_with_icon(
1006 menu,
1007 icons.remove(),
1008 cmds.Unstage.name(),
1009 cmds.run(cmds.Unstage, context, s.staged),
1010 hotkeys.STAGE_SELECTION,
1012 add_action(action)
1014 qtutils.add_action_with_icon(
1015 menu,
1016 icons.cola(),
1017 N_('Launch git-cola'),
1018 cmds.run(cmds.OpenRepo, context, path),
1020 add_action(action)
1022 elif item not in model.staged_deleted:
1023 if self.has_selection():
1024 apply_text = N_('Unstage Selected Lines')
1025 edit_and_apply_text = N_('Edit Selected Lines to Unstage...')
1026 else:
1027 apply_text = N_('Unstage Diff Hunk')
1028 edit_and_apply_text = N_('Edit Diff Hunk to Unstage...')
1030 self.action_apply_selection.setText(apply_text)
1031 self.action_apply_selection.setIcon(icons.remove())
1032 add_action(self.action_apply_selection)
1034 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1035 self.action_edit_and_apply_selection.setIcon(icons.remove())
1036 add_action(self.action_edit_and_apply_selection)
1038 if model.is_stageable() or model.is_unstageable():
1039 # Do not show the "edit" action when the file does not exist.
1040 # Untracked files exist by definition.
1041 if filename and core.exists(filename):
1042 add_action(qtutils.menu_separator(menu))
1043 add_action(self.launch_editor)
1045 # Removed files can still be diffed.
1046 add_action(self.launch_difftool)
1048 add_action(qtutils.menu_separator(menu))
1049 _add_patch_actions(self, self.context, menu)
1051 # Add the Previous/Next File actions, which improves discoverability
1052 # of their associated shortcuts
1053 add_action(qtutils.menu_separator(menu))
1054 add_action(self.move_up)
1055 add_action(self.move_down)
1056 add_action(qtutils.menu_separator(menu))
1058 if current_actions:
1059 first_action = current_actions[0]
1060 else:
1061 first_action = None
1062 menu.insertActions(first_action, menu_actions)
1064 return menu
1066 def mousePressEvent(self, event):
1067 if event.button() == Qt.RightButton:
1068 # Intercept right-click to move the cursor to the current position.
1069 # setTextCursor() clears the selection so this is only done when
1070 # nothing is selected.
1071 if not self.has_selection():
1072 cursor = self.cursorForPosition(event.pos())
1073 self.setTextCursor(cursor)
1075 return super(DiffEditor, self).mousePressEvent(event)
1077 def setPlainText(self, text):
1078 """setPlainText(str) while retaining scrollbar positions"""
1079 model = self.model
1080 mode = model.mode
1081 highlight = mode not in (
1082 model.mode_none,
1083 model.mode_display,
1084 model.mode_untracked,
1086 self.highlighter.set_enabled(highlight)
1088 scrollbar = self.verticalScrollBar()
1089 if scrollbar:
1090 scrollvalue = get(scrollbar)
1091 else:
1092 scrollvalue = None
1094 if text is None:
1095 return
1097 DiffTextEdit.setPlainText(self, text)
1099 if scrollbar and scrollvalue is not None:
1100 scrollbar.setValue(scrollvalue)
1102 def apply_selection(self, *, edit=False):
1103 model = self.model
1104 s = self.selection_model.single_selection()
1105 if model.is_partially_stageable() and (s.modified or s.untracked):
1106 self.process_diff_selection(edit=edit)
1107 elif model.is_unstageable():
1108 self.process_diff_selection(reverse=True, edit=edit)
1110 def revert_selection(self, *, edit=False):
1111 """Destructively revert selected lines or hunk from a worktree file."""
1113 if not edit:
1114 if self.has_selection():
1115 title = N_('Revert Selected Lines?')
1116 ok_text = N_('Revert Selected Lines')
1117 else:
1118 title = N_('Revert Diff Hunk?')
1119 ok_text = N_('Revert Diff Hunk')
1121 if not Interaction.confirm(
1122 title,
1124 'This operation drops uncommitted changes.\n'
1125 'These changes cannot be recovered.'
1127 N_('Revert the uncommitted changes?'),
1128 ok_text,
1129 default=True,
1130 icon=icons.undo(),
1132 return
1133 self.process_diff_selection(reverse=True, apply_to_worktree=True, edit=edit)
1135 def extract_patch(self, reverse=False):
1136 first_line_idx, last_line_idx = self.selected_lines()
1137 patch = diffparse.Patch.parse(self.model.filename, self.model.diff_text)
1138 if self.has_selection():
1139 return patch.extract_subset(first_line_idx, last_line_idx, reverse=reverse)
1140 return patch.extract_hunk(first_line_idx, reverse=reverse)
1142 def patch_encoding(self):
1143 if isinstance(self.model.diff_text, core.UStr):
1144 # original encoding must prevail
1145 return self.model.diff_text.encoding
1146 return self.context.cfg.file_encoding(self.model.filename)
1148 def process_diff_selection(
1149 self, reverse=False, apply_to_worktree=False, edit=False
1151 """Implement un/staging of the selected line(s) or hunk."""
1152 if self.selection_model.is_empty():
1153 return
1154 patch = self.extract_patch(reverse)
1155 if not patch.has_changes():
1156 return
1157 patch_encoding = self.patch_encoding()
1159 if edit:
1160 patch = edit_patch(
1161 patch,
1162 patch_encoding,
1163 self.context,
1164 reverse=reverse,
1165 apply_to_worktree=apply_to_worktree,
1167 if not patch.has_changes():
1168 return
1170 cmds.do(
1171 cmds.ApplyPatch,
1172 self.context,
1173 patch,
1174 patch_encoding,
1175 apply_to_worktree,
1178 def _update_line_number(self):
1179 """Update the selection model when the cursor changes"""
1180 self.selection_model.line_number = self.numbers.current_line()
1183 def _add_patch_actions(widget, context, menu):
1184 """Add actions for manipulating patch files"""
1185 patches_menu = menu.addMenu(N_('Patches'))
1186 patches_menu.setIcon(icons.diff())
1187 export_action = qtutils.add_action(
1188 patches_menu,
1189 N_('Export Patch'),
1190 lambda: _export_patch(widget, context),
1192 export_action.setIcon(icons.save())
1193 patches_menu.addAction(export_action)
1195 # Build the "Append Patch" menu dynamically.
1196 append_menu = patches_menu.addMenu(N_('Append Patch'))
1197 append_menu.setIcon(icons.add())
1198 append_menu.aboutToShow.connect(
1199 lambda: _build_patch_append_menu(widget, context, append_menu)
1203 def _build_patch_append_menu(widget, context, menu):
1204 """Build the "Append Patch" submenu"""
1205 # Build the menu when first displayed only. This initial check avoids
1206 # re-populating the menu with duplicate actions.
1207 menu_actions = menu.actions()
1208 if menu_actions:
1209 return
1211 choose_patch_action = qtutils.add_action(
1212 menu,
1213 N_('Choose Patch...'),
1214 lambda: _export_patch(widget, context, append=True),
1216 choose_patch_action.setIcon(icons.diff())
1217 menu.addAction(choose_patch_action)
1219 subdir_menus = {}
1220 path = prefs.patches_directory(context)
1221 patches = patch_mod.get_patches_from_dir(path)
1222 for patch in patches:
1223 relpath = os.path.relpath(patch, start=path)
1224 sub_menu = _add_patch_subdirs(menu, subdir_menus, relpath)
1225 patch_basename = os.path.basename(relpath)
1226 append_action = qtutils.add_action(
1227 sub_menu,
1228 patch_basename,
1229 lambda patch_file=patch: _append_patch(widget, patch_file),
1231 append_action.setIcon(icons.save())
1232 sub_menu.addAction(append_action)
1235 def _add_patch_subdirs(menu, subdir_menus, relpath):
1236 """Build menu leading up to the patch"""
1237 # If the path contains no directory separators then add it to the
1238 # root of the menu.
1239 if os.sep not in relpath:
1240 return menu
1242 # Loop over each directory component and build a menu if it doesn't already exist.
1243 components = []
1244 for dirname in os.path.dirname(relpath).split(os.sep):
1245 components.append(dirname)
1246 current_dir = os.sep.join(components)
1247 try:
1248 menu = subdir_menus[current_dir]
1249 except KeyError:
1250 menu = subdir_menus[current_dir] = menu.addMenu(dirname)
1251 menu.setIcon(icons.folder())
1253 return menu
1256 def _export_patch(diff_editor, context, append=False):
1257 """Export the selected diff to a patch file"""
1258 if diff_editor.selection_model.is_empty():
1259 return
1260 patch = diff_editor.extract_patch(reverse=False)
1261 if not patch.has_changes():
1262 return
1263 directory = prefs.patches_directory(context)
1264 if append:
1265 filename = qtutils.existing_file(directory, title=N_('Append Patch...'))
1266 else:
1267 default_filename = os.path.join(directory, 'diff.patch')
1268 filename = qtutils.save_as(default_filename)
1269 if not filename:
1270 return
1271 _write_patch_to_file(diff_editor, patch, filename, append=append)
1274 def _append_patch(diff_editor, filename):
1275 """Append diffs to the specified patch file"""
1276 if diff_editor.selection_model.is_empty():
1277 return
1278 patch = diff_editor.extract_patch(reverse=False)
1279 if not patch.has_changes():
1280 return
1281 _write_patch_to_file(diff_editor, patch, filename, append=True)
1284 def _write_patch_to_file(diff_editor, patch, filename, append=False):
1285 """Write diffs from the Diff Editor to the specified patch file"""
1286 encoding = diff_editor.patch_encoding()
1287 content = patch.as_text()
1288 try:
1289 core.write(filename, content, encoding=encoding, append=append)
1290 except (IOError, OSError) as exc:
1291 _, details = utils.format_exception(exc)
1292 title = N_('Error writing patch')
1293 msg = N_('Unable to write patch to "%s". Check permissions?' % filename)
1294 Interaction.critical(title, message=msg, details=details)
1295 return
1296 Interaction.log('Patch written to "%s"' % filename)
1299 class DiffWidget(QtWidgets.QWidget):
1300 """Display commit metadata and text diffs"""
1302 def __init__(self, context, parent, is_commit=False, options=None):
1303 QtWidgets.QWidget.__init__(self, parent)
1305 self.context = context
1306 self.oid = 'HEAD'
1307 self.oid_start = None
1308 self.oid_end = None
1309 self.options = options
1311 author_font = QtGui.QFont(self.font())
1312 author_font.setPointSize(int(author_font.pointSize() * 1.1))
1314 summary_font = QtGui.QFont(author_font)
1315 summary_font.setWeight(QtGui.QFont.Bold)
1317 policy = QtWidgets.QSizePolicy(
1318 QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Minimum
1321 self.gravatar_label = gravatar.GravatarLabel(self.context, parent=self)
1323 self.oid_label = TextLabel()
1324 self.oid_label.setTextFormat(Qt.PlainText)
1325 self.oid_label.setSizePolicy(policy)
1326 self.oid_label.setAlignment(Qt.AlignBottom)
1327 self.oid_label.elide()
1329 self.author_label = TextLabel()
1330 self.author_label.setTextFormat(Qt.RichText)
1331 self.author_label.setFont(author_font)
1332 self.author_label.setSizePolicy(policy)
1333 self.author_label.setAlignment(Qt.AlignTop)
1334 self.author_label.elide()
1336 self.date_label = TextLabel()
1337 self.date_label.setTextFormat(Qt.PlainText)
1338 self.date_label.setSizePolicy(policy)
1339 self.date_label.setAlignment(Qt.AlignTop)
1340 self.date_label.elide()
1342 self.summary_label = TextLabel()
1343 self.summary_label.setTextFormat(Qt.PlainText)
1344 self.summary_label.setFont(summary_font)
1345 self.summary_label.setSizePolicy(policy)
1346 self.summary_label.setAlignment(Qt.AlignTop)
1347 self.summary_label.elide()
1349 self.diff = DiffTextEdit(context, self, is_commit=is_commit, whitespace=False)
1350 self.setFocusProxy(self.diff)
1352 self.info_layout = qtutils.vbox(
1353 defs.no_margin,
1354 defs.no_spacing,
1355 self.oid_label,
1356 self.author_label,
1357 self.date_label,
1358 self.summary_label,
1361 self.logo_layout = qtutils.hbox(
1362 defs.no_margin, defs.button_spacing, self.gravatar_label, self.info_layout
1364 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
1366 self.main_layout = qtutils.vbox(
1367 defs.no_margin, defs.spacing, self.logo_layout, self.diff
1369 self.setLayout(self.main_layout)
1371 self.set_tabwidth(prefs.tabwidth(context))
1373 def set_tabwidth(self, width):
1374 self.diff.set_tabwidth(width)
1376 def set_word_wrapping(self, enabled, update=False):
1377 """Enable and disable word wrapping"""
1378 self.diff.set_word_wrapping(enabled, update=update)
1380 def set_options(self, options):
1381 """Register an options widget"""
1382 self.options = options
1383 self.diff.set_options(options)
1385 def start_diff_task(self, task):
1386 """Clear the display and start a diff-gathering task"""
1387 self.diff.save_scrollbar()
1388 self.diff.set_loading_message()
1389 self.context.runtask.start(task, result=self.set_diff)
1391 def set_diff_oid(self, oid, filename=None):
1392 """Set the diff from a single commit object ID"""
1393 task = DiffInfoTask(self.context, oid, filename)
1394 self.start_diff_task(task)
1396 def set_diff_range(self, start, end, filename=None):
1397 task = DiffRangeTask(self.context, start + '~', end, filename)
1398 self.start_diff_task(task)
1400 def commits_selected(self, commits):
1401 """Display an appropriate diff when commits are selected"""
1402 if not commits:
1403 self.clear()
1404 return
1405 commit = commits[-1]
1406 oid = commit.oid
1407 author = commit.author or ''
1408 email = commit.email or ''
1409 date = commit.authdate or ''
1410 summary = commit.summary or ''
1411 self.set_details(oid, author, email, date, summary)
1412 self.oid = oid
1414 if len(commits) > 1:
1415 start, end = commits[0], commits[-1]
1416 self.set_diff_range(start.oid, end.oid)
1417 self.oid_start = start
1418 self.oid_end = end
1419 else:
1420 self.set_diff_oid(oid)
1421 self.oid_start = None
1422 self.oid_end = None
1424 def set_diff(self, diff):
1425 """Set the diff text"""
1426 self.diff.set_diff(diff)
1428 def set_details(self, oid, author, email, date, summary):
1429 template_args = {'author': author, 'email': email, 'summary': summary}
1430 author_text = (
1431 """%(author)s &lt;"""
1432 """<a href="mailto:%(email)s">"""
1433 """%(email)s</a>&gt;""" % template_args
1435 author_template = '%(author)s <%(email)s>' % template_args
1437 self.date_label.set_text(date)
1438 self.date_label.setVisible(bool(date))
1439 self.oid_label.set_text(oid)
1440 self.author_label.set_template(author_text, author_template)
1441 self.summary_label.set_text(summary)
1442 self.gravatar_label.set_email(email)
1444 def clear(self):
1445 self.date_label.set_text('')
1446 self.oid_label.set_text('')
1447 self.author_label.set_text('')
1448 self.summary_label.set_text('')
1449 self.gravatar_label.clear()
1450 self.diff.clear()
1452 def files_selected(self, filenames):
1453 """Update the view when a filename is selected"""
1454 if not filenames:
1455 return
1456 oid_start = self.oid_start
1457 oid_end = self.oid_end
1458 if oid_start and oid_end:
1459 self.set_diff_range(oid_start.oid, oid_end.oid, filename=filenames[0])
1460 else:
1461 self.set_diff_oid(self.oid, filename=filenames[0])
1464 class DiffPanel(QtWidgets.QWidget):
1465 """A combined diff + search panel"""
1467 def __init__(self, diff_widget, text_widget, parent):
1468 super(DiffPanel, self).__init__(parent)
1469 self.diff_widget = diff_widget
1470 self.search_widget = TextSearchWidget(text_widget, self)
1471 self.search_widget.hide()
1472 layout = qtutils.vbox(
1473 defs.no_margin, defs.spacing, self.diff_widget, self.search_widget
1475 self.setLayout(layout)
1476 self.setFocusProxy(self.diff_widget)
1478 self.search_action = qtutils.add_action(
1479 self,
1480 N_('Search in Diff'),
1481 self.show_search,
1482 hotkeys.SEARCH,
1485 def show_search(self):
1486 """Show a dialog for searching diffs"""
1487 # The diff search is only active in text mode.
1488 if not self.search_widget.isVisible():
1489 self.search_widget.show()
1490 self.search_widget.setFocus(True)
1493 class TextLabel(QtWidgets.QLabel):
1494 def __init__(self, parent=None):
1495 QtWidgets.QLabel.__init__(self, parent)
1496 self.setTextInteractionFlags(
1497 Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse
1499 self._display = ''
1500 self._template = ''
1501 self._text = ''
1502 self._elide = False
1503 self._metrics = QtGui.QFontMetrics(self.font())
1504 self.setOpenExternalLinks(True)
1506 def elide(self):
1507 self._elide = True
1509 def set_text(self, text):
1510 self.set_template(text, text)
1512 def set_template(self, text, template):
1513 self._display = text
1514 self._text = text
1515 self._template = template
1516 self.update_text(self.width())
1517 self.setText(self._display)
1519 def update_text(self, width):
1520 self._display = self._text
1521 if not self._elide:
1522 return
1523 text = self._metrics.elidedText(self._template, Qt.ElideRight, width - 2)
1524 if text != self._template:
1525 self._display = text
1527 # Qt overrides
1528 def setFont(self, font):
1529 self._metrics = QtGui.QFontMetrics(font)
1530 QtWidgets.QLabel.setFont(self, font)
1532 def resizeEvent(self, event):
1533 if self._elide:
1534 self.update_text(event.size().width())
1535 with qtutils.BlockSignals(self):
1536 self.setText(self._display)
1537 QtWidgets.QLabel.resizeEvent(self, event)
1540 class DiffInfoTask(qtutils.Task):
1541 """Gather diffs for a single commit"""
1543 def __init__(self, context, oid, filename):
1544 qtutils.Task.__init__(self)
1545 self.context = context
1546 self.oid = oid
1547 self.filename = filename
1549 def task(self):
1550 context = self.context
1551 oid = self.oid
1552 return gitcmds.diff_info(context, oid, filename=self.filename)
1555 class DiffRangeTask(qtutils.Task):
1556 """Gather diffs for a range of commits"""
1558 def __init__(self, context, start, end, filename):
1559 qtutils.Task.__init__(self)
1560 self.context = context
1561 self.start = start
1562 self.end = end
1563 self.filename = filename
1565 def task(self):
1566 context = self.context
1567 return gitcmds.diff_range(context, self.start, self.end, filename=self.filename)