diff: add Alt-J and Alt-K Previous/Next File shortcuts
[git-cola.git] / cola / widgets / status.py
blobd24277373b26c188eb06e3a402248516fa9b52a8
1 from __future__ import division, absolute_import, unicode_literals
3 import itertools
5 from PyQt4 import QtCore
6 from PyQt4 import QtGui
7 from PyQt4.QtCore import Qt
8 from PyQt4.QtCore import SIGNAL
10 from cola import cmds
11 from cola import core
12 from cola import qtutils
13 from cola import utils
14 from cola.i18n import N_
15 from cola.models import main
16 from cola.models import selection
17 from cola.widgets import completion
18 from cola.widgets import defs
21 class StatusWidget(QtGui.QWidget):
22 """
23 Provides a git-status-like repository widget.
25 This widget observes the main model and broadcasts
26 Qt signals.
28 """
29 def __init__(self, titlebar, parent=None):
30 QtGui.QWidget.__init__(self, parent)
32 tooltip = N_('Toggle the paths filter')
33 self.filter_button = qtutils.create_action_button(
34 tooltip=tooltip,
35 icon=qtutils.filter_icon())
37 self.filter_widget = StatusFilterWidget()
38 self.filter_widget.hide()
39 self.tree = StatusTreeWidget()
40 self.setFocusProxy(self.tree)
42 self.main_layout = qtutils.vbox(defs.no_margin, defs.no_spacing,
43 self.filter_widget, self.tree)
44 self.setLayout(self.main_layout)
46 self.toggle_action = qtutils.add_action(self, tooltip,
47 self.toggle_filter, 'Shift+Ctrl+F')
49 titlebar.add_corner_widget(self.filter_button)
50 qtutils.connect_button(self.filter_button, self.toggle_filter)
52 def toggle_filter(self):
53 shown = not self.filter_widget.isVisible()
54 self.filter_widget.setVisible(shown)
55 if shown:
56 self.filter_widget.setFocus(True)
57 else:
58 self.tree.setFocus(True)
60 def set_initial_size(self):
61 self.setMaximumWidth(222)
62 QtCore.QTimer.singleShot(1, self.restore_size)
64 def restore_size(self):
65 self.setMaximumWidth(2 ** 13)
67 def refresh(self):
68 self.tree.show_selection()
70 def set_filter(self, txt):
71 self.filter_widget.setVisible(True)
72 self.filter_widget.text.set_value(txt)
73 self.filter_widget.apply_filter()
75 def move_up(self):
76 self.tree.move_up()
78 def move_down(self):
79 self.tree.move_down()
82 class StatusTreeWidget(QtGui.QTreeWidget):
83 # Item categories
84 idx_header = -1
85 idx_staged = 0
86 idx_unmerged = 1
87 idx_modified = 2
88 idx_untracked = 3
89 idx_end = 4
91 # Read-only access to the mode state
92 mode = property(lambda self: self.m.mode)
94 def __init__(self, parent=None):
95 QtGui.QTreeWidget.__init__(self, parent)
97 self.setSelectionMode(QtGui.QAbstractItemView.ExtendedSelection)
98 self.headerItem().setHidden(True)
99 self.setAllColumnsShowFocus(True)
100 self.setSortingEnabled(False)
101 self.setUniformRowHeights(True)
102 self.setAnimated(True)
103 self.setRootIsDecorated(False)
104 self.setIndentation(0)
105 self.setDragEnabled(True)
107 self.add_item(N_('Staged'), hide=True)
108 self.add_item(N_('Unmerged'), hide=True)
109 self.add_item(N_('Modified'), hide=True)
110 self.add_item(N_('Untracked'), hide=True)
112 # Used to restore the selection
113 self.old_scroll = None
114 self.old_selection = None
115 self.old_contents = None
116 self.old_current_item = None
117 self.expanded_items = set()
119 self.process_selection_action = qtutils.add_action(self,
120 cmds.StageOrUnstage.name(),
121 cmds.run(cmds.StageOrUnstage),
122 cmds.StageOrUnstage.SHORTCUT)
124 self.revert_unstaged_edits_action = qtutils.add_action(self,
125 cmds.RevertUnstagedEdits.name(),
126 cmds.run(cmds.RevertUnstagedEdits),
127 cmds.RevertUnstagedEdits.SHORTCUT)
128 self.revert_unstaged_edits_action.setIcon(qtutils.theme_icon('edit-undo.svg'))
130 self.launch_difftool_action = qtutils.add_action(self,
131 cmds.LaunchDifftool.name(),
132 cmds.run(cmds.LaunchDifftool),
133 cmds.LaunchDifftool.SHORTCUT)
134 self.launch_difftool_action.setIcon(qtutils.git_icon())
136 self.launch_editor_action = qtutils.add_action(self,
137 cmds.LaunchEditor.name(),
138 cmds.run(cmds.LaunchEditor),
139 cmds.LaunchEditor.SHORTCUT,
140 'Return', 'Enter')
141 self.launch_editor_action.setIcon(qtutils.options_icon())
143 if not utils.is_win32():
144 self.open_using_default_app = qtutils.add_action(self,
145 cmds.OpenDefaultApp.name(),
146 self._open_using_default_app,
147 cmds.OpenDefaultApp.SHORTCUT)
148 self.open_using_default_app.setIcon(qtutils.file_icon())
150 self.open_parent_dir_action = qtutils.add_action(self,
151 cmds.OpenParentDir.name(),
152 self._open_parent_dir,
153 cmds.OpenParentDir.SHORTCUT)
154 self.open_parent_dir_action.setIcon(qtutils.open_file_icon())
156 self.up_action = qtutils.add_action(self,
157 N_('Move Up'), self.move_up, Qt.Key_K)
159 self.down_action = qtutils.add_action(self,
160 N_('Move Down'), self.move_down, Qt.Key_J)
162 self.copy_path_action = qtutils.add_action(self,
163 N_('Copy Path to Clipboard'),
164 self.copy_path, QtGui.QKeySequence.Copy)
165 self.copy_path_action.setIcon(qtutils.theme_icon('edit-copy.svg'))
167 self.copy_relpath_action = qtutils.add_action(self,
168 N_('Copy Relative Path to Clipboard'),
169 self.copy_relpath, QtGui.QKeySequence.Cut)
170 self.copy_relpath_action.setIcon(qtutils.theme_icon('edit-copy.svg'))
172 # MoveToTrash and Delete use the same shortcut.
173 # We will only bind one of them, depending on whether or not the
174 # MoveToTrash command is avaialble. When available, the hotkey
175 # is bound to MoveToTrash, otherwise it is bound to Delete.
176 if cmds.MoveToTrash.AVAILABLE:
177 self.move_to_trash_action = qtutils.add_action(self,
178 N_('Move file(s) to trash'),
179 self._trash_untracked_files, cmds.MoveToTrash.SHORTCUT)
180 self.move_to_trash_action.setIcon(qtutils.discard_icon())
181 delete_shortcut = cmds.Delete.SHORTCUT
182 else:
183 self.move_to_trash_action = None
184 delete_shortcut = cmds.Delete.ALT_SHORTCUT
186 self.delete_untracked_files_action = qtutils.add_action(self,
187 N_('Delete File(s)...'),
188 self._delete_untracked_files, delete_shortcut)
189 self.delete_untracked_files_action.setIcon(qtutils.discard_icon())
191 self.connect(self, SIGNAL('about_to_update()'),
192 self._about_to_update, Qt.QueuedConnection)
193 self.connect(self, SIGNAL('updated()'),
194 self._updated, Qt.QueuedConnection)
196 self.m = main.model()
197 self.m.add_observer(self.m.message_about_to_update,
198 self.about_to_update)
199 self.m.add_observer(self.m.message_updated, self.updated)
201 self.connect(self, SIGNAL('itemSelectionChanged()'),
202 self.show_selection)
204 self.connect(self, SIGNAL('itemDoubleClicked(QTreeWidgetItem*,int)'),
205 self.double_clicked)
207 self.connect(self, SIGNAL('itemCollapsed(QTreeWidgetItem*)'),
208 lambda x: self.update_column_widths())
210 self.connect(self, SIGNAL('itemExpanded(QTreeWidgetItem*)'),
211 lambda x: self.update_column_widths())
213 def add_item(self, txt, hide=False):
214 """Create a new top-level item in the status tree."""
215 # TODO no icon
216 font = self.font()
217 font.setBold(True)
219 item = QtGui.QTreeWidgetItem(self)
220 item.setFont(0, font)
221 item.setText(0, txt)
222 if hide:
223 self.setItemHidden(item, True)
225 def restore_selection(self):
226 if not self.old_selection or not self.old_contents:
227 return
228 old_c = self.old_contents
229 old_s = self.old_selection
230 new_c = self.contents()
232 def mkselect(lst, widget_getter):
233 def select(item, current=False):
234 idx = lst.index(item)
235 widget = widget_getter(idx)
236 if current:
237 self.setCurrentItem(widget)
238 self.setItemSelected(widget, True)
239 return select
241 select_staged = mkselect(new_c.staged, self.staged_item)
242 select_unmerged = mkselect(new_c.unmerged, self.unmerged_item)
243 select_modified = mkselect(new_c.modified, self.modified_item)
244 select_untracked = mkselect(new_c.untracked, self.untracked_item)
246 saved_selection = [
247 (set(new_c.staged), old_c.staged, set(old_s.staged),
248 select_staged),
250 (set(new_c.unmerged), old_c.unmerged, set(old_s.unmerged),
251 select_unmerged),
253 (set(new_c.modified), old_c.modified, set(old_s.modified),
254 select_modified),
256 (set(new_c.untracked), old_c.untracked, set(old_s.untracked),
257 select_untracked),
260 # Restore the current item
261 if self.old_current_item:
262 category, idx = self.old_current_item
263 if category == self.idx_header:
264 item = self.invisibleRootItem().child(idx)
265 if item is not None:
266 self.setCurrentItem(item)
267 self.setItemSelected(item, True)
268 return
269 # Reselect the current item
270 selection_info = saved_selection[category]
271 new = selection_info[0]
272 old = selection_info[1]
273 reselect = selection_info[3]
274 try:
275 item = old[idx]
276 except:
277 return
278 if item in new:
279 reselect(item, current=True)
281 # Restore selection
282 # When reselecting we only care that the items are selected;
283 # we do not need to rerun the callbacks which were triggered
284 # above. Block signals to skip the callbacks.
285 self.blockSignals(True)
286 for (new, old, sel, reselect) in saved_selection:
287 for item in sel:
288 if item in new:
289 reselect(item, current=False)
290 self.blockSignals(False)
292 for (new, old, sel, reselect) in saved_selection:
293 # When modified is staged, select the next modified item
294 # When unmerged is staged, select the next unmerged item
295 # When unstaging, select the next staged item
296 # When staging untracked files, select the next untracked item
297 if len(new) >= len(old):
298 # The list did not shrink so it is not one of these cases.
299 continue
300 for item in sel:
301 # The item still exists so ignore it
302 if item in new or item not in old:
303 continue
304 # The item no longer exists in this list so search for
305 # its nearest neighbors and select them instead.
306 idx = old.index(item)
307 for j in itertools.chain(old[idx+1:], reversed(old[:idx])):
308 if j in new:
309 reselect(j, current=True)
310 return
312 def restore_scrollbar(self):
313 vscroll = self.verticalScrollBar()
314 if vscroll and self.old_scroll is not None:
315 vscroll.setValue(self.old_scroll)
316 self.old_scroll = None
318 def staged_item(self, itemidx):
319 return self._subtree_item(self.idx_staged, itemidx)
321 def modified_item(self, itemidx):
322 return self._subtree_item(self.idx_modified, itemidx)
324 def unmerged_item(self, itemidx):
325 return self._subtree_item(self.idx_unmerged, itemidx)
327 def untracked_item(self, itemidx):
328 return self._subtree_item(self.idx_untracked, itemidx)
330 def unstaged_item(self, itemidx):
331 # is it modified?
332 item = self.topLevelItem(self.idx_modified)
333 count = item.childCount()
334 if itemidx < count:
335 return item.child(itemidx)
336 # is it unmerged?
337 item = self.topLevelItem(self.idx_unmerged)
338 count += item.childCount()
339 if itemidx < count:
340 return item.child(itemidx)
341 # is it untracked?
342 item = self.topLevelItem(self.idx_untracked)
343 count += item.childCount()
344 if itemidx < count:
345 return item.child(itemidx)
346 # Nope..
347 return None
349 def _subtree_item(self, idx, itemidx):
350 parent = self.topLevelItem(idx)
351 return parent.child(itemidx)
353 def about_to_update(self):
354 self.emit(SIGNAL('about_to_update()'))
356 def _about_to_update(self):
357 self.save_selection()
358 self.save_scrollbar()
360 def save_scrollbar(self):
361 vscroll = self.verticalScrollBar()
362 if vscroll:
363 self.old_scroll = vscroll.value()
364 else:
365 self.old_scroll = None
367 def current_item(self):
368 s = self.selected_indexes()
369 if not s:
370 return None
371 current = self.currentItem()
372 if not current:
373 return None
374 idx = self.indexFromItem(current, 0)
375 if idx.parent().isValid():
376 parent_idx = idx.parent()
377 entry = (parent_idx.row(), idx.row())
378 else:
379 entry = (self.idx_header, idx.row())
380 return entry
382 def save_selection(self):
383 self.old_contents = self.contents()
384 self.old_selection = self.selection()
385 self.old_current_item = self.current_item()
387 def updated(self):
388 """Update display from model data."""
389 self.emit(SIGNAL('updated()'))
391 def _updated(self):
392 self.set_staged(self.m.staged)
393 self.set_modified(self.m.modified)
394 self.set_unmerged(self.m.unmerged)
395 self.set_untracked(self.m.untracked)
396 self.restore_selection()
397 self.restore_scrollbar()
398 self.update_column_widths()
399 self.update_actions()
401 def update_actions(self, selected=None):
402 if selected is None:
403 selected = selection.selection()
404 can_revert_edits = bool(selected.staged or selected.modified)
405 self.revert_unstaged_edits_action.setEnabled(can_revert_edits)
407 def set_staged(self, items):
408 """Adds items to the 'Staged' subtree."""
409 self._set_subtree(items, self.idx_staged, staged=True,
410 deleted_set=self.m.staged_deleted)
412 def set_modified(self, items):
413 """Adds items to the 'Modified' subtree."""
414 self._set_subtree(items, self.idx_modified,
415 deleted_set=self.m.unstaged_deleted)
417 def set_unmerged(self, items):
418 """Adds items to the 'Unmerged' subtree."""
419 self._set_subtree(items, self.idx_unmerged)
421 def set_untracked(self, items):
422 """Adds items to the 'Untracked' subtree."""
423 self._set_subtree(items, self.idx_untracked, untracked=True)
425 def _set_subtree(self, items, idx,
426 staged=False,
427 untracked=False,
428 deleted_set=None):
429 """Add a list of items to a treewidget item."""
430 self.blockSignals(True)
431 parent = self.topLevelItem(idx)
432 if items:
433 self.setItemHidden(parent, False)
434 else:
435 self.setItemHidden(parent, True)
437 # sip v4.14.7 and below leak memory in parent.takeChildren()
438 # so we use this backwards-compatible construct instead
439 while parent.takeChild(0) is not None:
440 pass
442 for item in items:
443 deleted = (deleted_set is not None and item in deleted_set)
444 treeitem = qtutils.create_treeitem(item,
445 staged=staged,
446 deleted=deleted,
447 untracked=untracked)
448 parent.addChild(treeitem)
449 self.expand_items(idx, items)
450 self.blockSignals(False)
452 def update_column_widths(self):
453 self.resizeColumnToContents(0)
455 def expand_items(self, idx, items):
456 """Expand the top-level category "folder" once and only once."""
457 # Don't do this if items is empty; this makes it so that we
458 # don't add the top-level index into the expanded_items set
459 # until an item appears in a particular category.
460 if not items:
461 return
462 # Only run this once; we don't want to re-expand items that
463 # we've clicked on to re-collapse on updated().
464 if idx in self.expanded_items:
465 return
466 self.expanded_items.add(idx)
467 item = self.topLevelItem(idx)
468 if item:
469 self.expandItem(item)
471 def contextMenuEvent(self, event):
472 """Create context menus for the repo status tree."""
473 menu = self.create_context_menu()
474 menu.exec_(self.mapToGlobal(event.pos()))
476 def create_context_menu(self):
477 """Set up the status menu for the repo status tree."""
478 s = self.selection()
479 menu = QtGui.QMenu(self)
481 selected_indexes = self.selected_indexes()
482 if selected_indexes:
483 category, idx = selected_indexes[0]
484 # A header item e.g. 'Staged', 'Modified', etc.
485 if category == self.idx_header:
486 return self._create_header_context_menu(menu, idx)
488 if s.staged:
489 return self._create_staged_context_menu(menu, s)
491 elif s.unmerged:
492 return self._create_unmerged_context_menu(menu, s)
493 else:
494 return self._create_unstaged_context_menu(menu, s)
496 def _create_header_context_menu(self, menu, idx):
497 if idx == self.idx_staged:
498 menu.addAction(qtutils.remove_icon(),
499 N_('Unstage All'),
500 cmds.run(cmds.UnstageAll))
501 return menu
502 elif idx == self.idx_unmerged:
503 action = menu.addAction(qtutils.add_icon(),
504 cmds.StageUnmerged.name(),
505 cmds.run(cmds.StageUnmerged))
506 action.setShortcut(cmds.StageUnmerged.SHORTCUT)
507 return menu
508 elif idx == self.idx_modified:
509 action = menu.addAction(qtutils.add_icon(),
510 cmds.StageModified.name(),
511 cmds.run(cmds.StageModified))
512 action.setShortcut(cmds.StageModified.SHORTCUT)
513 return menu
515 elif idx == self.idx_untracked:
516 action = menu.addAction(qtutils.add_icon(),
517 cmds.StageUntracked.name(),
518 cmds.run(cmds.StageUntracked))
519 action.setShortcut(cmds.StageUntracked.SHORTCUT)
520 return menu
522 def _create_staged_context_menu(self, menu, s):
523 if s.staged[0] in self.m.submodules:
524 return self._create_staged_submodule_context_menu(menu, s)
526 if self.m.unstageable():
527 action = menu.addAction(qtutils.remove_icon(),
528 N_('Unstage Selected'),
529 cmds.run(cmds.Unstage, self.staged()))
530 action.setShortcut(cmds.Unstage.SHORTCUT)
532 # Do all of the selected items exist?
533 all_exist = all(not i in self.m.staged_deleted and core.exists(i)
534 for i in self.staged())
536 if all_exist:
537 menu.addAction(self.launch_editor_action)
538 menu.addAction(self.launch_difftool_action)
540 if all_exist and not utils.is_win32():
541 menu.addSeparator()
542 action = menu.addAction(qtutils.file_icon(),
543 cmds.OpenDefaultApp.name(),
544 cmds.run(cmds.OpenDefaultApp, self.staged()))
545 action.setShortcut(cmds.OpenDefaultApp.SHORTCUT)
547 action = menu.addAction(qtutils.open_file_icon(),
548 cmds.OpenParentDir.name(),
549 self._open_parent_dir)
550 action.setShortcut(cmds.OpenParentDir.SHORTCUT)
552 if self.m.undoable():
553 menu.addSeparator()
554 menu.addAction(self.revert_unstaged_edits_action)
556 menu.addSeparator()
557 menu.addAction(self.copy_path_action)
558 menu.addAction(self.copy_relpath_action)
559 return menu
561 def _create_staged_submodule_context_menu(self, menu, s):
562 menu.addAction(qtutils.git_icon(),
563 N_('Launch git-cola'),
564 cmds.run(cmds.OpenRepo,
565 core.abspath(s.staged[0])))
567 menu.addAction(self.launch_editor_action)
568 menu.addSeparator()
570 action = menu.addAction(qtutils.remove_icon(),
571 N_('Unstage Selected'),
572 cmds.run(cmds.Unstage, self.staged()))
573 action.setShortcut(cmds.Unstage.SHORTCUT)
574 menu.addSeparator()
576 menu.addAction(self.copy_path_action)
577 menu.addAction(self.copy_relpath_action)
578 return menu
580 def _create_unmerged_context_menu(self, menu, s):
581 menu.addAction(self.launch_difftool_action)
583 action = menu.addAction(qtutils.add_icon(),
584 N_('Stage Selected'),
585 cmds.run(cmds.Stage, self.unstaged()))
586 action.setShortcut(cmds.Stage.SHORTCUT)
587 menu.addSeparator()
588 menu.addAction(self.launch_editor_action)
590 if not utils.is_win32():
591 menu.addSeparator()
592 action = menu.addAction(qtutils.file_icon(),
593 cmds.OpenDefaultApp.name(),
594 cmds.run(cmds.OpenDefaultApp, self.unmerged()))
595 action.setShortcut(cmds.OpenDefaultApp.SHORTCUT)
597 action = menu.addAction(qtutils.open_file_icon(),
598 cmds.OpenParentDir.name(),
599 self._open_parent_dir)
600 action.setShortcut(cmds.OpenParentDir.SHORTCUT)
602 menu.addSeparator()
603 menu.addAction(self.copy_path_action)
604 menu.addAction(self.copy_relpath_action)
605 return menu
607 def _create_unstaged_context_menu(self, menu, s):
608 modified_submodule = (s.modified and
609 s.modified[0] in self.m.submodules)
610 if modified_submodule:
611 return self._create_modified_submodule_context_menu(menu, s)
613 if self.m.stageable():
614 action = menu.addAction(qtutils.add_icon(),
615 N_('Stage Selected'),
616 cmds.run(cmds.Stage, self.unstaged()))
617 action.setShortcut(cmds.Stage.SHORTCUT)
619 # Do all of the selected items exist?
620 all_exist = all(not i in self.m.unstaged_deleted and core.exists(i)
621 for i in self.staged())
623 if all_exist and self.unstaged():
624 menu.addAction(self.launch_editor_action)
626 if all_exist and s.modified and self.m.stageable():
627 menu.addAction(self.launch_difftool_action)
629 if s.modified and self.m.stageable():
630 if self.m.undoable():
631 menu.addSeparator()
632 menu.addAction(self.revert_unstaged_edits_action)
634 if all_exist and self.unstaged() and not utils.is_win32():
635 menu.addSeparator()
636 action = menu.addAction(qtutils.file_icon(),
637 cmds.OpenDefaultApp.name(),
638 cmds.run(cmds.OpenDefaultApp, self.unstaged()))
639 action.setShortcut(cmds.OpenDefaultApp.SHORTCUT)
641 action = menu.addAction(qtutils.open_file_icon(),
642 cmds.OpenParentDir.name(),
643 self._open_parent_dir)
644 action.setShortcut(cmds.OpenParentDir.SHORTCUT)
646 if all_exist and s.untracked:
647 menu.addSeparator()
648 if self.move_to_trash_action is not None:
649 menu.addAction(self.move_to_trash_action)
650 menu.addAction(self.delete_untracked_files_action)
651 menu.addSeparator()
652 menu.addAction(qtutils.theme_icon('edit-clear.svg'),
653 N_('Add to .gitignore'),
654 cmds.run(cmds.Ignore,
655 map(lambda x: '/' + x, self.untracked())))
656 menu.addSeparator()
657 menu.addAction(self.copy_path_action)
658 menu.addAction(self.copy_relpath_action)
659 return menu
661 def _create_modified_submodule_context_menu(self, menu, s):
662 menu.addAction(qtutils.git_icon(),
663 N_('Launch git-cola'),
664 cmds.run(cmds.OpenRepo, core.abspath(s.modified[0])))
666 menu.addAction(self.launch_editor_action)
668 if self.m.stageable():
669 menu.addSeparator()
670 action = menu.addAction(qtutils.add_icon(),
671 N_('Stage Selected'),
672 cmds.run(cmds.Stage, self.unstaged()))
673 action.setShortcut(cmds.Stage.SHORTCUT)
675 menu.addSeparator()
676 menu.addAction(self.copy_path_action)
677 menu.addAction(self.copy_relpath_action)
678 return menu
681 def _delete_untracked_files(self):
682 cmds.do(cmds.Delete, self.untracked())
684 def _trash_untracked_files(self):
685 cmds.do(cmds.MoveToTrash, self.untracked())
687 def single_selection(self):
688 """Scan across staged, modified, etc. and return a single item."""
689 st = None
690 um = None
691 m = None
692 ut = None
694 s = self.selection()
695 if s.staged:
696 st = s.staged[0]
697 elif s.modified:
698 m = s.modified[0]
699 elif s.unmerged:
700 um = s.unmerged[0]
701 elif s.untracked:
702 ut = s.untracked[0]
704 return selection.State(st, um, m, ut)
706 def selected_indexes(self):
707 """Returns a list of (category, row) representing the tree selection."""
708 selected = self.selectedIndexes()
709 result = []
710 for idx in selected:
711 if idx.parent().isValid():
712 parent_idx = idx.parent()
713 entry = (parent_idx.row(), idx.row())
714 else:
715 entry = (self.idx_header, idx.row())
716 result.append(entry)
717 return result
719 def selection(self):
720 """Return the current selection in the repo status tree."""
721 return selection.State(self.staged(), self.unmerged(),
722 self.modified(), self.untracked())
724 def contents(self):
725 return selection.State(self.m.staged, self.m.unmerged,
726 self.m.modified, self.m.untracked)
728 def all_files(self):
729 c = self.contents()
730 return c.staged + c.unmerged + c.modified + c.untracked
732 def selected_group(self):
733 """A list of selected files in various states of being"""
734 return selection.pick(self.selection())
736 def selected_idx(self):
737 c = self.contents()
738 s = self.single_selection()
739 offset = 0
740 for content, selection in zip(c, s):
741 if len(content) == 0:
742 continue
743 if selection is not None:
744 return offset + content.index(selection)
745 offset += len(content)
746 return None
748 def select_by_index(self, idx):
749 c = self.contents()
750 to_try = [
751 (c.staged, self.idx_staged),
752 (c.unmerged, self.idx_unmerged),
753 (c.modified, self.idx_modified),
754 (c.untracked, self.idx_untracked),
756 for content, toplevel_idx in to_try:
757 if len(content) == 0:
758 continue
759 if idx < len(content):
760 parent = self.topLevelItem(toplevel_idx)
761 item = parent.child(idx)
762 self.select_item(item)
763 return
764 idx -= len(content)
766 def select_item(self, item):
767 self.scrollToItem(item)
768 self.setCurrentItem(item)
769 self.setItemSelected(item, True)
771 def staged(self):
772 return self._subtree_selection(self.idx_staged, self.m.staged)
774 def unstaged(self):
775 return self.unmerged() + self.modified() + self.untracked()
777 def modified(self):
778 return self._subtree_selection(self.idx_modified, self.m.modified)
780 def unmerged(self):
781 return self._subtree_selection(self.idx_unmerged, self.m.unmerged)
783 def untracked(self):
784 return self._subtree_selection(self.idx_untracked, self.m.untracked)
786 def staged_items(self):
787 return self._subtree_selection_items(self.idx_staged)
789 def unstaged_items(self):
790 return (self.unmerged_items() + self.modified_items() +
791 self.untracked_items())
793 def modified_items(self):
794 return self._subtree_selection_items(self.idx_modified)
796 def unmerged_items(self):
797 return self._subtree_selection_items(self.idx_unmerged)
799 def untracked_items(self):
800 return self._subtree_selection_items(self.idx_untracked)
802 def _subtree_selection(self, idx, items):
803 item = self.topLevelItem(idx)
804 return qtutils.tree_selection(item, items)
806 def _subtree_selection_items(self, idx):
807 item = self.topLevelItem(idx)
808 return qtutils.tree_selection_items(item)
810 def double_clicked(self, item, idx):
811 """Called when an item is double-clicked in the repo status tree."""
812 cmds.do(cmds.StageOrUnstage)
814 def _open_using_default_app(self):
815 cmds.do(cmds.OpenDefaultApp, self.selected_group())
817 def _open_parent_dir(self):
818 cmds.do(cmds.OpenParentDir, self.selected_group())
820 def show_selection(self):
821 """Show the selected item."""
822 # Sync the selection model
823 selected = self.selection()
824 selection.selection_model().set_selection(selected)
825 self.update_actions(selected=selected)
827 selected_indexes = self.selected_indexes()
828 if not selected_indexes:
829 if self.m.amending():
830 cmds.do(cmds.SetDiffText, '')
831 else:
832 cmds.do(cmds.ResetMode)
833 return
834 category, idx = selected_indexes[0]
835 # A header item e.g. 'Staged', 'Modified', etc.
836 if category == self.idx_header:
837 cls = {
838 self.idx_staged: cmds.DiffStagedSummary,
839 self.idx_modified: cmds.Diffstat,
840 # TODO implement UnmergedSummary
841 #self.idx_unmerged: cmds.UnmergedSummary,
842 self.idx_untracked: cmds.UntrackedSummary,
843 }.get(idx, cmds.Diffstat)
844 cmds.do(cls)
845 # A staged file
846 elif category == self.idx_staged:
847 item = self.staged_items()[0]
848 cmds.do(cmds.DiffStaged, item.path, deleted=item.deleted)
850 # A modified file
851 elif category == self.idx_modified:
852 item = self.modified_items()[0]
853 cmds.do(cmds.Diff, item.path, deleted=item.deleted)
855 elif category == self.idx_unmerged:
856 item = self.unmerged_items()[0]
857 cmds.do(cmds.Diff, item.path)
859 elif category == self.idx_untracked:
860 item = self.unstaged_items()[0]
861 cmds.do(cmds.ShowUntracked, item.path)
863 def move_up(self):
864 idx = self.selected_idx()
865 all_files = self.all_files()
866 if idx is None:
867 selected_indexes = self.selected_indexes()
868 if selected_indexes:
869 category, toplevel_idx = selected_indexes[0]
870 if category == self.idx_header:
871 item = self.itemAbove(self.topLevelItem(toplevel_idx))
872 if item is not None:
873 self.select_item(item)
874 return
875 if all_files:
876 self.select_by_index(len(all_files) - 1)
877 return
878 if idx - 1 >= 0:
879 self.select_by_index(idx - 1)
880 else:
881 self.select_by_index(len(all_files) - 1)
883 def move_down(self):
884 idx = self.selected_idx()
885 all_files = self.all_files()
886 if idx is None:
887 selected_indexes = self.selected_indexes()
888 if selected_indexes:
889 category, toplevel_idx = selected_indexes[0]
890 if category == self.idx_header:
891 item = self.itemBelow(self.topLevelItem(toplevel_idx))
892 if item is not None:
893 self.select_item(item)
894 return
895 if all_files:
896 self.select_by_index(0)
897 return
898 if idx + 1 < len(all_files):
899 self.select_by_index(idx + 1)
900 else:
901 self.select_by_index(0)
903 def copy_path(self, absolute=True):
904 """Copy a selected path to the clipboard"""
905 filename = selection.selection_model().filename()
906 qtutils.copy_path(filename, absolute=absolute)
908 def copy_relpath(self):
909 """Copy a selected relative path to the clipboard"""
910 self.copy_path(absolute=False)
912 def mimeData(self, items):
913 """Return a list of absolute-path URLs"""
914 paths = qtutils.paths_from_items(items, item_filter=lambda item:
915 not item.deleted
916 and core.exists(item.path))
917 return qtutils.mimedata_from_paths(paths)
919 def mimeTypes(self):
920 return qtutils.path_mimetypes()
923 class StatusFilterWidget(QtGui.QWidget):
925 def __init__(self, parent=None):
926 QtGui.QWidget.__init__(self, parent)
927 self.main_model = main.model()
929 hint = N_('Filter paths...')
930 self.text = completion.GitStatusFilterLineEdit(hint=hint, parent=self)
931 self.text.setToolTip(hint)
932 self.text.hint.enable(True)
933 self.setFocusProxy(self.text)
934 self._filter = None
936 self.main_layout = qtutils.hbox(defs.no_margin, defs.spacing, self.text)
937 self.setLayout(self.main_layout)
939 self.connect(self.text, SIGNAL('changed()'), self.apply_filter)
940 self.connect(self.text, SIGNAL('cleared()'), self.apply_filter)
941 self.connect(self.text, SIGNAL('return()'), self.apply_filter)
942 self.connect(self.text, SIGNAL('editingFinished()'), self.apply_filter)
944 def apply_filter(self):
945 text = self.text.value()
946 if text == self._filter:
947 return
948 self._filter = text
949 paths = utils.shell_split(text)
950 self.main_model.update_path_filter(paths)