resources: remove unnecessary os.path.dirname import
[git-cola.git] / cola / widgets / status.py
blob0345cf3726e8f5a88a6dbc3606a26c00fbf6626a
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 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)
152 ok_icon = icons.ok()
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(
173 self,
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(
181 self,
182 cmds.StageOrUnstageAll.name(),
183 cmds.run(cmds.StageOrUnstageAll, self.context),
184 hotkeys.STAGE_ALL,
186 self.stage_or_unstage_all_action.setIcon(icons.add())
188 self.revert_unstaged_edits_action = qtutils.add_action(
189 self,
190 cmds.RevertUnstagedEdits.name(),
191 cmds.run(cmds.RevertUnstagedEdits, context),
192 hotkeys.REVERT,
193 hotkeys.REVERT_ALT,
195 self.revert_unstaged_edits_action.setIcon(icons.undo())
197 self.launch_difftool_action = qtutils.add_action(
198 self,
199 difftool.LaunchDifftool.name(),
200 cmds.run(difftool.LaunchDifftool, context),
201 hotkeys.DIFF,
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(
224 self,
225 N_('Move Up'),
226 self.move_up,
227 hotkeys.MOVE_UP,
228 hotkeys.MOVE_UP_SECONDARY,
231 self.down_action = qtutils.add_action(
232 self,
233 N_('Move Down'),
234 self.move_down,
235 hotkeys.MOVE_DOWN,
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(
250 self,
251 N_('Copy Path to Clipboard'),
252 partial(copy_path, context),
253 hotkeys.COPY,
255 self.copy_path_action.setIcon(icons.copy())
257 self.copy_relpath_action = qtutils.add_action(
258 self,
259 N_('Copy Relative Path to Clipboard'),
260 partial(copy_relpath, context),
261 hotkeys.CUT,
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(
299 self,
300 N_('Move files to trash'),
301 self._trash_untracked_files,
302 hotkeys.TRASH,
304 self.move_to_trash_action.setIcon(icons.discard())
305 delete_shortcut = hotkeys.DELETE_FILE
306 else:
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()
335 if item:
336 qtutils.scroll_to_item(self, item)
338 def _add_toplevel_item(self, txt, icon, hide=False):
339 context = self.context
340 font = self.font()
341 if prefs.bold_headers(context):
342 font.setBold(True)
343 else:
344 font.setItalic(True)
346 item = QtWidgets.QTreeWidgetItem(self)
347 item.setFont(0, font)
348 item.setText(0, txt)
349 item.setIcon(0, icon)
350 if prefs.bold_headers(context):
351 item.setBackground(0, self.palette().midlight())
352 if hide:
353 item.setHidden(True)
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:
362 return
363 # The old set of categorized files.
364 old_c = self.old_contents
365 # The old selection.
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
381 saved_selection = [
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),
387 old_c.untracked,
388 set(old_s.untracked),
389 select_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):
397 return
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]
403 try:
404 item = old[idx]
405 except IndexError:
406 item = None
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.
418 did_reselect = False
420 with qtutils.BlockSignals(self):
421 for new, old, sel, reselect in saved_selection:
422 for item in sel:
423 if item in new:
424 reselect(item, current=False)
425 did_reselect = True
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.
445 continue
446 for item in sel:
447 # The item still exists so ignore it
448 if item in new or item not in old:
449 continue
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])):
454 if j in new:
455 reselect(j, current=True)
456 return
458 # If we already reselected stuff then there's nothing more to do.
459 if did_reselect:
460 return
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)
480 if is_header:
481 is_staged = any(
482 idx == STAGED_IDX and category == HEADER_IDX
483 for (category, idx) in selected_indexes
485 is_modified = any(
486 idx == MODIFIED_IDX and category == HEADER_IDX
487 for (category, idx) in selected_indexes
489 is_untracked = any(
490 idx == UNTRACKED_IDX and category == HEADER_IDX
491 for (category, idx) in selected_indexes
493 # A header item: 'Staged', 'Modified' or 'Untracked'.
494 if is_staged:
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
502 # stage everything.
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.
509 if is_modified:
510 cmds.do(cmds.StageModified, context)
511 elif is_untracked:
512 cmds.do(cmds.StageUntracked, context)
513 else:
514 # Do nothing for unmerged items, by design
515 pass
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):
532 # is it modified?
533 item = self.topLevelItem(MODIFIED_IDX)
534 count = item.childCount()
535 if itemidx < count:
536 return item.child(itemidx)
537 # is it unmerged?
538 item = self.topLevelItem(UNMERGED_IDX)
539 count += item.childCount()
540 if itemidx < count:
541 return item.child(itemidx)
542 # is it untracked?
543 item = self.topLevelItem(UNTRACKED_IDX)
544 count += item.childCount()
545 if itemidx < count:
546 return item.child(itemidx)
547 # Nope..
548 return None
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()
572 if not s:
573 return None
574 current = self.currentItem()
575 if not current:
576 return None
577 idx = self.indexFromItem(current)
578 if idx.parent().isValid():
579 parent_idx = idx.parent()
580 entry = (parent_idx.row(), idx.row())
581 else:
582 entry = (HEADER_IDX, idx.row())
583 return entry
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()
590 def refresh(self):
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):
601 if selected is 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):
616 self._set_subtree(
617 items,
618 STAGED_IDX,
619 N_('Staged'),
620 staged=True,
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):
627 self._set_subtree(
628 items,
629 MODIFIED_IDX,
630 N_('Modified'),
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):
638 self._set_subtree(
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)
647 def _set_subtree(
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:
658 pass
660 for item in items:
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.
679 if not items:
680 return
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:
684 return
685 self.expanded_items.add(idx)
686 item = self.topLevelItem(idx)
687 if item:
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()
700 if 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)
706 if sel.staged:
707 self._create_staged_context_menu(menu, sel)
708 elif sel.unmerged:
709 self._create_unmerged_context_menu(menu, sel)
710 else:
711 self._create_unstaged_context_menu(menu, sel)
713 if not menu.isEmpty():
714 menu.addSeparator()
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)
727 return 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()),
760 menu.addSeparator()
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
769 if 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', '')
776 if name and fmt:
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:
787 menu.addAction(
788 icons.remove(), N_('Unstage All'), cmds.run(cmds.UnstageAll, context)
790 elif idx == UNMERGED_IDX:
791 action = menu.addAction(
792 icons.add(),
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(
799 icons.add(),
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(
806 icons.add(),
807 cmds.StageUntracked.name(),
808 cmds.run(cmds.StageUntracked, context),
810 action.setShortcut(hotkeys.STAGE_SELECTION)
811 return menu
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(
820 icons.remove(),
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?
829 all_exist = all(
830 i not in self._model.staged_deleted and core.exists(i)
831 for i in self.staged()
834 if all_exist:
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)
842 return menu
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:
848 menu.addAction(
849 icons.cola(),
850 N_('Launch git-cola'),
851 cmds.run(cmds.OpenRepo, context, path),
853 menu.addSeparator()
854 action = menu.addAction(
855 icons.remove(),
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)
862 return menu
864 def _create_unmerged_context_menu(self, menu, _s):
865 context = self.context
866 menu.addAction(self.launch_difftool_action)
868 action = menu.addAction(
869 icons.add(),
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)
878 menu.addSeparator()
879 menu.addAction(self.checkout_ours_action)
880 menu.addAction(self.checkout_theirs_action)
881 return menu
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(
891 icons.add(),
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?
901 all_exist = all(
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():
910 menu.addSeparator()
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')
917 if annex or lfs:
918 menu.addSeparator()
919 if annex:
920 menu.addAction(self.annex_add_action)
921 if lfs:
922 menu.addAction(self.lfs_track_action)
924 menu.addSeparator()
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)
928 menu.addSeparator()
929 menu.addAction(
930 icons.edit(),
931 N_('Ignore...'),
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)
938 return menu
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:
944 menu.addAction(
945 icons.cola(),
946 N_('Launch git-cola'),
947 cmds.run(cmds.OpenRepo, context, path),
949 menu.addAction(
950 icons.pull(),
951 N_('Update this submodule'),
952 cmds.run(cmds.SubmoduleUpdate, context, path),
954 menu.addSeparator()
956 if self._model.is_stageable():
957 menu.addSeparator()
958 action = menu.addAction(
959 icons.add(),
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)
966 return menu
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."""
980 staged = None
981 unmerged = None
982 modified = None
983 untracked = None
985 s = self.selection()
986 if s.staged:
987 staged = s.staged[0]
988 elif s.unmerged:
989 unmerged = s.unmerged[0]
990 elif s.modified:
991 modified = s.modified[0]
992 elif s.untracked:
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()
1000 result = []
1001 for idx in selected:
1002 if idx.parent().isValid():
1003 parent_idx = idx.parent()
1004 entry = (parent_idx.row(), idx.row())
1005 else:
1006 entry = (HEADER_IDX, idx.row())
1007 result.append(entry)
1008 return result
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()
1016 def contents(self):
1017 """Return all of the current files in a selection.State container"""
1018 return selection.State(
1019 self._model.staged,
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"""
1027 c = self.contents()
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):
1035 c = self.contents()
1036 s = self.single_selection()
1037 offset = 0
1038 for content, sel in zip(c, s):
1039 if not content:
1040 continue
1041 if sel is not None:
1042 return offset + content.index(sel)
1043 offset += len(content)
1044 return None
1046 def select_by_index(self, idx):
1047 c = self.contents()
1048 to_try = [
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:
1055 if not content:
1056 continue
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)
1062 return
1063 idx -= len(content)
1065 def staged(self):
1066 return qtutils.get_selected_values(self, STAGED_IDX, self._model.staged)
1068 def unstaged(self):
1069 return self.unmerged() + self.modified() + self.untracked()
1071 def modified(self):
1072 return qtutils.get_selected_values(self, MODIFIED_IDX, self._model.modified)
1074 def unmerged(self):
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, '')
1109 else:
1110 cmds.do(cmds.ResetMode, context)
1111 return
1113 # A header item e.g. 'Staged', 'Modified', etc.
1114 category, idx = selected_indexes[0]
1115 header = category == HEADER_IDX
1116 if header:
1117 cls = {
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)
1124 return
1126 staged = category == STAGED_IDX
1127 modified = category == MODIFIED_IDX
1128 unmerged = category == UNMERGED_IDX
1129 untracked = category == UNTRACKED_IDX
1131 if staged:
1132 item = self.staged_items()[0]
1133 elif unmerged:
1134 item = self.unmerged_items()[0]
1135 elif modified:
1136 item = self.modified_items()[0]
1137 elif untracked:
1138 item = self.unstaged_items()[0]
1139 else:
1140 item = None # this shouldn't happen
1141 assert item is not None
1143 path = item.path
1144 deleted = item.deleted
1145 image = self.image_formats.ok(path)
1147 # Update the diff text
1148 if staged:
1149 cmds.do(cmds.DiffStaged, context, path, deleted=deleted)
1150 elif modified:
1151 cmds.do(cmds.Diff, context, path, deleted=deleted)
1152 elif unmerged:
1153 cmds.do(cmds.Diff, context, path)
1154 elif untracked:
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.
1160 if image:
1161 cmds.do(
1162 cmds.DiffImage,
1163 context,
1164 path,
1165 deleted,
1166 staged,
1167 modified,
1168 unmerged,
1169 untracked,
1171 else:
1172 cmds.do(cmds.DiffText, context)
1174 def select_header(self):
1175 """Select an active header, which triggers a diffstat"""
1176 for idx in (
1177 STAGED_IDX,
1178 UNMERGED_IDX,
1179 MODIFIED_IDX,
1180 UNTRACKED_IDX,
1182 item = self.topLevelItem(idx)
1183 if item.childCount() > 0:
1184 self.clearSelection()
1185 self.setCurrentItem(item)
1186 return
1188 def move_up(self):
1189 """Select the item above the currently selected item"""
1190 idx = self.selected_idx()
1191 all_files = self.all_files()
1192 if idx is None:
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)
1200 return
1201 if all_files:
1202 self.select_by_index(len(all_files) - 1)
1203 return
1204 if idx - 1 >= 0:
1205 self.select_by_index(idx - 1)
1206 else:
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()
1213 if idx is None:
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)
1221 return
1222 if all_files:
1223 self.select_by_index(0)
1224 return
1225 if idx + 1 < len(all_files):
1226 self.select_by_index(idx + 1)
1227 else:
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()
1287 value = 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"""
1295 values = {}
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"""
1307 help_text = N_(
1308 r"""
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)
1335 self._filter = None
1337 self.main_layout = qtutils.hbox(defs.no_margin, defs.spacing, self.text)
1338 self.setLayout(self.main_layout)
1340 widget = self.text
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:
1350 return
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)
1359 dialog.show()
1360 dialog.exec_()
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(
1388 defs.no_margin,
1389 defs.button_spacing,
1390 self.add_button,
1391 self.remove_button,
1392 self.show_help_button,
1393 qtutils.STRETCH,
1394 self.close_button,
1395 self.save_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
1417 settings.load()
1418 table = self.table
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)
1434 return 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)
1440 return result
1442 def add(self):
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)
1456 def remove(self):
1457 """Remove selected items"""
1458 # Gather a unique set of rows and remove them in reverse order
1459 rows = set()
1460 items = self.table.selectedItems()
1461 for item in items:
1462 rows.add(self.table.row(item))
1464 for row in reversed(sorted(rows)):
1465 self.table.removeRow(row)
1467 def save(self):
1468 """Save custom copy actions to the settings"""
1469 copy_formats = []
1470 for row in range(self.table.rowCount()):
1471 name = self.table.item(row, 0)
1472 fmt = self.table.item(row, 1)
1473 if name and fmt:
1474 entry = {
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)
1485 settings.save()
1487 self.accept()
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)
1502 if current:
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"""
1547 for item in items:
1548 if item.childCount() > 0:
1549 return item
1550 return None
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:
1560 return
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.
1580 try:
1581 old_path = prev_staged[idx]
1582 except IndexError:
1583 return
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.
1595 try:
1596 old_path = prev_unmerged[idx]
1597 except IndexError:
1598 return
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.
1609 try:
1610 old_path = prev_modified[idx]
1611 except IndexError:
1612 return
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.
1623 try:
1624 old_path = prev_untracked[idx]
1625 except IndexError:
1626 return
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'))
1644 icon = icons.copy()
1645 pixmap = icon.pixmap(defs.default_icon, defs.default_icon)
1646 self.icon.setPixmap(pixmap)
1647 self.label.setText(title)
1649 layout = qtutils.hbox(
1650 defs.small_margin,
1651 defs.titlebar_spacing,
1652 self.icon,
1653 self.label,
1654 qtutils.STRETCH,
1655 self.spinbox,
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()
1664 stylesheet = """
1665 * {{
1666 show-decoration-selected: 1
1668 QLabel {{
1669 color: {text_rgb};
1670 show-decoration-selected: 1
1672 QLabel:hover {{
1673 color: {highlight_text_rgb};
1674 background-color: {highlight_rgb};
1675 background-clip: padding;
1676 show-decoration-selected: 1
1678 QLabel:disabled {{
1679 color: {disabled_text_rgb};
1681 """.format(
1682 disabled_text_rgb=disabled_text_rgb,
1683 text_rgb=text_rgb,
1684 highlight_text_rgb=highlight_text_rgb,
1685 highlight_rgb=highlight_rgb,
1688 self.setStyleSheet(stylesheet)
1690 def value(self):
1691 """Return the current value of the spin-box"""
1692 return self.spinbox.value()
1694 def set_value(self, value):
1695 """Set the spin-box value"""
1696 self.spinbox.setValue(value)