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 metrics
= QtGui
.QFontMetrics(font
)
226 width
= metrics
.width('M')
227 self
.setCursorWidth(width
)
229 def _cursor_changed(self
):
230 """Update the line number display when the cursor changes"""
231 line_number
= max(0, self
.textCursor().blockNumber())
232 if self
.numbers
is not None:
233 self
.numbers
.set_highlighted(line_number
)
235 def _selection_changed(self
):
236 """Respond to selection changes"""
237 selected
= bool(self
.selected_text())
238 self
.copy_diff_action
.setEnabled(selected
)
240 def resizeEvent(self
, event
):
241 super().resizeEvent(event
)
243 self
.numbers
.refresh_size()
245 def save_scrollbar(self
):
246 """Save the scrollbar value, but only on the first call"""
247 if self
.scrollvalue
is None:
248 scrollbar
= self
.verticalScrollBar()
250 scrollvalue
= get(scrollbar
)
253 self
.scrollvalue
= scrollvalue
255 def restore_scrollbar(self
):
256 """Restore the scrollbar and clear state"""
257 scrollbar
= self
.verticalScrollBar()
258 scrollvalue
= self
.scrollvalue
259 if scrollbar
and scrollvalue
is not None:
260 scrollbar
.setValue(scrollvalue
)
261 self
.scrollvalue
= None
263 def set_loading_message(self
):
264 """Add a pending loading message in the diff view"""
265 self
.hint
.set_value('+++ ' + N_('Loading...'))
268 def set_diff(self
, diff
):
269 """Set the diff text, but save the scrollbar"""
270 diff
= diff
.rstrip('\n') # diffs include two empty newlines
271 self
.save_scrollbar()
273 self
.hint
.set_value('')
275 self
.numbers
.set_diff(diff
)
278 self
.restore_scrollbar()
280 def selected_diff_stripped(self
):
281 """Return the selected diff stripped of any diff characters"""
282 sep
, selection
= self
.selected_text_lines()
283 return sep
.join(_strip_diff(line
) for line
in selection
)
286 """Copy the selected diff text stripped of any diff prefix characters"""
287 text
= self
.selected_diff_stripped()
288 qtutils
.set_clipboard(text
)
290 def selected_lines(self
):
291 """Return selected lines"""
292 cursor
= self
.textCursor()
293 selection_start
= cursor
.selectionStart()
294 selection_end
= max(selection_start
, cursor
.selectionEnd() - 1)
301 for line_idx
, line
in enumerate(get(self
, default
='').splitlines()):
302 line_end
= line_start
+ len(line
)
303 if line_start
<= selection_start
<= line_end
:
304 first_line_idx
= line_idx
305 if line_start
<= selection_end
<= line_end
:
306 last_line_idx
= line_idx
308 line_start
= line_end
+ 1
310 if first_line_idx
== -1:
311 first_line_idx
= line_idx
313 if last_line_idx
== -1:
314 last_line_idx
= line_idx
316 return first_line_idx
, last_line_idx
318 def selected_text_lines(self
):
319 """Return selected lines and the CRLF / LF separator"""
320 first_line_idx
, last_line_idx
= self
.selected_lines()
321 text
= get(self
, default
='')
324 for line_idx
, line
in enumerate(text
.split(sep
)):
325 if first_line_idx
<= line_idx
<= last_line_idx
:
331 """Return either CRLF or LF based on the content"""
339 def _strip_diff(value
):
340 """Remove +/-/<space> from a selection"""
341 if value
.startswith(('+', '-', ' ')):
346 class DiffLineNumbers(TextDecorator
):
347 def __init__(self
, context
, parent
):
348 TextDecorator
.__init
__(self
, parent
)
349 self
.highlight_line
= -1
351 self
.parser
= diffparse
.DiffLines()
352 self
.formatter
= diffparse
.FormatDigits()
354 self
.setFont(qtutils
.diff_font(context
))
355 self
._char
_width
= self
.fontMetrics().width('0')
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
= qtutils
.label(fmt
=Qt
.PlainText
)
484 self
.text
= DiffEditor(context
, options
, self
)
485 self
.image
= imageview
.ImageView(parent
=self
)
486 self
.image
.setFocusPolicy(Qt
.NoFocus
)
487 self
.search_widget
= TextSearchWidget(self
.text
, self
)
488 self
.search_widget
.hide()
489 self
._drag
_has
_patches
= False
491 self
.setAcceptDrops(True)
492 self
.setFocusProxy(self
.text
)
494 stack
= self
.stack
= QtWidgets
.QStackedWidget(self
)
495 stack
.addWidget(self
.text
)
496 stack
.addWidget(self
.image
)
498 self
.main_layout
= qtutils
.vbox(
504 self
.setLayout(self
.main_layout
)
507 model
.images_changed
.connect(self
.set_images
, type=Qt
.QueuedConnection
)
509 # Observe the diff type
510 model
.diff_type_changed
.connect(self
.set_diff_type
, type=Qt
.QueuedConnection
)
512 # Observe the file type
513 model
.file_type_changed
.connect(self
.set_file_type
, type=Qt
.QueuedConnection
)
515 # Observe the image mode combo box
516 options
.image_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
517 options
.zoom_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
519 self
.search_action
= qtutils
.add_action(
521 N_('Search in Diff'),
522 self
.show_search_diff
,
526 def dragEnterEvent(self
, event
):
527 """Accepts drops if the mimedata contains patches"""
528 super().dragEnterEvent(event
)
529 patches
= get_patches_from_mimedata(event
.mimeData())
531 event
.acceptProposedAction()
532 self
._drag
_has
_patches
= True
534 def dragLeaveEvent(self
, event
):
535 """End the drag+drop interaction"""
536 super().dragLeaveEvent(event
)
537 if self
._drag
_has
_patches
:
541 self
._drag
_has
_patches
= False
543 def dropEvent(self
, event
):
544 """Apply patches when dropped onto the widget"""
545 if not self
._drag
_has
_patches
:
548 event
.setDropAction(Qt
.CopyAction
)
549 super().dropEvent(event
)
550 self
._drag
_has
_patches
= False
552 patches
= get_patches_from_mimedata(event
.mimeData())
554 apply_patches(self
.context
, patches
=patches
)
556 event
.accept() # must be called after dropEvent()
558 def show_search_diff(self
):
559 """Show a dialog for searching diffs"""
560 # The diff search is only active in text mode.
561 if self
.stack
.currentIndex() != self
.INDEX_TEXT
:
563 if not self
.search_widget
.isVisible():
564 self
.search_widget
.show()
565 self
.search_widget
.setFocus()
567 def export_state(self
, state
):
568 state
['show_diff_line_numbers'] = self
.options
.show_line_numbers
.isChecked()
569 state
['show_diff_filenames'] = self
.options
.show_filenames
.isChecked()
570 state
['image_diff_mode'] = self
.options
.image_mode
.currentIndex()
571 state
['image_zoom_mode'] = self
.options
.zoom_mode
.currentIndex()
572 state
['word_wrap'] = self
.options
.enable_word_wrapping
.isChecked()
575 def apply_state(self
, state
):
576 diff_numbers
= bool(state
.get('show_diff_line_numbers', False))
577 self
.set_line_numbers(diff_numbers
, update
=True)
579 show_filenames
= bool(state
.get('show_diff_filenames', True))
580 self
.set_show_filenames(show_filenames
, update
=True)
582 image_mode
= utils
.asint(state
.get('image_diff_mode', 0))
583 self
.options
.image_mode
.set_index(image_mode
)
585 zoom_mode
= utils
.asint(state
.get('image_zoom_mode', 0))
586 self
.options
.zoom_mode
.set_index(zoom_mode
)
588 word_wrap
= bool(state
.get('word_wrap', True))
589 self
.set_word_wrapping(word_wrap
, update
=True)
592 def set_diff_type(self
, diff_type
):
593 """Manage the image and text diff views when selection changes"""
594 # The "diff type" is whether the diff viewer is displaying an image.
595 self
.options
.set_diff_type(diff_type
)
596 if diff_type
== main
.Types
.IMAGE
:
597 self
.stack
.setCurrentWidget(self
.image
)
598 self
.search_widget
.hide()
601 self
.stack
.setCurrentWidget(self
.text
)
603 def set_file_type(self
, file_type
):
604 """Manage the diff options when the file type changes"""
605 # The "file type" is whether the file itself is an image.
606 self
.options
.set_file_type(file_type
)
608 def enable_filename_tracking(self
):
609 """Enable displaying the currently selected filename"""
610 self
.context
.selection
.selection_changed
.connect(
611 self
.update_filename
, type=Qt
.QueuedConnection
614 def update_filename(self
):
615 """Update the filename display when the selection changes"""
616 filename
= self
.context
.selection
.filename()
617 self
.filename
.set_text(filename
or '')
619 def update_options(self
):
620 """Emit a signal indicating that options have changed"""
621 self
.text
.update_options()
622 show_filenames
= get(self
.options
.show_filenames
)
623 self
.set_show_filenames(show_filenames
)
625 def set_show_filenames(self
, enabled
, update
=False):
626 """Enable/disable displaying the selected filename"""
627 self
.filename
.setVisible(enabled
)
629 with qtutils
.BlockSignals(self
.options
.show_filenames
):
630 self
.options
.show_filenames
.setChecked(enabled
)
632 def set_line_numbers(self
, enabled
, update
=False):
633 """Enable/disable line numbers in the text widget"""
634 self
.text
.set_line_numbers(enabled
, update
=update
)
636 def set_word_wrapping(self
, enabled
, update
=False):
637 """Enable/disable word wrapping in the text widget"""
638 self
.text
.set_word_wrapping(enabled
, update
=update
)
641 self
.image
.pixmap
= QtGui
.QPixmap()
645 for image
, unlink
in self
.images
:
646 if unlink
and core
.exists(image
):
650 def set_images(self
, images
):
657 # In order to comp, we first have to load all the images
658 all_pixmaps
= [QtGui
.QPixmap(image
[0]) for image
in images
]
659 pixmaps
= [pixmap
for pixmap
in all_pixmaps
if not pixmap
.isNull()]
664 self
.pixmaps
= pixmaps
672 mode
= self
.options
.image_mode
.currentIndex()
673 if mode
== self
.options
.SIDE_BY_SIDE
:
674 image
= self
.render_side_by_side()
675 elif mode
== self
.options
.DIFF
:
676 image
= self
.render_diff()
677 elif mode
== self
.options
.XOR
:
678 image
= self
.render_xor()
679 elif mode
== self
.options
.PIXEL_XOR
:
680 image
= self
.render_pixel_xor()
682 image
= self
.render_side_by_side()
684 image
= QtGui
.QPixmap()
685 self
.image
.pixmap
= image
688 zoom_mode
= self
.options
.zoom_mode
.currentIndex()
689 zoom_factor
= self
.options
.zoom_factors
[zoom_mode
][1]
690 if zoom_factor
> 0.0:
691 self
.image
.resetTransform()
692 self
.image
.scale(zoom_factor
, zoom_factor
)
693 poly
= self
.image
.mapToScene(self
.image
.viewport().rect())
694 self
.image
.last_scene_roi
= poly
.boundingRect()
696 def render_side_by_side(self
):
697 # Side-by-side lineup comp
698 pixmaps
= self
.pixmaps
699 width
= sum(pixmap
.width() for pixmap
in pixmaps
)
700 height
= max(pixmap
.height() for pixmap
in pixmaps
)
701 image
= create_image(width
, height
)
704 painter
= create_painter(image
)
706 for pixmap
in pixmaps
:
707 painter
.drawPixmap(x
, 0, pixmap
)
713 def render_comp(self
, comp_mode
):
714 # Get the max size to use as the render canvas
715 pixmaps
= self
.pixmaps
716 if len(pixmaps
) == 1:
719 width
= max(pixmap
.width() for pixmap
in pixmaps
)
720 height
= max(pixmap
.height() for pixmap
in pixmaps
)
721 image
= create_image(width
, height
)
723 painter
= create_painter(image
)
724 for pixmap
in (pixmaps
[0], pixmaps
[-1]):
725 x
= (width
- pixmap
.width()) // 2
726 y
= (height
- pixmap
.height()) // 2
727 painter
.drawPixmap(x
, y
, pixmap
)
728 painter
.setCompositionMode(comp_mode
)
733 def render_diff(self
):
734 comp_mode
= QtGui
.QPainter
.CompositionMode_Difference
735 return self
.render_comp(comp_mode
)
737 def render_xor(self
):
738 comp_mode
= QtGui
.QPainter
.CompositionMode_Xor
739 return self
.render_comp(comp_mode
)
741 def render_pixel_xor(self
):
742 comp_mode
= QtGui
.QPainter
.RasterOp_SourceXorDestination
743 return self
.render_comp(comp_mode
)
746 def create_image(width
, height
):
747 size
= QtCore
.QSize(width
, height
)
748 image
= QtGui
.QImage(size
, QtGui
.QImage
.Format_ARGB32_Premultiplied
)
749 image
.fill(Qt
.transparent
)
753 def create_painter(image
):
754 painter
= QtGui
.QPainter(image
)
755 painter
.fillRect(image
.rect(), Qt
.transparent
)
759 class Options(QtWidgets
.QWidget
):
760 """Provide the options widget used by the editor
762 Actions are registered on the parent widget.
766 # mode combobox indexes
772 def __init__(self
, parent
):
773 super().__init
__(parent
)
776 self
.ignore_space_at_eol
= self
.add_option(
777 N_('Ignore changes in whitespace at EOL')
779 self
.ignore_space_change
= self
.add_option(
780 N_('Ignore changes in amount of whitespace')
782 self
.ignore_all_space
= self
.add_option(N_('Ignore all whitespace'))
783 self
.function_context
= self
.add_option(
784 N_('Show whole surrounding functions of changes')
786 self
.show_line_numbers
= qtutils
.add_action_bool(
787 self
, N_('Show line numbers'), self
.set_line_numbers
, True
789 self
.show_filenames
= self
.add_option(N_('Show filenames'))
790 self
.enable_word_wrapping
= qtutils
.add_action_bool(
791 self
, N_('Enable word wrapping'), self
.set_word_wrapping
, True
794 self
.options
= qtutils
.create_action_button(
795 tooltip
=N_('Diff Options'), icon
=icons
.configure()
798 self
.toggle_image_diff
= qtutils
.create_action_button(
799 tooltip
=N_('Toggle image diff'), icon
=icons
.visualize()
801 self
.toggle_image_diff
.hide()
803 self
.image_mode
= qtutils
.combo(
804 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
807 self
.zoom_factors
= (
808 (N_('Zoom to Fit'), 0.0),
816 zoom_modes
= [factor
[0] for factor
in self
.zoom_factors
]
817 self
.zoom_mode
= qtutils
.combo(zoom_modes
, parent
=self
)
819 self
.menu
= menu
= qtutils
.create_menu(N_('Diff Options'), self
.options
)
820 self
.options
.setMenu(menu
)
821 menu
.addAction(self
.ignore_space_at_eol
)
822 menu
.addAction(self
.ignore_space_change
)
823 menu
.addAction(self
.ignore_all_space
)
825 menu
.addAction(self
.function_context
)
826 menu
.addAction(self
.show_line_numbers
)
827 menu
.addAction(self
.show_filenames
)
829 menu
.addAction(self
.enable_word_wrapping
)
832 layout
= qtutils
.hbox(
838 self
.toggle_image_diff
,
840 self
.setLayout(layout
)
843 self
.image_mode
.setFocusPolicy(Qt
.NoFocus
)
844 self
.zoom_mode
.setFocusPolicy(Qt
.NoFocus
)
845 self
.options
.setFocusPolicy(Qt
.NoFocus
)
846 self
.toggle_image_diff
.setFocusPolicy(Qt
.NoFocus
)
847 self
.setFocusPolicy(Qt
.NoFocus
)
849 def set_file_type(self
, file_type
):
850 """Set whether we are viewing an image file type"""
851 is_image
= file_type
== main
.Types
.IMAGE
852 self
.toggle_image_diff
.setVisible(is_image
)
854 def set_diff_type(self
, diff_type
):
855 """Toggle between image and text diffs"""
856 is_text
= diff_type
== main
.Types
.TEXT
857 is_image
= diff_type
== main
.Types
.IMAGE
858 self
.options
.setVisible(is_text
)
859 self
.image_mode
.setVisible(is_image
)
860 self
.zoom_mode
.setVisible(is_image
)
862 self
.toggle_image_diff
.setIcon(icons
.diff())
864 self
.toggle_image_diff
.setIcon(icons
.visualize())
866 def add_option(self
, title
):
867 """Add a diff option which calls update_options() on change"""
868 action
= qtutils
.add_action(self
, title
, self
.update_options
)
869 action
.setCheckable(True)
872 def update_options(self
):
873 """Update diff options in response to UI events"""
874 space_at_eol
= get(self
.ignore_space_at_eol
)
875 space_change
= get(self
.ignore_space_change
)
876 all_space
= get(self
.ignore_all_space
)
877 function_context
= get(self
.function_context
)
878 gitcmds
.update_diff_overrides(
879 space_at_eol
, space_change
, all_space
, function_context
881 self
.widget
.update_options()
883 def set_line_numbers(self
, value
):
884 """Enable / disable line numbers"""
885 self
.widget
.set_line_numbers(value
, update
=False)
887 def set_word_wrapping(self
, value
):
888 """Respond to Qt action callbacks"""
889 self
.widget
.set_word_wrapping(value
, update
=False)
891 def hide_advanced_options(self
):
892 """Hide advanced options that are not applicable to the DiffWidget"""
893 self
.show_filenames
.setVisible(False)
894 self
.show_line_numbers
.setVisible(False)
895 self
.ignore_space_at_eol
.setVisible(False)
896 self
.ignore_space_change
.setVisible(False)
897 self
.ignore_all_space
.setVisible(False)
898 self
.function_context
.setVisible(False)
901 # pylint: disable=too-many-ancestors
902 class DiffEditor(DiffTextEdit
):
905 options_changed
= Signal()
907 def __init__(self
, context
, options
, parent
):
908 DiffTextEdit
.__init
__(self
, context
, parent
, numbers
=True)
909 self
.context
= context
910 self
.model
= model
= context
.model
911 self
.selection_model
= selection_model
= context
.selection
913 # "Diff Options" tool menu
914 self
.options
= options
916 self
.action_apply_selection
= qtutils
.add_action(
919 self
.apply_selection
,
921 hotkeys
.STAGE_DIFF_ALT
,
924 self
.action_revert_selection
= qtutils
.add_action(
925 self
, 'Revert', self
.revert_selection
, hotkeys
.REVERT
, hotkeys
.REVERT_ALT
927 self
.action_revert_selection
.setIcon(icons
.undo())
929 self
.action_edit_and_apply_selection
= qtutils
.add_action(
932 partial(self
.apply_selection
, edit
=True),
933 hotkeys
.EDIT_AND_STAGE_DIFF
,
936 self
.action_edit_and_revert_selection
= qtutils
.add_action(
939 partial(self
.revert_selection
, edit
=True),
940 hotkeys
.EDIT_AND_REVERT
,
942 self
.action_edit_and_revert_selection
.setIcon(icons
.undo())
943 self
.launch_editor
= actions
.launch_editor_at_line(
944 context
, self
, hotkeys
.EDIT_SHORT
, *hotkeys
.ACCEPT
946 self
.launch_difftool
= actions
.launch_difftool(context
, self
)
947 self
.stage_or_unstage
= actions
.stage_or_unstage(context
, self
)
949 # Emit up/down signals so that they can be routed by the main widget
950 self
.move_up
= actions
.move_up(self
)
951 self
.move_down
= actions
.move_down(self
)
953 model
.diff_text_updated
.connect(self
.set_diff
, type=Qt
.QueuedConnection
)
955 selection_model
.selection_changed
.connect(
956 self
.refresh
, type=Qt
.QueuedConnection
958 # Update the selection model when the cursor changes
959 self
.cursorPositionChanged
.connect(self
._update
_line
_number
)
961 qtutils
.connect_button(options
.toggle_image_diff
, self
.toggle_diff_type
)
963 def toggle_diff_type(self
):
964 cmds
.do(cmds
.ToggleDiffType
, self
.context
)
968 s
= self
.selection_model
.selection()
970 if model
.is_partially_stageable():
971 item
= s
.modified
[0] if s
.modified
else None
972 if item
in model
.submodules
:
974 elif item
not in model
.unstaged_deleted
:
976 self
.action_revert_selection
.setEnabled(enabled
)
978 def set_line_numbers(self
, enabled
, update
=False):
979 """Enable/disable the diff line number display"""
980 self
.numbers
.setVisible(enabled
)
982 with qtutils
.BlockSignals(self
.options
.show_line_numbers
):
983 self
.options
.show_line_numbers
.setChecked(enabled
)
984 # Refresh the display. Not doing this results in the display not
985 # correctly displaying the line numbers widget until the text scrolls.
986 self
.set_value(self
.value())
988 def update_options(self
):
989 self
.options_changed
.emit()
991 def create_context_menu(self
, event_pos
):
992 """Override create_context_menu() to display a completely custom menu"""
993 menu
= super().create_context_menu(event_pos
)
994 context
= self
.context
996 s
= self
.selection_model
.selection()
997 filename
= self
.selection_model
.filename()
999 # These menu actions will be inserted at the start of the widget.
1000 current_actions
= menu
.actions()
1002 add_action
= menu_actions
.append
1003 edit_actions_added
= False
1004 stage_action_added
= False
1006 if s
.staged
and model
.is_unstageable():
1008 if item
not in model
.submodules
and item
not in model
.staged_deleted
:
1009 if self
.has_selection():
1010 apply_text
= N_('Unstage Selected Lines')
1012 apply_text
= N_('Unstage Diff Hunk')
1013 self
.action_apply_selection
.setText(apply_text
)
1014 self
.action_apply_selection
.setIcon(icons
.remove())
1015 add_action(self
.action_apply_selection
)
1016 stage_action_added
= self
._add
_stage
_or
_unstage
_action
(
1017 menu
, add_action
, stage_action_added
1020 if model
.is_partially_stageable():
1021 item
= s
.modified
[0] if s
.modified
else None
1022 if item
in model
.submodules
:
1023 path
= core
.abspath(item
)
1024 action
= qtutils
.add_action_with_icon(
1028 cmds
.run(cmds
.Stage
, context
, s
.modified
),
1029 hotkeys
.STAGE_SELECTION
,
1032 stage_action_added
= self
._add
_stage
_or
_unstage
_action
(
1033 menu
, add_action
, stage_action_added
1036 action
= qtutils
.add_action_with_icon(
1039 N_('Launch git-cola'),
1040 cmds
.run(cmds
.OpenRepo
, context
, path
),
1043 elif item
and item
not in model
.unstaged_deleted
:
1044 if self
.has_selection():
1045 apply_text
= N_('Stage Selected Lines')
1046 edit_and_apply_text
= N_('Edit Selected Lines to Stage...')
1047 revert_text
= N_('Revert Selected Lines...')
1048 edit_and_revert_text
= N_('Edit Selected Lines to Revert...')
1050 apply_text
= N_('Stage Diff Hunk')
1051 edit_and_apply_text
= N_('Edit Diff Hunk to Stage...')
1052 revert_text
= N_('Revert Diff Hunk...')
1053 edit_and_revert_text
= N_('Edit Diff Hunk to Revert...')
1055 self
.action_apply_selection
.setText(apply_text
)
1056 self
.action_apply_selection
.setIcon(icons
.add())
1057 add_action(self
.action_apply_selection
)
1059 self
.action_revert_selection
.setText(revert_text
)
1060 add_action(self
.action_revert_selection
)
1062 stage_action_added
= self
._add
_stage
_or
_unstage
_action
(
1063 menu
, add_action
, stage_action_added
1065 # Do not show the "edit" action when the file does not exist.
1066 add_action(qtutils
.menu_separator(menu
))
1067 if filename
and core
.exists(filename
):
1068 add_action(self
.launch_editor
)
1069 # Removed files can still be diffed.
1070 add_action(self
.launch_difftool
)
1071 edit_actions_added
= True
1073 add_action(qtutils
.menu_separator(menu
))
1074 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
1075 self
.action_edit_and_apply_selection
.setIcon(icons
.add())
1076 add_action(self
.action_edit_and_apply_selection
)
1078 self
.action_edit_and_revert_selection
.setText(edit_and_revert_text
)
1079 add_action(self
.action_edit_and_revert_selection
)
1081 if s
.staged
and model
.is_unstageable():
1083 if item
in model
.submodules
:
1084 path
= core
.abspath(item
)
1085 action
= qtutils
.add_action_with_icon(
1088 cmds
.Unstage
.name(),
1089 cmds
.run(cmds
.Unstage
, context
, s
.staged
),
1090 hotkeys
.STAGE_SELECTION
,
1094 stage_action_added
= self
._add
_stage
_or
_unstage
_action
(
1095 menu
, add_action
, stage_action_added
1098 qtutils
.add_action_with_icon(
1101 N_('Launch git-cola'),
1102 cmds
.run(cmds
.OpenRepo
, context
, path
),
1106 elif item
not in model
.staged_deleted
:
1107 # Do not show the "edit" action when the file does not exist.
1108 add_action(qtutils
.menu_separator(menu
))
1109 if filename
and core
.exists(filename
):
1110 add_action(self
.launch_editor
)
1111 # Removed files can still be diffed.
1112 add_action(self
.launch_difftool
)
1113 add_action(qtutils
.menu_separator(menu
))
1114 edit_actions_added
= True
1116 if self
.has_selection():
1117 edit_and_apply_text
= N_('Edit Selected Lines to Unstage...')
1119 edit_and_apply_text
= N_('Edit Diff Hunk to Unstage...')
1120 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
1121 self
.action_edit_and_apply_selection
.setIcon(icons
.remove())
1122 add_action(self
.action_edit_and_apply_selection
)
1124 if not edit_actions_added
and (model
.is_stageable() or model
.is_unstageable()):
1125 add_action(qtutils
.menu_separator(menu
))
1126 # Do not show the "edit" action when the file does not exist.
1127 # Untracked files exist by definition.
1128 if filename
and core
.exists(filename
):
1129 add_action(self
.launch_editor
)
1131 # Removed files can still be diffed.
1132 add_action(self
.launch_difftool
)
1134 add_action(qtutils
.menu_separator(menu
))
1135 _add_patch_actions(self
, self
.context
, menu
)
1137 # Add the Previous/Next File actions, which improves discoverability
1138 # of their associated shortcuts
1139 add_action(qtutils
.menu_separator(menu
))
1140 add_action(self
.move_up
)
1141 add_action(self
.move_down
)
1142 add_action(qtutils
.menu_separator(menu
))
1145 first_action
= current_actions
[0]
1148 menu
.insertActions(first_action
, menu_actions
)
1152 def _add_stage_or_unstage_action(self
, menu
, add_action
, already_added
):
1153 """Add the Stage / Unstage menu action"""
1156 model
= self
.context
.model
1157 s
= self
.selection_model
.selection()
1158 if model
.is_stageable() or model
.is_unstageable():
1159 if (model
.is_amend_mode() and s
.staged
) or not self
.model
.is_stageable():
1160 self
.stage_or_unstage
.setText(N_('Unstage'))
1161 self
.stage_or_unstage
.setIcon(icons
.remove())
1163 self
.stage_or_unstage
.setText(N_('Stage'))
1164 self
.stage_or_unstage
.setIcon(icons
.add())
1165 add_action(qtutils
.menu_separator(menu
))
1166 add_action(self
.stage_or_unstage
)
1169 def mousePressEvent(self
, event
):
1170 if event
.button() == Qt
.RightButton
:
1171 # Intercept right-click to move the cursor to the current position.
1172 # setTextCursor() clears the selection so this is only done when
1173 # nothing is selected.
1174 if not self
.has_selection():
1175 cursor
= self
.cursorForPosition(event
.pos())
1176 self
.setTextCursor(cursor
)
1178 return super().mousePressEvent(event
)
1180 def setPlainText(self
, text
):
1181 """setPlainText(str) while retaining scrollbar positions"""
1184 highlight
= mode
not in (
1187 model
.mode_untracked
,
1189 self
.highlighter
.set_enabled(highlight
)
1191 scrollbar
= self
.verticalScrollBar()
1193 scrollvalue
= get(scrollbar
)
1200 DiffTextEdit
.setPlainText(self
, text
)
1202 if scrollbar
and scrollvalue
is not None:
1203 scrollbar
.setValue(scrollvalue
)
1205 def apply_selection(self
, *, edit
=False):
1207 s
= self
.selection_model
.single_selection()
1208 if model
.is_partially_stageable() and (s
.modified
or s
.untracked
):
1209 self
.process_diff_selection(edit
=edit
)
1210 elif model
.is_unstageable():
1211 self
.process_diff_selection(reverse
=True, edit
=edit
)
1213 def revert_selection(self
, *, edit
=False):
1214 """Destructively revert selected lines or hunk from a worktree file."""
1217 if self
.has_selection():
1218 title
= N_('Revert Selected Lines?')
1219 ok_text
= N_('Revert Selected Lines')
1221 title
= N_('Revert Diff Hunk?')
1222 ok_text
= N_('Revert Diff Hunk')
1224 if not Interaction
.confirm(
1227 'This operation drops uncommitted changes.\n'
1228 'These changes cannot be recovered.'
1230 N_('Revert the uncommitted changes?'),
1236 self
.process_diff_selection(reverse
=True, apply_to_worktree
=True, edit
=edit
)
1238 def extract_patch(self
, reverse
=False):
1239 first_line_idx
, last_line_idx
= self
.selected_lines()
1240 patch
= diffparse
.Patch
.parse(self
.model
.filename
, self
.model
.diff_text
)
1241 if self
.has_selection():
1242 return patch
.extract_subset(first_line_idx
, last_line_idx
, reverse
=reverse
)
1243 return patch
.extract_hunk(first_line_idx
, reverse
=reverse
)
1245 def patch_encoding(self
):
1246 if isinstance(self
.model
.diff_text
, core
.UStr
):
1247 # original encoding must prevail
1248 return self
.model
.diff_text
.encoding
1249 return self
.context
.cfg
.file_encoding(self
.model
.filename
)
1251 def process_diff_selection(
1252 self
, reverse
=False, apply_to_worktree
=False, edit
=False
1254 """Implement un/staging of the selected line(s) or hunk."""
1255 if self
.selection_model
.is_empty():
1257 patch
= self
.extract_patch(reverse
)
1258 if not patch
.has_changes():
1260 patch_encoding
= self
.patch_encoding()
1268 apply_to_worktree
=apply_to_worktree
,
1270 if not patch
.has_changes():
1281 def _update_line_number(self
):
1282 """Update the selection model when the cursor changes"""
1283 self
.selection_model
.line_number
= self
.numbers
.current_line()
1286 def _add_patch_actions(widget
, context
, menu
):
1287 """Add actions for manipulating patch files"""
1288 patches_menu
= menu
.addMenu(N_('Patches'))
1289 patches_menu
.setIcon(icons
.diff())
1290 export_action
= qtutils
.add_action(
1293 lambda: _export_patch(widget
, context
),
1295 export_action
.setIcon(icons
.save())
1296 patches_menu
.addAction(export_action
)
1298 # Build the "Append Patch" menu dynamically.
1299 append_menu
= patches_menu
.addMenu(N_('Append Patch'))
1300 append_menu
.setIcon(icons
.add())
1301 append_menu
.aboutToShow
.connect(
1302 lambda: _build_patch_append_menu(widget
, context
, append_menu
)
1306 def _build_patch_append_menu(widget
, context
, menu
):
1307 """Build the "Append Patch" submenu"""
1308 # Build the menu when first displayed only. This initial check avoids
1309 # re-populating the menu with duplicate actions.
1310 menu_actions
= menu
.actions()
1314 choose_patch_action
= qtutils
.add_action(
1316 N_('Choose Patch...'),
1317 lambda: _export_patch(widget
, context
, append
=True),
1319 choose_patch_action
.setIcon(icons
.diff())
1320 menu
.addAction(choose_patch_action
)
1323 path
= prefs
.patches_directory(context
)
1324 patches
= get_patches_from_dir(path
)
1325 for patch
in patches
:
1326 relpath
= os
.path
.relpath(patch
, start
=path
)
1327 sub_menu
= _add_patch_subdirs(menu
, subdir_menus
, relpath
)
1328 patch_basename
= os
.path
.basename(relpath
)
1329 append_action
= qtutils
.add_action(
1332 lambda patch_file
=patch
: _append_patch(widget
, patch_file
),
1334 append_action
.setIcon(icons
.save())
1335 sub_menu
.addAction(append_action
)
1338 def _add_patch_subdirs(menu
, subdir_menus
, relpath
):
1339 """Build menu leading up to the patch"""
1340 # If the path contains no directory separators then add it to the
1342 if os
.sep
not in relpath
:
1345 # Loop over each directory component and build a menu if it doesn't already exist.
1347 for dirname
in os
.path
.dirname(relpath
).split(os
.sep
):
1348 components
.append(dirname
)
1349 current_dir
= os
.sep
.join(components
)
1351 menu
= subdir_menus
[current_dir
]
1353 menu
= subdir_menus
[current_dir
] = menu
.addMenu(dirname
)
1354 menu
.setIcon(icons
.folder())
1359 def _export_patch(diff_editor
, context
, append
=False):
1360 """Export the selected diff to a patch file"""
1361 if diff_editor
.selection_model
.is_empty():
1363 patch
= diff_editor
.extract_patch(reverse
=False)
1364 if not patch
.has_changes():
1366 directory
= prefs
.patches_directory(context
)
1368 filename
= qtutils
.existing_file(directory
, title
=N_('Append Patch...'))
1370 default_filename
= os
.path
.join(directory
, 'diff.patch')
1371 filename
= qtutils
.save_as(default_filename
)
1374 _write_patch_to_file(diff_editor
, patch
, filename
, append
=append
)
1377 def _append_patch(diff_editor
, filename
):
1378 """Append diffs to the specified patch file"""
1379 if diff_editor
.selection_model
.is_empty():
1381 patch
= diff_editor
.extract_patch(reverse
=False)
1382 if not patch
.has_changes():
1384 _write_patch_to_file(diff_editor
, patch
, filename
, append
=True)
1387 def _write_patch_to_file(diff_editor
, patch
, filename
, append
=False):
1388 """Write diffs from the Diff Editor to the specified patch file"""
1389 encoding
= diff_editor
.patch_encoding()
1390 content
= patch
.as_text()
1392 core
.write(filename
, content
, encoding
=encoding
, append
=append
)
1393 except OSError as exc
:
1394 _
, details
= utils
.format_exception(exc
)
1395 title
= N_('Error writing patch')
1396 msg
= N_('Unable to write patch to "%s". Check permissions?' % filename
)
1397 Interaction
.critical(title
, message
=msg
, details
=details
)
1399 Interaction
.log('Patch written to "%s"' % filename
)
1402 class DiffWidget(QtWidgets
.QWidget
):
1403 """Display commit metadata and text diffs"""
1405 def __init__(self
, context
, parent
, is_commit
=False, options
=None):
1406 QtWidgets
.QWidget
.__init
__(self
, parent
)
1408 self
.context
= context
1410 self
.oid_start
= None
1412 self
.options
= options
1414 author_font
= QtGui
.QFont(self
.font())
1415 author_font
.setPointSize(int(author_font
.pointSize() * 1.1))
1417 summary_font
= QtGui
.QFont(author_font
)
1418 summary_font
.setWeight(QtGui
.QFont
.Bold
)
1420 self
.gravatar_label
= gravatar
.GravatarLabel(self
.context
, parent
=self
)
1422 self
.oid_label
= PlainTextLabel(parent
=self
)
1423 self
.oid_label
.setAlignment(Qt
.AlignBottom
)
1424 self
.oid_label
.elide()
1426 self
.author_label
= RichTextLabel(parent
=self
)
1427 self
.author_label
.setFont(author_font
)
1428 self
.author_label
.setAlignment(Qt
.AlignTop
)
1429 self
.author_label
.elide()
1431 self
.date_label
= PlainTextLabel(parent
=self
)
1432 self
.date_label
.setAlignment(Qt
.AlignTop
)
1433 self
.date_label
.elide()
1435 self
.summary_label
= PlainTextLabel(parent
=self
)
1436 self
.summary_label
.setFont(summary_font
)
1437 self
.summary_label
.setAlignment(Qt
.AlignTop
)
1438 self
.summary_label
.elide()
1440 self
.diff
= DiffTextEdit(context
, self
, is_commit
=is_commit
, whitespace
=False)
1441 self
.setFocusProxy(self
.diff
)
1443 self
.info_layout
= qtutils
.vbox(
1452 self
.logo_layout
= qtutils
.hbox(
1453 defs
.no_margin
, defs
.button_spacing
, self
.gravatar_label
, self
.info_layout
1455 self
.logo_layout
.setContentsMargins(defs
.margin
, 0, defs
.margin
, 0)
1457 self
.main_layout
= qtutils
.vbox(
1458 defs
.no_margin
, defs
.spacing
, self
.logo_layout
, self
.diff
1460 self
.setLayout(self
.main_layout
)
1462 self
.set_tabwidth(prefs
.tabwidth(context
))
1464 def set_tabwidth(self
, width
):
1465 self
.diff
.set_tabwidth(width
)
1467 def set_word_wrapping(self
, enabled
, update
=False):
1468 """Enable and disable word wrapping"""
1469 self
.diff
.set_word_wrapping(enabled
, update
=update
)
1471 def set_options(self
, options
):
1472 """Register an options widget"""
1473 self
.options
= options
1474 self
.diff
.set_options(options
)
1476 def start_diff_task(self
, task
):
1477 """Clear the display and start a diff-gathering task"""
1478 self
.diff
.save_scrollbar()
1479 self
.diff
.set_loading_message()
1480 self
.context
.runtask
.start(task
, result
=self
.set_diff
)
1482 def set_diff_oid(self
, oid
, filename
=None):
1483 """Set the diff from a single commit object ID"""
1484 task
= DiffInfoTask(self
.context
, oid
, filename
)
1485 self
.start_diff_task(task
)
1487 def set_diff_range(self
, start
, end
, filename
=None):
1488 task
= DiffRangeTask(self
.context
, start
+ '~', end
, filename
)
1489 self
.start_diff_task(task
)
1491 def commits_selected(self
, commits
):
1492 """Display an appropriate diff when commits are selected"""
1496 commit
= commits
[-1]
1498 author
= commit
.author
or ''
1499 email
= commit
.email
or ''
1500 date
= commit
.authdate
or ''
1501 summary
= commit
.summary
or ''
1502 self
.set_details(oid
, author
, email
, date
, summary
)
1505 if len(commits
) > 1:
1506 start
, end
= commits
[0], commits
[-1]
1507 self
.set_diff_range(start
.oid
, end
.oid
)
1508 self
.oid_start
= start
1511 self
.set_diff_oid(oid
)
1512 self
.oid_start
= None
1515 def set_diff(self
, diff
):
1516 """Set the diff text"""
1517 self
.diff
.set_diff(diff
)
1519 def set_details(self
, oid
, author
, email
, date
, summary
):
1520 template_args
= {'author': author
, 'email': email
, 'summary': summary
}
1522 """%(author)s <"""
1523 """<a href="mailto:%(email)s">"""
1524 """%(email)s</a>>""" % template_args
1526 author_template
= '%(author)s <%(email)s>' % template_args
1528 self
.date_label
.set_text(date
)
1529 self
.date_label
.setVisible(bool(date
))
1530 self
.oid_label
.set_text(oid
)
1531 self
.author_label
.set_template(author_text
, author_template
)
1532 self
.summary_label
.set_text(summary
)
1533 self
.gravatar_label
.set_email(email
)
1536 self
.date_label
.set_text('')
1537 self
.oid_label
.set_text('')
1538 self
.author_label
.set_text('')
1539 self
.summary_label
.set_text('')
1540 self
.gravatar_label
.clear()
1543 def files_selected(self
, filenames
):
1544 """Update the view when a filename is selected"""
1547 oid_start
= self
.oid_start
1548 oid_end
= self
.oid_end
1549 if oid_start
and oid_end
:
1550 self
.set_diff_range(oid_start
.oid
, oid_end
.oid
, filename
=filenames
[0])
1552 self
.set_diff_oid(self
.oid
, filename
=filenames
[0])
1555 class DiffPanel(QtWidgets
.QWidget
):
1556 """A combined diff + search panel"""
1558 def __init__(self
, diff_widget
, text_widget
, parent
):
1559 super().__init
__(parent
)
1560 self
.diff_widget
= diff_widget
1561 self
.search_widget
= TextSearchWidget(text_widget
, self
)
1562 self
.search_widget
.hide()
1563 layout
= qtutils
.vbox(
1564 defs
.no_margin
, defs
.spacing
, self
.diff_widget
, self
.search_widget
1566 self
.setLayout(layout
)
1567 self
.setFocusProxy(self
.diff_widget
)
1569 self
.search_action
= qtutils
.add_action(
1571 N_('Search in Diff'),
1576 def show_search(self
):
1577 """Show a dialog for searching diffs"""
1578 # The diff search is only active in text mode.
1579 if not self
.search_widget
.isVisible():
1580 self
.search_widget
.show()
1581 self
.search_widget
.setFocus()
1584 class DiffInfoTask(qtutils
.Task
):
1585 """Gather diffs for a single commit"""
1587 def __init__(self
, context
, oid
, filename
):
1588 qtutils
.Task
.__init
__(self
)
1589 self
.context
= context
1591 self
.filename
= filename
1594 context
= self
.context
1596 return gitcmds
.diff_info(context
, oid
, filename
=self
.filename
)
1599 class DiffRangeTask(qtutils
.Task
):
1600 """Gather diffs for a range of commits"""
1602 def __init__(self
, context
, start
, end
, filename
):
1603 qtutils
.Task
.__init
__(self
)
1604 self
.context
= context
1607 self
.filename
= filename
1610 context
= self
.context
1611 return gitcmds
.diff_range(context
, self
.start
, self
.end
, filename
=self
.filename
)
1614 def apply_patches(context
, patches
=None):
1615 """Open the ApplyPatches dialog"""
1616 parent
= qtutils
.active_window()
1617 dlg
= new_apply_patches(context
, patches
=patches
, parent
=parent
)
1623 def new_apply_patches(context
, patches
=None, parent
=None):
1624 """Create a new instances of the ApplyPatches dialog"""
1625 dlg
= ApplyPatches(context
, parent
=parent
)
1627 dlg
.add_paths(patches
)
1631 def get_patches_from_paths(paths
):
1632 """Returns all patches benath a given path"""
1633 paths
= [core
.decode(p
) for p
in paths
]
1634 patches
= [p
for p
in paths
if core
.isfile(p
) and p
.endswith(('.patch', '.mbox'))]
1635 dirs
= [p
for p
in paths
if core
.isdir(p
)]
1638 patches
.extend(get_patches_from_dir(d
))
1642 def get_patches_from_mimedata(mimedata
):
1643 """Extract path files from a QMimeData payload"""
1644 urls
= mimedata
.urls()
1647 paths
= [x
.path() for x
in urls
]
1648 return get_patches_from_paths(paths
)
1651 def get_patches_from_dir(path
):
1652 """Find patches in a subdirectory"""
1654 for root
, _
, files
in core
.walk(path
):
1655 for name
in [f
for f
in files
if f
.endswith(('.patch', '.mbox'))]:
1656 patches
.append(core
.decode(os
.path
.join(root
, name
)))
1660 class ApplyPatches(standard
.Dialog
):
1661 def __init__(self
, context
, parent
=None):
1662 super().__init
__(parent
=parent
)
1663 self
.context
= context
1664 self
.setWindowTitle(N_('Apply Patches'))
1665 self
.setAcceptDrops(True)
1666 if parent
is not None:
1667 self
.setWindowModality(Qt
.WindowModal
)
1669 self
.curdir
= core
.getcwd()
1670 self
.inner_drag
= False
1672 self
.usage
= QtWidgets
.QLabel()
1677 Drag and drop or use the <strong>Add</strong> button to add
1684 self
.tree
= PatchTreeWidget(parent
=self
)
1685 self
.tree
.setHeaderHidden(True)
1686 # pylint: disable=no-member
1687 self
.tree
.itemSelectionChanged
.connect(self
._tree
_selection
_changed
)
1689 self
.diffwidget
= DiffWidget(context
, self
, is_commit
=True)
1691 self
.add_button
= qtutils
.create_toolbutton(
1692 text
=N_('Add'), icon
=icons
.add(), tooltip
=N_('Add patches (+)')
1695 self
.remove_button
= qtutils
.create_toolbutton(
1697 icon
=icons
.remove(),
1698 tooltip
=N_('Remove selected (Delete)'),
1701 self
.apply_button
= qtutils
.create_button(text
=N_('Apply'), icon
=icons
.ok())
1703 self
.close_button
= qtutils
.close_button()
1705 self
.add_action
= qtutils
.add_action(
1706 self
, N_('Add'), self
.add_files
, hotkeys
.ADD_ITEM
1709 self
.remove_action
= qtutils
.add_action(
1712 self
.tree
.remove_selected
,
1715 hotkeys
.REMOVE_ITEM
,
1718 self
.top_layout
= qtutils
.hbox(
1720 defs
.button_spacing
,
1727 self
.bottom_layout
= qtutils
.hbox(
1729 defs
.button_spacing
,
1735 self
.splitter
= qtutils
.splitter(Qt
.Vertical
, self
.tree
, self
.diffwidget
)
1737 self
.main_layout
= qtutils
.vbox(
1744 self
.setLayout(self
.main_layout
)
1746 qtutils
.connect_button(self
.add_button
, self
.add_files
)
1747 qtutils
.connect_button(self
.remove_button
, self
.tree
.remove_selected
)
1748 qtutils
.connect_button(self
.apply_button
, self
.apply_patches
)
1749 qtutils
.connect_button(self
.close_button
, self
.close
)
1751 self
.init_state(None, self
.resize
, 666, 420)
1753 def apply_patches(self
):
1754 items
= self
.tree
.items()
1757 context
= self
.context
1758 patches
= [i
.data(0, Qt
.UserRole
) for i
in items
]
1759 cmds
.do(cmds
.ApplyPatches
, context
, patches
)
1762 def add_files(self
):
1763 files
= qtutils
.open_files(
1764 N_('Select patch file(s)...'),
1765 directory
=self
.curdir
,
1766 filters
='Patches (*.patch *.mbox)',
1770 self
.curdir
= os
.path
.dirname(files
[0])
1771 self
.add_paths([core
.relpath(f
) for f
in files
])
1773 def dragEnterEvent(self
, event
):
1774 """Accepts drops if the mimedata contains patches"""
1775 super().dragEnterEvent(event
)
1776 patches
= get_patches_from_mimedata(event
.mimeData())
1778 event
.acceptProposedAction()
1780 def dropEvent(self
, event
):
1781 """Add dropped patches"""
1783 patches
= get_patches_from_mimedata(event
.mimeData())
1786 self
.add_paths(patches
)
1788 def add_paths(self
, paths
):
1789 self
.tree
.add_paths(paths
)
1791 def _tree_selection_changed(self
):
1792 items
= self
.tree
.selected_items()
1795 item
= items
[-1] # take the last item
1796 path
= item
.data(0, Qt
.UserRole
)
1797 if not core
.exists(path
):
1799 commit
= parse_patch(path
)
1800 self
.diffwidget
.set_details(
1801 commit
.oid
, commit
.author
, commit
.email
, commit
.date
, commit
.summary
1803 self
.diffwidget
.set_diff(commit
.diff
)
1805 def export_state(self
):
1806 """Export persistent settings"""
1807 state
= super().export_state()
1808 state
['sizes'] = get(self
.splitter
)
1811 def apply_state(self
, state
):
1812 """Apply persistent settings"""
1813 result
= super().apply_state(state
)
1815 self
.splitter
.setSizes(state
['sizes'])
1816 except (AttributeError, KeyError, ValueError, TypeError):
1821 # pylint: disable=too-many-ancestors
1822 class PatchTreeWidget(standard
.DraggableTreeWidget
):
1823 def add_paths(self
, paths
):
1824 patches
= get_patches_from_paths(paths
)
1828 icon
= icons
.file_text()
1829 for patch
in patches
:
1830 item
= QtWidgets
.QTreeWidgetItem()
1831 flags
= item
.flags() & ~Qt
.ItemIsDropEnabled
1832 item
.setFlags(flags
)
1833 item
.setIcon(0, icon
)
1834 item
.setText(0, os
.path
.basename(patch
))
1835 item
.setData(0, Qt
.UserRole
, patch
)
1836 item
.setToolTip(0, patch
)
1838 self
.addTopLevelItems(items
)
1840 def remove_selected(self
):
1841 idxs
= self
.selectedIndexes()
1842 rows
= [idx
.row() for idx
in idxs
]
1843 for row
in reversed(sorted(rows
)):
1844 self
.invisibleRootItem().takeChild(row
)
1848 """Container for commit details"""
1860 def parse_patch(path
):
1861 content
= core
.read(path
)
1863 parse(content
, commit
)
1867 def parse(content
, commit
):
1868 """Parse commit details from a patch"""
1869 from_rgx
= re
.compile(r
'^From (?P<oid>[a-f0-9]{40}) .*$')
1870 author_rgx
= re
.compile(r
'^From: (?P<author>[^<]+) <(?P<email>[^>]+)>$')
1871 date_rgx
= re
.compile(r
'^Date: (?P<date>.*)$')
1872 subject_rgx
= re
.compile(r
'^Subject: (?P<summary>.*)$')
1874 commit
.content
= content
1876 lines
= content
.splitlines()
1877 for idx
, line
in enumerate(lines
):
1878 match
= from_rgx
.match(line
)
1880 commit
.oid
= match
.group('oid')
1883 match
= author_rgx
.match(line
)
1885 commit
.author
= match
.group('author')
1886 commit
.email
= match
.group('email')
1889 match
= date_rgx
.match(line
)
1891 commit
.date
= match
.group('date')
1894 match
= subject_rgx
.match(line
)
1896 commit
.summary
= match
.group('summary')
1897 commit
.diff
= '\n'.join(lines
[idx
+ 1 :])