clone: clone into the parent directory by default
[git-cola.git] / cola / widgets / diff.py
blob9b4c6a50706fae2c957d05c1c0815d4834797c12
1 from functools import partial
2 import os
3 import re
5 from qtpy import QtCore
6 from qtpy import QtGui
7 from qtpy import QtWidgets
8 from qtpy.QtCore import Qt
9 from qtpy.QtCore import Signal
11 from ..i18n import N_
12 from ..editpatch import edit_patch
13 from ..interaction import Interaction
14 from ..models import main
15 from ..models import prefs
16 from ..qtutils import get
17 from .. import actions
18 from .. import cmds
19 from .. import core
20 from .. import diffparse
21 from .. import gitcmds
22 from .. import gravatar
23 from .. import hotkeys
24 from .. import icons
25 from .. import utils
26 from .. import qtutils
27 from .text import TextDecorator
28 from .text import VimHintedPlainTextEdit
29 from .text import PlainTextLabel
30 from .text import RichTextLabel
31 from .text import TextSearchWidget
32 from . import defs
33 from . import standard
34 from . import imageview
37 class DiffSyntaxHighlighter(QtGui.QSyntaxHighlighter):
38 """Implements the diff syntax highlighting"""
40 INITIAL_STATE = -1
41 DEFAULT_STATE = 0
42 DIFFSTAT_STATE = 1
43 DIFF_FILE_HEADER_STATE = 2
44 DIFF_STATE = 3
45 SUBMODULE_STATE = 4
46 END_STATE = 5
48 DIFF_FILE_HEADER_START_RGX = re.compile(r'diff --git a/.* b/.*')
49 DIFF_HUNK_HEADER_RGX = re.compile(
50 r'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)'
52 BAD_WHITESPACE_RGX = re.compile(r'\s+$')
54 def __init__(self, context, doc, whitespace=True, is_commit=False):
55 QtGui.QSyntaxHighlighter.__init__(self, doc)
56 self.whitespace = whitespace
57 self.enabled = True
58 self.is_commit = is_commit
60 QPalette = QtGui.QPalette
61 cfg = context.cfg
62 palette = QPalette()
63 disabled = palette.color(QPalette.Disabled, QPalette.Text)
64 header = qtutils.rgb_hex(disabled)
66 dark = palette.color(QPalette.Base).lightnessF() < 0.5
68 self.color_text = qtutils.rgb_triple(cfg.color('text', '030303'))
69 self.color_add = qtutils.rgb_triple(
70 cfg.color('add', '77aa77' if dark else 'd2ffe4')
72 self.color_remove = qtutils.rgb_triple(
73 cfg.color('remove', 'aa7777' if dark else 'fee0e4')
75 self.color_header = qtutils.rgb_triple(cfg.color('header', header))
77 self.diff_header_fmt = qtutils.make_format(foreground=self.color_header)
78 self.bold_diff_header_fmt = qtutils.make_format(
79 foreground=self.color_header, bold=True
82 self.diff_add_fmt = qtutils.make_format(
83 foreground=self.color_text, background=self.color_add
85 self.diff_remove_fmt = qtutils.make_format(
86 foreground=self.color_text, background=self.color_remove
88 self.bad_whitespace_fmt = qtutils.make_format(background=Qt.red)
89 self.setCurrentBlockState(self.INITIAL_STATE)
91 def set_enabled(self, enabled):
92 self.enabled = enabled
94 def highlightBlock(self, text):
95 """Highlight the current text block"""
96 if not self.enabled or not text:
97 return
98 formats = []
99 state = self.get_next_state(text)
100 if state == self.DIFFSTAT_STATE:
101 state, formats = self.get_formats_for_diffstat(state, text)
102 elif state == self.DIFF_FILE_HEADER_STATE:
103 state, formats = self.get_formats_for_diff_header(state, text)
104 elif state == self.DIFF_STATE:
105 state, formats = self.get_formats_for_diff_text(state, text)
107 for start, end, fmt in formats:
108 self.setFormat(start, end, fmt)
110 self.setCurrentBlockState(state)
112 def get_next_state(self, text):
113 """Transition to the next state based on the input text"""
114 state = self.previousBlockState()
115 if state == DiffSyntaxHighlighter.INITIAL_STATE:
116 if text.startswith('Submodule '):
117 state = DiffSyntaxHighlighter.SUBMODULE_STATE
118 elif text.startswith('diff --git '):
119 state = DiffSyntaxHighlighter.DIFFSTAT_STATE
120 elif self.is_commit:
121 state = DiffSyntaxHighlighter.DEFAULT_STATE
122 else:
123 state = DiffSyntaxHighlighter.DIFFSTAT_STATE
125 return state
127 def get_formats_for_diffstat(self, state, text):
128 """Returns (state, [(start, end, fmt), ...]) for highlighting diffstat text"""
129 formats = []
130 if self.DIFF_FILE_HEADER_START_RGX.match(text):
131 state = self.DIFF_FILE_HEADER_STATE
132 end = len(text)
133 fmt = self.diff_header_fmt
134 formats.append((0, end, fmt))
135 elif self.DIFF_HUNK_HEADER_RGX.match(text):
136 state = self.DIFF_STATE
137 end = len(text)
138 fmt = self.bold_diff_header_fmt
139 formats.append((0, end, fmt))
140 elif '|' in text:
141 offset = text.index('|')
142 formats.append((0, offset, self.bold_diff_header_fmt))
143 formats.append((offset, len(text) - offset, self.diff_header_fmt))
144 else:
145 formats.append((0, len(text), self.diff_header_fmt))
147 return state, formats
149 def get_formats_for_diff_header(self, state, text):
150 """Returns (state, [(start, end, fmt), ...]) for highlighting diff headers"""
151 formats = []
152 if self.DIFF_HUNK_HEADER_RGX.match(text):
153 state = self.DIFF_STATE
154 formats.append((0, len(text), self.bold_diff_header_fmt))
155 else:
156 formats.append((0, len(text), self.diff_header_fmt))
158 return state, formats
160 def get_formats_for_diff_text(self, state, text):
161 """Return (state, [(start, end fmt), ...]) for highlighting diff text"""
162 formats = []
164 if self.DIFF_FILE_HEADER_START_RGX.match(text):
165 state = self.DIFF_FILE_HEADER_STATE
166 formats.append((0, len(text), self.diff_header_fmt))
168 elif self.DIFF_HUNK_HEADER_RGX.match(text):
169 formats.append((0, len(text), self.bold_diff_header_fmt))
171 elif text.startswith('-'):
172 if text == '-- ':
173 state = self.END_STATE
174 else:
175 formats.append((0, len(text), self.diff_remove_fmt))
177 elif text.startswith('+'):
178 formats.append((0, len(text), self.diff_add_fmt))
179 if self.whitespace:
180 match = self.BAD_WHITESPACE_RGX.search(text)
181 if match is not None:
182 start = match.start()
183 formats.append((start, len(text) - start, self.bad_whitespace_fmt))
185 return state, formats
188 class DiffTextEdit(VimHintedPlainTextEdit):
189 """A textedit for interacting with diff text"""
191 def __init__(
192 self, context, parent, is_commit=False, whitespace=True, numbers=False
194 VimHintedPlainTextEdit.__init__(self, context, '', parent=parent)
195 # Diff/patch syntax highlighter
196 self.highlighter = DiffSyntaxHighlighter(
197 context, self.document(), is_commit=is_commit, whitespace=whitespace
199 if numbers:
200 self.numbers = DiffLineNumbers(context, self)
201 self.numbers.hide()
202 else:
203 self.numbers = None
204 self.scrollvalue = None
206 self.copy_diff_action = qtutils.add_action_with_icon(
207 self,
208 icons.copy(),
209 N_('Copy Diff'),
210 self.copy_diff,
211 hotkeys.COPY_DIFF,
213 self.copy_diff_action.setEnabled(False)
214 self.menu_actions.append(self.copy_diff_action)
215 self.cursorPositionChanged.connect(self._cursor_changed)
216 self.selectionChanged.connect(self._selection_changed)
218 def setFont(self, font):
219 """Override setFont() so that we can use a custom "block" cursor"""
220 super().setFont(font)
221 if prefs.block_cursor(self.context):
222 width = qtutils.text_width(font, 'M')
223 self.setCursorWidth(width)
225 def _cursor_changed(self):
226 """Update the line number display when the cursor changes"""
227 line_number = max(0, self.textCursor().blockNumber())
228 if self.numbers is not None:
229 self.numbers.set_highlighted(line_number)
231 def _selection_changed(self):
232 """Respond to selection changes"""
233 selected = bool(self.selected_text())
234 self.copy_diff_action.setEnabled(selected)
236 def resizeEvent(self, event):
237 super().resizeEvent(event)
238 if self.numbers:
239 self.numbers.refresh_size()
241 def save_scrollbar(self):
242 """Save the scrollbar value, but only on the first call"""
243 if self.scrollvalue is None:
244 scrollbar = self.verticalScrollBar()
245 if scrollbar:
246 scrollvalue = get(scrollbar)
247 else:
248 scrollvalue = None
249 self.scrollvalue = scrollvalue
251 def restore_scrollbar(self):
252 """Restore the scrollbar and clear state"""
253 scrollbar = self.verticalScrollBar()
254 scrollvalue = self.scrollvalue
255 if scrollbar and scrollvalue is not None:
256 scrollbar.setValue(scrollvalue)
257 self.scrollvalue = None
259 def set_loading_message(self):
260 """Add a pending loading message in the diff view"""
261 self.hint.set_value('+++ ' + N_('Loading...'))
262 self.set_value('')
264 def set_diff(self, diff):
265 """Set the diff text, but save the scrollbar"""
266 diff = diff.rstrip('\n') # diffs include two empty newlines
267 self.save_scrollbar()
269 self.hint.set_value('')
270 if self.numbers:
271 self.numbers.set_diff(diff)
272 self.set_value(diff)
274 self.restore_scrollbar()
276 def selected_diff_stripped(self):
277 """Return the selected diff stripped of any diff characters"""
278 sep, selection = self.selected_text_lines()
279 return sep.join(_strip_diff(line) for line in selection)
281 def copy_diff(self):
282 """Copy the selected diff text stripped of any diff prefix characters"""
283 text = self.selected_diff_stripped()
284 qtutils.set_clipboard(text)
286 def selected_lines(self):
287 """Return selected lines"""
288 cursor = self.textCursor()
289 selection_start = cursor.selectionStart()
290 selection_end = max(selection_start, cursor.selectionEnd() - 1)
292 first_line_idx = -1
293 last_line_idx = -1
294 line_idx = 0
295 line_start = 0
297 for line_idx, line in enumerate(get(self, default='').splitlines()):
298 line_end = line_start + len(line)
299 if line_start <= selection_start <= line_end:
300 first_line_idx = line_idx
301 if line_start <= selection_end <= line_end:
302 last_line_idx = line_idx
303 break
304 line_start = line_end + 1
306 if first_line_idx == -1:
307 first_line_idx = line_idx
309 if last_line_idx == -1:
310 last_line_idx = line_idx
312 return first_line_idx, last_line_idx
314 def selected_text_lines(self):
315 """Return selected lines and the CRLF / LF separator"""
316 first_line_idx, last_line_idx = self.selected_lines()
317 text = get(self, default='')
318 sep = _get_sep(text)
319 lines = []
320 for line_idx, line in enumerate(text.split(sep)):
321 if first_line_idx <= line_idx <= last_line_idx:
322 lines.append(line)
323 return sep, lines
326 def _get_sep(text):
327 """Return either CRLF or LF based on the content"""
328 if '\r\n' in text:
329 sep = '\r\n'
330 else:
331 sep = '\n'
332 return sep
335 def _strip_diff(value):
336 """Remove +/-/<space> from a selection"""
337 if value.startswith(('+', '-', ' ')):
338 return value[1:]
339 return value
342 class DiffLineNumbers(TextDecorator):
343 def __init__(self, context, parent):
344 TextDecorator.__init__(self, parent)
345 self.highlight_line = -1
346 self.lines = None
347 self.parser = diffparse.DiffLines()
348 self.formatter = diffparse.FormatDigits()
350 font = qtutils.diff_font(context)
351 self.setFont(font)
352 self._char_width = qtutils.text_width(font, 'M')
354 QPalette = QtGui.QPalette
355 self._palette = palette = self.palette()
356 self._base = palette.color(QtGui.QPalette.Base)
357 self._highlight = palette.color(QPalette.Highlight)
358 self._highlight.setAlphaF(0.3)
359 self._highlight_text = palette.color(QPalette.HighlightedText)
360 self._window = palette.color(QPalette.Window)
361 self._disabled = palette.color(QPalette.Disabled, QPalette.Text)
363 def set_diff(self, diff):
364 self.lines = self.parser.parse(diff)
365 self.formatter.set_digits(self.parser.digits())
367 def width_hint(self):
368 if not self.isVisible():
369 return 0
370 parser = self.parser
372 if parser.merge:
373 columns = 3
374 extra = 3 # one space in-between, one space after
375 else:
376 columns = 2
377 extra = 2 # one space in-between, one space after
379 digits = parser.digits() * columns
381 return defs.margin + (self._char_width * (digits + extra))
383 def set_highlighted(self, line_number):
384 """Set the line to highlight"""
385 self.highlight_line = line_number
387 def current_line(self):
388 lines = self.lines
389 if lines and self.highlight_line >= 0:
390 # Find the next valid line
391 for i in range(self.highlight_line, len(lines)):
392 # take the "new" line number: last value in tuple
393 line_number = lines[i][-1]
394 if line_number > 0:
395 return line_number
397 # Find the previous valid line
398 for i in range(self.highlight_line - 1, -1, -1):
399 # take the "new" line number: last value in tuple
400 if i < len(lines):
401 line_number = lines[i][-1]
402 if line_number > 0:
403 return line_number
404 return None
406 def paintEvent(self, event):
407 """Paint the line number"""
408 if not self.lines:
409 return
411 painter = QtGui.QPainter(self)
412 painter.fillRect(event.rect(), self._base)
414 editor = self.editor
415 content_offset = editor.contentOffset()
416 block = editor.firstVisibleBlock()
417 width = self.width()
418 text_width = width - (defs.margin * 2)
419 text_flags = Qt.AlignRight | Qt.AlignVCenter
420 event_rect_bottom = event.rect().bottom()
422 highlight_line = self.highlight_line
423 highlight = self._highlight
424 highlight_text = self._highlight_text
425 disabled = self._disabled
427 fmt = self.formatter
428 lines = self.lines
429 num_lines = len(lines)
431 while block.isValid():
432 block_number = block.blockNumber()
433 if block_number >= num_lines:
434 break
435 block_geom = editor.blockBoundingGeometry(block)
436 rect = block_geom.translated(content_offset).toRect()
437 if not block.isVisible() or rect.top() >= event_rect_bottom:
438 break
440 if block_number == highlight_line:
441 painter.fillRect(rect.x(), rect.y(), width, rect.height(), highlight)
442 painter.setPen(highlight_text)
443 else:
444 painter.setPen(disabled)
446 line = lines[block_number]
447 if len(line) == 2:
448 a, b = line
449 text = fmt.value(a, b)
450 elif len(line) == 3:
451 old, base, new = line
452 text = fmt.merge_value(old, base, new)
454 painter.drawText(
455 rect.x(),
456 rect.y(),
457 text_width,
458 rect.height(),
459 text_flags,
460 text,
463 block = block.next()
466 class Viewer(QtWidgets.QFrame):
467 """Text and image diff viewers"""
469 INDEX_TEXT = 0
470 INDEX_IMAGE = 1
472 def __init__(self, context, parent=None):
473 super().__init__(parent)
475 self.context = context
476 self.model = model = context.model
477 self.images = []
478 self.pixmaps = []
479 self.options = options = Options(self)
480 self.filename = PlainTextLabel(parent=self)
481 self.filename.setAlignment(Qt.AlignVCenter | Qt.AlignLeft)
482 font = self.font()
483 font.setItalic(True)
484 self.filename.setFont(font)
485 self.filename.elide()
486 self.text = DiffEditor(context, options, self)
487 self.image = imageview.ImageView(parent=self)
488 self.image.setFocusPolicy(Qt.NoFocus)
489 self.search_widget = TextSearchWidget(self.text, self)
490 self.search_widget.hide()
491 self._drag_has_patches = False
493 self.setAcceptDrops(True)
494 self.setFocusProxy(self.text)
496 stack = self.stack = QtWidgets.QStackedWidget(self)
497 stack.addWidget(self.text)
498 stack.addWidget(self.image)
500 self.main_layout = qtutils.vbox(
501 defs.no_margin,
502 defs.no_spacing,
503 self.stack,
504 self.search_widget,
506 self.setLayout(self.main_layout)
508 # Observe images
509 model.images_changed.connect(self.set_images, type=Qt.QueuedConnection)
511 # Observe the diff type
512 model.diff_type_changed.connect(self.set_diff_type, type=Qt.QueuedConnection)
514 # Observe the file type
515 model.file_type_changed.connect(self.set_file_type, type=Qt.QueuedConnection)
517 # Observe the image mode combo box
518 options.image_mode.currentIndexChanged.connect(lambda _: self.render())
519 options.zoom_mode.currentIndexChanged.connect(lambda _: self.render())
521 self.search_action = qtutils.add_action(
522 self,
523 N_('Search in Diff'),
524 self.show_search_diff,
525 hotkeys.SEARCH,
528 def dragEnterEvent(self, event):
529 """Accepts drops if the mimedata contains patches"""
530 super().dragEnterEvent(event)
531 patches = get_patches_from_mimedata(event.mimeData())
532 if patches:
533 event.acceptProposedAction()
534 self._drag_has_patches = True
536 def dragLeaveEvent(self, event):
537 """End the drag+drop interaction"""
538 super().dragLeaveEvent(event)
539 if self._drag_has_patches:
540 event.accept()
541 else:
542 event.ignore()
543 self._drag_has_patches = False
545 def dropEvent(self, event):
546 """Apply patches when dropped onto the widget"""
547 if not self._drag_has_patches:
548 event.ignore()
549 return
550 event.setDropAction(Qt.CopyAction)
551 super().dropEvent(event)
552 self._drag_has_patches = False
554 patches = get_patches_from_mimedata(event.mimeData())
555 if patches:
556 apply_patches(self.context, patches=patches)
558 event.accept() # must be called after dropEvent()
560 def show_search_diff(self):
561 """Show a dialog for searching diffs"""
562 # The diff search is only active in text mode.
563 if self.stack.currentIndex() != self.INDEX_TEXT:
564 return
565 if not self.search_widget.isVisible():
566 self.search_widget.show()
567 self.search_widget.setFocus()
569 def export_state(self, state):
570 state['show_diff_line_numbers'] = self.options.show_line_numbers.isChecked()
571 state['show_diff_filenames'] = self.options.show_filenames.isChecked()
572 state['image_diff_mode'] = self.options.image_mode.currentIndex()
573 state['image_zoom_mode'] = self.options.zoom_mode.currentIndex()
574 state['word_wrap'] = self.options.enable_word_wrapping.isChecked()
575 return state
577 def apply_state(self, state):
578 diff_numbers = bool(state.get('show_diff_line_numbers', False))
579 self.set_line_numbers(diff_numbers, update=True)
581 show_filenames = bool(state.get('show_diff_filenames', True))
582 self.set_show_filenames(show_filenames, update=True)
584 image_mode = utils.asint(state.get('image_diff_mode', 0))
585 self.options.image_mode.set_index(image_mode)
587 zoom_mode = utils.asint(state.get('image_zoom_mode', 0))
588 self.options.zoom_mode.set_index(zoom_mode)
590 word_wrap = bool(state.get('word_wrap', True))
591 self.set_word_wrapping(word_wrap, update=True)
592 return True
594 def set_diff_type(self, diff_type):
595 """Manage the image and text diff views when selection changes"""
596 # The "diff type" is whether the diff viewer is displaying an image.
597 self.options.set_diff_type(diff_type)
598 if diff_type == main.Types.IMAGE:
599 self.stack.setCurrentWidget(self.image)
600 self.search_widget.hide()
601 self.render()
602 else:
603 self.stack.setCurrentWidget(self.text)
605 def set_file_type(self, file_type):
606 """Manage the diff options when the file type changes"""
607 # The "file type" is whether the file itself is an image.
608 self.options.set_file_type(file_type)
610 def enable_filename_tracking(self):
611 """Enable displaying the currently selected filename"""
612 self.context.selection.selection_changed.connect(
613 self.update_filename, type=Qt.QueuedConnection
616 def update_filename(self):
617 """Update the filename display when the selection changes"""
618 filename = self.context.selection.filename()
619 self.filename.set_text(filename or '')
621 def update_options(self):
622 """Emit a signal indicating that options have changed"""
623 self.text.update_options()
624 show_filenames = get(self.options.show_filenames)
625 self.set_show_filenames(show_filenames)
627 def set_show_filenames(self, enabled, update=False):
628 """Enable/disable displaying the selected filename"""
629 self.filename.setVisible(enabled)
630 if update:
631 with qtutils.BlockSignals(self.options.show_filenames):
632 self.options.show_filenames.setChecked(enabled)
634 def set_line_numbers(self, enabled, update=False):
635 """Enable/disable line numbers in the text widget"""
636 self.text.set_line_numbers(enabled, update=update)
638 def set_word_wrapping(self, enabled, update=False):
639 """Enable/disable word wrapping in the text widget"""
640 self.text.set_word_wrapping(enabled, update=update)
642 def reset(self):
643 self.image.pixmap = QtGui.QPixmap()
644 self.cleanup()
646 def cleanup(self):
647 for image, unlink in self.images:
648 if unlink and core.exists(image):
649 os.unlink(image)
650 self.images = []
652 def set_images(self, images):
653 self.images = images
654 self.pixmaps = []
655 if not images:
656 self.reset()
657 return False
659 # In order to comp, we first have to load all the images
660 all_pixmaps = [QtGui.QPixmap(image[0]) for image in images]
661 pixmaps = [pixmap for pixmap in all_pixmaps if not pixmap.isNull()]
662 if not pixmaps:
663 self.reset()
664 return False
666 self.pixmaps = pixmaps
667 self.render()
668 self.cleanup()
669 return True
671 def render(self):
672 # Update images
673 if self.pixmaps:
674 mode = self.options.image_mode.currentIndex()
675 if mode == self.options.SIDE_BY_SIDE:
676 image = self.render_side_by_side()
677 elif mode == self.options.DIFF:
678 image = self.render_diff()
679 elif mode == self.options.XOR:
680 image = self.render_xor()
681 elif mode == self.options.PIXEL_XOR:
682 image = self.render_pixel_xor()
683 else:
684 image = self.render_side_by_side()
685 else:
686 image = QtGui.QPixmap()
687 self.image.pixmap = image
689 # Apply zoom
690 zoom_mode = self.options.zoom_mode.currentIndex()
691 zoom_factor = self.options.zoom_factors[zoom_mode][1]
692 if zoom_factor > 0.0:
693 self.image.resetTransform()
694 self.image.scale(zoom_factor, zoom_factor)
695 poly = self.image.mapToScene(self.image.viewport().rect())
696 self.image.last_scene_roi = poly.boundingRect()
698 def render_side_by_side(self):
699 # Side-by-side lineup comp
700 pixmaps = self.pixmaps
701 width = sum(pixmap.width() for pixmap in pixmaps)
702 height = max(pixmap.height() for pixmap in pixmaps)
703 image = create_image(width, height)
705 # Paint each pixmap
706 painter = create_painter(image)
707 x = 0
708 for pixmap in pixmaps:
709 painter.drawPixmap(x, 0, pixmap)
710 x += pixmap.width()
711 painter.end()
713 return image
715 def render_comp(self, comp_mode):
716 # Get the max size to use as the render canvas
717 pixmaps = self.pixmaps
718 if len(pixmaps) == 1:
719 return pixmaps[0]
721 width = max(pixmap.width() for pixmap in pixmaps)
722 height = max(pixmap.height() for pixmap in pixmaps)
723 image = create_image(width, height)
725 painter = create_painter(image)
726 for pixmap in (pixmaps[0], pixmaps[-1]):
727 x = (width - pixmap.width()) // 2
728 y = (height - pixmap.height()) // 2
729 painter.drawPixmap(x, y, pixmap)
730 painter.setCompositionMode(comp_mode)
731 painter.end()
733 return image
735 def render_diff(self):
736 comp_mode = QtGui.QPainter.CompositionMode_Difference
737 return self.render_comp(comp_mode)
739 def render_xor(self):
740 comp_mode = QtGui.QPainter.CompositionMode_Xor
741 return self.render_comp(comp_mode)
743 def render_pixel_xor(self):
744 comp_mode = QtGui.QPainter.RasterOp_SourceXorDestination
745 return self.render_comp(comp_mode)
748 def create_image(width, height):
749 size = QtCore.QSize(width, height)
750 image = QtGui.QImage(size, QtGui.QImage.Format_ARGB32_Premultiplied)
751 image.fill(Qt.transparent)
752 return image
755 def create_painter(image):
756 painter = QtGui.QPainter(image)
757 painter.fillRect(image.rect(), Qt.transparent)
758 return painter
761 class Options(QtWidgets.QWidget):
762 """Provide the options widget used by the editor
764 Actions are registered on the parent widget.
768 # mode combobox indexes
769 SIDE_BY_SIDE = 0
770 DIFF = 1
771 XOR = 2
772 PIXEL_XOR = 3
774 def __init__(self, parent):
775 super().__init__(parent)
776 # Create widgets
777 self.widget = parent
778 self.ignore_space_at_eol = self.add_option(
779 N_('Ignore changes in whitespace at EOL')
781 self.ignore_space_change = self.add_option(
782 N_('Ignore changes in amount of whitespace')
784 self.ignore_all_space = self.add_option(N_('Ignore all whitespace'))
785 self.function_context = self.add_option(
786 N_('Show whole surrounding functions of changes')
788 self.show_line_numbers = qtutils.add_action_bool(
789 self, N_('Show line numbers'), self.set_line_numbers, True
791 self.show_filenames = self.add_option(N_('Show filenames'))
792 self.enable_word_wrapping = qtutils.add_action_bool(
793 self, N_('Enable word wrapping'), self.set_word_wrapping, True
796 self.options = qtutils.create_action_button(
797 tooltip=N_('Diff Options'), icon=icons.configure()
800 self.toggle_image_diff = qtutils.create_action_button(
801 tooltip=N_('Toggle image diff'), icon=icons.visualize()
803 self.toggle_image_diff.hide()
805 self.image_mode = qtutils.combo(
806 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
809 self.zoom_factors = (
810 (N_('Zoom to Fit'), 0.0),
811 (N_('25%'), 0.25),
812 (N_('50%'), 0.5),
813 (N_('100%'), 1.0),
814 (N_('200%'), 2.0),
815 (N_('400%'), 4.0),
816 (N_('800%'), 8.0),
818 zoom_modes = [factor[0] for factor in self.zoom_factors]
819 self.zoom_mode = qtutils.combo(zoom_modes, parent=self)
821 self.menu = menu = qtutils.create_menu(N_('Diff Options'), self.options)
822 self.options.setMenu(menu)
823 menu.addAction(self.ignore_space_at_eol)
824 menu.addAction(self.ignore_space_change)
825 menu.addAction(self.ignore_all_space)
826 menu.addSeparator()
827 menu.addAction(self.function_context)
828 menu.addAction(self.show_line_numbers)
829 menu.addAction(self.show_filenames)
830 menu.addSeparator()
831 menu.addAction(self.enable_word_wrapping)
833 # Layouts
834 layout = qtutils.hbox(
835 defs.no_margin,
836 defs.button_spacing,
837 self.image_mode,
838 self.zoom_mode,
839 self.options,
840 self.toggle_image_diff,
842 self.setLayout(layout)
844 # Policies
845 self.image_mode.setFocusPolicy(Qt.NoFocus)
846 self.zoom_mode.setFocusPolicy(Qt.NoFocus)
847 self.options.setFocusPolicy(Qt.NoFocus)
848 self.toggle_image_diff.setFocusPolicy(Qt.NoFocus)
849 self.setFocusPolicy(Qt.NoFocus)
851 def set_file_type(self, file_type):
852 """Set whether we are viewing an image file type"""
853 is_image = file_type == main.Types.IMAGE
854 self.toggle_image_diff.setVisible(is_image)
856 def set_diff_type(self, diff_type):
857 """Toggle between image and text diffs"""
858 is_text = diff_type == main.Types.TEXT
859 is_image = diff_type == main.Types.IMAGE
860 self.options.setVisible(is_text)
861 self.image_mode.setVisible(is_image)
862 self.zoom_mode.setVisible(is_image)
863 if is_image:
864 self.toggle_image_diff.setIcon(icons.diff())
865 else:
866 self.toggle_image_diff.setIcon(icons.visualize())
868 def add_option(self, title):
869 """Add a diff option which calls update_options() on change"""
870 action = qtutils.add_action(self, title, self.update_options)
871 action.setCheckable(True)
872 return action
874 def update_options(self):
875 """Update diff options in response to UI events"""
876 space_at_eol = get(self.ignore_space_at_eol)
877 space_change = get(self.ignore_space_change)
878 all_space = get(self.ignore_all_space)
879 function_context = get(self.function_context)
880 gitcmds.update_diff_overrides(
881 space_at_eol, space_change, all_space, function_context
883 self.widget.update_options()
885 def set_line_numbers(self, value):
886 """Enable / disable line numbers"""
887 self.widget.set_line_numbers(value, update=False)
889 def set_word_wrapping(self, value):
890 """Respond to Qt action callbacks"""
891 self.widget.set_word_wrapping(value, update=False)
893 def hide_advanced_options(self):
894 """Hide advanced options that are not applicable to the DiffWidget"""
895 self.show_filenames.setVisible(False)
896 self.show_line_numbers.setVisible(False)
897 self.ignore_space_at_eol.setVisible(False)
898 self.ignore_space_change.setVisible(False)
899 self.ignore_all_space.setVisible(False)
900 self.function_context.setVisible(False)
903 class DiffEditor(DiffTextEdit):
904 up = Signal()
905 down = Signal()
906 options_changed = Signal()
908 def __init__(self, context, options, parent):
909 DiffTextEdit.__init__(self, context, parent, numbers=True)
910 self.context = context
911 self.model = model = context.model
912 self.selection_model = selection_model = context.selection
914 # "Diff Options" tool menu
915 self.options = options
917 self.action_apply_selection = qtutils.add_action(
918 self,
919 'Apply',
920 self.apply_selection,
921 hotkeys.STAGE_DIFF,
922 hotkeys.STAGE_DIFF_ALT,
925 self.action_revert_selection = qtutils.add_action(
926 self, 'Revert', self.revert_selection, hotkeys.REVERT, hotkeys.REVERT_ALT
928 self.action_revert_selection.setIcon(icons.undo())
930 self.action_edit_and_apply_selection = qtutils.add_action(
931 self,
932 'Edit and Apply',
933 partial(self.apply_selection, edit=True),
934 hotkeys.EDIT_AND_STAGE_DIFF,
937 self.action_edit_and_revert_selection = qtutils.add_action(
938 self,
939 'Edit and Revert',
940 partial(self.revert_selection, edit=True),
941 hotkeys.EDIT_AND_REVERT,
943 self.action_edit_and_revert_selection.setIcon(icons.undo())
944 self.launch_editor = actions.launch_editor_at_line(
945 context, self, hotkeys.EDIT_SHORT, *hotkeys.ACCEPT
947 self.launch_difftool = actions.launch_difftool(context, self)
948 self.stage_or_unstage = actions.stage_or_unstage(context, self)
950 # Emit up/down signals so that they can be routed by the main widget
951 self.move_up = actions.move_up(self)
952 self.move_down = actions.move_down(self)
954 model.diff_text_updated.connect(self.set_diff, type=Qt.QueuedConnection)
956 selection_model.selection_changed.connect(
957 self.refresh, type=Qt.QueuedConnection
959 # Update the selection model when the cursor changes
960 self.cursorPositionChanged.connect(self._update_line_number)
962 qtutils.connect_button(options.toggle_image_diff, self.toggle_diff_type)
964 def toggle_diff_type(self):
965 cmds.do(cmds.ToggleDiffType, self.context)
967 def refresh(self):
968 enabled = False
969 s = self.selection_model.selection()
970 model = self.model
971 if model.is_partially_stageable():
972 item = s.modified[0] if s.modified else None
973 if item in model.submodules:
974 pass
975 elif item not in model.unstaged_deleted:
976 enabled = True
977 self.action_revert_selection.setEnabled(enabled)
979 def set_line_numbers(self, enabled, update=False):
980 """Enable/disable the diff line number display"""
981 self.numbers.setVisible(enabled)
982 if update:
983 with qtutils.BlockSignals(self.options.show_line_numbers):
984 self.options.show_line_numbers.setChecked(enabled)
985 # Refresh the display. Not doing this results in the display not
986 # correctly displaying the line numbers widget until the text scrolls.
987 self.set_value(self.value())
989 def update_options(self):
990 self.options_changed.emit()
992 def create_context_menu(self, event_pos):
993 """Override create_context_menu() to display a completely custom menu"""
994 menu = super().create_context_menu(event_pos)
995 context = self.context
996 model = self.model
997 s = self.selection_model.selection()
998 filename = self.selection_model.filename()
1000 # These menu actions will be inserted at the start of the widget.
1001 current_actions = menu.actions()
1002 menu_actions = []
1003 add_action = menu_actions.append
1004 edit_actions_added = False
1005 stage_action_added = False
1007 if s.staged and model.is_unstageable():
1008 item = s.staged[0]
1009 if item not in model.submodules and item not in model.staged_deleted:
1010 if self.has_selection():
1011 apply_text = N_('Unstage Selected Lines')
1012 else:
1013 apply_text = N_('Unstage Diff Hunk')
1014 self.action_apply_selection.setText(apply_text)
1015 self.action_apply_selection.setIcon(icons.remove())
1016 add_action(self.action_apply_selection)
1017 stage_action_added = self._add_stage_or_unstage_action(
1018 menu, add_action, stage_action_added
1021 if model.is_partially_stageable():
1022 item = s.modified[0] if s.modified else None
1023 if item in model.submodules:
1024 path = core.abspath(item)
1025 action = qtutils.add_action_with_icon(
1026 menu,
1027 icons.add(),
1028 cmds.Stage.name(),
1029 cmds.run(cmds.Stage, context, s.modified),
1030 hotkeys.STAGE_SELECTION,
1032 add_action(action)
1033 stage_action_added = self._add_stage_or_unstage_action(
1034 menu, add_action, stage_action_added
1037 action = qtutils.add_action_with_icon(
1038 menu,
1039 icons.cola(),
1040 N_('Launch git-cola'),
1041 cmds.run(cmds.OpenRepo, context, path),
1043 add_action(action)
1044 elif item and item not in model.unstaged_deleted:
1045 if self.has_selection():
1046 apply_text = N_('Stage Selected Lines')
1047 edit_and_apply_text = N_('Edit Selected Lines to Stage...')
1048 revert_text = N_('Revert Selected Lines...')
1049 edit_and_revert_text = N_('Edit Selected Lines to Revert...')
1050 else:
1051 apply_text = N_('Stage Diff Hunk')
1052 edit_and_apply_text = N_('Edit Diff Hunk to Stage...')
1053 revert_text = N_('Revert Diff Hunk...')
1054 edit_and_revert_text = N_('Edit Diff Hunk to Revert...')
1056 self.action_apply_selection.setText(apply_text)
1057 self.action_apply_selection.setIcon(icons.add())
1058 add_action(self.action_apply_selection)
1060 self.action_revert_selection.setText(revert_text)
1061 add_action(self.action_revert_selection)
1063 stage_action_added = self._add_stage_or_unstage_action(
1064 menu, add_action, stage_action_added
1066 # Do not show the "edit" action when the file does not exist.
1067 add_action(qtutils.menu_separator(menu))
1068 if filename and core.exists(filename):
1069 add_action(self.launch_editor)
1070 # Removed files can still be diffed.
1071 add_action(self.launch_difftool)
1072 edit_actions_added = True
1074 add_action(qtutils.menu_separator(menu))
1075 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1076 self.action_edit_and_apply_selection.setIcon(icons.add())
1077 add_action(self.action_edit_and_apply_selection)
1079 self.action_edit_and_revert_selection.setText(edit_and_revert_text)
1080 add_action(self.action_edit_and_revert_selection)
1082 if s.staged and model.is_unstageable():
1083 item = s.staged[0]
1084 if item in model.submodules:
1085 path = core.abspath(item)
1086 action = qtutils.add_action_with_icon(
1087 menu,
1088 icons.remove(),
1089 cmds.Unstage.name(),
1090 cmds.run(cmds.Unstage, context, s.staged),
1091 hotkeys.STAGE_SELECTION,
1093 add_action(action)
1095 stage_action_added = self._add_stage_or_unstage_action(
1096 menu, add_action, stage_action_added
1099 qtutils.add_action_with_icon(
1100 menu,
1101 icons.cola(),
1102 N_('Launch git-cola'),
1103 cmds.run(cmds.OpenRepo, context, path),
1105 add_action(action)
1107 elif item not in model.staged_deleted:
1108 # Do not show the "edit" action when the file does not exist.
1109 add_action(qtutils.menu_separator(menu))
1110 if filename and core.exists(filename):
1111 add_action(self.launch_editor)
1112 # Removed files can still be diffed.
1113 add_action(self.launch_difftool)
1114 add_action(qtutils.menu_separator(menu))
1115 edit_actions_added = True
1117 if self.has_selection():
1118 edit_and_apply_text = N_('Edit Selected Lines to Unstage...')
1119 else:
1120 edit_and_apply_text = N_('Edit Diff Hunk to Unstage...')
1121 self.action_edit_and_apply_selection.setText(edit_and_apply_text)
1122 self.action_edit_and_apply_selection.setIcon(icons.remove())
1123 add_action(self.action_edit_and_apply_selection)
1125 if not edit_actions_added and (model.is_stageable() or model.is_unstageable()):
1126 add_action(qtutils.menu_separator(menu))
1127 # Do not show the "edit" action when the file does not exist.
1128 # Untracked files exist by definition.
1129 if filename and core.exists(filename):
1130 add_action(self.launch_editor)
1132 # Removed files can still be diffed.
1133 add_action(self.launch_difftool)
1135 add_action(qtutils.menu_separator(menu))
1136 _add_patch_actions(self, self.context, menu)
1138 # Add the Previous/Next File actions, which improves discoverability
1139 # of their associated shortcuts
1140 add_action(qtutils.menu_separator(menu))
1141 add_action(self.move_up)
1142 add_action(self.move_down)
1143 add_action(qtutils.menu_separator(menu))
1145 if current_actions:
1146 first_action = current_actions[0]
1147 else:
1148 first_action = None
1149 menu.insertActions(first_action, menu_actions)
1151 return menu
1153 def _add_stage_or_unstage_action(self, menu, add_action, already_added):
1154 """Add the Stage / Unstage menu action"""
1155 if already_added:
1156 return True
1157 model = self.context.model
1158 s = self.selection_model.selection()
1159 if model.is_stageable() or model.is_unstageable():
1160 if (model.is_amend_mode() and s.staged) or not self.model.is_stageable():
1161 self.stage_or_unstage.setText(N_('Unstage'))
1162 self.stage_or_unstage.setIcon(icons.remove())
1163 else:
1164 self.stage_or_unstage.setText(N_('Stage'))
1165 self.stage_or_unstage.setIcon(icons.add())
1166 add_action(qtutils.menu_separator(menu))
1167 add_action(self.stage_or_unstage)
1168 return True
1170 def mousePressEvent(self, event):
1171 if event.button() == Qt.RightButton:
1172 # Intercept right-click to move the cursor to the current position.
1173 # setTextCursor() clears the selection so this is only done when
1174 # nothing is selected.
1175 if not self.has_selection():
1176 cursor = self.cursorForPosition(event.pos())
1177 self.setTextCursor(cursor)
1179 return super().mousePressEvent(event)
1181 def setPlainText(self, text):
1182 """setPlainText(str) while retaining scrollbar positions"""
1183 model = self.model
1184 mode = model.mode
1185 highlight = mode not in (
1186 model.mode_none,
1187 model.mode_display,
1188 model.mode_untracked,
1190 self.highlighter.set_enabled(highlight)
1192 scrollbar = self.verticalScrollBar()
1193 if scrollbar:
1194 scrollvalue = get(scrollbar)
1195 else:
1196 scrollvalue = None
1198 if text is None:
1199 return
1201 DiffTextEdit.setPlainText(self, text)
1203 if scrollbar and scrollvalue is not None:
1204 scrollbar.setValue(scrollvalue)
1206 def apply_selection(self, *, edit=False):
1207 model = self.model
1208 s = self.selection_model.single_selection()
1209 if model.is_partially_stageable() and (s.modified or s.untracked):
1210 self.process_diff_selection(edit=edit)
1211 elif model.is_unstageable():
1212 self.process_diff_selection(reverse=True, edit=edit)
1214 def revert_selection(self, *, edit=False):
1215 """Destructively revert selected lines or hunk from a worktree file."""
1217 if not edit:
1218 if self.has_selection():
1219 title = N_('Revert Selected Lines?')
1220 ok_text = N_('Revert Selected Lines')
1221 else:
1222 title = N_('Revert Diff Hunk?')
1223 ok_text = N_('Revert Diff Hunk')
1225 if not Interaction.confirm(
1226 title,
1228 'This operation drops uncommitted changes.\n'
1229 'These changes cannot be recovered.'
1231 N_('Revert the uncommitted changes?'),
1232 ok_text,
1233 default=True,
1234 icon=icons.undo(),
1236 return
1237 self.process_diff_selection(reverse=True, apply_to_worktree=True, edit=edit)
1239 def extract_patch(self, reverse=False):
1240 first_line_idx, last_line_idx = self.selected_lines()
1241 patch = diffparse.Patch.parse(self.model.filename, self.model.diff_text)
1242 if self.has_selection():
1243 return patch.extract_subset(first_line_idx, last_line_idx, reverse=reverse)
1244 return patch.extract_hunk(first_line_idx, reverse=reverse)
1246 def patch_encoding(self):
1247 if isinstance(self.model.diff_text, core.UStr):
1248 # original encoding must prevail
1249 return self.model.diff_text.encoding
1250 return self.context.cfg.file_encoding(self.model.filename)
1252 def process_diff_selection(
1253 self, reverse=False, apply_to_worktree=False, edit=False
1255 """Implement un/staging of the selected line(s) or hunk."""
1256 if self.selection_model.is_empty():
1257 return
1258 patch = self.extract_patch(reverse)
1259 if not patch.has_changes():
1260 return
1261 patch_encoding = self.patch_encoding()
1263 if edit:
1264 patch = edit_patch(
1265 patch,
1266 patch_encoding,
1267 self.context,
1268 reverse=reverse,
1269 apply_to_worktree=apply_to_worktree,
1271 if not patch.has_changes():
1272 return
1274 cmds.do(
1275 cmds.ApplyPatch,
1276 self.context,
1277 patch,
1278 patch_encoding,
1279 apply_to_worktree,
1282 def _update_line_number(self):
1283 """Update the selection model when the cursor changes"""
1284 self.selection_model.line_number = self.numbers.current_line()
1287 def _add_patch_actions(widget, context, menu):
1288 """Add actions for manipulating patch files"""
1289 patches_menu = menu.addMenu(N_('Patches'))
1290 patches_menu.setIcon(icons.diff())
1291 export_action = qtutils.add_action(
1292 patches_menu,
1293 N_('Export Patch'),
1294 lambda: _export_patch(widget, context),
1296 export_action.setIcon(icons.save())
1297 patches_menu.addAction(export_action)
1299 # Build the "Append Patch" menu dynamically.
1300 append_menu = patches_menu.addMenu(N_('Append Patch'))
1301 append_menu.setIcon(icons.add())
1302 append_menu.aboutToShow.connect(
1303 lambda: _build_patch_append_menu(widget, context, append_menu)
1307 def _build_patch_append_menu(widget, context, menu):
1308 """Build the "Append Patch" sub-menu"""
1309 # Build the menu when first displayed only. This initial check avoids
1310 # re-populating the menu with duplicate actions.
1311 menu_actions = menu.actions()
1312 if menu_actions:
1313 return
1315 choose_patch_action = qtutils.add_action(
1316 menu,
1317 N_('Choose Patch...'),
1318 lambda: _export_patch(widget, context, append=True),
1320 choose_patch_action.setIcon(icons.diff())
1321 menu.addAction(choose_patch_action)
1323 subdir_menus = {}
1324 path = prefs.patches_directory(context)
1325 patches = get_patches_from_dir(path)
1326 for patch in patches:
1327 relpath = os.path.relpath(patch, start=path)
1328 sub_menu = _add_patch_subdirs(menu, subdir_menus, relpath)
1329 patch_basename = os.path.basename(relpath)
1330 append_action = qtutils.add_action(
1331 sub_menu,
1332 patch_basename,
1333 lambda patch_file=patch: _append_patch(widget, patch_file),
1335 append_action.setIcon(icons.save())
1336 sub_menu.addAction(append_action)
1339 def _add_patch_subdirs(menu, subdir_menus, relpath):
1340 """Build menu leading up to the patch"""
1341 # If the path contains no directory separators then add it to the
1342 # root of the menu.
1343 if os.sep not in relpath:
1344 return menu
1346 # Loop over each directory component and build a menu if it doesn't already exist.
1347 components = []
1348 for dirname in os.path.dirname(relpath).split(os.sep):
1349 components.append(dirname)
1350 current_dir = os.sep.join(components)
1351 try:
1352 menu = subdir_menus[current_dir]
1353 except KeyError:
1354 menu = subdir_menus[current_dir] = menu.addMenu(dirname)
1355 menu.setIcon(icons.folder())
1357 return menu
1360 def _export_patch(diff_editor, context, append=False):
1361 """Export the selected diff to a patch file"""
1362 if diff_editor.selection_model.is_empty():
1363 return
1364 patch = diff_editor.extract_patch(reverse=False)
1365 if not patch.has_changes():
1366 return
1367 directory = prefs.patches_directory(context)
1368 if append:
1369 filename = qtutils.existing_file(directory, title=N_('Append Patch...'))
1370 else:
1371 default_filename = os.path.join(directory, 'diff.patch')
1372 filename = qtutils.save_as(default_filename)
1373 if not filename:
1374 return
1375 _write_patch_to_file(diff_editor, patch, filename, append=append)
1378 def _append_patch(diff_editor, filename):
1379 """Append diffs to the specified patch file"""
1380 if diff_editor.selection_model.is_empty():
1381 return
1382 patch = diff_editor.extract_patch(reverse=False)
1383 if not patch.has_changes():
1384 return
1385 _write_patch_to_file(diff_editor, patch, filename, append=True)
1388 def _write_patch_to_file(diff_editor, patch, filename, append=False):
1389 """Write diffs from the Diff Editor to the specified patch file"""
1390 encoding = diff_editor.patch_encoding()
1391 content = patch.as_text()
1392 try:
1393 core.write(filename, content, encoding=encoding, append=append)
1394 except OSError as exc:
1395 _, details = utils.format_exception(exc)
1396 title = N_('Error writing patch')
1397 msg = N_('Unable to write patch to "%s". Check permissions?' % filename)
1398 Interaction.critical(title, message=msg, details=details)
1399 return
1400 Interaction.log('Patch written to "%s"' % filename)
1403 class ObjectIdLabel(PlainTextLabel):
1404 """Interactive object IDs"""
1406 def __init__(self, context, oid='', parent=None):
1407 super().__init__(parent=parent)
1408 self.context = context
1409 self.oid = oid
1410 self.setCursor(Qt.PointingHandCursor)
1411 self.setContextMenuPolicy(Qt.CustomContextMenu)
1412 self.setFocusPolicy(Qt.NoFocus)
1413 self.setToolTip(N_('Click to Copy'))
1414 self.customContextMenuRequested.connect(self._context_menu)
1415 self._copy_short_action = qtutils.add_action_with_icon(
1416 self,
1417 icons.copy(),
1418 N_('Copy Commit (Short)'),
1419 self._copy_short,
1420 hotkeys.COPY,
1422 self._copy_long_action = qtutils.add_action_with_icon(
1423 self,
1424 icons.copy(),
1425 N_('Copy Commit'),
1426 self._copy_long,
1427 hotkeys.COPY_COMMIT_ID,
1429 self._select_all_action = qtutils.add_action(
1430 self, N_('Select All'), self._select_all, hotkeys.SELECT_ALL
1432 self.timer = QtCore.QTimer(self)
1433 self.timer.setInterval(200)
1434 self.timer.setSingleShot(True)
1435 self.timer.timeout.connect(self._timeout)
1437 def _timeout(self):
1438 """Clear the selection"""
1439 self.setSelection(0, 0)
1441 def set_oid(self, oid):
1442 """Record the object ID and update the display"""
1443 self.oid = oid
1444 self.set_text(oid)
1446 def _copy_short(self, clicked=False):
1447 """Copy the abbreviated commit ID"""
1448 abbrev = prefs.abbrev(self.context)
1449 qtutils.set_clipboard(self.oid[:abbrev])
1450 self._select_all()
1451 if not self.timer.isActive():
1452 self.timer.start()
1454 def _copy_long(self):
1455 """Copy the full commit ID"""
1456 qtutils.set_clipboard(self.oid)
1457 self._select_all()
1458 if not self.timer.isActive():
1459 self.timer.start()
1461 def _select_all(self):
1462 """Select the text"""
1463 length = len(self.get())
1464 self.setSelection(0, length)
1466 def mousePressEvent(self, event):
1467 """Copy the commit ID when clicked"""
1468 if event.button() == Qt.LeftButton:
1469 # This behavior makes it impossible to select text by clicking and dragging,
1470 # but it's okay because this also makes copying text a single-click affair.
1471 self._copy_short(clicked=True)
1472 return super().mousePressEvent(event)
1474 def _context_menu(self, pos):
1475 """Display a custom context menu"""
1476 menu = QtWidgets.QMenu(self)
1477 menu.addAction(self._copy_short_action)
1478 menu.addAction(self._copy_long_action)
1479 menu.addAction(self._select_all_action)
1480 menu.exec_(self.mapToGlobal(pos))
1483 class DiffWidget(QtWidgets.QWidget):
1484 """Display commit metadata and text diffs"""
1486 def __init__(self, context, parent, is_commit=False, options=None):
1487 QtWidgets.QWidget.__init__(self, parent)
1489 self.context = context
1490 self.oid = 'HEAD'
1491 self.oid_start = None
1492 self.oid_end = None
1493 self.options = options
1495 author_font = QtGui.QFont(self.font())
1496 author_font.setPointSize(int(author_font.pointSize() * 1.1))
1498 summary_font = QtGui.QFont(author_font)
1499 summary_font.setWeight(QtGui.QFont.Bold)
1501 self.gravatar_label = gravatar.GravatarLabel(self.context, parent=self)
1503 self.oid_label = ObjectIdLabel(context, parent=self)
1504 self.oid_label.setAlignment(Qt.AlignBottom)
1505 self.oid_label.elide()
1507 self.author_label = RichTextLabel(selectable=False, parent=self)
1508 self.author_label.setFont(author_font)
1509 self.author_label.setAlignment(Qt.AlignTop)
1510 self.author_label.elide()
1512 self.date_label = PlainTextLabel(parent=self)
1513 self.date_label.setAlignment(Qt.AlignTop)
1514 self.date_label.elide()
1516 self.summary_label = PlainTextLabel(parent=self)
1517 self.summary_label.setFont(summary_font)
1518 self.summary_label.setAlignment(Qt.AlignTop)
1519 self.summary_label.elide()
1521 self.diff = DiffTextEdit(context, self, is_commit=is_commit, whitespace=False)
1522 self.setFocusProxy(self.diff)
1524 self.info_layout = qtutils.vbox(
1525 defs.no_margin,
1526 defs.no_spacing,
1527 self.oid_label,
1528 self.author_label,
1529 self.date_label,
1530 self.summary_label,
1533 self.logo_layout = qtutils.hbox(
1534 defs.no_margin, defs.button_spacing, self.gravatar_label, self.info_layout
1536 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
1538 self.main_layout = qtutils.vbox(
1539 defs.no_margin, defs.spacing, self.logo_layout, self.diff
1541 self.setLayout(self.main_layout)
1543 self.set_tabwidth(prefs.tabwidth(context))
1545 def set_tabwidth(self, width):
1546 self.diff.set_tabwidth(width)
1548 def set_word_wrapping(self, enabled, update=False):
1549 """Enable and disable word wrapping"""
1550 self.diff.set_word_wrapping(enabled, update=update)
1552 def set_options(self, options):
1553 """Register an options widget"""
1554 self.options = options
1555 self.diff.set_options(options)
1557 def start_diff_task(self, task):
1558 """Clear the display and start a diff-gathering task"""
1559 self.diff.save_scrollbar()
1560 self.diff.set_loading_message()
1561 self.context.runtask.start(task, result=self.set_diff)
1563 def set_diff_oid(self, oid, filename=None):
1564 """Set the diff from a single commit object ID"""
1565 task = DiffInfoTask(self.context, oid, filename)
1566 self.start_diff_task(task)
1568 def set_diff_range(self, start, end, filename=None):
1569 task = DiffRangeTask(self.context, start + '~', end, filename)
1570 self.start_diff_task(task)
1572 def commits_selected(self, commits):
1573 """Display an appropriate diff when commits are selected"""
1574 if not commits:
1575 self.clear()
1576 return
1577 commit = commits[-1]
1578 oid = commit.oid
1579 author = commit.author or ''
1580 email = commit.email or ''
1581 date = commit.authdate or ''
1582 summary = commit.summary or ''
1583 self.set_details(oid, author, email, date, summary)
1584 self.oid = oid
1586 if len(commits) > 1:
1587 start, end = commits[0], commits[-1]
1588 self.set_diff_range(start.oid, end.oid)
1589 self.oid_start = start
1590 self.oid_end = end
1591 else:
1592 self.set_diff_oid(oid)
1593 self.oid_start = None
1594 self.oid_end = None
1596 def set_diff(self, diff):
1597 """Set the diff text"""
1598 self.diff.set_diff(diff)
1600 def set_details(self, oid, author, email, date, summary):
1601 template_args = {'author': author, 'email': email, 'summary': summary}
1602 author_text = (
1603 """%(author)s &lt;"""
1604 """<a href="mailto:%(email)s">"""
1605 """%(email)s</a>&gt;""" % template_args
1607 author_template = '%(author)s <%(email)s>' % template_args
1609 self.date_label.set_text(date)
1610 self.date_label.setVisible(bool(date))
1611 self.oid_label.set_oid(oid)
1612 self.author_label.set_template(author_text, author_template)
1613 self.summary_label.set_text(summary)
1614 self.gravatar_label.set_email(email)
1616 def clear(self):
1617 self.date_label.set_text('')
1618 self.oid_label.set_oid('')
1619 self.author_label.set_text('')
1620 self.summary_label.set_text('')
1621 self.gravatar_label.clear()
1622 self.diff.clear()
1624 def files_selected(self, filenames):
1625 """Update the view when a filename is selected"""
1626 oid_start = self.oid_start
1627 oid_end = self.oid_end
1628 extra_args = {}
1629 if filenames:
1630 extra_args['filename'] = filenames[0]
1631 if oid_start and oid_end:
1632 self.set_diff_range(oid_start.oid, oid_end.oid, **extra_args)
1633 else:
1634 self.set_diff_oid(self.oid, **extra_args)
1637 class DiffPanel(QtWidgets.QWidget):
1638 """A combined diff + search panel"""
1640 def __init__(self, diff_widget, text_widget, parent):
1641 super().__init__(parent)
1642 self.diff_widget = diff_widget
1643 self.search_widget = TextSearchWidget(text_widget, self)
1644 self.search_widget.hide()
1645 layout = qtutils.vbox(
1646 defs.no_margin, defs.spacing, self.diff_widget, self.search_widget
1648 self.setLayout(layout)
1649 self.setFocusProxy(self.diff_widget)
1651 self.search_action = qtutils.add_action(
1652 self,
1653 N_('Search in Diff'),
1654 self.show_search,
1655 hotkeys.SEARCH,
1658 def show_search(self):
1659 """Show a dialog for searching diffs"""
1660 # The diff search is only active in text mode.
1661 if not self.search_widget.isVisible():
1662 self.search_widget.show()
1663 self.search_widget.setFocus()
1666 class DiffInfoTask(qtutils.Task):
1667 """Gather diffs for a single commit"""
1669 def __init__(self, context, oid, filename):
1670 qtutils.Task.__init__(self)
1671 self.context = context
1672 self.oid = oid
1673 self.filename = filename
1675 def task(self):
1676 context = self.context
1677 oid = self.oid
1678 return gitcmds.diff_info(context, oid, filename=self.filename)
1681 class DiffRangeTask(qtutils.Task):
1682 """Gather diffs for a range of commits"""
1684 def __init__(self, context, start, end, filename):
1685 qtutils.Task.__init__(self)
1686 self.context = context
1687 self.start = start
1688 self.end = end
1689 self.filename = filename
1691 def task(self):
1692 context = self.context
1693 return gitcmds.diff_range(context, self.start, self.end, filename=self.filename)
1696 def apply_patches(context, patches=None):
1697 """Open the ApplyPatches dialog"""
1698 parent = qtutils.active_window()
1699 dlg = new_apply_patches(context, patches=patches, parent=parent)
1700 dlg.show()
1701 dlg.raise_()
1702 return dlg
1705 def new_apply_patches(context, patches=None, parent=None):
1706 """Create a new instances of the ApplyPatches dialog"""
1707 dlg = ApplyPatches(context, parent=parent)
1708 if patches:
1709 dlg.add_paths(patches)
1710 return dlg
1713 def get_patches_from_paths(paths):
1714 """Returns all patches beneath a given path"""
1715 paths = [core.decode(p) for p in paths]
1716 patches = [p for p in paths if core.isfile(p) and p.endswith(('.patch', '.mbox'))]
1717 dirs = [p for p in paths if core.isdir(p)]
1718 dirs.sort()
1719 for d in dirs:
1720 patches.extend(get_patches_from_dir(d))
1721 return patches
1724 def get_patches_from_mimedata(mimedata):
1725 """Extract path files from a QMimeData payload"""
1726 urls = mimedata.urls()
1727 if not urls:
1728 return []
1729 paths = [x.path() for x in urls]
1730 return get_patches_from_paths(paths)
1733 def get_patches_from_dir(path):
1734 """Find patches in a subdirectory"""
1735 patches = []
1736 for root, _, files in core.walk(path):
1737 for name in [f for f in files if f.endswith(('.patch', '.mbox'))]:
1738 patches.append(core.decode(os.path.join(root, name)))
1739 return patches
1742 class ApplyPatches(standard.Dialog):
1743 def __init__(self, context, parent=None):
1744 super().__init__(parent=parent)
1745 self.context = context
1746 self.setWindowTitle(N_('Apply Patches'))
1747 self.setAcceptDrops(True)
1748 if parent is not None:
1749 self.setWindowModality(Qt.WindowModal)
1751 self.curdir = core.getcwd()
1752 self.inner_drag = False
1754 self.usage = QtWidgets.QLabel()
1755 self.usage.setText(
1759 Drag and drop or use the <strong>Add</strong> button to add
1760 patches to the list
1761 </p>
1766 self.tree = PatchTreeWidget(parent=self)
1767 self.tree.setHeaderHidden(True)
1768 self.tree.itemSelectionChanged.connect(self._tree_selection_changed)
1770 self.diffwidget = DiffWidget(context, self, is_commit=True)
1772 self.add_button = qtutils.create_toolbutton(
1773 text=N_('Add'), icon=icons.add(), tooltip=N_('Add patches (+)')
1776 self.remove_button = qtutils.create_toolbutton(
1777 text=N_('Remove'),
1778 icon=icons.remove(),
1779 tooltip=N_('Remove selected (Delete)'),
1782 self.apply_button = qtutils.create_button(text=N_('Apply'), icon=icons.ok())
1784 self.close_button = qtutils.close_button()
1786 self.add_action = qtutils.add_action(
1787 self, N_('Add'), self.add_files, hotkeys.ADD_ITEM
1790 self.remove_action = qtutils.add_action(
1791 self,
1792 N_('Remove'),
1793 self.tree.remove_selected,
1794 hotkeys.DELETE,
1795 hotkeys.BACKSPACE,
1796 hotkeys.REMOVE_ITEM,
1799 self.top_layout = qtutils.hbox(
1800 defs.no_margin,
1801 defs.button_spacing,
1802 self.add_button,
1803 self.remove_button,
1804 qtutils.STRETCH,
1805 self.usage,
1808 self.bottom_layout = qtutils.hbox(
1809 defs.no_margin,
1810 defs.button_spacing,
1811 qtutils.STRETCH,
1812 self.close_button,
1813 self.apply_button,
1816 self.splitter = qtutils.splitter(Qt.Vertical, self.tree, self.diffwidget)
1818 self.main_layout = qtutils.vbox(
1819 defs.margin,
1820 defs.spacing,
1821 self.top_layout,
1822 self.splitter,
1823 self.bottom_layout,
1825 self.setLayout(self.main_layout)
1827 qtutils.connect_button(self.add_button, self.add_files)
1828 qtutils.connect_button(self.remove_button, self.tree.remove_selected)
1829 qtutils.connect_button(self.apply_button, self.apply_patches)
1830 qtutils.connect_button(self.close_button, self.close)
1832 self.init_state(None, self.resize, 720, 480)
1834 def apply_patches(self):
1835 items = self.tree.items()
1836 if not items:
1837 return
1838 context = self.context
1839 patches = [i.data(0, Qt.UserRole) for i in items]
1840 cmds.do(cmds.ApplyPatches, context, patches)
1841 self.accept()
1843 def add_files(self):
1844 files = qtutils.open_files(
1845 N_('Select patch file(s)...'),
1846 directory=self.curdir,
1847 filters='Patches (*.patch *.mbox)',
1849 if not files:
1850 return
1851 self.curdir = os.path.dirname(files[0])
1852 self.add_paths([core.relpath(f) for f in files])
1854 def dragEnterEvent(self, event):
1855 """Accepts drops if the mimedata contains patches"""
1856 super().dragEnterEvent(event)
1857 patches = get_patches_from_mimedata(event.mimeData())
1858 if patches:
1859 event.acceptProposedAction()
1861 def dropEvent(self, event):
1862 """Add dropped patches"""
1863 event.accept()
1864 patches = get_patches_from_mimedata(event.mimeData())
1865 if not patches:
1866 return
1867 self.add_paths(patches)
1869 def add_paths(self, paths):
1870 self.tree.add_paths(paths)
1872 def _tree_selection_changed(self):
1873 items = self.tree.selected_items()
1874 if not items:
1875 return
1876 item = items[-1] # take the last item
1877 path = item.data(0, Qt.UserRole)
1878 if not core.exists(path):
1879 return
1880 commit = parse_patch(path)
1881 self.diffwidget.set_details(
1882 commit.oid, commit.author, commit.email, commit.date, commit.summary
1884 self.diffwidget.set_diff(commit.diff)
1886 def export_state(self):
1887 """Export persistent settings"""
1888 state = super().export_state()
1889 state['sizes'] = get(self.splitter)
1890 return state
1892 def apply_state(self, state):
1893 """Apply persistent settings"""
1894 result = super().apply_state(state)
1895 try:
1896 self.splitter.setSizes(state['sizes'])
1897 except (AttributeError, KeyError, ValueError, TypeError):
1898 pass
1899 return result
1902 class PatchTreeWidget(standard.DraggableTreeWidget):
1903 def add_paths(self, paths):
1904 patches = get_patches_from_paths(paths)
1905 if not patches:
1906 return
1907 items = []
1908 icon = icons.file_text()
1909 for patch in patches:
1910 item = QtWidgets.QTreeWidgetItem()
1911 flags = item.flags() & ~Qt.ItemIsDropEnabled
1912 item.setFlags(flags)
1913 item.setIcon(0, icon)
1914 item.setText(0, os.path.basename(patch))
1915 item.setData(0, Qt.UserRole, patch)
1916 item.setToolTip(0, patch)
1917 items.append(item)
1918 self.addTopLevelItems(items)
1920 def remove_selected(self):
1921 idxs = self.selectedIndexes()
1922 rows = [idx.row() for idx in idxs]
1923 for row in reversed(sorted(rows)):
1924 self.invisibleRootItem().takeChild(row)
1927 class Commit:
1928 """Container for commit details"""
1930 def __init__(self):
1931 self.content = ''
1932 self.author = ''
1933 self.email = ''
1934 self.oid = ''
1935 self.summary = ''
1936 self.diff = ''
1937 self.date = ''
1940 def parse_patch(path):
1941 content = core.read(path)
1942 commit = Commit()
1943 parse(content, commit)
1944 return commit
1947 def parse(content, commit):
1948 """Parse commit details from a patch"""
1949 from_rgx = re.compile(r'^From (?P<oid>[a-f0-9]{40}) .*$')
1950 author_rgx = re.compile(r'^From: (?P<author>[^<]+) <(?P<email>[^>]+)>$')
1951 date_rgx = re.compile(r'^Date: (?P<date>.*)$')
1952 subject_rgx = re.compile(r'^Subject: (?P<summary>.*)$')
1954 commit.content = content
1956 lines = content.splitlines()
1957 for idx, line in enumerate(lines):
1958 match = from_rgx.match(line)
1959 if match:
1960 commit.oid = match.group('oid')
1961 continue
1963 match = author_rgx.match(line)
1964 if match:
1965 commit.author = match.group('author')
1966 commit.email = match.group('email')
1967 continue
1969 match = date_rgx.match(line)
1970 if match:
1971 commit.date = match.group('date')
1972 continue
1974 match = subject_rgx.match(line)
1975 if match:
1976 commit.summary = match.group('summary')
1977 commit.diff = '\n'.join(lines[idx + 1 :])
1978 break