pylint: disable too-many-ancestors and no-member warnings
[git-cola.git] / cola / widgets / status.py
blob5c8287b7d70208ab841cf51ee14d5d5f3177a595
1 from __future__ import division, absolute_import, unicode_literals
2 import itertools
3 import os
4 from functools import partial
6 from qtpy.QtCore import Qt
7 from qtpy.QtCore import Signal
8 from qtpy import QtCore
9 from qtpy import QtWidgets
11 from ..i18n import N_
12 from ..models import prefs
13 from ..models import selection
14 from ..widgets import gitignore
15 from ..widgets import standard
16 from ..qtutils import get
17 from .. import actions
18 from .. import cmds
19 from .. import core
20 from .. import hotkeys
21 from .. import icons
22 from .. import qtutils
23 from .. import settings
24 from .. import utils
25 from . import common
26 from . import completion
27 from . import defs
28 from . import text
31 class StatusWidget(QtWidgets.QFrame):
32 """
33 Provides a git-status-like repository widget.
35 This widget observes the main model and broadcasts
36 Qt signals.
38 """
40 def __init__(self, context, titlebar, parent):
41 QtWidgets.QFrame.__init__(self, parent)
42 self.context = context
44 tooltip = N_('Toggle the paths filter')
45 icon = icons.ellipsis()
46 self.filter_button = qtutils.create_action_button(tooltip=tooltip,
47 icon=icon)
48 self.filter_widget = StatusFilterWidget(context)
49 self.filter_widget.hide()
50 self.tree = StatusTreeWidget(context, parent=self)
51 self.setFocusProxy(self.tree)
53 self.main_layout = qtutils.vbox(defs.no_margin, defs.no_spacing,
54 self.filter_widget, self.tree)
55 self.setLayout(self.main_layout)
57 self.toggle_action = qtutils.add_action(
58 self, tooltip, self.toggle_filter, hotkeys.FILTER)
60 titlebar.add_corner_widget(self.filter_button)
61 qtutils.connect_button(self.filter_button, self.toggle_filter)
63 def toggle_filter(self):
64 shown = not self.filter_widget.isVisible()
65 self.filter_widget.setVisible(shown)
66 if shown:
67 self.filter_widget.setFocus()
68 else:
69 self.tree.setFocus()
71 def set_initial_size(self):
72 self.setMaximumWidth(222)
73 QtCore.QTimer.singleShot(
74 1, lambda: self.setMaximumWidth(2 ** 13))
76 def refresh(self):
77 self.tree.show_selection()
79 def set_filter(self, txt):
80 self.filter_widget.setVisible(True)
81 self.filter_widget.text.set_value(txt)
82 self.filter_widget.apply_filter()
84 def move_up(self):
85 self.tree.move_up()
87 def move_down(self):
88 self.tree.move_down()
90 def select_header(self):
91 self.tree.select_header()
94 # pylint: disable=too-many-ancestors
95 class StatusTreeWidget(QtWidgets.QTreeWidget):
96 # Signals
97 about_to_update = Signal()
98 updated = Signal()
99 diff_text_changed = Signal()
101 # Item categories
102 idx_header = -1
103 idx_staged = 0
104 idx_unmerged = 1
105 idx_modified = 2
106 idx_untracked = 3
107 idx_end = 4
109 # Read-only access to the mode state
110 mode = property(lambda self: self.m.mode)
112 def __init__(self, context, parent=None):
113 QtWidgets.QTreeWidget.__init__(self, parent)
114 self.context = context
115 self.selection_model = context.selection
117 self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
118 self.headerItem().setHidden(True)
119 self.setAllColumnsShowFocus(True)
120 self.setSortingEnabled(False)
121 self.setUniformRowHeights(True)
122 self.setAnimated(True)
123 self.setRootIsDecorated(False)
124 self.setDragEnabled(True)
125 self.setAutoScroll(False)
127 if not prefs.status_indent(context):
128 self.setIndentation(0)
130 ok = icons.ok()
131 compare = icons.compare()
132 question = icons.question()
133 self._add_toplevel_item(N_('Staged'), ok, hide=True)
134 self._add_toplevel_item(N_('Unmerged'), compare, hide=True)
135 self._add_toplevel_item(N_('Modified'), compare, hide=True)
136 self._add_toplevel_item(N_('Untracked'), question, hide=True)
138 # Used to restore the selection
139 self.old_vscroll = None
140 self.old_hscroll = None
141 self.old_selection = None
142 self.old_contents = None
143 self.old_current_item = None
144 self.was_visible = True
145 self.expanded_items = set()
147 self.image_formats = qtutils.ImageFormats()
149 self.process_selection_action = qtutils.add_action(
150 self, cmds.StageOrUnstage.name(), self._stage_selection,
151 hotkeys.STAGE_SELECTION)
153 self.revert_unstaged_edits_action = qtutils.add_action(
154 self, cmds.RevertUnstagedEdits.name(),
155 cmds.run(cmds.RevertUnstagedEdits, context), hotkeys.REVERT)
156 self.revert_unstaged_edits_action.setIcon(icons.undo())
158 self.launch_difftool_action = qtutils.add_action(
159 self, cmds.LaunchDifftool.name(),
160 cmds.run(cmds.LaunchDifftool, context), hotkeys.DIFF)
161 self.launch_difftool_action.setIcon(icons.diff())
163 self.launch_editor_action = actions.launch_editor(
164 context, self, *hotkeys.ACCEPT)
166 if not utils.is_win32():
167 self.default_app_action = common.default_app_action(
168 context, self, self.selected_group)
170 self.parent_dir_action = common.parent_dir_action(
171 context, self, self.selected_group)
173 self.terminal_action = common.terminal_action(
174 context, self, self.selected_group)
176 self.up_action = qtutils.add_action(
177 self, N_('Move Up'), self.move_up,
178 hotkeys.MOVE_UP, hotkeys.MOVE_UP_SECONDARY)
180 self.down_action = qtutils.add_action(
181 self, N_('Move Down'), self.move_down,
182 hotkeys.MOVE_DOWN, hotkeys.MOVE_DOWN_SECONDARY)
184 self.copy_path_action = qtutils.add_action(
185 self, N_('Copy Path to Clipboard'),
186 partial(copy_path, context), hotkeys.COPY)
187 self.copy_path_action.setIcon(icons.copy())
189 self.copy_relpath_action = qtutils.add_action(
190 self, N_('Copy Relative Path to Clipboard'),
191 partial(copy_relpath, context), hotkeys.CUT)
192 self.copy_relpath_action.setIcon(icons.copy())
194 self.copy_leading_path_action = qtutils.add_action(
195 self, N_('Copy Leading Path to Clipboard'),
196 partial(copy_leading_path, context))
197 self.copy_leading_path_action.setIcon(icons.copy())
199 self.copy_basename_action = qtutils.add_action(
200 self, N_('Copy Basename to Clipboard'),
201 partial(copy_basename, context))
202 self.copy_basename_action.setIcon(icons.copy())
204 self.copy_customize_action = qtutils.add_action(
205 self, N_('Customize...'),
206 partial(customize_copy_actions, context, self))
207 self.copy_customize_action.setIcon(icons.configure())
209 self.view_history_action = qtutils.add_action(
210 self, N_('View History...'), partial(view_history, context),
211 hotkeys.HISTORY)
213 self.view_blame_action = qtutils.add_action(
214 self, N_('Blame...'),
215 partial(view_blame, context), hotkeys.BLAME)
217 self.annex_add_action = qtutils.add_action(
218 self, N_('Add to Git Annex'), cmds.run(cmds.AnnexAdd, context))
220 self.lfs_track_action = qtutils.add_action(
221 self, N_('Add to Git LFS'), cmds.run(cmds.LFSTrack, context))
223 # MoveToTrash and Delete use the same shortcut.
224 # We will only bind one of them, depending on whether or not the
225 # MoveToTrash command is available. When available, the hotkey
226 # is bound to MoveToTrash, otherwise it is bound to Delete.
227 if cmds.MoveToTrash.AVAILABLE:
228 self.move_to_trash_action = qtutils.add_action(
229 self, N_('Move files to trash'),
230 self._trash_untracked_files, hotkeys.TRASH)
231 self.move_to_trash_action.setIcon(icons.discard())
232 delete_shortcut = hotkeys.DELETE_FILE
233 else:
234 self.move_to_trash_action = None
235 delete_shortcut = hotkeys.DELETE_FILE_SECONDARY
237 self.delete_untracked_files_action = qtutils.add_action(
238 self, N_('Delete Files...'),
239 self._delete_untracked_files, delete_shortcut)
240 self.delete_untracked_files_action.setIcon(icons.discard())
242 about_to_update = self._about_to_update
243 self.about_to_update.connect(about_to_update, type=Qt.QueuedConnection)
244 self.updated.connect(self.refresh, type=Qt.QueuedConnection)
245 self.diff_text_changed.connect(
246 self._make_current_item_visible, type=Qt.QueuedConnection)
248 self.m = context.model
249 self.m.add_observer(self.m.message_about_to_update,
250 self.about_to_update.emit)
251 self.m.add_observer(self.m.message_updated, self.updated.emit)
252 self.m.add_observer(self.m.message_diff_text_changed,
253 self.diff_text_changed.emit)
254 # pylint: disable=no-member
255 self.itemSelectionChanged.connect(self.show_selection)
256 self.itemDoubleClicked.connect(self._double_clicked)
257 self.itemCollapsed.connect(lambda x: self._update_column_widths())
258 self.itemExpanded.connect(lambda x: self._update_column_widths())
260 def _make_current_item_visible(self):
261 item = self.currentItem()
262 if item:
263 self.scroll_to_item(item)
265 def _add_toplevel_item(self, txt, icon, hide=False):
266 context = self.context
267 font = self.font()
268 if prefs.bold_headers(context):
269 font.setBold(True)
270 else:
271 font.setItalic(True)
273 item = QtWidgets.QTreeWidgetItem(self)
274 item.setFont(0, font)
275 item.setText(0, txt)
276 item.setIcon(0, icon)
277 if prefs.bold_headers(context):
278 item.setBackground(0, self.palette().midlight())
279 if hide:
280 item.setHidden(True)
282 def _restore_selection(self):
283 if not self.old_selection or not self.old_contents:
284 return
285 old_c = self.old_contents
286 old_s = self.old_selection
287 new_c = self.contents()
289 def mkselect(lst, widget_getter):
290 def select(item, current=False):
291 idx = lst.index(item)
292 item = widget_getter(idx)
293 if current:
294 self.setCurrentItem(item)
295 item.setSelected(True)
296 return select
298 select_staged = mkselect(new_c.staged, self._staged_item)
299 select_unmerged = mkselect(new_c.unmerged, self._unmerged_item)
300 select_modified = mkselect(new_c.modified, self._modified_item)
301 select_untracked = mkselect(new_c.untracked, self._untracked_item)
303 saved_selection = [
304 (set(new_c.staged), old_c.staged, set(old_s.staged),
305 select_staged),
307 (set(new_c.unmerged), old_c.unmerged, set(old_s.unmerged),
308 select_unmerged),
310 (set(new_c.modified), old_c.modified, set(old_s.modified),
311 select_modified),
313 (set(new_c.untracked), old_c.untracked, set(old_s.untracked),
314 select_untracked),
317 # Restore the current item
318 if self.old_current_item:
319 category, idx = self.old_current_item
320 if category == self.idx_header:
321 item = self.invisibleRootItem().child(idx)
322 if item is not None:
323 self.blockSignals(True)
324 self.setCurrentItem(item)
325 item.setSelected(True)
326 self.blockSignals(False)
327 self.show_selection()
328 return
329 # Reselect the current item
330 selection_info = saved_selection[category]
331 new = selection_info[0]
332 old = selection_info[1]
333 reselect = selection_info[3]
334 try:
335 item = old[idx]
336 except IndexError:
337 return
338 if item in new:
339 reselect(item, current=True)
341 # Restore selection
342 # When reselecting we only care that the items are selected;
343 # we do not need to rerun the callbacks which were triggered
344 # above. Block signals to skip the callbacks.
345 self.blockSignals(True)
346 for (new, old, sel, reselect) in saved_selection:
347 for item in sel:
348 if item in new:
349 reselect(item, current=False)
350 self.blockSignals(False)
352 for (new, old, sel, reselect) in saved_selection:
353 # When modified is staged, select the next modified item
354 # When unmerged is staged, select the next unmerged item
355 # When unstaging, select the next staged item
356 # When staging untracked files, select the next untracked item
357 if len(new) >= len(old):
358 # The list did not shrink so it is not one of these cases.
359 continue
360 for item in sel:
361 # The item still exists so ignore it
362 if item in new or item not in old:
363 continue
364 # The item no longer exists in this list so search for
365 # its nearest neighbors and select them instead.
366 idx = old.index(item)
367 for j in itertools.chain(old[idx+1:], reversed(old[:idx])):
368 if j in new:
369 reselect(j, current=True)
370 return
372 def _restore_scrollbars(self):
373 vscroll = self.verticalScrollBar()
374 if vscroll and self.old_vscroll is not None:
375 vscroll.setValue(self.old_vscroll)
376 self.old_vscroll = None
378 hscroll = self.horizontalScrollBar()
379 if hscroll and self.old_hscroll is not None:
380 hscroll.setValue(self.old_hscroll)
381 self.old_hscroll = None
383 def _stage_selection(self):
384 """Stage or unstage files according to the selection"""
385 context = self.context
386 selected_indexes = self.selected_indexes()
387 if selected_indexes:
388 category, idx = selected_indexes[0]
389 # A header item e.g. 'Staged', 'Modified', etc.
390 if category == self.idx_header:
391 if idx == self.idx_staged:
392 cmds.do(cmds.UnstageAll, context)
393 elif idx == self.idx_modified:
394 cmds.do(cmds.StageModified, context)
395 elif idx == self.idx_untracked:
396 cmds.do(cmds.StageUntracked, context)
397 else:
398 pass # Do nothing for unmerged items, by design
399 return
400 cmds.do(cmds.StageOrUnstage, context)
402 def _staged_item(self, itemidx):
403 return self._subtree_item(self.idx_staged, itemidx)
405 def _modified_item(self, itemidx):
406 return self._subtree_item(self.idx_modified, itemidx)
408 def _unmerged_item(self, itemidx):
409 return self._subtree_item(self.idx_unmerged, itemidx)
411 def _untracked_item(self, itemidx):
412 return self._subtree_item(self.idx_untracked, itemidx)
414 def _unstaged_item(self, itemidx):
415 # is it modified?
416 item = self.topLevelItem(self.idx_modified)
417 count = item.childCount()
418 if itemidx < count:
419 return item.child(itemidx)
420 # is it unmerged?
421 item = self.topLevelItem(self.idx_unmerged)
422 count += item.childCount()
423 if itemidx < count:
424 return item.child(itemidx)
425 # is it untracked?
426 item = self.topLevelItem(self.idx_untracked)
427 count += item.childCount()
428 if itemidx < count:
429 return item.child(itemidx)
430 # Nope..
431 return None
433 def _subtree_item(self, idx, itemidx):
434 parent = self.topLevelItem(idx)
435 return parent.child(itemidx)
437 def _about_to_update(self):
438 self._save_scrollbars()
439 self._save_selection()
441 def _save_scrollbars(self):
442 vscroll = self.verticalScrollBar()
443 if vscroll:
444 self.old_vscroll = get(vscroll)
446 hscroll = self.horizontalScrollBar()
447 if hscroll:
448 self.old_hscroll = get(hscroll)
450 def current_item(self):
451 s = self.selected_indexes()
452 if not s:
453 return None
454 current = self.currentItem()
455 if not current:
456 return None
457 idx = self.indexFromItem(current, 0)
458 if idx.parent().isValid():
459 parent_idx = idx.parent()
460 entry = (parent_idx.row(), idx.row())
461 else:
462 entry = (self.idx_header, idx.row())
463 return entry
465 def _save_selection(self):
466 self.old_contents = self.contents()
467 self.old_selection = self.selection()
468 self.old_current_item = self.current_item()
470 def refresh(self):
471 self._set_staged(self.m.staged)
472 self._set_modified(self.m.modified)
473 self._set_unmerged(self.m.unmerged)
474 self._set_untracked(self.m.untracked)
475 self._update_column_widths()
476 self._update_actions()
477 self._restore_selection()
478 self._restore_scrollbars()
480 def _update_actions(self, selected=None):
481 if selected is None:
482 selected = self.selection_model.selection()
483 can_revert_edits = bool(selected.staged or selected.modified)
484 self.revert_unstaged_edits_action.setEnabled(can_revert_edits)
486 def _set_staged(self, items):
487 """Adds items to the 'Staged' subtree."""
488 self._set_subtree(items, self.idx_staged, N_('Staged'), staged=True,
489 deleted_set=self.m.staged_deleted)
491 def _set_modified(self, items):
492 """Adds items to the 'Modified' subtree."""
493 self._set_subtree(items, self.idx_modified, N_('Modified'),
494 deleted_set=self.m.unstaged_deleted)
496 def _set_unmerged(self, items):
497 """Adds items to the 'Unmerged' subtree."""
498 deleted_set = set([path for path in items if not core.exists(path)])
499 self._set_subtree(items, self.idx_unmerged, N_('Unmerged'),
500 deleted_set=deleted_set)
502 def _set_untracked(self, items):
503 """Adds items to the 'Untracked' subtree."""
504 self._set_subtree(items, self.idx_untracked, N_('Untracked'),
505 untracked=True)
507 def _set_subtree(self, items, idx, parent_title,
508 staged=False,
509 untracked=False,
510 deleted_set=None):
511 """Add a list of items to a treewidget item."""
512 self.blockSignals(True)
513 parent = self.topLevelItem(idx)
514 hide = not bool(items)
515 parent.setHidden(hide)
517 # sip v4.14.7 and below leak memory in parent.takeChildren()
518 # so we use this backwards-compatible construct instead
519 while parent.takeChild(0) is not None:
520 pass
522 for item in items:
523 deleted = (deleted_set is not None and item in deleted_set)
524 treeitem = qtutils.create_treeitem(item,
525 staged=staged,
526 deleted=deleted,
527 untracked=untracked)
528 parent.addChild(treeitem)
529 self._expand_items(idx, items)
530 self.blockSignals(False)
531 if prefs.status_show_totals(self.context):
532 parent.setText(0, '%s (%s)' % (parent_title, len(items)))
534 def _update_column_widths(self):
535 self.resizeColumnToContents(0)
537 def _expand_items(self, idx, items):
538 """Expand the top-level category "folder" once and only once."""
539 # Don't do this if items is empty; this makes it so that we
540 # don't add the top-level index into the expanded_items set
541 # until an item appears in a particular category.
542 if not items:
543 return
544 # Only run this once; we don't want to re-expand items that
545 # we've clicked on to re-collapse on updated().
546 if idx in self.expanded_items:
547 return
548 self.expanded_items.add(idx)
549 item = self.topLevelItem(idx)
550 if item:
551 self.expandItem(item)
553 def contextMenuEvent(self, event):
554 """Create context menus for the repo status tree."""
555 menu = self._create_context_menu()
556 menu.exec_(self.mapToGlobal(event.pos()))
558 def _create_context_menu(self):
559 """Set up the status menu for the repo status tree."""
560 s = self.selection()
561 menu = qtutils.create_menu('Status', self)
562 selected_indexes = self.selected_indexes()
563 if selected_indexes:
564 category, idx = selected_indexes[0]
565 # A header item e.g. 'Staged', 'Modified', etc.
566 if category == self.idx_header:
567 return self._create_header_context_menu(menu, idx)
569 if s.staged:
570 self._create_staged_context_menu(menu, s)
571 elif s.unmerged:
572 self._create_unmerged_context_menu(menu, s)
573 else:
574 self._create_unstaged_context_menu(menu, s)
576 if not utils.is_win32():
577 if not menu.isEmpty():
578 menu.addSeparator()
579 if not self.selection_model.is_empty():
580 menu.addAction(self.default_app_action)
581 menu.addAction(self.parent_dir_action)
583 if self.terminal_action is not None:
584 menu.addAction(self.terminal_action)
586 self._add_copy_actions(menu)
588 return menu
590 def _add_copy_actions(self, menu):
591 """Add the "Copy" sub-menu"""
592 enabled = self.selection_model.filename() is not None
593 self.copy_path_action.setEnabled(enabled)
594 self.copy_relpath_action.setEnabled(enabled)
595 self.copy_leading_path_action.setEnabled(enabled)
596 self.copy_basename_action.setEnabled(enabled)
597 copy_icon = icons.copy()
599 menu.addSeparator()
600 copy_menu = QtWidgets.QMenu(N_('Copy...'), menu)
601 menu.addMenu(copy_menu)
603 copy_menu.setIcon(copy_icon)
604 copy_menu.addAction(self.copy_path_action)
605 copy_menu.addAction(self.copy_relpath_action)
606 copy_menu.addAction(self.copy_leading_path_action)
607 copy_menu.addAction(self.copy_basename_action)
609 current_settings = settings.Settings()
610 current_settings.load()
612 copy_formats = current_settings.copy_formats
613 if copy_formats:
614 copy_menu.addSeparator()
616 context = self.context
617 for entry in copy_formats:
618 name = entry.get('name', '')
619 fmt = entry.get('format', '')
620 if name and fmt:
621 action = copy_menu.addAction(
622 name, partial(copy_format, context, fmt))
623 action.setIcon(copy_icon)
624 action.setEnabled(enabled)
626 copy_menu.addSeparator()
627 copy_menu.addAction(self.copy_customize_action)
629 def _create_header_context_menu(self, menu, idx):
630 context = self.context
631 if idx == self.idx_staged:
632 menu.addAction(icons.remove(), N_('Unstage All'),
633 cmds.run(cmds.UnstageAll, context))
634 elif idx == self.idx_unmerged:
635 action = menu.addAction(icons.add(), cmds.StageUnmerged.name(),
636 cmds.run(cmds.StageUnmerged, context))
637 action.setShortcut(hotkeys.STAGE_SELECTION)
638 elif idx == self.idx_modified:
639 action = menu.addAction(icons.add(), cmds.StageModified.name(),
640 cmds.run(cmds.StageModified, context))
641 action.setShortcut(hotkeys.STAGE_SELECTION)
642 elif idx == self.idx_untracked:
643 action = menu.addAction(icons.add(), cmds.StageUntracked.name(),
644 cmds.run(cmds.StageUntracked, context))
645 action.setShortcut(hotkeys.STAGE_SELECTION)
646 return menu
648 def _create_staged_context_menu(self, menu, s):
649 if s.staged[0] in self.m.submodules:
650 return self._create_staged_submodule_context_menu(menu, s)
652 context = self.context
653 if self.m.unstageable():
654 action = menu.addAction(
655 icons.remove(), N_('Unstage Selected'),
656 cmds.run(cmds.Unstage, context, self.staged()))
657 action.setShortcut(hotkeys.STAGE_SELECTION)
659 menu.addAction(self.launch_editor_action)
661 # Do all of the selected items exist?
662 all_exist = all(i not in self.m.staged_deleted and core.exists(i)
663 for i in self.staged())
665 if all_exist:
666 menu.addAction(self.launch_difftool_action)
668 if self.m.undoable():
669 menu.addAction(self.revert_unstaged_edits_action)
671 menu.addAction(self.view_history_action)
672 menu.addAction(self.view_blame_action)
673 return menu
675 def _create_staged_submodule_context_menu(self, menu, s):
676 context = self.context
677 path = core.abspath(s.staged[0])
678 if len(self.staged()) == 1:
679 menu.addAction(icons.cola(), N_('Launch git-cola'),
680 cmds.run(cmds.OpenRepo, context, path))
681 menu.addSeparator()
682 action = menu.addAction(
683 icons.remove(), N_('Unstage Selected'),
684 cmds.run(cmds.Unstage, context, self.staged()))
685 action.setShortcut(hotkeys.STAGE_SELECTION)
687 menu.addAction(self.view_history_action)
688 return menu
690 def _create_unmerged_context_menu(self, menu, _s):
691 context = self.context
692 menu.addAction(self.launch_difftool_action)
694 action = menu.addAction(
695 icons.add(), N_('Stage Selected'),
696 cmds.run(cmds.Stage, context, self.unstaged()))
697 action.setShortcut(hotkeys.STAGE_SELECTION)
699 menu.addAction(self.launch_editor_action)
700 menu.addAction(self.view_history_action)
701 menu.addAction(self.view_blame_action)
702 return menu
704 def _create_unstaged_context_menu(self, menu, s):
705 context = self.context
706 modified_submodule = (s.modified and
707 s.modified[0] in self.m.submodules)
708 if modified_submodule:
709 return self._create_modified_submodule_context_menu(menu, s)
711 if self.m.stageable():
712 action = menu.addAction(
713 icons.add(), N_('Stage Selected'),
714 cmds.run(cmds.Stage, context, self.unstaged()))
715 action.setShortcut(hotkeys.STAGE_SELECTION)
717 if not self.selection_model.is_empty():
718 menu.addAction(self.launch_editor_action)
720 # Do all of the selected items exist?
721 all_exist = all(i not in self.m.unstaged_deleted and core.exists(i)
722 for i in self.staged())
724 if all_exist and s.modified and self.m.stageable():
725 menu.addAction(self.launch_difftool_action)
727 if s.modified and self.m.stageable():
728 if self.m.undoable():
729 menu.addSeparator()
730 menu.addAction(self.revert_unstaged_edits_action)
732 if all_exist and s.untracked:
733 # Git Annex / Git LFS
734 annex = self.m.annex
735 lfs = core.find_executable('git-lfs')
736 if annex or lfs:
737 menu.addSeparator()
738 if annex:
739 menu.addAction(self.annex_add_action)
740 if lfs:
741 menu.addAction(self.lfs_track_action)
743 menu.addSeparator()
744 if self.move_to_trash_action is not None:
745 menu.addAction(self.move_to_trash_action)
746 menu.addAction(self.delete_untracked_files_action)
747 menu.addSeparator()
748 menu.addAction(icons.edit(), N_('Add to .gitignore'),
749 partial(gitignore.gitignore_view, self.context))
751 if not self.selection_model.is_empty():
752 menu.addAction(self.view_history_action)
753 menu.addAction(self.view_blame_action)
754 return menu
756 def _create_modified_submodule_context_menu(self, menu, s):
757 context = self.context
758 path = core.abspath(s.modified[0])
759 if len(self.unstaged()) == 1:
760 menu.addAction(icons.cola(), N_('Launch git-cola'),
761 cmds.run(cmds.OpenRepo, context, path))
762 menu.addAction(icons.pull(), N_('Update this submodule'),
763 cmds.run(cmds.SubmoduleUpdate, context, path))
764 menu.addSeparator()
766 if self.m.stageable():
767 menu.addSeparator()
768 action = menu.addAction(
769 icons.add(), N_('Stage Selected'),
770 cmds.run(cmds.Stage, context, self.unstaged()))
771 action.setShortcut(hotkeys.STAGE_SELECTION)
773 menu.addAction(self.view_history_action)
774 return menu
776 def _delete_untracked_files(self):
777 cmds.do(cmds.Delete, self.context, self.untracked())
779 def _trash_untracked_files(self):
780 cmds.do(cmds.MoveToTrash, self.context, self.untracked())
782 def selected_path(self):
783 s = self.single_selection()
784 return s.staged or s.unmerged or s.modified or s.untracked or None
786 def single_selection(self):
787 """Scan across staged, modified, etc. and return a single item."""
788 staged = None
789 unmerged = None
790 modified = None
791 untracked = None
793 s = self.selection()
794 if s.staged:
795 staged = s.staged[0]
796 elif s.unmerged:
797 unmerged = s.unmerged[0]
798 elif s.modified:
799 modified = s.modified[0]
800 elif s.untracked:
801 untracked = s.untracked[0]
803 return selection.State(staged, unmerged, modified, untracked)
805 def selected_indexes(self):
806 """Returns a list of (category, row) representing the tree selection."""
807 selected = self.selectedIndexes()
808 result = []
809 for idx in selected:
810 if idx.parent().isValid():
811 parent_idx = idx.parent()
812 entry = (parent_idx.row(), idx.row())
813 else:
814 entry = (self.idx_header, idx.row())
815 result.append(entry)
816 return result
818 def selection(self):
819 """Return the current selection in the repo status tree."""
820 return selection.State(self.staged(), self.unmerged(),
821 self.modified(), self.untracked())
823 def contents(self):
824 return selection.State(self.m.staged, self.m.unmerged,
825 self.m.modified, self.m.untracked)
827 def all_files(self):
828 c = self.contents()
829 return c.staged + c.unmerged + c.modified + c.untracked
831 def selected_group(self):
832 """A list of selected files in various states of being"""
833 return selection.pick(self.selection())
835 def selected_idx(self):
836 c = self.contents()
837 s = self.single_selection()
838 offset = 0
839 for content, sel in zip(c, s):
840 if not content:
841 continue
842 if sel is not None:
843 return offset + content.index(sel)
844 offset += len(content)
845 return None
847 def select_by_index(self, idx):
848 c = self.contents()
849 to_try = [
850 (c.staged, self.idx_staged),
851 (c.unmerged, self.idx_unmerged),
852 (c.modified, self.idx_modified),
853 (c.untracked, self.idx_untracked),
855 for content, toplevel_idx in to_try:
856 if not content:
857 continue
858 if idx < len(content):
859 parent = self.topLevelItem(toplevel_idx)
860 item = parent.child(idx)
861 if item is not None:
862 self.select_item(item)
863 return
864 idx -= len(content)
866 def scroll_to_item(self, item):
867 # First, scroll to the item, but keep the original hscroll
868 hscroll = None
869 hscrollbar = self.horizontalScrollBar()
870 if hscrollbar:
871 hscroll = get(hscrollbar)
872 self.scrollToItem(item)
873 if hscroll is not None:
874 hscrollbar.setValue(hscroll)
876 def select_item(self, item):
877 self.scroll_to_item(item)
878 self.setCurrentItem(item)
879 item.setSelected(True)
881 def staged(self):
882 return self._subtree_selection(self.idx_staged, self.m.staged)
884 def unstaged(self):
885 return self.unmerged() + self.modified() + self.untracked()
887 def modified(self):
888 return self._subtree_selection(self.idx_modified, self.m.modified)
890 def unmerged(self):
891 return self._subtree_selection(self.idx_unmerged, self.m.unmerged)
893 def untracked(self):
894 return self._subtree_selection(self.idx_untracked, self.m.untracked)
896 def staged_items(self):
897 return self._subtree_selection_items(self.idx_staged)
899 def unstaged_items(self):
900 return (self.unmerged_items() + self.modified_items() +
901 self.untracked_items())
903 def modified_items(self):
904 return self._subtree_selection_items(self.idx_modified)
906 def unmerged_items(self):
907 return self._subtree_selection_items(self.idx_unmerged)
909 def untracked_items(self):
910 return self._subtree_selection_items(self.idx_untracked)
912 def _subtree_selection(self, idx, items):
913 item = self.topLevelItem(idx)
914 return qtutils.tree_selection(item, items)
916 def _subtree_selection_items(self, idx):
917 item = self.topLevelItem(idx)
918 return qtutils.tree_selection_items(item)
920 def _double_clicked(self, _item, _idx):
921 """Called when an item is double-clicked in the repo status tree."""
922 cmds.do(cmds.StageOrUnstage, self.context)
924 def show_selection(self):
925 """Show the selected item."""
926 context = self.context
927 self.scroll_to_item(self.currentItem())
928 # Sync the selection model
929 selected = self.selection()
930 selection_model = self.selection_model
931 selection_model.set_selection(selected)
932 self._update_actions(selected=selected)
934 selected_indexes = self.selected_indexes()
935 if not selected_indexes:
936 if self.m.amending():
937 cmds.do(cmds.SetDiffText, context, '')
938 else:
939 cmds.do(cmds.ResetMode, context)
940 return
942 # A header item e.g. 'Staged', 'Modified', etc.
943 category, idx = selected_indexes[0]
944 header = category == self.idx_header
945 if header:
946 cls = {
947 self.idx_staged: cmds.DiffStagedSummary,
948 self.idx_modified: cmds.Diffstat,
949 # TODO implement UnmergedSummary
950 # self.idx_unmerged: cmds.UnmergedSummary,
951 self.idx_untracked: cmds.UntrackedSummary,
952 }.get(idx, cmds.Diffstat)
953 cmds.do(cls, context)
954 return
956 staged = category == self.idx_staged
957 modified = category == self.idx_modified
958 unmerged = category == self.idx_unmerged
959 untracked = category == self.idx_untracked
961 if staged:
962 item = self.staged_items()[0]
963 elif unmerged:
964 item = self.unmerged_items()[0]
965 elif modified:
966 item = self.modified_items()[0]
967 elif untracked:
968 item = self.unstaged_items()[0]
969 else:
970 item = None # this shouldn't happen
971 assert item is not None
973 path = item.path
974 deleted = item.deleted
975 image = self.image_formats.ok(path)
977 # Images are diffed differently
978 if image:
979 cmds.do(cmds.DiffImage, context, path, deleted,
980 staged, modified, unmerged, untracked)
981 elif staged:
982 cmds.do(cmds.DiffStaged, context, path, deleted=deleted)
983 elif modified:
984 cmds.do(cmds.Diff, context, path, deleted=deleted)
985 elif unmerged:
986 cmds.do(cmds.Diff, context, path)
987 elif untracked:
988 cmds.do(cmds.ShowUntracked, context, path)
990 def select_header(self):
991 """Select an active header, which triggers a diffstat"""
992 for idx in (self.idx_staged, self.idx_unmerged,
993 self.idx_modified, self.idx_untracked):
994 item = self.topLevelItem(idx)
995 if item.childCount() > 0:
996 self.clearSelection()
997 self.setCurrentItem(item)
998 return
1000 def move_up(self):
1001 idx = self.selected_idx()
1002 all_files = self.all_files()
1003 if idx is None:
1004 selected_indexes = self.selected_indexes()
1005 if selected_indexes:
1006 category, toplevel_idx = selected_indexes[0]
1007 if category == self.idx_header:
1008 item = self.itemAbove(self.topLevelItem(toplevel_idx))
1009 if item is not None:
1010 self.select_item(item)
1011 return
1012 if all_files:
1013 self.select_by_index(len(all_files) - 1)
1014 return
1015 if idx - 1 >= 0:
1016 self.select_by_index(idx - 1)
1017 else:
1018 self.select_by_index(len(all_files) - 1)
1020 def move_down(self):
1021 idx = self.selected_idx()
1022 all_files = self.all_files()
1023 if idx is None:
1024 selected_indexes = self.selected_indexes()
1025 if selected_indexes:
1026 category, toplevel_idx = selected_indexes[0]
1027 if category == self.idx_header:
1028 item = self.itemBelow(self.topLevelItem(toplevel_idx))
1029 if item is not None:
1030 self.select_item(item)
1031 return
1032 if all_files:
1033 self.select_by_index(0)
1034 return
1035 if idx + 1 < len(all_files):
1036 self.select_by_index(idx + 1)
1037 else:
1038 self.select_by_index(0)
1040 def mimeData(self, items):
1041 """Return a list of absolute-path URLs"""
1042 context = self.context
1043 paths = qtutils.paths_from_items(items, item_filter=_item_filter)
1044 return qtutils.mimedata_from_paths(context, paths)
1046 # pylint: disable=no-self-use
1047 def mimeTypes(self):
1048 return qtutils.path_mimetypes()
1051 def _item_filter(item):
1052 return not item.deleted and core.exists(item.path)
1055 def view_blame(context):
1056 """Signal that we should view blame for paths."""
1057 cmds.do(cmds.BlamePaths, context)
1060 def view_history(context):
1061 """Signal that we should view history for paths."""
1062 cmds.do(cmds.VisualizePaths, context, context.selection.union())
1065 def copy_path(context, absolute=True):
1066 """Copy a selected path to the clipboard"""
1067 filename = context.selection.filename()
1068 qtutils.copy_path(filename, absolute=absolute)
1071 def copy_relpath(context):
1072 """Copy a selected relative path to the clipboard"""
1073 copy_path(context, absolute=False)
1076 def copy_basename(context):
1077 filename = os.path.basename(context.selection.filename())
1078 basename, _ = os.path.splitext(filename)
1079 qtutils.copy_path(basename, absolute=False)
1082 def copy_leading_path(context):
1083 """Copy the selected leading path to the clipboard"""
1084 filename = context.selection.filename()
1085 dirname = os.path.dirname(filename)
1086 qtutils.copy_path(dirname, absolute=False)
1089 def copy_format(context, fmt):
1090 values = {}
1091 values['path'] = path = context.selection.filename()
1092 values['abspath'] = abspath = os.path.abspath(path)
1093 values['absdirname'] = os.path.dirname(abspath)
1094 values['dirname'] = os.path.dirname(path)
1095 values['filename'] = os.path.basename(path)
1096 values['basename'], values['ext'] = (
1097 os.path.splitext(os.path.basename(path)))
1098 qtutils.set_clipboard(fmt % values)
1101 def show_help(context):
1102 help_text = N_(r"""
1103 Format String Variables
1104 -----------------------
1105 %(path)s = relative file path
1106 %(abspath)s = absolute file path
1107 %(dirname)s = relative directory path
1108 %(absdirname)s = absolute directory path
1109 %(filename)s = file basename
1110 %(basename)s = file basename without extension
1111 %(ext)s = file extension
1112 """)
1113 title = N_('Help - Custom Copy Actions')
1114 return text.text_dialog(context, help_text, title)
1117 class StatusFilterWidget(QtWidgets.QWidget):
1119 def __init__(self, context, parent=None):
1120 QtWidgets.QWidget.__init__(self, parent)
1121 self.context = context
1123 hint = N_('Filter paths...')
1124 self.text = completion.GitStatusFilterLineEdit(
1125 context, hint=hint, parent=self)
1126 self.text.setToolTip(hint)
1127 self.setFocusProxy(self.text)
1128 self._filter = None
1130 self.main_layout = qtutils.hbox(defs.no_margin, defs.spacing, self.text)
1131 self.setLayout(self.main_layout)
1133 widget = self.text
1134 # pylint: disable=no-member
1135 widget.changed.connect(self.apply_filter)
1136 widget.cleared.connect(self.apply_filter)
1137 widget.enter.connect(self.apply_filter)
1138 widget.editingFinished.connect(self.apply_filter)
1140 def apply_filter(self):
1141 value = get(self.text)
1142 if value == self._filter:
1143 return
1144 self._filter = value
1145 paths = utils.shell_split(value)
1146 self.context.model.update_path_filter(paths)
1149 def customize_copy_actions(context, parent):
1150 """Customize copy actions"""
1151 dialog = CustomizeCopyActions(context, parent)
1152 dialog.show()
1153 dialog.exec_()
1156 class CustomizeCopyActions(standard.Dialog):
1158 def __init__(self, context, parent):
1159 standard.Dialog.__init__(self, parent=parent)
1160 self.setWindowTitle(N_('Custom Copy Actions'))
1162 self.table = QtWidgets.QTableWidget(self)
1163 self.table.setColumnCount(2)
1164 self.table.setHorizontalHeaderLabels([
1165 N_('Action Name'),
1166 N_('Format String'),
1168 self.table.setSortingEnabled(False)
1169 self.table.verticalHeader().hide()
1170 self.table.horizontalHeader().setStretchLastSection(True)
1172 self.add_button = qtutils.create_button(N_('Add'))
1173 self.remove_button = qtutils.create_button(N_('Remove'))
1174 self.remove_button.setEnabled(False)
1175 self.show_help_button = qtutils.create_button(N_('Show Help'))
1176 self.show_help_button.setShortcut(hotkeys.QUESTION)
1178 self.close_button = qtutils.close_button()
1179 self.save_button = qtutils.ok_button(N_('Save'))
1181 self.buttons = qtutils.hbox(defs.no_margin, defs.button_spacing,
1182 self.add_button,
1183 self.remove_button,
1184 self.show_help_button,
1185 qtutils.STRETCH,
1186 self.close_button,
1187 self.save_button)
1189 layout = qtutils.vbox(defs.margin, defs.spacing,
1190 self.table, self.buttons)
1191 self.setLayout(layout)
1193 qtutils.connect_button(self.add_button, self.add)
1194 qtutils.connect_button(self.remove_button, self.remove)
1195 qtutils.connect_button(
1196 self.show_help_button, partial(show_help, context))
1197 qtutils.connect_button(self.close_button, self.reject)
1198 qtutils.connect_button(self.save_button, self.save)
1199 qtutils.add_close_action(self)
1200 # pylint: disable=no-member
1201 self.table.itemSelectionChanged.connect(self.table_selection_changed)
1203 self.init_size(parent=parent)
1205 self.settings = settings.Settings()
1206 QtCore.QTimer.singleShot(0, self.reload_settings)
1208 def reload_settings(self):
1209 # Called once after the GUI is initialized
1210 self.settings.load()
1211 table = self.table
1212 for entry in self.settings.copy_formats:
1213 name_string = entry.get('name', '')
1214 format_string = entry.get('format', '')
1215 if name_string and format_string:
1216 name = QtWidgets.QTableWidgetItem(name_string)
1217 fmt = QtWidgets.QTableWidgetItem(format_string)
1218 rows = table.rowCount()
1219 table.setRowCount(rows + 1)
1220 table.setItem(rows, 0, name)
1221 table.setItem(rows, 1, fmt)
1223 def export_state(self):
1224 state = super(CustomizeCopyActions, self).export_state()
1225 standard.export_header_columns(self.table, state)
1226 return state
1228 def apply_state(self, state):
1229 result = super(CustomizeCopyActions, self).apply_state(state)
1230 standard.apply_header_columns(self.table, state)
1231 return result
1233 def add(self):
1234 self.table.setFocus()
1235 rows = self.table.rowCount()
1236 self.table.setRowCount(rows + 1)
1238 name = QtWidgets.QTableWidgetItem(N_('Name'))
1239 fmt = QtWidgets.QTableWidgetItem(r'%(path)s')
1240 self.table.setItem(rows, 0, name)
1241 self.table.setItem(rows, 1, fmt)
1243 self.table.setCurrentCell(rows, 0)
1244 self.table.editItem(name)
1246 def remove(self):
1247 """Remove selected items"""
1248 # Gather a unique set of rows and remove them in reverse order
1249 rows = set()
1250 items = self.table.selectedItems()
1251 for item in items:
1252 rows.add(self.table.row(item))
1254 for row in reversed(sorted(rows)):
1255 self.table.removeRow(row)
1257 def save(self):
1258 copy_formats = []
1259 for row in range(self.table.rowCount()):
1260 name = self.table.item(row, 0)
1261 fmt = self.table.item(row, 1)
1262 if name and fmt:
1263 entry = {
1264 'name': name.text(),
1265 'format': fmt.text(),
1267 copy_formats.append(entry)
1269 while self.settings.copy_formats:
1270 self.settings.copy_formats.pop()
1272 self.settings.copy_formats.extend(copy_formats)
1273 self.settings.save()
1275 self.accept()
1277 def table_selection_changed(self):
1278 items = self.table.selectedItems()
1279 self.remove_button.setEnabled(bool(items))