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_with_icon(
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 ObjectIdLabel(PlainTextLabel
):
1404 """Interactive object IDs"""
1406 def __init__(self
, context
, oid
='', parent
=None):
1407 super().__init
__(parent
=parent
)
1408 self
.context
= context
1410 self
.setCursor(Qt
.PointingHandCursor
)
1411 self
.setContextMenuPolicy(Qt
.CustomContextMenu
)
1412 self
.setFocusPolicy(Qt
.NoFocus
)
1413 self
.setToolTip(N_('Click to Copy'))
1414 self
.customContextMenuRequested
.connect(self
._context
_menu
)
1415 self
._copy
_short
_action
= qtutils
.add_action_with_icon(
1418 N_('Copy Commit (Short)'),
1422 self
._copy
_long
_action
= qtutils
.add_action_with_icon(
1427 hotkeys
.COPY_COMMIT_ID
,
1429 self
._select
_all
_action
= qtutils
.add_action(
1430 self
, N_('Select All'), self
._select
_all
, hotkeys
.SELECT_ALL
1432 self
.timer
= QtCore
.QTimer(self
)
1433 self
.timer
.setInterval(200)
1434 self
.timer
.setSingleShot(True)
1435 self
.timer
.timeout
.connect(self
._timeout
)
1438 """Clear the selection"""
1439 self
.setSelection(0, 0)
1441 def set_oid(self
, oid
):
1442 """Record the object ID and update the display"""
1446 def _copy_short(self
, clicked
=False):
1447 """Copy the abbreviated commit ID"""
1448 abbrev
= prefs
.abbrev(self
.context
)
1449 qtutils
.set_clipboard(self
.oid
[:abbrev
])
1451 if not self
.timer
.isActive():
1454 def _copy_long(self
):
1455 """Copy the full commit ID"""
1456 qtutils
.set_clipboard(self
.oid
)
1458 if not self
.timer
.isActive():
1461 def _select_all(self
):
1462 """Select the text"""
1463 length
= len(self
.get())
1464 self
.setSelection(0, length
)
1466 def mousePressEvent(self
, event
):
1467 """Copy the commit ID when clicked"""
1468 if event
.button() == Qt
.LeftButton
:
1469 # This behavior makes it impossible to select text by clicking and dragging,
1470 # but it's okay because this also makes copying text a single-click affair.
1471 self
._copy
_short
(clicked
=True)
1472 return super().mousePressEvent(event
)
1474 def _context_menu(self
, pos
):
1475 """Display a custom context menu"""
1476 menu
= QtWidgets
.QMenu(self
)
1477 menu
.addAction(self
._copy
_short
_action
)
1478 menu
.addAction(self
._copy
_long
_action
)
1479 menu
.addAction(self
._select
_all
_action
)
1480 menu
.exec_(self
.mapToGlobal(pos
))
1483 class DiffWidget(QtWidgets
.QWidget
):
1484 """Display commit metadata and text diffs"""
1486 def __init__(self
, context
, parent
, is_commit
=False, options
=None):
1487 QtWidgets
.QWidget
.__init
__(self
, parent
)
1489 self
.context
= context
1491 self
.oid_start
= None
1493 self
.options
= options
1495 author_font
= QtGui
.QFont(self
.font())
1496 author_font
.setPointSize(int(author_font
.pointSize() * 1.1))
1498 summary_font
= QtGui
.QFont(author_font
)
1499 summary_font
.setWeight(QtGui
.QFont
.Bold
)
1501 self
.gravatar_label
= gravatar
.GravatarLabel(self
.context
, parent
=self
)
1503 self
.oid_label
= ObjectIdLabel(context
, parent
=self
)
1504 self
.oid_label
.setAlignment(Qt
.AlignBottom
)
1505 self
.oid_label
.elide()
1507 self
.author_label
= RichTextLabel(selectable
=False, parent
=self
)
1508 self
.author_label
.setFont(author_font
)
1509 self
.author_label
.setAlignment(Qt
.AlignTop
)
1510 self
.author_label
.elide()
1512 self
.date_label
= PlainTextLabel(parent
=self
)
1513 self
.date_label
.setAlignment(Qt
.AlignTop
)
1514 self
.date_label
.elide()
1516 self
.summary_label
= PlainTextLabel(parent
=self
)
1517 self
.summary_label
.setFont(summary_font
)
1518 self
.summary_label
.setAlignment(Qt
.AlignTop
)
1519 self
.summary_label
.elide()
1521 self
.diff
= DiffTextEdit(context
, self
, is_commit
=is_commit
, whitespace
=False)
1522 self
.setFocusProxy(self
.diff
)
1524 self
.info_layout
= qtutils
.vbox(
1533 self
.logo_layout
= qtutils
.hbox(
1534 defs
.no_margin
, defs
.button_spacing
, self
.gravatar_label
, self
.info_layout
1536 self
.logo_layout
.setContentsMargins(defs
.margin
, 0, defs
.margin
, 0)
1538 self
.main_layout
= qtutils
.vbox(
1539 defs
.no_margin
, defs
.spacing
, self
.logo_layout
, self
.diff
1541 self
.setLayout(self
.main_layout
)
1543 self
.set_tabwidth(prefs
.tabwidth(context
))
1545 def set_tabwidth(self
, width
):
1546 self
.diff
.set_tabwidth(width
)
1548 def set_word_wrapping(self
, enabled
, update
=False):
1549 """Enable and disable word wrapping"""
1550 self
.diff
.set_word_wrapping(enabled
, update
=update
)
1552 def set_options(self
, options
):
1553 """Register an options widget"""
1554 self
.options
= options
1555 self
.diff
.set_options(options
)
1557 def start_diff_task(self
, task
):
1558 """Clear the display and start a diff-gathering task"""
1559 self
.diff
.save_scrollbar()
1560 self
.diff
.set_loading_message()
1561 self
.context
.runtask
.start(task
, result
=self
.set_diff
)
1563 def set_diff_oid(self
, oid
, filename
=None):
1564 """Set the diff from a single commit object ID"""
1565 task
= DiffInfoTask(self
.context
, oid
, filename
)
1566 self
.start_diff_task(task
)
1568 def set_diff_range(self
, start
, end
, filename
=None):
1569 task
= DiffRangeTask(self
.context
, start
+ '~', end
, filename
)
1570 self
.start_diff_task(task
)
1572 def commits_selected(self
, commits
):
1573 """Display an appropriate diff when commits are selected"""
1577 commit
= commits
[-1]
1579 author
= commit
.author
or ''
1580 email
= commit
.email
or ''
1581 date
= commit
.authdate
or ''
1582 summary
= commit
.summary
or ''
1583 self
.set_details(oid
, author
, email
, date
, summary
)
1586 if len(commits
) > 1:
1587 start
, end
= commits
[0], commits
[-1]
1588 self
.set_diff_range(start
.oid
, end
.oid
)
1589 self
.oid_start
= start
1592 self
.set_diff_oid(oid
)
1593 self
.oid_start
= None
1596 def set_diff(self
, diff
):
1597 """Set the diff text"""
1598 self
.diff
.set_diff(diff
)
1600 def set_details(self
, oid
, author
, email
, date
, summary
):
1601 template_args
= {'author': author
, 'email': email
, 'summary': summary
}
1603 """%(author)s <"""
1604 """<a href="mailto:%(email)s">"""
1605 """%(email)s</a>>""" % template_args
1607 author_template
= '%(author)s <%(email)s>' % template_args
1609 self
.date_label
.set_text(date
)
1610 self
.date_label
.setVisible(bool(date
))
1611 self
.oid_label
.set_oid(oid
)
1612 self
.author_label
.set_template(author_text
, author_template
)
1613 self
.summary_label
.set_text(summary
)
1614 self
.gravatar_label
.set_email(email
)
1617 self
.date_label
.set_text('')
1618 self
.oid_label
.set_oid('')
1619 self
.author_label
.set_text('')
1620 self
.summary_label
.set_text('')
1621 self
.gravatar_label
.clear()
1624 def files_selected(self
, filenames
):
1625 """Update the view when a filename is selected"""
1626 oid_start
= self
.oid_start
1627 oid_end
= self
.oid_end
1630 extra_args
['filename'] = filenames
[0]
1631 if oid_start
and oid_end
:
1632 self
.set_diff_range(oid_start
.oid
, oid_end
.oid
, **extra_args
)
1634 self
.set_diff_oid(self
.oid
, **extra_args
)
1637 class DiffPanel(QtWidgets
.QWidget
):
1638 """A combined diff + search panel"""
1640 def __init__(self
, diff_widget
, text_widget
, parent
):
1641 super().__init
__(parent
)
1642 self
.diff_widget
= diff_widget
1643 self
.search_widget
= TextSearchWidget(text_widget
, self
)
1644 self
.search_widget
.hide()
1645 layout
= qtutils
.vbox(
1646 defs
.no_margin
, defs
.spacing
, self
.diff_widget
, self
.search_widget
1648 self
.setLayout(layout
)
1649 self
.setFocusProxy(self
.diff_widget
)
1651 self
.search_action
= qtutils
.add_action(
1653 N_('Search in Diff'),
1658 def show_search(self
):
1659 """Show a dialog for searching diffs"""
1660 # The diff search is only active in text mode.
1661 if not self
.search_widget
.isVisible():
1662 self
.search_widget
.show()
1663 self
.search_widget
.setFocus()
1666 class DiffInfoTask(qtutils
.Task
):
1667 """Gather diffs for a single commit"""
1669 def __init__(self
, context
, oid
, filename
):
1670 qtutils
.Task
.__init
__(self
)
1671 self
.context
= context
1673 self
.filename
= filename
1676 context
= self
.context
1678 return gitcmds
.diff_info(context
, oid
, filename
=self
.filename
)
1681 class DiffRangeTask(qtutils
.Task
):
1682 """Gather diffs for a range of commits"""
1684 def __init__(self
, context
, start
, end
, filename
):
1685 qtutils
.Task
.__init
__(self
)
1686 self
.context
= context
1689 self
.filename
= filename
1692 context
= self
.context
1693 return gitcmds
.diff_range(context
, self
.start
, self
.end
, filename
=self
.filename
)
1696 def apply_patches(context
, patches
=None):
1697 """Open the ApplyPatches dialog"""
1698 parent
= qtutils
.active_window()
1699 dlg
= new_apply_patches(context
, patches
=patches
, parent
=parent
)
1705 def new_apply_patches(context
, patches
=None, parent
=None):
1706 """Create a new instances of the ApplyPatches dialog"""
1707 dlg
= ApplyPatches(context
, parent
=parent
)
1709 dlg
.add_paths(patches
)
1713 def get_patches_from_paths(paths
):
1714 """Returns all patches beneath a given path"""
1715 paths
= [core
.decode(p
) for p
in paths
]
1716 patches
= [p
for p
in paths
if core
.isfile(p
) and p
.endswith(('.patch', '.mbox'))]
1717 dirs
= [p
for p
in paths
if core
.isdir(p
)]
1720 patches
.extend(get_patches_from_dir(d
))
1724 def get_patches_from_mimedata(mimedata
):
1725 """Extract path files from a QMimeData payload"""
1726 urls
= mimedata
.urls()
1729 paths
= [x
.path() for x
in urls
]
1730 return get_patches_from_paths(paths
)
1733 def get_patches_from_dir(path
):
1734 """Find patches in a subdirectory"""
1736 for root
, _
, files
in core
.walk(path
):
1737 for name
in [f
for f
in files
if f
.endswith(('.patch', '.mbox'))]:
1738 patches
.append(core
.decode(os
.path
.join(root
, name
)))
1742 class ApplyPatches(standard
.Dialog
):
1743 def __init__(self
, context
, parent
=None):
1744 super().__init
__(parent
=parent
)
1745 self
.context
= context
1746 self
.setWindowTitle(N_('Apply Patches'))
1747 self
.setAcceptDrops(True)
1748 if parent
is not None:
1749 self
.setWindowModality(Qt
.WindowModal
)
1751 self
.curdir
= core
.getcwd()
1752 self
.inner_drag
= False
1754 self
.usage
= QtWidgets
.QLabel()
1759 Drag and drop or use the <strong>Add</strong> button to add
1766 self
.tree
= PatchTreeWidget(parent
=self
)
1767 self
.tree
.setHeaderHidden(True)
1768 self
.tree
.itemSelectionChanged
.connect(self
._tree
_selection
_changed
)
1770 self
.diffwidget
= DiffWidget(context
, self
, is_commit
=True)
1772 self
.add_button
= qtutils
.create_toolbutton(
1773 text
=N_('Add'), icon
=icons
.add(), tooltip
=N_('Add patches (+)')
1776 self
.remove_button
= qtutils
.create_toolbutton(
1778 icon
=icons
.remove(),
1779 tooltip
=N_('Remove selected (Delete)'),
1782 self
.apply_button
= qtutils
.create_button(text
=N_('Apply'), icon
=icons
.ok())
1784 self
.close_button
= qtutils
.close_button()
1786 self
.add_action
= qtutils
.add_action(
1787 self
, N_('Add'), self
.add_files
, hotkeys
.ADD_ITEM
1790 self
.remove_action
= qtutils
.add_action(
1793 self
.tree
.remove_selected
,
1796 hotkeys
.REMOVE_ITEM
,
1799 self
.top_layout
= qtutils
.hbox(
1801 defs
.button_spacing
,
1808 self
.bottom_layout
= qtutils
.hbox(
1810 defs
.button_spacing
,
1816 self
.splitter
= qtutils
.splitter(Qt
.Vertical
, self
.tree
, self
.diffwidget
)
1818 self
.main_layout
= qtutils
.vbox(
1825 self
.setLayout(self
.main_layout
)
1827 qtutils
.connect_button(self
.add_button
, self
.add_files
)
1828 qtutils
.connect_button(self
.remove_button
, self
.tree
.remove_selected
)
1829 qtutils
.connect_button(self
.apply_button
, self
.apply_patches
)
1830 qtutils
.connect_button(self
.close_button
, self
.close
)
1832 self
.init_state(None, self
.resize
, 720, 480)
1834 def apply_patches(self
):
1835 items
= self
.tree
.items()
1838 context
= self
.context
1839 patches
= [i
.data(0, Qt
.UserRole
) for i
in items
]
1840 cmds
.do(cmds
.ApplyPatches
, context
, patches
)
1843 def add_files(self
):
1844 files
= qtutils
.open_files(
1845 N_('Select patch file(s)...'),
1846 directory
=self
.curdir
,
1847 filters
='Patches (*.patch *.mbox)',
1851 self
.curdir
= os
.path
.dirname(files
[0])
1852 self
.add_paths([core
.relpath(f
) for f
in files
])
1854 def dragEnterEvent(self
, event
):
1855 """Accepts drops if the mimedata contains patches"""
1856 super().dragEnterEvent(event
)
1857 patches
= get_patches_from_mimedata(event
.mimeData())
1859 event
.acceptProposedAction()
1861 def dropEvent(self
, event
):
1862 """Add dropped patches"""
1864 patches
= get_patches_from_mimedata(event
.mimeData())
1867 self
.add_paths(patches
)
1869 def add_paths(self
, paths
):
1870 self
.tree
.add_paths(paths
)
1872 def _tree_selection_changed(self
):
1873 items
= self
.tree
.selected_items()
1876 item
= items
[-1] # take the last item
1877 path
= item
.data(0, Qt
.UserRole
)
1878 if not core
.exists(path
):
1880 commit
= parse_patch(path
)
1881 self
.diffwidget
.set_details(
1882 commit
.oid
, commit
.author
, commit
.email
, commit
.date
, commit
.summary
1884 self
.diffwidget
.set_diff(commit
.diff
)
1886 def export_state(self
):
1887 """Export persistent settings"""
1888 state
= super().export_state()
1889 state
['sizes'] = get(self
.splitter
)
1892 def apply_state(self
, state
):
1893 """Apply persistent settings"""
1894 result
= super().apply_state(state
)
1896 self
.splitter
.setSizes(state
['sizes'])
1897 except (AttributeError, KeyError, ValueError, TypeError):
1902 class PatchTreeWidget(standard
.DraggableTreeWidget
):
1903 def add_paths(self
, paths
):
1904 patches
= get_patches_from_paths(paths
)
1908 icon
= icons
.file_text()
1909 for patch
in patches
:
1910 item
= QtWidgets
.QTreeWidgetItem()
1911 flags
= item
.flags() & ~Qt
.ItemIsDropEnabled
1912 item
.setFlags(flags
)
1913 item
.setIcon(0, icon
)
1914 item
.setText(0, os
.path
.basename(patch
))
1915 item
.setData(0, Qt
.UserRole
, patch
)
1916 item
.setToolTip(0, patch
)
1918 self
.addTopLevelItems(items
)
1920 def remove_selected(self
):
1921 idxs
= self
.selectedIndexes()
1922 rows
= [idx
.row() for idx
in idxs
]
1923 for row
in reversed(sorted(rows
)):
1924 self
.invisibleRootItem().takeChild(row
)
1928 """Container for commit details"""
1940 def parse_patch(path
):
1941 content
= core
.read(path
)
1943 parse(content
, commit
)
1947 def parse(content
, commit
):
1948 """Parse commit details from a patch"""
1949 from_rgx
= re
.compile(r
'^From (?P<oid>[a-f0-9]{40}) .*$')
1950 author_rgx
= re
.compile(r
'^From: (?P<author>[^<]+) <(?P<email>[^>]+)>$')
1951 date_rgx
= re
.compile(r
'^Date: (?P<date>.*)$')
1952 subject_rgx
= re
.compile(r
'^Subject: (?P<summary>.*)$')
1954 commit
.content
= content
1956 lines
= content
.splitlines()
1957 for idx
, line
in enumerate(lines
):
1958 match
= from_rgx
.match(line
)
1960 commit
.oid
= match
.group('oid')
1963 match
= author_rgx
.match(line
)
1965 commit
.author
= match
.group('author')
1966 commit
.email
= match
.group('email')
1969 match
= date_rgx
.match(line
)
1971 commit
.date
= match
.group('date')
1974 match
= subject_rgx
.match(line
)
1976 commit
.summary
= match
.group('summary')
1977 commit
.diff
= '\n'.join(lines
[idx
+ 1 :])