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 TextSearchWidget
31 from . import standard
32 from . import imageview
35 class DiffSyntaxHighlighter(QtGui
.QSyntaxHighlighter
):
36 """Implements the diff syntax highlighting"""
41 DIFF_FILE_HEADER_STATE
= 2
46 DIFF_FILE_HEADER_START_RGX
= re
.compile(r
'diff --git a/.* b/.*')
47 DIFF_HUNK_HEADER_RGX
= re
.compile(
48 r
'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)'
50 BAD_WHITESPACE_RGX
= re
.compile(r
'\s+$')
52 def __init__(self
, context
, doc
, whitespace
=True, is_commit
=False):
53 QtGui
.QSyntaxHighlighter
.__init
__(self
, doc
)
54 self
.whitespace
= whitespace
56 self
.is_commit
= is_commit
58 QPalette
= QtGui
.QPalette
61 disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
62 header
= qtutils
.rgb_hex(disabled
)
64 dark
= palette
.color(QPalette
.Base
).lightnessF() < 0.5
66 self
.color_text
= qtutils
.rgb_triple(cfg
.color('text', '030303'))
67 self
.color_add
= qtutils
.rgb_triple(
68 cfg
.color('add', '77aa77' if dark
else 'd2ffe4')
70 self
.color_remove
= qtutils
.rgb_triple(
71 cfg
.color('remove', 'aa7777' if dark
else 'fee0e4')
73 self
.color_header
= qtutils
.rgb_triple(cfg
.color('header', header
))
75 self
.diff_header_fmt
= qtutils
.make_format(foreground
=self
.color_header
)
76 self
.bold_diff_header_fmt
= qtutils
.make_format(
77 foreground
=self
.color_header
, bold
=True
80 self
.diff_add_fmt
= qtutils
.make_format(
81 foreground
=self
.color_text
, background
=self
.color_add
83 self
.diff_remove_fmt
= qtutils
.make_format(
84 foreground
=self
.color_text
, background
=self
.color_remove
86 self
.bad_whitespace_fmt
= qtutils
.make_format(background
=Qt
.red
)
87 self
.setCurrentBlockState(self
.INITIAL_STATE
)
89 def set_enabled(self
, enabled
):
90 self
.enabled
= enabled
92 def highlightBlock(self
, text
):
93 """Highlight the current text block"""
94 if not self
.enabled
or not text
:
97 state
= self
.get_next_state(text
)
98 if state
== self
.DIFFSTAT_STATE
:
99 state
, formats
= self
.get_formats_for_diffstat(state
, text
)
100 elif state
== self
.DIFF_FILE_HEADER_STATE
:
101 state
, formats
= self
.get_formats_for_diff_header(state
, text
)
102 elif state
== self
.DIFF_STATE
:
103 state
, formats
= self
.get_formats_for_diff_text(state
, text
)
105 for start
, end
, fmt
in formats
:
106 self
.setFormat(start
, end
, fmt
)
108 self
.setCurrentBlockState(state
)
110 def get_next_state(self
, text
):
111 """Transition to the next state based on the input text"""
112 state
= self
.previousBlockState()
113 if state
== DiffSyntaxHighlighter
.INITIAL_STATE
:
114 if text
.startswith('Submodule '):
115 state
= DiffSyntaxHighlighter
.SUBMODULE_STATE
116 elif text
.startswith('diff --git '):
117 state
= DiffSyntaxHighlighter
.DIFFSTAT_STATE
119 state
= DiffSyntaxHighlighter
.DEFAULT_STATE
121 state
= DiffSyntaxHighlighter
.DIFFSTAT_STATE
125 def get_formats_for_diffstat(self
, state
, text
):
126 """Returns (state, [(start, end, fmt), ...]) for highlighting diffstat text"""
128 if self
.DIFF_FILE_HEADER_START_RGX
.match(text
):
129 state
= self
.DIFF_FILE_HEADER_STATE
131 fmt
= self
.diff_header_fmt
132 formats
.append((0, end
, fmt
))
133 elif self
.DIFF_HUNK_HEADER_RGX
.match(text
):
134 state
= self
.DIFF_STATE
136 fmt
= self
.bold_diff_header_fmt
137 formats
.append((0, end
, fmt
))
139 offset
= text
.index('|')
140 formats
.append((0, offset
, self
.bold_diff_header_fmt
))
141 formats
.append((offset
, len(text
) - offset
, self
.diff_header_fmt
))
143 formats
.append((0, len(text
), self
.diff_header_fmt
))
145 return state
, formats
147 def get_formats_for_diff_header(self
, state
, text
):
148 """Returns (state, [(start, end, fmt), ...]) for highlighting diff headers"""
150 if self
.DIFF_HUNK_HEADER_RGX
.match(text
):
151 state
= self
.DIFF_STATE
152 formats
.append((0, len(text
), self
.bold_diff_header_fmt
))
154 formats
.append((0, len(text
), self
.diff_header_fmt
))
156 return state
, formats
158 def get_formats_for_diff_text(self
, state
, text
):
159 """Return (state, [(start, end fmt), ...]) for highlighting diff text"""
162 if self
.DIFF_FILE_HEADER_START_RGX
.match(text
):
163 state
= self
.DIFF_FILE_HEADER_STATE
164 formats
.append((0, len(text
), self
.diff_header_fmt
))
166 elif self
.DIFF_HUNK_HEADER_RGX
.match(text
):
167 formats
.append((0, len(text
), self
.bold_diff_header_fmt
))
169 elif text
.startswith('-'):
171 state
= self
.END_STATE
173 formats
.append((0, len(text
), self
.diff_remove_fmt
))
175 elif text
.startswith('+'):
176 formats
.append((0, len(text
), self
.diff_add_fmt
))
178 match
= self
.BAD_WHITESPACE_RGX
.search(text
)
179 if match
is not None:
180 start
= match
.start()
181 formats
.append((start
, len(text
) - start
, self
.bad_whitespace_fmt
))
183 return state
, formats
186 # pylint: disable=too-many-ancestors
187 class DiffTextEdit(VimHintedPlainTextEdit
):
188 """A textedit for interacting with diff text"""
191 self
, context
, parent
, is_commit
=False, whitespace
=True, numbers
=False
193 VimHintedPlainTextEdit
.__init
__(self
, context
, '', parent
=parent
)
194 # Diff/patch syntax highlighter
195 self
.highlighter
= DiffSyntaxHighlighter(
196 context
, self
.document(), is_commit
=is_commit
, whitespace
=whitespace
199 self
.numbers
= DiffLineNumbers(context
, self
)
203 self
.scrollvalue
= None
205 self
.copy_diff_action
= qtutils
.add_action(
211 self
.copy_diff_action
.setIcon(icons
.copy())
212 self
.copy_diff_action
.setEnabled(False)
213 self
.menu_actions
.append(self
.copy_diff_action
)
215 # pylint: disable=no-member
216 self
.cursorPositionChanged
.connect(self
._cursor
_changed
)
217 self
.selectionChanged
.connect(self
._selection
_changed
)
219 def setFont(self
, font
):
220 """Override setFont() so that we can use a custom "block" cursor"""
221 super().setFont(font
)
222 if prefs
.block_cursor(self
.context
):
223 metrics
= QtGui
.QFontMetrics(font
)
224 width
= metrics
.width('M')
225 self
.setCursorWidth(width
)
227 def _cursor_changed(self
):
228 """Update the line number display when the cursor changes"""
229 line_number
= max(0, self
.textCursor().blockNumber())
230 if self
.numbers
is not None:
231 self
.numbers
.set_highlighted(line_number
)
233 def _selection_changed(self
):
234 """Respond to selection changes"""
235 selected
= bool(self
.selected_text())
236 self
.copy_diff_action
.setEnabled(selected
)
238 def resizeEvent(self
, event
):
239 super().resizeEvent(event
)
241 self
.numbers
.refresh_size()
243 def save_scrollbar(self
):
244 """Save the scrollbar value, but only on the first call"""
245 if self
.scrollvalue
is None:
246 scrollbar
= self
.verticalScrollBar()
248 scrollvalue
= get(scrollbar
)
251 self
.scrollvalue
= scrollvalue
253 def restore_scrollbar(self
):
254 """Restore the scrollbar and clear state"""
255 scrollbar
= self
.verticalScrollBar()
256 scrollvalue
= self
.scrollvalue
257 if scrollbar
and scrollvalue
is not None:
258 scrollbar
.setValue(scrollvalue
)
259 self
.scrollvalue
= None
261 def set_loading_message(self
):
262 """Add a pending loading message in the diff view"""
263 self
.hint
.set_value('+++ ' + N_('Loading...'))
266 def set_diff(self
, diff
):
267 """Set the diff text, but save the scrollbar"""
268 diff
= diff
.rstrip('\n') # diffs include two empty newlines
269 self
.save_scrollbar()
271 self
.hint
.set_value('')
273 self
.numbers
.set_diff(diff
)
276 self
.restore_scrollbar()
278 def selected_diff_stripped(self
):
279 """Return the selected diff stripped of any diff characters"""
280 sep
, selection
= self
.selected_text_lines()
281 return sep
.join(_strip_diff(line
) for line
in selection
)
284 """Copy the selected diff text stripped of any diff prefix characters"""
285 text
= self
.selected_diff_stripped()
286 qtutils
.set_clipboard(text
)
288 def selected_lines(self
):
289 """Return selected lines"""
290 cursor
= self
.textCursor()
291 selection_start
= cursor
.selectionStart()
292 selection_end
= max(selection_start
, cursor
.selectionEnd() - 1)
299 for line_idx
, line
in enumerate(get(self
, default
='').splitlines()):
300 line_end
= line_start
+ len(line
)
301 if line_start
<= selection_start
<= line_end
:
302 first_line_idx
= line_idx
303 if line_start
<= selection_end
<= line_end
:
304 last_line_idx
= line_idx
306 line_start
= line_end
+ 1
308 if first_line_idx
== -1:
309 first_line_idx
= line_idx
311 if last_line_idx
== -1:
312 last_line_idx
= line_idx
314 return first_line_idx
, last_line_idx
316 def selected_text_lines(self
):
317 """Return selected lines and the CRLF / LF separator"""
318 first_line_idx
, last_line_idx
= self
.selected_lines()
319 text
= get(self
, default
='')
322 for line_idx
, line
in enumerate(text
.split(sep
)):
323 if first_line_idx
<= line_idx
<= last_line_idx
:
329 """Return either CRLF or LF based on the content"""
337 def _strip_diff(value
):
338 """Remove +/-/<space> from a selection"""
339 if value
.startswith(('+', '-', ' ')):
344 class DiffLineNumbers(TextDecorator
):
345 def __init__(self
, context
, parent
):
346 TextDecorator
.__init
__(self
, parent
)
347 self
.highlight_line
= -1
349 self
.parser
= diffparse
.DiffLines()
350 self
.formatter
= diffparse
.FormatDigits()
352 self
.setFont(qtutils
.diff_font(context
))
353 self
._char
_width
= self
.fontMetrics().width('0')
355 QPalette
= QtGui
.QPalette
356 self
._palette
= palette
= self
.palette()
357 self
._base
= palette
.color(QtGui
.QPalette
.Base
)
358 self
._highlight
= palette
.color(QPalette
.Highlight
)
359 self
._highlight
.setAlphaF(0.3)
360 self
._highlight
_text
= palette
.color(QPalette
.HighlightedText
)
361 self
._window
= palette
.color(QPalette
.Window
)
362 self
._disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
364 def set_diff(self
, diff
):
365 self
.lines
= self
.parser
.parse(diff
)
366 self
.formatter
.set_digits(self
.parser
.digits())
368 def width_hint(self
):
369 if not self
.isVisible():
375 extra
= 3 # one space in-between, one space after
378 extra
= 2 # one space in-between, one space after
380 digits
= parser
.digits() * columns
382 return defs
.margin
+ (self
._char
_width
* (digits
+ extra
))
384 def set_highlighted(self
, line_number
):
385 """Set the line to highlight"""
386 self
.highlight_line
= line_number
388 def current_line(self
):
390 if lines
and self
.highlight_line
>= 0:
391 # Find the next valid line
392 for i
in range(self
.highlight_line
, len(lines
)):
393 # take the "new" line number: last value in tuple
394 line_number
= lines
[i
][-1]
398 # Find the previous valid line
399 for i
in range(self
.highlight_line
- 1, -1, -1):
400 # take the "new" line number: last value in tuple
402 line_number
= lines
[i
][-1]
407 def paintEvent(self
, event
):
408 """Paint the line number"""
412 painter
= QtGui
.QPainter(self
)
413 painter
.fillRect(event
.rect(), self
._base
)
416 content_offset
= editor
.contentOffset()
417 block
= editor
.firstVisibleBlock()
419 text_width
= width
- (defs
.margin
* 2)
420 text_flags
= Qt
.AlignRight | Qt
.AlignVCenter
421 event_rect_bottom
= event
.rect().bottom()
423 highlight_line
= self
.highlight_line
424 highlight
= self
._highlight
425 highlight_text
= self
._highlight
_text
426 disabled
= self
._disabled
430 num_lines
= len(lines
)
432 while block
.isValid():
433 block_number
= block
.blockNumber()
434 if block_number
>= num_lines
:
436 block_geom
= editor
.blockBoundingGeometry(block
)
437 rect
= block_geom
.translated(content_offset
).toRect()
438 if not block
.isVisible() or rect
.top() >= event_rect_bottom
:
441 if block_number
== highlight_line
:
442 painter
.fillRect(rect
.x(), rect
.y(), width
, rect
.height(), highlight
)
443 painter
.setPen(highlight_text
)
445 painter
.setPen(disabled
)
447 line
= lines
[block_number
]
450 text
= fmt
.value(a
, b
)
452 old
, base
, new
= line
453 text
= fmt
.merge_value(old
, base
, new
)
467 class Viewer(QtWidgets
.QFrame
):
468 """Text and image diff viewers"""
473 def __init__(self
, context
, parent
=None):
474 super().__init
__(parent
)
476 self
.context
= context
477 self
.model
= model
= context
.model
480 self
.options
= options
= Options(self
)
481 self
.text
= DiffEditor(context
, options
, self
)
482 self
.image
= imageview
.ImageView(parent
=self
)
483 self
.image
.setFocusPolicy(Qt
.NoFocus
)
484 self
.search_widget
= TextSearchWidget(self
.text
, self
)
485 self
.search_widget
.hide()
486 self
._drag
_has
_patches
= False
488 self
.setAcceptDrops(True)
489 self
.setFocusProxy(self
.text
)
491 stack
= self
.stack
= QtWidgets
.QStackedWidget(self
)
492 stack
.addWidget(self
.text
)
493 stack
.addWidget(self
.image
)
495 self
.main_layout
= qtutils
.vbox(
501 self
.setLayout(self
.main_layout
)
504 model
.images_changed
.connect(self
.set_images
, type=Qt
.QueuedConnection
)
506 # Observe the diff type
507 model
.diff_type_changed
.connect(self
.set_diff_type
, type=Qt
.QueuedConnection
)
509 # Observe the file type
510 model
.file_type_changed
.connect(self
.set_file_type
, type=Qt
.QueuedConnection
)
512 # Observe the image mode combo box
513 options
.image_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
514 options
.zoom_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
516 self
.search_action
= qtutils
.add_action(
518 N_('Search in Diff'),
519 self
.show_search_diff
,
523 def dragEnterEvent(self
, event
):
524 """Accepts drops if the mimedata contains patches"""
525 super().dragEnterEvent(event
)
526 patches
= get_patches_from_mimedata(event
.mimeData())
528 event
.acceptProposedAction()
529 self
._drag
_has
_patches
= True
531 def dragLeaveEvent(self
, event
):
532 """End the drag+drop interaction"""
533 super().dragLeaveEvent(event
)
534 if self
._drag
_has
_patches
:
538 self
._drag
_has
_patches
= False
540 def dropEvent(self
, event
):
541 """Apply patches when dropped onto the widget"""
542 if not self
._drag
_has
_patches
:
545 event
.setDropAction(Qt
.CopyAction
)
546 super().dropEvent(event
)
547 self
._drag
_has
_patches
= False
549 patches
= get_patches_from_mimedata(event
.mimeData())
551 apply_patches(self
.context
, patches
=patches
)
553 event
.accept() # must be called after dropEvent()
555 def show_search_diff(self
):
556 """Show a dialog for searching diffs"""
557 # The diff search is only active in text mode.
558 if self
.stack
.currentIndex() != self
.INDEX_TEXT
:
560 if not self
.search_widget
.isVisible():
561 self
.search_widget
.show()
562 self
.search_widget
.setFocus(True)
564 def export_state(self
, state
):
565 state
['show_diff_line_numbers'] = self
.options
.show_line_numbers
.isChecked()
566 state
['image_diff_mode'] = self
.options
.image_mode
.currentIndex()
567 state
['image_zoom_mode'] = self
.options
.zoom_mode
.currentIndex()
568 state
['word_wrap'] = self
.options
.enable_word_wrapping
.isChecked()
571 def apply_state(self
, state
):
572 diff_numbers
= bool(state
.get('show_diff_line_numbers', False))
573 self
.set_line_numbers(diff_numbers
, update
=True)
575 image_mode
= utils
.asint(state
.get('image_diff_mode', 0))
576 self
.options
.image_mode
.set_index(image_mode
)
578 zoom_mode
= utils
.asint(state
.get('image_zoom_mode', 0))
579 self
.options
.zoom_mode
.set_index(zoom_mode
)
581 word_wrap
= bool(state
.get('word_wrap', True))
582 self
.set_word_wrapping(word_wrap
, update
=True)
585 def set_diff_type(self
, diff_type
):
586 """Manage the image and text diff views when selection changes"""
587 # The "diff type" is whether the diff viewer is displaying an image.
588 self
.options
.set_diff_type(diff_type
)
589 if diff_type
== main
.Types
.IMAGE
:
590 self
.stack
.setCurrentWidget(self
.image
)
591 self
.search_widget
.hide()
594 self
.stack
.setCurrentWidget(self
.text
)
596 def set_file_type(self
, file_type
):
597 """Manage the diff options when the file type changes"""
598 # The "file type" is whether the file itself is an image.
599 self
.options
.set_file_type(file_type
)
601 def update_options(self
):
602 """Emit a signal indicating that options have changed"""
603 self
.text
.update_options()
605 def set_line_numbers(self
, enabled
, update
=False):
606 """Enable/disable line numbers in the text widget"""
607 self
.text
.set_line_numbers(enabled
, update
=update
)
609 def set_word_wrapping(self
, enabled
, update
=False):
610 """Enable/disable word wrapping in the text widget"""
611 self
.text
.set_word_wrapping(enabled
, update
=update
)
614 self
.image
.pixmap
= QtGui
.QPixmap()
618 for image
, unlink
in self
.images
:
619 if unlink
and core
.exists(image
):
623 def set_images(self
, images
):
630 # In order to comp, we first have to load all the images
631 all_pixmaps
= [QtGui
.QPixmap(image
[0]) for image
in images
]
632 pixmaps
= [pixmap
for pixmap
in all_pixmaps
if not pixmap
.isNull()]
637 self
.pixmaps
= pixmaps
645 mode
= self
.options
.image_mode
.currentIndex()
646 if mode
== self
.options
.SIDE_BY_SIDE
:
647 image
= self
.render_side_by_side()
648 elif mode
== self
.options
.DIFF
:
649 image
= self
.render_diff()
650 elif mode
== self
.options
.XOR
:
651 image
= self
.render_xor()
652 elif mode
== self
.options
.PIXEL_XOR
:
653 image
= self
.render_pixel_xor()
655 image
= self
.render_side_by_side()
657 image
= QtGui
.QPixmap()
658 self
.image
.pixmap
= image
661 zoom_mode
= self
.options
.zoom_mode
.currentIndex()
662 zoom_factor
= self
.options
.zoom_factors
[zoom_mode
][1]
663 if zoom_factor
> 0.0:
664 self
.image
.resetTransform()
665 self
.image
.scale(zoom_factor
, zoom_factor
)
666 poly
= self
.image
.mapToScene(self
.image
.viewport().rect())
667 self
.image
.last_scene_roi
= poly
.boundingRect()
669 def render_side_by_side(self
):
670 # Side-by-side lineup comp
671 pixmaps
= self
.pixmaps
672 width
= sum(pixmap
.width() for pixmap
in pixmaps
)
673 height
= max(pixmap
.height() for pixmap
in pixmaps
)
674 image
= create_image(width
, height
)
677 painter
= create_painter(image
)
679 for pixmap
in pixmaps
:
680 painter
.drawPixmap(x
, 0, pixmap
)
686 def render_comp(self
, comp_mode
):
687 # Get the max size to use as the render canvas
688 pixmaps
= self
.pixmaps
689 if len(pixmaps
) == 1:
692 width
= max(pixmap
.width() for pixmap
in pixmaps
)
693 height
= max(pixmap
.height() for pixmap
in pixmaps
)
694 image
= create_image(width
, height
)
696 painter
= create_painter(image
)
697 for pixmap
in (pixmaps
[0], pixmaps
[-1]):
698 x
= (width
- pixmap
.width()) // 2
699 y
= (height
- pixmap
.height()) // 2
700 painter
.drawPixmap(x
, y
, pixmap
)
701 painter
.setCompositionMode(comp_mode
)
706 def render_diff(self
):
707 comp_mode
= QtGui
.QPainter
.CompositionMode_Difference
708 return self
.render_comp(comp_mode
)
710 def render_xor(self
):
711 comp_mode
= QtGui
.QPainter
.CompositionMode_Xor
712 return self
.render_comp(comp_mode
)
714 def render_pixel_xor(self
):
715 comp_mode
= QtGui
.QPainter
.RasterOp_SourceXorDestination
716 return self
.render_comp(comp_mode
)
719 def create_image(width
, height
):
720 size
= QtCore
.QSize(width
, height
)
721 image
= QtGui
.QImage(size
, QtGui
.QImage
.Format_ARGB32_Premultiplied
)
722 image
.fill(Qt
.transparent
)
726 def create_painter(image
):
727 painter
= QtGui
.QPainter(image
)
728 painter
.fillRect(image
.rect(), Qt
.transparent
)
732 class Options(QtWidgets
.QWidget
):
733 """Provide the options widget used by the editor
735 Actions are registered on the parent widget.
739 # mode combobox indexes
745 def __init__(self
, parent
):
746 super().__init
__(parent
)
749 self
.ignore_space_at_eol
= self
.add_option(
750 N_('Ignore changes in whitespace at EOL')
753 self
.ignore_space_change
= self
.add_option(
754 N_('Ignore changes in amount of whitespace')
757 self
.ignore_all_space
= self
.add_option(N_('Ignore all whitespace'))
759 self
.function_context
= self
.add_option(
760 N_('Show whole surrounding functions of changes')
763 self
.show_line_numbers
= qtutils
.add_action_bool(
764 self
, N_('Show line numbers'), self
.set_line_numbers
, True
766 self
.enable_word_wrapping
= qtutils
.add_action_bool(
767 self
, N_('Enable word wrapping'), self
.set_word_wrapping
, True
770 self
.options
= qtutils
.create_action_button(
771 tooltip
=N_('Diff Options'), icon
=icons
.configure()
774 self
.toggle_image_diff
= qtutils
.create_action_button(
775 tooltip
=N_('Toggle image diff'), icon
=icons
.visualize()
777 self
.toggle_image_diff
.hide()
779 self
.image_mode
= qtutils
.combo(
780 [N_('Side by side'), N_('Diff'), N_('XOR'), N_('Pixel XOR')]
783 self
.zoom_factors
= (
784 (N_('Zoom to Fit'), 0.0),
792 zoom_modes
= [factor
[0] for factor
in self
.zoom_factors
]
793 self
.zoom_mode
= qtutils
.combo(zoom_modes
, parent
=self
)
795 self
.menu
= menu
= qtutils
.create_menu(N_('Diff Options'), self
.options
)
796 self
.options
.setMenu(menu
)
797 menu
.addAction(self
.ignore_space_at_eol
)
798 menu
.addAction(self
.ignore_space_change
)
799 menu
.addAction(self
.ignore_all_space
)
801 menu
.addAction(self
.function_context
)
802 menu
.addAction(self
.show_line_numbers
)
804 menu
.addAction(self
.enable_word_wrapping
)
807 layout
= qtutils
.hbox(
813 self
.toggle_image_diff
,
815 self
.setLayout(layout
)
818 self
.image_mode
.setFocusPolicy(Qt
.NoFocus
)
819 self
.zoom_mode
.setFocusPolicy(Qt
.NoFocus
)
820 self
.options
.setFocusPolicy(Qt
.NoFocus
)
821 self
.toggle_image_diff
.setFocusPolicy(Qt
.NoFocus
)
822 self
.setFocusPolicy(Qt
.NoFocus
)
824 def set_file_type(self
, file_type
):
825 """Set whether we are viewing an image file type"""
826 is_image
= file_type
== main
.Types
.IMAGE
827 self
.toggle_image_diff
.setVisible(is_image
)
829 def set_diff_type(self
, diff_type
):
830 """Toggle between image and text diffs"""
831 is_text
= diff_type
== main
.Types
.TEXT
832 is_image
= diff_type
== main
.Types
.IMAGE
833 self
.options
.setVisible(is_text
)
834 self
.image_mode
.setVisible(is_image
)
835 self
.zoom_mode
.setVisible(is_image
)
837 self
.toggle_image_diff
.setIcon(icons
.diff())
839 self
.toggle_image_diff
.setIcon(icons
.visualize())
841 def add_option(self
, title
):
842 """Add a diff option which calls update_options() on change"""
843 action
= qtutils
.add_action(self
, title
, self
.update_options
)
844 action
.setCheckable(True)
847 def update_options(self
):
848 """Update diff options in response to UI events"""
849 space_at_eol
= get(self
.ignore_space_at_eol
)
850 space_change
= get(self
.ignore_space_change
)
851 all_space
= get(self
.ignore_all_space
)
852 function_context
= get(self
.function_context
)
853 gitcmds
.update_diff_overrides(
854 space_at_eol
, space_change
, all_space
, function_context
856 self
.widget
.update_options()
858 def set_line_numbers(self
, value
):
859 """Enable / disable line numbers"""
860 self
.widget
.set_line_numbers(value
, update
=False)
862 def set_word_wrapping(self
, value
):
863 """Respond to Qt action callbacks"""
864 self
.widget
.set_word_wrapping(value
, update
=False)
866 def hide_advanced_options(self
):
867 """Hide advanced options that are not applicable to the DiffWidget"""
868 self
.show_line_numbers
.setVisible(False)
869 self
.ignore_space_at_eol
.setVisible(False)
870 self
.ignore_space_change
.setVisible(False)
871 self
.ignore_all_space
.setVisible(False)
872 self
.function_context
.setVisible(False)
875 # pylint: disable=too-many-ancestors
876 class DiffEditor(DiffTextEdit
):
879 options_changed
= Signal()
881 def __init__(self
, context
, options
, parent
):
882 DiffTextEdit
.__init
__(self
, context
, parent
, numbers
=True)
883 self
.context
= context
884 self
.model
= model
= context
.model
885 self
.selection_model
= selection_model
= context
.selection
887 # "Diff Options" tool menu
888 self
.options
= options
890 self
.action_apply_selection
= qtutils
.add_action(
893 self
.apply_selection
,
895 hotkeys
.STAGE_DIFF_ALT
,
898 self
.action_revert_selection
= qtutils
.add_action(
899 self
, 'Revert', self
.revert_selection
, hotkeys
.REVERT
, hotkeys
.REVERT_ALT
901 self
.action_revert_selection
.setIcon(icons
.undo())
903 self
.action_edit_and_apply_selection
= qtutils
.add_action(
906 partial(self
.apply_selection
, edit
=True),
907 hotkeys
.EDIT_AND_STAGE_DIFF
,
910 self
.action_edit_and_revert_selection
= qtutils
.add_action(
913 partial(self
.revert_selection
, edit
=True),
914 hotkeys
.EDIT_AND_REVERT
,
916 self
.action_edit_and_revert_selection
.setIcon(icons
.undo())
917 self
.launch_editor
= actions
.launch_editor_at_line(
918 context
, self
, hotkeys
.EDIT_SHORT
, *hotkeys
.ACCEPT
920 self
.launch_difftool
= actions
.launch_difftool(context
, self
)
921 self
.stage_or_unstage
= actions
.stage_or_unstage(context
, self
)
923 # Emit up/down signals so that they can be routed by the main widget
924 self
.move_up
= actions
.move_up(self
)
925 self
.move_down
= actions
.move_down(self
)
927 model
.diff_text_updated
.connect(self
.set_diff
, type=Qt
.QueuedConnection
)
929 selection_model
.selection_changed
.connect(
930 self
.refresh
, type=Qt
.QueuedConnection
932 # Update the selection model when the cursor changes
933 self
.cursorPositionChanged
.connect(self
._update
_line
_number
)
935 qtutils
.connect_button(options
.toggle_image_diff
, self
.toggle_diff_type
)
937 def toggle_diff_type(self
):
938 cmds
.do(cmds
.ToggleDiffType
, self
.context
)
942 s
= self
.selection_model
.selection()
944 if model
.is_partially_stageable():
945 item
= s
.modified
[0] if s
.modified
else None
946 if item
in model
.submodules
:
948 elif item
not in model
.unstaged_deleted
:
950 self
.action_revert_selection
.setEnabled(enabled
)
952 def set_line_numbers(self
, enabled
, update
=False):
953 """Enable/disable the diff line number display"""
954 self
.numbers
.setVisible(enabled
)
956 with qtutils
.BlockSignals(self
.options
.show_line_numbers
):
957 self
.options
.show_line_numbers
.setChecked(enabled
)
958 # Refresh the display. Not doing this results in the display not
959 # correctly displaying the line numbers widget until the text scrolls.
960 self
.set_value(self
.value())
962 def update_options(self
):
963 self
.options_changed
.emit()
965 def create_context_menu(self
, event_pos
):
966 """Override create_context_menu() to display a completely custom menu"""
967 menu
= super().create_context_menu(event_pos
)
968 context
= self
.context
970 s
= self
.selection_model
.selection()
971 filename
= self
.selection_model
.filename()
973 # These menu actions will be inserted at the start of the widget.
974 current_actions
= menu
.actions()
976 add_action
= menu_actions
.append
977 edit_actions_added
= False
979 if model
.is_stageable() or model
.is_unstageable():
980 if (model
.is_amend_mode() and s
.staged
) or not self
.model
.is_stageable():
981 self
.stage_or_unstage
.setText(N_('Unstage'))
982 self
.stage_or_unstage
.setIcon(icons
.remove())
984 self
.stage_or_unstage
.setText(N_('Stage'))
985 self
.stage_or_unstage
.setIcon(icons
.add())
986 add_action(self
.stage_or_unstage
)
988 if s
.staged
and model
.is_unstageable():
990 if item
not in model
.submodules
and item
not in model
.staged_deleted
:
991 if self
.has_selection():
992 apply_text
= N_('Unstage Selected Lines')
994 apply_text
= N_('Unstage Diff Hunk')
995 self
.action_apply_selection
.setText(apply_text
)
996 self
.action_apply_selection
.setIcon(icons
.remove())
997 add_action(self
.action_apply_selection
)
999 if model
.is_partially_stageable():
1000 item
= s
.modified
[0] if s
.modified
else None
1001 if item
in model
.submodules
:
1002 path
= core
.abspath(item
)
1003 action
= qtutils
.add_action_with_icon(
1007 cmds
.run(cmds
.Stage
, context
, s
.modified
),
1008 hotkeys
.STAGE_SELECTION
,
1012 action
= qtutils
.add_action_with_icon(
1015 N_('Launch git-cola'),
1016 cmds
.run(cmds
.OpenRepo
, context
, path
),
1019 elif item
and item
not in model
.unstaged_deleted
:
1020 if self
.has_selection():
1021 apply_text
= N_('Stage Selected Lines')
1022 edit_and_apply_text
= N_('Edit Selected Lines to Stage...')
1023 revert_text
= N_('Revert Selected Lines...')
1024 edit_and_revert_text
= N_('Edit Selected Lines to Revert...')
1026 apply_text
= N_('Stage Diff Hunk')
1027 edit_and_apply_text
= N_('Edit Diff Hunk to Stage...')
1028 revert_text
= N_('Revert Diff Hunk...')
1029 edit_and_revert_text
= N_('Edit Diff Hunk to Revert...')
1031 self
.action_apply_selection
.setText(apply_text
)
1032 self
.action_apply_selection
.setIcon(icons
.add())
1033 add_action(self
.action_apply_selection
)
1035 self
.action_revert_selection
.setText(revert_text
)
1036 add_action(self
.action_revert_selection
)
1038 # Do not show the "edit" action when the file does not exist.
1039 add_action(qtutils
.menu_separator(menu
))
1040 if filename
and core
.exists(filename
):
1041 add_action(self
.launch_editor
)
1042 # Removed files can still be diffed.
1043 add_action(self
.launch_difftool
)
1044 edit_actions_added
= True
1046 add_action(qtutils
.menu_separator(menu
))
1047 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
1048 self
.action_edit_and_apply_selection
.setIcon(icons
.add())
1049 add_action(self
.action_edit_and_apply_selection
)
1051 self
.action_edit_and_revert_selection
.setText(edit_and_revert_text
)
1052 add_action(self
.action_edit_and_revert_selection
)
1054 if s
.staged
and model
.is_unstageable():
1056 if item
in model
.submodules
:
1057 path
= core
.abspath(item
)
1058 action
= qtutils
.add_action_with_icon(
1061 cmds
.Unstage
.name(),
1062 cmds
.run(cmds
.Unstage
, context
, s
.staged
),
1063 hotkeys
.STAGE_SELECTION
,
1067 qtutils
.add_action_with_icon(
1070 N_('Launch git-cola'),
1071 cmds
.run(cmds
.OpenRepo
, context
, path
),
1075 elif item
not in model
.staged_deleted
:
1076 # Do not show the "edit" action when the file does not exist.
1077 add_action(qtutils
.menu_separator(menu
))
1078 if filename
and core
.exists(filename
):
1079 add_action(self
.launch_editor
)
1080 # Removed files can still be diffed.
1081 add_action(self
.launch_difftool
)
1082 add_action(qtutils
.menu_separator(menu
))
1083 edit_actions_added
= True
1085 if self
.has_selection():
1086 edit_and_apply_text
= N_('Edit Selected Lines to Unstage...')
1088 edit_and_apply_text
= N_('Edit Diff Hunk to Unstage...')
1089 self
.action_edit_and_apply_selection
.setText(edit_and_apply_text
)
1090 self
.action_edit_and_apply_selection
.setIcon(icons
.remove())
1091 add_action(self
.action_edit_and_apply_selection
)
1093 if not edit_actions_added
and (model
.is_stageable() or model
.is_unstageable()):
1094 add_action(qtutils
.menu_separator(menu
))
1095 # Do not show the "edit" action when the file does not exist.
1096 # Untracked files exist by definition.
1097 if filename
and core
.exists(filename
):
1098 add_action(self
.launch_editor
)
1100 # Removed files can still be diffed.
1101 add_action(self
.launch_difftool
)
1103 add_action(qtutils
.menu_separator(menu
))
1104 _add_patch_actions(self
, self
.context
, menu
)
1106 # Add the Previous/Next File actions, which improves discoverability
1107 # of their associated shortcuts
1108 add_action(qtutils
.menu_separator(menu
))
1109 add_action(self
.move_up
)
1110 add_action(self
.move_down
)
1111 add_action(qtutils
.menu_separator(menu
))
1114 first_action
= current_actions
[0]
1117 menu
.insertActions(first_action
, menu_actions
)
1121 def mousePressEvent(self
, event
):
1122 if event
.button() == Qt
.RightButton
:
1123 # Intercept right-click to move the cursor to the current position.
1124 # setTextCursor() clears the selection so this is only done when
1125 # nothing is selected.
1126 if not self
.has_selection():
1127 cursor
= self
.cursorForPosition(event
.pos())
1128 self
.setTextCursor(cursor
)
1130 return super().mousePressEvent(event
)
1132 def setPlainText(self
, text
):
1133 """setPlainText(str) while retaining scrollbar positions"""
1136 highlight
= mode
not in (
1139 model
.mode_untracked
,
1141 self
.highlighter
.set_enabled(highlight
)
1143 scrollbar
= self
.verticalScrollBar()
1145 scrollvalue
= get(scrollbar
)
1152 DiffTextEdit
.setPlainText(self
, text
)
1154 if scrollbar
and scrollvalue
is not None:
1155 scrollbar
.setValue(scrollvalue
)
1157 def apply_selection(self
, *, edit
=False):
1159 s
= self
.selection_model
.single_selection()
1160 if model
.is_partially_stageable() and (s
.modified
or s
.untracked
):
1161 self
.process_diff_selection(edit
=edit
)
1162 elif model
.is_unstageable():
1163 self
.process_diff_selection(reverse
=True, edit
=edit
)
1165 def revert_selection(self
, *, edit
=False):
1166 """Destructively revert selected lines or hunk from a worktree file."""
1169 if self
.has_selection():
1170 title
= N_('Revert Selected Lines?')
1171 ok_text
= N_('Revert Selected Lines')
1173 title
= N_('Revert Diff Hunk?')
1174 ok_text
= N_('Revert Diff Hunk')
1176 if not Interaction
.confirm(
1179 'This operation drops uncommitted changes.\n'
1180 'These changes cannot be recovered.'
1182 N_('Revert the uncommitted changes?'),
1188 self
.process_diff_selection(reverse
=True, apply_to_worktree
=True, edit
=edit
)
1190 def extract_patch(self
, reverse
=False):
1191 first_line_idx
, last_line_idx
= self
.selected_lines()
1192 patch
= diffparse
.Patch
.parse(self
.model
.filename
, self
.model
.diff_text
)
1193 if self
.has_selection():
1194 return patch
.extract_subset(first_line_idx
, last_line_idx
, reverse
=reverse
)
1195 return patch
.extract_hunk(first_line_idx
, reverse
=reverse
)
1197 def patch_encoding(self
):
1198 if isinstance(self
.model
.diff_text
, core
.UStr
):
1199 # original encoding must prevail
1200 return self
.model
.diff_text
.encoding
1201 return self
.context
.cfg
.file_encoding(self
.model
.filename
)
1203 def process_diff_selection(
1204 self
, reverse
=False, apply_to_worktree
=False, edit
=False
1206 """Implement un/staging of the selected line(s) or hunk."""
1207 if self
.selection_model
.is_empty():
1209 patch
= self
.extract_patch(reverse
)
1210 if not patch
.has_changes():
1212 patch_encoding
= self
.patch_encoding()
1220 apply_to_worktree
=apply_to_worktree
,
1222 if not patch
.has_changes():
1233 def _update_line_number(self
):
1234 """Update the selection model when the cursor changes"""
1235 self
.selection_model
.line_number
= self
.numbers
.current_line()
1238 def _add_patch_actions(widget
, context
, menu
):
1239 """Add actions for manipulating patch files"""
1240 patches_menu
= menu
.addMenu(N_('Patches'))
1241 patches_menu
.setIcon(icons
.diff())
1242 export_action
= qtutils
.add_action(
1245 lambda: _export_patch(widget
, context
),
1247 export_action
.setIcon(icons
.save())
1248 patches_menu
.addAction(export_action
)
1250 # Build the "Append Patch" menu dynamically.
1251 append_menu
= patches_menu
.addMenu(N_('Append Patch'))
1252 append_menu
.setIcon(icons
.add())
1253 append_menu
.aboutToShow
.connect(
1254 lambda: _build_patch_append_menu(widget
, context
, append_menu
)
1258 def _build_patch_append_menu(widget
, context
, menu
):
1259 """Build the "Append Patch" submenu"""
1260 # Build the menu when first displayed only. This initial check avoids
1261 # re-populating the menu with duplicate actions.
1262 menu_actions
= menu
.actions()
1266 choose_patch_action
= qtutils
.add_action(
1268 N_('Choose Patch...'),
1269 lambda: _export_patch(widget
, context
, append
=True),
1271 choose_patch_action
.setIcon(icons
.diff())
1272 menu
.addAction(choose_patch_action
)
1275 path
= prefs
.patches_directory(context
)
1276 patches
= get_patches_from_dir(path
)
1277 for patch
in patches
:
1278 relpath
= os
.path
.relpath(patch
, start
=path
)
1279 sub_menu
= _add_patch_subdirs(menu
, subdir_menus
, relpath
)
1280 patch_basename
= os
.path
.basename(relpath
)
1281 append_action
= qtutils
.add_action(
1284 lambda patch_file
=patch
: _append_patch(widget
, patch_file
),
1286 append_action
.setIcon(icons
.save())
1287 sub_menu
.addAction(append_action
)
1290 def _add_patch_subdirs(menu
, subdir_menus
, relpath
):
1291 """Build menu leading up to the patch"""
1292 # If the path contains no directory separators then add it to the
1294 if os
.sep
not in relpath
:
1297 # Loop over each directory component and build a menu if it doesn't already exist.
1299 for dirname
in os
.path
.dirname(relpath
).split(os
.sep
):
1300 components
.append(dirname
)
1301 current_dir
= os
.sep
.join(components
)
1303 menu
= subdir_menus
[current_dir
]
1305 menu
= subdir_menus
[current_dir
] = menu
.addMenu(dirname
)
1306 menu
.setIcon(icons
.folder())
1311 def _export_patch(diff_editor
, context
, append
=False):
1312 """Export the selected diff to a patch file"""
1313 if diff_editor
.selection_model
.is_empty():
1315 patch
= diff_editor
.extract_patch(reverse
=False)
1316 if not patch
.has_changes():
1318 directory
= prefs
.patches_directory(context
)
1320 filename
= qtutils
.existing_file(directory
, title
=N_('Append Patch...'))
1322 default_filename
= os
.path
.join(directory
, 'diff.patch')
1323 filename
= qtutils
.save_as(default_filename
)
1326 _write_patch_to_file(diff_editor
, patch
, filename
, append
=append
)
1329 def _append_patch(diff_editor
, filename
):
1330 """Append diffs to the specified patch file"""
1331 if diff_editor
.selection_model
.is_empty():
1333 patch
= diff_editor
.extract_patch(reverse
=False)
1334 if not patch
.has_changes():
1336 _write_patch_to_file(diff_editor
, patch
, filename
, append
=True)
1339 def _write_patch_to_file(diff_editor
, patch
, filename
, append
=False):
1340 """Write diffs from the Diff Editor to the specified patch file"""
1341 encoding
= diff_editor
.patch_encoding()
1342 content
= patch
.as_text()
1344 core
.write(filename
, content
, encoding
=encoding
, append
=append
)
1345 except OSError as exc
:
1346 _
, details
= utils
.format_exception(exc
)
1347 title
= N_('Error writing patch')
1348 msg
= N_('Unable to write patch to "%s". Check permissions?' % filename
)
1349 Interaction
.critical(title
, message
=msg
, details
=details
)
1351 Interaction
.log('Patch written to "%s"' % filename
)
1354 class DiffWidget(QtWidgets
.QWidget
):
1355 """Display commit metadata and text diffs"""
1357 def __init__(self
, context
, parent
, is_commit
=False, options
=None):
1358 QtWidgets
.QWidget
.__init
__(self
, parent
)
1360 self
.context
= context
1362 self
.oid_start
= None
1364 self
.options
= options
1366 author_font
= QtGui
.QFont(self
.font())
1367 author_font
.setPointSize(int(author_font
.pointSize() * 1.1))
1369 summary_font
= QtGui
.QFont(author_font
)
1370 summary_font
.setWeight(QtGui
.QFont
.Bold
)
1372 policy
= QtWidgets
.QSizePolicy(
1373 QtWidgets
.QSizePolicy
.MinimumExpanding
, QtWidgets
.QSizePolicy
.Minimum
1376 self
.gravatar_label
= gravatar
.GravatarLabel(self
.context
, parent
=self
)
1378 self
.oid_label
= TextLabel()
1379 self
.oid_label
.setTextFormat(Qt
.PlainText
)
1380 self
.oid_label
.setSizePolicy(policy
)
1381 self
.oid_label
.setAlignment(Qt
.AlignBottom
)
1382 self
.oid_label
.elide()
1384 self
.author_label
= TextLabel()
1385 self
.author_label
.setTextFormat(Qt
.RichText
)
1386 self
.author_label
.setFont(author_font
)
1387 self
.author_label
.setSizePolicy(policy
)
1388 self
.author_label
.setAlignment(Qt
.AlignTop
)
1389 self
.author_label
.elide()
1391 self
.date_label
= TextLabel()
1392 self
.date_label
.setTextFormat(Qt
.PlainText
)
1393 self
.date_label
.setSizePolicy(policy
)
1394 self
.date_label
.setAlignment(Qt
.AlignTop
)
1395 self
.date_label
.elide()
1397 self
.summary_label
= TextLabel()
1398 self
.summary_label
.setTextFormat(Qt
.PlainText
)
1399 self
.summary_label
.setFont(summary_font
)
1400 self
.summary_label
.setSizePolicy(policy
)
1401 self
.summary_label
.setAlignment(Qt
.AlignTop
)
1402 self
.summary_label
.elide()
1404 self
.diff
= DiffTextEdit(context
, self
, is_commit
=is_commit
, whitespace
=False)
1405 self
.setFocusProxy(self
.diff
)
1407 self
.info_layout
= qtutils
.vbox(
1416 self
.logo_layout
= qtutils
.hbox(
1417 defs
.no_margin
, defs
.button_spacing
, self
.gravatar_label
, self
.info_layout
1419 self
.logo_layout
.setContentsMargins(defs
.margin
, 0, defs
.margin
, 0)
1421 self
.main_layout
= qtutils
.vbox(
1422 defs
.no_margin
, defs
.spacing
, self
.logo_layout
, self
.diff
1424 self
.setLayout(self
.main_layout
)
1426 self
.set_tabwidth(prefs
.tabwidth(context
))
1428 def set_tabwidth(self
, width
):
1429 self
.diff
.set_tabwidth(width
)
1431 def set_word_wrapping(self
, enabled
, update
=False):
1432 """Enable and disable word wrapping"""
1433 self
.diff
.set_word_wrapping(enabled
, update
=update
)
1435 def set_options(self
, options
):
1436 """Register an options widget"""
1437 self
.options
= options
1438 self
.diff
.set_options(options
)
1440 def start_diff_task(self
, task
):
1441 """Clear the display and start a diff-gathering task"""
1442 self
.diff
.save_scrollbar()
1443 self
.diff
.set_loading_message()
1444 self
.context
.runtask
.start(task
, result
=self
.set_diff
)
1446 def set_diff_oid(self
, oid
, filename
=None):
1447 """Set the diff from a single commit object ID"""
1448 task
= DiffInfoTask(self
.context
, oid
, filename
)
1449 self
.start_diff_task(task
)
1451 def set_diff_range(self
, start
, end
, filename
=None):
1452 task
= DiffRangeTask(self
.context
, start
+ '~', end
, filename
)
1453 self
.start_diff_task(task
)
1455 def commits_selected(self
, commits
):
1456 """Display an appropriate diff when commits are selected"""
1460 commit
= commits
[-1]
1462 author
= commit
.author
or ''
1463 email
= commit
.email
or ''
1464 date
= commit
.authdate
or ''
1465 summary
= commit
.summary
or ''
1466 self
.set_details(oid
, author
, email
, date
, summary
)
1469 if len(commits
) > 1:
1470 start
, end
= commits
[0], commits
[-1]
1471 self
.set_diff_range(start
.oid
, end
.oid
)
1472 self
.oid_start
= start
1475 self
.set_diff_oid(oid
)
1476 self
.oid_start
= None
1479 def set_diff(self
, diff
):
1480 """Set the diff text"""
1481 self
.diff
.set_diff(diff
)
1483 def set_details(self
, oid
, author
, email
, date
, summary
):
1484 template_args
= {'author': author
, 'email': email
, 'summary': summary
}
1486 """%(author)s <"""
1487 """<a href="mailto:%(email)s">"""
1488 """%(email)s</a>>""" % template_args
1490 author_template
= '%(author)s <%(email)s>' % template_args
1492 self
.date_label
.set_text(date
)
1493 self
.date_label
.setVisible(bool(date
))
1494 self
.oid_label
.set_text(oid
)
1495 self
.author_label
.set_template(author_text
, author_template
)
1496 self
.summary_label
.set_text(summary
)
1497 self
.gravatar_label
.set_email(email
)
1500 self
.date_label
.set_text('')
1501 self
.oid_label
.set_text('')
1502 self
.author_label
.set_text('')
1503 self
.summary_label
.set_text('')
1504 self
.gravatar_label
.clear()
1507 def files_selected(self
, filenames
):
1508 """Update the view when a filename is selected"""
1511 oid_start
= self
.oid_start
1512 oid_end
= self
.oid_end
1513 if oid_start
and oid_end
:
1514 self
.set_diff_range(oid_start
.oid
, oid_end
.oid
, filename
=filenames
[0])
1516 self
.set_diff_oid(self
.oid
, filename
=filenames
[0])
1519 class DiffPanel(QtWidgets
.QWidget
):
1520 """A combined diff + search panel"""
1522 def __init__(self
, diff_widget
, text_widget
, parent
):
1523 super().__init
__(parent
)
1524 self
.diff_widget
= diff_widget
1525 self
.search_widget
= TextSearchWidget(text_widget
, self
)
1526 self
.search_widget
.hide()
1527 layout
= qtutils
.vbox(
1528 defs
.no_margin
, defs
.spacing
, self
.diff_widget
, self
.search_widget
1530 self
.setLayout(layout
)
1531 self
.setFocusProxy(self
.diff_widget
)
1533 self
.search_action
= qtutils
.add_action(
1535 N_('Search in Diff'),
1540 def show_search(self
):
1541 """Show a dialog for searching diffs"""
1542 # The diff search is only active in text mode.
1543 if not self
.search_widget
.isVisible():
1544 self
.search_widget
.show()
1545 self
.search_widget
.setFocus(True)
1548 class TextLabel(QtWidgets
.QLabel
):
1549 def __init__(self
, parent
=None):
1550 QtWidgets
.QLabel
.__init
__(self
, parent
)
1551 self
.setTextInteractionFlags(
1552 Qt
.TextSelectableByMouse | Qt
.LinksAccessibleByMouse
1558 self
._metrics
= QtGui
.QFontMetrics(self
.font())
1559 self
.setOpenExternalLinks(True)
1564 def set_text(self
, text
):
1565 self
.set_template(text
, text
)
1567 def set_template(self
, text
, template
):
1568 self
._display
= text
1570 self
._template
= template
1571 self
.update_text(self
.width())
1572 self
.setText(self
._display
)
1574 def update_text(self
, width
):
1575 self
._display
= self
._text
1578 text
= self
._metrics
.elidedText(self
._template
, Qt
.ElideRight
, width
- 2)
1579 if text
!= self
._template
:
1580 self
._display
= text
1583 def setFont(self
, font
):
1584 self
._metrics
= QtGui
.QFontMetrics(font
)
1585 QtWidgets
.QLabel
.setFont(self
, font
)
1587 def resizeEvent(self
, event
):
1589 self
.update_text(event
.size().width())
1590 with qtutils
.BlockSignals(self
):
1591 self
.setText(self
._display
)
1592 QtWidgets
.QLabel
.resizeEvent(self
, event
)
1595 class DiffInfoTask(qtutils
.Task
):
1596 """Gather diffs for a single commit"""
1598 def __init__(self
, context
, oid
, filename
):
1599 qtutils
.Task
.__init
__(self
)
1600 self
.context
= context
1602 self
.filename
= filename
1605 context
= self
.context
1607 return gitcmds
.diff_info(context
, oid
, filename
=self
.filename
)
1610 class DiffRangeTask(qtutils
.Task
):
1611 """Gather diffs for a range of commits"""
1613 def __init__(self
, context
, start
, end
, filename
):
1614 qtutils
.Task
.__init
__(self
)
1615 self
.context
= context
1618 self
.filename
= filename
1621 context
= self
.context
1622 return gitcmds
.diff_range(context
, self
.start
, self
.end
, filename
=self
.filename
)
1625 def apply_patches(context
, patches
=None):
1626 """Open the ApplyPatches dialog"""
1627 parent
= qtutils
.active_window()
1628 dlg
= new_apply_patches(context
, patches
=patches
, parent
=parent
)
1634 def new_apply_patches(context
, patches
=None, parent
=None):
1635 """Create a new instances of the ApplyPatches dialog"""
1636 dlg
= ApplyPatches(context
, parent
=parent
)
1638 dlg
.add_paths(patches
)
1642 def get_patches_from_paths(paths
):
1643 """Returns all patches benath a given path"""
1644 paths
= [core
.decode(p
) for p
in paths
]
1645 patches
= [p
for p
in paths
if core
.isfile(p
) and p
.endswith(('.patch', '.mbox'))]
1646 dirs
= [p
for p
in paths
if core
.isdir(p
)]
1649 patches
.extend(get_patches_from_dir(d
))
1653 def get_patches_from_mimedata(mimedata
):
1654 """Extract path files from a QMimeData payload"""
1655 urls
= mimedata
.urls()
1658 paths
= [x
.path() for x
in urls
]
1659 return get_patches_from_paths(paths
)
1662 def get_patches_from_dir(path
):
1663 """Find patches in a subdirectory"""
1665 for root
, _
, files
in core
.walk(path
):
1666 for name
in [f
for f
in files
if f
.endswith(('.patch', '.mbox'))]:
1667 patches
.append(core
.decode(os
.path
.join(root
, name
)))
1671 class ApplyPatches(standard
.Dialog
):
1672 def __init__(self
, context
, parent
=None):
1673 super().__init
__(parent
=parent
)
1674 self
.context
= context
1675 self
.setWindowTitle(N_('Apply Patches'))
1676 self
.setAcceptDrops(True)
1677 if parent
is not None:
1678 self
.setWindowModality(Qt
.WindowModal
)
1680 self
.curdir
= core
.getcwd()
1681 self
.inner_drag
= False
1683 self
.usage
= QtWidgets
.QLabel()
1688 Drag and drop or use the <strong>Add</strong> button to add
1695 self
.tree
= PatchTreeWidget(parent
=self
)
1696 self
.tree
.setHeaderHidden(True)
1697 # pylint: disable=no-member
1698 self
.tree
.itemSelectionChanged
.connect(self
._tree
_selection
_changed
)
1700 self
.diffwidget
= DiffWidget(context
, self
, is_commit
=True)
1702 self
.add_button
= qtutils
.create_toolbutton(
1703 text
=N_('Add'), icon
=icons
.add(), tooltip
=N_('Add patches (+)')
1706 self
.remove_button
= qtutils
.create_toolbutton(
1708 icon
=icons
.remove(),
1709 tooltip
=N_('Remove selected (Delete)'),
1712 self
.apply_button
= qtutils
.create_button(text
=N_('Apply'), icon
=icons
.ok())
1714 self
.close_button
= qtutils
.close_button()
1716 self
.add_action
= qtutils
.add_action(
1717 self
, N_('Add'), self
.add_files
, hotkeys
.ADD_ITEM
1720 self
.remove_action
= qtutils
.add_action(
1723 self
.tree
.remove_selected
,
1726 hotkeys
.REMOVE_ITEM
,
1729 self
.top_layout
= qtutils
.hbox(
1731 defs
.button_spacing
,
1738 self
.bottom_layout
= qtutils
.hbox(
1740 defs
.button_spacing
,
1746 self
.splitter
= qtutils
.splitter(Qt
.Vertical
, self
.tree
, self
.diffwidget
)
1748 self
.main_layout
= qtutils
.vbox(
1755 self
.setLayout(self
.main_layout
)
1757 qtutils
.connect_button(self
.add_button
, self
.add_files
)
1758 qtutils
.connect_button(self
.remove_button
, self
.tree
.remove_selected
)
1759 qtutils
.connect_button(self
.apply_button
, self
.apply_patches
)
1760 qtutils
.connect_button(self
.close_button
, self
.close
)
1762 self
.init_state(None, self
.resize
, 666, 420)
1764 def apply_patches(self
):
1765 items
= self
.tree
.items()
1768 context
= self
.context
1769 patches
= [i
.data(0, Qt
.UserRole
) for i
in items
]
1770 cmds
.do(cmds
.ApplyPatches
, context
, patches
)
1773 def add_files(self
):
1774 files
= qtutils
.open_files(
1775 N_('Select patch file(s)...'),
1776 directory
=self
.curdir
,
1777 filters
='Patches (*.patch *.mbox)',
1781 self
.curdir
= os
.path
.dirname(files
[0])
1782 self
.add_paths([core
.relpath(f
) for f
in files
])
1784 def dragEnterEvent(self
, event
):
1785 """Accepts drops if the mimedata contains patches"""
1786 super().dragEnterEvent(event
)
1787 patches
= get_patches_from_mimedata(event
.mimeData())
1789 event
.acceptProposedAction()
1791 def dropEvent(self
, event
):
1792 """Add dropped patches"""
1794 patches
= get_patches_from_mimedata(event
.mimeData())
1797 self
.add_paths(patches
)
1799 def add_paths(self
, paths
):
1800 self
.tree
.add_paths(paths
)
1802 def _tree_selection_changed(self
):
1803 items
= self
.tree
.selected_items()
1806 item
= items
[-1] # take the last item
1807 path
= item
.data(0, Qt
.UserRole
)
1808 if not core
.exists(path
):
1810 commit
= parse_patch(path
)
1811 self
.diffwidget
.set_details(
1812 commit
.oid
, commit
.author
, commit
.email
, commit
.date
, commit
.summary
1814 self
.diffwidget
.set_diff(commit
.diff
)
1816 def export_state(self
):
1817 """Export persistent settings"""
1818 state
= super().export_state()
1819 state
['sizes'] = get(self
.splitter
)
1822 def apply_state(self
, state
):
1823 """Apply persistent settings"""
1824 result
= super().apply_state(state
)
1826 self
.splitter
.setSizes(state
['sizes'])
1827 except (AttributeError, KeyError, ValueError, TypeError):
1832 # pylint: disable=too-many-ancestors
1833 class PatchTreeWidget(standard
.DraggableTreeWidget
):
1834 def add_paths(self
, paths
):
1835 patches
= get_patches_from_paths(paths
)
1839 icon
= icons
.file_text()
1840 for patch
in patches
:
1841 item
= QtWidgets
.QTreeWidgetItem()
1842 flags
= item
.flags() & ~Qt
.ItemIsDropEnabled
1843 item
.setFlags(flags
)
1844 item
.setIcon(0, icon
)
1845 item
.setText(0, os
.path
.basename(patch
))
1846 item
.setData(0, Qt
.UserRole
, patch
)
1847 item
.setToolTip(0, patch
)
1849 self
.addTopLevelItems(items
)
1851 def remove_selected(self
):
1852 idxs
= self
.selectedIndexes()
1853 rows
= [idx
.row() for idx
in idxs
]
1854 for row
in reversed(sorted(rows
)):
1855 self
.invisibleRootItem().takeChild(row
)
1859 """Container for commit details"""
1871 def parse_patch(path
):
1872 content
= core
.read(path
)
1874 parse(content
, commit
)
1878 def parse(content
, commit
):
1879 """Parse commit details from a patch"""
1880 from_rgx
= re
.compile(r
'^From (?P<oid>[a-f0-9]{40}) .*$')
1881 author_rgx
= re
.compile(r
'^From: (?P<author>[^<]+) <(?P<email>[^>]+)>$')
1882 date_rgx
= re
.compile(r
'^Date: (?P<date>.*)$')
1883 subject_rgx
= re
.compile(r
'^Subject: (?P<summary>.*)$')
1885 commit
.content
= content
1887 lines
= content
.splitlines()
1888 for idx
, line
in enumerate(lines
):
1889 match
= from_rgx
.match(line
)
1891 commit
.oid
= match
.group('oid')
1894 match
= author_rgx
.match(line
)
1896 commit
.author
= match
.group('author')
1897 commit
.email
= match
.group('email')
1900 match
= date_rgx
.match(line
)
1902 commit
.date
= match
.group('date')
1905 match
= subject_rgx
.match(line
)
1907 commit
.summary
= match
.group('summary')
1908 commit
.diff
= '\n'.join(lines
[idx
+ 1 :])