widgets: Use the common shorcuts defined in widgets.defs
[git-cola.git] / cola / widgets / status.py
blobcef84f35ce3daf8f05d3e1a5342883ed79ef5785
1 import os
2 import subprocess
3 import itertools
5 from PyQt4 import QtGui
6 from PyQt4.QtCore import SIGNAL
8 import cola
9 from cola import signals
10 from cola import qtutils
11 from cola.compat import set
12 from cola.qtutils import SLOT
13 from cola.widgets import defs
14 from cola.models.selection import State
17 def select_item(tree, item):
18 if not item:
19 return
20 tree.setItemSelected(item, True)
21 parent = item.parent()
22 if parent:
23 tree.scrollToItem(parent)
24 tree.scrollToItem(item)
27 class StatusWidget(QtGui.QWidget):
28 """
29 Provides a git-status-like repository widget.
31 This widget observes the main model and broadcasts
32 Qt signals.
34 """
35 def __init__(self, parent=None):
36 QtGui.QWidget.__init__(self, parent)
37 self.layout = QtGui.QVBoxLayout(self)
38 self.setLayout(self.layout)
40 self.tree = StatusTreeWidget(self)
41 self.layout.addWidget(self.tree)
42 self.layout.setContentsMargins(0, 0, 0, 0)
45 class StatusTreeWidget(QtGui.QTreeWidget):
46 # Item categories
47 idx_header = -1
48 idx_staged = 0
49 idx_unmerged = 1
50 idx_modified = 2
51 idx_untracked = 3
52 idx_end = 4
54 # Read-only access to the mode state
55 mode = property(lambda self: self.m.mode)
57 def __init__(self, parent):
58 QtGui.QTreeWidget.__init__(self, parent)
60 self.setSelectionMode(QtGui.QAbstractItemView.ExtendedSelection)
61 self.headerItem().setHidden(True)
62 self.setAllColumnsShowFocus(True)
63 self.setSortingEnabled(False)
64 self.setUniformRowHeights(True)
65 self.setAnimated(True)
66 self.setRootIsDecorated(False)
68 self.add_item('Staged', 'plus.png', hide=True)
69 self.add_item('Unmerged', 'unmerged.png', hide=True)
70 self.add_item('Modified', 'modified.png', hide=True)
71 self.add_item('Untracked', 'untracked.png', hide=True)
73 # Used to restore the selection
74 self.old_scroll = None
75 self.old_selection = None
76 self.old_contents = None
78 self.expanded_items = set()
80 self.process_selection = qtutils.add_action(self,
81 'Process Selection', self._process_selection,
82 defs.stage_shortcut)
84 self.launch_difftool = qtutils.add_action(self,
85 'Launch Diff Tool', self._launch_difftool,
86 defs.difftool_shortcut)
87 self.launch_difftool.setIcon(qtutils.icon('git.svg'))
89 self.launch_editor = qtutils.add_action(self,
90 'Launch Editor', self._launch_editor,
91 defs.editor_shortcut)
92 self.launch_editor.setIcon(qtutils.open_file_icon())
94 self.up = qtutils.add_action(self, 'Move Up', self.move_up, 'K')
95 self.down = qtutils.add_action(self, 'Move Down', self.move_down, 'J')
97 self.connect(self, SIGNAL('about_to_update'), self._about_to_update)
98 self.connect(self, SIGNAL('updated'), self._updated)
100 self.m = cola.model()
101 self.m.add_observer(self.m.message_about_to_update,
102 self.about_to_update)
103 self.m.add_observer(self.m.message_updated, self.updated)
105 self.connect(self, SIGNAL('itemSelectionChanged()'),
106 self.show_selection)
108 self.connect(self,
109 SIGNAL('itemDoubleClicked(QTreeWidgetItem*,int)'),
110 self.double_clicked)
112 self.connect(self,
113 SIGNAL('itemCollapsed(QTreeWidgetItem*)'),
114 lambda x: self.update_column_widths())
116 self.connect(self,
117 SIGNAL('itemExpanded(QTreeWidgetItem*)'),
118 lambda x: self.update_column_widths())
120 def add_item(self, txt, path, hide=False):
121 """Create a new top-level item in the status tree."""
122 item = QtGui.QTreeWidgetItem(self)
123 item.setText(0, self.tr(txt))
124 item.setIcon(0, qtutils.icon(path))
125 if hide:
126 self.setItemHidden(item, True)
128 def restore_selection(self):
129 if not self.old_selection or not self.old_contents:
130 return
132 old_c = self.old_contents
133 old_s = self.old_selection
134 new_c = self.contents()
136 def select_modified(item):
137 idx = new_c.modified.index(item)
138 select_item(self, self.modified_item(idx))
140 def select_unmerged(item):
141 idx = new_c.unmerged.index(item)
142 select_item(self, self.unmerged_item(idx))
144 def select_untracked(item):
145 idx = new_c.untracked.index(item)
146 select_item(self, self.untracked_item(idx))
148 def select_staged(item):
149 idx = new_c.staged.index(item)
150 select_item(self, self.staged_item(idx))
152 restore_selection_actions = (
153 (new_c.modified, old_c.modified, old_s.modified, select_modified),
154 (new_c.unmerged, old_c.unmerged, old_s.unmerged, select_unmerged),
155 (new_c.untracked, old_c.untracked, old_s.untracked, select_untracked),
156 (new_c.staged, old_c.staged, old_s.staged, select_staged),
159 for (new, old, selection, action) in restore_selection_actions:
160 # When modified is staged, select the next modified item
161 # When unmerged is staged, select the next unmerged item
162 # When untracked is staged, select the next untracked item
163 # When something is unstaged we should select the next staged item
164 new_set = set(new)
165 if len(new) < len(old) and old:
166 for idx, i in enumerate(old):
167 if i not in new_set:
168 for j in itertools.chain(old[idx+1:],
169 reversed(old[:idx])):
170 if j in new_set:
171 action(j)
172 return
174 for (new, old, selection, action) in restore_selection_actions:
175 # Reselect items when doing partial-staging
176 new_set = set(new)
177 for item in selection:
178 if item in new_set:
179 action(item)
181 def staged_item(self, itemidx):
182 return self._subtree_item(self.idx_staged, itemidx)
184 def modified_item(self, itemidx):
185 return self._subtree_item(self.idx_modified, itemidx)
187 def unmerged_item(self, itemidx):
188 return self._subtree_item(self.idx_unmerged, itemidx)
190 def untracked_item(self, itemidx):
191 return self._subtree_item(self.idx_untracked, itemidx)
193 def unstaged_item(self, itemidx):
194 # is it modified?
195 item = self.topLevelItem(self.idx_modified)
196 count = item.childCount()
197 if itemidx < count:
198 return item.child(itemidx)
199 # is it unmerged?
200 item = self.topLevelItem(self.idx_unmerged)
201 count += item.childCount()
202 if itemidx < count:
203 return item.child(itemidx)
204 # is it untracked?
205 item = self.topLevelItem(self.idx_untracked)
206 count += item.childCount()
207 if itemidx < count:
208 return item.child(itemidx)
209 # Nope..
210 return None
212 def _subtree_item(self, idx, itemidx):
213 parent = self.topLevelItem(idx)
214 return parent.child(itemidx)
216 def about_to_update(self):
217 self.emit(SIGNAL('about_to_update'))
219 def _about_to_update(self):
220 self.old_selection = self.selection()
221 self.old_contents = self.contents()
223 self.old_scroll = None
224 vscroll = self.verticalScrollBar()
225 if vscroll:
226 self.old_scroll = vscroll.value()
228 def updated(self):
229 """Update display from model data."""
230 self.emit(SIGNAL('updated'))
232 def _updated(self):
233 self.set_staged(self.m.staged)
234 self.set_modified(self.m.modified)
235 self.set_unmerged(self.m.unmerged)
236 self.set_untracked(self.m.untracked)
238 vscroll = self.verticalScrollBar()
239 if vscroll and self.old_scroll is not None:
240 vscroll.setValue(self.old_scroll)
241 self.old_scroll = None
243 self.restore_selection()
244 self.update_column_widths()
246 def set_staged(self, items):
247 """Adds items to the 'Staged' subtree."""
248 self._set_subtree(items, self.idx_staged, staged=True,
249 check=not self.m.amending())
251 def set_modified(self, items):
252 """Adds items to the 'Modified' subtree."""
253 self._set_subtree(items, self.idx_modified)
255 def set_unmerged(self, items):
256 """Adds items to the 'Unmerged' subtree."""
257 self._set_subtree(items, self.idx_unmerged)
259 def set_untracked(self, items):
260 """Adds items to the 'Untracked' subtree."""
261 self._set_subtree(items, self.idx_untracked)
263 def _set_subtree(self, items, idx,
264 staged=False,
265 untracked=False,
266 check=True):
267 """Add a list of items to a treewidget item."""
268 parent = self.topLevelItem(idx)
269 if items:
270 self.setItemHidden(parent, False)
271 else:
272 self.setItemHidden(parent, True)
273 parent.takeChildren()
274 for item in items:
275 treeitem = qtutils.create_treeitem(item,
276 staged=staged,
277 check=check,
278 untracked=untracked)
279 parent.addChild(treeitem)
280 self.expand_items(idx, items)
282 def update_column_widths(self):
283 self.resizeColumnToContents(0)
285 def expand_items(self, idx, items):
286 """Expand the top-level category "folder" once and only once."""
287 # Don't do this if items is empty; this makes it so that we
288 # don't add the top-level index into the expanded_items set
289 # until an item appears in a particular category.
290 if not items:
291 return
292 # Only run this once; we don't want to re-expand items that
293 # we've clicked on to re-collapse on updated().
294 if idx in self.expanded_items:
295 return
296 self.expanded_items.add(idx)
297 item = self.topLevelItem(idx)
298 if item:
299 self.expandItem(item)
301 def contextMenuEvent(self, event):
302 """Create context menus for the repo status tree."""
303 menu = self.create_context_menu()
304 menu.exec_(self.mapToGlobal(event.pos()))
306 def create_context_menu(self):
307 """Set up the status menu for the repo status tree."""
308 s = self.selection()
309 menu = QtGui.QMenu(self)
311 selection = self.selected_indexes()
312 if selection:
313 category, idx = selection[0]
314 # A header item e.g. 'Staged', 'Modified', etc.
315 if category == self.idx_header:
316 if idx == self.idx_staged:
317 menu.addAction(qtutils.icon('remove.svg'),
318 self.tr('Unstage All'),
319 SLOT(signals.unstage_all))
320 return menu
321 elif idx == self.idx_unmerged:
322 menu.addAction(qtutils.icon('add.svg'),
323 self.tr('Stage Merged'),
324 SLOT(signals.stage_unmerged))
325 return menu
326 elif idx == self.idx_modified:
327 menu.addAction(qtutils.icon('add.svg'),
328 self.tr('Stage Modified'),
329 SLOT(signals.stage_modified))
330 return menu
332 elif idx == self.idx_untracked:
333 menu.addAction(qtutils.icon('add.svg'),
334 self.tr('Stage Untracked'),
335 SLOT(signals.stage_untracked))
336 return menu
338 if s.staged and self.m.unstageable():
339 menu.addAction(qtutils.icon('remove.svg'),
340 self.tr('Unstage Selected'),
341 SLOT(signals.unstage, self.staged()))
343 if s.staged and s.staged[0] in self.m.submodules:
344 menu.addAction(qtutils.git_icon(),
345 self.tr('Launch git-cola'),
346 SLOT(signals.open_repo,
347 os.path.abspath(s.staged[0])))
348 return menu
349 elif s.staged:
350 menu.addSeparator()
351 menu.addAction(qtutils.icon('open.svg'),
352 self.tr('Launch Editor'),
353 SLOT(signals.edit, self.staged()))
354 menu.addAction(qtutils.git_icon(),
355 self.tr('Launch Diff Tool'),
356 SLOT(signals.difftool, True, self.staged()))
357 if self.m.undoable():
358 menu.addSeparator()
359 menu.addAction(qtutils.icon('undo.svg'),
360 self.tr('Revert Unstaged Edits...'),
361 lambda: self._revert_unstaged_edits(staged=True))
362 return menu
364 if s.unmerged:
365 menu.addAction(qtutils.git_icon(),
366 self.tr('Launch Merge Tool'),
367 SLOT(signals.mergetool, self.unmerged()))
368 menu.addAction(qtutils.icon('open.svg'),
369 self.tr('Launch Editor'),
370 SLOT(signals.edit, self.unmerged()))
371 menu.addSeparator()
372 menu.addAction(qtutils.icon('add.svg'),
373 self.tr('Stage Selected'),
374 SLOT(signals.stage, self.unstaged()))
375 return menu
377 modified_submodule = (s.modified and
378 s.modified[0] in self.m.submodules)
379 if self.m.stageable():
380 menu.addAction(qtutils.icon('add.svg'),
381 self.tr('Stage Selected'),
382 SLOT(signals.stage, self.unstaged()))
383 menu.addSeparator()
385 if modified_submodule:
386 menu.addAction(qtutils.git_icon(),
387 self.tr('Launch git-cola'),
388 SLOT(signals.open_repo,
389 os.path.abspath(s.modified[0])))
390 elif self.unstaged():
391 menu.addAction(qtutils.icon('open.svg'),
392 self.tr('Launch Editor'),
393 SLOT(signals.edit, self.unstaged()))
395 if s.modified and self.m.stageable() and not modified_submodule:
396 menu.addAction(qtutils.git_icon(),
397 self.tr('Launch Diff Tool'),
398 SLOT(signals.difftool, False, self.modified()))
399 menu.addSeparator()
400 if self.m.undoable():
401 menu.addAction(qtutils.icon('undo.svg'),
402 self.tr('Revert Unstaged Edits...'),
403 self._revert_unstaged_edits)
404 menu.addAction(qtutils.icon('undo.svg'),
405 self.tr('Revert Uncommited Edits...'),
406 self._revert_uncommitted_edits)
408 if s.untracked:
409 menu.addSeparator()
410 menu.addAction(qtutils.discard_icon(),
411 self.tr('Delete File(s)...'), self._delete_files)
412 menu.addSeparator()
413 menu.addAction(qtutils.icon('edit-clear.svg'),
414 self.tr('Add to .gitignore'),
415 SLOT(signals.ignore,
416 map(lambda x: '/' + x, self.untracked())))
417 return menu
419 def _delete_files(self):
420 files = self.untracked()
421 count = len(files)
422 if count == 0:
423 return
425 title = 'Delete Files?'
426 msg = self.tr('The following files will be deleted:\n\n')
428 fileinfo = subprocess.list2cmdline(files)
429 if len(fileinfo) > 2048:
430 fileinfo = fileinfo[:2048].rstrip() + '...'
431 msg += fileinfo
433 info_txt = unicode(self.tr('Delete %d file(s)?')) % count
434 ok_txt = 'Delete Files'
436 if qtutils.confirm(title, msg, info_txt, ok_txt,
437 default=False,
438 icon=qtutils.discard_icon()):
439 cola.notifier().broadcast(signals.delete, files)
441 def _revert_unstaged_edits(self, staged=False):
442 if not self.m.undoable():
443 return
444 if staged:
445 items_to_undo = self.staged()
446 else:
447 items_to_undo = self.modified()
449 if items_to_undo:
450 if not qtutils.confirm('Revert Unstaged Changes?',
451 'This operation drops unstaged changes.'
452 '\nThese changes cannot be recovered.',
453 'Revert the unstaged changes?',
454 'Revert Unstaged Changes',
455 default=False,
456 icon=qtutils.icon('undo.svg')):
457 return
458 args = []
459 if not staged and self.m.amending():
460 args.append(self.m.head)
461 cola.notifier().broadcast(signals.checkout,
462 args + ['--'] + items_to_undo)
463 else:
464 qtutils.log(1, self.tr('No files selected for '
465 'checkout from HEAD.'))
467 def _revert_uncommitted_edits(self):
468 items_to_undo = self.modified()
469 if items_to_undo:
470 if not qtutils.confirm('Revert Uncommitted Changes?',
471 'This operation drops uncommitted changes.'
472 '\nThese changes cannot be recovered.',
473 'Revert the uncommitted changes?',
474 'Revert Uncommitted Changes',
475 default=False,
476 icon=qtutils.icon('undo.svg')):
477 return
478 cola.notifier().broadcast(signals.checkout,
479 [self.m.head, '--'] + items_to_undo)
480 else:
481 qtutils.log(1, self.tr('No files selected for '
482 'checkout from HEAD.'))
484 def single_selection(self):
485 """Scan across staged, modified, etc. and return a single item."""
486 st = None
487 um = None
488 m = None
489 ut = None
491 s = self.selection()
492 if s.staged:
493 st = s.staged[0]
494 elif s.modified:
495 m = s.modified[0]
496 elif s.unmerged:
497 um = s.unmerged[0]
498 elif s.untracked:
499 ut = s.untracked[0]
501 return State(st, um, m, ut)
503 def selected_indexes(self):
504 """Returns a list of (category, row) representing the tree selection."""
505 selected = self.selectedIndexes()
506 result = []
507 for idx in selected:
508 if idx.parent().isValid():
509 parent_idx = idx.parent()
510 entry = (parent_idx.row(), idx.row())
511 else:
512 entry = (-1, idx.row())
513 result.append(entry)
514 return result
516 def selection(self):
517 """Return the current selection in the repo status tree."""
518 return State(self.staged(), self.unmerged(),
519 self.modified(), self.untracked())
521 def contents(self):
522 return State(self.m.staged, self.m.unmerged,
523 self.m.modified, self.m.untracked)
525 def all_files(self):
526 c = self.contents()
527 return c.staged + c.unmerged + c.modified + c.untracked
529 def selected_idx(self):
530 c = self.contents()
531 s = self.single_selection()
532 offset = 0
533 for content, selection in zip(c, s):
534 if len(content) == 0:
535 continue
536 if selection is not None:
537 return offset + content.index(selection)
538 offset += len(content)
539 return None
541 def select_by_index(self, idx):
542 c = self.contents()
543 to_try = [
544 (c.staged, self.idx_staged),
545 (c.unmerged, self.idx_unmerged),
546 (c.modified, self.idx_modified),
547 (c.untracked, self.idx_untracked),
549 for content, toplevel_idx in to_try:
550 if len(content) == 0:
551 continue
552 if idx < len(content):
553 parent = self.topLevelItem(toplevel_idx)
554 item = parent.child(idx)
555 self.select_item(item)
556 return
557 idx -= len(content)
559 def select_item(self, item):
560 self.scrollToItem(item)
561 self.setCurrentItem(item)
562 self.setItemSelected(item, True)
564 def staged(self):
565 return self._subtree_selection(self.idx_staged, self.m.staged)
567 def unstaged(self):
568 return self.unmerged() + self.modified() + self.untracked()
570 def modified(self):
571 return self._subtree_selection(self.idx_modified, self.m.modified)
573 def unmerged(self):
574 return self._subtree_selection(self.idx_unmerged, self.m.unmerged)
576 def untracked(self):
577 return self._subtree_selection(self.idx_untracked, self.m.untracked)
579 def _subtree_selection(self, idx, items):
580 item = self.topLevelItem(idx)
581 return qtutils.tree_selection(item, items)
583 def mouseReleaseEvent(self, event):
584 result = QtGui.QTreeWidget.mouseReleaseEvent(self, event)
585 self.clicked()
586 return result
588 def clicked(self, item=None, idx=None):
589 """Called when a repo status tree item is clicked.
591 This handles the behavior where clicking on the icon invokes
592 the a context-specific action.
595 # Sync the selection model
596 s = self.selection()
597 cola.selection_model().set_selection(s)
599 # Clear the selection if an empty area was clicked
600 selection = self.selected_indexes()
601 if not selection:
602 if self.m.amending():
603 cola.notifier().broadcast(signals.set_diff_text, '')
604 else:
605 cola.notifier().broadcast(signals.reset_mode)
606 self.blockSignals(True)
607 self.clearSelection()
608 self.blockSignals(False)
609 return
611 filename = cola.selection_model().filename()
612 if filename is not None:
613 qtutils.set_clipboard(filename)
615 def double_clicked(self, item, idx):
616 """Called when an item is double-clicked in the repo status tree."""
617 self._process_selection()
619 def _process_selection(self):
620 s = self.selection()
621 if s.staged:
622 cola.notifier().broadcast(signals.unstage, s.staged)
624 unstaged = []
625 if s.unmerged:
626 unstaged.extend(s.unmerged)
627 if s.modified:
628 unstaged.extend(s.modified)
629 if s.untracked:
630 unstaged.extend(s.untracked)
631 if unstaged:
632 cola.notifier().broadcast(signals.stage, unstaged)
634 def _launch_difftool(self):
635 staged, modified, unmerged, untracked = self.selection()
636 if staged:
637 selection = staged
638 elif unmerged:
639 selection = unmerged
640 elif modified:
641 selection = modified
642 else:
643 return
644 cola.notifier().broadcast(signals.difftool, bool(staged), selection)
646 def _launch_editor(self):
647 s = self.selection()
648 if s.staged:
649 selection = s.staged
650 elif s.unmerged:
651 selection = s.unmerged
652 elif s.modified:
653 selection = s.modified
654 elif s.untracked:
655 selection = s.untracked
656 else:
657 return
658 cola.notifier().broadcast(signals.edit, selection)
660 def show_selection(self):
661 """Show the selected item."""
662 # Sync the selection model
663 cola.selection_model().set_selection(self.selection())
665 selection = self.selected_indexes()
666 if not selection:
667 return
668 category, idx = selection[0]
669 # A header item e.g. 'Staged', 'Modified', etc.
670 if category == self.idx_header:
671 signal = {
672 self.idx_staged: signals.staged_summary,
673 self.idx_modified: signals.modified_summary,
674 self.idx_unmerged: signals.unmerged_summary,
675 self.idx_untracked: signals.untracked_summary,
676 }.get(idx, signals.diffstat)
677 cola.notifier().broadcast(signal)
678 # A staged file
679 elif category == self.idx_staged:
680 cola.notifier().broadcast(signals.diff_staged, self.staged())
682 # A modified file
683 elif category == self.idx_modified:
684 cola.notifier().broadcast(signals.diff, self.modified())
686 elif category == self.idx_unmerged:
687 cola.notifier().broadcast(signals.diff, self.unmerged())
689 elif category == self.idx_untracked:
690 cola.notifier().broadcast(signals.show_untracked, self.unstaged())
692 def move_up(self):
693 idx = self.selected_idx()
694 all_files = self.all_files()
695 if idx is None:
696 selection = self.selected_indexes()
697 if selection:
698 category, toplevel_idx = selection[0]
699 if category == self.idx_header:
700 item = self.itemAbove(self.topLevelItem(toplevel_idx))
701 if item is not None:
702 self.select_item(item)
703 return
704 if all_files:
705 self.select_by_index(len(all_files) - 1)
706 return
707 if idx - 1 >= 0:
708 self.select_by_index(idx - 1)
709 else:
710 self.select_by_index(len(all_files) - 1)
712 def move_down(self):
713 idx = self.selected_idx()
714 all_files = self.all_files()
715 if idx is None:
716 selection = self.selected_indexes()
717 if selection:
718 category, toplevel_idx = selection[0]
719 if category == self.idx_header:
720 item = self.itemBelow(self.topLevelItem(toplevel_idx))
721 if item is not None:
722 self.select_item(item)
723 return
724 if all_files:
725 self.select_by_index(0)
726 return
727 if idx + 1 < len(all_files):
728 self.select_by_index(idx + 1)
729 else:
730 self.select_by_index(0)