cmds: add a separate LaunchEditorAtLine command
[git-cola.git] / cola / widgets / diff.py
blobcb49ca18507158ef622d299526f17649e21f9ffc
1 from __future__ import division, absolute_import, unicode_literals
2 import os
3 import re
5 from qtpy import QtCore
6 from qtpy import QtGui
7 from qtpy import QtWidgets
8 from qtpy.QtCore import Qt
9 from qtpy.QtCore import Signal
11 from ..i18n import N_
12 from ..interaction import Interaction
13 from ..qtutils import get
14 from .. import actions
15 from .. import cmds
16 from .. import core
17 from .. import diffparse
18 from .. import gitcmds
19 from .. import gravatar
20 from .. import hotkeys
21 from .. import icons
22 from .. import utils
23 from .. import qtutils
24 from .text import TextDecorator
25 from .text import VimHintedPlainTextEdit
26 from . import defs
27 from . import imageview
30 COMMITS_SELECTED = 'COMMITS_SELECTED'
31 FILES_SELECTED = 'FILES_SELECTED'
34 class DiffSyntaxHighlighter(QtGui.QSyntaxHighlighter):
35 """Implements the diff syntax highlighting"""
37 INITIAL_STATE = -1
38 DEFAULT_STATE = 0
39 DIFFSTAT_STATE = 1
40 DIFF_FILE_HEADER_STATE = 2
41 DIFF_STATE = 3
42 SUBMODULE_STATE = 4
44 DIFF_FILE_HEADER_START_RGX = re.compile(r'diff --git a/.* b/.*')
45 DIFF_HUNK_HEADER_RGX = re.compile(r'(?:@@ -[0-9,]+ \+[0-9,]+ @@)|'
46 r'(?:@@@ (?:-[0-9,]+ ){2}\+[0-9,]+ @@@)')
47 BAD_WHITESPACE_RGX = re.compile(r'\s+$')
49 def __init__(self, context, doc, whitespace=True, is_commit=False):
50 QtGui.QSyntaxHighlighter.__init__(self, doc)
51 self.whitespace = whitespace
52 self.enabled = True
53 self.is_commit = is_commit
55 QPalette = QtGui.QPalette
56 cfg = context.cfg
57 palette = QPalette()
58 disabled = palette.color(QPalette.Disabled, QPalette.Text)
59 header = qtutils.rgb_hex(disabled)
61 self.color_text = qtutils.RGB(cfg.color('text', '030303'))
62 self.color_add = qtutils.RGB(cfg.color('add', 'd2ffe4'))
63 self.color_remove = qtutils.RGB(cfg.color('remove', 'fee0e4'))
64 self.color_header = qtutils.RGB(cfg.color('header', header))
66 self.diff_header_fmt = qtutils.make_format(fg=self.color_header)
67 self.bold_diff_header_fmt = qtutils.make_format(
68 fg=self.color_header, bold=True)
70 self.diff_add_fmt = qtutils.make_format(
71 fg=self.color_text, bg=self.color_add)
72 self.diff_remove_fmt = qtutils.make_format(
73 fg=self.color_text, bg=self.color_remove)
74 self.bad_whitespace_fmt = qtutils.make_format(bg=Qt.red)
75 self.setCurrentBlockState(self.INITIAL_STATE)
77 def set_enabled(self, enabled):
78 self.enabled = enabled
80 def highlightBlock(self, text):
81 if not self.enabled or not text:
82 return
84 state = self.previousBlockState()
85 if state == self.INITIAL_STATE:
86 if text.startswith('Submodule '):
87 state = self.SUBMODULE_STATE
88 elif text.startswith('diff --git '):
89 state = self.DIFFSTAT_STATE
90 elif self.is_commit:
91 state = self.DEFAULT_STATE
92 else:
93 state = self.DIFFSTAT_STATE
95 if state == self.DIFFSTAT_STATE:
96 if self.DIFF_FILE_HEADER_START_RGX.match(text):
97 state = self.DIFF_FILE_HEADER_STATE
98 self.setFormat(0, len(text), self.diff_header_fmt)
99 elif self.DIFF_HUNK_HEADER_RGX.match(text):
100 state = self.DIFF_STATE
101 self.setFormat(0, len(text), self.bold_diff_header_fmt)
102 elif '|' in text:
103 i = text.index('|')
104 self.setFormat(0, i, self.bold_diff_header_fmt)
105 self.setFormat(i, len(text) - i, self.diff_header_fmt)
106 else:
107 self.setFormat(0, len(text), self.diff_header_fmt)
108 elif state == self.DIFF_FILE_HEADER_STATE:
109 if self.DIFF_HUNK_HEADER_RGX.match(text):
110 state = self.DIFF_STATE
111 self.setFormat(0, len(text), self.bold_diff_header_fmt)
112 else:
113 self.setFormat(0, len(text), self.diff_header_fmt)
114 elif state == self.DIFF_STATE:
115 if self.DIFF_FILE_HEADER_START_RGX.match(text):
116 state = self.DIFF_FILE_HEADER_STATE
117 self.setFormat(0, len(text), self.diff_header_fmt)
118 elif self.DIFF_HUNK_HEADER_RGX.match(text):
119 self.setFormat(0, len(text), self.bold_diff_header_fmt)
120 elif text.startswith('-'):
121 self.setFormat(0, len(text), self.diff_remove_fmt)
122 elif text.startswith('+'):
123 self.setFormat(0, len(text), self.diff_add_fmt)
124 if self.whitespace:
125 m = self.BAD_WHITESPACE_RGX.search(text)
126 if m is not None:
127 i = m.start()
128 self.setFormat(i, len(text) - i,
129 self.bad_whitespace_fmt)
131 self.setCurrentBlockState(state)
134 class DiffTextEdit(VimHintedPlainTextEdit):
136 def __init__(self, context, parent,
137 is_commit=False, whitespace=True, numbers=False):
138 VimHintedPlainTextEdit.__init__(self, context, '', parent=parent)
139 # Diff/patch syntax highlighter
140 self.highlighter = DiffSyntaxHighlighter(
141 context, self.document(),
142 is_commit=is_commit, whitespace=whitespace)
143 if numbers:
144 self.numbers = DiffLineNumbers(context, self)
145 self.numbers.hide()
146 else:
147 self.numbers = None
148 self.scrollvalue = None
150 self.cursorPositionChanged.connect(self._cursor_changed)
152 def _cursor_changed(self):
153 """Update the line number display when the cursor changes"""
154 line_number = max(0, self.textCursor().blockNumber())
155 if self.numbers:
156 self.numbers.set_highlighted(line_number)
158 def resizeEvent(self, event):
159 super(DiffTextEdit, self).resizeEvent(event)
160 if self.numbers:
161 self.numbers.refresh_size()
163 def save_scrollbar(self):
164 """Save the scrollbar value, but only on the first call"""
165 if self.scrollvalue is None:
166 scrollbar = self.verticalScrollBar()
167 if scrollbar:
168 scrollvalue = get(scrollbar)
169 else:
170 scrollvalue = None
171 self.scrollvalue = scrollvalue
173 def restore_scrollbar(self):
174 """Restore the scrollbar and clear state"""
175 scrollbar = self.verticalScrollBar()
176 scrollvalue = self.scrollvalue
177 if scrollbar and scrollvalue is not None:
178 scrollbar.setValue(scrollvalue)
179 self.scrollvalue = None
181 def set_loading_message(self):
182 self.hint.set_value('+++ ' + N_('Loading...'))
183 self.set_value('')
185 def set_diff(self, diff):
186 """Set the diff text, but save the scrollbar"""
187 diff = diff.rstrip('\n') # diffs include two empty newlines
188 self.save_scrollbar()
190 self.hint.set_value('')
191 if self.numbers:
192 self.numbers.set_diff(diff)
193 self.set_value(diff)
195 self.restore_scrollbar()
198 class DiffLineNumbers(TextDecorator):
200 def __init__(self, context, parent):
201 TextDecorator.__init__(self, parent)
202 self.highlight_line = -1
203 self.lines = None
204 self.parser = diffparse.DiffLines()
205 self.formatter = diffparse.FormatDigits()
207 self.setFont(qtutils.diff_font(context))
208 self._char_width = self.fontMetrics().width('0')
210 QPalette = QtGui.QPalette
211 self._palette = palette = self.palette()
212 self._base = palette.color(QtGui.QPalette.Base)
213 self._highlight = palette.color(QPalette.Highlight)
214 self._highlight_text = palette.color(QPalette.HighlightedText)
215 self._window = palette.color(QPalette.Window)
216 self._disabled = palette.color(QPalette.Disabled, QPalette.Text)
218 def set_diff(self, diff):
219 parser = self.parser
220 lines = parser.parse(diff)
221 if parser.valid:
222 self.lines = lines
223 self.formatter.set_digits(self.parser.digits())
224 else:
225 self.lines = None
227 def set_lines(self, lines):
228 self.lines = lines
230 def width_hint(self):
231 if not self.isVisible():
232 return 0
233 parser = self.parser
235 if parser.merge:
236 columns = 3
237 extra = 3 # one space in-between, one space after
238 else:
239 columns = 2
240 extra = 2 # one space in-between, one space after
242 if parser.valid:
243 digits = parser.digits() * columns
244 else:
245 digits = 4
247 return defs.margin + (self._char_width * (digits + extra))
249 def set_highlighted(self, line_number):
250 """Set the line to highlight"""
251 self.highlight_line = line_number
253 def current_line(self):
254 if self.lines and self.highlight_line >= 0:
255 # Find the next valid line
256 for line in self.lines[self.highlight_line:]:
257 # take the "new" line number: last value in tuple
258 line_number = line[-1]
259 if line_number > 0:
260 return line_number
262 # Find the previous valid line
263 for line in self.lines[self.highlight_line-1::-1]:
264 # take the "new" line number: last value in tuple
265 line_number = line[-1]
266 if line_number > 0:
267 return line_number
268 return None
270 def paintEvent(self, event):
271 """Paint the line number"""
272 if not self.lines:
273 return
275 painter = QtGui.QPainter(self)
276 painter.fillRect(event.rect(), self._base)
278 editor = self.editor
279 content_offset = editor.contentOffset()
280 block = editor.firstVisibleBlock()
281 width = self.width()
282 event_rect_bottom = event.rect().bottom()
284 highlight = self._highlight
285 highlight_text = self._highlight_text
286 disabled = self._disabled
288 fmt = self.formatter
289 lines = self.lines
290 num_lines = len(self.lines)
291 painter.setPen(disabled)
292 text = ''
294 while block.isValid():
295 block_number = block.blockNumber()
296 if block_number >= num_lines:
297 break
298 block_geom = editor.blockBoundingGeometry(block)
299 block_top = block_geom.translated(content_offset).top()
300 if not block.isVisible() or block_top >= event_rect_bottom:
301 break
303 rect = block_geom.translated(content_offset).toRect()
304 if block_number == self.highlight_line:
305 painter.fillRect(rect.x(), rect.y(),
306 width, rect.height(), highlight)
307 painter.setPen(highlight_text)
308 else:
309 painter.setPen(disabled)
311 line = lines[block_number]
312 if len(line) == 2:
313 a, b = line
314 text = fmt.value(a, b)
315 elif len(line) == 3:
316 old, base, new = line
317 text = fmt.merge_value(old, base, new)
319 painter.drawText(rect.x(), rect.y(),
320 self.width() - (defs.margin * 2), rect.height(),
321 Qt.AlignRight | Qt.AlignVCenter, text)
323 block = block.next() # pylint: disable=next-method-called
326 class Viewer(QtWidgets.QWidget):
327 """Text and image diff viewers"""
329 images_changed = Signal(object)
330 type_changed = Signal(object)
332 def __init__(self, context, parent=None):
333 super(Viewer, self).__init__(parent)
335 self.context = context
336 self.model = model = context.model
337 self.images = []
338 self.pixmaps = []
339 self.options = options = Options(self)
340 self.text = DiffEditor(context, options, self)
341 self.image = imageview.ImageView(parent=self)
342 self.image.setFocusPolicy(Qt.NoFocus)
344 stack = self.stack = QtWidgets.QStackedWidget(self)
345 stack.addWidget(self.text)
346 stack.addWidget(self.image)
348 self.main_layout = qtutils.vbox(
349 defs.no_margin, defs.no_spacing, self.stack)
350 self.setLayout(self.main_layout)
352 # Observe images
353 images_msg = model.message_images_changed
354 model.add_observer(images_msg, self.images_changed.emit)
355 self.images_changed.connect(self.set_images, type=Qt.QueuedConnection)
357 # Observe the diff type
358 diff_type_msg = model.message_diff_type_changed
359 model.add_observer(diff_type_msg, self.type_changed.emit)
360 self.type_changed.connect(self.set_diff_type, type=Qt.QueuedConnection)
362 # Observe the image mode combo box
363 options.image_mode.currentIndexChanged.connect(lambda _: self.render())
364 options.zoom_mode.currentIndexChanged.connect(lambda _: self.render())
366 self.setFocusProxy(self.text)
368 def export_state(self, state):
369 state['show_diff_line_numbers'] = self.text.show_line_numbers()
370 state['image_diff_mode'] = self.options.image_mode.currentIndex()
371 state['image_zoom_mode'] = self.options.zoom_mode.currentIndex()
372 return state
374 def apply_state(self, state):
375 diff_numbers = bool(state.get('show_diff_line_numbers', False))
376 self.text.enable_line_numbers(diff_numbers)
378 image_mode = utils.asint(state.get('image_diff_mode', 0))
379 self.options.image_mode.set_index(image_mode)
381 zoom_mode = utils.asint(state.get('image_zoom_mode', 0))
382 self.options.zoom_mode.set_index(zoom_mode)
383 return True
385 def set_diff_type(self, diff_type):
386 """Manage the image and text diff views when selection changes"""
387 self.options.set_diff_type(diff_type)
388 if diff_type == 'image':
389 self.stack.setCurrentWidget(self.image)
390 self.render()
391 else:
392 self.stack.setCurrentWidget(self.text)
394 def update_options(self):
395 self.text.update_options()
397 def reset(self):
398 self.image.pixmap = QtGui.QPixmap()
399 self.cleanup()
401 def cleanup(self):
402 for (image, unlink) in self.images:
403 if unlink and core.exists(image):
404 os.unlink(image)
405 self.images = []
407 def set_images(self, images):
408 self.images = images
409 self.pixmaps = []
410 if not images:
411 self.reset()
412 return False
414 # In order to comp, we first have to load all the images
415 all_pixmaps = [QtGui.QPixmap(image[0]) for image in images]
416 pixmaps = [pixmap for pixmap in all_pixmaps if not pixmap.isNull()]
417 if not pixmaps:
418 self.reset()
419 return False
421 self.pixmaps = pixmaps
422 self.render()
423 self.cleanup()
424 return True
426 def render(self):
427 # Update images
428 if self.pixmaps:
429 mode = self.options.image_mode.currentIndex()
430 if mode == self.options.SIDE_BY_SIDE:
431 image = self.render_side_by_side()
432 elif mode == self.options.DIFF:
433 image = self.render_diff()
434 elif mode == self.options.XOR:
435 image = self.render_xor()
436 elif mode == self.options.PIXEL_XOR:
437 image = self.render_pixel_xor()
438 else:
439 image = self.render_side_by_side()
440 else:
441 image = QtGui.QPixmap()
442 self.image.pixmap = image
444 # Apply zoom
445 zoom_mode = self.options.zoom_mode.currentIndex()
446 zoom_factor = self.options.zoom_factors[zoom_mode][1]
447 if zoom_factor > 0.0:
448 self.image.resetTransform()
449 self.image.scale(zoom_factor, zoom_factor)
450 poly = self.image.mapToScene(self.image.viewport().rect())
451 self.image.last_scene_roi = poly.boundingRect()
453 def render_side_by_side(self):
454 # Side-by-side lineup comp
455 pixmaps = self.pixmaps
456 width = sum([pixmap.width() for pixmap in pixmaps])
457 height = max([pixmap.height() for pixmap in pixmaps])
458 image = create_image(width, height)
460 # Paint each pixmap
461 painter = create_painter(image)
462 x = 0
463 for pixmap in pixmaps:
464 painter.drawPixmap(x, 0, pixmap)
465 x += pixmap.width()
466 painter.end()
468 return image
470 def render_comp(self, comp_mode):
471 # Get the max size to use as the render canvas
472 pixmaps = self.pixmaps
473 if len(pixmaps) == 1:
474 return pixmaps[0]
476 width = max([pixmap.width() for pixmap in pixmaps])
477 height = max([pixmap.height() for pixmap in pixmaps])
478 image = create_image(width, height)
480 painter = create_painter(image)
481 for pixmap in (pixmaps[0], pixmaps[-1]):
482 x = (width - pixmap.width()) // 2
483 y = (height - pixmap.height()) // 2
484 painter.drawPixmap(x, y, pixmap)
485 painter.setCompositionMode(comp_mode)
486 painter.end()
488 return image
490 def render_diff(self):
491 comp_mode = QtGui.QPainter.CompositionMode_Difference
492 return self.render_comp(comp_mode)
494 def render_xor(self):
495 comp_mode = QtGui.QPainter.CompositionMode_Xor
496 return self.render_comp(comp_mode)
498 def render_pixel_xor(self):
499 comp_mode = QtGui.QPainter.RasterOp_SourceXorDestination
500 return self.render_comp(comp_mode)
503 def create_image(width, height):
504 size = QtCore.QSize(width, height)
505 image = QtGui.QImage(size, QtGui.QImage.Format_ARGB32_Premultiplied)
506 image.fill(Qt.transparent)
507 return image
510 def create_painter(image):
511 painter = QtGui.QPainter(image)
512 painter.fillRect(image.rect(), Qt.transparent)
513 return painter
516 class Options(QtWidgets.QWidget):
517 """Provide the options widget used by the editor
519 Actions are registered on the parent widget.
523 # mode combobox indexes
524 SIDE_BY_SIDE = 0
525 DIFF = 1
526 XOR = 2
527 PIXEL_XOR = 3
529 def __init__(self, parent):
530 super(Options, self).__init__(parent)
531 self.widget = parent
532 self.ignore_space_at_eol = self.add_option(
533 N_('Ignore changes in whitespace at EOL'))
535 self.ignore_space_change = self.add_option(
536 N_('Ignore changes in amount of whitespace'))
538 self.ignore_all_space = self.add_option(
539 N_('Ignore all whitespace'))
541 self.function_context = self.add_option(
542 N_('Show whole surrounding functions of changes'))
544 self.show_line_numbers = self.add_option(
545 N_('Show line numbers'))
547 self.options = options = qtutils.create_action_button(
548 tooltip=N_('Diff Options'), icon=icons.configure())
549 qtutils.hide_button_menu_indicator(options)
551 self.image_mode = qtutils.combo([
552 N_('Side by side'),
553 N_('Diff'),
554 N_('XOR'),
555 N_('Pixel XOR'),
558 self.zoom_factors = (
559 (N_('Zoom to Fit'), 0.0),
560 (N_('25%'), 0.25),
561 (N_('50%'), 0.5),
562 (N_('100%'), 1.0),
563 (N_('200%'), 2.0),
564 (N_('400%'), 4.0),
565 (N_('800%'), 8.0),
567 zoom_modes = [factor[0] for factor in self.zoom_factors]
568 self.zoom_mode = qtutils.combo(zoom_modes, parent=self)
570 self.menu = menu = qtutils.create_menu(N_('Diff Options'), options)
571 options.setMenu(menu)
573 menu.addAction(self.ignore_space_at_eol)
574 menu.addAction(self.ignore_space_change)
575 menu.addAction(self.ignore_all_space)
576 menu.addAction(self.show_line_numbers)
577 menu.addAction(self.function_context)
579 layout = qtutils.hbox(
580 defs.no_margin, defs.no_spacing,
581 self.image_mode, defs.button_spacing, self.zoom_mode, options)
582 self.setLayout(layout)
584 self.image_mode.setFocusPolicy(Qt.NoFocus)
585 self.zoom_mode.setFocusPolicy(Qt.NoFocus)
586 self.options.setFocusPolicy(Qt.NoFocus)
587 self.setFocusPolicy(Qt.NoFocus)
589 def set_diff_type(self, diff_type):
590 is_text = diff_type == 'text'
591 is_image = diff_type == 'image'
592 self.options.setVisible(is_text)
593 self.image_mode.setVisible(is_image)
594 self.zoom_mode.setVisible(is_image)
596 def add_option(self, title):
597 action = qtutils.add_action(self, title, self.update_options)
598 action.setCheckable(True)
599 return action
601 def update_options(self):
602 space_at_eol = get(self.ignore_space_at_eol)
603 space_change = get(self.ignore_space_change)
604 all_space = get(self.ignore_all_space)
605 function_context = get(self.function_context)
606 gitcmds.update_diff_overrides(
607 space_at_eol, space_change, all_space, function_context)
608 self.widget.update_options()
611 class DiffEditor(DiffTextEdit):
613 up = Signal()
614 down = Signal()
615 options_changed = Signal()
616 updated = Signal()
617 diff_text_updated = Signal(object)
619 def __init__(self, context, options, parent):
620 DiffTextEdit.__init__(self, context, parent, numbers=True)
621 self.context = context
622 self.model = model = context.model
623 self.selection_model = selection_model = context.selection
625 # "Diff Options" tool menu
626 self.options = options
627 self.action_apply_selection = qtutils.add_action(
628 self, 'Apply', self.apply_selection, hotkeys.STAGE_DIFF)
630 self.action_revert_selection = qtutils.add_action(
631 self, 'Revert', self.revert_selection, hotkeys.REVERT)
632 self.action_revert_selection.setIcon(icons.undo())
634 self.launch_editor = actions.launch_editor_at_line(
635 context, self, *hotkeys.ACCEPT)
636 self.launch_difftool = actions.launch_difftool(context, self)
637 self.stage_or_unstage = actions.stage_or_unstage(context, self)
639 # Emit up/down signals so that they can be routed by the main widget
640 self.move_up = actions.move_up(self)
641 self.move_down = actions.move_down(self)
643 diff_text_updated = model.message_diff_text_updated
644 model.add_observer(diff_text_updated, self.diff_text_updated.emit)
645 self.diff_text_updated.connect(self.set_diff, type=Qt.QueuedConnection)
647 selection_model.add_observer(
648 selection_model.message_selection_changed, self.updated.emit)
649 self.updated.connect(self.refresh, type=Qt.QueuedConnection)
650 # Update the selection model when the cursor changes
651 self.cursorPositionChanged.connect(self._update_line_number)
653 def refresh(self):
654 enabled = False
655 s = self.selection_model.selection()
656 model = self.model
657 if s.modified and model.stageable():
658 if s.modified[0] in model.submodules:
659 pass
660 elif s.modified[0] not in model.unstaged_deleted:
661 enabled = True
662 self.action_revert_selection.setEnabled(enabled)
664 def enable_line_numbers(self, enabled):
665 """Enable/disable the diff line number display"""
666 self.numbers.setVisible(enabled)
667 self.options.show_line_numbers.setChecked(enabled)
669 def show_line_numbers(self):
670 """Return True if we should show line numbers"""
671 return get(self.options.show_line_numbers)
673 def update_options(self):
674 self.numbers.setVisible(self.show_line_numbers())
675 self.options_changed.emit()
677 # Qt overrides
678 def contextMenuEvent(self, event):
679 """Create the context menu for the diff display."""
680 menu = qtutils.create_menu(N_('Actions'), self)
681 context = self.context
682 model = self.model
683 s = self.selection_model.selection()
684 filename = self.selection_model.filename()
686 if model.stageable() or model.unstageable():
687 if model.stageable():
688 self.stage_or_unstage.setText(N_('Stage'))
689 else:
690 self.stage_or_unstage.setText(N_('Unstage'))
691 menu.addAction(self.stage_or_unstage)
693 if s.modified and model.stageable():
694 item = s.modified[0]
695 if item in model.submodules:
696 path = core.abspath(item)
697 action = menu.addAction(
698 icons.add(), cmds.Stage.name(),
699 cmds.run(cmds.Stage, context, s.modified))
700 action.setShortcut(hotkeys.STAGE_SELECTION)
701 menu.addAction(icons.cola(), N_('Launch git-cola'),
702 cmds.run(cmds.OpenRepo, context, path))
703 elif item not in model.unstaged_deleted:
704 if self.has_selection():
705 apply_text = N_('Stage Selected Lines')
706 revert_text = N_('Revert Selected Lines...')
707 else:
708 apply_text = N_('Stage Diff Hunk')
709 revert_text = N_('Revert Diff Hunk...')
711 self.action_apply_selection.setText(apply_text)
712 self.action_apply_selection.setIcon(icons.add())
714 self.action_revert_selection.setText(revert_text)
716 menu.addAction(self.action_apply_selection)
717 menu.addAction(self.action_revert_selection)
719 if s.staged and model.unstageable():
720 item = s.staged[0]
721 if item in model.submodules:
722 path = core.abspath(item)
723 action = menu.addAction(
724 icons.remove(), cmds.Unstage.name(),
725 cmds.run(cmds.Unstage, context, s.staged))
726 action.setShortcut(hotkeys.STAGE_SELECTION)
727 menu.addAction(icons.cola(), N_('Launch git-cola'),
728 cmds.run(cmds.OpenRepo, context, path))
729 elif item not in model.staged_deleted:
730 if self.has_selection():
731 apply_text = N_('Unstage Selected Lines')
732 else:
733 apply_text = N_('Unstage Diff Hunk')
735 self.action_apply_selection.setText(apply_text)
736 self.action_apply_selection.setIcon(icons.remove())
737 menu.addAction(self.action_apply_selection)
739 if model.stageable() or model.unstageable():
740 # Do not show the "edit" action when the file does not exist.
741 # Untracked files exist by definition.
742 if filename and core.exists(filename):
743 menu.addSeparator()
744 menu.addAction(self.launch_editor)
746 # Removed files can still be diffed.
747 menu.addAction(self.launch_difftool)
749 # Add the Previous/Next File actions, which improves discoverability
750 # of their associated shortcuts
751 menu.addSeparator()
752 menu.addAction(self.move_up)
753 menu.addAction(self.move_down)
755 menu.addSeparator()
756 action = menu.addAction(icons.copy(), N_('Copy'), self.copy)
757 action.setShortcut(QtGui.QKeySequence.Copy)
759 action = menu.addAction(icons.select_all(), N_('Select All'),
760 self.selectAll)
761 action.setShortcut(QtGui.QKeySequence.SelectAll)
762 menu.exec_(self.mapToGlobal(event.pos()))
764 def mousePressEvent(self, event):
765 if event.button() == Qt.RightButton:
766 # Intercept right-click to move the cursor to the current position.
767 # setTextCursor() clears the selection so this is only done when
768 # nothing is selected.
769 if not self.has_selection():
770 cursor = self.cursorForPosition(event.pos())
771 self.setTextCursor(cursor)
773 return super(DiffEditor, self).mousePressEvent(event)
775 def setPlainText(self, text):
776 """setPlainText(str) while retaining scrollbar positions"""
777 model = self.model
778 mode = model.mode
779 highlight = (mode != model.mode_none and
780 mode != model.mode_untracked)
781 self.highlighter.set_enabled(highlight)
783 scrollbar = self.verticalScrollBar()
784 if scrollbar:
785 scrollvalue = get(scrollbar)
786 else:
787 scrollvalue = None
789 if text is None:
790 return
792 DiffTextEdit.setPlainText(self, text)
794 if scrollbar and scrollvalue is not None:
795 scrollbar.setValue(scrollvalue)
797 def selected_lines(self):
798 cursor = self.textCursor()
799 selection_start = cursor.selectionStart()
800 selection_end = max(selection_start, cursor.selectionEnd() - 1)
802 first_line_idx = -1
803 last_line_idx = -1
804 line_idx = 0
805 line_start = 0
807 for line_idx, line in enumerate(get(self).splitlines()):
808 line_end = line_start + len(line)
809 if line_start <= selection_start <= line_end:
810 first_line_idx = line_idx
811 if line_start <= selection_end <= line_end:
812 last_line_idx = line_idx
813 break
814 line_start = line_end + 1
816 if first_line_idx == -1:
817 first_line_idx = line_idx
819 if last_line_idx == -1:
820 last_line_idx = line_idx
822 return first_line_idx, last_line_idx
824 def apply_selection(self):
825 model = self.model
826 s = self.selection_model.single_selection()
827 if model.stageable() and s.modified:
828 self.process_diff_selection()
829 elif model.unstageable():
830 self.process_diff_selection(reverse=True)
832 def revert_selection(self):
833 """Destructively revert selected lines or hunk from a worktree file."""
835 if self.has_selection():
836 title = N_('Revert Selected Lines?')
837 ok_text = N_('Revert Selected Lines')
838 else:
839 title = N_('Revert Diff Hunk?')
840 ok_text = N_('Revert Diff Hunk')
842 if not Interaction.confirm(
843 title,
844 N_('This operation drops uncommitted changes.\n'
845 'These changes cannot be recovered.'),
846 N_('Revert the uncommitted changes?'),
847 ok_text, default=True, icon=icons.undo()):
848 return
849 self.process_diff_selection(reverse=True, apply_to_worktree=True)
851 def process_diff_selection(self, reverse=False, apply_to_worktree=False):
852 """Implement un/staging of the selected line(s) or hunk."""
853 if self.selection_model.is_empty():
854 return
855 context = self.context
856 first_line_idx, last_line_idx = self.selected_lines()
857 cmds.do(cmds.ApplyDiffSelection, context,
858 first_line_idx, last_line_idx,
859 self.has_selection(), reverse, apply_to_worktree)
861 def _update_line_number(self):
862 """Update the selection model when the cursor changes"""
863 self.selection_model.line_number = self.numbers.current_line()
866 class DiffWidget(QtWidgets.QWidget):
868 def __init__(self, context, notifier, parent, is_commit=False):
869 QtWidgets.QWidget.__init__(self, parent)
871 self.context = context
872 self.oid = 'HEAD'
874 author_font = QtGui.QFont(self.font())
875 author_font.setPointSize(int(author_font.pointSize() * 1.1))
877 summary_font = QtGui.QFont(author_font)
878 summary_font.setWeight(QtGui.QFont.Bold)
880 policy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding,
881 QtWidgets.QSizePolicy.Minimum)
883 self.gravatar_label = gravatar.GravatarLabel()
885 self.author_label = TextLabel()
886 self.author_label.setTextFormat(Qt.RichText)
887 self.author_label.setFont(author_font)
888 self.author_label.setSizePolicy(policy)
889 self.author_label.setAlignment(Qt.AlignBottom)
890 self.author_label.elide()
892 self.summary_label = TextLabel()
893 self.summary_label.setTextFormat(Qt.PlainText)
894 self.summary_label.setFont(summary_font)
895 self.summary_label.setSizePolicy(policy)
896 self.summary_label.setAlignment(Qt.AlignTop)
897 self.summary_label.elide()
899 self.oid_label = TextLabel()
900 self.oid_label.setTextFormat(Qt.PlainText)
901 self.oid_label.setSizePolicy(policy)
902 self.oid_label.setAlignment(Qt.AlignTop)
903 self.oid_label.elide()
905 self.diff = DiffTextEdit(
906 context, self, is_commit=is_commit, whitespace=False)
908 self.info_layout = qtutils.vbox(defs.no_margin, defs.no_spacing,
909 self.author_label, self.summary_label,
910 self.oid_label)
912 self.logo_layout = qtutils.hbox(defs.no_margin, defs.button_spacing,
913 self.gravatar_label, self.info_layout)
914 self.logo_layout.setContentsMargins(defs.margin, 0, defs.margin, 0)
916 self.main_layout = qtutils.vbox(defs.no_margin, defs.spacing,
917 self.logo_layout, self.diff)
918 self.setLayout(self.main_layout)
920 notifier.add_observer(COMMITS_SELECTED, self.commits_selected)
921 notifier.add_observer(FILES_SELECTED, self.files_selected)
923 def set_tabwidth(self, width):
924 self.diff.set_tabwidth(width)
926 def set_diff_oid(self, oid, filename=None):
927 context = self.context
928 self.diff.save_scrollbar()
929 self.diff.set_loading_message()
930 task = DiffInfoTask(context, oid, filename, self)
931 self.context.runtask.start(task, result=self.diff.set_diff)
933 def commits_selected(self, commits):
934 if len(commits) != 1:
935 return
936 commit = commits[0]
937 self.oid = commit.oid
939 email = commit.email or ''
940 summary = commit.summary or ''
941 author = commit.author or ''
943 template_args = {
944 'author': author,
945 'email': email,
946 'summary': summary
949 author_text = ("""%(author)s &lt;"""
950 """<a href="mailto:%(email)s">"""
951 """%(email)s</a>&gt;"""
952 % template_args)
954 author_template = '%(author)s <%(email)s>' % template_args
955 self.author_label.set_template(author_text, author_template)
956 self.summary_label.set_text(summary)
957 self.oid_label.set_text(self.oid)
959 self.set_diff_oid(self.oid)
960 self.gravatar_label.set_email(email)
962 def files_selected(self, filenames):
963 if not filenames:
964 return
965 self.set_diff_oid(self.oid, filenames[0])
968 class TextLabel(QtWidgets.QLabel):
970 def __init__(self, parent=None):
971 QtWidgets.QLabel.__init__(self, parent)
972 self.setTextInteractionFlags(Qt.TextSelectableByMouse |
973 Qt.LinksAccessibleByMouse)
974 self._display = ''
975 self._template = ''
976 self._text = ''
977 self._elide = False
978 self._metrics = QtGui.QFontMetrics(self.font())
979 self.setOpenExternalLinks(True)
981 def elide(self):
982 self._elide = True
984 def set_text(self, text):
985 self.set_template(text, text)
987 def set_template(self, text, template):
988 self._display = text
989 self._text = text
990 self._template = template
991 self.update_text(self.width())
992 self.setText(self._display)
994 def update_text(self, width):
995 self._display = self._text
996 if not self._elide:
997 return
998 text = self._metrics.elidedText(self._template,
999 Qt.ElideRight, width-2)
1000 if text != self._template:
1001 self._display = text
1003 # Qt overrides
1004 def setFont(self, font):
1005 self._metrics = QtGui.QFontMetrics(font)
1006 QtWidgets.QLabel.setFont(self, font)
1008 def resizeEvent(self, event):
1009 if self._elide:
1010 self.update_text(event.size().width())
1011 block = self.blockSignals(True)
1012 self.setText(self._display)
1013 self.blockSignals(block)
1014 QtWidgets.QLabel.resizeEvent(self, event)
1017 class DiffInfoTask(qtutils.Task):
1019 def __init__(self, context, oid, filename, parent):
1020 qtutils.Task.__init__(self, parent)
1021 self.context = context
1022 self.oid = oid
1023 self.filename = filename
1025 def task(self):
1026 context = self.context
1027 oid = self.oid
1028 return gitcmds.diff_info(context, oid, filename=self.filename)