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