1 from functools
import partial
5 from qtpy
import QtCore
7 from qtpy
import QtWidgets
8 from qtpy
.QtCore
import Qt
9 from qtpy
.QtCore
import Signal
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
20 from .. import diffparse
21 from .. import gitcmds
22 from .. import gravatar
23 from .. import hotkeys
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
33 from . import standard
34 from . import imageview
37 class DiffSyntaxHighlighter(QtGui
.QSyntaxHighlighter
):
38 """Implements the diff syntax highlighting"""
43 DIFF_FILE_HEADER_STATE
= 2
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
58 self
.is_commit
= is_commit
60 QPalette
= QtGui
.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
:
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
121 state
= DiffSyntaxHighlighter
.DEFAULT_STATE
123 state
= DiffSyntaxHighlighter
.DIFFSTAT_STATE
127 def get_formats_for_diffstat(self
, state
, text
):
128 """Returns (state, [(start, end, fmt), ...]) for highlighting diffstat text"""
130 if self
.DIFF_FILE_HEADER_START_RGX
.match(text
):
131 state
= self
.DIFF_FILE_HEADER_STATE
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
138 fmt
= self
.bold_diff_header_fmt
139 formats
.append((0, end
, fmt
))
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
))
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"""
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
))
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"""
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('-'):
173 state
= self
.END_STATE
175 formats
.append((0, len(text
), self
.diff_remove_fmt
))
177 elif text
.startswith('+'):
178 formats
.append((0, len(text
), self
.diff_add_fmt
))
180 match
= self
.BAD_WHITESPACE_RGX
.search(text
)
181 if match
is not None:
182 start
= match
.start()
183 formats
.append((start
, len(text
) - start
, self
.bad_whitespace_fmt
))
185 return state
, formats
188 # pylint: disable=too-many-ancestors
189 class DiffTextEdit(VimHintedPlainTextEdit
):
190 """A textedit for interacting with diff text"""
193 self
, context
, parent
, is_commit
=False, whitespace
=True, numbers
=False
195 VimHintedPlainTextEdit
.__init
__(self
, context
, '', parent
=parent
)
196 # Diff/patch syntax highlighter
197 self
.highlighter
= DiffSyntaxHighlighter(
198 context
, self
.document(), is_commit
=is_commit
, whitespace
=whitespace
201 self
.numbers
= DiffLineNumbers(context
, self
)
205 self
.scrollvalue
= None
207 self
.copy_diff_action
= qtutils
.add_action(
213 self
.copy_diff_action
.setIcon(icons
.copy())
214 self
.copy_diff_action
.setEnabled(False)
215 self
.menu_actions
.append(self
.copy_diff_action
)
217 # pylint: disable=no-member
218 self
.cursorPositionChanged
.connect(self
._cursor
_changed
)
219 self
.selectionChanged
.connect(self
._selection
_changed
)
221 def setFont(self
, font
):
222 """Override setFont() so that we can use a custom "block" cursor"""
223 super().setFont(font
)
224 if prefs
.block_cursor(self
.context
):
225 width
= qtutils
.text_width(font
, '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().resizeEvent(event
)
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()
249 scrollvalue
= get(scrollbar
)
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...'))
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('')
274 self
.numbers
.set_diff(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
)
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)
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
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
='')
323 for line_idx
, line
in enumerate(text
.split(sep
)):
324 if first_line_idx
<= line_idx
<= last_line_idx
:
330 """Return either CRLF or LF based on the content"""
338 def _strip_diff(value
):
339 """Remove +/-/<space> from a selection"""
340 if value
.startswith(('+', '-', ' ')):
345 class DiffLineNumbers(TextDecorator
):
346 def __init__(self
, context
, parent
):
347 TextDecorator
.__init
__(self
, parent
)
348 self
.highlight_line
= -1
350 self
.parser
= diffparse
.DiffLines()
351 self
.formatter
= diffparse
.FormatDigits()
353 font
= qtutils
.diff_font(context
)
355 self
._char
_width
= qtutils
.text_width(font
, 'M')
357 QPalette
= QtGui
.QPalette
358 self
._palette
= palette
= self
.palette()
359 self
._base
= palette
.color(QtGui
.QPalette
.Base
)
360 self
._highlight
= palette
.color(QPalette
.Highlight
)
361 self
._highlight
.setAlphaF(0.3)
362 self
._highlight
_text
= palette
.color(QPalette
.HighlightedText
)
363 self
._window
= palette
.color(QPalette
.Window
)
364 self
._disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
366 def set_diff(self
, diff
):
367 self
.lines
= self
.parser
.parse(diff
)
368 self
.formatter
.set_digits(self
.parser
.digits())
370 def width_hint(self
):
371 if not self
.isVisible():
377 extra
= 3 # one space in-between, one space after
380 extra
= 2 # one space in-between, one space after
382 digits
= parser
.digits() * columns
384 return defs
.margin
+ (self
._char
_width
* (digits
+ extra
))
386 def set_highlighted(self
, line_number
):
387 """Set the line to highlight"""
388 self
.highlight_line
= line_number
390 def current_line(self
):
392 if lines
and self
.highlight_line
>= 0:
393 # Find the next valid line
394 for i
in range(self
.highlight_line
, len(lines
)):
395 # take the "new" line number: last value in tuple
396 line_number
= lines
[i
][-1]
400 # Find the previous valid line
401 for i
in range(self
.highlight_line
- 1, -1, -1):
402 # take the "new" line number: last value in tuple
404 line_number
= lines
[i
][-1]
409 def paintEvent(self
, event
):
410 """Paint the line number"""
414 painter
= QtGui
.QPainter(self
)
415 painter
.fillRect(event
.rect(), self
._base
)
418 content_offset
= editor
.contentOffset()
419 block
= editor
.firstVisibleBlock()
421 text_width
= width
- (defs
.margin
* 2)
422 text_flags
= Qt
.AlignRight | Qt
.AlignVCenter
423 event_rect_bottom
= event
.rect().bottom()
425 highlight_line
= self
.highlight_line
426 highlight
= self
._highlight
427 highlight_text
= self
._highlight
_text
428 disabled
= self
._disabled
432 num_lines
= len(lines
)
434 while block
.isValid():
435 block_number
= block
.blockNumber()
436 if block_number
>= num_lines
:
438 block_geom
= editor
.blockBoundingGeometry(block
)
439 rect
= block_geom
.translated(content_offset
).toRect()
440 if not block
.isVisible() or rect
.top() >= event_rect_bottom
:
443 if block_number
== highlight_line
:
444 painter
.fillRect(rect
.x(), rect
.y(), width
, rect
.height(), highlight
)
445 painter
.setPen(highlight_text
)
447 painter
.setPen(disabled
)
449 line
= lines
[block_number
]
452 text
= fmt
.value(a
, b
)
454 old
, base
, new
= line
455 text
= fmt
.merge_value(old
, base
, new
)
469 class Viewer(QtWidgets
.QFrame
):
470 """Text and image diff viewers"""
475 def __init__(self
, context
, parent
=None):
476 super().__init
__(parent
)
478 self
.context
= context
479 self
.model
= model
= context
.model
482 self
.options
= options
= Options(self
)
483 self
.filename
= PlainTextLabel(parent
=self
)
484 self
.filename
.setAlignment(Qt
.AlignVCenter | Qt
.AlignLeft
)
487 self
.filename
.setFont(font
)
488 self
.filename
.elide()
489 self
.text
= DiffEditor(context
, options
, self
)
490 self
.image
= imageview
.ImageView(parent
=self
)
491 self
.image
.setFocusPolicy(Qt
.NoFocus
)
492 self
.search_widget
= TextSearchWidget(self
.text
, self
)
493 self
.search_widget
.hide()
494 self
._drag
_has
_patches
= False
496 self
.setAcceptDrops(True)
497 self
.setFocusProxy(self
.text
)
499 stack
= self
.stack
= QtWidgets
.QStackedWidget(self
)
500 stack
.addWidget(self
.text
)
501 stack
.addWidget(self
.image
)
503 self
.main_layout
= qtutils
.vbox(
509 self
.setLayout(self
.main_layout
)
512 model
.images_changed
.connect(self
.set_images
, type=Qt
.QueuedConnection
)
514 # Observe the diff type
515 model
.diff_type_changed
.connect(self
.set_diff_type
, type=Qt
.QueuedConnection
)
517 # Observe the file type
518 model
.file_type_changed
.connect(self
.set_file_type
, type=Qt
.QueuedConnection
)
520 # Observe the image mode combo box
521 options
.image_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
522 options
.zoom_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
524 self
.search_action
= qtutils
.add_action(
526 N_('Search in Diff'),
527 self
.show_search_diff
,
531 def dragEnterEvent(self
, event
):
532 """Accepts drops if the mimedata contains patches"""
533 super().dragEnterEvent(event
)
534 patches
= get_patches_from_mimedata(event
.mimeData())
536 event
.acceptProposedAction()
537 self
._drag
_has
_patches
= True
539 def dragLeaveEvent(self
, event
):
540 """End the drag+drop interaction"""
541 super().dragLeaveEvent(event
)
542 if self
._drag
_has
_patches
:
546 self
._drag
_has
_patches
= False
548 def dropEvent(self
, event
):
549 """Apply patches when dropped onto the widget"""
550 if not self
._drag
_has
_patches
:
553 event
.setDropAction(Qt
.CopyAction
)
554 super().dropEvent(event
)
555 self
._drag
_has
_patches
= False
557 patches
= get_patches_from_mimedata(event
.mimeData())
559 apply_patches(self
.context
, patches
=patches
)
561 event
.accept() # must be called after dropEvent()
563 def show_search_diff(self
):
564 """Show a dialog for searching diffs"""
565 # The diff search is only active in text mode.
566 if self
.stack
.currentIndex() != self
.INDEX_TEXT
:
568 if not self
.search_widget
.isVisible():
569 self
.search_widget
.show()
570 self
.search_widget
.setFocus()
572 def export_state(self
, state
):
573 state
['show_diff_line_numbers'] = self
.options
.show_line_numbers
.isChecked()
574 state
['show_diff_filenames'] = self
.options
.show_filenames
.isChecked()
575 state
['image_diff_mode'] = self
.options
.image_mode
.currentIndex()
576 state
['image_zoom_mode'] = self
.options
.zoom_mode
.currentIndex()
577 state
['word_wrap'] = self
.options
.enable_word_wrapping
.isChecked()
580 def apply_state(self
, state
):
581 diff_numbers
= bool(state
.get('show_diff_line_numbers', False))
582 self
.set_line_numbers(diff_numbers
, update
=True)
584 show_filenames
= bool(state
.get('show_diff_filenames', True))
585 self
.set_show_filenames(show_filenames
, update
=True)
587 image_mode
= utils
.asint(state
.get('image_diff_mode', 0))
588 self
.options
.image_mode
.set_index(image_mode
)
590 zoom_mode
= utils
.asint(state
.get('image_zoom_mode', 0))
591 self
.options
.zoom_mode
.set_index(zoom_mode
)
593 word_wrap
= bool(state
.get('word_wrap', True))
594 self
.set_word_wrapping(word_wrap
, update
=True)
597 def set_diff_type(self
, diff_type
):
598 """Manage the image and text diff views when selection changes"""
599 # The "diff type" is whether the diff viewer is displaying an image.
600 self
.options
.set_diff_type(diff_type
)
601 if diff_type
== main
.Types
.IMAGE
:
602 self
.stack
.setCurrentWidget(self
.image
)
603 self
.search_widget
.hide()
606 self
.stack
.setCurrentWidget(self
.text
)
608 def set_file_type(self
, file_type
):
609 """Manage the diff options when the file type changes"""
610 # The "file type" is whether the file itself is an image.
611 self
.options
.set_file_type(file_type
)
613 def enable_filename_tracking(self
):
614 """Enable displaying the currently selected filename"""
615 self
.context
.selection
.selection_changed
.connect(
616 self
.update_filename
, type=Qt
.QueuedConnection
619 def update_filename(self
):
620 """Update the filename display when the selection changes"""
621 filename
= self
.context
.selection
.filename()
622 self
.filename
.set_text(filename
or '')
624 def update_options(self
):
625 """Emit a signal indicating that options have changed"""
626 self
.text
.update_options()
627 show_filenames
= get(self
.options
.show_filenames
)
628 self
.set_show_filenames(show_filenames
)
630 def set_show_filenames(self
, enabled
, update
=False):
631 """Enable/disable displaying the selected filename"""
632 self
.filename
.setVisible(enabled
)
634 with qtutils
.BlockSignals(self
.options
.show_filenames
):
635 self
.options
.show_filenames
.setChecked(enabled
)
637 def set_line_numbers(self
, enabled
, update
=False):
638 """Enable/disable line numbers in the text widget"""
639 self
.text
.set_line_numbers(enabled
, update
=update
)
641 def set_word_wrapping(self
, enabled
, update
=False):
642 """Enable/disable word wrapping in the text widget"""
643 self
.text
.set_word_wrapping(enabled
, update
=update
)
646 self
.image
.pixmap
= QtGui
.QPixmap()
650 for image
, unlink
in self
.images
:
651 if unlink
and core
.exists(image
):
655 def set_images(self
, images
):
662 # In order to comp, we first have to load all the images
663 all_pixmaps
= [QtGui
.QPixmap(image
[0]) for image
in images
]
664 pixmaps
= [pixmap
for pixmap
in all_pixmaps
if not pixmap
.isNull()]
669 self
.pixmaps
= pixmaps
677 mode
= self
.options
.image_mode
.currentIndex()
678 if mode
== self
.options
.SIDE_BY_SIDE
:
679 image
= self
.render_side_by_side()
680 elif mode
== self
.options
.DIFF
:
681 image
= self
.render_diff()
682 elif mode
== self
.options
.XOR
:
683 image
= self
.render_xor()
684 elif mode
== self
.options
.PIXEL_XOR
:
685 image
= self
.render_pixel_xor()
687 image
= self
.render_side_by_side()
689 image
= QtGui
.QPixmap()
690 self
.image
.pixmap
= image
693 zoom_mode
= self
.options
.zoom_mode
.currentIndex()
694 zoom_factor
= self
.options
.zoom_factors
[zoom_mode
][1]
695 if zoom_factor
> 0.0:
696 self
.image
.resetTransform()
697 self
.image
.scale(zoom_factor
, zoom_factor
)
698 poly
= self
.image
.mapToScene(self
.image
.viewport().rect())
699 self
.image
.last_scene_roi
= poly
.boundingRect()
701 def render_side_by_side(self
):
702 # Side-by-side lineup comp
703 pixmaps
= self
.pixmaps
704 width
= sum(pixmap
.width() for pixmap
in pixmaps
)
705 height
= max(pixmap
.height() for pixmap
in pixmaps
)
706 image
= create_image(width
, height
)
709 painter
= create_painter(image
)
711 for pixmap
in pixmaps
:
712 painter
.drawPixmap(x
, 0, pixmap
)
718 def render_comp(self
, comp_mode
):
719 # Get the max size to use as the render canvas
720 pixmaps
= self
.pixmaps
721 if len(pixmaps
) == 1:
724 width
= max(pixmap
.width() for pixmap
in pixmaps
)
725 height
= max(pixmap
.height() for pixmap
in pixmaps
)
726 image
= create_image(width
, height
)
728 painter
= create_painter(image
)
729 for pixmap
in (pixmaps
[0], pixmaps
[-1]):
730 x
= (width
- pixmap
.width()) // 2
731 y
= (height
- pixmap
.height()) // 2
732 painter
.drawPixmap(x
, y
, pixmap
)
733 painter
.setCompositionMode(comp_mode
)
738 def render_diff(self
):
739 comp_mode
= QtGui
.QPainter
.CompositionMode_Difference
740 return self
.render_comp(comp_mode
)
742 def render_xor(self
):
743 comp_mode
= QtGui
.QPainter
.CompositionMode_Xor
744 return self
.render_comp(comp_mode
)
746 def render_pixel_xor(self
):
747 comp_mode
= QtGui
.QPainter
.RasterOp_SourceXorDestination
748 return self
.render_comp(comp_mode
)
751 def create_image(width
, height
):
752 size
= QtCore
.QSize(width
, height
)
753 image
= QtGui
.QImage(size
, QtGui
.QImage
.Format_ARGB32_Premultiplied
)
754 image
.fill(Qt
.transparent
)
758 def create_painter(image
):
759 painter
= QtGui
.QPainter(image
)
760 painter
.fillRect(image
.rect(), Qt
.transparent
)
764 class Options(QtWidgets
.QWidget
):
765 """Provide the options widget used by the editor
767 Actions are registered on the parent widget.
771 # mode combobox indexes
777 def __init__(self
, parent
):
778 super().__init
__(parent
)
781 self
.ignore_space_at_eol
= self
.add_option(
782 N_('Ignore changes in whitespace at EOL')
784 self
.ignore_space_change
= self
.add_option(
785 N_('Ignore changes in amount of whitespace')
787 self
.ignore_all_space
= self
.add_option(N_('Ignore all whitespace'))
788 self
.function_context
= self
.add_option(
789 N_('Show whole surrounding functions of changes')
791 self
.show_line_numbers
= qtutils
.add_action_bool(
792 self
, N_('Show line numbers'), self
.set_line_numbers
, True
794 self
.show_filenames
= self
.add_option(N_('Show filenames'))
795 self
.enable_word_wrapping
= qtutils
.add_action_bool(
796 self
, N_('Enable word wrapping'), self
.set_word_wrapping
, True
799 self
.options
= qtutils
.create_action_button(
800 tooltip
=N_('Diff Options'), icon
=icons
.configure()
803 self
.toggle_image_diff
= qtutils
.create_action_button(
804 tooltip
=N_('Toggle image diff'), icon
=icons
.visualize()
806 self
.toggle_image_diff
.hide()
808 self
.image_mode
= qtutils
.combo(
809 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
812 self
.zoom_factors
= (
813 (N_('Zoom to Fit'), 0.0),
821 zoom_modes
= [factor
[0] for factor
in self
.zoom_factors
]
822 self
.zoom_mode
= qtutils
.combo(zoom_modes
, parent
=self
)
824 self
.menu
= menu
= qtutils
.create_menu(N_('Diff Options'), self
.options
)
825 self
.options
.setMenu(menu
)
826 menu
.addAction(self
.ignore_space_at_eol
)
827 menu
.addAction(self
.ignore_space_change
)
828 menu
.addAction(self
.ignore_all_space
)
830 menu
.addAction(self
.function_context
)
831 menu
.addAction(self
.show_line_numbers
)
832 menu
.addAction(self
.show_filenames
)
834 menu
.addAction(self
.enable_word_wrapping
)
837 layout
= qtutils
.hbox(
843 self
.toggle_image_diff
,
845 self
.setLayout(layout
)
848 self
.image_mode
.setFocusPolicy(Qt
.NoFocus
)
849 self
.zoom_mode
.setFocusPolicy(Qt
.NoFocus
)
850 self
.options
.setFocusPolicy(Qt
.NoFocus
)
851 self
.toggle_image_diff
.setFocusPolicy(Qt
.NoFocus
)
852 self
.setFocusPolicy(Qt
.NoFocus
)
854 def set_file_type(self
, file_type
):
855 """Set whether we are viewing an image file type"""
856 is_image
= file_type
== main
.Types
.IMAGE
857 self
.toggle_image_diff
.setVisible(is_image
)
859 def set_diff_type(self
, diff_type
):
860 """Toggle between image and text diffs"""
861 is_text
= diff_type
== main
.Types
.TEXT
862 is_image
= diff_type
== main
.Types
.IMAGE
863 self
.options
.setVisible(is_text
)
864 self
.image_mode
.setVisible(is_image
)
865 self
.zoom_mode
.setVisible(is_image
)
867 self
.toggle_image_diff
.setIcon(icons
.diff())
869 self
.toggle_image_diff
.setIcon(icons
.visualize())
871 def add_option(self
, title
):
872 """Add a diff option which calls update_options() on change"""
873 action
= qtutils
.add_action(self
, title
, self
.update_options
)
874 action
.setCheckable(True)
877 def update_options(self
):
878 """Update diff options in response to UI events"""
879 space_at_eol
= get(self
.ignore_space_at_eol
)
880 space_change
= get(self
.ignore_space_change
)
881 all_space
= get(self
.ignore_all_space
)
882 function_context
= get(self
.function_context
)
883 gitcmds
.update_diff_overrides(
884 space_at_eol
, space_change
, all_space
, function_context
886 self
.widget
.update_options()
888 def set_line_numbers(self
, value
):
889 """Enable / disable line numbers"""
890 self
.widget
.set_line_numbers(value
, update
=False)
892 def set_word_wrapping(self
, value
):
893 """Respond to Qt action callbacks"""
894 self
.widget
.set_word_wrapping(value
, update
=False)
896 def hide_advanced_options(self
):
897 """Hide advanced options that are not applicable to the DiffWidget"""
898 self
.show_filenames
.setVisible(False)
899 self
.show_line_numbers
.setVisible(False)
900 self
.ignore_space_at_eol
.setVisible(False)
901 self
.ignore_space_change
.setVisible(False)
902 self
.ignore_all_space
.setVisible(False)
903 self
.function_context
.setVisible(False)
906 # pylint: disable=too-many-ancestors
907 class DiffEditor(DiffTextEdit
):
910 options_changed
= Signal()
912 def __init__(self
, context
, options
, parent
):
913 DiffTextEdit
.__init
__(self
, context
, parent
, numbers
=True)
914 self
.context
= context
915 self
.model
= model
= context
.model
916 self
.selection_model
= selection_model
= context
.selection
918 # "Diff Options" tool menu
919 self
.options
= options
921 self
.action_apply_selection
= qtutils
.add_action(
924 self
.apply_selection
,
926 hotkeys
.STAGE_DIFF_ALT
,
929 self
.action_revert_selection
= qtutils
.add_action(
930 self
, 'Revert', self
.revert_selection
, hotkeys
.REVERT
, hotkeys
.REVERT_ALT
932 self
.action_revert_selection
.setIcon(icons
.undo())
934 self
.action_edit_and_apply_selection
= qtutils
.add_action(
937 partial(self
.apply_selection
, edit
=True),
938 hotkeys
.EDIT_AND_STAGE_DIFF
,
941 self
.action_edit_and_revert_selection
= qtutils
.add_action(
944 partial(self
.revert_selection
, edit
=True),
945 hotkeys
.EDIT_AND_REVERT
,
947 self
.action_edit_and_revert_selection
.setIcon(icons
.undo())
948 self
.launch_editor
= actions
.launch_editor_at_line(
949 context
, self
, hotkeys
.EDIT_SHORT
, *hotkeys
.ACCEPT
951 self
.launch_difftool
= actions
.launch_difftool(context
, self
)
952 self
.stage_or_unstage
= actions
.stage_or_unstage(context
, self
)
954 # Emit up/down signals so that they can be routed by the main widget
955 self
.move_up
= actions
.move_up(self
)
956 self
.move_down
= actions
.move_down(self
)
958 model
.diff_text_updated
.connect(self
.set_diff
, type=Qt
.QueuedConnection
)
960 selection_model
.selection_changed
.connect(
961 self
.refresh
, type=Qt
.QueuedConnection
963 # Update the selection model when the cursor changes
964 self
.cursorPositionChanged
.connect(self
._update
_line
_number
)
966 qtutils
.connect_button(options
.toggle_image_diff
, self
.toggle_diff_type
)
968 def toggle_diff_type(self
):
969 cmds
.do(cmds
.ToggleDiffType
, self
.context
)
973 s
= self
.selection_model
.selection()
975 if model
.is_partially_stageable():
976 item
= s
.modified
[0] if s
.modified
else None
977 if item
in model
.submodules
:
979 elif item
not in model
.unstaged_deleted
:
981 self
.action_revert_selection
.setEnabled(enabled
)
983 def set_line_numbers(self
, enabled
, update
=False):
984 """Enable/disable the diff line number display"""
985 self
.numbers
.setVisible(enabled
)
987 with qtutils
.BlockSignals(self
.options
.show_line_numbers
):
988 self
.options
.show_line_numbers
.setChecked(enabled
)
989 # Refresh the display. Not doing this results in the display not
990 # correctly displaying the line numbers widget until the text scrolls.
991 self
.set_value(self
.value())
993 def update_options(self
):
994 self
.options_changed
.emit()
996 def create_context_menu(self
, event_pos
):
997 """Override create_context_menu() to display a completely custom menu"""
998 menu
= super().create_context_menu(event_pos
)
999 context
= self
.context
1001 s
= self
.selection_model
.selection()
1002 filename
= self
.selection_model
.filename()
1004 # These menu actions will be inserted at the start of the widget.
1005 current_actions
= menu
.actions()
1007 add_action
= menu_actions
.append
1008 edit_actions_added
= False
1009 stage_action_added
= False
1011 if s
.staged
and model
.is_unstageable():
1013 if item
not in model
.submodules
and item
not in model
.staged_deleted
:
1014 if self
.has_selection():
1015 apply_text
= N_('Unstage Selected Lines')
1017 apply_text
= N_('Unstage Diff Hunk')
1018 self
.action_apply_selection
.setText(apply_text
)
1019 self
.action_apply_selection
.setIcon(icons
.remove())
1020 add_action(self
.action_apply_selection
)
1021 stage_action_added
= self
._add
_stage
_or
_unstage
_action
(
1022 menu
, add_action
, stage_action_added
1025 if model
.is_partially_stageable():
1026 item
= s
.modified
[0] if s
.modified
else None
1027 if item
in model
.submodules
:
1028 path
= core
.abspath(item
)
1029 action
= qtutils
.add_action_with_icon(
1033 cmds
.run(cmds
.Stage
, context
, s
.modified
),
1034 hotkeys
.STAGE_SELECTION
,
1037 stage_action_added
= self
._add
_stage
_or
_unstage
_action
(
1038 menu
, add_action
, stage_action_added
1041 action
= qtutils
.add_action_with_icon(
1044 N_('Launch git-cola'),
1045 cmds
.run(cmds
.OpenRepo
, context
, path
),
1048 elif item
and item
not in model
.unstaged_deleted
:
1049 if self
.has_selection():
1050 apply_text
= N_('Stage Selected Lines')
1051 edit_and_apply_text
= N_('Edit Selected Lines to Stage...')
1052 revert_text
= N_('Revert Selected Lines...')
1053 edit_and_revert_text
= N_('Edit Selected Lines to Revert...')
1055 apply_text
= N_('Stage Diff Hunk')
1056 edit_and_apply_text
= N_('Edit Diff Hunk to Stage...')
1057 revert_text
= N_('Revert Diff Hunk...')
1058 edit_and_revert_text
= N_('Edit Diff Hunk to Revert...')
1060 self
.action_apply_selection
.setText(apply_text
)
1061 self
.action_apply_selection
.setIcon(icons
.add())
1062 add_action(self
.action_apply_selection
)
1064 self
.action_revert_selection
.setText(revert_text
)
1065 add_action(self
.action_revert_selection
)
1067 stage_action_added
= self
._add
_stage
_or
_unstage
_action
(
1068 menu
, add_action
, stage_action_added
1070 # Do not show the "edit" action when the file does not exist.
1071 add_action(qtutils
.menu_separator(menu
))
1072 if filename
and core
.exists(filename
):
1073 add_action(self
.launch_editor
)
1074 # Removed files can still be diffed.
1075 add_action(self
.launch_difftool
)
1076 edit_actions_added
= True
1078 add_action(qtutils
.menu_separator(menu
))
1079 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
1080 self
.action_edit_and_apply_selection
.setIcon(icons
.add())
1081 add_action(self
.action_edit_and_apply_selection
)
1083 self
.action_edit_and_revert_selection
.setText(edit_and_revert_text
)
1084 add_action(self
.action_edit_and_revert_selection
)
1086 if s
.staged
and model
.is_unstageable():
1088 if item
in model
.submodules
:
1089 path
= core
.abspath(item
)
1090 action
= qtutils
.add_action_with_icon(
1093 cmds
.Unstage
.name(),
1094 cmds
.run(cmds
.Unstage
, context
, s
.staged
),
1095 hotkeys
.STAGE_SELECTION
,
1099 stage_action_added
= self
._add
_stage
_or
_unstage
_action
(
1100 menu
, add_action
, stage_action_added
1103 qtutils
.add_action_with_icon(
1106 N_('Launch git-cola'),
1107 cmds
.run(cmds
.OpenRepo
, context
, path
),
1111 elif item
not in model
.staged_deleted
:
1112 # Do not show the "edit" action when the file does not exist.
1113 add_action(qtutils
.menu_separator(menu
))
1114 if filename
and core
.exists(filename
):
1115 add_action(self
.launch_editor
)
1116 # Removed files can still be diffed.
1117 add_action(self
.launch_difftool
)
1118 add_action(qtutils
.menu_separator(menu
))
1119 edit_actions_added
= True
1121 if self
.has_selection():
1122 edit_and_apply_text
= N_('Edit Selected Lines to Unstage...')
1124 edit_and_apply_text
= N_('Edit Diff Hunk to Unstage...')
1125 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
1126 self
.action_edit_and_apply_selection
.setIcon(icons
.remove())
1127 add_action(self
.action_edit_and_apply_selection
)
1129 if not edit_actions_added
and (model
.is_stageable() or model
.is_unstageable()):
1130 add_action(qtutils
.menu_separator(menu
))
1131 # Do not show the "edit" action when the file does not exist.
1132 # Untracked files exist by definition.
1133 if filename
and core
.exists(filename
):
1134 add_action(self
.launch_editor
)
1136 # Removed files can still be diffed.
1137 add_action(self
.launch_difftool
)
1139 add_action(qtutils
.menu_separator(menu
))
1140 _add_patch_actions(self
, self
.context
, menu
)
1142 # Add the Previous/Next File actions, which improves discoverability
1143 # of their associated shortcuts
1144 add_action(qtutils
.menu_separator(menu
))
1145 add_action(self
.move_up
)
1146 add_action(self
.move_down
)
1147 add_action(qtutils
.menu_separator(menu
))
1150 first_action
= current_actions
[0]
1153 menu
.insertActions(first_action
, menu_actions
)
1157 def _add_stage_or_unstage_action(self
, menu
, add_action
, already_added
):
1158 """Add the Stage / Unstage menu action"""
1161 model
= self
.context
.model
1162 s
= self
.selection_model
.selection()
1163 if model
.is_stageable() or model
.is_unstageable():
1164 if (model
.is_amend_mode() and s
.staged
) or not self
.model
.is_stageable():
1165 self
.stage_or_unstage
.setText(N_('Unstage'))
1166 self
.stage_or_unstage
.setIcon(icons
.remove())
1168 self
.stage_or_unstage
.setText(N_('Stage'))
1169 self
.stage_or_unstage
.setIcon(icons
.add())
1170 add_action(qtutils
.menu_separator(menu
))
1171 add_action(self
.stage_or_unstage
)
1174 def mousePressEvent(self
, event
):
1175 if event
.button() == Qt
.RightButton
:
1176 # Intercept right-click to move the cursor to the current position.
1177 # setTextCursor() clears the selection so this is only done when
1178 # nothing is selected.
1179 if not self
.has_selection():
1180 cursor
= self
.cursorForPosition(event
.pos())
1181 self
.setTextCursor(cursor
)
1183 return super().mousePressEvent(event
)
1185 def setPlainText(self
, text
):
1186 """setPlainText(str) while retaining scrollbar positions"""
1189 highlight
= mode
not in (
1192 model
.mode_untracked
,
1194 self
.highlighter
.set_enabled(highlight
)
1196 scrollbar
= self
.verticalScrollBar()
1198 scrollvalue
= get(scrollbar
)
1205 DiffTextEdit
.setPlainText(self
, text
)
1207 if scrollbar
and scrollvalue
is not None:
1208 scrollbar
.setValue(scrollvalue
)
1210 def apply_selection(self
, *, edit
=False):
1212 s
= self
.selection_model
.single_selection()
1213 if model
.is_partially_stageable() and (s
.modified
or s
.untracked
):
1214 self
.process_diff_selection(edit
=edit
)
1215 elif model
.is_unstageable():
1216 self
.process_diff_selection(reverse
=True, edit
=edit
)
1218 def revert_selection(self
, *, edit
=False):
1219 """Destructively revert selected lines or hunk from a worktree file."""
1222 if self
.has_selection():
1223 title
= N_('Revert Selected Lines?')
1224 ok_text
= N_('Revert Selected Lines')
1226 title
= N_('Revert Diff Hunk?')
1227 ok_text
= N_('Revert Diff Hunk')
1229 if not Interaction
.confirm(
1232 'This operation drops uncommitted changes.\n'
1233 'These changes cannot be recovered.'
1235 N_('Revert the uncommitted changes?'),
1241 self
.process_diff_selection(reverse
=True, apply_to_worktree
=True, edit
=edit
)
1243 def extract_patch(self
, reverse
=False):
1244 first_line_idx
, last_line_idx
= self
.selected_lines()
1245 patch
= diffparse
.Patch
.parse(self
.model
.filename
, self
.model
.diff_text
)
1246 if self
.has_selection():
1247 return patch
.extract_subset(first_line_idx
, last_line_idx
, reverse
=reverse
)
1248 return patch
.extract_hunk(first_line_idx
, reverse
=reverse
)
1250 def patch_encoding(self
):
1251 if isinstance(self
.model
.diff_text
, core
.UStr
):
1252 # original encoding must prevail
1253 return self
.model
.diff_text
.encoding
1254 return self
.context
.cfg
.file_encoding(self
.model
.filename
)
1256 def process_diff_selection(
1257 self
, reverse
=False, apply_to_worktree
=False, edit
=False
1259 """Implement un/staging of the selected line(s) or hunk."""
1260 if self
.selection_model
.is_empty():
1262 patch
= self
.extract_patch(reverse
)
1263 if not patch
.has_changes():
1265 patch_encoding
= self
.patch_encoding()
1273 apply_to_worktree
=apply_to_worktree
,
1275 if not patch
.has_changes():
1286 def _update_line_number(self
):
1287 """Update the selection model when the cursor changes"""
1288 self
.selection_model
.line_number
= self
.numbers
.current_line()
1291 def _add_patch_actions(widget
, context
, menu
):
1292 """Add actions for manipulating patch files"""
1293 patches_menu
= menu
.addMenu(N_('Patches'))
1294 patches_menu
.setIcon(icons
.diff())
1295 export_action
= qtutils
.add_action(
1298 lambda: _export_patch(widget
, context
),
1300 export_action
.setIcon(icons
.save())
1301 patches_menu
.addAction(export_action
)
1303 # Build the "Append Patch" menu dynamically.
1304 append_menu
= patches_menu
.addMenu(N_('Append Patch'))
1305 append_menu
.setIcon(icons
.add())
1306 append_menu
.aboutToShow
.connect(
1307 lambda: _build_patch_append_menu(widget
, context
, append_menu
)
1311 def _build_patch_append_menu(widget
, context
, menu
):
1312 """Build the "Append Patch" sub-menu"""
1313 # Build the menu when first displayed only. This initial check avoids
1314 # re-populating the menu with duplicate actions.
1315 menu_actions
= menu
.actions()
1319 choose_patch_action
= qtutils
.add_action(
1321 N_('Choose Patch...'),
1322 lambda: _export_patch(widget
, context
, append
=True),
1324 choose_patch_action
.setIcon(icons
.diff())
1325 menu
.addAction(choose_patch_action
)
1328 path
= prefs
.patches_directory(context
)
1329 patches
= get_patches_from_dir(path
)
1330 for patch
in patches
:
1331 relpath
= os
.path
.relpath(patch
, start
=path
)
1332 sub_menu
= _add_patch_subdirs(menu
, subdir_menus
, relpath
)
1333 patch_basename
= os
.path
.basename(relpath
)
1334 append_action
= qtutils
.add_action(
1337 lambda patch_file
=patch
: _append_patch(widget
, patch_file
),
1339 append_action
.setIcon(icons
.save())
1340 sub_menu
.addAction(append_action
)
1343 def _add_patch_subdirs(menu
, subdir_menus
, relpath
):
1344 """Build menu leading up to the patch"""
1345 # If the path contains no directory separators then add it to the
1347 if os
.sep
not in relpath
:
1350 # Loop over each directory component and build a menu if it doesn't already exist.
1352 for dirname
in os
.path
.dirname(relpath
).split(os
.sep
):
1353 components
.append(dirname
)
1354 current_dir
= os
.sep
.join(components
)
1356 menu
= subdir_menus
[current_dir
]
1358 menu
= subdir_menus
[current_dir
] = menu
.addMenu(dirname
)
1359 menu
.setIcon(icons
.folder())
1364 def _export_patch(diff_editor
, context
, append
=False):
1365 """Export the selected diff to a patch file"""
1366 if diff_editor
.selection_model
.is_empty():
1368 patch
= diff_editor
.extract_patch(reverse
=False)
1369 if not patch
.has_changes():
1371 directory
= prefs
.patches_directory(context
)
1373 filename
= qtutils
.existing_file(directory
, title
=N_('Append Patch...'))
1375 default_filename
= os
.path
.join(directory
, 'diff.patch')
1376 filename
= qtutils
.save_as(default_filename
)
1379 _write_patch_to_file(diff_editor
, patch
, filename
, append
=append
)
1382 def _append_patch(diff_editor
, filename
):
1383 """Append diffs to the specified patch file"""
1384 if diff_editor
.selection_model
.is_empty():
1386 patch
= diff_editor
.extract_patch(reverse
=False)
1387 if not patch
.has_changes():
1389 _write_patch_to_file(diff_editor
, patch
, filename
, append
=True)
1392 def _write_patch_to_file(diff_editor
, patch
, filename
, append
=False):
1393 """Write diffs from the Diff Editor to the specified patch file"""
1394 encoding
= diff_editor
.patch_encoding()
1395 content
= patch
.as_text()
1397 core
.write(filename
, content
, encoding
=encoding
, append
=append
)
1398 except OSError as exc
:
1399 _
, details
= utils
.format_exception(exc
)
1400 title
= N_('Error writing patch')
1401 msg
= N_('Unable to write patch to "%s". Check permissions?' % filename
)
1402 Interaction
.critical(title
, message
=msg
, details
=details
)
1404 Interaction
.log('Patch written to "%s"' % filename
)
1407 class DiffWidget(QtWidgets
.QWidget
):
1408 """Display commit metadata and text diffs"""
1410 def __init__(self
, context
, parent
, is_commit
=False, options
=None):
1411 QtWidgets
.QWidget
.__init
__(self
, parent
)
1413 self
.context
= context
1415 self
.oid_start
= None
1417 self
.options
= options
1419 author_font
= QtGui
.QFont(self
.font())
1420 author_font
.setPointSize(int(author_font
.pointSize() * 1.1))
1422 summary_font
= QtGui
.QFont(author_font
)
1423 summary_font
.setWeight(QtGui
.QFont
.Bold
)
1425 self
.gravatar_label
= gravatar
.GravatarLabel(self
.context
, parent
=self
)
1427 self
.oid_label
= PlainTextLabel(parent
=self
)
1428 self
.oid_label
.setAlignment(Qt
.AlignBottom
)
1429 self
.oid_label
.elide()
1431 self
.author_label
= RichTextLabel(parent
=self
)
1432 self
.author_label
.setFont(author_font
)
1433 self
.author_label
.setAlignment(Qt
.AlignTop
)
1434 self
.author_label
.elide()
1436 self
.date_label
= PlainTextLabel(parent
=self
)
1437 self
.date_label
.setAlignment(Qt
.AlignTop
)
1438 self
.date_label
.elide()
1440 self
.summary_label
= PlainTextLabel(parent
=self
)
1441 self
.summary_label
.setFont(summary_font
)
1442 self
.summary_label
.setAlignment(Qt
.AlignTop
)
1443 self
.summary_label
.elide()
1445 self
.diff
= DiffTextEdit(context
, self
, is_commit
=is_commit
, whitespace
=False)
1446 self
.setFocusProxy(self
.diff
)
1448 self
.info_layout
= qtutils
.vbox(
1457 self
.logo_layout
= qtutils
.hbox(
1458 defs
.no_margin
, defs
.button_spacing
, self
.gravatar_label
, self
.info_layout
1460 self
.logo_layout
.setContentsMargins(defs
.margin
, 0, defs
.margin
, 0)
1462 self
.main_layout
= qtutils
.vbox(
1463 defs
.no_margin
, defs
.spacing
, self
.logo_layout
, self
.diff
1465 self
.setLayout(self
.main_layout
)
1467 self
.set_tabwidth(prefs
.tabwidth(context
))
1469 def set_tabwidth(self
, width
):
1470 self
.diff
.set_tabwidth(width
)
1472 def set_word_wrapping(self
, enabled
, update
=False):
1473 """Enable and disable word wrapping"""
1474 self
.diff
.set_word_wrapping(enabled
, update
=update
)
1476 def set_options(self
, options
):
1477 """Register an options widget"""
1478 self
.options
= options
1479 self
.diff
.set_options(options
)
1481 def start_diff_task(self
, task
):
1482 """Clear the display and start a diff-gathering task"""
1483 self
.diff
.save_scrollbar()
1484 self
.diff
.set_loading_message()
1485 self
.context
.runtask
.start(task
, result
=self
.set_diff
)
1487 def set_diff_oid(self
, oid
, filename
=None):
1488 """Set the diff from a single commit object ID"""
1489 task
= DiffInfoTask(self
.context
, oid
, filename
)
1490 self
.start_diff_task(task
)
1492 def set_diff_range(self
, start
, end
, filename
=None):
1493 task
= DiffRangeTask(self
.context
, start
+ '~', end
, filename
)
1494 self
.start_diff_task(task
)
1496 def commits_selected(self
, commits
):
1497 """Display an appropriate diff when commits are selected"""
1501 commit
= commits
[-1]
1503 author
= commit
.author
or ''
1504 email
= commit
.email
or ''
1505 date
= commit
.authdate
or ''
1506 summary
= commit
.summary
or ''
1507 self
.set_details(oid
, author
, email
, date
, summary
)
1510 if len(commits
) > 1:
1511 start
, end
= commits
[0], commits
[-1]
1512 self
.set_diff_range(start
.oid
, end
.oid
)
1513 self
.oid_start
= start
1516 self
.set_diff_oid(oid
)
1517 self
.oid_start
= None
1520 def set_diff(self
, diff
):
1521 """Set the diff text"""
1522 self
.diff
.set_diff(diff
)
1524 def set_details(self
, oid
, author
, email
, date
, summary
):
1525 template_args
= {'author': author
, 'email': email
, 'summary': summary
}
1527 """%(author)s <"""
1528 """<a href="mailto:%(email)s">"""
1529 """%(email)s</a>>""" % template_args
1531 author_template
= '%(author)s <%(email)s>' % template_args
1533 self
.date_label
.set_text(date
)
1534 self
.date_label
.setVisible(bool(date
))
1535 self
.oid_label
.set_text(oid
)
1536 self
.author_label
.set_template(author_text
, author_template
)
1537 self
.summary_label
.set_text(summary
)
1538 self
.gravatar_label
.set_email(email
)
1541 self
.date_label
.set_text('')
1542 self
.oid_label
.set_text('')
1543 self
.author_label
.set_text('')
1544 self
.summary_label
.set_text('')
1545 self
.gravatar_label
.clear()
1548 def files_selected(self
, filenames
):
1549 """Update the view when a filename is selected"""
1550 oid_start
= self
.oid_start
1551 oid_end
= self
.oid_end
1554 extra_args
['filename'] = filenames
[0]
1555 if oid_start
and oid_end
:
1556 self
.set_diff_range(oid_start
.oid
, oid_end
.oid
, **extra_args
)
1558 self
.set_diff_oid(self
.oid
, **extra_args
)
1561 class DiffPanel(QtWidgets
.QWidget
):
1562 """A combined diff + search panel"""
1564 def __init__(self
, diff_widget
, text_widget
, parent
):
1565 super().__init
__(parent
)
1566 self
.diff_widget
= diff_widget
1567 self
.search_widget
= TextSearchWidget(text_widget
, self
)
1568 self
.search_widget
.hide()
1569 layout
= qtutils
.vbox(
1570 defs
.no_margin
, defs
.spacing
, self
.diff_widget
, self
.search_widget
1572 self
.setLayout(layout
)
1573 self
.setFocusProxy(self
.diff_widget
)
1575 self
.search_action
= qtutils
.add_action(
1577 N_('Search in Diff'),
1582 def show_search(self
):
1583 """Show a dialog for searching diffs"""
1584 # The diff search is only active in text mode.
1585 if not self
.search_widget
.isVisible():
1586 self
.search_widget
.show()
1587 self
.search_widget
.setFocus()
1590 class DiffInfoTask(qtutils
.Task
):
1591 """Gather diffs for a single commit"""
1593 def __init__(self
, context
, oid
, filename
):
1594 qtutils
.Task
.__init
__(self
)
1595 self
.context
= context
1597 self
.filename
= filename
1600 context
= self
.context
1602 return gitcmds
.diff_info(context
, oid
, filename
=self
.filename
)
1605 class DiffRangeTask(qtutils
.Task
):
1606 """Gather diffs for a range of commits"""
1608 def __init__(self
, context
, start
, end
, filename
):
1609 qtutils
.Task
.__init
__(self
)
1610 self
.context
= context
1613 self
.filename
= filename
1616 context
= self
.context
1617 return gitcmds
.diff_range(context
, self
.start
, self
.end
, filename
=self
.filename
)
1620 def apply_patches(context
, patches
=None):
1621 """Open the ApplyPatches dialog"""
1622 parent
= qtutils
.active_window()
1623 dlg
= new_apply_patches(context
, patches
=patches
, parent
=parent
)
1629 def new_apply_patches(context
, patches
=None, parent
=None):
1630 """Create a new instances of the ApplyPatches dialog"""
1631 dlg
= ApplyPatches(context
, parent
=parent
)
1633 dlg
.add_paths(patches
)
1637 def get_patches_from_paths(paths
):
1638 """Returns all patches beneath a given path"""
1639 paths
= [core
.decode(p
) for p
in paths
]
1640 patches
= [p
for p
in paths
if core
.isfile(p
) and p
.endswith(('.patch', '.mbox'))]
1641 dirs
= [p
for p
in paths
if core
.isdir(p
)]
1644 patches
.extend(get_patches_from_dir(d
))
1648 def get_patches_from_mimedata(mimedata
):
1649 """Extract path files from a QMimeData payload"""
1650 urls
= mimedata
.urls()
1653 paths
= [x
.path() for x
in urls
]
1654 return get_patches_from_paths(paths
)
1657 def get_patches_from_dir(path
):
1658 """Find patches in a subdirectory"""
1660 for root
, _
, files
in core
.walk(path
):
1661 for name
in [f
for f
in files
if f
.endswith(('.patch', '.mbox'))]:
1662 patches
.append(core
.decode(os
.path
.join(root
, name
)))
1666 class ApplyPatches(standard
.Dialog
):
1667 def __init__(self
, context
, parent
=None):
1668 super().__init
__(parent
=parent
)
1669 self
.context
= context
1670 self
.setWindowTitle(N_('Apply Patches'))
1671 self
.setAcceptDrops(True)
1672 if parent
is not None:
1673 self
.setWindowModality(Qt
.WindowModal
)
1675 self
.curdir
= core
.getcwd()
1676 self
.inner_drag
= False
1678 self
.usage
= QtWidgets
.QLabel()
1683 Drag and drop or use the <strong>Add</strong> button to add
1690 self
.tree
= PatchTreeWidget(parent
=self
)
1691 self
.tree
.setHeaderHidden(True)
1692 # pylint: disable=no-member
1693 self
.tree
.itemSelectionChanged
.connect(self
._tree
_selection
_changed
)
1695 self
.diffwidget
= DiffWidget(context
, self
, is_commit
=True)
1697 self
.add_button
= qtutils
.create_toolbutton(
1698 text
=N_('Add'), icon
=icons
.add(), tooltip
=N_('Add patches (+)')
1701 self
.remove_button
= qtutils
.create_toolbutton(
1703 icon
=icons
.remove(),
1704 tooltip
=N_('Remove selected (Delete)'),
1707 self
.apply_button
= qtutils
.create_button(text
=N_('Apply'), icon
=icons
.ok())
1709 self
.close_button
= qtutils
.close_button()
1711 self
.add_action
= qtutils
.add_action(
1712 self
, N_('Add'), self
.add_files
, hotkeys
.ADD_ITEM
1715 self
.remove_action
= qtutils
.add_action(
1718 self
.tree
.remove_selected
,
1721 hotkeys
.REMOVE_ITEM
,
1724 self
.top_layout
= qtutils
.hbox(
1726 defs
.button_spacing
,
1733 self
.bottom_layout
= qtutils
.hbox(
1735 defs
.button_spacing
,
1741 self
.splitter
= qtutils
.splitter(Qt
.Vertical
, self
.tree
, self
.diffwidget
)
1743 self
.main_layout
= qtutils
.vbox(
1750 self
.setLayout(self
.main_layout
)
1752 qtutils
.connect_button(self
.add_button
, self
.add_files
)
1753 qtutils
.connect_button(self
.remove_button
, self
.tree
.remove_selected
)
1754 qtutils
.connect_button(self
.apply_button
, self
.apply_patches
)
1755 qtutils
.connect_button(self
.close_button
, self
.close
)
1757 self
.init_state(None, self
.resize
, 666, 420)
1759 def apply_patches(self
):
1760 items
= self
.tree
.items()
1763 context
= self
.context
1764 patches
= [i
.data(0, Qt
.UserRole
) for i
in items
]
1765 cmds
.do(cmds
.ApplyPatches
, context
, patches
)
1768 def add_files(self
):
1769 files
= qtutils
.open_files(
1770 N_('Select patch file(s)...'),
1771 directory
=self
.curdir
,
1772 filters
='Patches (*.patch *.mbox)',
1776 self
.curdir
= os
.path
.dirname(files
[0])
1777 self
.add_paths([core
.relpath(f
) for f
in files
])
1779 def dragEnterEvent(self
, event
):
1780 """Accepts drops if the mimedata contains patches"""
1781 super().dragEnterEvent(event
)
1782 patches
= get_patches_from_mimedata(event
.mimeData())
1784 event
.acceptProposedAction()
1786 def dropEvent(self
, event
):
1787 """Add dropped patches"""
1789 patches
= get_patches_from_mimedata(event
.mimeData())
1792 self
.add_paths(patches
)
1794 def add_paths(self
, paths
):
1795 self
.tree
.add_paths(paths
)
1797 def _tree_selection_changed(self
):
1798 items
= self
.tree
.selected_items()
1801 item
= items
[-1] # take the last item
1802 path
= item
.data(0, Qt
.UserRole
)
1803 if not core
.exists(path
):
1805 commit
= parse_patch(path
)
1806 self
.diffwidget
.set_details(
1807 commit
.oid
, commit
.author
, commit
.email
, commit
.date
, commit
.summary
1809 self
.diffwidget
.set_diff(commit
.diff
)
1811 def export_state(self
):
1812 """Export persistent settings"""
1813 state
= super().export_state()
1814 state
['sizes'] = get(self
.splitter
)
1817 def apply_state(self
, state
):
1818 """Apply persistent settings"""
1819 result
= super().apply_state(state
)
1821 self
.splitter
.setSizes(state
['sizes'])
1822 except (AttributeError, KeyError, ValueError, TypeError):
1827 # pylint: disable=too-many-ancestors
1828 class PatchTreeWidget(standard
.DraggableTreeWidget
):
1829 def add_paths(self
, paths
):
1830 patches
= get_patches_from_paths(paths
)
1834 icon
= icons
.file_text()
1835 for patch
in patches
:
1836 item
= QtWidgets
.QTreeWidgetItem()
1837 flags
= item
.flags() & ~Qt
.ItemIsDropEnabled
1838 item
.setFlags(flags
)
1839 item
.setIcon(0, icon
)
1840 item
.setText(0, os
.path
.basename(patch
))
1841 item
.setData(0, Qt
.UserRole
, patch
)
1842 item
.setToolTip(0, patch
)
1844 self
.addTopLevelItems(items
)
1846 def remove_selected(self
):
1847 idxs
= self
.selectedIndexes()
1848 rows
= [idx
.row() for idx
in idxs
]
1849 for row
in reversed(sorted(rows
)):
1850 self
.invisibleRootItem().takeChild(row
)
1854 """Container for commit details"""
1866 def parse_patch(path
):
1867 content
= core
.read(path
)
1869 parse(content
, commit
)
1873 def parse(content
, commit
):
1874 """Parse commit details from a patch"""
1875 from_rgx
= re
.compile(r
'^From (?P<oid>[a-f0-9]{40}) .*$')
1876 author_rgx
= re
.compile(r
'^From: (?P<author>[^<]+) <(?P<email>[^>]+)>$')
1877 date_rgx
= re
.compile(r
'^Date: (?P<date>.*)$')
1878 subject_rgx
= re
.compile(r
'^Subject: (?P<summary>.*)$')
1880 commit
.content
= content
1882 lines
= content
.splitlines()
1883 for idx
, line
in enumerate(lines
):
1884 match
= from_rgx
.match(line
)
1886 commit
.oid
= match
.group('oid')
1889 match
= author_rgx
.match(line
)
1891 commit
.author
= match
.group('author')
1892 commit
.email
= match
.group('email')
1895 match
= date_rgx
.match(line
)
1897 commit
.date
= match
.group('date')
1900 match
= subject_rgx
.match(line
)
1902 commit
.summary
= match
.group('summary')
1903 commit
.diff
= '\n'.join(lines
[idx
+ 1 :])