core: make getcwd() fail-safe
[git-cola.git] / cola / widgets / status.py
blob96b2ffb526d09c5ce53402518b88ce63a08963d5
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 class StatusTreeWidget(QtWidgets.QTreeWidget):
95 # Signals
96 about_to_update = Signal()
97 updated = Signal()
98 diff_text_changed = Signal()
100 # Item categories
101 idx_header = -1
102 idx_staged = 0
103 idx_unmerged = 1
104 idx_modified = 2
105 idx_untracked = 3
106 idx_end = 4
108 # Read-only access to the mode state
109 mode = property(lambda self: self.m.mode)
111 def __init__(self, context, parent=None):
112 QtWidgets.QTreeWidget.__init__(self, parent)
113 self.context = context
114 self.selection_model = context.selection
116 self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
117 self.headerItem().setHidden(True)
118 self.setAllColumnsShowFocus(True)
119 self.setSortingEnabled(False)
120 self.setUniformRowHeights(True)
121 self.setAnimated(True)
122 self.setRootIsDecorated(False)
123 self.setDragEnabled(True)
124 self.setAutoScroll(False)
126 if not prefs.status_indent(context):
127 self.setIndentation(0)
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, N_('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, N_('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, N_('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, N_('Untracked'),
504 untracked=True)
506 def _set_subtree(self, items, idx, parent_title,
507 staged=False,
508 untracked=False,
509 deleted_set=None):
510 """Add a list of items to a treewidget item."""
511 self.blockSignals(True)
512 parent = self.topLevelItem(idx)
513 hide = not bool(items)
514 parent.setHidden(hide)
516 # sip v4.14.7 and below leak memory in parent.takeChildren()
517 # so we use this backwards-compatible construct instead
518 while parent.takeChild(0) is not None:
519 pass
521 for item in items:
522 deleted = (deleted_set is not None and item in deleted_set)
523 treeitem = qtutils.create_treeitem(item,
524 staged=staged,
525 deleted=deleted,
526 untracked=untracked)
527 parent.addChild(treeitem)
528 self._expand_items(idx, items)
529 self.blockSignals(False)
530 if prefs.status_show_totals(self.context):
531 parent.setText(0, '%s (%s)' % (parent_title, len(items)))
533 def _update_column_widths(self):
534 self.resizeColumnToContents(0)
536 def _expand_items(self, idx, items):
537 """Expand the top-level category "folder" once and only once."""
538 # Don't do this if items is empty; this makes it so that we
539 # don't add the top-level index into the expanded_items set
540 # until an item appears in a particular category.
541 if not items:
542 return
543 # Only run this once; we don't want to re-expand items that
544 # we've clicked on to re-collapse on updated().
545 if idx in self.expanded_items:
546 return
547 self.expanded_items.add(idx)
548 item = self.topLevelItem(idx)
549 if item:
550 self.expandItem(item)
552 def contextMenuEvent(self, event):
553 """Create context menus for the repo status tree."""
554 menu = self._create_context_menu()
555 menu.exec_(self.mapToGlobal(event.pos()))
557 def _create_context_menu(self):
558 """Set up the status menu for the repo status tree."""
559 s = self.selection()
560 menu = qtutils.create_menu('Status', self)
561 selected_indexes = self.selected_indexes()
562 if selected_indexes:
563 category, idx = selected_indexes[0]
564 # A header item e.g. 'Staged', 'Modified', etc.
565 if category == self.idx_header:
566 return self._create_header_context_menu(menu, idx)
568 if s.staged:
569 self._create_staged_context_menu(menu, s)
570 elif s.unmerged:
571 self._create_unmerged_context_menu(menu, s)
572 else:
573 self._create_unstaged_context_menu(menu, s)
575 if not utils.is_win32():
576 if not menu.isEmpty():
577 menu.addSeparator()
578 if not self.selection_model.is_empty():
579 menu.addAction(self.default_app_action)
580 menu.addAction(self.parent_dir_action)
582 if self.terminal_action is not None:
583 menu.addAction(self.terminal_action)
585 self._add_copy_actions(menu)
587 return menu
589 def _add_copy_actions(self, menu):
590 """Add the "Copy" sub-menu"""
591 enabled = self.selection_model.filename() is not None
592 self.copy_path_action.setEnabled(enabled)
593 self.copy_relpath_action.setEnabled(enabled)
594 self.copy_leading_path_action.setEnabled(enabled)
595 self.copy_basename_action.setEnabled(enabled)
596 copy_icon = icons.copy()
598 menu.addSeparator()
599 copy_menu = QtWidgets.QMenu(N_('Copy...'), menu)
600 menu.addMenu(copy_menu)
602 copy_menu.setIcon(copy_icon)
603 copy_menu.addAction(self.copy_path_action)
604 copy_menu.addAction(self.copy_relpath_action)
605 copy_menu.addAction(self.copy_leading_path_action)
606 copy_menu.addAction(self.copy_basename_action)
608 current_settings = settings.Settings()
609 current_settings.load()
611 copy_formats = current_settings.copy_formats
612 if copy_formats:
613 copy_menu.addSeparator()
615 context = self.context
616 for entry in copy_formats:
617 name = entry.get('name', '')
618 fmt = entry.get('format', '')
619 if name and fmt:
620 action = copy_menu.addAction(
621 name, partial(copy_format, context, fmt))
622 action.setIcon(copy_icon)
623 action.setEnabled(enabled)
625 copy_menu.addSeparator()
626 copy_menu.addAction(self.copy_customize_action)
628 def _create_header_context_menu(self, menu, idx):
629 context = self.context
630 if idx == self.idx_staged:
631 menu.addAction(icons.remove(), N_('Unstage All'),
632 cmds.run(cmds.UnstageAll, context))
633 elif idx == self.idx_unmerged:
634 action = menu.addAction(icons.add(), cmds.StageUnmerged.name(),
635 cmds.run(cmds.StageUnmerged, context))
636 action.setShortcut(hotkeys.STAGE_SELECTION)
637 elif idx == self.idx_modified:
638 action = menu.addAction(icons.add(), cmds.StageModified.name(),
639 cmds.run(cmds.StageModified, context))
640 action.setShortcut(hotkeys.STAGE_SELECTION)
641 elif idx == self.idx_untracked:
642 action = menu.addAction(icons.add(), cmds.StageUntracked.name(),
643 cmds.run(cmds.StageUntracked, context))
644 action.setShortcut(hotkeys.STAGE_SELECTION)
645 return menu
647 def _create_staged_context_menu(self, menu, s):
648 if s.staged[0] in self.m.submodules:
649 return self._create_staged_submodule_context_menu(menu, s)
651 context = self.context
652 if self.m.unstageable():
653 action = menu.addAction(
654 icons.remove(), N_('Unstage Selected'),
655 cmds.run(cmds.Unstage, context, self.staged()))
656 action.setShortcut(hotkeys.STAGE_SELECTION)
658 menu.addAction(self.launch_editor_action)
660 # Do all of the selected items exist?
661 all_exist = all(i not in self.m.staged_deleted and core.exists(i)
662 for i in self.staged())
664 if all_exist:
665 menu.addAction(self.launch_difftool_action)
667 if self.m.undoable():
668 menu.addAction(self.revert_unstaged_edits_action)
670 menu.addAction(self.view_history_action)
671 menu.addAction(self.view_blame_action)
672 return menu
674 def _create_staged_submodule_context_menu(self, menu, s):
675 context = self.context
676 path = core.abspath(s.staged[0])
677 if len(self.staged()) == 1:
678 menu.addAction(icons.cola(), N_('Launch git-cola'),
679 cmds.run(cmds.OpenRepo, context, path))
680 menu.addSeparator()
681 action = menu.addAction(
682 icons.remove(), N_('Unstage Selected'),
683 cmds.run(cmds.Unstage, context, self.staged()))
684 action.setShortcut(hotkeys.STAGE_SELECTION)
686 menu.addAction(self.view_history_action)
687 return menu
689 def _create_unmerged_context_menu(self, menu, _s):
690 context = self.context
691 menu.addAction(self.launch_difftool_action)
693 action = menu.addAction(
694 icons.add(), N_('Stage Selected'),
695 cmds.run(cmds.Stage, context, self.unstaged()))
696 action.setShortcut(hotkeys.STAGE_SELECTION)
698 menu.addAction(self.launch_editor_action)
699 menu.addAction(self.view_history_action)
700 menu.addAction(self.view_blame_action)
701 return menu
703 def _create_unstaged_context_menu(self, menu, s):
704 context = self.context
705 modified_submodule = (s.modified and
706 s.modified[0] in self.m.submodules)
707 if modified_submodule:
708 return self._create_modified_submodule_context_menu(menu, s)
710 if self.m.stageable():
711 action = menu.addAction(
712 icons.add(), N_('Stage Selected'),
713 cmds.run(cmds.Stage, context, self.unstaged()))
714 action.setShortcut(hotkeys.STAGE_SELECTION)
716 if not self.selection_model.is_empty():
717 menu.addAction(self.launch_editor_action)
719 # Do all of the selected items exist?
720 all_exist = all(i not in self.m.unstaged_deleted and core.exists(i)
721 for i in self.staged())
723 if all_exist and s.modified and self.m.stageable():
724 menu.addAction(self.launch_difftool_action)
726 if s.modified and self.m.stageable():
727 if self.m.undoable():
728 menu.addSeparator()
729 menu.addAction(self.revert_unstaged_edits_action)
731 if all_exist and s.untracked:
732 # Git Annex / Git LFS
733 annex = self.m.annex
734 lfs = core.find_executable('git-lfs')
735 if annex or lfs:
736 menu.addSeparator()
737 if annex:
738 menu.addAction(self.annex_add_action)
739 if lfs:
740 menu.addAction(self.lfs_track_action)
742 menu.addSeparator()
743 if self.move_to_trash_action is not None:
744 menu.addAction(self.move_to_trash_action)
745 menu.addAction(self.delete_untracked_files_action)
746 menu.addSeparator()
747 menu.addAction(icons.edit(), N_('Add to .gitignore'),
748 partial(gitignore.gitignore_view, self.context))
750 if not self.selection_model.is_empty():
751 menu.addAction(self.view_history_action)
752 menu.addAction(self.view_blame_action)
753 return menu
755 def _create_modified_submodule_context_menu(self, menu, s):
756 context = self.context
757 path = core.abspath(s.modified[0])
758 if len(self.unstaged()) == 1:
759 menu.addAction(icons.cola(), N_('Launch git-cola'),
760 cmds.run(cmds.OpenRepo, context, path))
761 menu.addAction(icons.pull(), N_('Update this submodule'),
762 cmds.run(cmds.SubmoduleUpdate, context, path))
763 menu.addSeparator()
765 if self.m.stageable():
766 menu.addSeparator()
767 action = menu.addAction(
768 icons.add(), N_('Stage Selected'),
769 cmds.run(cmds.Stage, context, self.unstaged()))
770 action.setShortcut(hotkeys.STAGE_SELECTION)
772 menu.addAction(self.view_history_action)
773 return menu
775 def _delete_untracked_files(self):
776 cmds.do(cmds.Delete, self.context, self.untracked())
778 def _trash_untracked_files(self):
779 cmds.do(cmds.MoveToTrash, self.context, self.untracked())
781 def selected_path(self):
782 s = self.single_selection()
783 return s.staged or s.unmerged or s.modified or s.untracked or None
785 def single_selection(self):
786 """Scan across staged, modified, etc. and return a single item."""
787 staged = None
788 unmerged = None
789 modified = None
790 untracked = None
792 s = self.selection()
793 if s.staged:
794 staged = s.staged[0]
795 elif s.unmerged:
796 unmerged = s.unmerged[0]
797 elif s.modified:
798 modified = s.modified[0]
799 elif s.untracked:
800 untracked = s.untracked[0]
802 return selection.State(staged, unmerged, modified, untracked)
804 def selected_indexes(self):
805 """Returns a list of (category, row) representing the tree selection."""
806 selected = self.selectedIndexes()
807 result = []
808 for idx in selected:
809 if idx.parent().isValid():
810 parent_idx = idx.parent()
811 entry = (parent_idx.row(), idx.row())
812 else:
813 entry = (self.idx_header, idx.row())
814 result.append(entry)
815 return result
817 def selection(self):
818 """Return the current selection in the repo status tree."""
819 return selection.State(self.staged(), self.unmerged(),
820 self.modified(), self.untracked())
822 def contents(self):
823 return selection.State(self.m.staged, self.m.unmerged,
824 self.m.modified, self.m.untracked)
826 def all_files(self):
827 c = self.contents()
828 return c.staged + c.unmerged + c.modified + c.untracked
830 def selected_group(self):
831 """A list of selected files in various states of being"""
832 return selection.pick(self.selection())
834 def selected_idx(self):
835 c = self.contents()
836 s = self.single_selection()
837 offset = 0
838 for content, sel in zip(c, s):
839 if not content:
840 continue
841 if sel is not None:
842 return offset + content.index(sel)
843 offset += len(content)
844 return None
846 def select_by_index(self, idx):
847 c = self.contents()
848 to_try = [
849 (c.staged, self.idx_staged),
850 (c.unmerged, self.idx_unmerged),
851 (c.modified, self.idx_modified),
852 (c.untracked, self.idx_untracked),
854 for content, toplevel_idx in to_try:
855 if not content:
856 continue
857 if idx < len(content):
858 parent = self.topLevelItem(toplevel_idx)
859 item = parent.child(idx)
860 if item is not None:
861 self.select_item(item)
862 return
863 idx -= len(content)
865 def scroll_to_item(self, item):
866 # First, scroll to the item, but keep the original hscroll
867 hscroll = None
868 hscrollbar = self.horizontalScrollBar()
869 if hscrollbar:
870 hscroll = get(hscrollbar)
871 self.scrollToItem(item)
872 if hscroll is not None:
873 hscrollbar.setValue(hscroll)
875 def select_item(self, item):
876 self.scroll_to_item(item)
877 self.setCurrentItem(item)
878 item.setSelected(True)
880 def staged(self):
881 return self._subtree_selection(self.idx_staged, self.m.staged)
883 def unstaged(self):
884 return self.unmerged() + self.modified() + self.untracked()
886 def modified(self):
887 return self._subtree_selection(self.idx_modified, self.m.modified)
889 def unmerged(self):
890 return self._subtree_selection(self.idx_unmerged, self.m.unmerged)
892 def untracked(self):
893 return self._subtree_selection(self.idx_untracked, self.m.untracked)
895 def staged_items(self):
896 return self._subtree_selection_items(self.idx_staged)
898 def unstaged_items(self):
899 return (self.unmerged_items() + self.modified_items() +
900 self.untracked_items())
902 def modified_items(self):
903 return self._subtree_selection_items(self.idx_modified)
905 def unmerged_items(self):
906 return self._subtree_selection_items(self.idx_unmerged)
908 def untracked_items(self):
909 return self._subtree_selection_items(self.idx_untracked)
911 def _subtree_selection(self, idx, items):
912 item = self.topLevelItem(idx)
913 return qtutils.tree_selection(item, items)
915 def _subtree_selection_items(self, idx):
916 item = self.topLevelItem(idx)
917 return qtutils.tree_selection_items(item)
919 def _double_clicked(self, _item, _idx):
920 """Called when an item is double-clicked in the repo status tree."""
921 cmds.do(cmds.StageOrUnstage, self.context)
923 def show_selection(self):
924 """Show the selected item."""
925 context = self.context
926 self.scroll_to_item(self.currentItem())
927 # Sync the selection model
928 selected = self.selection()
929 selection_model = self.selection_model
930 selection_model.set_selection(selected)
931 self._update_actions(selected=selected)
933 selected_indexes = self.selected_indexes()
934 if not selected_indexes:
935 if self.m.amending():
936 cmds.do(cmds.SetDiffText, context, '')
937 else:
938 cmds.do(cmds.ResetMode, context)
939 return
941 # A header item e.g. 'Staged', 'Modified', etc.
942 category, idx = selected_indexes[0]
943 header = category == self.idx_header
944 if header:
945 cls = {
946 self.idx_staged: cmds.DiffStagedSummary,
947 self.idx_modified: cmds.Diffstat,
948 # TODO implement UnmergedSummary
949 # self.idx_unmerged: cmds.UnmergedSummary,
950 self.idx_untracked: cmds.UntrackedSummary,
951 }.get(idx, cmds.Diffstat)
952 cmds.do(cls, context)
953 return
955 staged = category == self.idx_staged
956 modified = category == self.idx_modified
957 unmerged = category == self.idx_unmerged
958 untracked = category == self.idx_untracked
960 if staged:
961 item = self.staged_items()[0]
962 elif unmerged:
963 item = self.unmerged_items()[0]
964 elif modified:
965 item = self.modified_items()[0]
966 elif untracked:
967 item = self.unstaged_items()[0]
968 else:
969 item = None # this shouldn't happen
970 assert item is not None
972 path = item.path
973 deleted = item.deleted
974 image = self.image_formats.ok(path)
976 # Images are diffed differently
977 if image:
978 cmds.do(cmds.DiffImage, context, path, deleted,
979 staged, modified, unmerged, untracked)
980 elif staged:
981 cmds.do(cmds.DiffStaged, context, path, deleted=deleted)
982 elif modified:
983 cmds.do(cmds.Diff, context, path, deleted=deleted)
984 elif unmerged:
985 cmds.do(cmds.Diff, context, path)
986 elif untracked:
987 cmds.do(cmds.ShowUntracked, context, path)
989 def select_header(self):
990 """Select an active header, which triggers a diffstat"""
991 for idx in (self.idx_staged, self.idx_unmerged,
992 self.idx_modified, self.idx_untracked):
993 item = self.topLevelItem(idx)
994 if item.childCount() > 0:
995 self.clearSelection()
996 self.setCurrentItem(item)
997 return
999 def move_up(self):
1000 idx = self.selected_idx()
1001 all_files = self.all_files()
1002 if idx is None:
1003 selected_indexes = self.selected_indexes()
1004 if selected_indexes:
1005 category, toplevel_idx = selected_indexes[0]
1006 if category == self.idx_header:
1007 item = self.itemAbove(self.topLevelItem(toplevel_idx))
1008 if item is not None:
1009 self.select_item(item)
1010 return
1011 if all_files:
1012 self.select_by_index(len(all_files) - 1)
1013 return
1014 if idx - 1 >= 0:
1015 self.select_by_index(idx - 1)
1016 else:
1017 self.select_by_index(len(all_files) - 1)
1019 def move_down(self):
1020 idx = self.selected_idx()
1021 all_files = self.all_files()
1022 if idx is None:
1023 selected_indexes = self.selected_indexes()
1024 if selected_indexes:
1025 category, toplevel_idx = selected_indexes[0]
1026 if category == self.idx_header:
1027 item = self.itemBelow(self.topLevelItem(toplevel_idx))
1028 if item is not None:
1029 self.select_item(item)
1030 return
1031 if all_files:
1032 self.select_by_index(0)
1033 return
1034 if idx + 1 < len(all_files):
1035 self.select_by_index(idx + 1)
1036 else:
1037 self.select_by_index(0)
1039 def mimeData(self, items):
1040 """Return a list of absolute-path URLs"""
1041 context = self.context
1042 paths = qtutils.paths_from_items(items, item_filter=_item_filter)
1043 return qtutils.mimedata_from_paths(context, paths)
1045 # pylint: disable=no-self-use
1046 def mimeTypes(self):
1047 return qtutils.path_mimetypes()
1050 def _item_filter(item):
1051 return not item.deleted and core.exists(item.path)
1054 def view_blame(context):
1055 """Signal that we should view blame for paths."""
1056 cmds.do(cmds.BlamePaths, context)
1059 def view_history(context):
1060 """Signal that we should view history for paths."""
1061 cmds.do(cmds.VisualizePaths, context, context.selection.union())
1064 def copy_path(context, absolute=True):
1065 """Copy a selected path to the clipboard"""
1066 filename = context.selection.filename()
1067 qtutils.copy_path(filename, absolute=absolute)
1070 def copy_relpath(context):
1071 """Copy a selected relative path to the clipboard"""
1072 copy_path(context, absolute=False)
1075 def copy_basename(context):
1076 filename = os.path.basename(context.selection.filename())
1077 basename, _ = os.path.splitext(filename)
1078 qtutils.copy_path(basename, absolute=False)
1081 def copy_leading_path(context):
1082 """Copy the selected leading path to the clipboard"""
1083 filename = context.selection.filename()
1084 dirname = os.path.dirname(filename)
1085 qtutils.copy_path(dirname, absolute=False)
1088 def copy_format(context, fmt):
1089 values = {}
1090 values['path'] = path = context.selection.filename()
1091 values['abspath'] = abspath = os.path.abspath(path)
1092 values['absdirname'] = os.path.dirname(abspath)
1093 values['dirname'] = os.path.dirname(path)
1094 values['filename'] = os.path.basename(path)
1095 values['basename'], values['ext'] = (
1096 os.path.splitext(os.path.basename(path)))
1097 qtutils.set_clipboard(fmt % values)
1100 def show_help(context):
1101 help_text = N_(r"""
1102 Format String Variables
1103 -----------------------
1104 %(path)s = relative file path
1105 %(abspath)s = absolute file path
1106 %(dirname)s = relative directory path
1107 %(absdirname)s = absolute directory path
1108 %(filename)s = file basename
1109 %(basename)s = file basename without extension
1110 %(ext)s = file extension
1111 """)
1112 title = N_('Help - Custom Copy Actions')
1113 return text.text_dialog(context, help_text, title)
1116 class StatusFilterWidget(QtWidgets.QWidget):
1118 def __init__(self, context, parent=None):
1119 QtWidgets.QWidget.__init__(self, parent)
1120 self.main_model = context.model
1122 hint = N_('Filter paths...')
1123 self.text = completion.GitStatusFilterLineEdit(
1124 context, hint=hint, parent=self)
1125 self.text.setToolTip(hint)
1126 self.setFocusProxy(self.text)
1127 self._filter = None
1129 self.main_layout = qtutils.hbox(defs.no_margin, defs.spacing, self.text)
1130 self.setLayout(self.main_layout)
1132 widget = self.text
1133 widget.changed.connect(self.apply_filter)
1134 widget.cleared.connect(self.apply_filter)
1135 widget.enter.connect(self.apply_filter)
1136 widget.editingFinished.connect(self.apply_filter)
1138 def apply_filter(self):
1139 value = get(self.text)
1140 if value == self._filter:
1141 return
1142 self._filter = value
1143 paths = utils.shell_split(value)
1144 self.main_model.update_path_filter(paths)
1147 def customize_copy_actions(context, parent):
1148 """Customize copy actions"""
1149 dialog = CustomizeCopyActions(context, parent)
1150 dialog.show()
1151 dialog.exec_()
1154 class CustomizeCopyActions(standard.Dialog):
1156 def __init__(self, context, parent):
1157 standard.Dialog.__init__(self, parent=parent)
1158 self.setWindowTitle(N_('Custom Copy Actions'))
1160 self.table = QtWidgets.QTableWidget(self)
1161 self.table.setColumnCount(2)
1162 self.table.setHorizontalHeaderLabels([
1163 N_('Action Name'),
1164 N_('Format String'),
1166 self.table.setSortingEnabled(False)
1167 self.table.verticalHeader().hide()
1168 self.table.horizontalHeader().setStretchLastSection(True)
1170 self.add_button = qtutils.create_button(N_('Add'))
1171 self.remove_button = qtutils.create_button(N_('Remove'))
1172 self.remove_button.setEnabled(False)
1173 self.show_help_button = qtutils.create_button(N_('Show Help'))
1174 self.show_help_button.setShortcut(hotkeys.QUESTION)
1176 self.close_button = qtutils.close_button()
1177 self.save_button = qtutils.ok_button(N_('Save'))
1179 self.buttons = qtutils.hbox(defs.no_margin, defs.button_spacing,
1180 self.add_button,
1181 self.remove_button,
1182 self.show_help_button,
1183 qtutils.STRETCH,
1184 self.close_button,
1185 self.save_button)
1187 layout = qtutils.vbox(defs.margin, defs.spacing,
1188 self.table, self.buttons)
1189 self.setLayout(layout)
1191 qtutils.connect_button(self.add_button, self.add)
1192 qtutils.connect_button(self.remove_button, self.remove)
1193 qtutils.connect_button(
1194 self.show_help_button, partial(show_help, context))
1195 qtutils.connect_button(self.close_button, self.reject)
1196 qtutils.connect_button(self.save_button, self.save)
1197 qtutils.add_close_action(self)
1198 self.table.itemSelectionChanged.connect(self.table_selection_changed)
1200 self.init_size(parent=parent)
1202 self.settings = settings.Settings()
1203 QtCore.QTimer.singleShot(0, self.reload_settings)
1205 def reload_settings(self):
1206 # Called once after the GUI is initialized
1207 self.settings.load()
1208 table = self.table
1209 for entry in self.settings.copy_formats:
1210 name_string = entry.get('name', '')
1211 format_string = entry.get('format', '')
1212 if name_string and format_string:
1213 name = QtWidgets.QTableWidgetItem(name_string)
1214 fmt = QtWidgets.QTableWidgetItem(format_string)
1215 rows = table.rowCount()
1216 table.setRowCount(rows + 1)
1217 table.setItem(rows, 0, name)
1218 table.setItem(rows, 1, fmt)
1220 def export_state(self):
1221 state = super(CustomizeCopyActions, self).export_state()
1222 standard.export_header_columns(self.table, state)
1223 return state
1225 def apply_state(self, state):
1226 result = super(CustomizeCopyActions, self).apply_state(state)
1227 standard.apply_header_columns(self.table, state)
1228 return result
1230 def add(self):
1231 self.table.setFocus()
1232 rows = self.table.rowCount()
1233 self.table.setRowCount(rows + 1)
1235 name = QtWidgets.QTableWidgetItem(N_('Name'))
1236 fmt = QtWidgets.QTableWidgetItem(r'%(path)s')
1237 self.table.setItem(rows, 0, name)
1238 self.table.setItem(rows, 1, fmt)
1240 self.table.setCurrentCell(rows, 0)
1241 self.table.editItem(name)
1243 def remove(self):
1244 """Remove selected items"""
1245 # Gather a unique set of rows and remove them in reverse order
1246 rows = set()
1247 items = self.table.selectedItems()
1248 for item in items:
1249 rows.add(self.table.row(item))
1251 for row in reversed(sorted(rows)):
1252 self.table.removeRow(row)
1254 def save(self):
1255 copy_formats = []
1256 for row in range(self.table.rowCount()):
1257 name = self.table.item(row, 0)
1258 fmt = self.table.item(row, 1)
1259 if name and fmt:
1260 entry = {
1261 'name': name.text(),
1262 'format': fmt.text(),
1264 copy_formats.append(entry)
1266 while self.settings.copy_formats:
1267 self.settings.copy_formats.pop()
1269 self.settings.copy_formats.extend(copy_formats)
1270 self.settings.save()
1272 self.accept()
1274 def table_selection_changed(self):
1275 items = self.table.selectedItems()
1276 self.remove_button.setEnabled(bool(items))