widgets.recent: Refresh after editing the commit count
[git-cola.git] / cola / widgets / status.py
blob6d4fbdbc22165e9f4e3f623cf7193b759e9ca875
1 import os
2 import copy
3 import subprocess
4 import itertools
6 from PyQt4 import QtGui
7 from PyQt4.QtCore import SIGNAL
9 import cola
10 from cola import signals
11 from cola import qtutils
12 from cola.compat import set
13 from cola.qtutils import SLOT
16 def select_item(tree, item):
17 if not item:
18 return
19 tree.setItemSelected(item, True)
20 parent = item.parent()
21 if parent:
22 tree.scrollToItem(parent)
23 tree.scrollToItem(item)
26 class StatusWidget(QtGui.QWidget):
27 """
28 Provides a git-status-like repository widget.
30 This widget observes the main model and broadcasts
31 Qt signals.
33 """
34 def __init__(self, parent=None):
35 QtGui.QWidget.__init__(self, parent)
36 self.layout = QtGui.QVBoxLayout(self)
37 self.setLayout(self.layout)
39 self.tree = StatusTreeWidget(self)
40 self.layout.addWidget(self.tree)
41 self.layout.setContentsMargins(0, 0, 0, 0)
44 class StatusTreeWidget(QtGui.QTreeWidget):
45 # Item categories
46 idx_header = -1
47 idx_staged = 0
48 idx_unmerged = 1
49 idx_modified = 2
50 idx_untracked = 3
51 idx_end = 4
53 # Read-only access to the mode state
54 mode = property(lambda self: self.m.mode)
56 def __init__(self, parent):
57 QtGui.QTreeWidget.__init__(self, parent)
59 self.setSelectionMode(QtGui.QAbstractItemView.ExtendedSelection)
60 self.headerItem().setHidden(True)
61 self.setAllColumnsShowFocus(True)
62 self.setSortingEnabled(False)
63 self.setUniformRowHeights(True)
64 self.setAnimated(True)
66 self.add_item('Staged', 'plus.png', hide=True)
67 self.add_item('Unmerged', 'unmerged.png', hide=True)
68 self.add_item('Modified', 'modified.png', hide=True)
69 self.add_item('Untracked', 'untracked.png', hide=True)
71 # Used to restore the selection
72 self.old_scroll = None
73 self.old_selection = None
74 self.old_contents = None
76 self.expanded_items = set()
78 self.process_selection = qtutils.add_action(self,
79 'Process Selection', self._process_selection, 'Ctrl+S')
81 self.launch_difftool = qtutils.add_action(self,
82 'Process Selection', self._launch_difftool, 'Ctrl+D')
84 self.launch_difftool = qtutils.add_action(self,
85 'Launch Editor', self._launch_editor, 'Ctrl+E')
87 self.up = qtutils.add_action(self, 'Move Up', self.move_up, 'K')
88 self.down = qtutils.add_action(self, 'Move Down', self.move_down, 'J')
90 self.connect(self, SIGNAL('about_to_update'), self._about_to_update)
91 self.connect(self, SIGNAL('updated'), self._updated)
93 self.m = cola.model()
94 self.m.add_message_observer(self.m.message_about_to_update,
95 self.about_to_update)
96 self.m.add_message_observer(self.m.message_updated, self.updated)
98 self.connect(self, SIGNAL('itemSelectionChanged()'),
99 self.show_selection)
100 self.connect(self,
101 SIGNAL('itemDoubleClicked(QTreeWidgetItem*,int)'),
102 self.double_clicked)
103 self.connect(self,
104 SIGNAL('itemClicked(QTreeWidgetItem*,int)'),
105 self.clicked)
107 def add_item(self, txt, path, hide=False):
108 """Create a new top-level item in the status tree."""
109 item = QtGui.QTreeWidgetItem(self)
110 item.setText(0, self.tr(txt))
111 item.setIcon(0, qtutils.icon(path))
112 if hide:
113 self.setItemHidden(item, True)
115 def restore_selection(self):
116 if not self.old_selection or not self.old_contents:
117 return
119 (staged, modified, unmerged, untracked) = self.old_contents
121 (staged_sel, modified_sel,
122 unmerged_sel, untracked_sel) = self.old_selection
124 (updated_staged, updated_modified,
125 updated_unmerged, updated_untracked) = self.contents()
127 def select_modified(item):
128 idx = updated_modified.index(item)
129 select_item(self, self.modified_item(idx))
131 def select_unmerged(item):
132 idx = updated_unmerged.index(item)
133 select_item(self, self.unmerged_item(idx))
135 def select_untracked(item):
136 idx = updated_untracked.index(item)
137 select_item(self, self.untracked_item(idx))
139 def select_staged(item):
140 idx = updated_staged.index(item)
141 select_item(self, self.staged_item(idx))
143 restore_selection_actions = (
144 (updated_modified, modified, modified_sel, select_modified),
145 (updated_unmerged, unmerged, unmerged_sel, select_unmerged),
146 (updated_untracked, untracked, untracked_sel, select_untracked),
147 (updated_staged, staged, staged_sel, select_staged),
150 for (new, old, selection, action) in restore_selection_actions:
151 # When modified is staged, select the next modified item
152 # When unmerged is staged, select the next unmerged item
153 # When untracked is staged, select the next untracked item
154 # When something is unstaged we should select the next staged item
155 new_set = set(new)
156 if len(new) < len(old) and old:
157 for idx, i in enumerate(old):
158 if i not in new_set:
159 for j in itertools.chain(old[idx+1:],
160 reversed(old[:idx])):
161 if j in new_set:
162 action(j)
163 return
165 for (new, old, selection, action) in restore_selection_actions:
166 # Reselect items when doing partial-staging
167 new_set = set(new)
168 for item in selection:
169 if item in new_set:
170 action(item)
172 def staged_item(self, itemidx):
173 return self._subtree_item(self.idx_staged, itemidx)
175 def modified_item(self, itemidx):
176 return self._subtree_item(self.idx_modified, itemidx)
178 def unmerged_item(self, itemidx):
179 return self._subtree_item(self.idx_unmerged, itemidx)
181 def untracked_item(self, itemidx):
182 return self._subtree_item(self.idx_untracked, itemidx)
184 def unstaged_item(self, itemidx):
185 # is it modified?
186 item = self.topLevelItem(self.idx_modified)
187 count = item.childCount()
188 if itemidx < count:
189 return item.child(itemidx)
190 # is it unmerged?
191 item = self.topLevelItem(self.idx_unmerged)
192 count += item.childCount()
193 if itemidx < count:
194 return item.child(itemidx)
195 # is it untracked?
196 item = self.topLevelItem(self.idx_untracked)
197 count += item.childCount()
198 if itemidx < count:
199 return item.child(itemidx)
200 # Nope..
201 return None
203 def _subtree_item(self, idx, itemidx):
204 parent = self.topLevelItem(idx)
205 return parent.child(itemidx)
207 def about_to_update(self):
208 self.emit(SIGNAL('about_to_update'))
210 def _about_to_update(self):
211 self.old_selection = copy.deepcopy(self.selection())
212 self.old_contents = copy.deepcopy(self.contents())
214 self.old_scroll = None
215 vscroll = self.verticalScrollBar()
216 if vscroll:
217 self.old_scroll = vscroll.value()
219 def updated(self):
220 """Update display from model data."""
221 self.emit(SIGNAL('updated'))
223 def _updated(self):
224 self.set_staged(self.m.staged)
225 self.set_modified(self.m.modified)
226 self.set_unmerged(self.m.unmerged)
227 self.set_untracked(self.m.untracked)
229 vscroll = self.verticalScrollBar()
230 if vscroll and self.old_scroll is not None:
231 vscroll.setValue(self.old_scroll)
232 self.old_scroll = None
234 self.restore_selection()
236 if not self.m.staged:
237 return
239 staged = self.topLevelItem(self.idx_staged)
240 if self.mode in self.m.modes_read_only:
241 staged.setText(0, self.tr('Changed'))
242 else:
243 staged.setText(0, self.tr('Staged'))
245 def set_staged(self, items):
246 """Adds items to the 'Staged' subtree."""
247 self._set_subtree(items, self.idx_staged, staged=True,
248 check=not self.m.read_only())
250 def set_modified(self, items):
251 """Adds items to the 'Modified' subtree."""
252 self._set_subtree(items, self.idx_modified)
254 def set_unmerged(self, items):
255 """Adds items to the 'Unmerged' subtree."""
256 self._set_subtree(items, self.idx_unmerged)
258 def set_untracked(self, items):
259 """Adds items to the 'Untracked' subtree."""
260 self._set_subtree(items, self.idx_untracked)
262 def _set_subtree(self, items, idx,
263 staged=False,
264 untracked=False,
265 check=True):
266 """Add a list of items to a treewidget item."""
267 parent = self.topLevelItem(idx)
268 if items:
269 self.setItemHidden(parent, False)
270 else:
271 self.setItemHidden(parent, True)
272 parent.takeChildren()
273 for item in items:
274 treeitem = qtutils.create_treeitem(item,
275 staged=staged,
276 check=check,
277 untracked=untracked)
278 parent.addChild(treeitem)
279 self.expand_items(idx, items)
281 def expand_items(self, idx, items):
282 """Expand the top-level category "folder" once and only once."""
283 # Don't do this if items is empty; this makes it so that we
284 # don't add the top-level index into the expanded_items set
285 # until an item appears in a particular category.
286 if not items:
287 return
288 # Only run this once; we don't want to re-expand items that
289 # we've clicked on to re-collapse on updated().
290 if idx in self.expanded_items:
291 return
292 self.expanded_items.add(idx)
293 item = self.topLevelItem(idx)
294 if item:
295 self.expandItem(item)
297 def contextMenuEvent(self, event):
298 """Create context menus for the repo status tree."""
299 menu = self.create_context_menu()
300 menu.exec_(self.mapToGlobal(event.pos()))
302 def create_context_menu(self):
303 """Set up the status menu for the repo status tree."""
304 staged, modified, unmerged, untracked = self.selection()
305 menu = QtGui.QMenu(self)
307 enable_staging = self.m.enable_staging()
308 if not enable_staging:
309 menu.addAction(qtutils.icon('remove.svg'),
310 self.tr('Unstage Selected'),
311 SLOT(signals.unstage, self.staged()))
313 if staged and staged[0] in self.m.submodules:
314 menu.addAction(qtutils.git_icon(),
315 self.tr('Launch git-cola'),
316 SLOT(signals.open_repo, os.path.abspath(staged[0])))
317 return menu
318 elif staged:
319 menu.addSeparator()
320 menu.addAction(qtutils.icon('open.svg'),
321 self.tr('Launch Editor'),
322 SLOT(signals.edit, self.staged()))
323 menu.addAction(qtutils.git_icon(),
324 self.tr('Launch Diff Tool'),
325 SLOT(signals.difftool, True, self.staged()))
326 menu.addSeparator()
327 menu.addAction(qtutils.icon('undo.svg'),
328 self.tr('Revert Unstaged Edits...'),
329 lambda: self._revert_unstaged_edits(use_staged=True))
330 return menu
332 if unmerged:
333 menu.addAction(qtutils.git_icon(),
334 self.tr('Launch Merge Tool'),
335 SLOT(signals.mergetool, self.unmerged()))
336 menu.addAction(qtutils.icon('open.svg'),
337 self.tr('Launch Editor'),
338 SLOT(signals.edit, self.unmerged()))
339 menu.addSeparator()
340 menu.addAction(qtutils.icon('add.svg'),
341 self.tr('Stage Selected'),
342 SLOT(signals.stage, self.unmerged()))
343 return menu
345 modified_submodule = (modified and
346 modified[0] in self.m.submodules)
347 if enable_staging:
348 menu.addAction(qtutils.icon('add.svg'),
349 self.tr('Stage Selected'),
350 SLOT(signals.stage, self.unstaged()))
351 menu.addSeparator()
353 if modified_submodule:
354 menu.addAction(qtutils.git_icon(),
355 self.tr('Launch git-cola'),
356 SLOT(signals.open_repo,
357 os.path.abspath(modified[0])))
358 elif self.unstaged():
359 menu.addAction(qtutils.icon('open.svg'),
360 self.tr('Launch Editor'),
361 SLOT(signals.edit, self.unstaged()))
363 if modified and enable_staging and not modified_submodule:
364 menu.addAction(qtutils.git_icon(),
365 self.tr('Launch Diff Tool'),
366 SLOT(signals.difftool, False, self.modified()))
367 menu.addSeparator()
368 menu.addAction(qtutils.icon('undo.svg'),
369 self.tr('Revert Unstaged Edits...'),
370 self._revert_unstaged_edits)
371 menu.addAction(qtutils.icon('undo.svg'),
372 self.tr('Revert Uncommited Edits...'),
373 self._revert_uncommitted_edits)
375 if untracked:
376 menu.addSeparator()
377 menu.addAction(qtutils.discard_icon(),
378 self.tr('Delete File(s)...'), self._delete_files)
379 menu.addSeparator()
380 menu.addAction(qtutils.icon('edit-clear.svg'),
381 self.tr('Add to .gitignore'),
382 SLOT(signals.ignore,
383 map(lambda x: '/' + x, self.untracked())))
384 return menu
386 def _delete_files(self):
387 files = self.untracked()
388 count = len(files)
389 if count == 0:
390 return
392 title = 'Delete Files?'
393 msg = self.tr('The following files will be deleted:\n\n')
395 fileinfo = subprocess.list2cmdline(files)
396 if len(fileinfo) > 2048:
397 fileinfo = fileinfo[:2048].rstrip() + '...'
398 msg += fileinfo
400 info_txt = unicode(self.tr('Delete %d file(s)?')) % count
401 ok_txt = 'Delete Files'
403 if qtutils.confirm(title, msg, info_txt, ok_txt,
404 default=False,
405 icon=qtutils.discard_icon()):
406 cola.notifier().broadcast(signals.delete, files)
408 def _revert_unstaged_edits(self, use_staged=False):
409 if not self.m.undoable():
410 return
411 if use_staged:
412 items_to_undo = self.staged()
413 else:
414 items_to_undo = self.modified()
416 if items_to_undo:
417 if not qtutils.confirm('Revert Unstaged Changes?',
418 'This operation drops unstaged changes.'
419 '\nThese changes cannot be recovered.',
420 'Revert the unstaged changes?',
421 'Revert Unstaged Changes',
422 default=False,
423 icon=qtutils.icon('undo.svg')):
424 return
425 cola.notifier().broadcast(signals.checkout,
426 ['--'] + items_to_undo)
427 else:
428 qtutils.log(1, self.tr('No files selected for '
429 'checkout from HEAD.'))
431 def _revert_uncommitted_edits(self):
432 if not self.m.undoable():
433 return
434 items_to_undo = self.modified()
435 if items_to_undo:
436 if not qtutils.confirm('Revert Uncommitted Changes?',
437 'This operation drops uncommitted changes.'
438 '\nThese changes cannot be recovered.',
439 'Revert the uncommitted changes?',
440 'Revert Uncommitted Changes',
441 default=False,
442 icon=qtutils.icon('undo.svg')):
443 return
444 cola.notifier().broadcast(signals.checkout,
445 ['HEAD', '--'] + items_to_undo)
446 else:
447 qtutils.log(1, self.tr('No files selected for '
448 'checkout from HEAD.'))
450 def single_selection(self):
451 """Scan across staged, modified, etc. and return a single item."""
452 staged, modified, unmerged, untracked = self.selection()
453 s = None
454 m = None
455 um = None
456 ut = None
457 if staged:
458 s = staged[0]
459 elif modified:
460 m = modified[0]
461 elif unmerged:
462 um = unmerged[0]
463 elif untracked:
464 ut = untracked[0]
465 return s, m, um, ut
467 def selected_indexes(self):
468 """Returns a list of (category, row) representing the tree selection."""
469 selected = self.selectedIndexes()
470 result = []
471 for idx in selected:
472 if idx.parent().isValid():
473 parent_idx = idx.parent()
474 entry = (parent_idx.row(), idx.row())
475 else:
476 entry = (-1, idx.row())
477 result.append(entry)
478 return result
480 def selection(self):
481 """Return the current selection in the repo status tree."""
482 return (self.staged(), self.modified(),
483 self.unmerged(), self.untracked())
485 def contents(self):
486 return (self.m.staged, self.m.modified,
487 self.m.unmerged, self.m.untracked)
489 def staged(self):
490 return self._subtree_selection(self.idx_staged, self.m.staged)
492 def unstaged(self):
493 return self.modified() + self.unmerged() + self.untracked()
495 def modified(self):
496 return self._subtree_selection(self.idx_modified, self.m.modified)
498 def unmerged(self):
499 return self._subtree_selection(self.idx_unmerged, self.m.unmerged)
501 def untracked(self):
502 return self._subtree_selection(self.idx_untracked, self.m.untracked)
504 def _subtree_selection(self, idx, items):
505 item = self.topLevelItem(idx)
506 return qtutils.tree_selection(item, items)
508 def mouseReleaseEvent(self, event):
509 result = QtGui.QTreeWidget.mouseReleaseEvent(self, event)
510 self.clicked()
511 return result
513 def clicked(self, item=None, idx=None):
514 """Called when a repo status tree item is clicked.
516 This handles the behavior where clicking on the icon invokes
517 the a context-specific action.
520 if self.m.read_only():
521 return
523 # Sync the selection model
524 staged, modified, unmerged, untracked = self.selection()
525 cola.selection_model().set_selection(staged, modified,
526 unmerged, untracked)
528 # Clear the selection if an empty area was clicked
529 selection = self.selected_indexes()
530 if not selection:
531 if self.mode == self.m.mode_amend:
532 cola.notifier().broadcast(signals.set_diff_text, '')
533 else:
534 cola.notifier().broadcast(signals.reset_mode)
535 self.blockSignals(True)
536 self.clearSelection()
537 self.blockSignals(False)
538 return
540 if staged:
541 qtutils.set_clipboard(staged[0])
542 elif modified:
543 qtutils.set_clipboard(modified[0])
544 elif unmerged:
545 qtutils.set_clipboard(unmerged[0])
546 elif untracked:
547 qtutils.set_clipboard(untracked[0])
549 def double_clicked(self, item, idx):
550 """Called when an item is double-clicked in the repo status tree."""
551 self._process_selection()
553 def _process_selection(self):
554 if self.m.read_only():
555 return
556 staged, modified, unmerged, untracked = self.selection()
557 if staged:
558 cola.notifier().broadcast(signals.unstage, staged)
559 elif modified:
560 cola.notifier().broadcast(signals.stage, modified)
561 elif unmerged:
562 cola.notifier().broadcast(signals.stage, unmerged)
563 elif untracked:
564 cola.notifier().broadcast(signals.stage, untracked)
566 def _launch_difftool(self):
567 staged, modified, unmerged, untracked = self.selection()
568 if staged:
569 selection = staged
570 elif unmerged:
571 selection = unmerged
572 elif modified:
573 selection = modified
574 else:
575 return
576 cola.notifier().broadcast(signals.difftool, bool(staged), selection)
578 def _launch_editor(self):
579 staged, modified, unmerged, untracked = self.selection()
580 if staged:
581 selection = staged
582 elif unmerged:
583 selection = unmerged
584 elif modified:
585 selection = modified
586 elif untracked:
587 selection = untracked
588 else:
589 return
590 cola.notifier().broadcast(signals.edit, selection)
592 def show_selection(self):
593 """Show the selected item."""
594 # Sync the selection model
595 s, m, um, ut = self.selection()
596 cola.selection_model().set_selection(s, m, um, ut)
598 selection = self.selected_indexes()
599 if not selection:
600 return
601 category, idx = selection[0]
602 # A header item e.g. 'Staged', 'Modified', etc.
603 if category == self.idx_header:
604 signal = {
605 self.idx_staged: signals.staged_summary,
606 self.idx_modified: signals.modified_summary,
607 self.idx_unmerged: signals.unmerged_summary,
608 self.idx_untracked: signals.untracked_summary,
609 }.get(idx, signals.diffstat)
610 cola.notifier().broadcast(signal)
611 # A staged file
612 elif category == self.idx_staged:
613 cola.notifier().broadcast(signals.diff_staged, self.staged())
615 # A modified file
616 elif category == self.idx_modified:
617 cola.notifier().broadcast(signals.diff, self.modified())
619 elif category == self.idx_unmerged:
620 cola.notifier().broadcast(signals.diff, self.unmerged())
622 elif category == self.idx_untracked:
623 cola.notifier().broadcast(signals.show_untracked, self.unstaged())
625 def move_up(self):
626 self.move('up')
628 def move_down(self):
629 self.move('down')
631 def move(self, direction):
632 staged, modified, unmerged, untracked = self.single_selection()
633 to_try = [
634 (staged, self.m.staged, self.idx_staged),
635 (unmerged, self.m.unmerged, self.idx_unmerged),
636 (modified, self.m.modified, self.idx_modified),
637 (untracked, self.m.untracked, self.idx_untracked),
639 going_up = direction == 'up'
640 def select(item):
641 self.scrollToItem(item)
642 self.setCurrentItem(item)
643 self.setItemSelected(item, True)
645 has_selection = [i[0] for i in to_try if i[0] is not None]
646 if not has_selection:
647 indexes = self.selectedIndexes()
648 if indexes:
649 # No files are selected but the tree has a selection;
650 # it must be one of the container items.
651 index = indexes[0]
652 parent_item = self.itemFromIndex(index)
653 if going_up:
654 item = self.itemAbove(parent_item)
655 else:
656 item = self.itemBelow(parent_item)
657 if item:
658 select(item)
659 return
661 if going_up:
662 # go to the last item
663 for dummy, itemlist, parent_idx in reversed(to_try):
664 idx = len(itemlist)-1
665 if idx >= 0:
666 parent = self.topLevelItem(parent_idx)
667 select(parent.child(idx))
668 return
669 else:
670 # go to the first item
671 for dummy, itemlist, parent_idx in to_try:
672 if itemlist:
673 parent = self.topLevelItem(parent_idx)
674 item = parent.child(0)
675 select(parent.child(0))
676 return
677 return
679 for item, itemlist, parent_idx in to_try:
680 if item is None:
681 continue
682 idx = itemlist.index(item)
683 if going_up:
684 if idx > 0:
685 parent = self.topLevelItem(parent_idx)
686 select(parent.child(idx - 1))
687 return
688 else:
689 if idx < len(itemlist) - 1:
690 parent = self.topLevelItem(parent_idx)
691 select(parent.child(idx + 1))
692 return
694 # Jump across category boundaries to select the next file.
695 # We do not use itemAbove/Below because we want to avoid
696 # selecting the 'Staged', 'Modified', etc. parent items
698 ready = False
699 best_idx = None
700 best_list = None
702 if going_up:
703 to_try.reverse()
705 for item, itemlist, parent_idx in to_try:
706 if item is not None and not ready:
707 ready = True
708 continue
709 if itemlist:
710 best_list = itemlist
711 best_idx = parent_idx
713 if best_list:
714 parent = self.topLevelItem(best_idx)
715 if going_up:
716 select(parent.child(len(best_list) - 1))
717 else:
718 select(parent.child(0))