maint: prefer functions over methods
[git-cola.git] / cola / widgets / status.py
blobbd9caf31bf27392b70fb829cbbf9fd14c7b07dfc
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.QWidget):
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.QWidget.__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(True)
68 else:
69 self.tree.setFocus(True)
71 def set_initial_size(self):
72 self.setMaximumWidth(222)
73 QtCore.QTimer.singleShot(1, self.restore_size)
75 def restore_size(self):
76 self.setMaximumWidth(2 ** 13)
78 def refresh(self):
79 self.tree.show_selection()
81 def set_filter(self, txt):
82 self.filter_widget.setVisible(True)
83 self.filter_widget.text.set_value(txt)
84 self.filter_widget.apply_filter()
86 def move_up(self):
87 self.tree.move_up()
89 def move_down(self):
90 self.tree.move_down()
92 def select_header(self):
93 self.tree.select_header()
96 class StatusTreeWidget(QtWidgets.QTreeWidget):
97 # Signals
98 about_to_update = Signal()
99 updated = Signal()
100 diff_text_changed = Signal()
102 # Item categories
103 idx_header = -1
104 idx_staged = 0
105 idx_unmerged = 1
106 idx_modified = 2
107 idx_untracked = 3
108 idx_end = 4
110 # Read-only access to the mode state
111 mode = property(lambda self: self.m.mode)
113 def __init__(self, context, parent=None):
114 QtWidgets.QTreeWidget.__init__(self, parent)
115 self.context = context
116 self.selection_model = context.selection
118 self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
119 self.headerItem().setHidden(True)
120 self.setAllColumnsShowFocus(True)
121 self.setSortingEnabled(False)
122 self.setUniformRowHeights(True)
123 self.setAnimated(True)
124 self.setRootIsDecorated(False)
125 self.setIndentation(0)
126 self.setDragEnabled(True)
127 self.setAutoScroll(False)
129 ok = icons.ok()
130 compare = icons.compare()
131 question = icons.question()
132 self.add_toplevel_item(N_('Staged'), ok, hide=True)
133 self.add_toplevel_item(N_('Unmerged'), compare, hide=True)
134 self.add_toplevel_item(N_('Modified'), compare, hide=True)
135 self.add_toplevel_item(N_('Untracked'), question, hide=True)
137 # Used to restore the selection
138 self.old_vscroll = None
139 self.old_hscroll = None
140 self.old_selection = None
141 self.old_contents = None
142 self.old_current_item = None
143 self.was_visible = True
144 self.expanded_items = set()
146 self.image_formats = qtutils.ImageFormats()
148 self.process_selection_action = qtutils.add_action(
149 self, cmds.StageOrUnstage.name(), self.stage_selection,
150 hotkeys.STAGE_SELECTION)
152 self.revert_unstaged_edits_action = qtutils.add_action(
153 self, cmds.RevertUnstagedEdits.name(),
154 cmds.run(cmds.RevertUnstagedEdits, context), hotkeys.REVERT)
155 self.revert_unstaged_edits_action.setIcon(icons.undo())
157 self.launch_difftool_action = qtutils.add_action(
158 self, cmds.LaunchDifftool.name(),
159 cmds.run(cmds.LaunchDifftool, context), hotkeys.DIFF)
160 self.launch_difftool_action.setIcon(icons.diff())
162 self.launch_editor_action = actions.launch_editor(
163 context, self, *hotkeys.ACCEPT)
165 if not utils.is_win32():
166 self.default_app_action = common.default_app_action(
167 context, self, self.selected_group)
169 self.parent_dir_action = common.parent_dir_action(
170 context, self, self.selected_group)
172 self.terminal_action = common.terminal_action(
173 context, self, self.selected_group)
175 self.up_action = qtutils.add_action(
176 self, N_('Move Up'), self.move_up,
177 hotkeys.MOVE_UP, hotkeys.MOVE_UP_SECONDARY)
179 self.down_action = qtutils.add_action(
180 self, N_('Move Down'), self.move_down,
181 hotkeys.MOVE_DOWN, hotkeys.MOVE_DOWN_SECONDARY)
183 self.copy_path_action = qtutils.add_action(
184 self, N_('Copy Path to Clipboard'),
185 partial(copy_path, context), hotkeys.COPY)
186 self.copy_path_action.setIcon(icons.copy())
188 self.copy_relpath_action = qtutils.add_action(
189 self, N_('Copy Relative Path to Clipboard'),
190 partial(copy_relpath, context), hotkeys.CUT)
191 self.copy_relpath_action.setIcon(icons.copy())
193 self.copy_leading_path_action = qtutils.add_action(
194 self, N_('Copy Leading Path to Clipboard'),
195 partial(copy_leading_path, context))
196 self.copy_leading_path_action.setIcon(icons.copy())
198 self.copy_basename_action = qtutils.add_action(
199 self, N_('Copy Basename to Clipboard'),
200 partial(copy_basename, context))
201 self.copy_basename_action.setIcon(icons.copy())
203 self.copy_customize_action = qtutils.add_action(
204 self, N_('Customize...'),
205 partial(customize_copy_actions, context, self))
206 self.copy_customize_action.setIcon(icons.configure())
208 self.view_history_action = qtutils.add_action(
209 self, N_('View History...'), partial(view_history, context),
210 hotkeys.HISTORY)
212 self.view_blame_action = qtutils.add_action(
213 self, N_('Blame...'),
214 partial(view_blame, context), hotkeys.BLAME)
216 self.annex_add_action = qtutils.add_action(
217 self, N_('Add to Git Annex'), cmds.run(cmds.AnnexAdd, context))
219 self.lfs_track_action = qtutils.add_action(
220 self, N_('Add to Git LFS'), cmds.run(cmds.LFSTrack, context))
222 # MoveToTrash and Delete use the same shortcut.
223 # We will only bind one of them, depending on whether or not the
224 # MoveToTrash command is available. When available, the hotkey
225 # is bound to MoveToTrash, otherwise it is bound to Delete.
226 if cmds.MoveToTrash.AVAILABLE:
227 self.move_to_trash_action = qtutils.add_action(
228 self, N_('Move files to trash'),
229 self._trash_untracked_files, hotkeys.TRASH)
230 self.move_to_trash_action.setIcon(icons.discard())
231 delete_shortcut = hotkeys.DELETE_FILE
232 else:
233 self.move_to_trash_action = None
234 delete_shortcut = hotkeys.DELETE_FILE_SECONDARY
236 self.delete_untracked_files_action = qtutils.add_action(
237 self, N_('Delete Files...'),
238 self._delete_untracked_files, delete_shortcut)
239 self.delete_untracked_files_action.setIcon(icons.discard())
241 about_to_update = self._about_to_update
242 self.about_to_update.connect(about_to_update, type=Qt.QueuedConnection)
243 self.updated.connect(self.refresh, type=Qt.QueuedConnection)
244 self.diff_text_changed.connect(
245 self._make_current_item_visible, type=Qt.QueuedConnection)
247 self.m = context.model
248 self.m.add_observer(self.m.message_about_to_update,
249 self.about_to_update.emit)
250 self.m.add_observer(self.m.message_updated, self.updated.emit)
251 self.m.add_observer(self.m.message_diff_text_changed,
252 self.diff_text_changed.emit)
254 self.itemSelectionChanged.connect(self.show_selection)
255 self.itemDoubleClicked.connect(self._double_clicked)
256 self.itemCollapsed.connect(lambda x: self._update_column_widths())
257 self.itemExpanded.connect(lambda x: self._update_column_widths())
259 def _make_current_item_visible(self):
260 item = self.currentItem()
261 if item:
262 self.scroll_to_item(item)
264 def add_toplevel_item(self, txt, icon, hide=False):
265 context = self.context
266 font = self.font()
267 if prefs.bold_headers(context):
268 font.setBold(True)
269 else:
270 font.setItalic(True)
272 item = QtWidgets.QTreeWidgetItem(self)
273 item.setFont(0, font)
274 item.setText(0, txt)
275 item.setIcon(0, icon)
276 if prefs.bold_headers(context):
277 item.setBackground(0, self.palette().midlight())
278 if hide:
279 item.setHidden(True)
281 def _restore_selection(self):
282 if not self.old_selection or not self.old_contents:
283 return
284 old_c = self.old_contents
285 old_s = self.old_selection
286 new_c = self.contents()
288 def mkselect(lst, widget_getter):
289 def select(item, current=False):
290 idx = lst.index(item)
291 item = widget_getter(idx)
292 if current:
293 self.setCurrentItem(item)
294 item.setSelected(True)
295 return select
297 select_staged = mkselect(new_c.staged, self.staged_item)
298 select_unmerged = mkselect(new_c.unmerged, self.unmerged_item)
299 select_modified = mkselect(new_c.modified, self.modified_item)
300 select_untracked = mkselect(new_c.untracked, self.untracked_item)
302 saved_selection = [
303 (set(new_c.staged), old_c.staged, set(old_s.staged),
304 select_staged),
306 (set(new_c.unmerged), old_c.unmerged, set(old_s.unmerged),
307 select_unmerged),
309 (set(new_c.modified), old_c.modified, set(old_s.modified),
310 select_modified),
312 (set(new_c.untracked), old_c.untracked, set(old_s.untracked),
313 select_untracked),
316 # Restore the current item
317 if self.old_current_item:
318 category, idx = self.old_current_item
319 if category == self.idx_header:
320 item = self.invisibleRootItem().child(idx)
321 if item is not None:
322 self.blockSignals(True)
323 self.setCurrentItem(item)
324 item.setSelected(True)
325 self.blockSignals(False)
326 self.show_selection()
327 return
328 # Reselect the current item
329 selection_info = saved_selection[category]
330 new = selection_info[0]
331 old = selection_info[1]
332 reselect = selection_info[3]
333 try:
334 item = old[idx]
335 except IndexError:
336 return
337 if item in new:
338 reselect(item, current=True)
340 # Restore selection
341 # When reselecting we only care that the items are selected;
342 # we do not need to rerun the callbacks which were triggered
343 # above. Block signals to skip the callbacks.
344 self.blockSignals(True)
345 for (new, old, sel, reselect) in saved_selection:
346 for item in sel:
347 if item in new:
348 reselect(item, current=False)
349 self.blockSignals(False)
351 for (new, old, sel, reselect) in saved_selection:
352 # When modified is staged, select the next modified item
353 # When unmerged is staged, select the next unmerged item
354 # When unstaging, select the next staged item
355 # When staging untracked files, select the next untracked item
356 if len(new) >= len(old):
357 # The list did not shrink so it is not one of these cases.
358 continue
359 for item in sel:
360 # The item still exists so ignore it
361 if item in new or item not in old:
362 continue
363 # The item no longer exists in this list so search for
364 # its nearest neighbors and select them instead.
365 idx = old.index(item)
366 for j in itertools.chain(old[idx+1:], reversed(old[:idx])):
367 if j in new:
368 reselect(j, current=True)
369 return
371 def _restore_scrollbars(self):
372 vscroll = self.verticalScrollBar()
373 if vscroll and self.old_vscroll is not None:
374 vscroll.setValue(self.old_vscroll)
375 self.old_vscroll = None
377 hscroll = self.horizontalScrollBar()
378 if hscroll and self.old_hscroll is not None:
379 hscroll.setValue(self.old_hscroll)
380 self.old_hscroll = None
382 def stage_selection(self):
383 """Stage or unstage files according to the selection"""
384 context = self.context
385 selected_indexes = self.selected_indexes()
386 if selected_indexes:
387 category, idx = selected_indexes[0]
388 # A header item e.g. 'Staged', 'Modified', etc.
389 if category == self.idx_header:
390 if idx == self.idx_staged:
391 cmds.do(cmds.UnstageAll, context)
392 elif idx == self.idx_modified:
393 cmds.do(cmds.StageModified, context)
394 elif idx == self.idx_untracked:
395 cmds.do(cmds.StageUntracked, context)
396 else:
397 pass # Do nothing for unmerged items, by design
398 return
399 cmds.do(cmds.StageOrUnstage, context)
401 def staged_item(self, itemidx):
402 return self._subtree_item(self.idx_staged, itemidx)
404 def modified_item(self, itemidx):
405 return self._subtree_item(self.idx_modified, itemidx)
407 def unmerged_item(self, itemidx):
408 return self._subtree_item(self.idx_unmerged, itemidx)
410 def untracked_item(self, itemidx):
411 return self._subtree_item(self.idx_untracked, itemidx)
413 def unstaged_item(self, itemidx):
414 # is it modified?
415 item = self.topLevelItem(self.idx_modified)
416 count = item.childCount()
417 if itemidx < count:
418 return item.child(itemidx)
419 # is it unmerged?
420 item = self.topLevelItem(self.idx_unmerged)
421 count += item.childCount()
422 if itemidx < count:
423 return item.child(itemidx)
424 # is it untracked?
425 item = self.topLevelItem(self.idx_untracked)
426 count += item.childCount()
427 if itemidx < count:
428 return item.child(itemidx)
429 # Nope..
430 return None
432 def _subtree_item(self, idx, itemidx):
433 parent = self.topLevelItem(idx)
434 return parent.child(itemidx)
436 def _about_to_update(self):
437 self.save_scrollbars()
438 self.save_selection()
440 def save_scrollbars(self):
441 vscroll = self.verticalScrollBar()
442 if vscroll:
443 self.old_vscroll = get(vscroll)
445 hscroll = self.horizontalScrollBar()
446 if hscroll:
447 self.old_hscroll = get(hscroll)
449 def current_item(self):
450 s = self.selected_indexes()
451 if not s:
452 return None
453 current = self.currentItem()
454 if not current:
455 return None
456 idx = self.indexFromItem(current, 0)
457 if idx.parent().isValid():
458 parent_idx = idx.parent()
459 entry = (parent_idx.row(), idx.row())
460 else:
461 entry = (self.idx_header, idx.row())
462 return entry
464 def save_selection(self):
465 self.old_contents = self.contents()
466 self.old_selection = self.selection()
467 self.old_current_item = self.current_item()
469 def refresh(self):
470 self.set_staged(self.m.staged)
471 self.set_modified(self.m.modified)
472 self.set_unmerged(self.m.unmerged)
473 self.set_untracked(self.m.untracked)
474 self._update_column_widths()
475 self._update_actions()
476 self._restore_selection()
477 self._restore_scrollbars()
479 def _update_actions(self, selected=None):
480 if selected is None:
481 selected = self.selection_model.selection()
482 can_revert_edits = bool(selected.staged or selected.modified)
483 self.revert_unstaged_edits_action.setEnabled(can_revert_edits)
485 def set_staged(self, items):
486 """Adds items to the 'Staged' subtree."""
487 self._set_subtree(items, self.idx_staged, staged=True,
488 deleted_set=self.m.staged_deleted)
490 def set_modified(self, items):
491 """Adds items to the 'Modified' subtree."""
492 self._set_subtree(items, self.idx_modified,
493 deleted_set=self.m.unstaged_deleted)
495 def set_unmerged(self, items):
496 """Adds items to the 'Unmerged' subtree."""
497 deleted_set = set([path for path in items if not core.exists(path)])
498 self._set_subtree(items, self.idx_unmerged,
499 deleted_set=deleted_set)
501 def set_untracked(self, items):
502 """Adds items to the 'Untracked' subtree."""
503 self._set_subtree(items, self.idx_untracked, untracked=True)
505 def _set_subtree(self, items, idx,
506 staged=False,
507 untracked=False,
508 deleted_set=None):
509 """Add a list of items to a treewidget item."""
510 self.blockSignals(True)
511 parent = self.topLevelItem(idx)
512 hide = not bool(items)
513 parent.setHidden(hide)
515 # sip v4.14.7 and below leak memory in parent.takeChildren()
516 # so we use this backwards-compatible construct instead
517 while parent.takeChild(0) is not None:
518 pass
520 for item in items:
521 deleted = (deleted_set is not None and item in deleted_set)
522 treeitem = qtutils.create_treeitem(item,
523 staged=staged,
524 deleted=deleted,
525 untracked=untracked)
526 parent.addChild(treeitem)
527 self.expand_items(idx, items)
528 self.blockSignals(False)
530 def _update_column_widths(self):
531 self.resizeColumnToContents(0)
533 def expand_items(self, idx, items):
534 """Expand the top-level category "folder" once and only once."""
535 # Don't do this if items is empty; this makes it so that we
536 # don't add the top-level index into the expanded_items set
537 # until an item appears in a particular category.
538 if not items:
539 return
540 # Only run this once; we don't want to re-expand items that
541 # we've clicked on to re-collapse on updated().
542 if idx in self.expanded_items:
543 return
544 self.expanded_items.add(idx)
545 item = self.topLevelItem(idx)
546 if item:
547 self.expandItem(item)
549 def contextMenuEvent(self, event):
550 """Create context menus for the repo status tree."""
551 menu = self._create_context_menu()
552 menu.exec_(self.mapToGlobal(event.pos()))
554 def _create_context_menu(self):
555 """Set up the status menu for the repo status tree."""
556 s = self.selection()
557 menu = qtutils.create_menu('Status', self)
558 selected_indexes = self.selected_indexes()
559 if selected_indexes:
560 category, idx = selected_indexes[0]
561 # A header item e.g. 'Staged', 'Modified', etc.
562 if category == self.idx_header:
563 return self._create_header_context_menu(menu, idx)
565 if s.staged:
566 self._create_staged_context_menu(menu, s)
567 elif s.unmerged:
568 self._create_unmerged_context_menu(menu, s)
569 else:
570 self._create_unstaged_context_menu(menu, s)
572 if not utils.is_win32():
573 if not menu.isEmpty():
574 menu.addSeparator()
575 if not self.selection_model.is_empty():
576 menu.addAction(self.default_app_action)
577 menu.addAction(self.parent_dir_action)
578 menu.addAction(self.terminal_action)
580 self._add_copy_actions(menu)
582 return menu
584 def _add_copy_actions(self, menu):
585 """Add the "Copy" sub-menu"""
586 enabled = self.selection_model.filename() is not None
587 self.copy_path_action.setEnabled(enabled)
588 self.copy_relpath_action.setEnabled(enabled)
589 self.copy_leading_path_action.setEnabled(enabled)
590 self.copy_basename_action.setEnabled(enabled)
591 copy_icon = icons.copy()
593 menu.addSeparator()
594 copy_menu = QtWidgets.QMenu(N_('Copy...'), menu)
595 menu.addMenu(copy_menu)
597 copy_menu.setIcon(copy_icon)
598 copy_menu.addAction(self.copy_path_action)
599 copy_menu.addAction(self.copy_relpath_action)
600 copy_menu.addAction(self.copy_leading_path_action)
601 copy_menu.addAction(self.copy_basename_action)
603 current_settings = settings.Settings()
604 current_settings.load()
606 copy_formats = current_settings.copy_formats
607 if copy_formats:
608 copy_menu.addSeparator()
610 context = self.context
611 for entry in copy_formats:
612 name = entry.get('name', '')
613 fmt = entry.get('format', '')
614 if name and fmt:
615 action = copy_menu.addAction(
616 name, partial(copy_format, context, fmt))
617 action.setIcon(copy_icon)
618 action.setEnabled(enabled)
620 copy_menu.addSeparator()
621 copy_menu.addAction(self.copy_customize_action)
623 def _create_header_context_menu(self, menu, idx):
624 context = self.context
625 if idx == self.idx_staged:
626 menu.addAction(icons.remove(), N_('Unstage All'),
627 cmds.run(cmds.UnstageAll, context))
628 elif idx == self.idx_unmerged:
629 action = menu.addAction(icons.add(), cmds.StageUnmerged.name(),
630 cmds.run(cmds.StageUnmerged, context))
631 action.setShortcut(hotkeys.STAGE_SELECTION)
632 elif idx == self.idx_modified:
633 action = menu.addAction(icons.add(), cmds.StageModified.name(),
634 cmds.run(cmds.StageModified, context))
635 action.setShortcut(hotkeys.STAGE_SELECTION)
636 elif idx == self.idx_untracked:
637 action = menu.addAction(icons.add(), cmds.StageUntracked.name(),
638 cmds.run(cmds.StageUntracked, context))
639 action.setShortcut(hotkeys.STAGE_SELECTION)
640 return menu
642 def _create_staged_context_menu(self, menu, s):
643 if s.staged[0] in self.m.submodules:
644 return self._create_staged_submodule_context_menu(menu, s)
646 context = self.context
647 if self.m.unstageable():
648 action = menu.addAction(
649 icons.remove(), N_('Unstage Selected'),
650 cmds.run(cmds.Unstage, context, self.staged()))
651 action.setShortcut(hotkeys.STAGE_SELECTION)
653 menu.addAction(self.launch_editor_action)
655 # Do all of the selected items exist?
656 all_exist = all(i not in self.m.staged_deleted and core.exists(i)
657 for i in self.staged())
659 if all_exist:
660 menu.addAction(self.launch_difftool_action)
662 if self.m.undoable():
663 menu.addAction(self.revert_unstaged_edits_action)
665 menu.addAction(self.view_history_action)
666 menu.addAction(self.view_blame_action)
667 return menu
669 def _create_staged_submodule_context_menu(self, menu, s):
670 context = self.context
671 path = core.abspath(s.staged[0])
672 menu.addAction(icons.cola(), N_('Launch git-cola'),
673 cmds.run(cmds.OpenRepo, context, path))
674 action = menu.addAction(
675 icons.remove(), N_('Unstage Selected'),
676 cmds.run(cmds.Unstage, context, self.staged()))
677 action.setShortcut(hotkeys.STAGE_SELECTION)
679 menu.addAction(self.view_history_action)
680 return menu
682 def _create_unmerged_context_menu(self, menu, _s):
683 context = self.context
684 menu.addAction(self.launch_difftool_action)
686 action = menu.addAction(
687 icons.add(), N_('Stage Selected'),
688 cmds.run(cmds.Stage, context, self.unstaged()))
689 action.setShortcut(hotkeys.STAGE_SELECTION)
691 menu.addAction(self.launch_editor_action)
692 menu.addAction(self.view_history_action)
693 menu.addAction(self.view_blame_action)
694 return menu
696 def _create_unstaged_context_menu(self, menu, s):
697 context = self.context
698 modified_submodule = (s.modified and
699 s.modified[0] in self.m.submodules)
700 if modified_submodule:
701 return self._create_modified_submodule_context_menu(menu, s)
703 if self.m.stageable():
704 action = menu.addAction(
705 icons.add(), N_('Stage Selected'),
706 cmds.run(cmds.Stage, context, self.unstaged()))
707 action.setShortcut(hotkeys.STAGE_SELECTION)
709 if not self.selection_model.is_empty():
710 menu.addAction(self.launch_editor_action)
712 # Do all of the selected items exist?
713 all_exist = all(i not in self.m.unstaged_deleted and core.exists(i)
714 for i in self.staged())
716 if all_exist and s.modified and self.m.stageable():
717 menu.addAction(self.launch_difftool_action)
719 if s.modified and self.m.stageable():
720 if self.m.undoable():
721 menu.addSeparator()
722 menu.addAction(self.revert_unstaged_edits_action)
724 if all_exist and s.untracked:
725 # Git Annex / Git LFS
726 annex = self.m.annex
727 lfs = core.find_executable('git-lfs')
728 if annex or lfs:
729 menu.addSeparator()
730 if annex:
731 menu.addAction(self.annex_add_action)
732 if lfs:
733 menu.addAction(self.lfs_track_action)
735 menu.addSeparator()
736 if self.move_to_trash_action is not None:
737 menu.addAction(self.move_to_trash_action)
738 menu.addAction(self.delete_untracked_files_action)
739 menu.addSeparator()
740 menu.addAction(icons.edit(), N_('Add to .gitignore'),
741 partial(gitignore.gitignore_view, self.context))
743 if not self.selection_model.is_empty():
744 menu.addAction(self.view_history_action)
745 menu.addAction(self.view_blame_action)
746 return menu
748 def _create_modified_submodule_context_menu(self, menu, s):
749 context = self.context
750 path = core.abspath(s.modified[0])
751 menu.addAction(icons.cola(), N_('Launch git-cola'),
752 cmds.run(cmds.OpenRepo, context, path))
754 if self.m.stageable():
755 menu.addSeparator()
756 action = menu.addAction(
757 icons.add(), N_('Stage Selected'),
758 cmds.run(cmds.Stage, context, self.unstaged()))
759 action.setShortcut(hotkeys.STAGE_SELECTION)
761 menu.addAction(self.view_history_action)
762 return menu
764 def _delete_untracked_files(self):
765 cmds.do(cmds.Delete, self.context, self.untracked())
767 def _trash_untracked_files(self):
768 cmds.do(cmds.MoveToTrash, self.context, self.untracked())
770 def selected_path(self):
771 s = self.single_selection()
772 return s.staged or s.unmerged or s.modified or s.untracked or None
774 def single_selection(self):
775 """Scan across staged, modified, etc. and return a single item."""
776 staged = None
777 unmerged = None
778 modified = None
779 untracked = None
781 s = self.selection()
782 if s.staged:
783 staged = s.staged[0]
784 elif s.unmerged:
785 unmerged = s.unmerged[0]
786 elif s.modified:
787 modified = s.modified[0]
788 elif s.untracked:
789 untracked = s.untracked[0]
791 return selection.State(staged, unmerged, modified, untracked)
793 def selected_indexes(self):
794 """Returns a list of (category, row) representing the tree selection."""
795 selected = self.selectedIndexes()
796 result = []
797 for idx in selected:
798 if idx.parent().isValid():
799 parent_idx = idx.parent()
800 entry = (parent_idx.row(), idx.row())
801 else:
802 entry = (self.idx_header, idx.row())
803 result.append(entry)
804 return result
806 def selection(self):
807 """Return the current selection in the repo status tree."""
808 return selection.State(self.staged(), self.unmerged(),
809 self.modified(), self.untracked())
811 def contents(self):
812 return selection.State(self.m.staged, self.m.unmerged,
813 self.m.modified, self.m.untracked)
815 def all_files(self):
816 c = self.contents()
817 return c.staged + c.unmerged + c.modified + c.untracked
819 def selected_group(self):
820 """A list of selected files in various states of being"""
821 return selection.pick(self.selection())
823 def selected_idx(self):
824 c = self.contents()
825 s = self.single_selection()
826 offset = 0
827 for content, sel in zip(c, s):
828 if not content:
829 continue
830 if sel is not None:
831 return offset + content.index(sel)
832 offset += len(content)
833 return None
835 def select_by_index(self, idx):
836 c = self.contents()
837 to_try = [
838 (c.staged, self.idx_staged),
839 (c.unmerged, self.idx_unmerged),
840 (c.modified, self.idx_modified),
841 (c.untracked, self.idx_untracked),
843 for content, toplevel_idx in to_try:
844 if not content:
845 continue
846 if idx < len(content):
847 parent = self.topLevelItem(toplevel_idx)
848 item = parent.child(idx)
849 if item is not None:
850 self.select_item(item)
851 return
852 idx -= len(content)
854 def scroll_to_item(self, item):
855 # First, scroll to the item, but keep the original hscroll
856 hscroll = None
857 hscrollbar = self.horizontalScrollBar()
858 if hscrollbar:
859 hscroll = get(hscrollbar)
860 self.scrollToItem(item)
861 if hscroll is not None:
862 hscrollbar.setValue(hscroll)
864 def select_item(self, item):
865 self.scroll_to_item(item)
866 self.setCurrentItem(item)
867 item.setSelected(True)
869 def staged(self):
870 return self._subtree_selection(self.idx_staged, self.m.staged)
872 def unstaged(self):
873 return self.unmerged() + self.modified() + self.untracked()
875 def modified(self):
876 return self._subtree_selection(self.idx_modified, self.m.modified)
878 def unmerged(self):
879 return self._subtree_selection(self.idx_unmerged, self.m.unmerged)
881 def untracked(self):
882 return self._subtree_selection(self.idx_untracked, self.m.untracked)
884 def staged_items(self):
885 return self._subtree_selection_items(self.idx_staged)
887 def unstaged_items(self):
888 return (self.unmerged_items() + self.modified_items() +
889 self.untracked_items())
891 def modified_items(self):
892 return self._subtree_selection_items(self.idx_modified)
894 def unmerged_items(self):
895 return self._subtree_selection_items(self.idx_unmerged)
897 def untracked_items(self):
898 return self._subtree_selection_items(self.idx_untracked)
900 def _subtree_selection(self, idx, items):
901 item = self.topLevelItem(idx)
902 return qtutils.tree_selection(item, items)
904 def _subtree_selection_items(self, idx):
905 item = self.topLevelItem(idx)
906 return qtutils.tree_selection_items(item)
908 def _double_clicked(self, _item, _idx):
909 """Called when an item is double-clicked in the repo status tree."""
910 cmds.do(cmds.StageOrUnstage, self.context)
912 def show_selection(self):
913 """Show the selected item."""
914 context = self.context
915 self.scroll_to_item(self.currentItem())
916 # Sync the selection model
917 selected = self.selection()
918 selection_model = self.selection_model
919 selection_model.set_selection(selected)
920 self._update_actions(selected=selected)
922 selected_indexes = self.selected_indexes()
923 if not selected_indexes:
924 if self.m.amending():
925 cmds.do(cmds.SetDiffText, context, '')
926 else:
927 cmds.do(cmds.ResetMode, context)
928 return
930 # A header item e.g. 'Staged', 'Modified', etc.
931 category, idx = selected_indexes[0]
932 header = category == self.idx_header
933 if header:
934 cls = {
935 self.idx_staged: cmds.DiffStagedSummary,
936 self.idx_modified: cmds.Diffstat,
937 # TODO implement UnmergedSummary
938 # self.idx_unmerged: cmds.UnmergedSummary,
939 self.idx_untracked: cmds.UntrackedSummary,
940 }.get(idx, cmds.Diffstat)
941 cmds.do(cls, context)
942 return
944 staged = category == self.idx_staged
945 modified = category == self.idx_modified
946 unmerged = category == self.idx_unmerged
947 untracked = category == self.idx_untracked
949 if staged:
950 item = self.staged_items()[0]
951 elif unmerged:
952 item = self.unmerged_items()[0]
953 elif modified:
954 item = self.modified_items()[0]
955 elif untracked:
956 item = self.unstaged_items()[0]
957 else:
958 item = None # this shouldn't happen
959 assert item is not None
961 path = item.path
962 deleted = item.deleted
963 image = self.image_formats.ok(path)
965 # Images are diffed differently
966 if image:
967 cmds.do(cmds.DiffImage, context, path, deleted,
968 staged, modified, unmerged, untracked)
969 elif staged:
970 cmds.do(cmds.DiffStaged, context, path, deleted=deleted)
971 elif modified:
972 cmds.do(cmds.Diff, context, path, deleted=deleted)
973 elif unmerged:
974 cmds.do(cmds.Diff, context, path)
975 elif untracked:
976 cmds.do(cmds.ShowUntracked, context, path)
978 def select_header(self):
979 """Select an active header, which triggers a diffstat"""
980 for idx in (self.idx_staged, self.idx_unmerged,
981 self.idx_modified, self.idx_untracked):
982 item = self.topLevelItem(idx)
983 if item.childCount() > 0:
984 self.clearSelection()
985 self.setCurrentItem(item)
986 return
988 def move_up(self):
989 idx = self.selected_idx()
990 all_files = self.all_files()
991 if idx is None:
992 selected_indexes = self.selected_indexes()
993 if selected_indexes:
994 category, toplevel_idx = selected_indexes[0]
995 if category == self.idx_header:
996 item = self.itemAbove(self.topLevelItem(toplevel_idx))
997 if item is not None:
998 self.select_item(item)
999 return
1000 if all_files:
1001 self.select_by_index(len(all_files) - 1)
1002 return
1003 if idx - 1 >= 0:
1004 self.select_by_index(idx - 1)
1005 else:
1006 self.select_by_index(len(all_files) - 1)
1008 def move_down(self):
1009 idx = self.selected_idx()
1010 all_files = self.all_files()
1011 if idx is None:
1012 selected_indexes = self.selected_indexes()
1013 if selected_indexes:
1014 category, toplevel_idx = selected_indexes[0]
1015 if category == self.idx_header:
1016 item = self.itemBelow(self.topLevelItem(toplevel_idx))
1017 if item is not None:
1018 self.select_item(item)
1019 return
1020 if all_files:
1021 self.select_by_index(0)
1022 return
1023 if idx + 1 < len(all_files):
1024 self.select_by_index(idx + 1)
1025 else:
1026 self.select_by_index(0)
1028 def mimeData(self, items):
1029 """Return a list of absolute-path URLs"""
1030 context = self.context
1031 paths = qtutils.paths_from_items(items, item_filter=_item_filter)
1032 return qtutils.mimedata_from_paths(context, paths)
1034 def mimeTypes(self):
1035 return qtutils.path_mimetypes()
1038 def _item_filter(item):
1039 return not item.deleted and core.exists(item.path)
1042 def view_blame(context):
1043 """Signal that we should view blame for paths."""
1044 cmds.do(cmds.BlamePaths, context, context.selection.union())
1047 def view_history(context):
1048 """Signal that we should view history for paths."""
1049 cmds.do(cmds.VisualizePaths, context, context.selection.union())
1052 def copy_path(context, absolute=True):
1053 """Copy a selected path to the clipboard"""
1054 filename = context.selection.filename()
1055 qtutils.copy_path(filename, absolute=absolute)
1058 def copy_relpath(context):
1059 """Copy a selected relative path to the clipboard"""
1060 copy_path(context, absolute=False)
1063 def copy_basename(context):
1064 filename = os.path.basename(context.selection.filename())
1065 basename, _ = os.path.splitext(filename)
1066 qtutils.copy_path(basename, absolute=False)
1069 def copy_leading_path(context):
1070 """Copy the selected leading path to the clipboard"""
1071 filename = context.selection.filename()
1072 dirname = os.path.dirname(filename)
1073 qtutils.copy_path(dirname, absolute=False)
1076 def copy_format(context, fmt):
1077 values = {}
1078 values['path'] = path = context.selection.filename()
1079 values['abspath'] = abspath = os.path.abspath(path)
1080 values['absdirname'] = os.path.dirname(abspath)
1081 values['dirname'] = os.path.dirname(path)
1082 values['filename'] = os.path.basename(path)
1083 values['basename'], values['ext'] = (
1084 os.path.splitext(os.path.basename(path)))
1085 qtutils.set_clipboard(fmt % values)
1088 def show_help(context):
1089 help_text = N_(r"""
1090 Format String Variables
1091 -----------------------
1092 %(path)s = relative file path
1093 %(abspath)s = absolute file path
1094 %(dirname)s = relative directory path
1095 %(absdirname)s = absolute directory path
1096 %(filename)s = file basename
1097 %(basename)s = file basename without extension
1098 %(ext)s = file extension
1099 """)
1100 title = N_('Help - Custom Copy Actions')
1101 return text.text_dialog(context, help_text, title)
1104 class StatusFilterWidget(QtWidgets.QWidget):
1106 def __init__(self, context, parent=None):
1107 QtWidgets.QWidget.__init__(self, parent)
1108 self.main_model = context.model
1110 hint = N_('Filter paths...')
1111 self.text = completion.GitStatusFilterLineEdit(
1112 context, hint=hint, parent=self)
1113 self.text.setToolTip(hint)
1114 self.setFocusProxy(self.text)
1115 self._filter = None
1117 self.main_layout = qtutils.hbox(defs.no_margin, defs.spacing, self.text)
1118 self.setLayout(self.main_layout)
1120 widget = self.text
1121 widget.changed.connect(self.apply_filter)
1122 widget.cleared.connect(self.apply_filter)
1123 widget.enter.connect(self.apply_filter)
1124 widget.editingFinished.connect(self.apply_filter)
1126 def apply_filter(self):
1127 value = get(self.text)
1128 if value == self._filter:
1129 return
1130 self._filter = value
1131 paths = utils.shell_split(value)
1132 self.main_model.update_path_filter(paths)
1135 def customize_copy_actions(context, parent):
1136 """Customize copy actions"""
1137 dialog = CustomizeCopyActions(context, parent)
1138 dialog.show()
1139 dialog.exec_()
1142 class CustomizeCopyActions(standard.Dialog):
1144 def __init__(self, context, parent):
1145 standard.Dialog.__init__(self, parent=parent)
1146 self.setWindowTitle(N_('Custom Copy Actions'))
1148 self.table = QtWidgets.QTableWidget(self)
1149 self.table.setColumnCount(2)
1150 self.table.setHorizontalHeaderLabels([
1151 N_('Action Name'),
1152 N_('Format String'),
1154 self.table.setSortingEnabled(False)
1155 self.table.verticalHeader().hide()
1156 self.table.horizontalHeader().setStretchLastSection(True)
1158 self.add_button = qtutils.create_button(N_('Add'))
1159 self.remove_button = qtutils.create_button(N_('Remove'))
1160 self.remove_button.setEnabled(False)
1161 self.show_help_button = qtutils.create_button(N_('Show Help'))
1162 self.show_help_button.setShortcut(hotkeys.QUESTION)
1164 self.close_button = qtutils.close_button()
1165 self.save_button = qtutils.ok_button(N_('Save'))
1167 self.buttons = qtutils.hbox(defs.no_margin, defs.button_spacing,
1168 self.add_button,
1169 self.remove_button,
1170 self.show_help_button,
1171 qtutils.STRETCH,
1172 self.close_button,
1173 self.save_button)
1175 layout = qtutils.vbox(defs.margin, defs.spacing,
1176 self.table, self.buttons)
1177 self.setLayout(layout)
1179 qtutils.connect_button(self.add_button, self.add)
1180 qtutils.connect_button(self.remove_button, self.remove)
1181 qtutils.connect_button(
1182 self.show_help_button, partial(show_help, context))
1183 qtutils.connect_button(self.close_button, self.reject)
1184 qtutils.connect_button(self.save_button, self.save)
1185 qtutils.add_close_action(self)
1186 self.table.itemSelectionChanged.connect(self.table_selection_changed)
1188 self.init_size(parent=parent)
1190 self.settings = settings.Settings()
1191 QtCore.QTimer.singleShot(0, self.reload_settings)
1193 def reload_settings(self):
1194 # Called once after the GUI is initialized
1195 self.settings.load()
1196 table = self.table
1197 for entry in self.settings.copy_formats:
1198 name_string = entry.get('name', '')
1199 format_string = entry.get('format', '')
1200 if name_string and format_string:
1201 name = QtWidgets.QTableWidgetItem(name_string)
1202 fmt = QtWidgets.QTableWidgetItem(format_string)
1203 rows = table.rowCount()
1204 table.setRowCount(rows + 1)
1205 table.setItem(rows, 0, name)
1206 table.setItem(rows, 1, fmt)
1208 def export_state(self):
1209 state = super(CustomizeCopyActions, self).export_state()
1210 standard.export_header_columns(self.table, state)
1211 return state
1213 def apply_state(self, state):
1214 result = super(CustomizeCopyActions, self).apply_state(state)
1215 standard.apply_header_columns(self.table, state)
1216 return result
1218 def add(self):
1219 self.table.setFocus(True)
1220 rows = self.table.rowCount()
1221 self.table.setRowCount(rows + 1)
1223 name = QtWidgets.QTableWidgetItem(N_('Name'))
1224 fmt = QtWidgets.QTableWidgetItem(r'%(path)s')
1225 self.table.setItem(rows, 0, name)
1226 self.table.setItem(rows, 1, fmt)
1228 self.table.setCurrentCell(rows, 0)
1229 self.table.editItem(name)
1231 def remove(self):
1232 """Remove selected items"""
1233 # Gather a unique set of rows and remove them in reverse order
1234 rows = set()
1235 items = self.table.selectedItems()
1236 for item in items:
1237 rows.add(self.table.row(item))
1239 for row in reversed(sorted(rows)):
1240 self.table.removeRow(row)
1242 def save(self):
1243 copy_formats = []
1244 for row in range(self.table.rowCount()):
1245 name = self.table.item(row, 0)
1246 fmt = self.table.item(row, 1)
1247 if name and fmt:
1248 entry = {
1249 'name': name.text(),
1250 'format': fmt.text(),
1252 copy_formats.append(entry)
1254 while self.settings.copy_formats:
1255 self.settings.copy_formats.pop()
1257 self.settings.copy_formats.extend(copy_formats)
1258 self.settings.save()
1260 self.accept()
1262 def table_selection_changed(self):
1263 items = self.table.selectedItems()
1264 self.remove_button.setEnabled(bool(items))