1 from __future__
import absolute_import
, division
, print_function
, unicode_literals
4 from functools
import partial
6 from qtpy
.QtCore
import Qt
7 from qtpy
import QtCore
9 from qtpy
import QtWidgets
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
21 from .. import hotkeys
23 from .. import qtutils
26 from . import completion
31 # Top-level status widget item indexes.
39 # Indexes into the saved_selection entries.
46 class StatusWidget(QtWidgets
.QFrame
):
48 Provides a git-status-like repository widget.
50 This widget observes the main model and broadcasts
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
)
95 self
.filter_widget
.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))
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
)
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)
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(
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(
184 cmds
.StageOrUnstageAll
.name(),
185 cmds
.run(cmds
.StageOrUnstageAll
, self
.context
),
188 self
.stage_or_unstage_all_action
.setIcon(icons
.add())
190 self
.revert_unstaged_edits_action
= qtutils
.add_action(
192 cmds
.RevertUnstagedEdits
.name(),
193 cmds
.run(cmds
.RevertUnstagedEdits
, context
),
196 self
.revert_unstaged_edits_action
.setIcon(icons
.undo())
198 self
.launch_difftool_action
= qtutils
.add_action(
200 cmds
.LaunchDifftool
.name(),
201 cmds
.run(cmds
.LaunchDifftool
, context
),
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(
229 hotkeys
.MOVE_UP_SECONDARY
,
232 self
.down_action
= qtutils
.add_action(
237 hotkeys
.MOVE_DOWN_SECONDARY
,
240 self
.copy_path_action
= qtutils
.add_action(
242 N_('Copy Path to Clipboard'),
243 partial(copy_path
, context
),
246 self
.copy_path_action
.setIcon(icons
.copy())
248 self
.copy_relpath_action
= qtutils
.add_action(
250 N_('Copy Relative Path to Clipboard'),
251 partial(copy_relpath
, context
),
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(
291 N_('Move files to trash'),
292 self
._trash
_untracked
_files
,
295 self
.move_to_trash_action
.setIcon(icons
.discard())
296 delete_shortcut
= hotkeys
.DELETE_FILE
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()
328 qtutils
.scroll_to_item(self
, item
)
330 def _add_toplevel_item(self
, txt
, icon
, hide
=False):
331 context
= self
.context
333 if prefs
.bold_headers(context
):
338 item
= QtWidgets
.QTreeWidgetItem(self
)
339 item
.setFont(0, font
)
341 item
.setIcon(0, icon
)
342 if prefs
.bold_headers(context
):
343 item
.setBackground(0, self
.palette().midlight())
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
:
355 # The old set of categorized files.
356 old_c
= self
.old_contents
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
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
),
380 set(old_s
.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
):
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
]
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.
412 with qtutils
.BlockSignals(self
):
413 for (new
, old
, sel
, reselect
) in saved_selection
:
416 reselect(item
, current
=False)
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.
439 # The item still exists so ignore it
440 if item
in new
or item
not in old
:
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
])):
447 reselect(j
, current
=True)
450 # If we already reselected stuff then there's nothing more to do.
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
)
474 idx
== STAGED_IDX
and category
== HEADER_IDX
475 for (category
, idx
) in selected_indexes
478 idx
== MODIFIED_IDX
and category
== HEADER_IDX
479 for (category
, idx
) in selected_indexes
482 idx
== UNTRACKED_IDX
and category
== HEADER_IDX
483 for (category
, idx
) in selected_indexes
485 # A header item: 'Staged', 'Modified' or 'Untracked'.
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
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.
502 cmds
.do(cmds
.StageModified
, context
)
504 cmds
.do(cmds
.StageUntracked
, context
)
506 # Do nothing for unmerged items, by design
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
):
525 item
= self
.topLevelItem(MODIFIED_IDX
)
526 count
= item
.childCount()
528 return item
.child(itemidx
)
530 item
= self
.topLevelItem(UNMERGED_IDX
)
531 count
+= item
.childCount()
533 return item
.child(itemidx
)
535 item
= self
.topLevelItem(UNTRACKED_IDX
)
536 count
+= item
.childCount()
538 return item
.child(itemidx
)
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()
566 current
= self
.currentItem()
569 idx
= self
.indexFromItem(current
)
570 if idx
.parent().isValid():
571 parent_idx
= idx
.parent()
572 entry
= (parent_idx
.row(), idx
.row())
574 entry
= (HEADER_IDX
, idx
.row())
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()
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):
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
):
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
):
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
):
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)
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:
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.
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
:
677 self
.expanded_items
.add(idx
)
678 item
= self
.topLevelItem(idx
)
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()
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
)
699 self
._create
_staged
_context
_menu
(menu
, sel
)
701 self
._create
_unmerged
_context
_menu
(menu
, sel
)
703 self
._create
_unstaged
_context
_menu
(menu
, sel
)
705 if not menu
.isEmpty():
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
)
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()),
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
763 copy_menu
.addSeparator()
765 context
= self
.context
766 for entry
in copy_formats
:
767 name
= entry
.get('name', '')
768 fmt
= entry
.get('format', '')
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
:
781 icons
.remove(), N_('Unstage All'), cmds
.run(cmds
.UnstageAll
, context
)
783 elif idx
== UNMERGED_IDX
:
784 action
= menu
.addAction(
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(
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(
800 cmds
.StageUntracked
.name(),
801 cmds
.run(cmds
.StageUntracked
, context
),
803 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
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(
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?
823 i
not in self
._model
.staged_deleted
and core
.exists(i
)
824 for i
in self
.staged()
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
)
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:
843 N_('Launch git-cola'),
844 cmds
.run(cmds
.OpenRepo
, context
, path
),
847 action
= menu
.addAction(
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
)
857 def _create_unmerged_context_menu(self
, menu
, _s
):
858 context
= self
.context
859 menu
.addAction(self
.launch_difftool_action
)
861 action
= menu
.addAction(
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
)
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(
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?
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():
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')
910 menu
.addAction(self
.annex_add_action
)
912 menu
.addAction(self
.lfs_track_action
)
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
)
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
)
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:
936 N_('Launch git-cola'),
937 cmds
.run(cmds
.OpenRepo
, context
, path
),
941 N_('Update this submodule'),
942 cmds
.run(cmds
.SubmoduleUpdate
, context
, path
),
946 if self
._model
.is_stageable():
948 action
= menu
.addAction(
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
)
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."""
979 unmerged
= s
.unmerged
[0]
981 modified
= s
.modified
[0]
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()
992 if idx
.parent().isValid():
993 parent_idx
= idx
.parent()
994 entry
= (parent_idx
.row(), idx
.row())
996 entry
= (HEADER_IDX
, idx
.row())
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()
1007 """Return all of the current files in a selection.State container"""
1008 return selection
.State(
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"""
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
):
1026 s
= self
.single_selection()
1028 for content
, sel
in zip(c
, s
):
1032 return offset
+ content
.index(sel
)
1033 offset
+= len(content
)
1036 def select_by_index(self
, idx
):
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
:
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
)
1056 return qtutils
.get_selected_values(self
, STAGED_IDX
, self
._model
.staged
)
1059 return self
.unmerged() + self
.modified() + self
.untracked()
1062 return qtutils
.get_selected_values(self
, MODIFIED_IDX
, self
._model
.modified
)
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
, '')
1100 cmds
.do(cmds
.ResetMode
, context
)
1103 # A header item e.g. 'Staged', 'Modified', etc.
1104 category
, idx
= selected_indexes
[0]
1105 header
= category
== HEADER_IDX
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
)
1117 staged
= category
== STAGED_IDX
1118 modified
= category
== MODIFIED_IDX
1119 unmerged
= category
== UNMERGED_IDX
1120 untracked
= category
== UNTRACKED_IDX
1123 item
= self
.staged_items()[0]
1125 item
= self
.unmerged_items()[0]
1127 item
= self
.modified_items()[0]
1129 item
= self
.unstaged_items()[0]
1131 item
= None # this shouldn't happen
1132 assert item
is not None
1135 deleted
= item
.deleted
1136 image
= self
.image_formats
.ok(path
)
1138 # Update the diff text
1140 cmds
.do(cmds
.DiffStaged
, context
, path
, deleted
=deleted
)
1142 cmds
.do(cmds
.Diff
, context
, path
, deleted
=deleted
)
1144 cmds
.do(cmds
.Diff
, context
, path
)
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.
1163 cmds
.do(cmds
.DiffText
, context
)
1165 def select_header(self
):
1166 """Select an active header, which triggers a diffstat"""
1173 item
= self
.topLevelItem(idx
)
1174 if item
.childCount() > 0:
1175 self
.clearSelection()
1176 self
.setCurrentItem(item
)
1180 idx
= self
.selected_idx()
1181 all_files
= self
.all_files()
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
)
1192 self
.select_by_index(len(all_files
) - 1)
1195 self
.select_by_index(idx
- 1)
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()
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
)
1212 self
.select_by_index(0)
1214 if idx
+ 1 < len(all_files
):
1215 self
.select_by_index(idx
+ 1)
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()
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
):
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
):
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
)
1321 self
.main_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, self
.text
)
1322 self
.setLayout(self
.main_layout
)
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
:
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
)
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(
1371 defs
.button_spacing
,
1374 self
.show_help_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
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
)
1417 def apply_state(self
, state
):
1418 result
= super(CustomizeCopyActions
, self
).apply_state(state
)
1419 standard
.apply_header_columns(self
.table
, state
)
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
)
1436 """Remove selected items"""
1437 # Gather a unique set of rows and remove them in reverse order
1439 items
= self
.table
.selectedItems()
1441 rows
.add(self
.table
.row(item
))
1443 for row
in reversed(sorted(rows
)):
1444 self
.table
.removeRow(row
)
1448 for row
in range(self
.table
.rowCount()):
1449 name
= self
.table
.item(row
, 0)
1450 fmt
= self
.table
.item(row
, 1)
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
)
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
)
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"""
1525 if item
.childCount() > 0:
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
:
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.
1558 old_path
= prev_staged
[idx
]
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.
1573 old_path
= prev_unmerged
[idx
]
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.
1587 old_path
= prev_modified
[idx
]
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.
1601 old_path
= prev_untracked
[idx
]
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'))
1622 pixmap
= icon
.pixmap(defs
.default_icon
, defs
.default_icon
)
1623 self
.icon
.setPixmap(pixmap
)
1624 self
.label
.setText(title
)
1626 layout
= qtutils
.hbox(
1628 defs
.titlebar_spacing
,
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
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
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)' % (
1658 if theme
.disabled_text_color
:
1659 disabled_text_rgb
= theme
.disabled_text_color
1661 color
= palette
.color(QtGui
.QPalette
.Disabled
, QtGui
.QPalette
.Text
)
1662 disabled_text_rgb
= qtutils
.rgb_css(color
)
1666 show-decoration-selected: 1
1669 color: %(text_rgb)s;
1670 show-decoration-selected: 1
1673 color: %(highlight_text_rgb)s;
1674 background-color: %(highlight_rgb)s;
1675 background-clip: padding;
1676 show-decoration-selected: 1
1679 color: %(disabled_text_rgb)s;
1682 disabled_text_rgb
=disabled_text_rgb
,
1684 highlight_text_rgb
=highlight_text_rgb
,
1685 highlight_rgb
=highlight_rgb
,
1688 self
.setStyleSheet(stylesheet
)
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
)