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 class DiffTextEdit(VimHintedPlainTextEdit
):
189 """A textedit for interacting with diff text"""
192 self
, context
, parent
, is_commit
=False, whitespace
=True, numbers
=False
194 VimHintedPlainTextEdit
.__init
__(self
, context
, '', parent
=parent
)
195 # Diff/patch syntax highlighter
196 self
.highlighter
= DiffSyntaxHighlighter(
197 context
, self
.document(), is_commit
=is_commit
, whitespace
=whitespace
200 self
.numbers
= DiffLineNumbers(context
, self
)
204 self
.scrollvalue
= None
206 self
.copy_diff_action
= qtutils
.add_action(
212 self
.copy_diff_action
.setIcon(icons
.copy())
213 self
.copy_diff_action
.setEnabled(False)
214 self
.menu_actions
.append(self
.copy_diff_action
)
215 self
.cursorPositionChanged
.connect(self
._cursor
_changed
)
216 self
.selectionChanged
.connect(self
._selection
_changed
)
218 def setFont(self
, font
):
219 """Override setFont() so that we can use a custom "block" cursor"""
220 super().setFont(font
)
221 if prefs
.block_cursor(self
.context
):
222 width
= qtutils
.text_width(font
, 'M')
223 self
.setCursorWidth(width
)
225 def _cursor_changed(self
):
226 """Update the line number display when the cursor changes"""
227 line_number
= max(0, self
.textCursor().blockNumber())
228 if self
.numbers
is not None:
229 self
.numbers
.set_highlighted(line_number
)
231 def _selection_changed(self
):
232 """Respond to selection changes"""
233 selected
= bool(self
.selected_text())
234 self
.copy_diff_action
.setEnabled(selected
)
236 def resizeEvent(self
, event
):
237 super().resizeEvent(event
)
239 self
.numbers
.refresh_size()
241 def save_scrollbar(self
):
242 """Save the scrollbar value, but only on the first call"""
243 if self
.scrollvalue
is None:
244 scrollbar
= self
.verticalScrollBar()
246 scrollvalue
= get(scrollbar
)
249 self
.scrollvalue
= scrollvalue
251 def restore_scrollbar(self
):
252 """Restore the scrollbar and clear state"""
253 scrollbar
= self
.verticalScrollBar()
254 scrollvalue
= self
.scrollvalue
255 if scrollbar
and scrollvalue
is not None:
256 scrollbar
.setValue(scrollvalue
)
257 self
.scrollvalue
= None
259 def set_loading_message(self
):
260 """Add a pending loading message in the diff view"""
261 self
.hint
.set_value('+++ ' + N_('Loading...'))
264 def set_diff(self
, diff
):
265 """Set the diff text, but save the scrollbar"""
266 diff
= diff
.rstrip('\n') # diffs include two empty newlines
267 self
.save_scrollbar()
269 self
.hint
.set_value('')
271 self
.numbers
.set_diff(diff
)
274 self
.restore_scrollbar()
276 def selected_diff_stripped(self
):
277 """Return the selected diff stripped of any diff characters"""
278 sep
, selection
= self
.selected_text_lines()
279 return sep
.join(_strip_diff(line
) for line
in selection
)
282 """Copy the selected diff text stripped of any diff prefix characters"""
283 text
= self
.selected_diff_stripped()
284 qtutils
.set_clipboard(text
)
286 def selected_lines(self
):
287 """Return selected lines"""
288 cursor
= self
.textCursor()
289 selection_start
= cursor
.selectionStart()
290 selection_end
= max(selection_start
, cursor
.selectionEnd() - 1)
297 for line_idx
, line
in enumerate(get(self
, default
='').splitlines()):
298 line_end
= line_start
+ len(line
)
299 if line_start
<= selection_start
<= line_end
:
300 first_line_idx
= line_idx
301 if line_start
<= selection_end
<= line_end
:
302 last_line_idx
= line_idx
304 line_start
= line_end
+ 1
306 if first_line_idx
== -1:
307 first_line_idx
= line_idx
309 if last_line_idx
== -1:
310 last_line_idx
= line_idx
312 return first_line_idx
, last_line_idx
314 def selected_text_lines(self
):
315 """Return selected lines and the CRLF / LF separator"""
316 first_line_idx
, last_line_idx
= self
.selected_lines()
317 text
= get(self
, default
='')
320 for line_idx
, line
in enumerate(text
.split(sep
)):
321 if first_line_idx
<= line_idx
<= last_line_idx
:
327 """Return either CRLF or LF based on the content"""
335 def _strip_diff(value
):
336 """Remove +/-/<space> from a selection"""
337 if value
.startswith(('+', '-', ' ')):
342 class DiffLineNumbers(TextDecorator
):
343 def __init__(self
, context
, parent
):
344 TextDecorator
.__init
__(self
, parent
)
345 self
.highlight_line
= -1
347 self
.parser
= diffparse
.DiffLines()
348 self
.formatter
= diffparse
.FormatDigits()
350 font
= qtutils
.diff_font(context
)
352 self
._char
_width
= qtutils
.text_width(font
, 'M')
354 QPalette
= QtGui
.QPalette
355 self
._palette
= palette
= self
.palette()
356 self
._base
= palette
.color(QtGui
.QPalette
.Base
)
357 self
._highlight
= palette
.color(QPalette
.Highlight
)
358 self
._highlight
.setAlphaF(0.3)
359 self
._highlight
_text
= palette
.color(QPalette
.HighlightedText
)
360 self
._window
= palette
.color(QPalette
.Window
)
361 self
._disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
363 def set_diff(self
, diff
):
364 self
.lines
= self
.parser
.parse(diff
)
365 self
.formatter
.set_digits(self
.parser
.digits())
367 def width_hint(self
):
368 if not self
.isVisible():
374 extra
= 3 # one space in-between, one space after
377 extra
= 2 # one space in-between, one space after
379 digits
= parser
.digits() * columns
381 return defs
.margin
+ (self
._char
_width
* (digits
+ extra
))
383 def set_highlighted(self
, line_number
):
384 """Set the line to highlight"""
385 self
.highlight_line
= line_number
387 def current_line(self
):
389 if lines
and self
.highlight_line
>= 0:
390 # Find the next valid line
391 for i
in range(self
.highlight_line
, len(lines
)):
392 # take the "new" line number: last value in tuple
393 line_number
= lines
[i
][-1]
397 # Find the previous valid line
398 for i
in range(self
.highlight_line
- 1, -1, -1):
399 # take the "new" line number: last value in tuple
401 line_number
= lines
[i
][-1]
406 def paintEvent(self
, event
):
407 """Paint the line number"""
411 painter
= QtGui
.QPainter(self
)
412 painter
.fillRect(event
.rect(), self
._base
)
415 content_offset
= editor
.contentOffset()
416 block
= editor
.firstVisibleBlock()
418 text_width
= width
- (defs
.margin
* 2)
419 text_flags
= Qt
.AlignRight | Qt
.AlignVCenter
420 event_rect_bottom
= event
.rect().bottom()
422 highlight_line
= self
.highlight_line
423 highlight
= self
._highlight
424 highlight_text
= self
._highlight
_text
425 disabled
= self
._disabled
429 num_lines
= len(lines
)
431 while block
.isValid():
432 block_number
= block
.blockNumber()
433 if block_number
>= num_lines
:
435 block_geom
= editor
.blockBoundingGeometry(block
)
436 rect
= block_geom
.translated(content_offset
).toRect()
437 if not block
.isVisible() or rect
.top() >= event_rect_bottom
:
440 if block_number
== highlight_line
:
441 painter
.fillRect(rect
.x(), rect
.y(), width
, rect
.height(), highlight
)
442 painter
.setPen(highlight_text
)
444 painter
.setPen(disabled
)
446 line
= lines
[block_number
]
449 text
= fmt
.value(a
, b
)
451 old
, base
, new
= line
452 text
= fmt
.merge_value(old
, base
, new
)
466 class Viewer(QtWidgets
.QFrame
):
467 """Text and image diff viewers"""
472 def __init__(self
, context
, parent
=None):
473 super().__init
__(parent
)
475 self
.context
= context
476 self
.model
= model
= context
.model
479 self
.options
= options
= Options(self
)
480 self
.filename
= PlainTextLabel(parent
=self
)
481 self
.filename
.setAlignment(Qt
.AlignVCenter | Qt
.AlignLeft
)
484 self
.filename
.setFont(font
)
485 self
.filename
.elide()
486 self
.text
= DiffEditor(context
, options
, self
)
487 self
.image
= imageview
.ImageView(parent
=self
)
488 self
.image
.setFocusPolicy(Qt
.NoFocus
)
489 self
.search_widget
= TextSearchWidget(self
.text
, self
)
490 self
.search_widget
.hide()
491 self
._drag
_has
_patches
= False
493 self
.setAcceptDrops(True)
494 self
.setFocusProxy(self
.text
)
496 stack
= self
.stack
= QtWidgets
.QStackedWidget(self
)
497 stack
.addWidget(self
.text
)
498 stack
.addWidget(self
.image
)
500 self
.main_layout
= qtutils
.vbox(
506 self
.setLayout(self
.main_layout
)
509 model
.images_changed
.connect(self
.set_images
, type=Qt
.QueuedConnection
)
511 # Observe the diff type
512 model
.diff_type_changed
.connect(self
.set_diff_type
, type=Qt
.QueuedConnection
)
514 # Observe the file type
515 model
.file_type_changed
.connect(self
.set_file_type
, type=Qt
.QueuedConnection
)
517 # Observe the image mode combo box
518 options
.image_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
519 options
.zoom_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
521 self
.search_action
= qtutils
.add_action(
523 N_('Search in Diff'),
524 self
.show_search_diff
,
528 def dragEnterEvent(self
, event
):
529 """Accepts drops if the mimedata contains patches"""
530 super().dragEnterEvent(event
)
531 patches
= get_patches_from_mimedata(event
.mimeData())
533 event
.acceptProposedAction()
534 self
._drag
_has
_patches
= True
536 def dragLeaveEvent(self
, event
):
537 """End the drag+drop interaction"""
538 super().dragLeaveEvent(event
)
539 if self
._drag
_has
_patches
:
543 self
._drag
_has
_patches
= False
545 def dropEvent(self
, event
):
546 """Apply patches when dropped onto the widget"""
547 if not self
._drag
_has
_patches
:
550 event
.setDropAction(Qt
.CopyAction
)
551 super().dropEvent(event
)
552 self
._drag
_has
_patches
= False
554 patches
= get_patches_from_mimedata(event
.mimeData())
556 apply_patches(self
.context
, patches
=patches
)
558 event
.accept() # must be called after dropEvent()
560 def show_search_diff(self
):
561 """Show a dialog for searching diffs"""
562 # The diff search is only active in text mode.
563 if self
.stack
.currentIndex() != self
.INDEX_TEXT
:
565 if not self
.search_widget
.isVisible():
566 self
.search_widget
.show()
567 self
.search_widget
.setFocus()
569 def export_state(self
, state
):
570 state
['show_diff_line_numbers'] = self
.options
.show_line_numbers
.isChecked()
571 state
['show_diff_filenames'] = self
.options
.show_filenames
.isChecked()
572 state
['image_diff_mode'] = self
.options
.image_mode
.currentIndex()
573 state
['image_zoom_mode'] = self
.options
.zoom_mode
.currentIndex()
574 state
['word_wrap'] = self
.options
.enable_word_wrapping
.isChecked()
577 def apply_state(self
, state
):
578 diff_numbers
= bool(state
.get('show_diff_line_numbers', False))
579 self
.set_line_numbers(diff_numbers
, update
=True)
581 show_filenames
= bool(state
.get('show_diff_filenames', True))
582 self
.set_show_filenames(show_filenames
, update
=True)
584 image_mode
= utils
.asint(state
.get('image_diff_mode', 0))
585 self
.options
.image_mode
.set_index(image_mode
)
587 zoom_mode
= utils
.asint(state
.get('image_zoom_mode', 0))
588 self
.options
.zoom_mode
.set_index(zoom_mode
)
590 word_wrap
= bool(state
.get('word_wrap', True))
591 self
.set_word_wrapping(word_wrap
, update
=True)
594 def set_diff_type(self
, diff_type
):
595 """Manage the image and text diff views when selection changes"""
596 # The "diff type" is whether the diff viewer is displaying an image.
597 self
.options
.set_diff_type(diff_type
)
598 if diff_type
== main
.Types
.IMAGE
:
599 self
.stack
.setCurrentWidget(self
.image
)
600 self
.search_widget
.hide()
603 self
.stack
.setCurrentWidget(self
.text
)
605 def set_file_type(self
, file_type
):
606 """Manage the diff options when the file type changes"""
607 # The "file type" is whether the file itself is an image.
608 self
.options
.set_file_type(file_type
)
610 def enable_filename_tracking(self
):
611 """Enable displaying the currently selected filename"""
612 self
.context
.selection
.selection_changed
.connect(
613 self
.update_filename
, type=Qt
.QueuedConnection
616 def update_filename(self
):
617 """Update the filename display when the selection changes"""
618 filename
= self
.context
.selection
.filename()
619 self
.filename
.set_text(filename
or '')
621 def update_options(self
):
622 """Emit a signal indicating that options have changed"""
623 self
.text
.update_options()
624 show_filenames
= get(self
.options
.show_filenames
)
625 self
.set_show_filenames(show_filenames
)
627 def set_show_filenames(self
, enabled
, update
=False):
628 """Enable/disable displaying the selected filename"""
629 self
.filename
.setVisible(enabled
)
631 with qtutils
.BlockSignals(self
.options
.show_filenames
):
632 self
.options
.show_filenames
.setChecked(enabled
)
634 def set_line_numbers(self
, enabled
, update
=False):
635 """Enable/disable line numbers in the text widget"""
636 self
.text
.set_line_numbers(enabled
, update
=update
)
638 def set_word_wrapping(self
, enabled
, update
=False):
639 """Enable/disable word wrapping in the text widget"""
640 self
.text
.set_word_wrapping(enabled
, update
=update
)
643 self
.image
.pixmap
= QtGui
.QPixmap()
647 for image
, unlink
in self
.images
:
648 if unlink
and core
.exists(image
):
652 def set_images(self
, images
):
659 # In order to comp, we first have to load all the images
660 all_pixmaps
= [QtGui
.QPixmap(image
[0]) for image
in images
]
661 pixmaps
= [pixmap
for pixmap
in all_pixmaps
if not pixmap
.isNull()]
666 self
.pixmaps
= pixmaps
674 mode
= self
.options
.image_mode
.currentIndex()
675 if mode
== self
.options
.SIDE_BY_SIDE
:
676 image
= self
.render_side_by_side()
677 elif mode
== self
.options
.DIFF
:
678 image
= self
.render_diff()
679 elif mode
== self
.options
.XOR
:
680 image
= self
.render_xor()
681 elif mode
== self
.options
.PIXEL_XOR
:
682 image
= self
.render_pixel_xor()
684 image
= self
.render_side_by_side()
686 image
= QtGui
.QPixmap()
687 self
.image
.pixmap
= image
690 zoom_mode
= self
.options
.zoom_mode
.currentIndex()
691 zoom_factor
= self
.options
.zoom_factors
[zoom_mode
][1]
692 if zoom_factor
> 0.0:
693 self
.image
.resetTransform()
694 self
.image
.scale(zoom_factor
, zoom_factor
)
695 poly
= self
.image
.mapToScene(self
.image
.viewport().rect())
696 self
.image
.last_scene_roi
= poly
.boundingRect()
698 def render_side_by_side(self
):
699 # Side-by-side lineup comp
700 pixmaps
= self
.pixmaps
701 width
= sum(pixmap
.width() for pixmap
in pixmaps
)
702 height
= max(pixmap
.height() for pixmap
in pixmaps
)
703 image
= create_image(width
, height
)
706 painter
= create_painter(image
)
708 for pixmap
in pixmaps
:
709 painter
.drawPixmap(x
, 0, pixmap
)
715 def render_comp(self
, comp_mode
):
716 # Get the max size to use as the render canvas
717 pixmaps
= self
.pixmaps
718 if len(pixmaps
) == 1:
721 width
= max(pixmap
.width() for pixmap
in pixmaps
)
722 height
= max(pixmap
.height() for pixmap
in pixmaps
)
723 image
= create_image(width
, height
)
725 painter
= create_painter(image
)
726 for pixmap
in (pixmaps
[0], pixmaps
[-1]):
727 x
= (width
- pixmap
.width()) // 2
728 y
= (height
- pixmap
.height()) // 2
729 painter
.drawPixmap(x
, y
, pixmap
)
730 painter
.setCompositionMode(comp_mode
)
735 def render_diff(self
):
736 comp_mode
= QtGui
.QPainter
.CompositionMode_Difference
737 return self
.render_comp(comp_mode
)
739 def render_xor(self
):
740 comp_mode
= QtGui
.QPainter
.CompositionMode_Xor
741 return self
.render_comp(comp_mode
)
743 def render_pixel_xor(self
):
744 comp_mode
= QtGui
.QPainter
.RasterOp_SourceXorDestination
745 return self
.render_comp(comp_mode
)
748 def create_image(width
, height
):
749 size
= QtCore
.QSize(width
, height
)
750 image
= QtGui
.QImage(size
, QtGui
.QImage
.Format_ARGB32_Premultiplied
)
751 image
.fill(Qt
.transparent
)
755 def create_painter(image
):
756 painter
= QtGui
.QPainter(image
)
757 painter
.fillRect(image
.rect(), Qt
.transparent
)
761 class Options(QtWidgets
.QWidget
):
762 """Provide the options widget used by the editor
764 Actions are registered on the parent widget.
768 # mode combobox indexes
774 def __init__(self
, parent
):
775 super().__init
__(parent
)
778 self
.ignore_space_at_eol
= self
.add_option(
779 N_('Ignore changes in whitespace at EOL')
781 self
.ignore_space_change
= self
.add_option(
782 N_('Ignore changes in amount of whitespace')
784 self
.ignore_all_space
= self
.add_option(N_('Ignore all whitespace'))
785 self
.function_context
= self
.add_option(
786 N_('Show whole surrounding functions of changes')
788 self
.show_line_numbers
= qtutils
.add_action_bool(
789 self
, N_('Show line numbers'), self
.set_line_numbers
, True
791 self
.show_filenames
= self
.add_option(N_('Show filenames'))
792 self
.enable_word_wrapping
= qtutils
.add_action_bool(
793 self
, N_('Enable word wrapping'), self
.set_word_wrapping
, True
796 self
.options
= qtutils
.create_action_button(
797 tooltip
=N_('Diff Options'), icon
=icons
.configure()
800 self
.toggle_image_diff
= qtutils
.create_action_button(
801 tooltip
=N_('Toggle image diff'), icon
=icons
.visualize()
803 self
.toggle_image_diff
.hide()
805 self
.image_mode
= qtutils
.combo(
806 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
809 self
.zoom_factors
= (
810 (N_('Zoom to Fit'), 0.0),
818 zoom_modes
= [factor
[0] for factor
in self
.zoom_factors
]
819 self
.zoom_mode
= qtutils
.combo(zoom_modes
, parent
=self
)
821 self
.menu
= menu
= qtutils
.create_menu(N_('Diff Options'), self
.options
)
822 self
.options
.setMenu(menu
)
823 menu
.addAction(self
.ignore_space_at_eol
)
824 menu
.addAction(self
.ignore_space_change
)
825 menu
.addAction(self
.ignore_all_space
)
827 menu
.addAction(self
.function_context
)
828 menu
.addAction(self
.show_line_numbers
)
829 menu
.addAction(self
.show_filenames
)
831 menu
.addAction(self
.enable_word_wrapping
)
834 layout
= qtutils
.hbox(
840 self
.toggle_image_diff
,
842 self
.setLayout(layout
)
845 self
.image_mode
.setFocusPolicy(Qt
.NoFocus
)
846 self
.zoom_mode
.setFocusPolicy(Qt
.NoFocus
)
847 self
.options
.setFocusPolicy(Qt
.NoFocus
)
848 self
.toggle_image_diff
.setFocusPolicy(Qt
.NoFocus
)
849 self
.setFocusPolicy(Qt
.NoFocus
)
851 def set_file_type(self
, file_type
):
852 """Set whether we are viewing an image file type"""
853 is_image
= file_type
== main
.Types
.IMAGE
854 self
.toggle_image_diff
.setVisible(is_image
)
856 def set_diff_type(self
, diff_type
):
857 """Toggle between image and text diffs"""
858 is_text
= diff_type
== main
.Types
.TEXT
859 is_image
= diff_type
== main
.Types
.IMAGE
860 self
.options
.setVisible(is_text
)
861 self
.image_mode
.setVisible(is_image
)
862 self
.zoom_mode
.setVisible(is_image
)
864 self
.toggle_image_diff
.setIcon(icons
.diff())
866 self
.toggle_image_diff
.setIcon(icons
.visualize())
868 def add_option(self
, title
):
869 """Add a diff option which calls update_options() on change"""
870 action
= qtutils
.add_action(self
, title
, self
.update_options
)
871 action
.setCheckable(True)
874 def update_options(self
):
875 """Update diff options in response to UI events"""
876 space_at_eol
= get(self
.ignore_space_at_eol
)
877 space_change
= get(self
.ignore_space_change
)
878 all_space
= get(self
.ignore_all_space
)
879 function_context
= get(self
.function_context
)
880 gitcmds
.update_diff_overrides(
881 space_at_eol
, space_change
, all_space
, function_context
883 self
.widget
.update_options()
885 def set_line_numbers(self
, value
):
886 """Enable / disable line numbers"""
887 self
.widget
.set_line_numbers(value
, update
=False)
889 def set_word_wrapping(self
, value
):
890 """Respond to Qt action callbacks"""
891 self
.widget
.set_word_wrapping(value
, update
=False)
893 def hide_advanced_options(self
):
894 """Hide advanced options that are not applicable to the DiffWidget"""
895 self
.show_filenames
.setVisible(False)
896 self
.show_line_numbers
.setVisible(False)
897 self
.ignore_space_at_eol
.setVisible(False)
898 self
.ignore_space_change
.setVisible(False)
899 self
.ignore_all_space
.setVisible(False)
900 self
.function_context
.setVisible(False)
903 class DiffEditor(DiffTextEdit
):
906 options_changed
= Signal()
908 def __init__(self
, context
, options
, parent
):
909 DiffTextEdit
.__init
__(self
, context
, parent
, numbers
=True)
910 self
.context
= context
911 self
.model
= model
= context
.model
912 self
.selection_model
= selection_model
= context
.selection
914 # "Diff Options" tool menu
915 self
.options
= options
917 self
.action_apply_selection
= qtutils
.add_action(
920 self
.apply_selection
,
922 hotkeys
.STAGE_DIFF_ALT
,
925 self
.action_revert_selection
= qtutils
.add_action(
926 self
, 'Revert', self
.revert_selection
, hotkeys
.REVERT
, hotkeys
.REVERT_ALT
928 self
.action_revert_selection
.setIcon(icons
.undo())
930 self
.action_edit_and_apply_selection
= qtutils
.add_action(
933 partial(self
.apply_selection
, edit
=True),
934 hotkeys
.EDIT_AND_STAGE_DIFF
,
937 self
.action_edit_and_revert_selection
= qtutils
.add_action(
940 partial(self
.revert_selection
, edit
=True),
941 hotkeys
.EDIT_AND_REVERT
,
943 self
.action_edit_and_revert_selection
.setIcon(icons
.undo())
944 self
.launch_editor
= actions
.launch_editor_at_line(
945 context
, self
, hotkeys
.EDIT_SHORT
, *hotkeys
.ACCEPT
947 self
.launch_difftool
= actions
.launch_difftool(context
, self
)
948 self
.stage_or_unstage
= actions
.stage_or_unstage(context
, self
)
950 # Emit up/down signals so that they can be routed by the main widget
951 self
.move_up
= actions
.move_up(self
)
952 self
.move_down
= actions
.move_down(self
)
954 model
.diff_text_updated
.connect(self
.set_diff
, type=Qt
.QueuedConnection
)
956 selection_model
.selection_changed
.connect(
957 self
.refresh
, type=Qt
.QueuedConnection
959 # Update the selection model when the cursor changes
960 self
.cursorPositionChanged
.connect(self
._update
_line
_number
)
962 qtutils
.connect_button(options
.toggle_image_diff
, self
.toggle_diff_type
)
964 def toggle_diff_type(self
):
965 cmds
.do(cmds
.ToggleDiffType
, self
.context
)
969 s
= self
.selection_model
.selection()
971 if model
.is_partially_stageable():
972 item
= s
.modified
[0] if s
.modified
else None
973 if item
in model
.submodules
:
975 elif item
not in model
.unstaged_deleted
:
977 self
.action_revert_selection
.setEnabled(enabled
)
979 def set_line_numbers(self
, enabled
, update
=False):
980 """Enable/disable the diff line number display"""
981 self
.numbers
.setVisible(enabled
)
983 with qtutils
.BlockSignals(self
.options
.show_line_numbers
):
984 self
.options
.show_line_numbers
.setChecked(enabled
)
985 # Refresh the display. Not doing this results in the display not
986 # correctly displaying the line numbers widget until the text scrolls.
987 self
.set_value(self
.value())
989 def update_options(self
):
990 self
.options_changed
.emit()
992 def create_context_menu(self
, event_pos
):
993 """Override create_context_menu() to display a completely custom menu"""
994 menu
= super().create_context_menu(event_pos
)
995 context
= self
.context
997 s
= self
.selection_model
.selection()
998 filename
= self
.selection_model
.filename()
1000 # These menu actions will be inserted at the start of the widget.
1001 current_actions
= menu
.actions()
1003 add_action
= menu_actions
.append
1004 edit_actions_added
= False
1005 stage_action_added
= False
1007 if s
.staged
and model
.is_unstageable():
1009 if item
not in model
.submodules
and item
not in model
.staged_deleted
:
1010 if self
.has_selection():
1011 apply_text
= N_('Unstage Selected Lines')
1013 apply_text
= N_('Unstage Diff Hunk')
1014 self
.action_apply_selection
.setText(apply_text
)
1015 self
.action_apply_selection
.setIcon(icons
.remove())
1016 add_action(self
.action_apply_selection
)
1017 stage_action_added
= self
._add
_stage
_or
_unstage
_action
(
1018 menu
, add_action
, stage_action_added
1021 if model
.is_partially_stageable():
1022 item
= s
.modified
[0] if s
.modified
else None
1023 if item
in model
.submodules
:
1024 path
= core
.abspath(item
)
1025 action
= qtutils
.add_action_with_icon(
1029 cmds
.run(cmds
.Stage
, context
, s
.modified
),
1030 hotkeys
.STAGE_SELECTION
,
1033 stage_action_added
= self
._add
_stage
_or
_unstage
_action
(
1034 menu
, add_action
, stage_action_added
1037 action
= qtutils
.add_action_with_icon(
1040 N_('Launch git-cola'),
1041 cmds
.run(cmds
.OpenRepo
, context
, path
),
1044 elif item
and item
not in model
.unstaged_deleted
:
1045 if self
.has_selection():
1046 apply_text
= N_('Stage Selected Lines')
1047 edit_and_apply_text
= N_('Edit Selected Lines to Stage...')
1048 revert_text
= N_('Revert Selected Lines...')
1049 edit_and_revert_text
= N_('Edit Selected Lines to Revert...')
1051 apply_text
= N_('Stage Diff Hunk')
1052 edit_and_apply_text
= N_('Edit Diff Hunk to Stage...')
1053 revert_text
= N_('Revert Diff Hunk...')
1054 edit_and_revert_text
= N_('Edit Diff Hunk to Revert...')
1056 self
.action_apply_selection
.setText(apply_text
)
1057 self
.action_apply_selection
.setIcon(icons
.add())
1058 add_action(self
.action_apply_selection
)
1060 self
.action_revert_selection
.setText(revert_text
)
1061 add_action(self
.action_revert_selection
)
1063 stage_action_added
= self
._add
_stage
_or
_unstage
_action
(
1064 menu
, add_action
, stage_action_added
1066 # Do not show the "edit" action when the file does not exist.
1067 add_action(qtutils
.menu_separator(menu
))
1068 if filename
and core
.exists(filename
):
1069 add_action(self
.launch_editor
)
1070 # Removed files can still be diffed.
1071 add_action(self
.launch_difftool
)
1072 edit_actions_added
= True
1074 add_action(qtutils
.menu_separator(menu
))
1075 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
1076 self
.action_edit_and_apply_selection
.setIcon(icons
.add())
1077 add_action(self
.action_edit_and_apply_selection
)
1079 self
.action_edit_and_revert_selection
.setText(edit_and_revert_text
)
1080 add_action(self
.action_edit_and_revert_selection
)
1082 if s
.staged
and model
.is_unstageable():
1084 if item
in model
.submodules
:
1085 path
= core
.abspath(item
)
1086 action
= qtutils
.add_action_with_icon(
1089 cmds
.Unstage
.name(),
1090 cmds
.run(cmds
.Unstage
, context
, s
.staged
),
1091 hotkeys
.STAGE_SELECTION
,
1095 stage_action_added
= self
._add
_stage
_or
_unstage
_action
(
1096 menu
, add_action
, stage_action_added
1099 qtutils
.add_action_with_icon(
1102 N_('Launch git-cola'),
1103 cmds
.run(cmds
.OpenRepo
, context
, path
),
1107 elif item
not in model
.staged_deleted
:
1108 # Do not show the "edit" action when the file does not exist.
1109 add_action(qtutils
.menu_separator(menu
))
1110 if filename
and core
.exists(filename
):
1111 add_action(self
.launch_editor
)
1112 # Removed files can still be diffed.
1113 add_action(self
.launch_difftool
)
1114 add_action(qtutils
.menu_separator(menu
))
1115 edit_actions_added
= True
1117 if self
.has_selection():
1118 edit_and_apply_text
= N_('Edit Selected Lines to Unstage...')
1120 edit_and_apply_text
= N_('Edit Diff Hunk to Unstage...')
1121 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
1122 self
.action_edit_and_apply_selection
.setIcon(icons
.remove())
1123 add_action(self
.action_edit_and_apply_selection
)
1125 if not edit_actions_added
and (model
.is_stageable() or model
.is_unstageable()):
1126 add_action(qtutils
.menu_separator(menu
))
1127 # Do not show the "edit" action when the file does not exist.
1128 # Untracked files exist by definition.
1129 if filename
and core
.exists(filename
):
1130 add_action(self
.launch_editor
)
1132 # Removed files can still be diffed.
1133 add_action(self
.launch_difftool
)
1135 add_action(qtutils
.menu_separator(menu
))
1136 _add_patch_actions(self
, self
.context
, menu
)
1138 # Add the Previous/Next File actions, which improves discoverability
1139 # of their associated shortcuts
1140 add_action(qtutils
.menu_separator(menu
))
1141 add_action(self
.move_up
)
1142 add_action(self
.move_down
)
1143 add_action(qtutils
.menu_separator(menu
))
1146 first_action
= current_actions
[0]
1149 menu
.insertActions(first_action
, menu_actions
)
1153 def _add_stage_or_unstage_action(self
, menu
, add_action
, already_added
):
1154 """Add the Stage / Unstage menu action"""
1157 model
= self
.context
.model
1158 s
= self
.selection_model
.selection()
1159 if model
.is_stageable() or model
.is_unstageable():
1160 if (model
.is_amend_mode() and s
.staged
) or not self
.model
.is_stageable():
1161 self
.stage_or_unstage
.setText(N_('Unstage'))
1162 self
.stage_or_unstage
.setIcon(icons
.remove())
1164 self
.stage_or_unstage
.setText(N_('Stage'))
1165 self
.stage_or_unstage
.setIcon(icons
.add())
1166 add_action(qtutils
.menu_separator(menu
))
1167 add_action(self
.stage_or_unstage
)
1170 def mousePressEvent(self
, event
):
1171 if event
.button() == Qt
.RightButton
:
1172 # Intercept right-click to move the cursor to the current position.
1173 # setTextCursor() clears the selection so this is only done when
1174 # nothing is selected.
1175 if not self
.has_selection():
1176 cursor
= self
.cursorForPosition(event
.pos())
1177 self
.setTextCursor(cursor
)
1179 return super().mousePressEvent(event
)
1181 def setPlainText(self
, text
):
1182 """setPlainText(str) while retaining scrollbar positions"""
1185 highlight
= mode
not in (
1188 model
.mode_untracked
,
1190 self
.highlighter
.set_enabled(highlight
)
1192 scrollbar
= self
.verticalScrollBar()
1194 scrollvalue
= get(scrollbar
)
1201 DiffTextEdit
.setPlainText(self
, text
)
1203 if scrollbar
and scrollvalue
is not None:
1204 scrollbar
.setValue(scrollvalue
)
1206 def apply_selection(self
, *, edit
=False):
1208 s
= self
.selection_model
.single_selection()
1209 if model
.is_partially_stageable() and (s
.modified
or s
.untracked
):
1210 self
.process_diff_selection(edit
=edit
)
1211 elif model
.is_unstageable():
1212 self
.process_diff_selection(reverse
=True, edit
=edit
)
1214 def revert_selection(self
, *, edit
=False):
1215 """Destructively revert selected lines or hunk from a worktree file."""
1218 if self
.has_selection():
1219 title
= N_('Revert Selected Lines?')
1220 ok_text
= N_('Revert Selected Lines')
1222 title
= N_('Revert Diff Hunk?')
1223 ok_text
= N_('Revert Diff Hunk')
1225 if not Interaction
.confirm(
1228 'This operation drops uncommitted changes.\n'
1229 'These changes cannot be recovered.'
1231 N_('Revert the uncommitted changes?'),
1237 self
.process_diff_selection(reverse
=True, apply_to_worktree
=True, edit
=edit
)
1239 def extract_patch(self
, reverse
=False):
1240 first_line_idx
, last_line_idx
= self
.selected_lines()
1241 patch
= diffparse
.Patch
.parse(self
.model
.filename
, self
.model
.diff_text
)
1242 if self
.has_selection():
1243 return patch
.extract_subset(first_line_idx
, last_line_idx
, reverse
=reverse
)
1244 return patch
.extract_hunk(first_line_idx
, reverse
=reverse
)
1246 def patch_encoding(self
):
1247 if isinstance(self
.model
.diff_text
, core
.UStr
):
1248 # original encoding must prevail
1249 return self
.model
.diff_text
.encoding
1250 return self
.context
.cfg
.file_encoding(self
.model
.filename
)
1252 def process_diff_selection(
1253 self
, reverse
=False, apply_to_worktree
=False, edit
=False
1255 """Implement un/staging of the selected line(s) or hunk."""
1256 if self
.selection_model
.is_empty():
1258 patch
= self
.extract_patch(reverse
)
1259 if not patch
.has_changes():
1261 patch_encoding
= self
.patch_encoding()
1269 apply_to_worktree
=apply_to_worktree
,
1271 if not patch
.has_changes():
1282 def _update_line_number(self
):
1283 """Update the selection model when the cursor changes"""
1284 self
.selection_model
.line_number
= self
.numbers
.current_line()
1287 def _add_patch_actions(widget
, context
, menu
):
1288 """Add actions for manipulating patch files"""
1289 patches_menu
= menu
.addMenu(N_('Patches'))
1290 patches_menu
.setIcon(icons
.diff())
1291 export_action
= qtutils
.add_action(
1294 lambda: _export_patch(widget
, context
),
1296 export_action
.setIcon(icons
.save())
1297 patches_menu
.addAction(export_action
)
1299 # Build the "Append Patch" menu dynamically.
1300 append_menu
= patches_menu
.addMenu(N_('Append Patch'))
1301 append_menu
.setIcon(icons
.add())
1302 append_menu
.aboutToShow
.connect(
1303 lambda: _build_patch_append_menu(widget
, context
, append_menu
)
1307 def _build_patch_append_menu(widget
, context
, menu
):
1308 """Build the "Append Patch" sub-menu"""
1309 # Build the menu when first displayed only. This initial check avoids
1310 # re-populating the menu with duplicate actions.
1311 menu_actions
= menu
.actions()
1315 choose_patch_action
= qtutils
.add_action(
1317 N_('Choose Patch...'),
1318 lambda: _export_patch(widget
, context
, append
=True),
1320 choose_patch_action
.setIcon(icons
.diff())
1321 menu
.addAction(choose_patch_action
)
1324 path
= prefs
.patches_directory(context
)
1325 patches
= get_patches_from_dir(path
)
1326 for patch
in patches
:
1327 relpath
= os
.path
.relpath(patch
, start
=path
)
1328 sub_menu
= _add_patch_subdirs(menu
, subdir_menus
, relpath
)
1329 patch_basename
= os
.path
.basename(relpath
)
1330 append_action
= qtutils
.add_action(
1333 lambda patch_file
=patch
: _append_patch(widget
, patch_file
),
1335 append_action
.setIcon(icons
.save())
1336 sub_menu
.addAction(append_action
)
1339 def _add_patch_subdirs(menu
, subdir_menus
, relpath
):
1340 """Build menu leading up to the patch"""
1341 # If the path contains no directory separators then add it to the
1343 if os
.sep
not in relpath
:
1346 # Loop over each directory component and build a menu if it doesn't already exist.
1348 for dirname
in os
.path
.dirname(relpath
).split(os
.sep
):
1349 components
.append(dirname
)
1350 current_dir
= os
.sep
.join(components
)
1352 menu
= subdir_menus
[current_dir
]
1354 menu
= subdir_menus
[current_dir
] = menu
.addMenu(dirname
)
1355 menu
.setIcon(icons
.folder())
1360 def _export_patch(diff_editor
, context
, append
=False):
1361 """Export the selected diff to a patch file"""
1362 if diff_editor
.selection_model
.is_empty():
1364 patch
= diff_editor
.extract_patch(reverse
=False)
1365 if not patch
.has_changes():
1367 directory
= prefs
.patches_directory(context
)
1369 filename
= qtutils
.existing_file(directory
, title
=N_('Append Patch...'))
1371 default_filename
= os
.path
.join(directory
, 'diff.patch')
1372 filename
= qtutils
.save_as(default_filename
)
1375 _write_patch_to_file(diff_editor
, patch
, filename
, append
=append
)
1378 def _append_patch(diff_editor
, filename
):
1379 """Append diffs to the specified patch file"""
1380 if diff_editor
.selection_model
.is_empty():
1382 patch
= diff_editor
.extract_patch(reverse
=False)
1383 if not patch
.has_changes():
1385 _write_patch_to_file(diff_editor
, patch
, filename
, append
=True)
1388 def _write_patch_to_file(diff_editor
, patch
, filename
, append
=False):
1389 """Write diffs from the Diff Editor to the specified patch file"""
1390 encoding
= diff_editor
.patch_encoding()
1391 content
= patch
.as_text()
1393 core
.write(filename
, content
, encoding
=encoding
, append
=append
)
1394 except OSError as exc
:
1395 _
, details
= utils
.format_exception(exc
)
1396 title
= N_('Error writing patch')
1397 msg
= N_('Unable to write patch to "%s". Check permissions?' % filename
)
1398 Interaction
.critical(title
, message
=msg
, details
=details
)
1400 Interaction
.log('Patch written to "%s"' % filename
)
1403 class DiffWidget(QtWidgets
.QWidget
):
1404 """Display commit metadata and text diffs"""
1406 def __init__(self
, context
, parent
, is_commit
=False, options
=None):
1407 QtWidgets
.QWidget
.__init
__(self
, parent
)
1409 self
.context
= context
1411 self
.oid_start
= None
1413 self
.options
= options
1415 author_font
= QtGui
.QFont(self
.font())
1416 author_font
.setPointSize(int(author_font
.pointSize() * 1.1))
1418 summary_font
= QtGui
.QFont(author_font
)
1419 summary_font
.setWeight(QtGui
.QFont
.Bold
)
1421 self
.gravatar_label
= gravatar
.GravatarLabel(self
.context
, parent
=self
)
1423 self
.oid_label
= PlainTextLabel(parent
=self
)
1424 self
.oid_label
.setAlignment(Qt
.AlignBottom
)
1425 self
.oid_label
.elide()
1427 self
.author_label
= RichTextLabel(parent
=self
)
1428 self
.author_label
.setFont(author_font
)
1429 self
.author_label
.setAlignment(Qt
.AlignTop
)
1430 self
.author_label
.elide()
1432 self
.date_label
= PlainTextLabel(parent
=self
)
1433 self
.date_label
.setAlignment(Qt
.AlignTop
)
1434 self
.date_label
.elide()
1436 self
.summary_label
= PlainTextLabel(parent
=self
)
1437 self
.summary_label
.setFont(summary_font
)
1438 self
.summary_label
.setAlignment(Qt
.AlignTop
)
1439 self
.summary_label
.elide()
1441 self
.diff
= DiffTextEdit(context
, self
, is_commit
=is_commit
, whitespace
=False)
1442 self
.setFocusProxy(self
.diff
)
1444 self
.info_layout
= qtutils
.vbox(
1453 self
.logo_layout
= qtutils
.hbox(
1454 defs
.no_margin
, defs
.button_spacing
, self
.gravatar_label
, self
.info_layout
1456 self
.logo_layout
.setContentsMargins(defs
.margin
, 0, defs
.margin
, 0)
1458 self
.main_layout
= qtutils
.vbox(
1459 defs
.no_margin
, defs
.spacing
, self
.logo_layout
, self
.diff
1461 self
.setLayout(self
.main_layout
)
1463 self
.set_tabwidth(prefs
.tabwidth(context
))
1465 def set_tabwidth(self
, width
):
1466 self
.diff
.set_tabwidth(width
)
1468 def set_word_wrapping(self
, enabled
, update
=False):
1469 """Enable and disable word wrapping"""
1470 self
.diff
.set_word_wrapping(enabled
, update
=update
)
1472 def set_options(self
, options
):
1473 """Register an options widget"""
1474 self
.options
= options
1475 self
.diff
.set_options(options
)
1477 def start_diff_task(self
, task
):
1478 """Clear the display and start a diff-gathering task"""
1479 self
.diff
.save_scrollbar()
1480 self
.diff
.set_loading_message()
1481 self
.context
.runtask
.start(task
, result
=self
.set_diff
)
1483 def set_diff_oid(self
, oid
, filename
=None):
1484 """Set the diff from a single commit object ID"""
1485 task
= DiffInfoTask(self
.context
, oid
, filename
)
1486 self
.start_diff_task(task
)
1488 def set_diff_range(self
, start
, end
, filename
=None):
1489 task
= DiffRangeTask(self
.context
, start
+ '~', end
, filename
)
1490 self
.start_diff_task(task
)
1492 def commits_selected(self
, commits
):
1493 """Display an appropriate diff when commits are selected"""
1497 commit
= commits
[-1]
1499 author
= commit
.author
or ''
1500 email
= commit
.email
or ''
1501 date
= commit
.authdate
or ''
1502 summary
= commit
.summary
or ''
1503 self
.set_details(oid
, author
, email
, date
, summary
)
1506 if len(commits
) > 1:
1507 start
, end
= commits
[0], commits
[-1]
1508 self
.set_diff_range(start
.oid
, end
.oid
)
1509 self
.oid_start
= start
1512 self
.set_diff_oid(oid
)
1513 self
.oid_start
= None
1516 def set_diff(self
, diff
):
1517 """Set the diff text"""
1518 self
.diff
.set_diff(diff
)
1520 def set_details(self
, oid
, author
, email
, date
, summary
):
1521 template_args
= {'author': author
, 'email': email
, 'summary': summary
}
1523 """%(author)s <"""
1524 """<a href="mailto:%(email)s">"""
1525 """%(email)s</a>>""" % template_args
1527 author_template
= '%(author)s <%(email)s>' % template_args
1529 self
.date_label
.set_text(date
)
1530 self
.date_label
.setVisible(bool(date
))
1531 self
.oid_label
.set_text(oid
)
1532 self
.author_label
.set_template(author_text
, author_template
)
1533 self
.summary_label
.set_text(summary
)
1534 self
.gravatar_label
.set_email(email
)
1537 self
.date_label
.set_text('')
1538 self
.oid_label
.set_text('')
1539 self
.author_label
.set_text('')
1540 self
.summary_label
.set_text('')
1541 self
.gravatar_label
.clear()
1544 def files_selected(self
, filenames
):
1545 """Update the view when a filename is selected"""
1546 oid_start
= self
.oid_start
1547 oid_end
= self
.oid_end
1550 extra_args
['filename'] = filenames
[0]
1551 if oid_start
and oid_end
:
1552 self
.set_diff_range(oid_start
.oid
, oid_end
.oid
, **extra_args
)
1554 self
.set_diff_oid(self
.oid
, **extra_args
)
1557 class DiffPanel(QtWidgets
.QWidget
):
1558 """A combined diff + search panel"""
1560 def __init__(self
, diff_widget
, text_widget
, parent
):
1561 super().__init
__(parent
)
1562 self
.diff_widget
= diff_widget
1563 self
.search_widget
= TextSearchWidget(text_widget
, self
)
1564 self
.search_widget
.hide()
1565 layout
= qtutils
.vbox(
1566 defs
.no_margin
, defs
.spacing
, self
.diff_widget
, self
.search_widget
1568 self
.setLayout(layout
)
1569 self
.setFocusProxy(self
.diff_widget
)
1571 self
.search_action
= qtutils
.add_action(
1573 N_('Search in Diff'),
1578 def show_search(self
):
1579 """Show a dialog for searching diffs"""
1580 # The diff search is only active in text mode.
1581 if not self
.search_widget
.isVisible():
1582 self
.search_widget
.show()
1583 self
.search_widget
.setFocus()
1586 class DiffInfoTask(qtutils
.Task
):
1587 """Gather diffs for a single commit"""
1589 def __init__(self
, context
, oid
, filename
):
1590 qtutils
.Task
.__init
__(self
)
1591 self
.context
= context
1593 self
.filename
= filename
1596 context
= self
.context
1598 return gitcmds
.diff_info(context
, oid
, filename
=self
.filename
)
1601 class DiffRangeTask(qtutils
.Task
):
1602 """Gather diffs for a range of commits"""
1604 def __init__(self
, context
, start
, end
, filename
):
1605 qtutils
.Task
.__init
__(self
)
1606 self
.context
= context
1609 self
.filename
= filename
1612 context
= self
.context
1613 return gitcmds
.diff_range(context
, self
.start
, self
.end
, filename
=self
.filename
)
1616 def apply_patches(context
, patches
=None):
1617 """Open the ApplyPatches dialog"""
1618 parent
= qtutils
.active_window()
1619 dlg
= new_apply_patches(context
, patches
=patches
, parent
=parent
)
1625 def new_apply_patches(context
, patches
=None, parent
=None):
1626 """Create a new instances of the ApplyPatches dialog"""
1627 dlg
= ApplyPatches(context
, parent
=parent
)
1629 dlg
.add_paths(patches
)
1633 def get_patches_from_paths(paths
):
1634 """Returns all patches beneath a given path"""
1635 paths
= [core
.decode(p
) for p
in paths
]
1636 patches
= [p
for p
in paths
if core
.isfile(p
) and p
.endswith(('.patch', '.mbox'))]
1637 dirs
= [p
for p
in paths
if core
.isdir(p
)]
1640 patches
.extend(get_patches_from_dir(d
))
1644 def get_patches_from_mimedata(mimedata
):
1645 """Extract path files from a QMimeData payload"""
1646 urls
= mimedata
.urls()
1649 paths
= [x
.path() for x
in urls
]
1650 return get_patches_from_paths(paths
)
1653 def get_patches_from_dir(path
):
1654 """Find patches in a subdirectory"""
1656 for root
, _
, files
in core
.walk(path
):
1657 for name
in [f
for f
in files
if f
.endswith(('.patch', '.mbox'))]:
1658 patches
.append(core
.decode(os
.path
.join(root
, name
)))
1662 class ApplyPatches(standard
.Dialog
):
1663 def __init__(self
, context
, parent
=None):
1664 super().__init
__(parent
=parent
)
1665 self
.context
= context
1666 self
.setWindowTitle(N_('Apply Patches'))
1667 self
.setAcceptDrops(True)
1668 if parent
is not None:
1669 self
.setWindowModality(Qt
.WindowModal
)
1671 self
.curdir
= core
.getcwd()
1672 self
.inner_drag
= False
1674 self
.usage
= QtWidgets
.QLabel()
1679 Drag and drop or use the <strong>Add</strong> button to add
1686 self
.tree
= PatchTreeWidget(parent
=self
)
1687 self
.tree
.setHeaderHidden(True)
1688 self
.tree
.itemSelectionChanged
.connect(self
._tree
_selection
_changed
)
1690 self
.diffwidget
= DiffWidget(context
, self
, is_commit
=True)
1692 self
.add_button
= qtutils
.create_toolbutton(
1693 text
=N_('Add'), icon
=icons
.add(), tooltip
=N_('Add patches (+)')
1696 self
.remove_button
= qtutils
.create_toolbutton(
1698 icon
=icons
.remove(),
1699 tooltip
=N_('Remove selected (Delete)'),
1702 self
.apply_button
= qtutils
.create_button(text
=N_('Apply'), icon
=icons
.ok())
1704 self
.close_button
= qtutils
.close_button()
1706 self
.add_action
= qtutils
.add_action(
1707 self
, N_('Add'), self
.add_files
, hotkeys
.ADD_ITEM
1710 self
.remove_action
= qtutils
.add_action(
1713 self
.tree
.remove_selected
,
1716 hotkeys
.REMOVE_ITEM
,
1719 self
.top_layout
= qtutils
.hbox(
1721 defs
.button_spacing
,
1728 self
.bottom_layout
= qtutils
.hbox(
1730 defs
.button_spacing
,
1736 self
.splitter
= qtutils
.splitter(Qt
.Vertical
, self
.tree
, self
.diffwidget
)
1738 self
.main_layout
= qtutils
.vbox(
1745 self
.setLayout(self
.main_layout
)
1747 qtutils
.connect_button(self
.add_button
, self
.add_files
)
1748 qtutils
.connect_button(self
.remove_button
, self
.tree
.remove_selected
)
1749 qtutils
.connect_button(self
.apply_button
, self
.apply_patches
)
1750 qtutils
.connect_button(self
.close_button
, self
.close
)
1752 self
.init_state(None, self
.resize
, 720, 480)
1754 def apply_patches(self
):
1755 items
= self
.tree
.items()
1758 context
= self
.context
1759 patches
= [i
.data(0, Qt
.UserRole
) for i
in items
]
1760 cmds
.do(cmds
.ApplyPatches
, context
, patches
)
1763 def add_files(self
):
1764 files
= qtutils
.open_files(
1765 N_('Select patch file(s)...'),
1766 directory
=self
.curdir
,
1767 filters
='Patches (*.patch *.mbox)',
1771 self
.curdir
= os
.path
.dirname(files
[0])
1772 self
.add_paths([core
.relpath(f
) for f
in files
])
1774 def dragEnterEvent(self
, event
):
1775 """Accepts drops if the mimedata contains patches"""
1776 super().dragEnterEvent(event
)
1777 patches
= get_patches_from_mimedata(event
.mimeData())
1779 event
.acceptProposedAction()
1781 def dropEvent(self
, event
):
1782 """Add dropped patches"""
1784 patches
= get_patches_from_mimedata(event
.mimeData())
1787 self
.add_paths(patches
)
1789 def add_paths(self
, paths
):
1790 self
.tree
.add_paths(paths
)
1792 def _tree_selection_changed(self
):
1793 items
= self
.tree
.selected_items()
1796 item
= items
[-1] # take the last item
1797 path
= item
.data(0, Qt
.UserRole
)
1798 if not core
.exists(path
):
1800 commit
= parse_patch(path
)
1801 self
.diffwidget
.set_details(
1802 commit
.oid
, commit
.author
, commit
.email
, commit
.date
, commit
.summary
1804 self
.diffwidget
.set_diff(commit
.diff
)
1806 def export_state(self
):
1807 """Export persistent settings"""
1808 state
= super().export_state()
1809 state
['sizes'] = get(self
.splitter
)
1812 def apply_state(self
, state
):
1813 """Apply persistent settings"""
1814 result
= super().apply_state(state
)
1816 self
.splitter
.setSizes(state
['sizes'])
1817 except (AttributeError, KeyError, ValueError, TypeError):
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 :])