text: defer calls to setStyleSheet()
[git-cola.git] / cola / widgets / status.py
blob441dbb57b9c925d3fca278955c10124c099269c7
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(
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.setIndentation(0)
124 self.setDragEnabled(True)
125 self.setAutoScroll(False)
127 ok = icons.ok()
128 compare = icons.compare()
129 question = icons.question()
130 self._add_toplevel_item(N_('Staged'), ok, hide=True)
131 self._add_toplevel_item(N_('Unmerged'), compare, hide=True)
132 self._add_toplevel_item(N_('Modified'), compare, hide=True)
133 self._add_toplevel_item(N_('Untracked'), question, hide=True)
135 # Used to restore the selection
136 self.old_vscroll = None
137 self.old_hscroll = None
138 self.old_selection = None
139 self.old_contents = None
140 self.old_current_item = None
141 self.was_visible = True
142 self.expanded_items = set()
144 self.image_formats = qtutils.ImageFormats()
146 self.process_selection_action = qtutils.add_action(
147 self, cmds.StageOrUnstage.name(), self._stage_selection,
148 hotkeys.STAGE_SELECTION)
150 self.revert_unstaged_edits_action = qtutils.add_action(
151 self, cmds.RevertUnstagedEdits.name(),
152 cmds.run(cmds.RevertUnstagedEdits, context), hotkeys.REVERT)
153 self.revert_unstaged_edits_action.setIcon(icons.undo())
155 self.launch_difftool_action = qtutils.add_action(
156 self, cmds.LaunchDifftool.name(),
157 cmds.run(cmds.LaunchDifftool, context), hotkeys.DIFF)
158 self.launch_difftool_action.setIcon(icons.diff())
160 self.launch_editor_action = actions.launch_editor(
161 context, self, *hotkeys.ACCEPT)
163 if not utils.is_win32():
164 self.default_app_action = common.default_app_action(
165 context, self, self.selected_group)
167 self.parent_dir_action = common.parent_dir_action(
168 context, self, self.selected_group)
170 self.terminal_action = common.terminal_action(
171 context, self, self.selected_group)
173 self.up_action = qtutils.add_action(
174 self, N_('Move Up'), self.move_up,
175 hotkeys.MOVE_UP, hotkeys.MOVE_UP_SECONDARY)
177 self.down_action = qtutils.add_action(
178 self, N_('Move Down'), self.move_down,
179 hotkeys.MOVE_DOWN, hotkeys.MOVE_DOWN_SECONDARY)
181 self.copy_path_action = qtutils.add_action(
182 self, N_('Copy Path to Clipboard'),
183 partial(copy_path, context), hotkeys.COPY)
184 self.copy_path_action.setIcon(icons.copy())
186 self.copy_relpath_action = qtutils.add_action(
187 self, N_('Copy Relative Path to Clipboard'),
188 partial(copy_relpath, context), hotkeys.CUT)
189 self.copy_relpath_action.setIcon(icons.copy())
191 self.copy_leading_path_action = qtutils.add_action(
192 self, N_('Copy Leading Path to Clipboard'),
193 partial(copy_leading_path, context))
194 self.copy_leading_path_action.setIcon(icons.copy())
196 self.copy_basename_action = qtutils.add_action(
197 self, N_('Copy Basename to Clipboard'),
198 partial(copy_basename, context))
199 self.copy_basename_action.setIcon(icons.copy())
201 self.copy_customize_action = qtutils.add_action(
202 self, N_('Customize...'),
203 partial(customize_copy_actions, context, self))
204 self.copy_customize_action.setIcon(icons.configure())
206 self.view_history_action = qtutils.add_action(
207 self, N_('View History...'), partial(view_history, context),
208 hotkeys.HISTORY)
210 self.view_blame_action = qtutils.add_action(
211 self, N_('Blame...'),
212 partial(view_blame, context), hotkeys.BLAME)
214 self.annex_add_action = qtutils.add_action(
215 self, N_('Add to Git Annex'), cmds.run(cmds.AnnexAdd, context))
217 self.lfs_track_action = qtutils.add_action(
218 self, N_('Add to Git LFS'), cmds.run(cmds.LFSTrack, context))
220 # MoveToTrash and Delete use the same shortcut.
221 # We will only bind one of them, depending on whether or not the
222 # MoveToTrash command is available. When available, the hotkey
223 # is bound to MoveToTrash, otherwise it is bound to Delete.
224 if cmds.MoveToTrash.AVAILABLE:
225 self.move_to_trash_action = qtutils.add_action(
226 self, N_('Move files to trash'),
227 self._trash_untracked_files, hotkeys.TRASH)
228 self.move_to_trash_action.setIcon(icons.discard())
229 delete_shortcut = hotkeys.DELETE_FILE
230 else:
231 self.move_to_trash_action = None
232 delete_shortcut = hotkeys.DELETE_FILE_SECONDARY
234 self.delete_untracked_files_action = qtutils.add_action(
235 self, N_('Delete Files...'),
236 self._delete_untracked_files, delete_shortcut)
237 self.delete_untracked_files_action.setIcon(icons.discard())
239 about_to_update = self._about_to_update
240 self.about_to_update.connect(about_to_update, type=Qt.QueuedConnection)
241 self.updated.connect(self.refresh, type=Qt.QueuedConnection)
242 self.diff_text_changed.connect(
243 self._make_current_item_visible, type=Qt.QueuedConnection)
245 self.m = context.model
246 self.m.add_observer(self.m.message_about_to_update,
247 self.about_to_update.emit)
248 self.m.add_observer(self.m.message_updated, self.updated.emit)
249 self.m.add_observer(self.m.message_diff_text_changed,
250 self.diff_text_changed.emit)
252 self.itemSelectionChanged.connect(self.show_selection)
253 self.itemDoubleClicked.connect(self._double_clicked)
254 self.itemCollapsed.connect(lambda x: self._update_column_widths())
255 self.itemExpanded.connect(lambda x: self._update_column_widths())
257 def _make_current_item_visible(self):
258 item = self.currentItem()
259 if item:
260 self.scroll_to_item(item)
262 def _add_toplevel_item(self, txt, icon, hide=False):
263 context = self.context
264 font = self.font()
265 if prefs.bold_headers(context):
266 font.setBold(True)
267 else:
268 font.setItalic(True)
270 item = QtWidgets.QTreeWidgetItem(self)
271 item.setFont(0, font)
272 item.setText(0, txt)
273 item.setIcon(0, icon)
274 if prefs.bold_headers(context):
275 item.setBackground(0, self.palette().midlight())
276 if hide:
277 item.setHidden(True)
279 def _restore_selection(self):
280 if not self.old_selection or not self.old_contents:
281 return
282 old_c = self.old_contents
283 old_s = self.old_selection
284 new_c = self.contents()
286 def mkselect(lst, widget_getter):
287 def select(item, current=False):
288 idx = lst.index(item)
289 item = widget_getter(idx)
290 if current:
291 self.setCurrentItem(item)
292 item.setSelected(True)
293 return select
295 select_staged = mkselect(new_c.staged, self._staged_item)
296 select_unmerged = mkselect(new_c.unmerged, self._unmerged_item)
297 select_modified = mkselect(new_c.modified, self._modified_item)
298 select_untracked = mkselect(new_c.untracked, self._untracked_item)
300 saved_selection = [
301 (set(new_c.staged), old_c.staged, set(old_s.staged),
302 select_staged),
304 (set(new_c.unmerged), old_c.unmerged, set(old_s.unmerged),
305 select_unmerged),
307 (set(new_c.modified), old_c.modified, set(old_s.modified),
308 select_modified),
310 (set(new_c.untracked), old_c.untracked, set(old_s.untracked),
311 select_untracked),
314 # Restore the current item
315 if self.old_current_item:
316 category, idx = self.old_current_item
317 if category == self.idx_header:
318 item = self.invisibleRootItem().child(idx)
319 if item is not None:
320 self.blockSignals(True)
321 self.setCurrentItem(item)
322 item.setSelected(True)
323 self.blockSignals(False)
324 self.show_selection()
325 return
326 # Reselect the current item
327 selection_info = saved_selection[category]
328 new = selection_info[0]
329 old = selection_info[1]
330 reselect = selection_info[3]
331 try:
332 item = old[idx]
333 except IndexError:
334 return
335 if item in new:
336 reselect(item, current=True)
338 # Restore selection
339 # When reselecting we only care that the items are selected;
340 # we do not need to rerun the callbacks which were triggered
341 # above. Block signals to skip the callbacks.
342 self.blockSignals(True)
343 for (new, old, sel, reselect) in saved_selection:
344 for item in sel:
345 if item in new:
346 reselect(item, current=False)
347 self.blockSignals(False)
349 for (new, old, sel, reselect) in saved_selection:
350 # When modified is staged, select the next modified item
351 # When unmerged is staged, select the next unmerged item
352 # When unstaging, select the next staged item
353 # When staging untracked files, select the next untracked item
354 if len(new) >= len(old):
355 # The list did not shrink so it is not one of these cases.
356 continue
357 for item in sel:
358 # The item still exists so ignore it
359 if item in new or item not in old:
360 continue
361 # The item no longer exists in this list so search for
362 # its nearest neighbors and select them instead.
363 idx = old.index(item)
364 for j in itertools.chain(old[idx+1:], reversed(old[:idx])):
365 if j in new:
366 reselect(j, current=True)
367 return
369 def _restore_scrollbars(self):
370 vscroll = self.verticalScrollBar()
371 if vscroll and self.old_vscroll is not None:
372 vscroll.setValue(self.old_vscroll)
373 self.old_vscroll = None
375 hscroll = self.horizontalScrollBar()
376 if hscroll and self.old_hscroll is not None:
377 hscroll.setValue(self.old_hscroll)
378 self.old_hscroll = None
380 def _stage_selection(self):
381 """Stage or unstage files according to the selection"""
382 context = self.context
383 selected_indexes = self.selected_indexes()
384 if selected_indexes:
385 category, idx = selected_indexes[0]
386 # A header item e.g. 'Staged', 'Modified', etc.
387 if category == self.idx_header:
388 if idx == self.idx_staged:
389 cmds.do(cmds.UnstageAll, context)
390 elif idx == self.idx_modified:
391 cmds.do(cmds.StageModified, context)
392 elif idx == self.idx_untracked:
393 cmds.do(cmds.StageUntracked, context)
394 else:
395 pass # Do nothing for unmerged items, by design
396 return
397 cmds.do(cmds.StageOrUnstage, context)
399 def _staged_item(self, itemidx):
400 return self._subtree_item(self.idx_staged, itemidx)
402 def _modified_item(self, itemidx):
403 return self._subtree_item(self.idx_modified, itemidx)
405 def _unmerged_item(self, itemidx):
406 return self._subtree_item(self.idx_unmerged, itemidx)
408 def _untracked_item(self, itemidx):
409 return self._subtree_item(self.idx_untracked, itemidx)
411 def _unstaged_item(self, itemidx):
412 # is it modified?
413 item = self.topLevelItem(self.idx_modified)
414 count = item.childCount()
415 if itemidx < count:
416 return item.child(itemidx)
417 # is it unmerged?
418 item = self.topLevelItem(self.idx_unmerged)
419 count += item.childCount()
420 if itemidx < count:
421 return item.child(itemidx)
422 # is it untracked?
423 item = self.topLevelItem(self.idx_untracked)
424 count += item.childCount()
425 if itemidx < count:
426 return item.child(itemidx)
427 # Nope..
428 return None
430 def _subtree_item(self, idx, itemidx):
431 parent = self.topLevelItem(idx)
432 return parent.child(itemidx)
434 def _about_to_update(self):
435 self._save_scrollbars()
436 self._save_selection()
438 def _save_scrollbars(self):
439 vscroll = self.verticalScrollBar()
440 if vscroll:
441 self.old_vscroll = get(vscroll)
443 hscroll = self.horizontalScrollBar()
444 if hscroll:
445 self.old_hscroll = get(hscroll)
447 def current_item(self):
448 s = self.selected_indexes()
449 if not s:
450 return None
451 current = self.currentItem()
452 if not current:
453 return None
454 idx = self.indexFromItem(current, 0)
455 if idx.parent().isValid():
456 parent_idx = idx.parent()
457 entry = (parent_idx.row(), idx.row())
458 else:
459 entry = (self.idx_header, idx.row())
460 return entry
462 def _save_selection(self):
463 self.old_contents = self.contents()
464 self.old_selection = self.selection()
465 self.old_current_item = self.current_item()
467 def refresh(self):
468 self._set_staged(self.m.staged)
469 self._set_modified(self.m.modified)
470 self._set_unmerged(self.m.unmerged)
471 self._set_untracked(self.m.untracked)
472 self._update_column_widths()
473 self._update_actions()
474 self._restore_selection()
475 self._restore_scrollbars()
477 def _update_actions(self, selected=None):
478 if selected is None:
479 selected = self.selection_model.selection()
480 can_revert_edits = bool(selected.staged or selected.modified)
481 self.revert_unstaged_edits_action.setEnabled(can_revert_edits)
483 def _set_staged(self, items):
484 """Adds items to the 'Staged' subtree."""
485 self._set_subtree(items, self.idx_staged, staged=True,
486 deleted_set=self.m.staged_deleted)
488 def _set_modified(self, items):
489 """Adds items to the 'Modified' subtree."""
490 self._set_subtree(items, self.idx_modified,
491 deleted_set=self.m.unstaged_deleted)
493 def _set_unmerged(self, items):
494 """Adds items to the 'Unmerged' subtree."""
495 deleted_set = set([path for path in items if not core.exists(path)])
496 self._set_subtree(items, self.idx_unmerged,
497 deleted_set=deleted_set)
499 def _set_untracked(self, items):
500 """Adds items to the 'Untracked' subtree."""
501 self._set_subtree(items, self.idx_untracked, untracked=True)
503 def _set_subtree(self, items, idx,
504 staged=False,
505 untracked=False,
506 deleted_set=None):
507 """Add a list of items to a treewidget item."""
508 self.blockSignals(True)
509 parent = self.topLevelItem(idx)
510 hide = not bool(items)
511 parent.setHidden(hide)
513 # sip v4.14.7 and below leak memory in parent.takeChildren()
514 # so we use this backwards-compatible construct instead
515 while parent.takeChild(0) is not None:
516 pass
518 for item in items:
519 deleted = (deleted_set is not None and item in deleted_set)
520 treeitem = qtutils.create_treeitem(item,
521 staged=staged,
522 deleted=deleted,
523 untracked=untracked)
524 parent.addChild(treeitem)
525 self._expand_items(idx, items)
526 self.blockSignals(False)
528 def _update_column_widths(self):
529 self.resizeColumnToContents(0)
531 def _expand_items(self, idx, items):
532 """Expand the top-level category "folder" once and only once."""
533 # Don't do this if items is empty; this makes it so that we
534 # don't add the top-level index into the expanded_items set
535 # until an item appears in a particular category.
536 if not items:
537 return
538 # Only run this once; we don't want to re-expand items that
539 # we've clicked on to re-collapse on updated().
540 if idx in self.expanded_items:
541 return
542 self.expanded_items.add(idx)
543 item = self.topLevelItem(idx)
544 if item:
545 self.expandItem(item)
547 def contextMenuEvent(self, event):
548 """Create context menus for the repo status tree."""
549 menu = self._create_context_menu()
550 menu.exec_(self.mapToGlobal(event.pos()))
552 def _create_context_menu(self):
553 """Set up the status menu for the repo status tree."""
554 s = self.selection()
555 menu = qtutils.create_menu('Status', self)
556 selected_indexes = self.selected_indexes()
557 if selected_indexes:
558 category, idx = selected_indexes[0]
559 # A header item e.g. 'Staged', 'Modified', etc.
560 if category == self.idx_header:
561 return self._create_header_context_menu(menu, idx)
563 if s.staged:
564 self._create_staged_context_menu(menu, s)
565 elif s.unmerged:
566 self._create_unmerged_context_menu(menu, s)
567 else:
568 self._create_unstaged_context_menu(menu, s)
570 if not utils.is_win32():
571 if not menu.isEmpty():
572 menu.addSeparator()
573 if not self.selection_model.is_empty():
574 menu.addAction(self.default_app_action)
575 menu.addAction(self.parent_dir_action)
576 menu.addAction(self.terminal_action)
578 self._add_copy_actions(menu)
580 return menu
582 def _add_copy_actions(self, menu):
583 """Add the "Copy" sub-menu"""
584 enabled = self.selection_model.filename() is not None
585 self.copy_path_action.setEnabled(enabled)
586 self.copy_relpath_action.setEnabled(enabled)
587 self.copy_leading_path_action.setEnabled(enabled)
588 self.copy_basename_action.setEnabled(enabled)
589 copy_icon = icons.copy()
591 menu.addSeparator()
592 copy_menu = QtWidgets.QMenu(N_('Copy...'), menu)
593 menu.addMenu(copy_menu)
595 copy_menu.setIcon(copy_icon)
596 copy_menu.addAction(self.copy_path_action)
597 copy_menu.addAction(self.copy_relpath_action)
598 copy_menu.addAction(self.copy_leading_path_action)
599 copy_menu.addAction(self.copy_basename_action)
601 current_settings = settings.Settings()
602 current_settings.load()
604 copy_formats = current_settings.copy_formats
605 if copy_formats:
606 copy_menu.addSeparator()
608 context = self.context
609 for entry in copy_formats:
610 name = entry.get('name', '')
611 fmt = entry.get('format', '')
612 if name and fmt:
613 action = copy_menu.addAction(
614 name, partial(copy_format, context, fmt))
615 action.setIcon(copy_icon)
616 action.setEnabled(enabled)
618 copy_menu.addSeparator()
619 copy_menu.addAction(self.copy_customize_action)
621 def _create_header_context_menu(self, menu, idx):
622 context = self.context
623 if idx == self.idx_staged:
624 menu.addAction(icons.remove(), N_('Unstage All'),
625 cmds.run(cmds.UnstageAll, context))
626 elif idx == self.idx_unmerged:
627 action = menu.addAction(icons.add(), cmds.StageUnmerged.name(),
628 cmds.run(cmds.StageUnmerged, context))
629 action.setShortcut(hotkeys.STAGE_SELECTION)
630 elif idx == self.idx_modified:
631 action = menu.addAction(icons.add(), cmds.StageModified.name(),
632 cmds.run(cmds.StageModified, context))
633 action.setShortcut(hotkeys.STAGE_SELECTION)
634 elif idx == self.idx_untracked:
635 action = menu.addAction(icons.add(), cmds.StageUntracked.name(),
636 cmds.run(cmds.StageUntracked, context))
637 action.setShortcut(hotkeys.STAGE_SELECTION)
638 return menu
640 def _create_staged_context_menu(self, menu, s):
641 if s.staged[0] in self.m.submodules:
642 return self._create_staged_submodule_context_menu(menu, s)
644 context = self.context
645 if self.m.unstageable():
646 action = menu.addAction(
647 icons.remove(), N_('Unstage Selected'),
648 cmds.run(cmds.Unstage, context, self.staged()))
649 action.setShortcut(hotkeys.STAGE_SELECTION)
651 menu.addAction(self.launch_editor_action)
653 # Do all of the selected items exist?
654 all_exist = all(i not in self.m.staged_deleted and core.exists(i)
655 for i in self.staged())
657 if all_exist:
658 menu.addAction(self.launch_difftool_action)
660 if self.m.undoable():
661 menu.addAction(self.revert_unstaged_edits_action)
663 menu.addAction(self.view_history_action)
664 menu.addAction(self.view_blame_action)
665 return menu
667 def _create_staged_submodule_context_menu(self, menu, s):
668 context = self.context
669 path = core.abspath(s.staged[0])
670 menu.addAction(icons.cola(), N_('Launch git-cola'),
671 cmds.run(cmds.OpenRepo, context, path))
672 action = menu.addAction(
673 icons.remove(), N_('Unstage Selected'),
674 cmds.run(cmds.Unstage, context, self.staged()))
675 action.setShortcut(hotkeys.STAGE_SELECTION)
677 menu.addAction(self.view_history_action)
678 return menu
680 def _create_unmerged_context_menu(self, menu, _s):
681 context = self.context
682 menu.addAction(self.launch_difftool_action)
684 action = menu.addAction(
685 icons.add(), N_('Stage Selected'),
686 cmds.run(cmds.Stage, context, self.unstaged()))
687 action.setShortcut(hotkeys.STAGE_SELECTION)
689 menu.addAction(self.launch_editor_action)
690 menu.addAction(self.view_history_action)
691 menu.addAction(self.view_blame_action)
692 return menu
694 def _create_unstaged_context_menu(self, menu, s):
695 context = self.context
696 modified_submodule = (s.modified and
697 s.modified[0] in self.m.submodules)
698 if modified_submodule:
699 return self._create_modified_submodule_context_menu(menu, s)
701 if self.m.stageable():
702 action = menu.addAction(
703 icons.add(), N_('Stage Selected'),
704 cmds.run(cmds.Stage, context, self.unstaged()))
705 action.setShortcut(hotkeys.STAGE_SELECTION)
707 if not self.selection_model.is_empty():
708 menu.addAction(self.launch_editor_action)
710 # Do all of the selected items exist?
711 all_exist = all(i not in self.m.unstaged_deleted and core.exists(i)
712 for i in self.staged())
714 if all_exist and s.modified and self.m.stageable():
715 menu.addAction(self.launch_difftool_action)
717 if s.modified and self.m.stageable():
718 if self.m.undoable():
719 menu.addSeparator()
720 menu.addAction(self.revert_unstaged_edits_action)
722 if all_exist and s.untracked:
723 # Git Annex / Git LFS
724 annex = self.m.annex
725 lfs = core.find_executable('git-lfs')
726 if annex or lfs:
727 menu.addSeparator()
728 if annex:
729 menu.addAction(self.annex_add_action)
730 if lfs:
731 menu.addAction(self.lfs_track_action)
733 menu.addSeparator()
734 if self.move_to_trash_action is not None:
735 menu.addAction(self.move_to_trash_action)
736 menu.addAction(self.delete_untracked_files_action)
737 menu.addSeparator()
738 menu.addAction(icons.edit(), N_('Add to .gitignore'),
739 partial(gitignore.gitignore_view, self.context))
741 if not self.selection_model.is_empty():
742 menu.addAction(self.view_history_action)
743 menu.addAction(self.view_blame_action)
744 return menu
746 def _create_modified_submodule_context_menu(self, menu, s):
747 context = self.context
748 path = core.abspath(s.modified[0])
749 menu.addAction(icons.cola(), N_('Launch git-cola'),
750 cmds.run(cmds.OpenRepo, context, path))
752 if self.m.stageable():
753 menu.addSeparator()
754 action = menu.addAction(
755 icons.add(), N_('Stage Selected'),
756 cmds.run(cmds.Stage, context, self.unstaged()))
757 action.setShortcut(hotkeys.STAGE_SELECTION)
759 menu.addAction(self.view_history_action)
760 return menu
762 def _delete_untracked_files(self):
763 cmds.do(cmds.Delete, self.context, self.untracked())
765 def _trash_untracked_files(self):
766 cmds.do(cmds.MoveToTrash, self.context, self.untracked())
768 def selected_path(self):
769 s = self.single_selection()
770 return s.staged or s.unmerged or s.modified or s.untracked or None
772 def single_selection(self):
773 """Scan across staged, modified, etc. and return a single item."""
774 staged = None
775 unmerged = None
776 modified = None
777 untracked = None
779 s = self.selection()
780 if s.staged:
781 staged = s.staged[0]
782 elif s.unmerged:
783 unmerged = s.unmerged[0]
784 elif s.modified:
785 modified = s.modified[0]
786 elif s.untracked:
787 untracked = s.untracked[0]
789 return selection.State(staged, unmerged, modified, untracked)
791 def selected_indexes(self):
792 """Returns a list of (category, row) representing the tree selection."""
793 selected = self.selectedIndexes()
794 result = []
795 for idx in selected:
796 if idx.parent().isValid():
797 parent_idx = idx.parent()
798 entry = (parent_idx.row(), idx.row())
799 else:
800 entry = (self.idx_header, idx.row())
801 result.append(entry)
802 return result
804 def selection(self):
805 """Return the current selection in the repo status tree."""
806 return selection.State(self.staged(), self.unmerged(),
807 self.modified(), self.untracked())
809 def contents(self):
810 return selection.State(self.m.staged, self.m.unmerged,
811 self.m.modified, self.m.untracked)
813 def all_files(self):
814 c = self.contents()
815 return c.staged + c.unmerged + c.modified + c.untracked
817 def selected_group(self):
818 """A list of selected files in various states of being"""
819 return selection.pick(self.selection())
821 def selected_idx(self):
822 c = self.contents()
823 s = self.single_selection()
824 offset = 0
825 for content, sel in zip(c, s):
826 if not content:
827 continue
828 if sel is not None:
829 return offset + content.index(sel)
830 offset += len(content)
831 return None
833 def select_by_index(self, idx):
834 c = self.contents()
835 to_try = [
836 (c.staged, self.idx_staged),
837 (c.unmerged, self.idx_unmerged),
838 (c.modified, self.idx_modified),
839 (c.untracked, self.idx_untracked),
841 for content, toplevel_idx in to_try:
842 if not content:
843 continue
844 if idx < len(content):
845 parent = self.topLevelItem(toplevel_idx)
846 item = parent.child(idx)
847 if item is not None:
848 self.select_item(item)
849 return
850 idx -= len(content)
852 def scroll_to_item(self, item):
853 # First, scroll to the item, but keep the original hscroll
854 hscroll = None
855 hscrollbar = self.horizontalScrollBar()
856 if hscrollbar:
857 hscroll = get(hscrollbar)
858 self.scrollToItem(item)
859 if hscroll is not None:
860 hscrollbar.setValue(hscroll)
862 def select_item(self, item):
863 self.scroll_to_item(item)
864 self.setCurrentItem(item)
865 item.setSelected(True)
867 def staged(self):
868 return self._subtree_selection(self.idx_staged, self.m.staged)
870 def unstaged(self):
871 return self.unmerged() + self.modified() + self.untracked()
873 def modified(self):
874 return self._subtree_selection(self.idx_modified, self.m.modified)
876 def unmerged(self):
877 return self._subtree_selection(self.idx_unmerged, self.m.unmerged)
879 def untracked(self):
880 return self._subtree_selection(self.idx_untracked, self.m.untracked)
882 def staged_items(self):
883 return self._subtree_selection_items(self.idx_staged)
885 def unstaged_items(self):
886 return (self.unmerged_items() + self.modified_items() +
887 self.untracked_items())
889 def modified_items(self):
890 return self._subtree_selection_items(self.idx_modified)
892 def unmerged_items(self):
893 return self._subtree_selection_items(self.idx_unmerged)
895 def untracked_items(self):
896 return self._subtree_selection_items(self.idx_untracked)
898 def _subtree_selection(self, idx, items):
899 item = self.topLevelItem(idx)
900 return qtutils.tree_selection(item, items)
902 def _subtree_selection_items(self, idx):
903 item = self.topLevelItem(idx)
904 return qtutils.tree_selection_items(item)
906 def _double_clicked(self, _item, _idx):
907 """Called when an item is double-clicked in the repo status tree."""
908 cmds.do(cmds.StageOrUnstage, self.context)
910 def show_selection(self):
911 """Show the selected item."""
912 context = self.context
913 self.scroll_to_item(self.currentItem())
914 # Sync the selection model
915 selected = self.selection()
916 selection_model = self.selection_model
917 selection_model.set_selection(selected)
918 self._update_actions(selected=selected)
920 selected_indexes = self.selected_indexes()
921 if not selected_indexes:
922 if self.m.amending():
923 cmds.do(cmds.SetDiffText, context, '')
924 else:
925 cmds.do(cmds.ResetMode, context)
926 return
928 # A header item e.g. 'Staged', 'Modified', etc.
929 category, idx = selected_indexes[0]
930 header = category == self.idx_header
931 if header:
932 cls = {
933 self.idx_staged: cmds.DiffStagedSummary,
934 self.idx_modified: cmds.Diffstat,
935 # TODO implement UnmergedSummary
936 # self.idx_unmerged: cmds.UnmergedSummary,
937 self.idx_untracked: cmds.UntrackedSummary,
938 }.get(idx, cmds.Diffstat)
939 cmds.do(cls, context)
940 return
942 staged = category == self.idx_staged
943 modified = category == self.idx_modified
944 unmerged = category == self.idx_unmerged
945 untracked = category == self.idx_untracked
947 if staged:
948 item = self.staged_items()[0]
949 elif unmerged:
950 item = self.unmerged_items()[0]
951 elif modified:
952 item = self.modified_items()[0]
953 elif untracked:
954 item = self.unstaged_items()[0]
955 else:
956 item = None # this shouldn't happen
957 assert item is not None
959 path = item.path
960 deleted = item.deleted
961 image = self.image_formats.ok(path)
963 # Images are diffed differently
964 if image:
965 cmds.do(cmds.DiffImage, context, path, deleted,
966 staged, modified, unmerged, untracked)
967 elif staged:
968 cmds.do(cmds.DiffStaged, context, path, deleted=deleted)
969 elif modified:
970 cmds.do(cmds.Diff, context, path, deleted=deleted)
971 elif unmerged:
972 cmds.do(cmds.Diff, context, path)
973 elif untracked:
974 cmds.do(cmds.ShowUntracked, context, path)
976 def select_header(self):
977 """Select an active header, which triggers a diffstat"""
978 for idx in (self.idx_staged, self.idx_unmerged,
979 self.idx_modified, self.idx_untracked):
980 item = self.topLevelItem(idx)
981 if item.childCount() > 0:
982 self.clearSelection()
983 self.setCurrentItem(item)
984 return
986 def move_up(self):
987 idx = self.selected_idx()
988 all_files = self.all_files()
989 if idx is None:
990 selected_indexes = self.selected_indexes()
991 if selected_indexes:
992 category, toplevel_idx = selected_indexes[0]
993 if category == self.idx_header:
994 item = self.itemAbove(self.topLevelItem(toplevel_idx))
995 if item is not None:
996 self.select_item(item)
997 return
998 if all_files:
999 self.select_by_index(len(all_files) - 1)
1000 return
1001 if idx - 1 >= 0:
1002 self.select_by_index(idx - 1)
1003 else:
1004 self.select_by_index(len(all_files) - 1)
1006 def move_down(self):
1007 idx = self.selected_idx()
1008 all_files = self.all_files()
1009 if idx is None:
1010 selected_indexes = self.selected_indexes()
1011 if selected_indexes:
1012 category, toplevel_idx = selected_indexes[0]
1013 if category == self.idx_header:
1014 item = self.itemBelow(self.topLevelItem(toplevel_idx))
1015 if item is not None:
1016 self.select_item(item)
1017 return
1018 if all_files:
1019 self.select_by_index(0)
1020 return
1021 if idx + 1 < len(all_files):
1022 self.select_by_index(idx + 1)
1023 else:
1024 self.select_by_index(0)
1026 def mimeData(self, items):
1027 """Return a list of absolute-path URLs"""
1028 context = self.context
1029 paths = qtutils.paths_from_items(items, item_filter=_item_filter)
1030 return qtutils.mimedata_from_paths(context, paths)
1032 # pylint: disable=no-self-use
1033 def mimeTypes(self):
1034 return qtutils.path_mimetypes()
1037 def _item_filter(item):
1038 return not item.deleted and core.exists(item.path)
1041 def view_blame(context):
1042 """Signal that we should view blame for paths."""
1043 cmds.do(cmds.BlamePaths, context, context.selection.union())
1046 def view_history(context):
1047 """Signal that we should view history for paths."""
1048 cmds.do(cmds.VisualizePaths, context, context.selection.union())
1051 def copy_path(context, absolute=True):
1052 """Copy a selected path to the clipboard"""
1053 filename = context.selection.filename()
1054 qtutils.copy_path(filename, absolute=absolute)
1057 def copy_relpath(context):
1058 """Copy a selected relative path to the clipboard"""
1059 copy_path(context, absolute=False)
1062 def copy_basename(context):
1063 filename = os.path.basename(context.selection.filename())
1064 basename, _ = os.path.splitext(filename)
1065 qtutils.copy_path(basename, absolute=False)
1068 def copy_leading_path(context):
1069 """Copy the selected leading path to the clipboard"""
1070 filename = context.selection.filename()
1071 dirname = os.path.dirname(filename)
1072 qtutils.copy_path(dirname, absolute=False)
1075 def copy_format(context, fmt):
1076 values = {}
1077 values['path'] = path = context.selection.filename()
1078 values['abspath'] = abspath = os.path.abspath(path)
1079 values['absdirname'] = os.path.dirname(abspath)
1080 values['dirname'] = os.path.dirname(path)
1081 values['filename'] = os.path.basename(path)
1082 values['basename'], values['ext'] = (
1083 os.path.splitext(os.path.basename(path)))
1084 qtutils.set_clipboard(fmt % values)
1087 def show_help(context):
1088 help_text = N_(r"""
1089 Format String Variables
1090 -----------------------
1091 %(path)s = relative file path
1092 %(abspath)s = absolute file path
1093 %(dirname)s = relative directory path
1094 %(absdirname)s = absolute directory path
1095 %(filename)s = file basename
1096 %(basename)s = file basename without extension
1097 %(ext)s = file extension
1098 """)
1099 title = N_('Help - Custom Copy Actions')
1100 return text.text_dialog(context, help_text, title)
1103 class StatusFilterWidget(QtWidgets.QWidget):
1105 def __init__(self, context, parent=None):
1106 QtWidgets.QWidget.__init__(self, parent)
1107 self.main_model = context.model
1109 hint = N_('Filter paths...')
1110 self.text = completion.GitStatusFilterLineEdit(
1111 context, hint=hint, parent=self)
1112 self.text.setToolTip(hint)
1113 self.setFocusProxy(self.text)
1114 self._filter = None
1116 self.main_layout = qtutils.hbox(defs.no_margin, defs.spacing, self.text)
1117 self.setLayout(self.main_layout)
1119 widget = self.text
1120 widget.changed.connect(self.apply_filter)
1121 widget.cleared.connect(self.apply_filter)
1122 widget.enter.connect(self.apply_filter)
1123 widget.editingFinished.connect(self.apply_filter)
1125 def apply_filter(self):
1126 value = get(self.text)
1127 if value == self._filter:
1128 return
1129 self._filter = value
1130 paths = utils.shell_split(value)
1131 self.main_model.update_path_filter(paths)
1134 def customize_copy_actions(context, parent):
1135 """Customize copy actions"""
1136 dialog = CustomizeCopyActions(context, parent)
1137 dialog.show()
1138 dialog.exec_()
1141 class CustomizeCopyActions(standard.Dialog):
1143 def __init__(self, context, parent):
1144 standard.Dialog.__init__(self, parent=parent)
1145 self.setWindowTitle(N_('Custom Copy Actions'))
1147 self.table = QtWidgets.QTableWidget(self)
1148 self.table.setColumnCount(2)
1149 self.table.setHorizontalHeaderLabels([
1150 N_('Action Name'),
1151 N_('Format String'),
1153 self.table.setSortingEnabled(False)
1154 self.table.verticalHeader().hide()
1155 self.table.horizontalHeader().setStretchLastSection(True)
1157 self.add_button = qtutils.create_button(N_('Add'))
1158 self.remove_button = qtutils.create_button(N_('Remove'))
1159 self.remove_button.setEnabled(False)
1160 self.show_help_button = qtutils.create_button(N_('Show Help'))
1161 self.show_help_button.setShortcut(hotkeys.QUESTION)
1163 self.close_button = qtutils.close_button()
1164 self.save_button = qtutils.ok_button(N_('Save'))
1166 self.buttons = qtutils.hbox(defs.no_margin, defs.button_spacing,
1167 self.add_button,
1168 self.remove_button,
1169 self.show_help_button,
1170 qtutils.STRETCH,
1171 self.close_button,
1172 self.save_button)
1174 layout = qtutils.vbox(defs.margin, defs.spacing,
1175 self.table, self.buttons)
1176 self.setLayout(layout)
1178 qtutils.connect_button(self.add_button, self.add)
1179 qtutils.connect_button(self.remove_button, self.remove)
1180 qtutils.connect_button(
1181 self.show_help_button, partial(show_help, context))
1182 qtutils.connect_button(self.close_button, self.reject)
1183 qtutils.connect_button(self.save_button, self.save)
1184 qtutils.add_close_action(self)
1185 self.table.itemSelectionChanged.connect(self.table_selection_changed)
1187 self.init_size(parent=parent)
1189 self.settings = settings.Settings()
1190 QtCore.QTimer.singleShot(0, self.reload_settings)
1192 def reload_settings(self):
1193 # Called once after the GUI is initialized
1194 self.settings.load()
1195 table = self.table
1196 for entry in self.settings.copy_formats:
1197 name_string = entry.get('name', '')
1198 format_string = entry.get('format', '')
1199 if name_string and format_string:
1200 name = QtWidgets.QTableWidgetItem(name_string)
1201 fmt = QtWidgets.QTableWidgetItem(format_string)
1202 rows = table.rowCount()
1203 table.setRowCount(rows + 1)
1204 table.setItem(rows, 0, name)
1205 table.setItem(rows, 1, fmt)
1207 def export_state(self):
1208 state = super(CustomizeCopyActions, self).export_state()
1209 standard.export_header_columns(self.table, state)
1210 return state
1212 def apply_state(self, state):
1213 result = super(CustomizeCopyActions, self).apply_state(state)
1214 standard.apply_header_columns(self.table, state)
1215 return result
1217 def add(self):
1218 self.table.setFocus(True)
1219 rows = self.table.rowCount()
1220 self.table.setRowCount(rows + 1)
1222 name = QtWidgets.QTableWidgetItem(N_('Name'))
1223 fmt = QtWidgets.QTableWidgetItem(r'%(path)s')
1224 self.table.setItem(rows, 0, name)
1225 self.table.setItem(rows, 1, fmt)
1227 self.table.setCurrentCell(rows, 0)
1228 self.table.editItem(name)
1230 def remove(self):
1231 """Remove selected items"""
1232 # Gather a unique set of rows and remove them in reverse order
1233 rows = set()
1234 items = self.table.selectedItems()
1235 for item in items:
1236 rows.add(self.table.row(item))
1238 for row in reversed(sorted(rows)):
1239 self.table.removeRow(row)
1241 def save(self):
1242 copy_formats = []
1243 for row in range(self.table.rowCount()):
1244 name = self.table.item(row, 0)
1245 fmt = self.table.item(row, 1)
1246 if name and fmt:
1247 entry = {
1248 'name': name.text(),
1249 'format': fmt.text(),
1251 copy_formats.append(entry)
1253 while self.settings.copy_formats:
1254 self.settings.copy_formats.pop()
1256 self.settings.copy_formats.extend(copy_formats)
1257 self.settings.save()
1259 self.accept()
1261 def table_selection_changed(self):
1262 items = self.table.selectedItems()
1263 self.remove_button.setEnabled(bool(items))