guicmds: Use the difftool module to implement 'Review Branch'
[git-cola.git] / cola / classic / view.py
blob52eaaac094ad78e51ec3533a9b3a87303d0fc7c3
1 import os
3 from PyQt4 import QtGui
4 from PyQt4 import QtCore
5 from PyQt4.QtCore import Qt
6 from PyQt4.QtCore import SIGNAL
8 import cola
9 from cola import qtutils
10 from cola import signals
11 from cola import utils
12 from cola.models.selection import State
13 from cola.widgets import defs
14 from cola.widgets import standard
15 from cola.classic.model import GitRepoNameItem
18 class RepoDialog(standard.Dialog):
19 def __init__(self, parent, update=True):
20 standard.Dialog.__init__(self, parent)
21 self.setObjectName('classic')
22 self.tree = RepoTreeView(self)
23 self.mainlayout = QtGui.QHBoxLayout()
24 self.setLayout(self.mainlayout)
25 self.mainlayout.setMargin(0)
26 self.mainlayout.setSpacing(defs.spacing)
27 self.mainlayout.addWidget(self.tree)
28 self.resize(720, 420)
30 self.connect(self, SIGNAL('updated'), self._updated_callback)
31 self.model = cola.model()
32 self.model.add_observer(self.model.message_updated, self.model_updated)
33 qtutils.add_close_action(self)
34 if update:
35 self.model_updated()
37 # Read-only mode property
38 mode = property(lambda self: self.model.mode)
40 def model_updated(self):
41 """Update the title with the current branch and directory name."""
42 self.emit(SIGNAL('updated'))
44 def _updated_callback(self):
45 branch = self.model.currentbranch
46 curdir = os.getcwd()
47 msg = 'Repository: %s\nBranch: %s' % (curdir, branch)
49 self.setToolTip(msg)
51 title = '%s [%s]' % (self.model.project, branch)
52 if self.mode in (self.model.mode_diff, self.model.mode_diff_expr):
53 title += ' *** diff mode***'
54 elif self.mode == self.model.mode_amend:
55 title += ' *** amending ***'
56 self.setWindowTitle(title)
59 class RepoTreeView(QtGui.QTreeView):
60 """Provides a filesystem-like view of a git repository."""
61 def __init__(self, parent):
62 QtGui.QTreeView.__init__(self, parent)
64 self.setSortingEnabled(False)
65 self.setAllColumnsShowFocus(True)
66 self.setAlternatingRowColors(True)
67 self.setUniformRowHeights(True)
68 self.setAnimated(True)
69 self.setSelectionMode(QtGui.QAbstractItemView.ExtendedSelection)
71 # Observe model updates
72 model = cola.model()
73 model.add_observer(model.message_updated, self.update_actions)
75 # The non-Qt cola application model
76 self.connect(self, SIGNAL('expanded(QModelIndex)'), self.size_columns)
77 self.connect(self, SIGNAL('collapsed(QModelIndex)'), self.size_columns)
78 self.action_history =\
79 self._create_action('View History...',
80 'View history for selected path(s).',
81 self.view_history,
82 'Shift+Ctrl+H')
83 self.action_stage =\
84 self._create_action('Stage Selected',
85 'Stage selected path(s) for commit.',
86 self.stage_selected,
87 'Ctrl+S')
88 self.action_unstage =\
89 self._create_action('Unstage Selected',
90 'Remove selected path(s) from '
91 'the staging area.',
92 self.unstage_selected,
93 'Ctrl+U')
94 self.action_difftool =\
95 self._create_action('View Diff...',
96 'Launch git-difftool on the current path.',
97 self.difftool,
98 'Ctrl+D')
99 self.action_difftool_predecessor =\
100 self._create_action('Diff Against Predecessor...',
101 'Launch git-difftool against previous versions.',
102 self.difftool_predecessor,
103 'Shift+Ctrl+D')
104 self.action_revert =\
105 self._create_action('Revert Uncommitted Changes...',
106 'Revert changes to selected path(s).',
107 self.revert,
108 'Ctrl+Z')
109 self.action_editor =\
110 self._create_action('Launch Editor',
111 'Edit selected path(s).',
112 self.editor,
113 'Ctrl+E')
115 def size_columns(self):
116 """Set the column widths."""
117 self.resizeColumnToContents(0)
119 def update_actions(self):
120 """Enable/disable actions."""
121 selection = self.selected_paths()
122 selected = bool(selection)
123 staged = bool(self.selected_staged_paths(selection=selection))
124 modified = bool(self.selected_modified_paths(selection=selection))
125 unstaged = bool(self.selected_unstaged_paths(selection=selection))
126 tracked = bool(self.selected_tracked_paths())
128 self.action_history.setEnabled(selected)
129 self.action_stage.setEnabled(unstaged)
130 self.action_unstage.setEnabled(staged)
131 self.action_difftool.setEnabled(staged or modified)
132 self.action_difftool_predecessor.setEnabled(tracked)
133 self.action_revert.setEnabled(tracked)
135 def contextMenuEvent(self, event):
136 """Create a context menu."""
137 self.update_actions()
138 menu = QtGui.QMenu(self)
139 menu.addAction(self.action_editor)
140 menu.addAction(self.action_stage)
141 menu.addAction(self.action_unstage)
142 menu.addSeparator()
143 menu.addAction(self.action_history)
144 menu.addAction(self.action_difftool)
145 menu.addAction(self.action_difftool_predecessor)
146 menu.addSeparator()
147 menu.addAction(self.action_revert)
148 menu.exec_(self.mapToGlobal(event.pos()))
150 def keyPressEvent(self, event):
152 Override keyPressEvent to allow LeftArrow to work on non-directories.
154 When LeftArrow is pressed on a file entry or an unexpanded directory,
155 then move the current index to the parent directory.
157 This simplifies navigation using the keyboard.
158 For power-users, we support Vim keybindings ;-P
161 # Check whether the item is expanded before calling the base class
162 # keyPressEvent otherwise we end up collapsing and changing the
163 # current index in one shot, which we don't want to do.
164 index = self.currentIndex()
165 was_expanded = self.isExpanded(index)
166 was_collapsed = not was_expanded
168 # Vim keybindings...
169 # Rewrite the event before marshalling to QTreeView.event()
170 key = event.key()
172 # Remap 'H' to 'Left'
173 if key == QtCore.Qt.Key_H:
174 event = QtGui.QKeyEvent(event.type(),
175 QtCore.Qt.Key_Left,
176 event.modifiers())
177 # Remap 'J' to 'Down'
178 elif key == QtCore.Qt.Key_J:
179 event = QtGui.QKeyEvent(event.type(),
180 QtCore.Qt.Key_Down,
181 event.modifiers())
182 # Remap 'K' to 'Up'
183 elif key == QtCore.Qt.Key_K:
184 event = QtGui.QKeyEvent(event.type(),
185 QtCore.Qt.Key_Up,
186 event.modifiers())
187 # Remap 'L' to 'Right'
188 elif key == QtCore.Qt.Key_L:
189 event = QtGui.QKeyEvent(event.type(),
190 QtCore.Qt.Key_Right,
191 event.modifiers())
193 # Re-read the event key to take the remappings into account
194 key = event.key()
196 # Process the keyPressEvent before changing the current index
197 # otherwise the event will affect the new index set here
198 # instead of the original index.
199 result = QtGui.QTreeView.keyPressEvent(self, event)
201 # Sync the selection model
202 self.sync_selection()
204 # Try to select the first item if the model index is invalid
205 if not index.isValid():
206 index = self.model().index(0, 0, QtCore.QModelIndex())
207 if index.isValid():
208 self.setCurrentIndex(index)
209 return result
211 # Automatically select the first entry when expanding a directory
212 if (key == QtCore.Qt.Key_Right and was_collapsed and
213 self.isExpanded(index)):
214 index = self.moveCursor(self.MoveDown, event.modifiers())
215 self.setCurrentIndex(index)
217 # Process non-root entries with valid parents only.
218 if key == QtCore.Qt.Key_Left and index.parent().isValid():
220 # File entries have rowCount() == 0
221 if self.item_from_index(index).rowCount() == 0:
222 self.setCurrentIndex(index.parent())
224 # Otherwise, do this for collapsed directories only
225 elif was_collapsed:
226 self.setCurrentIndex(index.parent())
228 return result
230 def mousePressEvent(self, event):
231 """Synchronize the selection on mouse-press."""
232 result = QtGui.QTreeView.mousePressEvent(self, event)
233 self.sync_selection()
234 return result
236 def sync_selection(self):
237 """Push selection into the selection model."""
238 staged = []
239 unmerged = []
240 modified = []
241 untracked = []
242 state = State(staged, unmerged, modified, untracked)
244 paths = self.selected_paths()
245 model = cola.model()
246 model_staged = utils.add_parents(set(model.staged))
247 model_modified = utils.add_parents(set(model.modified))
248 model_unmerged = utils.add_parents(set(model.unmerged))
249 model_untracked = utils.add_parents(set(model.untracked))
251 for path in paths:
252 if path in model_unmerged:
253 unmerged.append(path)
254 elif path in model_untracked:
255 untracked.append(path)
256 elif path in model_staged:
257 staged.append(path)
258 elif path in model_modified:
259 modified.append(path)
260 else:
261 staged.append(path)
262 # Push the new selection into the model.
263 cola.selection_model().set_selection(state)
264 return paths
266 def selectionChanged(self, old_selection, new_selection):
267 """Override selectionChanged to update available actions."""
268 result = QtGui.QTreeView.selectionChanged(self, old_selection, new_selection)
269 self.update_actions()
270 paths = self.sync_selection()
272 if paths and self.model().path_is_interesting(paths[0]):
273 cached = paths[0] in cola.model().staged
274 cola.notifier().broadcast(signals.diff, paths, cached)
275 return result
277 def setModel(self, model):
278 """Set the concrete QAbstractItemModel instance."""
279 QtGui.QTreeView.setModel(self, model)
280 self.size_columns()
282 def item_from_index(self, model_index):
283 """Return the name item corresponding to the model index."""
284 index = model_index.sibling(model_index.row(), 0)
285 return self.model().itemFromIndex(index)
287 def selected_paths(self):
288 """Return the selected paths."""
289 items = map(self.model().itemFromIndex, self.selectedIndexes())
290 return [i.path for i in items
291 if i.type() == GitRepoNameItem.TYPE]
293 def selected_staged_paths(self, selection=None):
294 """Return selected staged paths."""
295 if not selection:
296 selection = self.selected_paths()
297 staged = utils.add_parents(set(cola.model().staged))
298 return [p for p in selection if p in staged]
300 def selected_modified_paths(self, selection=None):
301 """Return selected modified paths."""
302 if not selection:
303 selection = self.selected_paths()
304 model = cola.model()
305 modified = utils.add_parents(set(model.modified))
306 return [p for p in selection if p in modified]
308 def selected_unstaged_paths(self, selection=None):
309 """Return selected unstaged paths."""
310 if not selection:
311 selection = self.selected_paths()
312 model = cola.model()
313 modified = utils.add_parents(set(model.modified))
314 untracked = utils.add_parents(set(model.untracked))
315 unstaged = modified.union(untracked)
316 return [p for p in selection if p in unstaged]
318 def selected_tracked_paths(self, selection=None):
319 """Return selected tracked paths."""
320 if not selection:
321 selection = self.selected_paths()
322 model = cola.model()
323 staged = set(self.selected_staged_paths())
324 modified = set(self.selected_modified_paths())
325 untracked = utils.add_parents(set(model.untracked))
326 tracked = staged.union(modified)
327 return [p for p in selection
328 if p not in untracked or p in tracked]
330 def _create_action(self, name, tooltip, slot, shortcut):
331 """Create an action with a shortcut, tooltip, and callback slot."""
332 action = QtGui.QAction(self.tr(name), self)
333 action.setStatusTip(self.tr(tooltip))
334 if hasattr(Qt, 'WidgetWithChildrenShortcut'):
335 action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
336 action.setShortcut(shortcut)
337 self.addAction(action)
338 qtutils.connect_action(action, slot)
339 return action
341 def view_history(self):
342 """Signal that we should view history for paths."""
343 self.emit(SIGNAL('history(QStringList)'), self.selected_paths())
345 def stage_selected(self):
346 """Signal that we should stage selected paths."""
347 cola.notifier().broadcast(signals.stage,
348 self.selected_unstaged_paths())
350 def unstage_selected(self):
351 """Signal that we should stage selected paths."""
352 cola.notifier().broadcast(signals.unstage,
353 self.selected_staged_paths())
355 def difftool(self):
356 """Signal that we should launch difftool on a path."""
357 cola.notifier().broadcast(signals.difftool,
358 False,
359 self.selected_tracked_paths())
361 def difftool_predecessor(self):
362 """Diff paths against previous versions."""
363 paths = self.selected_tracked_paths()
364 self.emit(SIGNAL('difftool_predecessor'), paths)
366 def revert(self):
367 """Signal that we should revert changes to a path."""
368 if not qtutils.confirm('Revert Uncommitted Changes?',
369 'This operation drops uncommitted changes.'
370 '\nThese changes cannot be recovered.',
371 'Revert the uncommitted changes?',
372 'Revert Uncommitted Changes',
373 default=False,
374 icon=qtutils.icon('undo.svg')):
375 return
376 paths = self.selected_tracked_paths()
377 cola.notifier().broadcast(signals.checkout,
378 ['HEAD', '--'] + paths)
380 def editor(self):
381 """Signal that we should edit selected paths using an external editor."""
382 cola.notifier().broadcast(signals.edit, self.selected_paths())
384 def current_path(self):
385 """Return the path for the current item."""
386 index = self.currentIndex()
387 if not index.isValid():
388 return None
389 return self.item_from_index(index).path