3 from functools
import partial
5 from qtpy
.QtCore
import Qt
6 from qtpy
import QtCore
7 from qtpy
import QtWidgets
10 from ..models
import prefs
11 from ..models
import selection
12 from ..widgets
import gitignore
13 from ..widgets
import standard
14 from ..qtutils
import get
15 from ..settings
import Settings
16 from .. import actions
19 from .. import difftool
20 from .. import hotkeys
22 from .. import qtutils
25 from . import completion
30 # Top-level status widget item indexes.
38 # Indexes into the saved_selection entries.
45 class StatusWidget(QtWidgets
.QFrame
):
47 Provides a git-status-like repository widget.
49 This widget observes the main model and broadcasts
54 def __init__(self
, context
, titlebar
, parent
):
55 QtWidgets
.QFrame
.__init
__(self
, parent
)
56 self
.context
= context
58 tooltip
= N_('Toggle the paths filter')
59 icon
= icons
.ellipsis()
60 self
.filter_button
= qtutils
.create_action_button(tooltip
=tooltip
, icon
=icon
)
61 self
.filter_widget
= StatusFilterWidget(context
)
62 self
.filter_widget
.hide()
63 self
.tree
= StatusTreeWidget(context
, parent
=self
)
64 self
.setFocusProxy(self
.tree
)
66 tooltip
= N_('Exit "Diff" mode')
67 icon
= icons
.circle_slash_red()
68 self
.exit_diff_mode_button
= qtutils
.create_action_button(
69 tooltip
=tooltip
, icon
=icon
, visible
=False
72 self
.main_layout
= qtutils
.vbox(
73 defs
.no_margin
, defs
.no_spacing
, self
.filter_widget
, self
.tree
75 self
.setLayout(self
.main_layout
)
77 self
.toggle_action
= qtutils
.add_action(
78 self
, tooltip
, self
.toggle_filter
, hotkeys
.FILTER
81 titlebar
.add_corner_widget(self
.exit_diff_mode_button
)
82 titlebar
.add_corner_widget(self
.filter_button
)
84 qtutils
.connect_button(self
.filter_button
, self
.toggle_filter
)
85 qtutils
.connect_button(
86 self
.exit_diff_mode_button
, cmds
.run(cmds
.ResetMode
, self
.context
)
89 def toggle_filter(self
):
90 """Toggle the paths filter"""
91 shown
= not self
.filter_widget
.isVisible()
92 self
.filter_widget
.setVisible(shown
)
94 self
.filter_widget
.setFocus()
98 def set_initial_size(self
):
99 """Set the initial size of the status widget"""
100 self
.setMaximumWidth(222)
101 QtCore
.QTimer
.singleShot(1, lambda: self
.setMaximumWidth(2**13))
104 """Refresh the tree and rerun the diff to see updates"""
105 self
.tree
.show_selection()
107 def set_filter(self
, txt
):
108 """Set the filter text"""
109 self
.filter_widget
.setVisible(True)
110 self
.filter_widget
.text
.set_value(txt
)
111 self
.filter_widget
.apply_filter()
113 def set_mode(self
, mode
):
114 """React to changes in model's editing mode"""
115 exit_diff_mode_visible
= mode
== self
.context
.model
.mode_diff
116 self
.exit_diff_mode_button
.setVisible(exit_diff_mode_visible
)
122 self
.tree
.move_down()
124 def select_header(self
):
125 self
.tree
.select_header()
128 class StatusTreeWidget(QtWidgets
.QTreeWidget
):
129 # Read-only access to the mode state
130 mode
= property(lambda self
: self
._model
.mode
)
132 def __init__(self
, context
, parent
=None):
133 QtWidgets
.QTreeWidget
.__init
__(self
, parent
)
134 self
.context
= context
135 self
.selection_model
= context
.selection
137 self
.setSelectionMode(QtWidgets
.QAbstractItemView
.ExtendedSelection
)
138 self
.headerItem().setHidden(True)
139 self
.setAllColumnsShowFocus(True)
140 self
.setSortingEnabled(False)
141 self
.setUniformRowHeights(True)
142 self
.setAnimated(True)
143 self
.setRootIsDecorated(False)
144 self
.setAutoScroll(False)
145 self
.setDragEnabled(True)
146 self
.setDragDropMode(QtWidgets
.QAbstractItemView
.DragOnly
)
147 self
._alt
_drag
= False
149 if not prefs
.status_indent(context
):
150 self
.setIndentation(0)
153 compare
= icons
.compare()
154 question
= icons
.question()
155 self
._add
_toplevel
_item
(N_('Staged'), ok_icon
, hide
=True)
156 self
._add
_toplevel
_item
(N_('Unmerged'), compare
, hide
=True)
157 self
._add
_toplevel
_item
(N_('Modified'), compare
, hide
=True)
158 self
._add
_toplevel
_item
(N_('Untracked'), question
, hide
=True)
160 # Used to restore the selection
161 self
.old_vscroll
= None
162 self
.old_hscroll
= None
163 self
.old_selection
= None
164 self
.old_contents
= None
165 self
.old_current_item
= None
166 self
.previous_contents
= None
167 self
.was_visible
= True
168 self
.expanded_items
= set()
170 self
.image_formats
= qtutils
.ImageFormats()
172 self
.process_selection_action
= qtutils
.add_action(
174 cmds
.StageOrUnstage
.name(),
175 self
._stage
_selection
,
176 hotkeys
.STAGE_SELECTION
,
178 self
.process_selection_action
.setIcon(icons
.add())
180 self
.stage_or_unstage_all_action
= qtutils
.add_action(
182 cmds
.StageOrUnstageAll
.name(),
183 cmds
.run(cmds
.StageOrUnstageAll
, self
.context
),
186 self
.stage_or_unstage_all_action
.setIcon(icons
.add())
188 self
.revert_unstaged_edits_action
= qtutils
.add_action(
190 cmds
.RevertUnstagedEdits
.name(),
191 cmds
.run(cmds
.RevertUnstagedEdits
, context
),
195 self
.revert_unstaged_edits_action
.setIcon(icons
.undo())
197 self
.launch_difftool_action
= qtutils
.add_action(
199 difftool
.LaunchDifftool
.name(),
200 cmds
.run(difftool
.LaunchDifftool
, context
),
203 self
.launch_difftool_action
.setIcon(icons
.diff())
205 self
.launch_editor_action
= actions
.launch_editor_at_line(
206 context
, self
, *hotkeys
.ACCEPT
209 self
.default_app_action
= common
.default_app_action(
210 context
, self
, self
.selected_group
213 self
.parent_dir_action
= common
.parent_dir_action(
214 context
, self
, self
.selected_group
217 self
.worktree_dir_action
= common
.worktree_dir_action(context
, self
)
219 self
.terminal_action
= common
.terminal_action(
220 context
, self
, func
=self
.selected_group
223 self
.up_action
= qtutils
.add_action(
228 hotkeys
.MOVE_UP_SECONDARY
,
231 self
.down_action
= qtutils
.add_action(
236 hotkeys
.MOVE_DOWN_SECONDARY
,
239 # Checkout the selected paths using "git checkout --ours".
240 self
.checkout_ours_action
= qtutils
.add_action(
241 self
, cmds
.CheckoutOurs
.name(), cmds
.run(cmds
.CheckoutOurs
, context
)
244 # Checkout the selected paths using "git checkout --theirs".
245 self
.checkout_theirs_action
= qtutils
.add_action(
246 self
, cmds
.CheckoutTheirs
.name(), cmds
.run(cmds
.CheckoutTheirs
, context
)
249 self
.copy_path_action
= qtutils
.add_action(
251 N_('Copy Path to Clipboard'),
252 partial(copy_path
, context
),
255 self
.copy_path_action
.setIcon(icons
.copy())
257 self
.copy_relpath_action
= qtutils
.add_action(
259 N_('Copy Relative Path to Clipboard'),
260 partial(copy_relpath
, context
),
263 self
.copy_relpath_action
.setIcon(icons
.copy())
265 self
.copy_leading_paths_value
= 1
267 self
.copy_basename_action
= qtutils
.add_action(
268 self
, N_('Copy Basename to Clipboard'), partial(copy_basename
, context
)
270 self
.copy_basename_action
.setIcon(icons
.copy())
272 self
.copy_customize_action
= qtutils
.add_action(
273 self
, N_('Customize...'), partial(customize_copy_actions
, context
, self
)
275 self
.copy_customize_action
.setIcon(icons
.configure())
277 self
.view_history_action
= qtutils
.add_action(
278 self
, N_('View History...'), partial(view_history
, context
), hotkeys
.HISTORY
281 self
.view_blame_action
= qtutils
.add_action(
282 self
, N_('Blame...'), partial(view_blame
, context
), hotkeys
.BLAME
285 self
.annex_add_action
= qtutils
.add_action(
286 self
, N_('Add to Git Annex'), cmds
.run(cmds
.AnnexAdd
, context
)
289 self
.lfs_track_action
= qtutils
.add_action(
290 self
, N_('Add to Git LFS'), cmds
.run(cmds
.LFSTrack
, context
)
293 # MoveToTrash and Delete use the same shortcut.
294 # We will only bind one of them, depending on whether or not the
295 # MoveToTrash command is available. When available, the hotkey
296 # is bound to MoveToTrash, otherwise it is bound to Delete.
297 if cmds
.MoveToTrash
.AVAILABLE
:
298 self
.move_to_trash_action
= qtutils
.add_action(
300 N_('Move files to trash'),
301 self
._trash
_untracked
_files
,
304 self
.move_to_trash_action
.setIcon(icons
.discard())
305 delete_shortcut
= hotkeys
.DELETE_FILE
307 self
.move_to_trash_action
= None
308 delete_shortcut
= hotkeys
.DELETE_FILE_SECONDARY
310 self
.delete_untracked_files_action
= qtutils
.add_action(
311 self
, N_('Delete Files...'), self
._delete
_untracked
_files
, delete_shortcut
313 self
.delete_untracked_files_action
.setIcon(icons
.discard())
315 # The model is stored as self._model because self.model() is a
316 # QTreeWidgetItem method that returns a QAbstractItemModel.
317 self
._model
= context
.model
318 self
._model
.previous_contents
.connect(
319 self
._set
_previous
_contents
, type=Qt
.QueuedConnection
321 self
._model
.about_to_update
.connect(
322 self
._about
_to
_update
, type=Qt
.QueuedConnection
324 self
._model
.updated
.connect(self
.refresh
, type=Qt
.QueuedConnection
)
325 self
._model
.diff_text_changed
.connect(
326 self
._make
_current
_item
_visible
, type=Qt
.QueuedConnection
328 self
.itemSelectionChanged
.connect(self
.show_selection
)
329 self
.itemDoubleClicked
.connect(cmds
.run(cmds
.StageOrUnstage
, self
.context
))
330 self
.itemCollapsed
.connect(lambda x
: self
._update
_column
_widths
())
331 self
.itemExpanded
.connect(lambda x
: self
._update
_column
_widths
())
333 def _make_current_item_visible(self
):
334 item
= self
.currentItem()
336 qtutils
.scroll_to_item(self
, item
)
338 def _add_toplevel_item(self
, txt
, icon
, hide
=False):
339 context
= self
.context
341 if prefs
.bold_headers(context
):
346 item
= QtWidgets
.QTreeWidgetItem(self
)
347 item
.setFont(0, font
)
349 item
.setIcon(0, icon
)
350 if prefs
.bold_headers(context
):
351 item
.setBackground(0, self
.palette().midlight())
355 def _restore_selection(self
):
356 """Apply the old selection to the newly updated items"""
357 # This function is called after a new set of items have been added to
358 # the per-category file lists. Its purpose is to either restore the
359 # existing selection or to create a new intuitive selection based on
360 # a combination of the old items, the old selection and the new items.
361 if not self
.old_selection
or not self
.old_contents
:
363 # The old set of categorized files.
364 old_c
= self
.old_contents
366 old_s
= self
.old_selection
367 # The current/new set of categorized files.
368 new_c
= self
.contents()
370 select_staged
= partial(_select_item
, self
, new_c
.staged
, self
._staged
_item
)
371 select_unmerged
= partial(
372 _select_item
, self
, new_c
.unmerged
, self
._unmerged
_item
374 select_modified
= partial(
375 _select_item
, self
, new_c
.modified
, self
._modified
_item
377 select_untracked
= partial(
378 _select_item
, self
, new_c
.untracked
, self
._untracked
_item
382 (set(new_c
.staged
), old_c
.staged
, set(old_s
.staged
), select_staged
),
383 (set(new_c
.unmerged
), old_c
.unmerged
, set(old_s
.unmerged
), select_unmerged
),
384 (set(new_c
.modified
), old_c
.modified
, set(old_s
.modified
), select_modified
),
386 set(new_c
.untracked
),
388 set(old_s
.untracked
),
393 # Restore the current item
394 if self
.old_current_item
:
395 category
, idx
= self
.old_current_item
396 if _apply_toplevel_selection(self
, category
, idx
):
398 # Reselect the current item
399 selection_info
= saved_selection
[category
]
400 new
= selection_info
[NEW_PATHS_IDX
]
401 old
= selection_info
[OLD_PATHS_IDX
]
402 reselect
= selection_info
[SELECT_FN_IDX
]
407 if item
and item
in new
:
408 reselect(item
, current
=True)
410 # Restore previously selected items.
411 # When reselecting in this section we only care that the items are
412 # selected; we do not need to rerun the callbacks which were triggered
413 # above for the current item. Block signals to skip the callbacks.
415 # Reselect items that were previously selected and still exist in the
416 # current path lists. This handles a common case such as a Ctrl-R
417 # refresh which results in the same exact path state.
420 with qtutils
.BlockSignals(self
):
421 for new
, old
, sel
, reselect
in saved_selection
:
424 reselect(item
, current
=False)
427 # The status widget is used to interactively work your way down the
428 # list of Staged, Unmerged, Modified and Untracked items and perform
429 # an operation on them.
431 # For Staged items we intend to work our way down the list of Staged
432 # items while we unstage each item. For every other category we work
433 # our way down the list of {Unmerged,Modified,Untracked} items while
434 # we stage each item.
436 # The following block of code implements the behavior of selecting
437 # the next item based on the previous selection.
438 for new
, old
, sel
, reselect
in saved_selection
:
439 # When modified is staged, select the next modified item
440 # When unmerged is staged, select the next unmerged item
441 # When unstaging, select the next staged item
442 # When staging untracked files, select the next untracked item
443 if len(new
) >= len(old
):
444 # The list did not shrink so it is not one of these cases.
447 # The item still exists so ignore it
448 if item
in new
or item
not in old
:
450 # The item no longer exists in this list so search for
451 # its nearest neighbors and select them instead.
452 idx
= old
.index(item
)
453 for j
in itertools
.chain(old
[idx
+ 1 :], reversed(old
[:idx
])):
455 reselect(j
, current
=True)
458 # If we already reselected stuff then there's nothing more to do.
461 # If we got this far then nothing was reselected and made current.
462 # Try a few more heuristics that we can use to keep something selected.
463 if self
.old_current_item
:
464 category
, idx
= self
.old_current_item
465 _transplant_selection_across_sections(
466 category
, idx
, self
.previous_contents
, saved_selection
469 def _restore_scrollbars(self
):
470 """Restore scrollbars to the stored values"""
471 qtutils
.set_scrollbar_values(self
, self
.old_hscroll
, self
.old_vscroll
)
472 self
.old_hscroll
= None
473 self
.old_vscroll
= None
475 def _stage_selection(self
):
476 """Stage or unstage files according to the selection"""
477 context
= self
.context
478 selected_indexes
= self
.selected_indexes()
479 is_header
= any(category
== HEADER_IDX
for (category
, idx
) in selected_indexes
)
482 idx
== STAGED_IDX
and category
== HEADER_IDX
483 for (category
, idx
) in selected_indexes
486 idx
== MODIFIED_IDX
and category
== HEADER_IDX
487 for (category
, idx
) in selected_indexes
490 idx
== UNTRACKED_IDX
and category
== HEADER_IDX
491 for (category
, idx
) in selected_indexes
493 # A header item: 'Staged', 'Modified' or 'Untracked'.
495 # If we have the staged header selected then the only sensible
496 # thing to do is to unstage everything and nothing else, even
497 # if the modified or untracked headers are selected.
498 cmds
.do(cmds
.UnstageAll
, context
)
499 return # Everything was unstaged. There's nothing more to be done.
500 if is_modified
and is_untracked
:
501 # If both modified and untracked headers are selected then
503 cmds
.do(cmds
.StageModifiedAndUntracked
, context
)
504 return # Nothing more to do.
505 # At this point we may stage all modified and untracked, and then
506 # possibly a subset of the other category (e.g. all modified and
507 # some untracked). We don't return here so that StageOrUnstage
508 # gets a chance to run below.
510 cmds
.do(cmds
.StageModified
, context
)
512 cmds
.do(cmds
.StageUntracked
, context
)
514 # Do nothing for unmerged items, by design
516 # Now handle individual files
517 cmds
.do(cmds
.StageOrUnstage
, context
)
519 def _staged_item(self
, itemidx
):
520 return self
._subtree
_item
(STAGED_IDX
, itemidx
)
522 def _modified_item(self
, itemidx
):
523 return self
._subtree
_item
(MODIFIED_IDX
, itemidx
)
525 def _unmerged_item(self
, itemidx
):
526 return self
._subtree
_item
(UNMERGED_IDX
, itemidx
)
528 def _untracked_item(self
, itemidx
):
529 return self
._subtree
_item
(UNTRACKED_IDX
, itemidx
)
531 def _unstaged_item(self
, itemidx
):
533 item
= self
.topLevelItem(MODIFIED_IDX
)
534 count
= item
.childCount()
536 return item
.child(itemidx
)
538 item
= self
.topLevelItem(UNMERGED_IDX
)
539 count
+= item
.childCount()
541 return item
.child(itemidx
)
543 item
= self
.topLevelItem(UNTRACKED_IDX
)
544 count
+= item
.childCount()
546 return item
.child(itemidx
)
550 def _subtree_item(self
, idx
, itemidx
):
551 parent
= self
.topLevelItem(idx
)
552 return parent
.child(itemidx
)
554 def _set_previous_contents(self
, staged
, unmerged
, modified
, untracked
):
555 """Callback triggered right before the model changes its contents"""
556 self
.previous_contents
= selection
.State(staged
, unmerged
, modified
, untracked
)
558 def _about_to_update(self
):
559 self
._save
_scrollbars
()
560 self
._save
_selection
()
562 def _save_scrollbars(self
):
563 """Store the scrollbar values for later application"""
564 hscroll
, vscroll
= qtutils
.get_scrollbar_values(self
)
565 if hscroll
is not None:
566 self
.old_hscroll
= hscroll
567 if vscroll
is not None:
568 self
.old_vscroll
= vscroll
570 def current_item(self
):
571 s
= self
.selected_indexes()
574 current
= self
.currentItem()
577 idx
= self
.indexFromItem(current
)
578 if idx
.parent().isValid():
579 parent_idx
= idx
.parent()
580 entry
= (parent_idx
.row(), idx
.row())
582 entry
= (HEADER_IDX
, idx
.row())
585 def _save_selection(self
):
586 self
.old_contents
= self
.contents()
587 self
.old_selection
= self
.selection()
588 self
.old_current_item
= self
.current_item()
591 self
._set
_staged
(self
._model
.staged
)
592 self
._set
_modified
(self
._model
.modified
)
593 self
._set
_unmerged
(self
._model
.unmerged
)
594 self
._set
_untracked
(self
._model
.untracked
)
595 self
._update
_column
_widths
()
596 self
._update
_actions
()
597 self
._restore
_selection
()
598 self
._restore
_scrollbars
()
600 def _update_actions(self
, selected
=None):
602 selected
= self
.selection_model
.selection()
603 can_revert_edits
= bool(selected
.staged
or selected
.modified
)
604 self
.revert_unstaged_edits_action
.setEnabled(can_revert_edits
)
606 enabled
= self
.selection_model
.filename() is not None
607 self
.default_app_action
.setEnabled(enabled
)
608 self
.parent_dir_action
.setEnabled(enabled
)
609 self
.copy_path_action
.setEnabled(enabled
)
610 self
.copy_relpath_action
.setEnabled(enabled
)
611 self
.copy_basename_action
.setEnabled(enabled
)
613 def _set_staged(self
, items
):
614 """Adds items to the 'Staged' sub-tree."""
615 with qtutils
.BlockSignals(self
):
621 deleted_set
=self
._model
.staged_deleted
,
624 def _set_modified(self
, items
):
625 """Adds items to the 'Modified' sub-tree."""
626 with qtutils
.BlockSignals(self
):
631 deleted_set
=self
._model
.unstaged_deleted
,
634 def _set_unmerged(self
, items
):
635 """Adds items to the 'Unmerged' sub-tree."""
636 deleted_set
= {path
for path
in items
if not core
.exists(path
)}
637 with qtutils
.BlockSignals(self
):
639 items
, UNMERGED_IDX
, N_('Unmerged'), deleted_set
=deleted_set
642 def _set_untracked(self
, items
):
643 """Adds items to the 'Untracked' sub-tree."""
644 with qtutils
.BlockSignals(self
):
645 self
._set
_subtree
(items
, UNTRACKED_IDX
, N_('Untracked'), untracked
=True)
648 self
, items
, idx
, parent_title
, staged
=False, untracked
=False, deleted_set
=None
650 """Add a list of items to a treewidget item."""
651 parent
= self
.topLevelItem(idx
)
652 hide
= not bool(items
)
653 parent
.setHidden(hide
)
655 # sip v4.14.7 and below leak memory in parent.takeChildren()
656 # so we use this backwards-compatible construct instead
657 while parent
.takeChild(0) is not None:
661 deleted
= deleted_set
is not None and item
in deleted_set
662 treeitem
= qtutils
.create_treeitem(
663 item
, staged
=staged
, deleted
=deleted
, untracked
=untracked
665 parent
.addChild(treeitem
)
666 self
._expand
_items
(idx
, items
)
668 if prefs
.status_show_totals(self
.context
):
669 parent
.setText(0, f
'{parent_title} ({len(items)})')
671 def _update_column_widths(self
):
672 self
.resizeColumnToContents(0)
674 def _expand_items(self
, idx
, items
):
675 """Expand the top-level category "folder" once and only once."""
676 # Don't do this if items is empty; this makes it so that we
677 # don't add the top-level index into the expanded_items set
678 # until an item appears in a particular category.
681 # Only run this once; we don't want to re-expand items that
682 # we've clicked on to re-collapse on updated().
683 if idx
in self
.expanded_items
:
685 self
.expanded_items
.add(idx
)
686 item
= self
.topLevelItem(idx
)
688 self
.expandItem(item
)
690 def contextMenuEvent(self
, event
):
691 """Create context menus for the repo status tree."""
692 menu
= self
._create
_context
_menu
()
693 menu
.exec_(self
.mapToGlobal(event
.pos()))
695 def _create_context_menu(self
):
696 """Set up the status menu for the repo status tree."""
697 sel
= self
.selection()
698 menu
= qtutils
.create_menu('Status', self
)
699 selected_indexes
= self
.selected_indexes()
701 category
, idx
= selected_indexes
[0]
702 # A header item e.g. 'Staged', 'Modified', etc.
703 if category
== HEADER_IDX
:
704 return self
._create
_header
_context
_menu
(menu
, idx
)
707 self
._create
_staged
_context
_menu
(menu
, sel
)
709 self
._create
_unmerged
_context
_menu
(menu
, sel
)
711 self
._create
_unstaged
_context
_menu
(menu
, sel
)
713 if not menu
.isEmpty():
716 if not self
.selection_model
.is_empty():
717 menu
.addAction(self
.default_app_action
)
718 menu
.addAction(self
.parent_dir_action
)
720 if self
.terminal_action
is not None:
721 menu
.addAction(self
.terminal_action
)
723 menu
.addAction(self
.worktree_dir_action
)
725 self
._add
_copy
_actions
(menu
)
729 def _add_copy_actions(self
, menu
):
730 """Add the "Copy" sub-menu"""
731 enabled
= self
.selection_model
.filename() is not None
732 self
.copy_path_action
.setEnabled(enabled
)
733 self
.copy_relpath_action
.setEnabled(enabled
)
734 self
.copy_basename_action
.setEnabled(enabled
)
736 copy_menu
= QtWidgets
.QMenu(N_('Copy...'), menu
)
737 copy_icon
= icons
.copy()
738 copy_menu
.setIcon(copy_icon
)
740 copy_leading_path_action
= QtWidgets
.QWidgetAction(copy_menu
)
741 copy_leading_path_action
.setEnabled(enabled
)
743 widget
= CopyLeadingPathWidget(
744 N_('Copy Leading Path to Clipboard'), self
.context
, copy_menu
746 # Store the value of the leading paths spin-box so that the value does not reset
747 # every time the menu is shown and recreated.
748 widget
.set_value(self
.copy_leading_paths_value
)
749 widget
.spinbox
.valueChanged
.connect(
750 partial(setattr, self
, 'copy_leading_paths_value')
752 copy_leading_path_action
.setDefaultWidget(widget
)
754 # Copy the leading path when the action is activated.
755 qtutils
.connect_action(
756 copy_leading_path_action
,
757 lambda widget
=widget
: copy_leading_path(context
, widget
.value()),
761 menu
.addMenu(copy_menu
)
762 copy_menu
.addAction(self
.copy_path_action
)
763 copy_menu
.addAction(self
.copy_relpath_action
)
764 copy_menu
.addAction(copy_leading_path_action
)
765 copy_menu
.addAction(self
.copy_basename_action
)
767 settings
= Settings
.read()
768 copy_formats
= settings
.copy_formats
770 copy_menu
.addSeparator()
772 context
= self
.context
773 for entry
in copy_formats
:
774 name
= entry
.get('name', '')
775 fmt
= entry
.get('format', '')
777 action
= copy_menu
.addAction(name
, partial(copy_format
, context
, fmt
))
778 action
.setIcon(copy_icon
)
779 action
.setEnabled(enabled
)
781 copy_menu
.addSeparator()
782 copy_menu
.addAction(self
.copy_customize_action
)
784 def _create_header_context_menu(self
, menu
, idx
):
785 context
= self
.context
786 if idx
== STAGED_IDX
:
788 icons
.remove(), N_('Unstage All'), cmds
.run(cmds
.UnstageAll
, context
)
790 elif idx
== UNMERGED_IDX
:
791 action
= menu
.addAction(
793 cmds
.StageUnmerged
.name(),
794 cmds
.run(cmds
.StageUnmerged
, context
),
796 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
797 elif idx
== MODIFIED_IDX
:
798 action
= menu
.addAction(
800 cmds
.StageModified
.name(),
801 cmds
.run(cmds
.StageModified
, context
),
803 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
804 elif idx
== UNTRACKED_IDX
:
805 action
= menu
.addAction(
807 cmds
.StageUntracked
.name(),
808 cmds
.run(cmds
.StageUntracked
, context
),
810 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
813 def _create_staged_context_menu(self
, menu
, s
):
814 if s
.staged
[0] in self
._model
.submodules
:
815 return self
._create
_staged
_submodule
_context
_menu
(menu
, s
)
817 context
= self
.context
818 if self
._model
.is_unstageable():
819 action
= menu
.addAction(
821 N_('Unstage Selected'),
822 cmds
.run(cmds
.Unstage
, context
, self
.staged()),
824 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
826 menu
.addAction(self
.launch_editor_action
)
828 # Do all of the selected items exist?
830 i
not in self
._model
.staged_deleted
and core
.exists(i
)
831 for i
in self
.staged()
835 menu
.addAction(self
.launch_difftool_action
)
837 if self
._model
.is_undoable():
838 menu
.addAction(self
.revert_unstaged_edits_action
)
840 menu
.addAction(self
.view_history_action
)
841 menu
.addAction(self
.view_blame_action
)
844 def _create_staged_submodule_context_menu(self
, menu
, s
):
845 context
= self
.context
846 path
= core
.abspath(s
.staged
[0])
847 if len(self
.staged()) == 1:
850 N_('Launch git-cola'),
851 cmds
.run(cmds
.OpenRepo
, context
, path
),
854 action
= menu
.addAction(
856 N_('Unstage Selected'),
857 cmds
.run(cmds
.Unstage
, context
, self
.staged()),
859 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
861 menu
.addAction(self
.view_history_action
)
864 def _create_unmerged_context_menu(self
, menu
, _s
):
865 context
= self
.context
866 menu
.addAction(self
.launch_difftool_action
)
868 action
= menu
.addAction(
870 N_('Stage Selected'),
871 cmds
.run(cmds
.Stage
, context
, self
.unstaged()),
873 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
875 menu
.addAction(self
.launch_editor_action
)
876 menu
.addAction(self
.view_history_action
)
877 menu
.addAction(self
.view_blame_action
)
879 menu
.addAction(self
.checkout_ours_action
)
880 menu
.addAction(self
.checkout_theirs_action
)
883 def _create_unstaged_context_menu(self
, menu
, s
):
884 context
= self
.context
885 modified_submodule
= s
.modified
and s
.modified
[0] in self
._model
.submodules
886 if modified_submodule
:
887 return self
._create
_modified
_submodule
_context
_menu
(menu
, s
)
889 if self
._model
.is_stageable():
890 action
= menu
.addAction(
892 N_('Stage Selected'),
893 cmds
.run(cmds
.Stage
, context
, self
.unstaged()),
895 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
897 if not self
.selection_model
.is_empty():
898 menu
.addAction(self
.launch_editor_action
)
900 # Do all of the selected items exist?
902 i
not in self
._model
.unstaged_deleted
and core
.exists(i
)
903 for i
in self
.staged()
906 if all_exist
and s
.modified
and self
._model
.is_stageable():
907 menu
.addAction(self
.launch_difftool_action
)
909 if s
.modified
and self
._model
.is_stageable() and self
._model
.is_undoable():
911 menu
.addAction(self
.revert_unstaged_edits_action
)
913 if all_exist
and s
.untracked
:
914 # Git Annex / Git LFS
915 annex
= self
._model
.annex
916 lfs
= core
.find_executable('git-lfs')
920 menu
.addAction(self
.annex_add_action
)
922 menu
.addAction(self
.lfs_track_action
)
925 if self
.move_to_trash_action
is not None:
926 menu
.addAction(self
.move_to_trash_action
)
927 menu
.addAction(self
.delete_untracked_files_action
)
932 partial(gitignore
.gitignore_view
, self
.context
),
935 if not self
.selection_model
.is_empty():
936 menu
.addAction(self
.view_history_action
)
937 menu
.addAction(self
.view_blame_action
)
940 def _create_modified_submodule_context_menu(self
, menu
, s
):
941 context
= self
.context
942 path
= core
.abspath(s
.modified
[0])
943 if len(self
.unstaged()) == 1:
946 N_('Launch git-cola'),
947 cmds
.run(cmds
.OpenRepo
, context
, path
),
951 N_('Update this submodule'),
952 cmds
.run(cmds
.SubmoduleUpdate
, context
, path
),
956 if self
._model
.is_stageable():
958 action
= menu
.addAction(
960 N_('Stage Selected'),
961 cmds
.run(cmds
.Stage
, context
, self
.unstaged()),
963 action
.setShortcut(hotkeys
.STAGE_SELECTION
)
965 menu
.addAction(self
.view_history_action
)
968 def _delete_untracked_files(self
):
969 cmds
.do(cmds
.Delete
, self
.context
, self
.untracked())
971 def _trash_untracked_files(self
):
972 cmds
.do(cmds
.MoveToTrash
, self
.context
, self
.untracked())
974 def selected_path(self
):
975 s
= self
.single_selection()
976 return s
.staged
or s
.unmerged
or s
.modified
or s
.untracked
or None
978 def single_selection(self
):
979 """Scan across staged, modified, etc. and return a single item."""
989 unmerged
= s
.unmerged
[0]
991 modified
= s
.modified
[0]
993 untracked
= s
.untracked
[0]
995 return selection
.State(staged
, unmerged
, modified
, untracked
)
997 def selected_indexes(self
):
998 """Returns a list of (category, row) representing the tree selection."""
999 selected
= self
.selectedIndexes()
1001 for idx
in selected
:
1002 if idx
.parent().isValid():
1003 parent_idx
= idx
.parent()
1004 entry
= (parent_idx
.row(), idx
.row())
1006 entry
= (HEADER_IDX
, idx
.row())
1007 result
.append(entry
)
1010 def selection(self
):
1011 """Return the current selection in the repo status tree."""
1012 return selection
.State(
1013 self
.staged(), self
.unmerged(), self
.modified(), self
.untracked()
1017 """Return all of the current files in a selection.State container"""
1018 return selection
.State(
1020 self
._model
.unmerged
,
1021 self
._model
.modified
,
1022 self
._model
.untracked
,
1025 def all_files(self
):
1026 """Return all of the current active files as a flat list"""
1028 return c
.staged
+ c
.unmerged
+ c
.modified
+ c
.untracked
1030 def selected_group(self
):
1031 """A list of selected files in various states of being"""
1032 return selection
.pick(self
.selection())
1034 def selected_idx(self
):
1036 s
= self
.single_selection()
1038 for content
, sel
in zip(c
, s
):
1042 return offset
+ content
.index(sel
)
1043 offset
+= len(content
)
1046 def select_by_index(self
, idx
):
1049 (c
.staged
, STAGED_IDX
),
1050 (c
.unmerged
, UNMERGED_IDX
),
1051 (c
.modified
, MODIFIED_IDX
),
1052 (c
.untracked
, UNTRACKED_IDX
),
1054 for content
, toplevel_idx
in to_try
:
1057 if idx
< len(content
):
1058 parent
= self
.topLevelItem(toplevel_idx
)
1059 item
= parent
.child(idx
)
1060 if item
is not None:
1061 qtutils
.select_item(self
, item
)
1066 return qtutils
.get_selected_values(self
, STAGED_IDX
, self
._model
.staged
)
1069 return self
.unmerged() + self
.modified() + self
.untracked()
1072 return qtutils
.get_selected_values(self
, MODIFIED_IDX
, self
._model
.modified
)
1075 return qtutils
.get_selected_values(self
, UNMERGED_IDX
, self
._model
.unmerged
)
1077 def untracked(self
):
1078 return qtutils
.get_selected_values(self
, UNTRACKED_IDX
, self
._model
.untracked
)
1080 def staged_items(self
):
1081 return qtutils
.get_selected_items(self
, STAGED_IDX
)
1083 def unstaged_items(self
):
1084 return self
.unmerged_items() + self
.modified_items() + self
.untracked_items()
1086 def modified_items(self
):
1087 return qtutils
.get_selected_items(self
, MODIFIED_IDX
)
1089 def unmerged_items(self
):
1090 return qtutils
.get_selected_items(self
, UNMERGED_IDX
)
1092 def untracked_items(self
):
1093 return qtutils
.get_selected_items(self
, UNTRACKED_IDX
)
1095 def show_selection(self
):
1096 """Show the selected item."""
1097 context
= self
.context
1098 qtutils
.scroll_to_item(self
, self
.currentItem())
1099 # Sync the selection model
1100 selected
= self
.selection()
1101 selection_model
= self
.selection_model
1102 selection_model
.set_selection(selected
)
1103 self
._update
_actions
(selected
=selected
)
1105 selected_indexes
= self
.selected_indexes()
1106 if not selected_indexes
:
1107 if self
._model
.is_amend_mode() or self
._model
.is_diff_mode():
1108 cmds
.do(cmds
.SetDiffText
, context
, '')
1110 cmds
.do(cmds
.ResetMode
, context
)
1113 # A header item e.g. 'Staged', 'Modified', etc.
1114 category
, idx
= selected_indexes
[0]
1115 header
= category
== HEADER_IDX
1118 STAGED_IDX
: cmds
.DiffStagedSummary
,
1119 MODIFIED_IDX
: cmds
.Diffstat
,
1120 UNMERGED_IDX
: cmds
.UnmergedSummary
,
1121 UNTRACKED_IDX
: cmds
.UntrackedSummary
,
1122 }.get(idx
, cmds
.Diffstat
)
1123 cmds
.do(cls
, context
)
1126 staged
= category
== STAGED_IDX
1127 modified
= category
== MODIFIED_IDX
1128 unmerged
= category
== UNMERGED_IDX
1129 untracked
= category
== UNTRACKED_IDX
1132 item
= self
.staged_items()[0]
1134 item
= self
.unmerged_items()[0]
1136 item
= self
.modified_items()[0]
1138 item
= self
.unstaged_items()[0]
1140 item
= None # this shouldn't happen
1141 assert item
is not None
1144 deleted
= item
.deleted
1145 image
= self
.image_formats
.ok(path
)
1147 # Update the diff text
1149 cmds
.do(cmds
.DiffStaged
, context
, path
, deleted
=deleted
)
1151 cmds
.do(cmds
.Diff
, context
, path
, deleted
=deleted
)
1153 cmds
.do(cmds
.Diff
, context
, path
)
1155 cmds
.do(cmds
.ShowUntracked
, context
, path
)
1157 # Images are diffed differently.
1158 # DiffImage transitions the diff mode to image.
1159 # DiffText transitions the diff mode to text.
1172 cmds
.do(cmds
.DiffText
, context
)
1174 def select_header(self
):
1175 """Select an active header, which triggers a diffstat"""
1182 item
= self
.topLevelItem(idx
)
1183 if item
.childCount() > 0:
1184 self
.clearSelection()
1185 self
.setCurrentItem(item
)
1189 """Select the item above the currently selected item"""
1190 idx
= self
.selected_idx()
1191 all_files
= self
.all_files()
1193 selected_indexes
= self
.selected_indexes()
1194 if selected_indexes
:
1195 category
, toplevel_idx
= selected_indexes
[0]
1196 if category
== HEADER_IDX
:
1197 item
= self
.itemAbove(self
.topLevelItem(toplevel_idx
))
1198 if item
is not None:
1199 qtutils
.select_item(self
, item
)
1202 self
.select_by_index(len(all_files
) - 1)
1205 self
.select_by_index(idx
- 1)
1207 self
.select_by_index(len(all_files
) - 1)
1209 def move_down(self
):
1210 """Select the item below the currently selected item"""
1211 idx
= self
.selected_idx()
1212 all_files
= self
.all_files()
1214 selected_indexes
= self
.selected_indexes()
1215 if selected_indexes
:
1216 category
, toplevel_idx
= selected_indexes
[0]
1217 if category
== HEADER_IDX
:
1218 item
= self
.itemBelow(self
.topLevelItem(toplevel_idx
))
1219 if item
is not None:
1220 qtutils
.select_item(self
, item
)
1223 self
.select_by_index(0)
1225 if idx
+ 1 < len(all_files
):
1226 self
.select_by_index(idx
+ 1)
1228 self
.select_by_index(0)
1230 def mousePressEvent(self
, event
):
1231 """Keep track of whether to drag URLs or just text"""
1232 self
._alt
_drag
= event
.modifiers() & Qt
.AltModifier
1233 return super().mousePressEvent(event
)
1235 def mouseMoveEvent(self
, event
):
1236 """Keep track of whether to drag URLs or just text"""
1237 self
._alt
_drag
= event
.modifiers() & Qt
.AltModifier
1238 return super().mouseMoveEvent(event
)
1240 def mimeData(self
, items
):
1241 """Return a list of absolute-path URLs"""
1242 context
= self
.context
1243 paths
= qtutils
.paths_from_items(items
, item_filter
=_item_filter
)
1244 include_urls
= not self
._alt
_drag
1245 return qtutils
.mimedata_from_paths(context
, paths
, include_urls
=include_urls
)
1247 def mimeTypes(self
):
1248 """Return the mime types that this widget generates"""
1249 return qtutils
.path_mimetypes(include_urls
=not self
._alt
_drag
)
1252 def _item_filter(item
):
1253 """Filter items down to just those that exist on disk"""
1254 return not item
.deleted
and core
.exists(item
.path
)
1257 def view_blame(context
):
1258 """Signal that we should view blame for paths."""
1259 cmds
.do(cmds
.BlamePaths
, context
)
1262 def view_history(context
):
1263 """Signal that we should view history for paths."""
1264 cmds
.do(cmds
.VisualizePaths
, context
, context
.selection
.union())
1267 def copy_path(context
, absolute
=True):
1268 """Copy a selected path to the clipboard"""
1269 filename
= context
.selection
.filename()
1270 qtutils
.copy_path(filename
, absolute
=absolute
)
1273 def copy_relpath(context
):
1274 """Copy a selected relative path to the clipboard"""
1275 copy_path(context
, absolute
=False)
1278 def copy_basename(context
):
1279 filename
= os
.path
.basename(context
.selection
.filename())
1280 basename
, _
= os
.path
.splitext(filename
)
1281 qtutils
.copy_path(basename
, absolute
=False)
1284 def copy_leading_path(context
, strip_components
):
1285 """Peal off trailing path components and copy the current path to the clipboard"""
1286 filename
= context
.selection
.filename()
1288 for _
in range(strip_components
):
1289 value
= os
.path
.dirname(value
)
1290 qtutils
.copy_path(value
, absolute
=False)
1293 def copy_format(context
, fmt
):
1294 """Add variables usable in the custom Copy format strings"""
1296 values
['path'] = path
= context
.selection
.filename()
1297 values
['abspath'] = abspath
= os
.path
.abspath(path
)
1298 values
['absdirname'] = os
.path
.dirname(abspath
)
1299 values
['dirname'] = os
.path
.dirname(path
)
1300 values
['filename'] = os
.path
.basename(path
)
1301 values
['basename'], values
['ext'] = os
.path
.splitext(os
.path
.basename(path
))
1302 qtutils
.set_clipboard(fmt
% values
)
1305 def show_help(context
):
1306 """Display the help for the custom Copy format strings"""
1309 Format String Variables
1310 -----------------------
1311 %(path)s = relative file path
1312 %(abspath)s = absolute file path
1313 %(dirname)s = relative directory path
1314 %(absdirname)s = absolute directory path
1315 %(filename)s = file basename
1316 %(basename)s = file basename without extension
1317 %(ext)s = file extension
1320 title
= N_('Help - Custom Copy Actions')
1321 return text
.text_dialog(context
, help_text
, title
)
1324 class StatusFilterWidget(QtWidgets
.QWidget
):
1325 """Filter paths displayed by the Status tool"""
1327 def __init__(self
, context
, parent
=None):
1328 QtWidgets
.QWidget
.__init
__(self
, parent
)
1329 self
.context
= context
1331 hint
= N_('Filter paths...')
1332 self
.text
= completion
.GitStatusFilterLineEdit(context
, hint
=hint
, parent
=self
)
1333 self
.text
.setToolTip(hint
)
1334 self
.setFocusProxy(self
.text
)
1337 self
.main_layout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, self
.text
)
1338 self
.setLayout(self
.main_layout
)
1341 widget
.changed
.connect(self
.apply_filter
)
1342 widget
.cleared
.connect(self
.apply_filter
)
1343 widget
.enter
.connect(self
.apply_filter
)
1344 widget
.editingFinished
.connect(self
.apply_filter
)
1346 def apply_filter(self
):
1347 """Apply the text filter to the model"""
1348 value
= get(self
.text
)
1349 if value
== self
._filter
:
1351 self
._filter
= value
1352 paths
= utils
.shell_split(value
)
1353 self
.context
.model
.update_path_filter(paths
)
1356 def customize_copy_actions(context
, parent
):
1357 """Customize copy actions"""
1358 dialog
= CustomizeCopyActions(context
, parent
)
1363 class CustomizeCopyActions(standard
.Dialog
):
1364 """A dialog for defining custom Copy actions and format strings"""
1366 def __init__(self
, context
, parent
):
1367 standard
.Dialog
.__init
__(self
, parent
=parent
)
1368 self
.setWindowTitle(N_('Custom Copy Actions'))
1370 self
.context
= context
1371 self
.table
= QtWidgets
.QTableWidget(self
)
1372 self
.table
.setColumnCount(2)
1373 self
.table
.setHorizontalHeaderLabels([N_('Action Name'), N_('Format String')])
1374 self
.table
.setSortingEnabled(False)
1375 self
.table
.verticalHeader().hide()
1376 self
.table
.horizontalHeader().setStretchLastSection(True)
1378 self
.add_button
= qtutils
.create_button(N_('Add'))
1379 self
.remove_button
= qtutils
.create_button(N_('Remove'))
1380 self
.remove_button
.setEnabled(False)
1381 self
.show_help_button
= qtutils
.create_button(N_('Show Help'))
1382 self
.show_help_button
.setShortcut(hotkeys
.QUESTION
)
1384 self
.close_button
= qtutils
.close_button()
1385 self
.save_button
= qtutils
.ok_button(N_('Save'))
1387 self
.buttons
= qtutils
.hbox(
1389 defs
.button_spacing
,
1392 self
.show_help_button
,
1398 layout
= qtutils
.vbox(defs
.margin
, defs
.spacing
, self
.table
, self
.buttons
)
1399 self
.setLayout(layout
)
1401 qtutils
.connect_button(self
.add_button
, self
.add
)
1402 qtutils
.connect_button(self
.remove_button
, self
.remove
)
1403 qtutils
.connect_button(self
.show_help_button
, partial(show_help
, context
))
1404 qtutils
.connect_button(self
.close_button
, self
.reject
)
1405 qtutils
.connect_button(self
.save_button
, self
.save
)
1406 qtutils
.add_close_action(self
)
1407 self
.table
.itemSelectionChanged
.connect(self
.table_selection_changed
)
1409 self
.init_size(parent
=parent
)
1411 QtCore
.QTimer
.singleShot(0, self
.reload_settings
)
1413 def reload_settings(self
):
1414 """Update the view to match the current settings"""
1415 # Called once after the GUI is initialized
1416 settings
= self
.context
.settings
1419 for entry
in settings
.copy_formats
:
1420 name_string
= entry
.get('name', '')
1421 format_string
= entry
.get('format', '')
1422 if name_string
and format_string
:
1423 name
= QtWidgets
.QTableWidgetItem(name_string
)
1424 fmt
= QtWidgets
.QTableWidgetItem(format_string
)
1425 rows
= table
.rowCount()
1426 table
.setRowCount(rows
+ 1)
1427 table
.setItem(rows
, 0, name
)
1428 table
.setItem(rows
, 1, fmt
)
1430 def export_state(self
):
1431 """Export the current state into the saved settings"""
1432 state
= super().export_state()
1433 standard
.export_header_columns(self
.table
, state
)
1436 def apply_state(self
, state
):
1437 """Restore state from the saved settings"""
1438 result
= super().apply_state(state
)
1439 standard
.apply_header_columns(self
.table
, state
)
1443 """Add a custom Copy action and format string"""
1444 self
.table
.setFocus()
1445 rows
= self
.table
.rowCount()
1446 self
.table
.setRowCount(rows
+ 1)
1448 name
= QtWidgets
.QTableWidgetItem(N_('Name'))
1449 fmt
= QtWidgets
.QTableWidgetItem(r
'%(path)s')
1450 self
.table
.setItem(rows
, 0, name
)
1451 self
.table
.setItem(rows
, 1, fmt
)
1453 self
.table
.setCurrentCell(rows
, 0)
1454 self
.table
.editItem(name
)
1457 """Remove selected items"""
1458 # Gather a unique set of rows and remove them in reverse order
1460 items
= self
.table
.selectedItems()
1462 rows
.add(self
.table
.row(item
))
1464 for row
in reversed(sorted(rows
)):
1465 self
.table
.removeRow(row
)
1468 """Save custom copy actions to the settings"""
1470 for row
in range(self
.table
.rowCount()):
1471 name
= self
.table
.item(row
, 0)
1472 fmt
= self
.table
.item(row
, 1)
1475 'name': name
.text(),
1476 'format': fmt
.text(),
1478 copy_formats
.append(entry
)
1480 settings
= self
.context
.settings
1481 while settings
.copy_formats
:
1482 settings
.copy_formats
.pop()
1484 settings
.copy_formats
.extend(copy_formats
)
1489 def table_selection_changed(self
):
1490 """Update the enabled state of action buttons based on the current selection"""
1491 items
= self
.table
.selectedItems()
1492 self
.remove_button
.setEnabled(bool(items
))
1495 def _select_item(widget
, path_list
, widget_getter
, item
, current
=False):
1496 """Select the widget item based on the list index"""
1497 # The path lists and widget indexes have a 1:1 correspondence.
1498 # Lookup the item filename in the list and use that index to
1499 # retrieve the widget item and select it.
1500 idx
= path_list
.index(item
)
1501 item
= widget_getter(idx
)
1503 widget
.setCurrentItem(item
)
1504 item
.setSelected(True)
1507 def _apply_toplevel_selection(widget
, category
, idx
):
1508 """Select a top-level "header" item (ex: the Staged parent item)
1510 Return True when a top-level item is selected.
1512 is_top_level_item
= category
== HEADER_IDX
1513 if is_top_level_item
:
1514 root_item
= widget
.invisibleRootItem()
1515 item
= root_item
.child(idx
)
1517 if item
is not None and item
.childCount() == 0:
1518 # The item now has no children. Select a different top-level item
1519 # corresponding to the previously selected item.
1520 if idx
== STAGED_IDX
:
1521 # If "Staged" was previously selected try "Modified" and "Untracked".
1522 item
= _get_first_item_with_children(
1523 root_item
.child(MODIFIED_IDX
), root_item
.child(UNTRACKED_IDX
)
1525 elif idx
== UNMERGED_IDX
:
1526 # If "Unmerged" was previously selected try "Staged".
1527 item
= _get_first_item_with_children(root_item
.child(STAGED_IDX
))
1528 elif idx
== MODIFIED_IDX
:
1529 # If "Modified" was previously selected try "Staged" or "Untracked".
1530 item
= _get_first_item_with_children(
1531 root_item
.child(STAGED_IDX
), root_item
.child(UNTRACKED_IDX
)
1533 elif idx
== UNTRACKED_IDX
:
1534 # If "Untracked" was previously selected try "Staged".
1535 item
= _get_first_item_with_children(root_item
.child(STAGED_IDX
))
1537 if item
is not None:
1538 with qtutils
.BlockSignals(widget
):
1539 widget
.setCurrentItem(item
)
1540 item
.setSelected(True)
1541 widget
.show_selection()
1542 return is_top_level_item
1545 def _get_first_item_with_children(*items
):
1546 """Return the first item that contains child items"""
1548 if item
.childCount() > 0:
1553 def _transplant_selection_across_sections(
1554 category
, idx
, previous_contents
, saved_selection
1556 """Transplant the selection to a different category"""
1557 # This function is used when the selection would otherwise become empty.
1558 # Apply heuristics to select the items based on the previous state.
1559 if not previous_contents
:
1561 staged
, unmerged
, modified
, untracked
= saved_selection
1562 prev_staged
, prev_unmerged
, prev_modified
, prev_untracked
= previous_contents
1564 # The current set of paths.
1565 staged_paths
= staged
[NEW_PATHS_IDX
]
1566 unmerged_paths
= unmerged
[NEW_PATHS_IDX
]
1567 modified_paths
= modified
[NEW_PATHS_IDX
]
1568 untracked_paths
= untracked
[NEW_PATHS_IDX
]
1570 # These callbacks select a path in the corresponding widget sub-tree lists.
1571 select_staged
= staged
[SELECT_FN_IDX
]
1572 select_unmerged
= unmerged
[SELECT_FN_IDX
]
1573 select_modified
= modified
[SELECT_FN_IDX
]
1574 select_untracked
= untracked
[SELECT_FN_IDX
]
1576 if category
== STAGED_IDX
:
1577 # Staged files can become Unmerged, Modified or Untracked.
1578 # If we previously had a staged file selected then try to select
1579 # it in either the Unmerged, Modified or Untracked sections.
1581 old_path
= prev_staged
[idx
]
1584 if old_path
in unmerged_paths
:
1585 select_unmerged(old_path
, current
=True)
1586 elif old_path
in modified_paths
:
1587 select_modified(old_path
, current
=True)
1588 elif old_path
in untracked_paths
:
1589 select_untracked(old_path
, current
=True)
1591 elif category
== UNMERGED_IDX
:
1592 # Unmerged files can become Staged, Modified or Untracked.
1593 # If we previously had an unmerged file selected then try to select it in
1594 # the Staged, Modified or Untracked sections.
1596 old_path
= prev_unmerged
[idx
]
1599 if old_path
in staged_paths
:
1600 select_staged(old_path
, current
=True)
1601 elif old_path
in modified_paths
:
1602 select_modified(old_path
, current
=True)
1603 elif old_path
in untracked_paths
:
1604 select_untracked(old_path
, current
=True)
1606 elif category
== MODIFIED_IDX
:
1607 # If we previously had a modified file selected then try to select
1608 # it in either the Staged or Untracked sections.
1610 old_path
= prev_modified
[idx
]
1613 if old_path
in staged_paths
:
1614 select_staged(old_path
, current
=True)
1615 elif old_path
in untracked_paths
:
1616 select_untracked(old_path
, current
=True)
1618 elif category
== UNTRACKED_IDX
:
1619 # If we previously had an untracked file selected then try to select
1620 # it in the Modified or Staged section. Modified is less common, but
1621 # it's possible for a file to be untracked and then the user adds and
1622 # modifies the file before we've refreshed our state.
1624 old_path
= prev_untracked
[idx
]
1627 if old_path
in modified_paths
:
1628 select_modified(old_path
, current
=True)
1629 elif old_path
in staged_paths
:
1630 select_staged(old_path
, current
=True)
1633 class CopyLeadingPathWidget(QtWidgets
.QWidget
):
1634 """A widget that holds a label and a spin-box for the number of paths to strip"""
1636 def __init__(self
, title
, context
, parent
):
1637 QtWidgets
.QWidget
.__init
__(self
, parent
)
1638 self
.context
= context
1639 self
.icon
= QtWidgets
.QLabel(self
)
1640 self
.label
= QtWidgets
.QLabel(self
)
1641 self
.spinbox
= standard
.SpinBox(value
=1, mini
=1, maxi
=99, parent
=self
)
1642 self
.spinbox
.setToolTip(N_('The number of leading paths to strip'))
1645 pixmap
= icon
.pixmap(defs
.default_icon
, defs
.default_icon
)
1646 self
.icon
.setPixmap(pixmap
)
1647 self
.label
.setText(title
)
1649 layout
= qtutils
.hbox(
1651 defs
.titlebar_spacing
,
1657 self
.setLayout(layout
)
1659 theme
= context
.app
.theme
1660 highlight_rgb
= theme
.highlight_color_rgb()
1661 text_rgb
, highlight_text_rgb
= theme
.text_colors_rgb()
1662 disabled_text_rgb
= theme
.disabled_text_color_rgb()
1666 show-decoration-selected: 1
1670 show-decoration-selected: 1
1673 color: {highlight_text_rgb};
1674 background-color: {highlight_rgb};
1675 background-clip: padding;
1676 show-decoration-selected: 1
1679 color: {disabled_text_rgb};
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 spin-box"""
1692 return self
.spinbox
.value()
1694 def set_value(self
, value
):
1695 """Set the spin-box value"""
1696 self
.spinbox
.setValue(value
)