status: do not exit diff mode when unselecting a file
[git-cola.git] / cola / widgets / status.py
blob3ac80e6db4b3c5c389e1125f22f229fd7a55033b
1 from __future__ import absolute_import, division, print_function, unicode_literals
2 import itertools
3 import os
4 from functools import partial
6 from qtpy.QtCore import Qt
7 from qtpy import QtCore
8 from qtpy import QtGui
9 from qtpy import QtWidgets
11 from ..i18n import N_
12 from ..models import prefs
13 from ..models import selection
14 from ..widgets import gitignore
15 from ..widgets import standard
16 from ..qtutils import get
17 from ..settings import Settings
18 from .. import actions
19 from .. import cmds
20 from .. import core
21 from .. import hotkeys
22 from .. import icons
23 from .. import qtutils
24 from .. import utils
25 from . import common
26 from . import completion
27 from . import defs
28 from . import text
31 # Top-level status widget item indexes.
32 HEADER_IDX = -1
33 STAGED_IDX = 0
34 UNMERGED_IDX = 1
35 MODIFIED_IDX = 2
36 UNTRACKED_IDX = 3
37 END_IDX = 4
39 # Indexes into the saved_selection entries.
40 NEW_PATHS_IDX = 0
41 OLD_PATHS_IDX = 1
42 SELECTION_IDX = 2
43 SELECT_FN_IDX = 3
46 class StatusWidget(QtWidgets.QFrame):
47 """
48 Provides a git-status-like repository widget.
50 This widget observes the main model and broadcasts
51 Qt signals.
53 """
55 def __init__(self, context, titlebar, parent):
56 QtWidgets.QFrame.__init__(self, parent)
57 self.context = context
59 tooltip = N_('Toggle the paths filter')
60 icon = icons.ellipsis()
61 self.filter_button = qtutils.create_action_button(tooltip=tooltip, icon=icon)
62 self.filter_widget = StatusFilterWidget(context)
63 self.filter_widget.hide()
64 self.tree = StatusTreeWidget(context, parent=self)
65 self.setFocusProxy(self.tree)
67 tooltip = N_('Exit "Diff" mode')
68 icon = icons.circle_slash_red()
69 self.exit_diff_mode_button = qtutils.create_action_button(
70 tooltip=tooltip, icon=icon, visible=False
73 self.main_layout = qtutils.vbox(
74 defs.no_margin, defs.no_spacing, self.filter_widget, self.tree
76 self.setLayout(self.main_layout)
78 self.toggle_action = qtutils.add_action(
79 self, tooltip, self.toggle_filter, hotkeys.FILTER
82 titlebar.add_corner_widget(self.exit_diff_mode_button)
83 titlebar.add_corner_widget(self.filter_button)
85 qtutils.connect_button(self.filter_button, self.toggle_filter)
86 qtutils.connect_button(
87 self.exit_diff_mode_button, cmds.run(cmds.ResetMode, self.context)
90 def toggle_filter(self):
91 """Toggle the paths filter"""
92 shown = not self.filter_widget.isVisible()
93 self.filter_widget.setVisible(shown)
94 if shown:
95 self.filter_widget.setFocus()
96 else:
97 self.tree.setFocus()
99 def set_initial_size(self):
100 """Set the initial size of the status widget"""
101 self.setMaximumWidth(222)
102 QtCore.QTimer.singleShot(1, lambda: self.setMaximumWidth(2**13))
104 def refresh(self):
105 """Refresh the tree and rerun the diff to see updates"""
106 self.tree.show_selection()
108 def set_filter(self, txt):
109 """Set the filter text"""
110 self.filter_widget.setVisible(True)
111 self.filter_widget.text.set_value(txt)
112 self.filter_widget.apply_filter()
114 def set_mode(self, mode):
115 """React to changes in model's editing mode"""
116 exit_diff_mode_visible = mode == self.context.model.mode_diff
117 self.exit_diff_mode_button.setVisible(exit_diff_mode_visible)
119 def move_up(self):
120 self.tree.move_up()
122 def move_down(self):
123 self.tree.move_down()
125 def select_header(self):
126 self.tree.select_header()
129 # pylint: disable=too-many-ancestors
130 class StatusTreeWidget(QtWidgets.QTreeWidget):
131 # Read-only access to the mode state
132 mode = property(lambda self: self._model.mode)
134 def __init__(self, context, parent=None):
135 QtWidgets.QTreeWidget.__init__(self, parent)
136 self.context = context
137 self.selection_model = context.selection
139 self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
140 self.headerItem().setHidden(True)
141 self.setAllColumnsShowFocus(True)
142 self.setSortingEnabled(False)
143 self.setUniformRowHeights(True)
144 self.setAnimated(True)
145 self.setRootIsDecorated(False)
146 self.setAutoScroll(False)
147 self.setDragEnabled(True)
148 self.setDragDropMode(QtWidgets.QAbstractItemView.DragOnly)
149 self._alt_drag = False
151 if not prefs.status_indent(context):
152 self.setIndentation(0)
154 ok_icon = icons.ok()
155 compare = icons.compare()
156 question = icons.question()
157 self._add_toplevel_item(N_('Staged'), ok_icon, hide=True)
158 self._add_toplevel_item(N_('Unmerged'), compare, hide=True)
159 self._add_toplevel_item(N_('Modified'), compare, hide=True)
160 self._add_toplevel_item(N_('Untracked'), question, hide=True)
162 # Used to restore the selection
163 self.old_vscroll = None
164 self.old_hscroll = None
165 self.old_selection = None
166 self.old_contents = None
167 self.old_current_item = None
168 self.previous_contents = None
169 self.was_visible = True
170 self.expanded_items = set()
172 self.image_formats = qtutils.ImageFormats()
174 self.process_selection_action = qtutils.add_action(
175 self,
176 cmds.StageOrUnstage.name(),
177 self._stage_selection,
178 hotkeys.STAGE_SELECTION,
180 self.process_selection_action.setIcon(icons.add())
182 self.stage_or_unstage_all_action = qtutils.add_action(
183 self,
184 cmds.StageOrUnstageAll.name(),
185 cmds.run(cmds.StageOrUnstageAll, self.context),
186 hotkeys.STAGE_ALL,
188 self.stage_or_unstage_all_action.setIcon(icons.add())
190 self.revert_unstaged_edits_action = qtutils.add_action(
191 self,
192 cmds.RevertUnstagedEdits.name(),
193 cmds.run(cmds.RevertUnstagedEdits, context),
194 hotkeys.REVERT,
196 self.revert_unstaged_edits_action.setIcon(icons.undo())
198 self.launch_difftool_action = qtutils.add_action(
199 self,
200 cmds.LaunchDifftool.name(),
201 cmds.run(cmds.LaunchDifftool, context),
202 hotkeys.DIFF,
204 self.launch_difftool_action.setIcon(icons.diff())
206 self.launch_editor_action = actions.launch_editor_at_line(
207 context, self, *hotkeys.ACCEPT
210 self.default_app_action = common.default_app_action(
211 context, self, self.selected_group
214 self.parent_dir_action = common.parent_dir_action(
215 context, self, self.selected_group
218 self.worktree_dir_action = common.worktree_dir_action(context, self)
220 self.terminal_action = common.terminal_action(
221 context, self, func=self.selected_group
224 self.up_action = qtutils.add_action(
225 self,
226 N_('Move Up'),
227 self.move_up,
228 hotkeys.MOVE_UP,
229 hotkeys.MOVE_UP_SECONDARY,
232 self.down_action = qtutils.add_action(
233 self,
234 N_('Move Down'),
235 self.move_down,
236 hotkeys.MOVE_DOWN,
237 hotkeys.MOVE_DOWN_SECONDARY,
240 self.copy_path_action = qtutils.add_action(
241 self,
242 N_('Copy Path to Clipboard'),
243 partial(copy_path, context),
244 hotkeys.COPY,
246 self.copy_path_action.setIcon(icons.copy())
248 self.copy_relpath_action = qtutils.add_action(
249 self,
250 N_('Copy Relative Path to Clipboard'),
251 partial(copy_relpath, context),
252 hotkeys.CUT,
254 self.copy_relpath_action.setIcon(icons.copy())
256 self.copy_leading_paths_value = 1
258 self.copy_basename_action = qtutils.add_action(
259 self, N_('Copy Basename to Clipboard'), partial(copy_basename, context)
261 self.copy_basename_action.setIcon(icons.copy())
263 self.copy_customize_action = qtutils.add_action(
264 self, N_('Customize...'), partial(customize_copy_actions, context, self)
266 self.copy_customize_action.setIcon(icons.configure())
268 self.view_history_action = qtutils.add_action(
269 self, N_('View History...'), partial(view_history, context), hotkeys.HISTORY
272 self.view_blame_action = qtutils.add_action(
273 self, N_('Blame...'), partial(view_blame, context), hotkeys.BLAME
276 self.annex_add_action = qtutils.add_action(
277 self, N_('Add to Git Annex'), cmds.run(cmds.AnnexAdd, context)
280 self.lfs_track_action = qtutils.add_action(
281 self, N_('Add to Git LFS'), cmds.run(cmds.LFSTrack, context)
284 # MoveToTrash and Delete use the same shortcut.
285 # We will only bind one of them, depending on whether or not the
286 # MoveToTrash command is available. When available, the hotkey
287 # is bound to MoveToTrash, otherwise it is bound to Delete.
288 if cmds.MoveToTrash.AVAILABLE:
289 self.move_to_trash_action = qtutils.add_action(
290 self,
291 N_('Move files to trash'),
292 self._trash_untracked_files,
293 hotkeys.TRASH,
295 self.move_to_trash_action.setIcon(icons.discard())
296 delete_shortcut = hotkeys.DELETE_FILE
297 else:
298 self.move_to_trash_action = None
299 delete_shortcut = hotkeys.DELETE_FILE_SECONDARY
301 self.delete_untracked_files_action = qtutils.add_action(
302 self, N_('Delete Files...'), self._delete_untracked_files, delete_shortcut
304 self.delete_untracked_files_action.setIcon(icons.discard())
306 # The model is stored as self._model because self.model() is a
307 # QTreeWidgetItem method that returns a QAbstractItemModel.
308 self._model = context.model
309 self._model.previous_contents.connect(
310 self._set_previous_contents, type=Qt.QueuedConnection
312 self._model.about_to_update.connect(
313 self._about_to_update, type=Qt.QueuedConnection
315 self._model.updated.connect(self.refresh, type=Qt.QueuedConnection)
316 self._model.diff_text_changed.connect(
317 self._make_current_item_visible, type=Qt.QueuedConnection
319 # pylint: disable=no-member
320 self.itemSelectionChanged.connect(self.show_selection)
321 self.itemDoubleClicked.connect(cmds.run(cmds.StageOrUnstage, self.context))
322 self.itemCollapsed.connect(lambda x: self._update_column_widths())
323 self.itemExpanded.connect(lambda x: self._update_column_widths())
325 def _make_current_item_visible(self):
326 item = self.currentItem()
327 if item:
328 qtutils.scroll_to_item(self, item)
330 def _add_toplevel_item(self, txt, icon, hide=False):
331 context = self.context
332 font = self.font()
333 if prefs.bold_headers(context):
334 font.setBold(True)
335 else:
336 font.setItalic(True)
338 item = QtWidgets.QTreeWidgetItem(self)
339 item.setFont(0, font)
340 item.setText(0, txt)
341 item.setIcon(0, icon)
342 if prefs.bold_headers(context):
343 item.setBackground(0, self.palette().midlight())
344 if hide:
345 item.setHidden(True)
347 def _restore_selection(self):
348 """Apply the old selection to the newly updated items"""
349 # This function is called after a new set of items have been added to
350 # the per-category file lists. Its purpose is to either restore the
351 # existing selection or to create a new intuitive selection based on
352 # a combination of the old items, the old selection and the new items.
353 if not self.old_selection or not self.old_contents:
354 return
355 # The old set of categorized files.
356 old_c = self.old_contents
357 # The old selection.
358 old_s = self.old_selection
359 # The current/new set of categorized files.
360 new_c = self.contents()
362 select_staged = partial(_select_item, self, new_c.staged, self._staged_item)
363 select_unmerged = partial(
364 _select_item, self, new_c.unmerged, self._unmerged_item
366 select_modified = partial(
367 _select_item, self, new_c.modified, self._modified_item
369 select_untracked = partial(
370 _select_item, self, new_c.untracked, self._untracked_item
373 saved_selection = [
374 (set(new_c.staged), old_c.staged, set(old_s.staged), select_staged),
375 (set(new_c.unmerged), old_c.unmerged, set(old_s.unmerged), select_unmerged),
376 (set(new_c.modified), old_c.modified, set(old_s.modified), select_modified),
378 set(new_c.untracked),
379 old_c.untracked,
380 set(old_s.untracked),
381 select_untracked,
385 # Restore the current item
386 if self.old_current_item:
387 category, idx = self.old_current_item
388 if _apply_toplevel_selection(self, category, idx):
389 return
390 # Reselect the current item
391 selection_info = saved_selection[category]
392 new = selection_info[NEW_PATHS_IDX]
393 old = selection_info[OLD_PATHS_IDX]
394 reselect = selection_info[SELECT_FN_IDX]
395 try:
396 item = old[idx]
397 except IndexError:
398 item = None
399 if item and item in new:
400 reselect(item, current=True)
402 # Restore previously selected items.
403 # When reselecting in this section we only care that the items are
404 # selected; we do not need to rerun the callbacks which were triggered
405 # above for the current item. Block signals to skip the callbacks.
407 # Reselect items that were previously selected and still exist in the
408 # current path lists. This handles a common case such as a Ctrl-R
409 # refresh which results in the same exact path state.
410 did_reselect = False
412 with qtutils.BlockSignals(self):
413 for (new, old, sel, reselect) in saved_selection:
414 for item in sel:
415 if item in new:
416 reselect(item, current=False)
417 did_reselect = True
419 # The status widget is used to interactively work your way down the
420 # list of Staged, Unmerged, Modified and Untracked items and perform
421 # an operation on them.
423 # For Staged items we intend to work our way down the list of Staged
424 # items while we unstage each item. For every other category we work
425 # our way down the list of {Unmerged,Modified,Untracked} items while
426 # we stage each item.
428 # The following block of code implements the behavior of selecting
429 # the next item based on the previous selection.
430 for (new, old, sel, reselect) in saved_selection:
431 # When modified is staged, select the next modified item
432 # When unmerged is staged, select the next unmerged item
433 # When unstaging, select the next staged item
434 # When staging untracked files, select the next untracked item
435 if len(new) >= len(old):
436 # The list did not shrink so it is not one of these cases.
437 continue
438 for item in sel:
439 # The item still exists so ignore it
440 if item in new or item not in old:
441 continue
442 # The item no longer exists in this list so search for
443 # its nearest neighbors and select them instead.
444 idx = old.index(item)
445 for j in itertools.chain(old[idx + 1:], reversed(old[:idx])):
446 if j in new:
447 reselect(j, current=True)
448 return
450 # If we already reselected stuff then there's nothing more to do.
451 if did_reselect:
452 return
453 # If we got this far then nothing was reselected and made current.
454 # Try a few more heuristics that we can use to keep something selected.
455 if self.old_current_item:
456 category, idx = self.old_current_item
457 _transplant_selection_across_sections(
458 category, idx, self.previous_contents, saved_selection
461 def _restore_scrollbars(self):
462 """Restore scrollbars to the stored values"""
463 qtutils.set_scrollbar_values(self, self.old_hscroll, self.old_vscroll)
464 self.old_hscroll = None
465 self.old_vscroll = None
467 def _stage_selection(self):
468 """Stage or unstage files according to the selection"""
469 context = self.context
470 selected_indexes = self.selected_indexes()
471 is_header = any(category == HEADER_IDX for (category, idx) in selected_indexes)
472 if is_header:
473 is_staged = any(
474 idx == STAGED_IDX and category == HEADER_IDX
475 for (category, idx) in selected_indexes
477 is_modified = any(
478 idx == MODIFIED_IDX and category == HEADER_IDX
479 for (category, idx) in selected_indexes
481 is_untracked = any(
482 idx == UNTRACKED_IDX and category == HEADER_IDX
483 for (category, idx) in selected_indexes
485 # A header item: 'Staged', 'Modified' or 'Untracked'.
486 if is_staged:
487 # If we have the staged header selected then the only sensible
488 # thing to do is to unstage everything and nothing else, even
489 # if the modified or untracked headers are selected.
490 cmds.do(cmds.UnstageAll, context)
491 return # Everything was unstaged. There's nothing more to be done.
492 elif is_modified and is_untracked:
493 # If both modified and untracked headers are selected then
494 # stage everything.
495 cmds.do(cmds.StageModifiedAndUntracked, context)
496 return # Nothing more to do.
497 # At this point we may stage all modified and untracked, and then
498 # possibly a subset of the other category (eg. all modified and
499 # some untracked). We don't return here so that StageOrUnstage
500 # gets a chance to run below.
501 elif is_modified:
502 cmds.do(cmds.StageModified, context)
503 elif is_untracked:
504 cmds.do(cmds.StageUntracked, context)
505 else:
506 # Do nothing for unmerged items, by design
507 pass
508 # Now handle individual files
509 cmds.do(cmds.StageOrUnstage, context)
511 def _staged_item(self, itemidx):
512 return self._subtree_item(STAGED_IDX, itemidx)
514 def _modified_item(self, itemidx):
515 return self._subtree_item(MODIFIED_IDX, itemidx)
517 def _unmerged_item(self, itemidx):
518 return self._subtree_item(UNMERGED_IDX, itemidx)
520 def _untracked_item(self, itemidx):
521 return self._subtree_item(UNTRACKED_IDX, itemidx)
523 def _unstaged_item(self, itemidx):
524 # is it modified?
525 item = self.topLevelItem(MODIFIED_IDX)
526 count = item.childCount()
527 if itemidx < count:
528 return item.child(itemidx)
529 # is it unmerged?
530 item = self.topLevelItem(UNMERGED_IDX)
531 count += item.childCount()
532 if itemidx < count:
533 return item.child(itemidx)
534 # is it untracked?
535 item = self.topLevelItem(UNTRACKED_IDX)
536 count += item.childCount()
537 if itemidx < count:
538 return item.child(itemidx)
539 # Nope..
540 return None
542 def _subtree_item(self, idx, itemidx):
543 parent = self.topLevelItem(idx)
544 return parent.child(itemidx)
546 def _set_previous_contents(self, staged, unmerged, modified, untracked):
547 """Callback triggered right before the model changes its contents"""
548 self.previous_contents = selection.State(staged, unmerged, modified, untracked)
550 def _about_to_update(self):
551 self._save_scrollbars()
552 self._save_selection()
554 def _save_scrollbars(self):
555 """Store the scrollbar values for later application"""
556 hscroll, vscroll = qtutils.get_scrollbar_values(self)
557 if hscroll is not None:
558 self.old_hscroll = hscroll
559 if vscroll is not None:
560 self.old_vscroll = vscroll
562 def current_item(self):
563 s = self.selected_indexes()
564 if not s:
565 return None
566 current = self.currentItem()
567 if not current:
568 return None
569 idx = self.indexFromItem(current)
570 if idx.parent().isValid():
571 parent_idx = idx.parent()
572 entry = (parent_idx.row(), idx.row())
573 else:
574 entry = (HEADER_IDX, idx.row())
575 return entry
577 def _save_selection(self):
578 self.old_contents = self.contents()
579 self.old_selection = self.selection()
580 self.old_current_item = self.current_item()
582 def refresh(self):
583 self._set_staged(self._model.staged)
584 self._set_modified(self._model.modified)
585 self._set_unmerged(self._model.unmerged)
586 self._set_untracked(self._model.untracked)
587 self._update_column_widths()
588 self._update_actions()
589 self._restore_selection()
590 self._restore_scrollbars()
592 def _update_actions(self, selected=None):
593 if selected is None:
594 selected = self.selection_model.selection()
595 can_revert_edits = bool(selected.staged or selected.modified)
596 self.revert_unstaged_edits_action.setEnabled(can_revert_edits)
598 enabled = self.selection_model.filename() is not None
599 self.default_app_action.setEnabled(enabled)
600 self.parent_dir_action.setEnabled(enabled)
601 self.copy_path_action.setEnabled(enabled)
602 self.copy_relpath_action.setEnabled(enabled)
603 self.copy_basename_action.setEnabled(enabled)
605 def _set_staged(self, items):
606 """Adds items to the 'Staged' subtree."""
607 with qtutils.BlockSignals(self):
608 self._set_subtree(
609 items,
610 STAGED_IDX,
611 N_('Staged'),
612 staged=True,
613 deleted_set=self._model.staged_deleted,
616 def _set_modified(self, items):
617 """Adds items to the 'Modified' subtree."""
618 with qtutils.BlockSignals(self):
619 self._set_subtree(
620 items,
621 MODIFIED_IDX,
622 N_('Modified'),
623 deleted_set=self._model.unstaged_deleted,
626 def _set_unmerged(self, items):
627 """Adds items to the 'Unmerged' subtree."""
628 deleted_set = {path for path in items if not core.exists(path)}
629 with qtutils.BlockSignals(self):
630 self._set_subtree(
631 items, UNMERGED_IDX, N_('Unmerged'), deleted_set=deleted_set
634 def _set_untracked(self, items):
635 """Adds items to the 'Untracked' subtree."""
636 with qtutils.BlockSignals(self):
637 self._set_subtree(items, UNTRACKED_IDX, N_('Untracked'), untracked=True)
639 def _set_subtree(
640 self, items, idx, parent_title, staged=False, untracked=False, deleted_set=None
642 """Add a list of items to a treewidget item."""
643 parent = self.topLevelItem(idx)
644 hide = not bool(items)
645 parent.setHidden(hide)
647 # sip v4.14.7 and below leak memory in parent.takeChildren()
648 # so we use this backwards-compatible construct instead
649 while parent.takeChild(0) is not None:
650 pass
652 for item in items:
653 deleted = deleted_set is not None and item in deleted_set
654 treeitem = qtutils.create_treeitem(
655 item, staged=staged, deleted=deleted, untracked=untracked
657 parent.addChild(treeitem)
658 self._expand_items(idx, items)
660 if prefs.status_show_totals(self.context):
661 parent.setText(0, '%s (%s)' % (parent_title, len(items)))
663 def _update_column_widths(self):
664 self.resizeColumnToContents(0)
666 def _expand_items(self, idx, items):
667 """Expand the top-level category "folder" once and only once."""
668 # Don't do this if items is empty; this makes it so that we
669 # don't add the top-level index into the expanded_items set
670 # until an item appears in a particular category.
671 if not items:
672 return
673 # Only run this once; we don't want to re-expand items that
674 # we've clicked on to re-collapse on updated().
675 if idx in self.expanded_items:
676 return
677 self.expanded_items.add(idx)
678 item = self.topLevelItem(idx)
679 if item:
680 self.expandItem(item)
682 def contextMenuEvent(self, event):
683 """Create context menus for the repo status tree."""
684 menu = self._create_context_menu()
685 menu.exec_(self.mapToGlobal(event.pos()))
687 def _create_context_menu(self):
688 """Set up the status menu for the repo status tree."""
689 sel = self.selection()
690 menu = qtutils.create_menu('Status', self)
691 selected_indexes = self.selected_indexes()
692 if selected_indexes:
693 category, idx = selected_indexes[0]
694 # A header item e.g. 'Staged', 'Modified', etc.
695 if category == HEADER_IDX:
696 return self._create_header_context_menu(menu, idx)
698 if sel.staged:
699 self._create_staged_context_menu(menu, sel)
700 elif sel.unmerged:
701 self._create_unmerged_context_menu(menu, sel)
702 else:
703 self._create_unstaged_context_menu(menu, sel)
705 if not menu.isEmpty():
706 menu.addSeparator()
708 if not self.selection_model.is_empty():
709 menu.addAction(self.default_app_action)
710 menu.addAction(self.parent_dir_action)
712 if self.terminal_action is not None:
713 menu.addAction(self.terminal_action)
715 menu.addAction(self.worktree_dir_action)
717 self._add_copy_actions(menu)
719 return menu
721 def _add_copy_actions(self, menu):
722 """Add the "Copy" sub-menu"""
723 enabled = self.selection_model.filename() is not None
724 self.copy_path_action.setEnabled(enabled)
725 self.copy_relpath_action.setEnabled(enabled)
726 self.copy_basename_action.setEnabled(enabled)
728 copy_menu = QtWidgets.QMenu(N_('Copy...'), menu)
729 copy_icon = icons.copy()
730 copy_menu.setIcon(copy_icon)
732 copy_leading_path_action = QtWidgets.QWidgetAction(copy_menu)
733 copy_leading_path_action.setEnabled(enabled)
735 widget = CopyLeadingPathWidget(
736 N_('Copy Leading Path to Clipboard'), self.context, copy_menu
739 # Store the value of the leading paths spinbox so that the value does not reset
740 # everytime the menu is shown and recreated.
741 widget.set_value(self.copy_leading_paths_value)
742 widget.spinbox.valueChanged.connect(
743 partial(setattr, self, 'copy_leading_paths_value')
745 copy_leading_path_action.setDefaultWidget(widget)
747 # Copy the leading path when the action is activated.
748 qtutils.connect_action(
749 copy_leading_path_action,
750 lambda widget=widget: copy_leading_path(context, widget.value()),
753 menu.addSeparator()
754 menu.addMenu(copy_menu)
755 copy_menu.addAction(self.copy_path_action)
756 copy_menu.addAction(self.copy_relpath_action)
757 copy_menu.addAction(copy_leading_path_action)
758 copy_menu.addAction(self.copy_basename_action)
760 settings = Settings.read()
761 copy_formats = settings.copy_formats
762 if copy_formats:
763 copy_menu.addSeparator()
765 context = self.context
766 for entry in copy_formats:
767 name = entry.get('name', '')
768 fmt = entry.get('format', '')
769 if name and fmt:
770 action = copy_menu.addAction(name, partial(copy_format, context, fmt))
771 action.setIcon(copy_icon)
772 action.setEnabled(enabled)
774 copy_menu.addSeparator()
775 copy_menu.addAction(self.copy_customize_action)
777 def _create_header_context_menu(self, menu, idx):
778 context = self.context
779 if idx == STAGED_IDX:
780 menu.addAction(
781 icons.remove(), N_('Unstage All'), cmds.run(cmds.UnstageAll, context)
783 elif idx == UNMERGED_IDX:
784 action = menu.addAction(
785 icons.add(),
786 cmds.StageUnmerged.name(),
787 cmds.run(cmds.StageUnmerged, context),
789 action.setShortcut(hotkeys.STAGE_SELECTION)
790 elif idx == MODIFIED_IDX:
791 action = menu.addAction(
792 icons.add(),
793 cmds.StageModified.name(),
794 cmds.run(cmds.StageModified, context),
796 action.setShortcut(hotkeys.STAGE_SELECTION)
797 elif idx == UNTRACKED_IDX:
798 action = menu.addAction(
799 icons.add(),
800 cmds.StageUntracked.name(),
801 cmds.run(cmds.StageUntracked, context),
803 action.setShortcut(hotkeys.STAGE_SELECTION)
804 return menu
806 def _create_staged_context_menu(self, menu, s):
807 if s.staged[0] in self._model.submodules:
808 return self._create_staged_submodule_context_menu(menu, s)
810 context = self.context
811 if self._model.is_unstageable():
812 action = menu.addAction(
813 icons.remove(),
814 N_('Unstage Selected'),
815 cmds.run(cmds.Unstage, context, self.staged()),
817 action.setShortcut(hotkeys.STAGE_SELECTION)
819 menu.addAction(self.launch_editor_action)
821 # Do all of the selected items exist?
822 all_exist = all(
823 i not in self._model.staged_deleted and core.exists(i)
824 for i in self.staged()
827 if all_exist:
828 menu.addAction(self.launch_difftool_action)
830 if self._model.is_undoable():
831 menu.addAction(self.revert_unstaged_edits_action)
833 menu.addAction(self.view_history_action)
834 menu.addAction(self.view_blame_action)
835 return menu
837 def _create_staged_submodule_context_menu(self, menu, s):
838 context = self.context
839 path = core.abspath(s.staged[0])
840 if len(self.staged()) == 1:
841 menu.addAction(
842 icons.cola(),
843 N_('Launch git-cola'),
844 cmds.run(cmds.OpenRepo, context, path),
846 menu.addSeparator()
847 action = menu.addAction(
848 icons.remove(),
849 N_('Unstage Selected'),
850 cmds.run(cmds.Unstage, context, self.staged()),
852 action.setShortcut(hotkeys.STAGE_SELECTION)
854 menu.addAction(self.view_history_action)
855 return menu
857 def _create_unmerged_context_menu(self, menu, _s):
858 context = self.context
859 menu.addAction(self.launch_difftool_action)
861 action = menu.addAction(
862 icons.add(),
863 N_('Stage Selected'),
864 cmds.run(cmds.Stage, context, self.unstaged()),
866 action.setShortcut(hotkeys.STAGE_SELECTION)
868 menu.addAction(self.launch_editor_action)
869 menu.addAction(self.view_history_action)
870 menu.addAction(self.view_blame_action)
871 return menu
873 def _create_unstaged_context_menu(self, menu, s):
874 context = self.context
875 modified_submodule = s.modified and s.modified[0] in self._model.submodules
876 if modified_submodule:
877 return self._create_modified_submodule_context_menu(menu, s)
879 if self._model.is_stageable():
880 action = menu.addAction(
881 icons.add(),
882 N_('Stage Selected'),
883 cmds.run(cmds.Stage, context, self.unstaged()),
885 action.setShortcut(hotkeys.STAGE_SELECTION)
887 if not self.selection_model.is_empty():
888 menu.addAction(self.launch_editor_action)
890 # Do all of the selected items exist?
891 all_exist = all(
892 i not in self._model.unstaged_deleted and core.exists(i)
893 for i in self.staged()
896 if all_exist and s.modified and self._model.is_stageable():
897 menu.addAction(self.launch_difftool_action)
899 if s.modified and self._model.is_stageable() and self._model.is_undoable():
900 menu.addSeparator()
901 menu.addAction(self.revert_unstaged_edits_action)
903 if all_exist and s.untracked:
904 # Git Annex / Git LFS
905 annex = self._model.annex
906 lfs = core.find_executable('git-lfs')
907 if annex or lfs:
908 menu.addSeparator()
909 if annex:
910 menu.addAction(self.annex_add_action)
911 if lfs:
912 menu.addAction(self.lfs_track_action)
914 menu.addSeparator()
915 if self.move_to_trash_action is not None:
916 menu.addAction(self.move_to_trash_action)
917 menu.addAction(self.delete_untracked_files_action)
918 menu.addSeparator()
919 menu.addAction(
920 icons.edit(),
921 N_('Ignore...'),
922 partial(gitignore.gitignore_view, self.context),
925 if not self.selection_model.is_empty():
926 menu.addAction(self.view_history_action)
927 menu.addAction(self.view_blame_action)
928 return menu
930 def _create_modified_submodule_context_menu(self, menu, s):
931 context = self.context
932 path = core.abspath(s.modified[0])
933 if len(self.unstaged()) == 1:
934 menu.addAction(
935 icons.cola(),
936 N_('Launch git-cola'),
937 cmds.run(cmds.OpenRepo, context, path),
939 menu.addAction(
940 icons.pull(),
941 N_('Update this submodule'),
942 cmds.run(cmds.SubmoduleUpdate, context, path),
944 menu.addSeparator()
946 if self._model.is_stageable():
947 menu.addSeparator()
948 action = menu.addAction(
949 icons.add(),
950 N_('Stage Selected'),
951 cmds.run(cmds.Stage, context, self.unstaged()),
953 action.setShortcut(hotkeys.STAGE_SELECTION)
955 menu.addAction(self.view_history_action)
956 return menu
958 def _delete_untracked_files(self):
959 cmds.do(cmds.Delete, self.context, self.untracked())
961 def _trash_untracked_files(self):
962 cmds.do(cmds.MoveToTrash, self.context, self.untracked())
964 def selected_path(self):
965 s = self.single_selection()
966 return s.staged or s.unmerged or s.modified or s.untracked or None
968 def single_selection(self):
969 """Scan across staged, modified, etc. and return a single item."""
970 staged = None
971 unmerged = None
972 modified = None
973 untracked = None
975 s = self.selection()
976 if s.staged:
977 staged = s.staged[0]
978 elif s.unmerged:
979 unmerged = s.unmerged[0]
980 elif s.modified:
981 modified = s.modified[0]
982 elif s.untracked:
983 untracked = s.untracked[0]
985 return selection.State(staged, unmerged, modified, untracked)
987 def selected_indexes(self):
988 """Returns a list of (category, row) representing the tree selection."""
989 selected = self.selectedIndexes()
990 result = []
991 for idx in selected:
992 if idx.parent().isValid():
993 parent_idx = idx.parent()
994 entry = (parent_idx.row(), idx.row())
995 else:
996 entry = (HEADER_IDX, idx.row())
997 result.append(entry)
998 return result
1000 def selection(self):
1001 """Return the current selection in the repo status tree."""
1002 return selection.State(
1003 self.staged(), self.unmerged(), self.modified(), self.untracked()
1006 def contents(self):
1007 """Return all of the current files in a selection.State container"""
1008 return selection.State(
1009 self._model.staged,
1010 self._model.unmerged,
1011 self._model.modified,
1012 self._model.untracked,
1015 def all_files(self):
1016 """Return all of the current active files as a flast list"""
1017 c = self.contents()
1018 return c.staged + c.unmerged + c.modified + c.untracked
1020 def selected_group(self):
1021 """A list of selected files in various states of being"""
1022 return selection.pick(self.selection())
1024 def selected_idx(self):
1025 c = self.contents()
1026 s = self.single_selection()
1027 offset = 0
1028 for content, sel in zip(c, s):
1029 if not content:
1030 continue
1031 if sel is not None:
1032 return offset + content.index(sel)
1033 offset += len(content)
1034 return None
1036 def select_by_index(self, idx):
1037 c = self.contents()
1038 to_try = [
1039 (c.staged, STAGED_IDX),
1040 (c.unmerged, UNMERGED_IDX),
1041 (c.modified, MODIFIED_IDX),
1042 (c.untracked, UNTRACKED_IDX),
1044 for content, toplevel_idx in to_try:
1045 if not content:
1046 continue
1047 if idx < len(content):
1048 parent = self.topLevelItem(toplevel_idx)
1049 item = parent.child(idx)
1050 if item is not None:
1051 qtutils.select_item(self, item)
1052 return
1053 idx -= len(content)
1055 def staged(self):
1056 return qtutils.get_selected_values(self, STAGED_IDX, self._model.staged)
1058 def unstaged(self):
1059 return self.unmerged() + self.modified() + self.untracked()
1061 def modified(self):
1062 return qtutils.get_selected_values(self, MODIFIED_IDX, self._model.modified)
1064 def unmerged(self):
1065 return qtutils.get_selected_values(self, UNMERGED_IDX, self._model.unmerged)
1067 def untracked(self):
1068 return qtutils.get_selected_values(self, UNTRACKED_IDX, self._model.untracked)
1070 def staged_items(self):
1071 return qtutils.get_selected_items(self, STAGED_IDX)
1073 def unstaged_items(self):
1074 return self.unmerged_items() + self.modified_items() + self.untracked_items()
1076 def modified_items(self):
1077 return qtutils.get_selected_items(self, MODIFIED_IDX)
1079 def unmerged_items(self):
1080 return qtutils.get_selected_items(self, UNMERGED_IDX)
1082 def untracked_items(self):
1083 return qtutils.get_selected_items(self, UNTRACKED_IDX)
1085 def show_selection(self):
1086 """Show the selected item."""
1087 context = self.context
1088 qtutils.scroll_to_item(self, self.currentItem())
1089 # Sync the selection model
1090 selected = self.selection()
1091 selection_model = self.selection_model
1092 selection_model.set_selection(selected)
1093 self._update_actions(selected=selected)
1095 selected_indexes = self.selected_indexes()
1096 if not selected_indexes:
1097 if self._model.is_amend_mode() or self._model.is_diff_mode():
1098 cmds.do(cmds.SetDiffText, context, '')
1099 else:
1100 cmds.do(cmds.ResetMode, context)
1101 return
1103 # A header item e.g. 'Staged', 'Modified', etc.
1104 category, idx = selected_indexes[0]
1105 header = category == HEADER_IDX
1106 if header:
1107 cls = {
1108 STAGED_IDX: cmds.DiffStagedSummary,
1109 MODIFIED_IDX: cmds.Diffstat,
1110 # TODO implement UnmergedSummary
1111 # UNMERGED_IDX: cmds.UnmergedSummary,
1112 UNTRACKED_IDX: cmds.UntrackedSummary,
1113 }.get(idx, cmds.Diffstat)
1114 cmds.do(cls, context)
1115 return
1117 staged = category == STAGED_IDX
1118 modified = category == MODIFIED_IDX
1119 unmerged = category == UNMERGED_IDX
1120 untracked = category == UNTRACKED_IDX
1122 if staged:
1123 item = self.staged_items()[0]
1124 elif unmerged:
1125 item = self.unmerged_items()[0]
1126 elif modified:
1127 item = self.modified_items()[0]
1128 elif untracked:
1129 item = self.unstaged_items()[0]
1130 else:
1131 item = None # this shouldn't happen
1132 assert item is not None
1134 path = item.path
1135 deleted = item.deleted
1136 image = self.image_formats.ok(path)
1138 # Update the diff text
1139 if staged:
1140 cmds.do(cmds.DiffStaged, context, path, deleted=deleted)
1141 elif modified:
1142 cmds.do(cmds.Diff, context, path, deleted=deleted)
1143 elif unmerged:
1144 cmds.do(cmds.Diff, context, path)
1145 elif untracked:
1146 cmds.do(cmds.ShowUntracked, context, path)
1148 # Images are diffed differently.
1149 # DiffImage transitions the diff mode to image.
1150 # DiffText transitions the diff mode to text.
1151 if image:
1152 cmds.do(
1153 cmds.DiffImage,
1154 context,
1155 path,
1156 deleted,
1157 staged,
1158 modified,
1159 unmerged,
1160 untracked,
1162 else:
1163 cmds.do(cmds.DiffText, context)
1165 def select_header(self):
1166 """Select an active header, which triggers a diffstat"""
1167 for idx in (
1168 STAGED_IDX,
1169 UNMERGED_IDX,
1170 MODIFIED_IDX,
1171 UNTRACKED_IDX,
1173 item = self.topLevelItem(idx)
1174 if item.childCount() > 0:
1175 self.clearSelection()
1176 self.setCurrentItem(item)
1177 return
1179 def move_up(self):
1180 idx = self.selected_idx()
1181 all_files = self.all_files()
1182 if idx is None:
1183 selected_indexes = self.selected_indexes()
1184 if selected_indexes:
1185 category, toplevel_idx = selected_indexes[0]
1186 if category == HEADER_IDX:
1187 item = self.itemAbove(self.topLevelItem(toplevel_idx))
1188 if item is not None:
1189 qtutils.select_item(self, item)
1190 return
1191 if all_files:
1192 self.select_by_index(len(all_files) - 1)
1193 return
1194 if idx - 1 >= 0:
1195 self.select_by_index(idx - 1)
1196 else:
1197 self.select_by_index(len(all_files) - 1)
1199 def move_down(self):
1200 idx = self.selected_idx()
1201 all_files = self.all_files()
1202 if idx is None:
1203 selected_indexes = self.selected_indexes()
1204 if selected_indexes:
1205 category, toplevel_idx = selected_indexes[0]
1206 if category == HEADER_IDX:
1207 item = self.itemBelow(self.topLevelItem(toplevel_idx))
1208 if item is not None:
1209 qtutils.select_item(self, item)
1210 return
1211 if all_files:
1212 self.select_by_index(0)
1213 return
1214 if idx + 1 < len(all_files):
1215 self.select_by_index(idx + 1)
1216 else:
1217 self.select_by_index(0)
1219 def mousePressEvent(self, event):
1220 """Keep track of whether to drag URLs or just text"""
1221 self._alt_drag = event.modifiers() & Qt.AltModifier
1222 return super(StatusTreeWidget, self).mousePressEvent(event)
1224 def mouseMoveEvent(self, event):
1225 """Keep track of whether to drag URLs or just text"""
1226 self._alt_drag = event.modifiers() & Qt.AltModifier
1227 return super(StatusTreeWidget, self).mouseMoveEvent(event)
1229 def mimeData(self, items):
1230 """Return a list of absolute-path URLs"""
1231 context = self.context
1232 paths = qtutils.paths_from_items(items, item_filter=_item_filter)
1233 include_urls = not self._alt_drag
1234 return qtutils.mimedata_from_paths(context, paths, include_urls=include_urls)
1236 # pylint: disable=no-self-use
1237 def mimeTypes(self):
1238 return qtutils.path_mimetypes(include_urls=not self._alt_drag)
1241 def _item_filter(item):
1242 return not item.deleted and core.exists(item.path)
1245 def view_blame(context):
1246 """Signal that we should view blame for paths."""
1247 cmds.do(cmds.BlamePaths, context)
1250 def view_history(context):
1251 """Signal that we should view history for paths."""
1252 cmds.do(cmds.VisualizePaths, context, context.selection.union())
1255 def copy_path(context, absolute=True):
1256 """Copy a selected path to the clipboard"""
1257 filename = context.selection.filename()
1258 qtutils.copy_path(filename, absolute=absolute)
1261 def copy_relpath(context):
1262 """Copy a selected relative path to the clipboard"""
1263 copy_path(context, absolute=False)
1266 def copy_basename(context):
1267 filename = os.path.basename(context.selection.filename())
1268 basename, _ = os.path.splitext(filename)
1269 qtutils.copy_path(basename, absolute=False)
1272 def copy_leading_path(context, strip_components):
1273 """Peal off trailing path components and copy the current path to the clipboard"""
1274 filename = context.selection.filename()
1275 value = filename
1276 for _ in range(strip_components):
1277 value = os.path.dirname(value)
1278 qtutils.copy_path(value, absolute=False)
1281 def copy_format(context, fmt):
1282 values = {}
1283 values['path'] = path = context.selection.filename()
1284 values['abspath'] = abspath = os.path.abspath(path)
1285 values['absdirname'] = os.path.dirname(abspath)
1286 values['dirname'] = os.path.dirname(path)
1287 values['filename'] = os.path.basename(path)
1288 values['basename'], values['ext'] = os.path.splitext(os.path.basename(path))
1289 qtutils.set_clipboard(fmt % values)
1292 def show_help(context):
1293 help_text = N_(
1294 r"""
1295 Format String Variables
1296 -----------------------
1297 %(path)s = relative file path
1298 %(abspath)s = absolute file path
1299 %(dirname)s = relative directory path
1300 %(absdirname)s = absolute directory path
1301 %(filename)s = file basename
1302 %(basename)s = file basename without extension
1303 %(ext)s = file extension
1306 title = N_('Help - Custom Copy Actions')
1307 return text.text_dialog(context, help_text, title)
1310 class StatusFilterWidget(QtWidgets.QWidget):
1311 def __init__(self, context, parent=None):
1312 QtWidgets.QWidget.__init__(self, parent)
1313 self.context = context
1315 hint = N_('Filter paths...')
1316 self.text = completion.GitStatusFilterLineEdit(context, hint=hint, parent=self)
1317 self.text.setToolTip(hint)
1318 self.setFocusProxy(self.text)
1319 self._filter = None
1321 self.main_layout = qtutils.hbox(defs.no_margin, defs.spacing, self.text)
1322 self.setLayout(self.main_layout)
1324 widget = self.text
1325 # pylint: disable=no-member
1326 widget.changed.connect(self.apply_filter)
1327 widget.cleared.connect(self.apply_filter)
1328 widget.enter.connect(self.apply_filter)
1329 widget.editingFinished.connect(self.apply_filter)
1331 def apply_filter(self):
1332 value = get(self.text)
1333 if value == self._filter:
1334 return
1335 self._filter = value
1336 paths = utils.shell_split(value)
1337 self.context.model.update_path_filter(paths)
1340 def customize_copy_actions(context, parent):
1341 """Customize copy actions"""
1342 dialog = CustomizeCopyActions(context, parent)
1343 dialog.show()
1344 dialog.exec_()
1347 class CustomizeCopyActions(standard.Dialog):
1348 def __init__(self, context, parent):
1349 standard.Dialog.__init__(self, parent=parent)
1350 self.setWindowTitle(N_('Custom Copy Actions'))
1352 self.context = context
1353 self.table = QtWidgets.QTableWidget(self)
1354 self.table.setColumnCount(2)
1355 self.table.setHorizontalHeaderLabels([N_('Action Name'), N_('Format String')])
1356 self.table.setSortingEnabled(False)
1357 self.table.verticalHeader().hide()
1358 self.table.horizontalHeader().setStretchLastSection(True)
1360 self.add_button = qtutils.create_button(N_('Add'))
1361 self.remove_button = qtutils.create_button(N_('Remove'))
1362 self.remove_button.setEnabled(False)
1363 self.show_help_button = qtutils.create_button(N_('Show Help'))
1364 self.show_help_button.setShortcut(hotkeys.QUESTION)
1366 self.close_button = qtutils.close_button()
1367 self.save_button = qtutils.ok_button(N_('Save'))
1369 self.buttons = qtutils.hbox(
1370 defs.no_margin,
1371 defs.button_spacing,
1372 self.add_button,
1373 self.remove_button,
1374 self.show_help_button,
1375 qtutils.STRETCH,
1376 self.close_button,
1377 self.save_button,
1380 layout = qtutils.vbox(defs.margin, defs.spacing, self.table, self.buttons)
1381 self.setLayout(layout)
1383 qtutils.connect_button(self.add_button, self.add)
1384 qtutils.connect_button(self.remove_button, self.remove)
1385 qtutils.connect_button(self.show_help_button, partial(show_help, context))
1386 qtutils.connect_button(self.close_button, self.reject)
1387 qtutils.connect_button(self.save_button, self.save)
1388 qtutils.add_close_action(self)
1389 # pylint: disable=no-member
1390 self.table.itemSelectionChanged.connect(self.table_selection_changed)
1392 self.init_size(parent=parent)
1394 QtCore.QTimer.singleShot(0, self.reload_settings)
1396 def reload_settings(self):
1397 # Called once after the GUI is initialized
1398 settings = self.context.settings
1399 settings.load()
1400 table = self.table
1401 for entry in settings.copy_formats:
1402 name_string = entry.get('name', '')
1403 format_string = entry.get('format', '')
1404 if name_string and format_string:
1405 name = QtWidgets.QTableWidgetItem(name_string)
1406 fmt = QtWidgets.QTableWidgetItem(format_string)
1407 rows = table.rowCount()
1408 table.setRowCount(rows + 1)
1409 table.setItem(rows, 0, name)
1410 table.setItem(rows, 1, fmt)
1412 def export_state(self):
1413 state = super(CustomizeCopyActions, self).export_state()
1414 standard.export_header_columns(self.table, state)
1415 return state
1417 def apply_state(self, state):
1418 result = super(CustomizeCopyActions, self).apply_state(state)
1419 standard.apply_header_columns(self.table, state)
1420 return result
1422 def add(self):
1423 self.table.setFocus()
1424 rows = self.table.rowCount()
1425 self.table.setRowCount(rows + 1)
1427 name = QtWidgets.QTableWidgetItem(N_('Name'))
1428 fmt = QtWidgets.QTableWidgetItem(r'%(path)s')
1429 self.table.setItem(rows, 0, name)
1430 self.table.setItem(rows, 1, fmt)
1432 self.table.setCurrentCell(rows, 0)
1433 self.table.editItem(name)
1435 def remove(self):
1436 """Remove selected items"""
1437 # Gather a unique set of rows and remove them in reverse order
1438 rows = set()
1439 items = self.table.selectedItems()
1440 for item in items:
1441 rows.add(self.table.row(item))
1443 for row in reversed(sorted(rows)):
1444 self.table.removeRow(row)
1446 def save(self):
1447 copy_formats = []
1448 for row in range(self.table.rowCount()):
1449 name = self.table.item(row, 0)
1450 fmt = self.table.item(row, 1)
1451 if name and fmt:
1452 entry = {
1453 'name': name.text(),
1454 'format': fmt.text(),
1456 copy_formats.append(entry)
1458 settings = self.context.settings
1459 while settings.copy_formats:
1460 settings.copy_formats.pop()
1462 settings.copy_formats.extend(copy_formats)
1463 settings.save()
1465 self.accept()
1467 def table_selection_changed(self):
1468 items = self.table.selectedItems()
1469 self.remove_button.setEnabled(bool(items))
1472 def _select_item(widget, path_list, widget_getter, item, current=False):
1473 """Select the widget item based on the list index"""
1474 # The path lists and widget indexes have a 1:1 correspondence.
1475 # Lookup the item filename in the list and use that index to
1476 # retrieve the widget item and select it.
1477 idx = path_list.index(item)
1478 item = widget_getter(idx)
1479 if current:
1480 widget.setCurrentItem(item)
1481 item.setSelected(True)
1484 def _apply_toplevel_selection(widget, category, idx):
1485 """Select a top-level "header" item (ex: the Staged parent item)
1487 Return True when a top-level item is selected.
1489 is_top_level_item = category == HEADER_IDX
1490 if is_top_level_item:
1491 root_item = widget.invisibleRootItem()
1492 item = root_item.child(idx)
1494 if item is not None and item.childCount() == 0:
1495 # The item now has no children. Select a different top-level item
1496 # corresponding to the previously selected item.
1497 if idx == STAGED_IDX:
1498 # If "Staged" was previously selected try "Modified" and "Untracked".
1499 item = _get_first_item_with_children(
1500 root_item.child(MODIFIED_IDX), root_item.child(UNTRACKED_IDX)
1502 elif idx == UNMERGED_IDX:
1503 # If "Unmerged" was previously selected try "Staged".
1504 item = _get_first_item_with_children(root_item.child(STAGED_IDX))
1505 elif idx == MODIFIED_IDX:
1506 # If "Modified" was previously selected try "Staged" or "Untracked".
1507 item = _get_first_item_with_children(
1508 root_item.child(STAGED_IDX), root_item.child(UNTRACKED_IDX)
1510 elif idx == UNTRACKED_IDX:
1511 # If "Untracked" was previously selected try "Staged".
1512 item = _get_first_item_with_children(root_item.child(STAGED_IDX))
1514 if item is not None:
1515 with qtutils.BlockSignals(widget):
1516 widget.setCurrentItem(item)
1517 item.setSelected(True)
1518 widget.show_selection()
1519 return is_top_level_item
1522 def _get_first_item_with_children(*items):
1523 """Return the first item that contains child items"""
1524 for item in items:
1525 if item.childCount() > 0:
1526 return item
1527 return None
1530 def _transplant_selection_across_sections(
1531 category, idx, previous_contents, saved_selection
1533 """Transplant the selection to a different category"""
1534 # This function is used when the selection would otherwise become empty.
1535 # Apply heuristics to select the items based on the previous state.
1536 if not previous_contents:
1537 return
1538 staged, unmerged, modified, untracked = saved_selection
1539 prev_staged, prev_unmerged, prev_modified, prev_untracked = previous_contents
1541 # The current set of paths.
1542 staged_paths = staged[NEW_PATHS_IDX]
1543 unmerged_paths = unmerged[NEW_PATHS_IDX]
1544 modified_paths = modified[NEW_PATHS_IDX]
1545 untracked_paths = untracked[NEW_PATHS_IDX]
1547 # These callbacks select a path in the corresponding widget subtree lists.
1548 select_staged = staged[SELECT_FN_IDX]
1549 select_unmerged = unmerged[SELECT_FN_IDX]
1550 select_modified = modified[SELECT_FN_IDX]
1551 select_untracked = untracked[SELECT_FN_IDX]
1553 if category == STAGED_IDX:
1554 # Staged files can become Unmerged, Modified or Untracked.
1555 # If we previously had a staged file selected then try to select
1556 # it in either the Unmerged, Modified or Untracked sections.
1557 try:
1558 old_path = prev_staged[idx]
1559 except IndexError:
1560 return
1561 if old_path in unmerged_paths:
1562 select_unmerged(old_path, current=True)
1563 elif old_path in modified_paths:
1564 select_modified(old_path, current=True)
1565 elif old_path in untracked_paths:
1566 select_untracked(old_path, current=True)
1568 elif category == UNMERGED_IDX:
1569 # Unmerged files can become Staged, Modified or Untracked.
1570 # If we previously had an unmerged file selected then try to select it in
1571 # the Staged, Modified or Untracked sections.
1572 try:
1573 old_path = prev_unmerged[idx]
1574 except IndexError:
1575 return
1576 if old_path in staged_paths:
1577 select_staged(old_path, current=True)
1578 elif old_path in modified_paths:
1579 select_modified(old_path, current=True)
1580 elif old_path in untracked_paths:
1581 select_untracked(old_path, current=True)
1583 elif category == MODIFIED_IDX:
1584 # If we previously had a modified file selected then try to select
1585 # it in either the Staged or Untracked sections.
1586 try:
1587 old_path = prev_modified[idx]
1588 except IndexError:
1589 return
1590 if old_path in staged_paths:
1591 select_staged(old_path, current=True)
1592 elif old_path in untracked_paths:
1593 select_untracked(old_path, current=True)
1595 elif category == UNTRACKED_IDX:
1596 # If we previously had an untracked file selected then try to select
1597 # it in the Modified or Staged section. Modified is less common, but
1598 # it's possible for a file to be untracked and then the user adds and
1599 # modifies the file before we've refreshed our state.
1600 try:
1601 old_path = prev_untracked[idx]
1602 except IndexError:
1603 return
1604 if old_path in modified_paths:
1605 select_modified(old_path, current=True)
1606 elif old_path in staged_paths:
1607 select_staged(old_path, current=True)
1610 class CopyLeadingPathWidget(QtWidgets.QWidget):
1611 """A widget that holds a label and a spinbox for the number of paths to strip"""
1613 def __init__(self, title, context, parent):
1614 QtWidgets.QWidget.__init__(self, parent)
1615 self.context = context
1616 self.icon = QtWidgets.QLabel(self)
1617 self.label = QtWidgets.QLabel(self)
1618 self.spinbox = standard.SpinBox(value=1, mini=1, maxi=99, parent=self)
1619 self.spinbox.setToolTip(N_('The number of leading paths to strip'))
1621 icon = icons.copy()
1622 pixmap = icon.pixmap(defs.default_icon, defs.default_icon)
1623 self.icon.setPixmap(pixmap)
1624 self.label.setText(title)
1626 layout = qtutils.hbox(
1627 defs.small_margin,
1628 defs.titlebar_spacing,
1629 self.icon,
1630 self.label,
1631 qtutils.STRETCH,
1632 self.spinbox,
1634 self.setLayout(layout)
1636 palette = context.app.palette()
1637 theme = context.app.theme
1638 if theme.highlight_color:
1639 highlight_rgb = context.app.theme.highlight_color
1640 else:
1641 color = palette.highlight().color()
1642 highlight_rgb = qtutils.rgb_css(color)
1644 if theme.text_color:
1645 text_rgb = theme.text_color
1646 highlight_text_rgb = theme.text_color
1647 else:
1648 color = palette.text().color()
1649 text_rgb = 'rgb(%s, %s, %s)' % (color.red(), color.green(), color.blue())
1651 color = palette.highlightedText().color()
1652 highlight_text_rgb = 'rgb(%s, %s, %s)' % (
1653 color.red(),
1654 color.green(),
1655 color.blue(),
1658 if theme.disabled_text_color:
1659 disabled_text_rgb = theme.disabled_text_color
1660 else:
1661 color = palette.color(QtGui.QPalette.Disabled, QtGui.QPalette.Text)
1662 disabled_text_rgb = qtutils.rgb_css(color)
1664 stylesheet = """
1666 show-decoration-selected: 1
1668 QLabel {
1669 color: %(text_rgb)s;
1670 show-decoration-selected: 1
1672 QLabel:hover {
1673 color: %(highlight_text_rgb)s;
1674 background-color: %(highlight_rgb)s;
1675 background-clip: padding;
1676 show-decoration-selected: 1
1678 QLabel:disabled {
1679 color: %(disabled_text_rgb)s;
1681 """ % dict(
1682 disabled_text_rgb=disabled_text_rgb,
1683 text_rgb=text_rgb,
1684 highlight_text_rgb=highlight_text_rgb,
1685 highlight_rgb=highlight_rgb,
1688 self.setStyleSheet(stylesheet)
1690 def value(self):
1691 """Return the current value of the spinbox"""
1692 return self.spinbox.value()
1694 def set_value(self, value):
1695 """Set the spinbox value"""
1696 self.spinbox.setValue(value)