1 from __future__
import division
, absolute_import
, unicode_literals
5 from qtpy
import QtCore
7 from qtpy
import QtWidgets
8 from qtpy
.QtCore
import Qt
9 from qtpy
.QtCore
import Signal
12 from ..interaction
import Interaction
13 from ..models
import prefs
14 from ..qtutils
import get
15 from .. import actions
18 from .. import diffparse
19 from .. import gitcmds
20 from .. import gravatar
21 from .. import hotkeys
24 from .. import qtutils
25 from .text
import TextDecorator
26 from .text
import VimHintedPlainTextEdit
28 from . import imageview
31 COMMITS_SELECTED
= 'COMMITS_SELECTED'
32 FILES_SELECTED
= 'FILES_SELECTED'
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(r
'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|'
48 r
'(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)')
49 BAD_WHITESPACE_RGX
= re
.compile(r
'\s+$')
51 def __init__(self
, context
, doc
, whitespace
=True, is_commit
=False):
52 QtGui
.QSyntaxHighlighter
.__init
__(self
, doc
)
53 self
.whitespace
= whitespace
55 self
.is_commit
= is_commit
57 QPalette
= QtGui
.QPalette
60 disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
61 header
= qtutils
.rgb_hex(disabled
)
63 self
.color_text
= qtutils
.RGB(cfg
.color('text', '030303'))
64 self
.color_add
= qtutils
.RGB(cfg
.color('add', 'd2ffe4'))
65 self
.color_remove
= qtutils
.RGB(cfg
.color('remove', 'fee0e4'))
66 self
.color_header
= qtutils
.RGB(cfg
.color('header', header
))
68 self
.diff_header_fmt
= qtutils
.make_format(fg
=self
.color_header
)
69 self
.bold_diff_header_fmt
= qtutils
.make_format(
70 fg
=self
.color_header
, bold
=True)
72 self
.diff_add_fmt
= qtutils
.make_format(
73 fg
=self
.color_text
, bg
=self
.color_add
)
74 self
.diff_remove_fmt
= qtutils
.make_format(
75 fg
=self
.color_text
, bg
=self
.color_remove
)
76 self
.bad_whitespace_fmt
= qtutils
.make_format(bg
=Qt
.red
)
77 self
.setCurrentBlockState(self
.INITIAL_STATE
)
79 def set_enabled(self
, enabled
):
80 self
.enabled
= enabled
82 def highlightBlock(self
, text
):
83 if not self
.enabled
or not text
:
85 # Aliases for quick local access
86 initial_state
= self
.INITIAL_STATE
87 default_state
= self
.DEFAULT_STATE
88 diff_state
= self
.DIFF_STATE
89 diffstat_state
= self
.DIFFSTAT_STATE
90 diff_file_header_state
= self
.DIFF_FILE_HEADER_STATE
91 submodule_state
= self
.SUBMODULE_STATE
92 end_state
= self
.END_STATE
94 diff_file_header_start_rgx
= self
.DIFF_FILE_HEADER_START_RGX
95 diff_hunk_header_rgx
= self
.DIFF_HUNK_HEADER_RGX
96 bad_whitespace_rgx
= self
.BAD_WHITESPACE_RGX
98 diff_header_fmt
= self
.diff_header_fmt
99 bold_diff_header_fmt
= self
.bold_diff_header_fmt
100 diff_add_fmt
= self
.diff_add_fmt
101 diff_remove_fmt
= self
.diff_remove_fmt
102 bad_whitespace_fmt
= self
.bad_whitespace_fmt
104 state
= self
.previousBlockState()
105 if state
== initial_state
:
106 if text
.startswith('Submodule '):
107 state
= submodule_state
108 elif text
.startswith('diff --git '):
109 state
= diffstat_state
111 state
= default_state
113 state
= diffstat_state
115 if state
== diffstat_state
:
116 if diff_file_header_start_rgx
.match(text
):
117 state
= diff_file_header_state
118 self
.setFormat(0, len(text
), diff_header_fmt
)
119 elif diff_hunk_header_rgx
.match(text
):
121 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
124 self
.setFormat(0, i
, bold_diff_header_fmt
)
125 self
.setFormat(i
, len(text
) - i
, diff_header_fmt
)
127 self
.setFormat(0, len(text
), diff_header_fmt
)
128 elif state
== diff_file_header_state
:
129 if diff_hunk_header_rgx
.match(text
):
131 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
133 self
.setFormat(0, len(text
), diff_header_fmt
)
134 elif state
== diff_state
:
135 if diff_file_header_start_rgx
.match(text
):
136 state
= diff_file_header_state
137 self
.setFormat(0, len(text
), diff_header_fmt
)
138 elif diff_hunk_header_rgx
.match(text
):
139 self
.setFormat(0, len(text
), bold_diff_header_fmt
)
140 elif text
.startswith('-'):
144 self
.setFormat(0, len(text
), diff_remove_fmt
)
145 elif text
.startswith('+'):
146 self
.setFormat(0, len(text
), diff_add_fmt
)
148 m
= bad_whitespace_rgx
.search(text
)
151 self
.setFormat(i
, len(text
) - i
, bad_whitespace_fmt
)
153 self
.setCurrentBlockState(state
)
156 class DiffTextEdit(VimHintedPlainTextEdit
):
158 def __init__(self
, context
, parent
,
159 is_commit
=False, whitespace
=True, numbers
=False):
160 VimHintedPlainTextEdit
.__init
__(self
, context
, '', parent
=parent
)
161 # Diff/patch syntax highlighter
162 self
.highlighter
= DiffSyntaxHighlighter(
163 context
, self
.document(),
164 is_commit
=is_commit
, whitespace
=whitespace
)
166 self
.numbers
= DiffLineNumbers(context
, self
)
170 self
.scrollvalue
= None
172 self
.cursorPositionChanged
.connect(self
._cursor
_changed
)
174 def _cursor_changed(self
):
175 """Update the line number display when the cursor changes"""
176 line_number
= max(0, self
.textCursor().blockNumber())
178 self
.numbers
.set_highlighted(line_number
)
180 def resizeEvent(self
, event
):
181 super(DiffTextEdit
, self
).resizeEvent(event
)
183 self
.numbers
.refresh_size()
185 def save_scrollbar(self
):
186 """Save the scrollbar value, but only on the first call"""
187 if self
.scrollvalue
is None:
188 scrollbar
= self
.verticalScrollBar()
190 scrollvalue
= get(scrollbar
)
193 self
.scrollvalue
= scrollvalue
195 def restore_scrollbar(self
):
196 """Restore the scrollbar and clear state"""
197 scrollbar
= self
.verticalScrollBar()
198 scrollvalue
= self
.scrollvalue
199 if scrollbar
and scrollvalue
is not None:
200 scrollbar
.setValue(scrollvalue
)
201 self
.scrollvalue
= None
203 def set_loading_message(self
):
204 self
.hint
.set_value('+++ ' + N_('Loading...'))
207 def set_diff(self
, diff
):
208 """Set the diff text, but save the scrollbar"""
209 diff
= diff
.rstrip('\n') # diffs include two empty newlines
210 self
.save_scrollbar()
212 self
.hint
.set_value('')
214 self
.numbers
.set_diff(diff
)
217 self
.restore_scrollbar()
220 class DiffLineNumbers(TextDecorator
):
222 def __init__(self
, context
, parent
):
223 TextDecorator
.__init
__(self
, parent
)
224 self
.highlight_line
= -1
226 self
.parser
= diffparse
.DiffLines()
227 self
.formatter
= diffparse
.FormatDigits()
229 self
.setFont(qtutils
.diff_font(context
))
230 self
._char
_width
= self
.fontMetrics().width('0')
232 QPalette
= QtGui
.QPalette
233 self
._palette
= palette
= self
.palette()
234 self
._base
= palette
.color(QtGui
.QPalette
.Base
)
235 self
._highlight
= palette
.color(QPalette
.Highlight
)
236 self
._highlight
_text
= palette
.color(QPalette
.HighlightedText
)
237 self
._window
= palette
.color(QPalette
.Window
)
238 self
._disabled
= palette
.color(QPalette
.Disabled
, QPalette
.Text
)
240 def set_diff(self
, diff
):
242 lines
= parser
.parse(diff
)
245 self
.formatter
.set_digits(self
.parser
.digits())
249 def set_lines(self
, lines
):
252 def width_hint(self
):
253 if not self
.isVisible():
259 extra
= 3 # one space in-between, one space after
262 extra
= 2 # one space in-between, one space after
265 digits
= parser
.digits() * columns
269 return defs
.margin
+ (self
._char
_width
* (digits
+ extra
))
271 def set_highlighted(self
, line_number
):
272 """Set the line to highlight"""
273 self
.highlight_line
= line_number
275 def current_line(self
):
276 if self
.lines
and self
.highlight_line
>= 0:
277 # Find the next valid line
278 for line
in self
.lines
[self
.highlight_line
:]:
279 # take the "new" line number: last value in tuple
280 line_number
= line
[-1]
284 # Find the previous valid line
285 for line
in self
.lines
[self
.highlight_line
-1::-1]:
286 # take the "new" line number: last value in tuple
287 line_number
= line
[-1]
292 def paintEvent(self
, event
):
293 """Paint the line number"""
297 painter
= QtGui
.QPainter(self
)
298 painter
.fillRect(event
.rect(), self
._base
)
301 content_offset
= editor
.contentOffset()
302 block
= editor
.firstVisibleBlock()
304 event_rect_bottom
= event
.rect().bottom()
306 highlight
= self
._highlight
307 highlight_text
= self
._highlight
_text
308 disabled
= self
._disabled
312 num_lines
= len(self
.lines
)
313 painter
.setPen(disabled
)
316 while block
.isValid():
317 block_number
= block
.blockNumber()
318 if block_number
>= num_lines
:
320 block_geom
= editor
.blockBoundingGeometry(block
)
321 block_top
= block_geom
.translated(content_offset
).top()
322 if not block
.isVisible() or block_top
>= event_rect_bottom
:
325 rect
= block_geom
.translated(content_offset
).toRect()
326 if block_number
== self
.highlight_line
:
327 painter
.fillRect(rect
.x(), rect
.y(),
328 width
, rect
.height(), highlight
)
329 painter
.setPen(highlight_text
)
331 painter
.setPen(disabled
)
333 line
= lines
[block_number
]
336 text
= fmt
.value(a
, b
)
338 old
, base
, new
= line
339 text
= fmt
.merge_value(old
, base
, new
)
341 painter
.drawText(rect
.x(), rect
.y(),
342 self
.width() - (defs
.margin
* 2), rect
.height(),
343 Qt
.AlignRight | Qt
.AlignVCenter
, text
)
345 block
= block
.next() # pylint: disable=next-method-called
348 class Viewer(QtWidgets
.QWidget
):
349 """Text and image diff viewers"""
351 images_changed
= Signal(object)
352 type_changed
= Signal(object)
354 def __init__(self
, context
, parent
=None):
355 super(Viewer
, self
).__init
__(parent
)
357 self
.context
= context
358 self
.model
= model
= context
.model
361 self
.options
= options
= Options(self
)
362 self
.text
= DiffEditor(context
, options
, self
)
363 self
.image
= imageview
.ImageView(parent
=self
)
364 self
.image
.setFocusPolicy(Qt
.NoFocus
)
366 stack
= self
.stack
= QtWidgets
.QStackedWidget(self
)
367 stack
.addWidget(self
.text
)
368 stack
.addWidget(self
.image
)
370 self
.main_layout
= qtutils
.vbox(
371 defs
.no_margin
, defs
.no_spacing
, self
.stack
)
372 self
.setLayout(self
.main_layout
)
375 images_msg
= model
.message_images_changed
376 model
.add_observer(images_msg
, self
.images_changed
.emit
)
377 self
.images_changed
.connect(self
.set_images
, type=Qt
.QueuedConnection
)
379 # Observe the diff type
380 diff_type_msg
= model
.message_diff_type_changed
381 model
.add_observer(diff_type_msg
, self
.type_changed
.emit
)
382 self
.type_changed
.connect(self
.set_diff_type
, type=Qt
.QueuedConnection
)
384 # Observe the image mode combo box
385 options
.image_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
386 options
.zoom_mode
.currentIndexChanged
.connect(lambda _
: self
.render())
388 self
.setFocusProxy(self
.text
)
390 def export_state(self
, state
):
391 state
['show_diff_line_numbers'] = self
.text
.show_line_numbers()
392 state
['image_diff_mode'] = self
.options
.image_mode
.currentIndex()
393 state
['image_zoom_mode'] = self
.options
.zoom_mode
.currentIndex()
396 def apply_state(self
, state
):
397 diff_numbers
= bool(state
.get('show_diff_line_numbers', False))
398 self
.text
.enable_line_numbers(diff_numbers
)
400 image_mode
= utils
.asint(state
.get('image_diff_mode', 0))
401 self
.options
.image_mode
.set_index(image_mode
)
403 zoom_mode
= utils
.asint(state
.get('image_zoom_mode', 0))
404 self
.options
.zoom_mode
.set_index(zoom_mode
)
407 def set_diff_type(self
, diff_type
):
408 """Manage the image and text diff views when selection changes"""
409 self
.options
.set_diff_type(diff_type
)
410 if diff_type
== 'image':
411 self
.stack
.setCurrentWidget(self
.image
)
414 self
.stack
.setCurrentWidget(self
.text
)
416 def update_options(self
):
417 self
.text
.update_options()
420 self
.image
.pixmap
= QtGui
.QPixmap()
424 for (image
, unlink
) in self
.images
:
425 if unlink
and core
.exists(image
):
429 def set_images(self
, images
):
436 # In order to comp, we first have to load all the images
437 all_pixmaps
= [QtGui
.QPixmap(image
[0]) for image
in images
]
438 pixmaps
= [pixmap
for pixmap
in all_pixmaps
if not pixmap
.isNull()]
443 self
.pixmaps
= pixmaps
451 mode
= self
.options
.image_mode
.currentIndex()
452 if mode
== self
.options
.SIDE_BY_SIDE
:
453 image
= self
.render_side_by_side()
454 elif mode
== self
.options
.DIFF
:
455 image
= self
.render_diff()
456 elif mode
== self
.options
.XOR
:
457 image
= self
.render_xor()
458 elif mode
== self
.options
.PIXEL_XOR
:
459 image
= self
.render_pixel_xor()
461 image
= self
.render_side_by_side()
463 image
= QtGui
.QPixmap()
464 self
.image
.pixmap
= image
467 zoom_mode
= self
.options
.zoom_mode
.currentIndex()
468 zoom_factor
= self
.options
.zoom_factors
[zoom_mode
][1]
469 if zoom_factor
> 0.0:
470 self
.image
.resetTransform()
471 self
.image
.scale(zoom_factor
, zoom_factor
)
472 poly
= self
.image
.mapToScene(self
.image
.viewport().rect())
473 self
.image
.last_scene_roi
= poly
.boundingRect()
475 def render_side_by_side(self
):
476 # Side-by-side lineup comp
477 pixmaps
= self
.pixmaps
478 width
= sum([pixmap
.width() for pixmap
in pixmaps
])
479 height
= max([pixmap
.height() for pixmap
in pixmaps
])
480 image
= create_image(width
, height
)
483 painter
= create_painter(image
)
485 for pixmap
in pixmaps
:
486 painter
.drawPixmap(x
, 0, pixmap
)
492 def render_comp(self
, comp_mode
):
493 # Get the max size to use as the render canvas
494 pixmaps
= self
.pixmaps
495 if len(pixmaps
) == 1:
498 width
= max([pixmap
.width() for pixmap
in pixmaps
])
499 height
= max([pixmap
.height() for pixmap
in pixmaps
])
500 image
= create_image(width
, height
)
502 painter
= create_painter(image
)
503 for pixmap
in (pixmaps
[0], pixmaps
[-1]):
504 x
= (width
- pixmap
.width()) // 2
505 y
= (height
- pixmap
.height()) // 2
506 painter
.drawPixmap(x
, y
, pixmap
)
507 painter
.setCompositionMode(comp_mode
)
512 def render_diff(self
):
513 comp_mode
= QtGui
.QPainter
.CompositionMode_Difference
514 return self
.render_comp(comp_mode
)
516 def render_xor(self
):
517 comp_mode
= QtGui
.QPainter
.CompositionMode_Xor
518 return self
.render_comp(comp_mode
)
520 def render_pixel_xor(self
):
521 comp_mode
= QtGui
.QPainter
.RasterOp_SourceXorDestination
522 return self
.render_comp(comp_mode
)
525 def create_image(width
, height
):
526 size
= QtCore
.QSize(width
, height
)
527 image
= QtGui
.QImage(size
, QtGui
.QImage
.Format_ARGB32_Premultiplied
)
528 image
.fill(Qt
.transparent
)
532 def create_painter(image
):
533 painter
= QtGui
.QPainter(image
)
534 painter
.fillRect(image
.rect(), Qt
.transparent
)
538 class Options(QtWidgets
.QWidget
):
539 """Provide the options widget used by the editor
541 Actions are registered on the parent widget.
545 # mode combobox indexes
551 def __init__(self
, parent
):
552 super(Options
, self
).__init
__(parent
)
554 self
.ignore_space_at_eol
= self
.add_option(
555 N_('Ignore changes in whitespace at EOL'))
557 self
.ignore_space_change
= self
.add_option(
558 N_('Ignore changes in amount of whitespace'))
560 self
.ignore_all_space
= self
.add_option(
561 N_('Ignore all whitespace'))
563 self
.function_context
= self
.add_option(
564 N_('Show whole surrounding functions of changes'))
566 self
.show_line_numbers
= self
.add_option(
567 N_('Show line numbers'))
569 self
.options
= options
= qtutils
.create_action_button(
570 tooltip
=N_('Diff Options'), icon
=icons
.configure())
571 qtutils
.hide_button_menu_indicator(options
)
573 self
.image_mode
= qtutils
.combo([
580 self
.zoom_factors
= (
581 (N_('Zoom to Fit'), 0.0),
589 zoom_modes
= [factor
[0] for factor
in self
.zoom_factors
]
590 self
.zoom_mode
= qtutils
.combo(zoom_modes
, parent
=self
)
592 self
.menu
= menu
= qtutils
.create_menu(N_('Diff Options'), options
)
593 options
.setMenu(menu
)
595 menu
.addAction(self
.ignore_space_at_eol
)
596 menu
.addAction(self
.ignore_space_change
)
597 menu
.addAction(self
.ignore_all_space
)
598 menu
.addAction(self
.show_line_numbers
)
599 menu
.addAction(self
.function_context
)
601 layout
= qtutils
.hbox(
602 defs
.no_margin
, defs
.no_spacing
,
603 self
.image_mode
, defs
.button_spacing
, self
.zoom_mode
, options
)
604 self
.setLayout(layout
)
606 self
.image_mode
.setFocusPolicy(Qt
.NoFocus
)
607 self
.zoom_mode
.setFocusPolicy(Qt
.NoFocus
)
608 self
.options
.setFocusPolicy(Qt
.NoFocus
)
609 self
.setFocusPolicy(Qt
.NoFocus
)
611 def set_diff_type(self
, diff_type
):
612 is_text
= diff_type
== 'text'
613 is_image
= diff_type
== 'image'
614 self
.options
.setVisible(is_text
)
615 self
.image_mode
.setVisible(is_image
)
616 self
.zoom_mode
.setVisible(is_image
)
618 def add_option(self
, title
):
619 action
= qtutils
.add_action(self
, title
, self
.update_options
)
620 action
.setCheckable(True)
623 def update_options(self
):
624 space_at_eol
= get(self
.ignore_space_at_eol
)
625 space_change
= get(self
.ignore_space_change
)
626 all_space
= get(self
.ignore_all_space
)
627 function_context
= get(self
.function_context
)
628 gitcmds
.update_diff_overrides(
629 space_at_eol
, space_change
, all_space
, function_context
)
630 self
.widget
.update_options()
633 class DiffEditor(DiffTextEdit
):
637 options_changed
= Signal()
639 diff_text_updated
= Signal(object)
641 def __init__(self
, context
, options
, parent
):
642 DiffTextEdit
.__init
__(self
, context
, parent
, numbers
=True)
643 self
.context
= context
644 self
.model
= model
= context
.model
645 self
.selection_model
= selection_model
= context
.selection
647 # "Diff Options" tool menu
648 self
.options
= options
649 self
.action_apply_selection
= qtutils
.add_action(
650 self
, 'Apply', self
.apply_selection
, hotkeys
.STAGE_DIFF
)
652 self
.action_revert_selection
= qtutils
.add_action(
653 self
, 'Revert', self
.revert_selection
, hotkeys
.REVERT
)
654 self
.action_revert_selection
.setIcon(icons
.undo())
656 self
.launch_editor
= actions
.launch_editor_at_line(
657 context
, self
, *hotkeys
.ACCEPT
)
658 self
.launch_difftool
= actions
.launch_difftool(context
, self
)
659 self
.stage_or_unstage
= actions
.stage_or_unstage(context
, self
)
661 # Emit up/down signals so that they can be routed by the main widget
662 self
.move_up
= actions
.move_up(self
)
663 self
.move_down
= actions
.move_down(self
)
665 diff_text_updated
= model
.message_diff_text_updated
666 model
.add_observer(diff_text_updated
, self
.diff_text_updated
.emit
)
667 self
.diff_text_updated
.connect(self
.set_diff
, type=Qt
.QueuedConnection
)
669 selection_model
.add_observer(
670 selection_model
.message_selection_changed
, self
.updated
.emit
)
671 self
.updated
.connect(self
.refresh
, type=Qt
.QueuedConnection
)
672 # Update the selection model when the cursor changes
673 self
.cursorPositionChanged
.connect(self
._update
_line
_number
)
677 s
= self
.selection_model
.selection()
679 if s
.modified
and model
.stageable():
680 if s
.modified
[0] in model
.submodules
:
682 elif s
.modified
[0] not in model
.unstaged_deleted
:
684 self
.action_revert_selection
.setEnabled(enabled
)
686 def enable_line_numbers(self
, enabled
):
687 """Enable/disable the diff line number display"""
688 self
.numbers
.setVisible(enabled
)
689 self
.options
.show_line_numbers
.setChecked(enabled
)
691 def show_line_numbers(self
):
692 """Return True if we should show line numbers"""
693 return get(self
.options
.show_line_numbers
)
695 def update_options(self
):
696 self
.numbers
.setVisible(self
.show_line_numbers())
697 self
.options_changed
.emit()
700 def contextMenuEvent(self
, event
):
701 """Create the context menu for the diff display."""
702 menu
= qtutils
.create_menu(N_('Actions'), self
)
703 context
= self
.context
705 s
= self
.selection_model
.selection()
706 filename
= self
.selection_model
.filename()
708 if model
.stageable() or model
.unstageable():
709 if model
.stageable():
710 self
.stage_or_unstage
.setText(N_('Stage'))
712 self
.stage_or_unstage
.setText(N_('Unstage'))
713 menu
.addAction(self
.stage_or_unstage
)
715 if s
.modified
and model
.stageable():
717 if item
in model
.submodules
:
718 path
= core
.abspath(item
)
719 action
= menu
.addAction(
720 icons
.add(), cmds
.Stage
.name(),
721 cmds
.run(cmds
.Stage
, context
, s
.modified
))
722 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
723 menu
.addAction(icons
.cola(), N_('Launch git-cola'),
724 cmds
.run(cmds
.OpenRepo
, context
, path
))
725 elif item
not in model
.unstaged_deleted
:
726 if self
.has_selection():
727 apply_text
= N_('Stage Selected Lines')
728 revert_text
= N_('Revert Selected Lines...')
730 apply_text
= N_('Stage Diff Hunk')
731 revert_text
= N_('Revert Diff Hunk...')
733 self
.action_apply_selection
.setText(apply_text
)
734 self
.action_apply_selection
.setIcon(icons
.add())
736 self
.action_revert_selection
.setText(revert_text
)
738 menu
.addAction(self
.action_apply_selection
)
739 menu
.addAction(self
.action_revert_selection
)
741 if s
.staged
and model
.unstageable():
743 if item
in model
.submodules
:
744 path
= core
.abspath(item
)
745 action
= menu
.addAction(
746 icons
.remove(), cmds
.Unstage
.name(),
747 cmds
.run(cmds
.Unstage
, context
, s
.staged
))
748 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
749 menu
.addAction(icons
.cola(), N_('Launch git-cola'),
750 cmds
.run(cmds
.OpenRepo
, context
, path
))
751 elif item
not in model
.staged_deleted
:
752 if self
.has_selection():
753 apply_text
= N_('Unstage Selected Lines')
755 apply_text
= N_('Unstage Diff Hunk')
757 self
.action_apply_selection
.setText(apply_text
)
758 self
.action_apply_selection
.setIcon(icons
.remove())
759 menu
.addAction(self
.action_apply_selection
)
761 if model
.stageable() or model
.unstageable():
762 # Do not show the "edit" action when the file does not exist.
763 # Untracked files exist by definition.
764 if filename
and core
.exists(filename
):
766 menu
.addAction(self
.launch_editor
)
768 # Removed files can still be diffed.
769 menu
.addAction(self
.launch_difftool
)
771 # Add the Previous/Next File actions, which improves discoverability
772 # of their associated shortcuts
774 menu
.addAction(self
.move_up
)
775 menu
.addAction(self
.move_down
)
778 action
= menu
.addAction(icons
.copy(), N_('Copy'), self
.copy
)
779 action
.setShortcut(QtGui
.QKeySequence
.Copy
)
781 action
= menu
.addAction(icons
.select_all(), N_('Select All'),
783 action
.setShortcut(QtGui
.QKeySequence
.SelectAll
)
784 menu
.exec_(self
.mapToGlobal(event
.pos()))
786 def mousePressEvent(self
, event
):
787 if event
.button() == Qt
.RightButton
:
788 # Intercept right-click to move the cursor to the current position.
789 # setTextCursor() clears the selection so this is only done when
790 # nothing is selected.
791 if not self
.has_selection():
792 cursor
= self
.cursorForPosition(event
.pos())
793 self
.setTextCursor(cursor
)
795 return super(DiffEditor
, self
).mousePressEvent(event
)
797 def setPlainText(self
, text
):
798 """setPlainText(str) while retaining scrollbar positions"""
801 highlight
= mode
not in (model
.mode_none
, model
.mode_untracked
)
802 self
.highlighter
.set_enabled(highlight
)
804 scrollbar
= self
.verticalScrollBar()
806 scrollvalue
= get(scrollbar
)
813 DiffTextEdit
.setPlainText(self
, text
)
815 if scrollbar
and scrollvalue
is not None:
816 scrollbar
.setValue(scrollvalue
)
818 def selected_lines(self
):
819 cursor
= self
.textCursor()
820 selection_start
= cursor
.selectionStart()
821 selection_end
= max(selection_start
, cursor
.selectionEnd() - 1)
828 for line_idx
, line
in enumerate(get(self
).splitlines()):
829 line_end
= line_start
+ len(line
)
830 if line_start
<= selection_start
<= line_end
:
831 first_line_idx
= line_idx
832 if line_start
<= selection_end
<= line_end
:
833 last_line_idx
= line_idx
835 line_start
= line_end
+ 1
837 if first_line_idx
== -1:
838 first_line_idx
= line_idx
840 if last_line_idx
== -1:
841 last_line_idx
= line_idx
843 return first_line_idx
, last_line_idx
845 def apply_selection(self
):
847 s
= self
.selection_model
.single_selection()
848 if model
.stageable() and s
.modified
:
849 self
.process_diff_selection()
850 elif model
.unstageable():
851 self
.process_diff_selection(reverse
=True)
853 def revert_selection(self
):
854 """Destructively revert selected lines or hunk from a worktree file."""
856 if self
.has_selection():
857 title
= N_('Revert Selected Lines?')
858 ok_text
= N_('Revert Selected Lines')
860 title
= N_('Revert Diff Hunk?')
861 ok_text
= N_('Revert Diff Hunk')
863 if not Interaction
.confirm(
865 N_('This operation drops uncommitted changes.\n'
866 'These changes cannot be recovered.'),
867 N_('Revert the uncommitted changes?'),
868 ok_text
, default
=True, icon
=icons
.undo()):
870 self
.process_diff_selection(reverse
=True, apply_to_worktree
=True)
872 def process_diff_selection(self
, reverse
=False, apply_to_worktree
=False):
873 """Implement un/staging of the selected line(s) or hunk."""
874 if self
.selection_model
.is_empty():
876 context
= self
.context
877 first_line_idx
, last_line_idx
= self
.selected_lines()
878 cmds
.do(cmds
.ApplyDiffSelection
, context
,
879 first_line_idx
, last_line_idx
,
880 self
.has_selection(), reverse
, apply_to_worktree
)
882 def _update_line_number(self
):
883 """Update the selection model when the cursor changes"""
884 self
.selection_model
.line_number
= self
.numbers
.current_line()
887 class DiffWidget(QtWidgets
.QWidget
):
889 def __init__(self
, context
, notifier
, parent
, is_commit
=False):
890 QtWidgets
.QWidget
.__init
__(self
, parent
)
892 self
.context
= context
895 author_font
= QtGui
.QFont(self
.font())
896 author_font
.setPointSize(int(author_font
.pointSize() * 1.1))
898 summary_font
= QtGui
.QFont(author_font
)
899 summary_font
.setWeight(QtGui
.QFont
.Bold
)
901 policy
= QtWidgets
.QSizePolicy(QtWidgets
.QSizePolicy
.MinimumExpanding
,
902 QtWidgets
.QSizePolicy
.Minimum
)
904 self
.gravatar_label
= gravatar
.GravatarLabel()
906 self
.author_label
= TextLabel()
907 self
.author_label
.setTextFormat(Qt
.RichText
)
908 self
.author_label
.setFont(author_font
)
909 self
.author_label
.setSizePolicy(policy
)
910 self
.author_label
.setAlignment(Qt
.AlignBottom
)
911 self
.author_label
.elide()
913 self
.date_label
= TextLabel()
914 self
.date_label
.setTextFormat(Qt
.PlainText
)
915 self
.date_label
.setSizePolicy(policy
)
916 self
.date_label
.setAlignment(Qt
.AlignTop
)
917 self
.date_label
.hide()
919 self
.summary_label
= TextLabel()
920 self
.summary_label
.setTextFormat(Qt
.PlainText
)
921 self
.summary_label
.setFont(summary_font
)
922 self
.summary_label
.setSizePolicy(policy
)
923 self
.summary_label
.setAlignment(Qt
.AlignTop
)
924 self
.summary_label
.elide()
926 self
.oid_label
= TextLabel()
927 self
.oid_label
.setTextFormat(Qt
.PlainText
)
928 self
.oid_label
.setSizePolicy(policy
)
929 self
.oid_label
.setAlignment(Qt
.AlignTop
)
930 self
.oid_label
.elide()
932 self
.diff
= DiffTextEdit(
933 context
, self
, is_commit
=is_commit
, whitespace
=False)
935 self
.info_layout
= qtutils
.vbox(defs
.no_margin
, defs
.no_spacing
,
936 self
.author_label
, self
.date_label
,
937 self
.summary_label
, self
.oid_label
)
939 self
.logo_layout
= qtutils
.hbox(defs
.no_margin
, defs
.button_spacing
,
940 self
.gravatar_label
, self
.info_layout
)
941 self
.logo_layout
.setContentsMargins(defs
.margin
, 0, defs
.margin
, 0)
943 self
.main_layout
= qtutils
.vbox(defs
.no_margin
, defs
.spacing
,
944 self
.logo_layout
, self
.diff
)
945 self
.setLayout(self
.main_layout
)
947 notifier
.add_observer(COMMITS_SELECTED
, self
.commits_selected
)
948 notifier
.add_observer(FILES_SELECTED
, self
.files_selected
)
949 self
.set_tabwidth(prefs
.tabwidth(context
))
951 def set_tabwidth(self
, width
):
952 self
.diff
.set_tabwidth(width
)
954 def set_diff_oid(self
, oid
, filename
=None):
955 context
= self
.context
956 self
.diff
.save_scrollbar()
957 self
.diff
.set_loading_message()
958 task
= DiffInfoTask(context
, oid
, filename
, self
)
959 self
.context
.runtask
.start(task
, result
=self
.set_diff
)
961 def commits_selected(self
, commits
):
962 if len(commits
) != 1:
965 oid
= self
.oid
= commit
.oid
966 email
= commit
.email
or ''
967 summary
= commit
.summary
or ''
968 author
= commit
.author
or ''
970 self
.set_details(oid
, author
, email
, '', summary
)
971 self
.set_diff_oid(oid
)
973 def set_diff(self
, diff
):
974 self
.diff
.set_diff(diff
)
976 def set_details(self
, oid
, author
, email
, date
, summary
):
982 author_text
= ("""%(author)s <"""
983 """<a href="mailto:%(email)s">"""
984 """%(email)s</a>>"""
986 author_template
= '%(author)s <%(email)s>' % template_args
988 self
.date_label
.set_text(date
)
989 self
.date_label
.setVisible(bool(date
))
990 self
.oid_label
.set_text(oid
)
991 self
.author_label
.set_template(author_text
, author_template
)
992 self
.summary_label
.set_text(summary
)
993 self
.gravatar_label
.set_email(email
)
995 def files_selected(self
, filenames
):
998 self
.set_diff_oid(self
.oid
, filenames
[0])
1001 class TextLabel(QtWidgets
.QLabel
):
1003 def __init__(self
, parent
=None):
1004 QtWidgets
.QLabel
.__init
__(self
, parent
)
1005 self
.setTextInteractionFlags(Qt
.TextSelectableByMouse |
1006 Qt
.LinksAccessibleByMouse
)
1011 self
._metrics
= QtGui
.QFontMetrics(self
.font())
1012 self
.setOpenExternalLinks(True)
1017 def set_text(self
, text
):
1018 self
.set_template(text
, text
)
1020 def set_template(self
, text
, template
):
1021 self
._display
= text
1023 self
._template
= template
1024 self
.update_text(self
.width())
1025 self
.setText(self
._display
)
1027 def update_text(self
, width
):
1028 self
._display
= self
._text
1031 text
= self
._metrics
.elidedText(self
._template
,
1032 Qt
.ElideRight
, width
-2)
1033 if text
!= self
._template
:
1034 self
._display
= text
1037 def setFont(self
, font
):
1038 self
._metrics
= QtGui
.QFontMetrics(font
)
1039 QtWidgets
.QLabel
.setFont(self
, font
)
1041 def resizeEvent(self
, event
):
1043 self
.update_text(event
.size().width())
1044 block
= self
.blockSignals(True)
1045 self
.setText(self
._display
)
1046 self
.blockSignals(block
)
1047 QtWidgets
.QLabel
.resizeEvent(self
, event
)
1050 class DiffInfoTask(qtutils
.Task
):
1052 def __init__(self
, context
, oid
, filename
, parent
):
1053 qtutils
.Task
.__init
__(self
, parent
)
1054 self
.context
= context
1056 self
.filename
= filename
1059 context
= self
.context
1061 return gitcmds
.diff_info(context
, oid
, filename
=self
.filename
)