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
= PlainTextLabel(parent
=self
)
484 self
.filename
.setAlignment(Qt
.AlignTop | 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" submenu"""
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"""
1552 oid_start
= self
.oid_start
1553 oid_end
= self
.oid_end
1554 if oid_start
and oid_end
:
1555 self
.set_diff_range(oid_start
.oid
, oid_end
.oid
, filename
=filenames
[0])
1557 self
.set_diff_oid(self
.oid
, filename
=filenames
[0])
1560 class DiffPanel(QtWidgets
.QWidget
):
1561 """A combined diff + search panel"""
1563 def __init__(self
, diff_widget
, text_widget
, parent
):
1564 super().__init
__(parent
)
1565 self
.diff_widget
= diff_widget
1566 self
.search_widget
= TextSearchWidget(text_widget
, self
)
1567 self
.search_widget
.hide()
1568 layout
= qtutils
.vbox(
1569 defs
.no_margin
, defs
.spacing
, self
.diff_widget
, self
.search_widget
1571 self
.setLayout(layout
)
1572 self
.setFocusProxy(self
.diff_widget
)
1574 self
.search_action
= qtutils
.add_action(
1576 N_('Search in Diff'),
1581 def show_search(self
):
1582 """Show a dialog for searching diffs"""
1583 # The diff search is only active in text mode.
1584 if not self
.search_widget
.isVisible():
1585 self
.search_widget
.show()
1586 self
.search_widget
.setFocus()
1589 class DiffInfoTask(qtutils
.Task
):
1590 """Gather diffs for a single commit"""
1592 def __init__(self
, context
, oid
, filename
):
1593 qtutils
.Task
.__init
__(self
)
1594 self
.context
= context
1596 self
.filename
= filename
1599 context
= self
.context
1601 return gitcmds
.diff_info(context
, oid
, filename
=self
.filename
)
1604 class DiffRangeTask(qtutils
.Task
):
1605 """Gather diffs for a range of commits"""
1607 def __init__(self
, context
, start
, end
, filename
):
1608 qtutils
.Task
.__init
__(self
)
1609 self
.context
= context
1612 self
.filename
= filename
1615 context
= self
.context
1616 return gitcmds
.diff_range(context
, self
.start
, self
.end
, filename
=self
.filename
)
1619 def apply_patches(context
, patches
=None):
1620 """Open the ApplyPatches dialog"""
1621 parent
= qtutils
.active_window()
1622 dlg
= new_apply_patches(context
, patches
=patches
, parent
=parent
)
1628 def new_apply_patches(context
, patches
=None, parent
=None):
1629 """Create a new instances of the ApplyPatches dialog"""
1630 dlg
= ApplyPatches(context
, parent
=parent
)
1632 dlg
.add_paths(patches
)
1636 def get_patches_from_paths(paths
):
1637 """Returns all patches benath a given path"""
1638 paths
= [core
.decode(p
) for p
in paths
]
1639 patches
= [p
for p
in paths
if core
.isfile(p
) and p
.endswith(('.patch', '.mbox'))]
1640 dirs
= [p
for p
in paths
if core
.isdir(p
)]
1643 patches
.extend(get_patches_from_dir(d
))
1647 def get_patches_from_mimedata(mimedata
):
1648 """Extract path files from a QMimeData payload"""
1649 urls
= mimedata
.urls()
1652 paths
= [x
.path() for x
in urls
]
1653 return get_patches_from_paths(paths
)
1656 def get_patches_from_dir(path
):
1657 """Find patches in a subdirectory"""
1659 for root
, _
, files
in core
.walk(path
):
1660 for name
in [f
for f
in files
if f
.endswith(('.patch', '.mbox'))]:
1661 patches
.append(core
.decode(os
.path
.join(root
, name
)))
1665 class ApplyPatches(standard
.Dialog
):
1666 def __init__(self
, context
, parent
=None):
1667 super().__init
__(parent
=parent
)
1668 self
.context
= context
1669 self
.setWindowTitle(N_('Apply Patches'))
1670 self
.setAcceptDrops(True)
1671 if parent
is not None:
1672 self
.setWindowModality(Qt
.WindowModal
)
1674 self
.curdir
= core
.getcwd()
1675 self
.inner_drag
= False
1677 self
.usage
= QtWidgets
.QLabel()
1682 Drag and drop or use the <strong>Add</strong> button to add
1689 self
.tree
= PatchTreeWidget(parent
=self
)
1690 self
.tree
.setHeaderHidden(True)
1691 # pylint: disable=no-member
1692 self
.tree
.itemSelectionChanged
.connect(self
._tree
_selection
_changed
)
1694 self
.diffwidget
= DiffWidget(context
, self
, is_commit
=True)
1696 self
.add_button
= qtutils
.create_toolbutton(
1697 text
=N_('Add'), icon
=icons
.add(), tooltip
=N_('Add patches (+)')
1700 self
.remove_button
= qtutils
.create_toolbutton(
1702 icon
=icons
.remove(),
1703 tooltip
=N_('Remove selected (Delete)'),
1706 self
.apply_button
= qtutils
.create_button(text
=N_('Apply'), icon
=icons
.ok())
1708 self
.close_button
= qtutils
.close_button()
1710 self
.add_action
= qtutils
.add_action(
1711 self
, N_('Add'), self
.add_files
, hotkeys
.ADD_ITEM
1714 self
.remove_action
= qtutils
.add_action(
1717 self
.tree
.remove_selected
,
1720 hotkeys
.REMOVE_ITEM
,
1723 self
.top_layout
= qtutils
.hbox(
1725 defs
.button_spacing
,
1732 self
.bottom_layout
= qtutils
.hbox(
1734 defs
.button_spacing
,
1740 self
.splitter
= qtutils
.splitter(Qt
.Vertical
, self
.tree
, self
.diffwidget
)
1742 self
.main_layout
= qtutils
.vbox(
1749 self
.setLayout(self
.main_layout
)
1751 qtutils
.connect_button(self
.add_button
, self
.add_files
)
1752 qtutils
.connect_button(self
.remove_button
, self
.tree
.remove_selected
)
1753 qtutils
.connect_button(self
.apply_button
, self
.apply_patches
)
1754 qtutils
.connect_button(self
.close_button
, self
.close
)
1756 self
.init_state(None, self
.resize
, 666, 420)
1758 def apply_patches(self
):
1759 items
= self
.tree
.items()
1762 context
= self
.context
1763 patches
= [i
.data(0, Qt
.UserRole
) for i
in items
]
1764 cmds
.do(cmds
.ApplyPatches
, context
, patches
)
1767 def add_files(self
):
1768 files
= qtutils
.open_files(
1769 N_('Select patch file(s)...'),
1770 directory
=self
.curdir
,
1771 filters
='Patches (*.patch *.mbox)',
1775 self
.curdir
= os
.path
.dirname(files
[0])
1776 self
.add_paths([core
.relpath(f
) for f
in files
])
1778 def dragEnterEvent(self
, event
):
1779 """Accepts drops if the mimedata contains patches"""
1780 super().dragEnterEvent(event
)
1781 patches
= get_patches_from_mimedata(event
.mimeData())
1783 event
.acceptProposedAction()
1785 def dropEvent(self
, event
):
1786 """Add dropped patches"""
1788 patches
= get_patches_from_mimedata(event
.mimeData())
1791 self
.add_paths(patches
)
1793 def add_paths(self
, paths
):
1794 self
.tree
.add_paths(paths
)
1796 def _tree_selection_changed(self
):
1797 items
= self
.tree
.selected_items()
1800 item
= items
[-1] # take the last item
1801 path
= item
.data(0, Qt
.UserRole
)
1802 if not core
.exists(path
):
1804 commit
= parse_patch(path
)
1805 self
.diffwidget
.set_details(
1806 commit
.oid
, commit
.author
, commit
.email
, commit
.date
, commit
.summary
1808 self
.diffwidget
.set_diff(commit
.diff
)
1810 def export_state(self
):
1811 """Export persistent settings"""
1812 state
= super().export_state()
1813 state
['sizes'] = get(self
.splitter
)
1816 def apply_state(self
, state
):
1817 """Apply persistent settings"""
1818 result
= super().apply_state(state
)
1820 self
.splitter
.setSizes(state
['sizes'])
1821 except (AttributeError, KeyError, ValueError, TypeError):
1826 # pylint: disable=too-many-ancestors
1827 class PatchTreeWidget(standard
.DraggableTreeWidget
):
1828 def add_paths(self
, paths
):
1829 patches
= get_patches_from_paths(paths
)
1833 icon
= icons
.file_text()
1834 for patch
in patches
:
1835 item
= QtWidgets
.QTreeWidgetItem()
1836 flags
= item
.flags() & ~Qt
.ItemIsDropEnabled
1837 item
.setFlags(flags
)
1838 item
.setIcon(0, icon
)
1839 item
.setText(0, os
.path
.basename(patch
))
1840 item
.setData(0, Qt
.UserRole
, patch
)
1841 item
.setToolTip(0, patch
)
1843 self
.addTopLevelItems(items
)
1845 def remove_selected(self
):
1846 idxs
= self
.selectedIndexes()
1847 rows
= [idx
.row() for idx
in idxs
]
1848 for row
in reversed(sorted(rows
)):
1849 self
.invisibleRootItem().takeChild(row
)
1853 """Container for commit details"""
1865 def parse_patch(path
):
1866 content
= core
.read(path
)
1868 parse(content
, commit
)
1872 def parse(content
, commit
):
1873 """Parse commit details from a patch"""
1874 from_rgx
= re
.compile(r
'^From (?P<oid>[a-f0-9]{40}) .*$')
1875 author_rgx
= re
.compile(r
'^From: (?P<author>[^<]+) <(?P<email>[^>]+)>$')
1876 date_rgx
= re
.compile(r
'^Date: (?P<date>.*)$')
1877 subject_rgx
= re
.compile(r
'^Subject: (?P<summary>.*)$')
1879 commit
.content
= content
1881 lines
= content
.splitlines()
1882 for idx
, line
in enumerate(lines
):
1883 match
= from_rgx
.match(line
)
1885 commit
.oid
= match
.group('oid')
1888 match
= author_rgx
.match(line
)
1890 commit
.author
= match
.group('author')
1891 commit
.email
= match
.group('email')
1894 match
= date_rgx
.match(line
)
1896 commit
.date
= match
.group('date')
1899 match
= subject_rgx
.match(line
)
1901 commit
.summary
= match
.group('summary')
1902 commit
.diff
= '\n'.join(lines
[idx
+ 1 :])