commitmsg: move the progress bar into the dock title
[git-cola.git] / cola / widgets / status.py
blob3baa5d3c33d017c218559f34a4414e8a9077a3eb
1 import itertools
2 import os
3 from functools import partial
5 from qtpy.QtCore import Qt
6 from qtpy import QtCore
7 from qtpy import QtWidgets
9 from ..i18n import N_
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
17 from .. import cmds
18 from .. import core
19 from .. import difftool
20 from .. import hotkeys
21 from .. import icons
22 from .. import qtutils
23 from .. import utils
24 from . import common
25 from . import completion
26 from . import defs
27 from . import text
30 # Top-level status widget item indexes.
31 HEADER_IDX = -1
32 STAGED_IDX = 0
33 UNMERGED_IDX = 1
34 MODIFIED_IDX = 2
35 UNTRACKED_IDX = 3
36 END_IDX = 4
38 # Indexes into the saved_selection entries.
39 NEW_PATHS_IDX = 0
40 OLD_PATHS_IDX = 1
41 SELECTION_IDX = 2
42 SELECT_FN_IDX = 3
45 class StatusWidget(QtWidgets.QFrame):
46 """
47 Provides a git-status-like repository widget.
49 This widget observes the main model and broadcasts
50 Qt signals.
52 """
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)
93 if shown:
94 self.filter_widget.setFocus()
95 else:
96 self.tree.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))
103 def refresh(self):
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)
118 def move_up(self):
119 self.tree.move_up()
121 def move_down(self):
122 self.tree.move_down()
124 def select_header(self):
125 self.tree.select_header()
128 # pylint: disable=too-many-ancestors
129 class StatusTreeWidget(QtWidgets.QTreeWidget):
130 # Read-only access to the mode state
131 mode = property(lambda self: self._model.mode)
133 def __init__(self, context, parent=None):
134 QtWidgets.QTreeWidget.__init__(self, parent)
135 self.context = context
136 self.selection_model = context.selection
138 self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
139 self.headerItem().setHidden(True)
140 self.setAllColumnsShowFocus(True)
141 self.setSortingEnabled(False)
142 self.setUniformRowHeights(True)
143 self.setAnimated(True)
144 self.setRootIsDecorated(False)
145 self.setAutoScroll(False)
146 self.setDragEnabled(True)
147 self.setDragDropMode(QtWidgets.QAbstractItemView.DragOnly)
148 self._alt_drag = False
150 if not prefs.status_indent(context):
151 self.setIndentation(0)
153 ok_icon = icons.ok()
154 compare = icons.compare()
155 question = icons.question()
156 self._add_toplevel_item(N_('Staged'), ok_icon, hide=True)
157 self._add_toplevel_item(N_('Unmerged'), compare, hide=True)
158 self._add_toplevel_item(N_('Modified'), compare, hide=True)
159 self._add_toplevel_item(N_('Untracked'), question, hide=True)
161 # Used to restore the selection
162 self.old_vscroll = None
163 self.old_hscroll = None
164 self.old_selection = None
165 self.old_contents = None
166 self.old_current_item = None
167 self.previous_contents = None
168 self.was_visible = True
169 self.expanded_items = set()
171 self.image_formats = qtutils.ImageFormats()
173 self.process_selection_action = qtutils.add_action(
174 self,
175 cmds.StageOrUnstage.name(),
176 self._stage_selection,
177 hotkeys.STAGE_SELECTION,
179 self.process_selection_action.setIcon(icons.add())
181 self.stage_or_unstage_all_action = qtutils.add_action(
182 self,
183 cmds.StageOrUnstageAll.name(),
184 cmds.run(cmds.StageOrUnstageAll, self.context),
185 hotkeys.STAGE_ALL,
187 self.stage_or_unstage_all_action.setIcon(icons.add())
189 self.revert_unstaged_edits_action = qtutils.add_action(
190 self,
191 cmds.RevertUnstagedEdits.name(),
192 cmds.run(cmds.RevertUnstagedEdits, context),
193 hotkeys.REVERT,
194 hotkeys.REVERT_ALT,
196 self.revert_unstaged_edits_action.setIcon(icons.undo())
198 self.launch_difftool_action = qtutils.add_action(
199 self,
200 difftool.LaunchDifftool.name(),
201 cmds.run(difftool.LaunchDifftool, context),
202 hotkeys.DIFF,
204 self.launch_difftool_action.setIcon(icons.diff())
206 self.launch_editor_action = actions.launch_editor_at_line(
207 context, self, *hotkeys.ACCEPT
210 self.default_app_action = common.default_app_action(
211 context, self, self.selected_group
214 self.parent_dir_action = common.parent_dir_action(
215 context, self, self.selected_group
218 self.worktree_dir_action = common.worktree_dir_action(context, self)
220 self.terminal_action = common.terminal_action(
221 context, self, func=self.selected_group
224 self.up_action = qtutils.add_action(
225 self,
226 N_('Move Up'),
227 self.move_up,
228 hotkeys.MOVE_UP,
229 hotkeys.MOVE_UP_SECONDARY,
232 self.down_action = qtutils.add_action(
233 self,
234 N_('Move Down'),
235 self.move_down,
236 hotkeys.MOVE_DOWN,
237 hotkeys.MOVE_DOWN_SECONDARY,
240 # Checkout the selected paths using "git checkout --ours".
241 self.checkout_ours_action = qtutils.add_action(
242 self, cmds.CheckoutOurs.name(), cmds.run(cmds.CheckoutOurs, context)
245 # Checkout the selected paths using "git checkout --theirs".
246 self.checkout_theirs_action = qtutils.add_action(
247 self, cmds.CheckoutTheirs.name(), cmds.run(cmds.CheckoutTheirs, context)
250 self.copy_path_action = qtutils.add_action(
251 self,
252 N_('Copy Path to Clipboard'),
253 partial(copy_path, context),
254 hotkeys.COPY,
256 self.copy_path_action.setIcon(icons.copy())
258 self.copy_relpath_action = qtutils.add_action(
259 self,
260 N_('Copy Relative Path to Clipboard'),
261 partial(copy_relpath, context),
262 hotkeys.CUT,
264 self.copy_relpath_action.setIcon(icons.copy())
266 self.copy_leading_paths_value = 1
268 self.copy_basename_action = qtutils.add_action(
269 self, N_('Copy Basename to Clipboard'), partial(copy_basename, context)
271 self.copy_basename_action.setIcon(icons.copy())
273 self.copy_customize_action = qtutils.add_action(
274 self, N_('Customize...'), partial(customize_copy_actions, context, self)
276 self.copy_customize_action.setIcon(icons.configure())
278 self.view_history_action = qtutils.add_action(
279 self, N_('View History...'), partial(view_history, context), hotkeys.HISTORY
282 self.view_blame_action = qtutils.add_action(
283 self, N_('Blame...'), partial(view_blame, context), hotkeys.BLAME
286 self.annex_add_action = qtutils.add_action(
287 self, N_('Add to Git Annex'), cmds.run(cmds.AnnexAdd, context)
290 self.lfs_track_action = qtutils.add_action(
291 self, N_('Add to Git LFS'), cmds.run(cmds.LFSTrack, context)
294 # MoveToTrash and Delete use the same shortcut.
295 # We will only bind one of them, depending on whether or not the
296 # MoveToTrash command is available. When available, the hotkey
297 # is bound to MoveToTrash, otherwise it is bound to Delete.
298 if cmds.MoveToTrash.AVAILABLE:
299 self.move_to_trash_action = qtutils.add_action(
300 self,
301 N_('Move files to trash'),
302 self._trash_untracked_files,
303 hotkeys.TRASH,
305 self.move_to_trash_action.setIcon(icons.discard())
306 delete_shortcut = hotkeys.DELETE_FILE
307 else:
308 self.move_to_trash_action = None
309 delete_shortcut = hotkeys.DELETE_FILE_SECONDARY
311 self.delete_untracked_files_action = qtutils.add_action(
312 self, N_('Delete Files...'), self._delete_untracked_files, delete_shortcut
314 self.delete_untracked_files_action.setIcon(icons.discard())
316 # The model is stored as self._model because self.model() is a
317 # QTreeWidgetItem method that returns a QAbstractItemModel.
318 self._model = context.model
319 self._model.previous_contents.connect(
320 self._set_previous_contents, type=Qt.QueuedConnection
322 self._model.about_to_update.connect(
323 self._about_to_update, type=Qt.QueuedConnection
325 self._model.updated.connect(self.refresh, type=Qt.QueuedConnection)
326 self._model.diff_text_changed.connect(
327 self._make_current_item_visible, type=Qt.QueuedConnection
329 # pylint: disable=no-member
330 self.itemSelectionChanged.connect(self.show_selection)
331 self.itemDoubleClicked.connect(cmds.run(cmds.StageOrUnstage, self.context))
332 self.itemCollapsed.connect(lambda x: self._update_column_widths())
333 self.itemExpanded.connect(lambda x: self._update_column_widths())
335 def _make_current_item_visible(self):
336 item = self.currentItem()
337 if item:
338 qtutils.scroll_to_item(self, item)
340 def _add_toplevel_item(self, txt, icon, hide=False):
341 context = self.context
342 font = self.font()
343 if prefs.bold_headers(context):
344 font.setBold(True)
345 else:
346 font.setItalic(True)
348 item = QtWidgets.QTreeWidgetItem(self)
349 item.setFont(0, font)
350 item.setText(0, txt)
351 item.setIcon(0, icon)
352 if prefs.bold_headers(context):
353 item.setBackground(0, self.palette().midlight())
354 if hide:
355 item.setHidden(True)
357 def _restore_selection(self):
358 """Apply the old selection to the newly updated items"""
359 # This function is called after a new set of items have been added to
360 # the per-category file lists. Its purpose is to either restore the
361 # existing selection or to create a new intuitive selection based on
362 # a combination of the old items, the old selection and the new items.
363 if not self.old_selection or not self.old_contents:
364 return
365 # The old set of categorized files.
366 old_c = self.old_contents
367 # The old selection.
368 old_s = self.old_selection
369 # The current/new set of categorized files.
370 new_c = self.contents()
372 select_staged = partial(_select_item, self, new_c.staged, self._staged_item)
373 select_unmerged = partial(
374 _select_item, self, new_c.unmerged, self._unmerged_item
376 select_modified = partial(
377 _select_item, self, new_c.modified, self._modified_item
379 select_untracked = partial(
380 _select_item, self, new_c.untracked, self._untracked_item
383 saved_selection = [
384 (set(new_c.staged), old_c.staged, set(old_s.staged), select_staged),
385 (set(new_c.unmerged), old_c.unmerged, set(old_s.unmerged), select_unmerged),
386 (set(new_c.modified), old_c.modified, set(old_s.modified), select_modified),
388 set(new_c.untracked),
389 old_c.untracked,
390 set(old_s.untracked),
391 select_untracked,
395 # Restore the current item
396 if self.old_current_item:
397 category, idx = self.old_current_item
398 if _apply_toplevel_selection(self, category, idx):
399 return
400 # Reselect the current item
401 selection_info = saved_selection[category]
402 new = selection_info[NEW_PATHS_IDX]
403 old = selection_info[OLD_PATHS_IDX]
404 reselect = selection_info[SELECT_FN_IDX]
405 try:
406 item = old[idx]
407 except IndexError:
408 item = None
409 if item and item in new:
410 reselect(item, current=True)
412 # Restore previously selected items.
413 # When reselecting in this section we only care that the items are
414 # selected; we do not need to rerun the callbacks which were triggered
415 # above for the current item. Block signals to skip the callbacks.
417 # Reselect items that were previously selected and still exist in the
418 # current path lists. This handles a common case such as a Ctrl-R
419 # refresh which results in the same exact path state.
420 did_reselect = False
422 with qtutils.BlockSignals(self):
423 for new, old, sel, reselect in saved_selection:
424 for item in sel:
425 if item in new:
426 reselect(item, current=False)
427 did_reselect = True
429 # The status widget is used to interactively work your way down the
430 # list of Staged, Unmerged, Modified and Untracked items and perform
431 # an operation on them.
433 # For Staged items we intend to work our way down the list of Staged
434 # items while we unstage each item. For every other category we work
435 # our way down the list of {Unmerged,Modified,Untracked} items while
436 # we stage each item.
438 # The following block of code implements the behavior of selecting
439 # the next item based on the previous selection.
440 for new, old, sel, reselect in saved_selection:
441 # When modified is staged, select the next modified item
442 # When unmerged is staged, select the next unmerged item
443 # When unstaging, select the next staged item
444 # When staging untracked files, select the next untracked item
445 if len(new) >= len(old):
446 # The list did not shrink so it is not one of these cases.
447 continue
448 for item in sel:
449 # The item still exists so ignore it
450 if item in new or item not in old:
451 continue
452 # The item no longer exists in this list so search for
453 # its nearest neighbors and select them instead.
454 idx = old.index(item)
455 for j in itertools.chain(old[idx + 1 :], reversed(old[:idx])):
456 if j in new:
457 reselect(j, current=True)
458 return
460 # If we already reselected stuff then there's nothing more to do.
461 if did_reselect:
462 return
463 # If we got this far then nothing was reselected and made current.
464 # Try a few more heuristics that we can use to keep something selected.
465 if self.old_current_item:
466 category, idx = self.old_current_item
467 _transplant_selection_across_sections(
468 category, idx, self.previous_contents, saved_selection
471 def _restore_scrollbars(self):
472 """Restore scrollbars to the stored values"""
473 qtutils.set_scrollbar_values(self, self.old_hscroll, self.old_vscroll)
474 self.old_hscroll = None
475 self.old_vscroll = None
477 def _stage_selection(self):
478 """Stage or unstage files according to the selection"""
479 context = self.context
480 selected_indexes = self.selected_indexes()
481 is_header = any(category == HEADER_IDX for (category, idx) in selected_indexes)
482 if is_header:
483 is_staged = any(
484 idx == STAGED_IDX and category == HEADER_IDX
485 for (category, idx) in selected_indexes
487 is_modified = any(
488 idx == MODIFIED_IDX and category == HEADER_IDX
489 for (category, idx) in selected_indexes
491 is_untracked = any(
492 idx == UNTRACKED_IDX and category == HEADER_IDX
493 for (category, idx) in selected_indexes
495 # A header item: 'Staged', 'Modified' or 'Untracked'.
496 if is_staged:
497 # If we have the staged header selected then the only sensible
498 # thing to do is to unstage everything and nothing else, even
499 # if the modified or untracked headers are selected.
500 cmds.do(cmds.UnstageAll, context)
501 return # Everything was unstaged. There's nothing more to be done.
502 if is_modified and is_untracked:
503 # If both modified and untracked headers are selected then
504 # stage everything.
505 cmds.do(cmds.StageModifiedAndUntracked, context)
506 return # Nothing more to do.
507 # At this point we may stage all modified and untracked, and then
508 # possibly a subset of the other category (eg. all modified and
509 # some untracked). We don't return here so that StageOrUnstage
510 # gets a chance to run below.
511 if is_modified:
512 cmds.do(cmds.StageModified, context)
513 elif is_untracked:
514 cmds.do(cmds.StageUntracked, context)
515 else:
516 # Do nothing for unmerged items, by design
517 pass
518 # Now handle individual files
519 cmds.do(cmds.StageOrUnstage, context)
521 def _staged_item(self, itemidx):
522 return self._subtree_item(STAGED_IDX, itemidx)
524 def _modified_item(self, itemidx):
525 return self._subtree_item(MODIFIED_IDX, itemidx)
527 def _unmerged_item(self, itemidx):
528 return self._subtree_item(UNMERGED_IDX, itemidx)
530 def _untracked_item(self, itemidx):
531 return self._subtree_item(UNTRACKED_IDX, itemidx)
533 def _unstaged_item(self, itemidx):
534 # is it modified?
535 item = self.topLevelItem(MODIFIED_IDX)
536 count = item.childCount()
537 if itemidx < count:
538 return item.child(itemidx)
539 # is it unmerged?
540 item = self.topLevelItem(UNMERGED_IDX)
541 count += item.childCount()
542 if itemidx < count:
543 return item.child(itemidx)
544 # is it untracked?
545 item = self.topLevelItem(UNTRACKED_IDX)
546 count += item.childCount()
547 if itemidx < count:
548 return item.child(itemidx)
549 # Nope..
550 return None
552 def _subtree_item(self, idx, itemidx):
553 parent = self.topLevelItem(idx)
554 return parent.child(itemidx)
556 def _set_previous_contents(self, staged, unmerged, modified, untracked):
557 """Callback triggered right before the model changes its contents"""
558 self.previous_contents = selection.State(staged, unmerged, modified, untracked)
560 def _about_to_update(self):
561 self._save_scrollbars()
562 self._save_selection()
564 def _save_scrollbars(self):
565 """Store the scrollbar values for later application"""
566 hscroll, vscroll = qtutils.get_scrollbar_values(self)
567 if hscroll is not None:
568 self.old_hscroll = hscroll
569 if vscroll is not None:
570 self.old_vscroll = vscroll
572 def current_item(self):
573 s = self.selected_indexes()
574 if not s:
575 return None
576 current = self.currentItem()
577 if not current:
578 return None
579 idx = self.indexFromItem(current)
580 if idx.parent().isValid():
581 parent_idx = idx.parent()
582 entry = (parent_idx.row(), idx.row())
583 else:
584 entry = (HEADER_IDX, idx.row())
585 return entry
587 def _save_selection(self):
588 self.old_contents = self.contents()
589 self.old_selection = self.selection()
590 self.old_current_item = self.current_item()
592 def refresh(self):
593 self._set_staged(self._model.staged)
594 self._set_modified(self._model.modified)
595 self._set_unmerged(self._model.unmerged)
596 self._set_untracked(self._model.untracked)
597 self._update_column_widths()
598 self._update_actions()
599 self._restore_selection()
600 self._restore_scrollbars()
602 def _update_actions(self, selected=None):
603 if selected is None:
604 selected = self.selection_model.selection()
605 can_revert_edits = bool(selected.staged or selected.modified)
606 self.revert_unstaged_edits_action.setEnabled(can_revert_edits)
608 enabled = self.selection_model.filename() is not None
609 self.default_app_action.setEnabled(enabled)
610 self.parent_dir_action.setEnabled(enabled)
611 self.copy_path_action.setEnabled(enabled)
612 self.copy_relpath_action.setEnabled(enabled)
613 self.copy_basename_action.setEnabled(enabled)
615 def _set_staged(self, items):
616 """Adds items to the 'Staged' subtree."""
617 with qtutils.BlockSignals(self):
618 self._set_subtree(
619 items,
620 STAGED_IDX,
621 N_('Staged'),
622 staged=True,
623 deleted_set=self._model.staged_deleted,
626 def _set_modified(self, items):
627 """Adds items to the 'Modified' subtree."""
628 with qtutils.BlockSignals(self):
629 self._set_subtree(
630 items,
631 MODIFIED_IDX,
632 N_('Modified'),
633 deleted_set=self._model.unstaged_deleted,
636 def _set_unmerged(self, items):
637 """Adds items to the 'Unmerged' subtree."""
638 deleted_set = {path for path in items if not core.exists(path)}
639 with qtutils.BlockSignals(self):
640 self._set_subtree(
641 items, UNMERGED_IDX, N_('Unmerged'), deleted_set=deleted_set
644 def _set_untracked(self, items):
645 """Adds items to the 'Untracked' subtree."""
646 with qtutils.BlockSignals(self):
647 self._set_subtree(items, UNTRACKED_IDX, N_('Untracked'), untracked=True)
649 def _set_subtree(
650 self, items, idx, parent_title, staged=False, untracked=False, deleted_set=None
652 """Add a list of items to a treewidget item."""
653 parent = self.topLevelItem(idx)
654 hide = not bool(items)
655 parent.setHidden(hide)
657 # sip v4.14.7 and below leak memory in parent.takeChildren()
658 # so we use this backwards-compatible construct instead
659 while parent.takeChild(0) is not None:
660 pass
662 for item in items:
663 deleted = deleted_set is not None and item in deleted_set
664 treeitem = qtutils.create_treeitem(
665 item, staged=staged, deleted=deleted, untracked=untracked
667 parent.addChild(treeitem)
668 self._expand_items(idx, items)
670 if prefs.status_show_totals(self.context):
671 parent.setText(0, f'{parent_title} ({len(items)})')
673 def _update_column_widths(self):
674 self.resizeColumnToContents(0)
676 def _expand_items(self, idx, items):
677 """Expand the top-level category "folder" once and only once."""
678 # Don't do this if items is empty; this makes it so that we
679 # don't add the top-level index into the expanded_items set
680 # until an item appears in a particular category.
681 if not items:
682 return
683 # Only run this once; we don't want to re-expand items that
684 # we've clicked on to re-collapse on updated().
685 if idx in self.expanded_items:
686 return
687 self.expanded_items.add(idx)
688 item = self.topLevelItem(idx)
689 if item:
690 self.expandItem(item)
692 def contextMenuEvent(self, event):
693 """Create context menus for the repo status tree."""
694 menu = self._create_context_menu()
695 menu.exec_(self.mapToGlobal(event.pos()))
697 def _create_context_menu(self):
698 """Set up the status menu for the repo status tree."""
699 sel = self.selection()
700 menu = qtutils.create_menu('Status', self)
701 selected_indexes = self.selected_indexes()
702 if selected_indexes:
703 category, idx = selected_indexes[0]
704 # A header item e.g. 'Staged', 'Modified', etc.
705 if category == HEADER_IDX:
706 return self._create_header_context_menu(menu, idx)
708 if sel.staged:
709 self._create_staged_context_menu(menu, sel)
710 elif sel.unmerged:
711 self._create_unmerged_context_menu(menu, sel)
712 else:
713 self._create_unstaged_context_menu(menu, sel)
715 if not menu.isEmpty():
716 menu.addSeparator()
718 if not self.selection_model.is_empty():
719 menu.addAction(self.default_app_action)
720 menu.addAction(self.parent_dir_action)
722 if self.terminal_action is not None:
723 menu.addAction(self.terminal_action)
725 menu.addAction(self.worktree_dir_action)
727 self._add_copy_actions(menu)
729 return menu
731 def _add_copy_actions(self, menu):
732 """Add the "Copy" sub-menu"""
733 enabled = self.selection_model.filename() is not None
734 self.copy_path_action.setEnabled(enabled)
735 self.copy_relpath_action.setEnabled(enabled)
736 self.copy_basename_action.setEnabled(enabled)
738 copy_menu = QtWidgets.QMenu(N_('Copy...'), menu)
739 copy_icon = icons.copy()
740 copy_menu.setIcon(copy_icon)
742 copy_leading_path_action = QtWidgets.QWidgetAction(copy_menu)
743 copy_leading_path_action.setEnabled(enabled)
745 widget = CopyLeadingPathWidget(
746 N_('Copy Leading Path to Clipboard'), self.context, copy_menu
749 # Store the value of the leading paths spinbox so that the value does not reset
750 # everytime the menu is shown and recreated.
751 widget.set_value(self.copy_leading_paths_value)
752 widget.spinbox.valueChanged.connect(
753 partial(setattr, self, 'copy_leading_paths_value')
755 copy_leading_path_action.setDefaultWidget(widget)
757 # Copy the leading path when the action is activated.
758 qtutils.connect_action(
759 copy_leading_path_action,
760 lambda widget=widget: copy_leading_path(context, widget.value()),
763 menu.addSeparator()
764 menu.addMenu(copy_menu)
765 copy_menu.addAction(self.copy_path_action)
766 copy_menu.addAction(self.copy_relpath_action)
767 copy_menu.addAction(copy_leading_path_action)
768 copy_menu.addAction(self.copy_basename_action)
770 settings = Settings.read()
771 copy_formats = settings.copy_formats
772 if copy_formats:
773 copy_menu.addSeparator()
775 context = self.context
776 for entry in copy_formats:
777 name = entry.get('name', '')
778 fmt = entry.get('format', '')
779 if name and fmt:
780 action = copy_menu.addAction(name, partial(copy_format, context, fmt))
781 action.setIcon(copy_icon)
782 action.setEnabled(enabled)
784 copy_menu.addSeparator()
785 copy_menu.addAction(self.copy_customize_action)
787 def _create_header_context_menu(self, menu, idx):
788 context = self.context
789 if idx == STAGED_IDX:
790 menu.addAction(
791 icons.remove(), N_('Unstage All'), cmds.run(cmds.UnstageAll, context)
793 elif idx == UNMERGED_IDX:
794 action = menu.addAction(
795 icons.add(),
796 cmds.StageUnmerged.name(),
797 cmds.run(cmds.StageUnmerged, context),
799 action.setShortcut(hotkeys.STAGE_SELECTION)
800 elif idx == MODIFIED_IDX:
801 action = menu.addAction(
802 icons.add(),
803 cmds.StageModified.name(),
804 cmds.run(cmds.StageModified, context),
806 action.setShortcut(hotkeys.STAGE_SELECTION)
807 elif idx == UNTRACKED_IDX:
808 action = menu.addAction(
809 icons.add(),
810 cmds.StageUntracked.name(),
811 cmds.run(cmds.StageUntracked, context),
813 action.setShortcut(hotkeys.STAGE_SELECTION)
814 return menu
816 def _create_staged_context_menu(self, menu, s):
817 if s.staged[0] in self._model.submodules:
818 return self._create_staged_submodule_context_menu(menu, s)
820 context = self.context
821 if self._model.is_unstageable():
822 action = menu.addAction(
823 icons.remove(),
824 N_('Unstage Selected'),
825 cmds.run(cmds.Unstage, context, self.staged()),
827 action.setShortcut(hotkeys.STAGE_SELECTION)
829 menu.addAction(self.launch_editor_action)
831 # Do all of the selected items exist?
832 all_exist = all(
833 i not in self._model.staged_deleted and core.exists(i)
834 for i in self.staged()
837 if all_exist:
838 menu.addAction(self.launch_difftool_action)
840 if self._model.is_undoable():
841 menu.addAction(self.revert_unstaged_edits_action)
843 menu.addAction(self.view_history_action)
844 menu.addAction(self.view_blame_action)
845 return menu
847 def _create_staged_submodule_context_menu(self, menu, s):
848 context = self.context
849 path = core.abspath(s.staged[0])
850 if len(self.staged()) == 1:
851 menu.addAction(
852 icons.cola(),
853 N_('Launch git-cola'),
854 cmds.run(cmds.OpenRepo, context, path),
856 menu.addSeparator()
857 action = menu.addAction(
858 icons.remove(),
859 N_('Unstage Selected'),
860 cmds.run(cmds.Unstage, context, self.staged()),
862 action.setShortcut(hotkeys.STAGE_SELECTION)
864 menu.addAction(self.view_history_action)
865 return menu
867 def _create_unmerged_context_menu(self, menu, _s):
868 context = self.context
869 menu.addAction(self.launch_difftool_action)
871 action = menu.addAction(
872 icons.add(),
873 N_('Stage Selected'),
874 cmds.run(cmds.Stage, context, self.unstaged()),
876 action.setShortcut(hotkeys.STAGE_SELECTION)
878 menu.addAction(self.launch_editor_action)
879 menu.addAction(self.view_history_action)
880 menu.addAction(self.view_blame_action)
881 menu.addSeparator()
882 menu.addAction(self.checkout_ours_action)
883 menu.addAction(self.checkout_theirs_action)
884 return menu
886 def _create_unstaged_context_menu(self, menu, s):
887 context = self.context
888 modified_submodule = s.modified and s.modified[0] in self._model.submodules
889 if modified_submodule:
890 return self._create_modified_submodule_context_menu(menu, s)
892 if self._model.is_stageable():
893 action = menu.addAction(
894 icons.add(),
895 N_('Stage Selected'),
896 cmds.run(cmds.Stage, context, self.unstaged()),
898 action.setShortcut(hotkeys.STAGE_SELECTION)
900 if not self.selection_model.is_empty():
901 menu.addAction(self.launch_editor_action)
903 # Do all of the selected items exist?
904 all_exist = all(
905 i not in self._model.unstaged_deleted and core.exists(i)
906 for i in self.staged()
909 if all_exist and s.modified and self._model.is_stageable():
910 menu.addAction(self.launch_difftool_action)
912 if s.modified and self._model.is_stageable() and self._model.is_undoable():
913 menu.addSeparator()
914 menu.addAction(self.revert_unstaged_edits_action)
916 if all_exist and s.untracked:
917 # Git Annex / Git LFS
918 annex = self._model.annex
919 lfs = core.find_executable('git-lfs')
920 if annex or lfs:
921 menu.addSeparator()
922 if annex:
923 menu.addAction(self.annex_add_action)
924 if lfs:
925 menu.addAction(self.lfs_track_action)
927 menu.addSeparator()
928 if self.move_to_trash_action is not None:
929 menu.addAction(self.move_to_trash_action)
930 menu.addAction(self.delete_untracked_files_action)
931 menu.addSeparator()
932 menu.addAction(
933 icons.edit(),
934 N_('Ignore...'),
935 partial(gitignore.gitignore_view, self.context),
938 if not self.selection_model.is_empty():
939 menu.addAction(self.view_history_action)
940 menu.addAction(self.view_blame_action)
941 return menu
943 def _create_modified_submodule_context_menu(self, menu, s):
944 context = self.context
945 path = core.abspath(s.modified[0])
946 if len(self.unstaged()) == 1:
947 menu.addAction(
948 icons.cola(),
949 N_('Launch git-cola'),
950 cmds.run(cmds.OpenRepo, context, path),
952 menu.addAction(
953 icons.pull(),
954 N_('Update this submodule'),
955 cmds.run(cmds.SubmoduleUpdate, context, path),
957 menu.addSeparator()
959 if self._model.is_stageable():
960 menu.addSeparator()
961 action = menu.addAction(
962 icons.add(),
963 N_('Stage Selected'),
964 cmds.run(cmds.Stage, context, self.unstaged()),
966 action.setShortcut(hotkeys.STAGE_SELECTION)
968 menu.addAction(self.view_history_action)
969 return menu
971 def _delete_untracked_files(self):
972 cmds.do(cmds.Delete, self.context, self.untracked())
974 def _trash_untracked_files(self):
975 cmds.do(cmds.MoveToTrash, self.context, self.untracked())
977 def selected_path(self):
978 s = self.single_selection()
979 return s.staged or s.unmerged or s.modified or s.untracked or None
981 def single_selection(self):
982 """Scan across staged, modified, etc. and return a single item."""
983 staged = None
984 unmerged = None
985 modified = None
986 untracked = None
988 s = self.selection()
989 if s.staged:
990 staged = s.staged[0]
991 elif s.unmerged:
992 unmerged = s.unmerged[0]
993 elif s.modified:
994 modified = s.modified[0]
995 elif s.untracked:
996 untracked = s.untracked[0]
998 return selection.State(staged, unmerged, modified, untracked)
1000 def selected_indexes(self):
1001 """Returns a list of (category, row) representing the tree selection."""
1002 selected = self.selectedIndexes()
1003 result = []
1004 for idx in selected:
1005 if idx.parent().isValid():
1006 parent_idx = idx.parent()
1007 entry = (parent_idx.row(), idx.row())
1008 else:
1009 entry = (HEADER_IDX, idx.row())
1010 result.append(entry)
1011 return result
1013 def selection(self):
1014 """Return the current selection in the repo status tree."""
1015 return selection.State(
1016 self.staged(), self.unmerged(), self.modified(), self.untracked()
1019 def contents(self):
1020 """Return all of the current files in a selection.State container"""
1021 return selection.State(
1022 self._model.staged,
1023 self._model.unmerged,
1024 self._model.modified,
1025 self._model.untracked,
1028 def all_files(self):
1029 """Return all of the current active files as a flast list"""
1030 c = self.contents()
1031 return c.staged + c.unmerged + c.modified + c.untracked
1033 def selected_group(self):
1034 """A list of selected files in various states of being"""
1035 return selection.pick(self.selection())
1037 def selected_idx(self):
1038 c = self.contents()
1039 s = self.single_selection()
1040 offset = 0
1041 for content, sel in zip(c, s):
1042 if not content:
1043 continue
1044 if sel is not None:
1045 return offset + content.index(sel)
1046 offset += len(content)
1047 return None
1049 def select_by_index(self, idx):
1050 c = self.contents()
1051 to_try = [
1052 (c.staged, STAGED_IDX),
1053 (c.unmerged, UNMERGED_IDX),
1054 (c.modified, MODIFIED_IDX),
1055 (c.untracked, UNTRACKED_IDX),
1057 for content, toplevel_idx in to_try:
1058 if not content:
1059 continue
1060 if idx < len(content):
1061 parent = self.topLevelItem(toplevel_idx)
1062 item = parent.child(idx)
1063 if item is not None:
1064 qtutils.select_item(self, item)
1065 return
1066 idx -= len(content)
1068 def staged(self):
1069 return qtutils.get_selected_values(self, STAGED_IDX, self._model.staged)
1071 def unstaged(self):
1072 return self.unmerged() + self.modified() + self.untracked()
1074 def modified(self):
1075 return qtutils.get_selected_values(self, MODIFIED_IDX, self._model.modified)
1077 def unmerged(self):
1078 return qtutils.get_selected_values(self, UNMERGED_IDX, self._model.unmerged)
1080 def untracked(self):
1081 return qtutils.get_selected_values(self, UNTRACKED_IDX, self._model.untracked)
1083 def staged_items(self):
1084 return qtutils.get_selected_items(self, STAGED_IDX)
1086 def unstaged_items(self):
1087 return self.unmerged_items() + self.modified_items() + self.untracked_items()
1089 def modified_items(self):
1090 return qtutils.get_selected_items(self, MODIFIED_IDX)
1092 def unmerged_items(self):
1093 return qtutils.get_selected_items(self, UNMERGED_IDX)
1095 def untracked_items(self):
1096 return qtutils.get_selected_items(self, UNTRACKED_IDX)
1098 def show_selection(self):
1099 """Show the selected item."""
1100 context = self.context
1101 qtutils.scroll_to_item(self, self.currentItem())
1102 # Sync the selection model
1103 selected = self.selection()
1104 selection_model = self.selection_model
1105 selection_model.set_selection(selected)
1106 self._update_actions(selected=selected)
1108 selected_indexes = self.selected_indexes()
1109 if not selected_indexes:
1110 if self._model.is_amend_mode() or self._model.is_diff_mode():
1111 cmds.do(cmds.SetDiffText, context, '')
1112 else:
1113 cmds.do(cmds.ResetMode, context)
1114 return
1116 # A header item e.g. 'Staged', 'Modified', etc.
1117 category, idx = selected_indexes[0]
1118 header = category == HEADER_IDX
1119 if header:
1120 cls = {
1121 STAGED_IDX: cmds.DiffStagedSummary,
1122 MODIFIED_IDX: cmds.Diffstat,
1123 UNMERGED_IDX: cmds.UnmergedSummary,
1124 UNTRACKED_IDX: cmds.UntrackedSummary,
1125 }.get(idx, cmds.Diffstat)
1126 cmds.do(cls, context)
1127 return
1129 staged = category == STAGED_IDX
1130 modified = category == MODIFIED_IDX
1131 unmerged = category == UNMERGED_IDX
1132 untracked = category == UNTRACKED_IDX
1134 if staged:
1135 item = self.staged_items()[0]
1136 elif unmerged:
1137 item = self.unmerged_items()[0]
1138 elif modified:
1139 item = self.modified_items()[0]
1140 elif untracked:
1141 item = self.unstaged_items()[0]
1142 else:
1143 item = None # this shouldn't happen
1144 assert item is not None
1146 path = item.path
1147 deleted = item.deleted
1148 image = self.image_formats.ok(path)
1150 # Update the diff text
1151 if staged:
1152 cmds.do(cmds.DiffStaged, context, path, deleted=deleted)
1153 elif modified:
1154 cmds.do(cmds.Diff, context, path, deleted=deleted)
1155 elif unmerged:
1156 cmds.do(cmds.Diff, context, path)
1157 elif untracked:
1158 cmds.do(cmds.ShowUntracked, context, path)
1160 # Images are diffed differently.
1161 # DiffImage transitions the diff mode to image.
1162 # DiffText transitions the diff mode to text.
1163 if image:
1164 cmds.do(
1165 cmds.DiffImage,
1166 context,
1167 path,
1168 deleted,
1169 staged,
1170 modified,
1171 unmerged,
1172 untracked,
1174 else:
1175 cmds.do(cmds.DiffText, context)
1177 def select_header(self):
1178 """Select an active header, which triggers a diffstat"""
1179 for idx in (
1180 STAGED_IDX,
1181 UNMERGED_IDX,
1182 MODIFIED_IDX,
1183 UNTRACKED_IDX,
1185 item = self.topLevelItem(idx)
1186 if item.childCount() > 0:
1187 self.clearSelection()
1188 self.setCurrentItem(item)
1189 return
1191 def move_up(self):
1192 """Select the item above the currently selected item"""
1193 idx = self.selected_idx()
1194 all_files = self.all_files()
1195 if idx is None:
1196 selected_indexes = self.selected_indexes()
1197 if selected_indexes:
1198 category, toplevel_idx = selected_indexes[0]
1199 if category == HEADER_IDX:
1200 item = self.itemAbove(self.topLevelItem(toplevel_idx))
1201 if item is not None:
1202 qtutils.select_item(self, item)
1203 return
1204 if all_files:
1205 self.select_by_index(len(all_files) - 1)
1206 return
1207 if idx - 1 >= 0:
1208 self.select_by_index(idx - 1)
1209 else:
1210 self.select_by_index(len(all_files) - 1)
1212 def move_down(self):
1213 """Select the item below the currently selected item"""
1214 idx = self.selected_idx()
1215 all_files = self.all_files()
1216 if idx is None:
1217 selected_indexes = self.selected_indexes()
1218 if selected_indexes:
1219 category, toplevel_idx = selected_indexes[0]
1220 if category == HEADER_IDX:
1221 item = self.itemBelow(self.topLevelItem(toplevel_idx))
1222 if item is not None:
1223 qtutils.select_item(self, item)
1224 return
1225 if all_files:
1226 self.select_by_index(0)
1227 return
1228 if idx + 1 < len(all_files):
1229 self.select_by_index(idx + 1)
1230 else:
1231 self.select_by_index(0)
1233 def mousePressEvent(self, event):
1234 """Keep track of whether to drag URLs or just text"""
1235 self._alt_drag = event.modifiers() & Qt.AltModifier
1236 return super().mousePressEvent(event)
1238 def mouseMoveEvent(self, event):
1239 """Keep track of whether to drag URLs or just text"""
1240 self._alt_drag = event.modifiers() & Qt.AltModifier
1241 return super().mouseMoveEvent(event)
1243 def mimeData(self, items):
1244 """Return a list of absolute-path URLs"""
1245 context = self.context
1246 paths = qtutils.paths_from_items(items, item_filter=_item_filter)
1247 include_urls = not self._alt_drag
1248 return qtutils.mimedata_from_paths(context, paths, include_urls=include_urls)
1250 def mimeTypes(self):
1251 """Return the mimetypes that this widget generates"""
1252 return qtutils.path_mimetypes(include_urls=not self._alt_drag)
1255 def _item_filter(item):
1256 """Filter items down to just those that exist on disk"""
1257 return not item.deleted and core.exists(item.path)
1260 def view_blame(context):
1261 """Signal that we should view blame for paths."""
1262 cmds.do(cmds.BlamePaths, context)
1265 def view_history(context):
1266 """Signal that we should view history for paths."""
1267 cmds.do(cmds.VisualizePaths, context, context.selection.union())
1270 def copy_path(context, absolute=True):
1271 """Copy a selected path to the clipboard"""
1272 filename = context.selection.filename()
1273 qtutils.copy_path(filename, absolute=absolute)
1276 def copy_relpath(context):
1277 """Copy a selected relative path to the clipboard"""
1278 copy_path(context, absolute=False)
1281 def copy_basename(context):
1282 filename = os.path.basename(context.selection.filename())
1283 basename, _ = os.path.splitext(filename)
1284 qtutils.copy_path(basename, absolute=False)
1287 def copy_leading_path(context, strip_components):
1288 """Peal off trailing path components and copy the current path to the clipboard"""
1289 filename = context.selection.filename()
1290 value = filename
1291 for _ in range(strip_components):
1292 value = os.path.dirname(value)
1293 qtutils.copy_path(value, absolute=False)
1296 def copy_format(context, fmt):
1297 """Add variables usable in the custom Copy format strings"""
1298 values = {}
1299 values['path'] = path = context.selection.filename()
1300 values['abspath'] = abspath = os.path.abspath(path)
1301 values['absdirname'] = os.path.dirname(abspath)
1302 values['dirname'] = os.path.dirname(path)
1303 values['filename'] = os.path.basename(path)
1304 values['basename'], values['ext'] = os.path.splitext(os.path.basename(path))
1305 qtutils.set_clipboard(fmt % values)
1308 def show_help(context):
1309 """Display the help for the custom Copy format strings"""
1310 help_text = N_(
1311 r"""
1312 Format String Variables
1313 -----------------------
1314 %(path)s = relative file path
1315 %(abspath)s = absolute file path
1316 %(dirname)s = relative directory path
1317 %(absdirname)s = absolute directory path
1318 %(filename)s = file basename
1319 %(basename)s = file basename without extension
1320 %(ext)s = file extension
1323 title = N_('Help - Custom Copy Actions')
1324 return text.text_dialog(context, help_text, title)
1327 class StatusFilterWidget(QtWidgets.QWidget):
1328 """Filter paths displayed by the Status tool"""
1330 def __init__(self, context, parent=None):
1331 QtWidgets.QWidget.__init__(self, parent)
1332 self.context = context
1334 hint = N_('Filter paths...')
1335 self.text = completion.GitStatusFilterLineEdit(context, hint=hint, parent=self)
1336 self.text.setToolTip(hint)
1337 self.setFocusProxy(self.text)
1338 self._filter = None
1340 self.main_layout = qtutils.hbox(defs.no_margin, defs.spacing, self.text)
1341 self.setLayout(self.main_layout)
1343 widget = self.text
1344 # pylint: disable=no-member
1345 widget.changed.connect(self.apply_filter)
1346 widget.cleared.connect(self.apply_filter)
1347 widget.enter.connect(self.apply_filter)
1348 widget.editingFinished.connect(self.apply_filter)
1350 def apply_filter(self):
1351 """Apply the text filter to the model"""
1352 value = get(self.text)
1353 if value == self._filter:
1354 return
1355 self._filter = value
1356 paths = utils.shell_split(value)
1357 self.context.model.update_path_filter(paths)
1360 def customize_copy_actions(context, parent):
1361 """Customize copy actions"""
1362 dialog = CustomizeCopyActions(context, parent)
1363 dialog.show()
1364 dialog.exec_()
1367 class CustomizeCopyActions(standard.Dialog):
1368 """A dialog for defining custom Copy actions and format strings"""
1370 def __init__(self, context, parent):
1371 standard.Dialog.__init__(self, parent=parent)
1372 self.setWindowTitle(N_('Custom Copy Actions'))
1374 self.context = context
1375 self.table = QtWidgets.QTableWidget(self)
1376 self.table.setColumnCount(2)
1377 self.table.setHorizontalHeaderLabels([N_('Action Name'), N_('Format String')])
1378 self.table.setSortingEnabled(False)
1379 self.table.verticalHeader().hide()
1380 self.table.horizontalHeader().setStretchLastSection(True)
1382 self.add_button = qtutils.create_button(N_('Add'))
1383 self.remove_button = qtutils.create_button(N_('Remove'))
1384 self.remove_button.setEnabled(False)
1385 self.show_help_button = qtutils.create_button(N_('Show Help'))
1386 self.show_help_button.setShortcut(hotkeys.QUESTION)
1388 self.close_button = qtutils.close_button()
1389 self.save_button = qtutils.ok_button(N_('Save'))
1391 self.buttons = qtutils.hbox(
1392 defs.no_margin,
1393 defs.button_spacing,
1394 self.add_button,
1395 self.remove_button,
1396 self.show_help_button,
1397 qtutils.STRETCH,
1398 self.close_button,
1399 self.save_button,
1402 layout = qtutils.vbox(defs.margin, defs.spacing, self.table, self.buttons)
1403 self.setLayout(layout)
1405 qtutils.connect_button(self.add_button, self.add)
1406 qtutils.connect_button(self.remove_button, self.remove)
1407 qtutils.connect_button(self.show_help_button, partial(show_help, context))
1408 qtutils.connect_button(self.close_button, self.reject)
1409 qtutils.connect_button(self.save_button, self.save)
1410 qtutils.add_close_action(self)
1411 # pylint: disable=no-member
1412 self.table.itemSelectionChanged.connect(self.table_selection_changed)
1414 self.init_size(parent=parent)
1416 QtCore.QTimer.singleShot(0, self.reload_settings)
1418 def reload_settings(self):
1419 """Update the view to match the current settings"""
1420 # Called once after the GUI is initialized
1421 settings = self.context.settings
1422 settings.load()
1423 table = self.table
1424 for entry in settings.copy_formats:
1425 name_string = entry.get('name', '')
1426 format_string = entry.get('format', '')
1427 if name_string and format_string:
1428 name = QtWidgets.QTableWidgetItem(name_string)
1429 fmt = QtWidgets.QTableWidgetItem(format_string)
1430 rows = table.rowCount()
1431 table.setRowCount(rows + 1)
1432 table.setItem(rows, 0, name)
1433 table.setItem(rows, 1, fmt)
1435 def export_state(self):
1436 """Export the current state into the saved settings"""
1437 state = super().export_state()
1438 standard.export_header_columns(self.table, state)
1439 return state
1441 def apply_state(self, state):
1442 """Restore state from the saved settings"""
1443 result = super().apply_state(state)
1444 standard.apply_header_columns(self.table, state)
1445 return result
1447 def add(self):
1448 """Add a custom Copy action and format string"""
1449 self.table.setFocus()
1450 rows = self.table.rowCount()
1451 self.table.setRowCount(rows + 1)
1453 name = QtWidgets.QTableWidgetItem(N_('Name'))
1454 fmt = QtWidgets.QTableWidgetItem(r'%(path)s')
1455 self.table.setItem(rows, 0, name)
1456 self.table.setItem(rows, 1, fmt)
1458 self.table.setCurrentCell(rows, 0)
1459 self.table.editItem(name)
1461 def remove(self):
1462 """Remove selected items"""
1463 # Gather a unique set of rows and remove them in reverse order
1464 rows = set()
1465 items = self.table.selectedItems()
1466 for item in items:
1467 rows.add(self.table.row(item))
1469 for row in reversed(sorted(rows)):
1470 self.table.removeRow(row)
1472 def save(self):
1473 """Save custom copy actions to the settings"""
1474 copy_formats = []
1475 for row in range(self.table.rowCount()):
1476 name = self.table.item(row, 0)
1477 fmt = self.table.item(row, 1)
1478 if name and fmt:
1479 entry = {
1480 'name': name.text(),
1481 'format': fmt.text(),
1483 copy_formats.append(entry)
1485 settings = self.context.settings
1486 while settings.copy_formats:
1487 settings.copy_formats.pop()
1489 settings.copy_formats.extend(copy_formats)
1490 settings.save()
1492 self.accept()
1494 def table_selection_changed(self):
1495 """Update the enabled state of action buttons based on the current selection"""
1496 items = self.table.selectedItems()
1497 self.remove_button.setEnabled(bool(items))
1500 def _select_item(widget, path_list, widget_getter, item, current=False):
1501 """Select the widget item based on the list index"""
1502 # The path lists and widget indexes have a 1:1 correspondence.
1503 # Lookup the item filename in the list and use that index to
1504 # retrieve the widget item and select it.
1505 idx = path_list.index(item)
1506 item = widget_getter(idx)
1507 if current:
1508 widget.setCurrentItem(item)
1509 item.setSelected(True)
1512 def _apply_toplevel_selection(widget, category, idx):
1513 """Select a top-level "header" item (ex: the Staged parent item)
1515 Return True when a top-level item is selected.
1517 is_top_level_item = category == HEADER_IDX
1518 if is_top_level_item:
1519 root_item = widget.invisibleRootItem()
1520 item = root_item.child(idx)
1522 if item is not None and item.childCount() == 0:
1523 # The item now has no children. Select a different top-level item
1524 # corresponding to the previously selected item.
1525 if idx == STAGED_IDX:
1526 # If "Staged" was previously selected try "Modified" and "Untracked".
1527 item = _get_first_item_with_children(
1528 root_item.child(MODIFIED_IDX), root_item.child(UNTRACKED_IDX)
1530 elif idx == UNMERGED_IDX:
1531 # If "Unmerged" was previously selected try "Staged".
1532 item = _get_first_item_with_children(root_item.child(STAGED_IDX))
1533 elif idx == MODIFIED_IDX:
1534 # If "Modified" was previously selected try "Staged" or "Untracked".
1535 item = _get_first_item_with_children(
1536 root_item.child(STAGED_IDX), root_item.child(UNTRACKED_IDX)
1538 elif idx == UNTRACKED_IDX:
1539 # If "Untracked" was previously selected try "Staged".
1540 item = _get_first_item_with_children(root_item.child(STAGED_IDX))
1542 if item is not None:
1543 with qtutils.BlockSignals(widget):
1544 widget.setCurrentItem(item)
1545 item.setSelected(True)
1546 widget.show_selection()
1547 return is_top_level_item
1550 def _get_first_item_with_children(*items):
1551 """Return the first item that contains child items"""
1552 for item in items:
1553 if item.childCount() > 0:
1554 return item
1555 return None
1558 def _transplant_selection_across_sections(
1559 category, idx, previous_contents, saved_selection
1561 """Transplant the selection to a different category"""
1562 # This function is used when the selection would otherwise become empty.
1563 # Apply heuristics to select the items based on the previous state.
1564 if not previous_contents:
1565 return
1566 staged, unmerged, modified, untracked = saved_selection
1567 prev_staged, prev_unmerged, prev_modified, prev_untracked = previous_contents
1569 # The current set of paths.
1570 staged_paths = staged[NEW_PATHS_IDX]
1571 unmerged_paths = unmerged[NEW_PATHS_IDX]
1572 modified_paths = modified[NEW_PATHS_IDX]
1573 untracked_paths = untracked[NEW_PATHS_IDX]
1575 # These callbacks select a path in the corresponding widget subtree lists.
1576 select_staged = staged[SELECT_FN_IDX]
1577 select_unmerged = unmerged[SELECT_FN_IDX]
1578 select_modified = modified[SELECT_FN_IDX]
1579 select_untracked = untracked[SELECT_FN_IDX]
1581 if category == STAGED_IDX:
1582 # Staged files can become Unmerged, Modified or Untracked.
1583 # If we previously had a staged file selected then try to select
1584 # it in either the Unmerged, Modified or Untracked sections.
1585 try:
1586 old_path = prev_staged[idx]
1587 except IndexError:
1588 return
1589 if old_path in unmerged_paths:
1590 select_unmerged(old_path, current=True)
1591 elif old_path in modified_paths:
1592 select_modified(old_path, current=True)
1593 elif old_path in untracked_paths:
1594 select_untracked(old_path, current=True)
1596 elif category == UNMERGED_IDX:
1597 # Unmerged files can become Staged, Modified or Untracked.
1598 # If we previously had an unmerged file selected then try to select it in
1599 # the Staged, Modified or Untracked sections.
1600 try:
1601 old_path = prev_unmerged[idx]
1602 except IndexError:
1603 return
1604 if old_path in staged_paths:
1605 select_staged(old_path, current=True)
1606 elif old_path in modified_paths:
1607 select_modified(old_path, current=True)
1608 elif old_path in untracked_paths:
1609 select_untracked(old_path, current=True)
1611 elif category == MODIFIED_IDX:
1612 # If we previously had a modified file selected then try to select
1613 # it in either the Staged or Untracked sections.
1614 try:
1615 old_path = prev_modified[idx]
1616 except IndexError:
1617 return
1618 if old_path in staged_paths:
1619 select_staged(old_path, current=True)
1620 elif old_path in untracked_paths:
1621 select_untracked(old_path, current=True)
1623 elif category == UNTRACKED_IDX:
1624 # If we previously had an untracked file selected then try to select
1625 # it in the Modified or Staged section. Modified is less common, but
1626 # it's possible for a file to be untracked and then the user adds and
1627 # modifies the file before we've refreshed our state.
1628 try:
1629 old_path = prev_untracked[idx]
1630 except IndexError:
1631 return
1632 if old_path in modified_paths:
1633 select_modified(old_path, current=True)
1634 elif old_path in staged_paths:
1635 select_staged(old_path, current=True)
1638 class CopyLeadingPathWidget(QtWidgets.QWidget):
1639 """A widget that holds a label and a spinbox for the number of paths to strip"""
1641 def __init__(self, title, context, parent):
1642 QtWidgets.QWidget.__init__(self, parent)
1643 self.context = context
1644 self.icon = QtWidgets.QLabel(self)
1645 self.label = QtWidgets.QLabel(self)
1646 self.spinbox = standard.SpinBox(value=1, mini=1, maxi=99, parent=self)
1647 self.spinbox.setToolTip(N_('The number of leading paths to strip'))
1649 icon = icons.copy()
1650 pixmap = icon.pixmap(defs.default_icon, defs.default_icon)
1651 self.icon.setPixmap(pixmap)
1652 self.label.setText(title)
1654 layout = qtutils.hbox(
1655 defs.small_margin,
1656 defs.titlebar_spacing,
1657 self.icon,
1658 self.label,
1659 qtutils.STRETCH,
1660 self.spinbox,
1662 self.setLayout(layout)
1664 theme = context.app.theme
1665 highlight_rgb = theme.highlight_color_rgb()
1666 text_rgb, highlight_text_rgb = theme.text_colors_rgb()
1667 disabled_text_rgb = theme.disabled_text_color_rgb()
1669 stylesheet = """
1670 * {{
1671 show-decoration-selected: 1
1673 QLabel {{
1674 color: {text_rgb};
1675 show-decoration-selected: 1
1677 QLabel:hover {{
1678 color: {highlight_text_rgb};
1679 background-color: {highlight_rgb};
1680 background-clip: padding;
1681 show-decoration-selected: 1
1683 QLabel:disabled {{
1684 color: {disabled_text_rgb};
1686 """.format(
1687 disabled_text_rgb=disabled_text_rgb,
1688 text_rgb=text_rgb,
1689 highlight_text_rgb=highlight_text_rgb,
1690 highlight_rgb=highlight_rgb,
1693 self.setStyleSheet(stylesheet)
1695 def value(self):
1696 """Return the current value of the spinbox"""
1697 return self.spinbox.value()
1699 def set_value(self, value):
1700 """Set the spinbox value"""
1701 self.spinbox.setValue(value)