Refactored several methods out of the main controller.
[ugit.git] / ugitlibs / controllers.py
blob18ffa03566e20822f88e1b756185cb5ca73978ec
1 #!/usr/bin/env python
2 import os
4 from PyQt4 import QtGui
5 from PyQt4.QtGui import QDialog
6 from PyQt4.QtGui import QMessageBox
7 from PyQt4.QtGui import QMenu
9 import utils
10 import qtutils
11 import defaults
12 from qobserver import QObserver
13 from repobrowsercontroller import browse_git_branch
14 from createbranchcontroller import create_new_branch
15 from pushcontroller import push_branches
16 from utilcontroller import choose_branch
17 from utilcontroller import select_commits
19 class Controller(QObserver):
20 '''The controller is a mediator between the model and view.
21 It allows for a clean decoupling between view and model classes.'''
23 def __init__(self, model, view):
24 QObserver.__init__(self, model, view)
26 # The diff-display context menu
27 self.__menu = None
28 self.__staged_diff_in_view = True
30 # Diff display context menu
31 view.displayText.controller = self
32 view.displayText.contextMenuEvent = self.__menu_event
34 # Default to creating a new commit(i.e. not an amend commit)
35 view.newCommitRadio.setChecked(True)
37 # Binds a specific model attribute to a view widget,
38 # and vice versa.
39 self.model_to_view('commitmsg', 'commitText')
40 self.model_to_view('staged', 'stagedList')
41 self.model_to_view('all_unstaged', 'unstagedList')
43 # When a model attribute changes, this runs a specific action
44 self.add_actions('staged', self.action_staged)
45 self.add_actions('all_unstaged', self.action_all_unstaged)
47 # Routes signals for multiple widgets to our callbacks
48 # defined below.
49 self.add_signals('textChanged()', view.commitText)
50 self.add_signals('stateChanged(int)', view.untrackedCheckBox)
52 self.add_signals('released()',
53 view.stageButton, view.commitButton,
54 view.pushButton, view.signOffButton,)
56 self.add_signals('triggered()',
57 view.rescan,
58 view.createBranch, view.checkoutBranch,
59 view.rebaseBranch, view.deleteBranch,
60 view.setCommitMessage, view.commit,
61 view.stageChanged, view.stageUntracked,
62 view.stageSelected, view.unstageAll,
63 view.unstageSelected,
64 view.showDiffstat,
65 view.browseBranch, view.browseOtherBranch,
66 view.visualizeAll, view.visualizeCurrent,
67 view.exportPatches, view.cherryPick,
68 view.loadCommitMsg,
69 view.cut, view.copy, view.paste, view.delete,
70 view.selectAll, view.undo, view.redo,)
72 self.add_signals('itemClicked(QListWidgetItem *)',
73 view.stagedList, view.unstagedList,)
75 self.add_signals('itemSelectionChanged()',
76 view.stagedList, view.unstagedList,)
78 self.add_signals('splitterMoved(int,int)',
79 view.splitter_top, view.splitter_bottom)
81 # App cleanup
82 self.connect(QtGui.qApp, 'lastWindowClosed()',
83 self.last_window_closed)
85 # These callbacks are called in response to the signals
86 # defined above. One property of the QObserver callback
87 # mechanism is that the model is passed in as the first
88 # argument to the callback. This allows for a single
89 # controller to manage multiple models, though this
90 # isn't used at the moment.
91 self.add_callbacks({
92 # Actions that delegate directly to the model
93 'signOffButton': model.add_signoff,
94 'setCommitMessage': model.get_prev_commitmsg,
95 'stageChanged': self.model.stage_changed,
96 'stageUntracked': self.model.stage_untracked,
97 'unstageAll': self.model.unstage_all,
98 # Actions that delegate direclty to the view
99 'cut': view.action_cut,
100 'copy': view.action_copy,
101 'paste': view.action_paste,
102 'delete': view.action_delete,
103 'selectAll': view.action_select_all,
104 'undo': view.action_undo,
105 'redo': view.action_redo,
106 # Push Buttons
107 'stageButton': self.stage_selected,
108 'commitButton': self.commit,
109 'pushButton': self.push,
110 # List Widgets
111 'stagedList': self.diff_staged,
112 'unstagedList': self.diff_unstaged,
113 # Checkboxes
114 'untrackedCheckBox': self.rescan,
115 # Menu Actions
116 'rescan': self.rescan,
117 'createBranch': self.branch_create,
118 'deleteBranch': self.branch_delete,
119 'checkoutBranch': self.checkout_branch,
120 'rebaseBranch': self.rebase,
121 'commit': self.commit,
122 'stageSelected': self.stage_selected,
123 'unstageSelected': self.unstage_selected,
124 'showDiffstat': self.show_diffstat,
125 'browseBranch': self.browse_current,
126 'browseOtherBranch': self.browse_other,
127 'visualizeCurrent': self.viz_current,
128 'visualizeAll': self.viz_all,
129 'exportPatches': self.export_patches,
130 'cherryPick': self.cherry_pick,
131 'loadCommitMsg': self.load_commitmsg,
132 # Splitters
133 'splitter_top': self.splitter_top_event,
134 'splitter_bottom': self.splitter_bottom_event,
137 # Handle double-clicks in the staged/unstaged lists.
138 # These are vanilla signal/slots since the qobserver
139 # signal routing is already handling these lists' signals.
140 self.connect(view.unstagedList,
141 'itemDoubleClicked(QListWidgetItem*)',
142 self.stage_selected)
144 self.connect(view.stagedList,
145 'itemDoubleClicked(QListWidgetItem*)',
146 self.unstage_selected )
148 # Delegate window move events here
149 self.view.moveEvent = self.move_event
150 self.view.resizeEvent = self.resize_event
152 # Initialize the GUI
153 self.load_window_settings()
154 self.rescan()
156 # Setup the inotify watchdog
157 self.__start_inotify_thread()
159 #####################################################################
160 # Actions triggered during model updates
162 def action_staged(self, widget):
163 qtutils.update_listwidget(widget,
164 self.model.get_staged(), staged=True)
166 def action_all_unstaged(self, widget):
167 qtutils.update_listwidget(widget,
168 self.model.get_unstaged(), staged=False)
170 if self.view.untrackedCheckBox.isChecked():
171 qtutils.update_listwidget(widget,
172 self.model.get_untracked(),
173 staged=False,
174 append=True,
175 untracked=True)
177 #####################################################################
178 # Qt callbacks
180 def branch_create(self):
181 if create_new_branch(self.view, self.model):
182 self.rescan()
184 def branch_delete(self):
185 branch = choose_branch('Delete Branch',
186 self.view, self.model.get_local_branches())
187 if not branch: return
188 self.show_output(self.model.delete_branch(branch))
190 def browse_current(self):
191 branch = self.model.get_branch()
192 browse_git_branch(self.model, self.view, branch)
194 def browse_other(self):
195 # Prompt for a branch to browse
196 branch = choose_branch('Browse Branch Files',
197 self.view, self.model.get_all_branches())
198 if not branch: return
199 # Launch the repobrowser
200 browse_git_branch(self.model, self.view, branch)
202 def checkout_branch(self):
203 branch = choose_branch('Checkout Branch',
204 self.view, self.model.get_local_branches())
205 if not branch: return
206 self.show_output(self.model.checkout(branch))
208 def cherry_pick(self):
209 '''Starts a cherry-picking session.'''
210 (revs, summaries) = self.model.log(all=True)
211 selection, idxs = self.select_commits_gui(revs, summaries)
212 if not selection: return
213 output = self.model.cherry_pick(selection)
214 self.show_output(self.tr(output))
216 def commit(self):
217 msg = self.model.get_commitmsg()
218 if not msg:
219 error_msg = self.tr(""
220 + "Please supply a commit message.\n"
221 + "\n"
222 + "A good commit message has the following format:\n"
223 + "\n"
224 + "- First line: Describe in one sentence what you did.\n"
225 + "- Second line: Blank\n"
226 + "- Remaining lines: Describe why this change is good.\n")
228 self.show_output(error_msg)
229 return
231 files = self.model.get_staged()
232 if not files:
233 errmsg = self.tr(""
234 + "No changes to commit.\n"
235 + "\n"
236 + "You must stage at least 1 file before you can commit.\n")
237 self.show_output(errmsg)
238 return
240 # Perform the commit
241 output = self.model.commit(msg, amend=self.view.amendRadio.isChecked())
243 # Reset state
244 self.view.newCommitRadio.setChecked(True)
245 self.view.amendRadio.setChecked(False)
246 self.model.set_commitmsg('')
247 self.show_output(output)
249 def view_diff(self, staged=True):
250 self.__staged_diff_in_view = staged
251 if self.__staged_diff_in_view:
252 widget = self.view.stagedList
253 else:
254 widget = self.view.unstagedList
255 row, selected = qtutils.get_selected_row(widget)
256 if not selected:
257 self.view.reset_display()
258 return
259 (diff,
260 status) = self.model.get_diff_and_status(row, staged=staged)
262 self.view.set_display(diff)
263 self.view.set_info(self.tr(status))
265 # use *rest to handle being called from different signals
266 def diff_staged(self, *rest):
267 self.view_diff(staged=True)
269 # use *rest to handle being called from different signals
270 def diff_unstaged(self,*rest):
271 self.view_diff(staged=False)
273 def export_patches(self):
274 '''Launches the commit browser and exports the selected
275 patches.'''
277 (revs, summaries) = self.model.log()
278 selection, idxs = self.select_commits_gui(revs, summaries)
279 if not selection: return
281 # now get the selected indices to determine whether
282 # a range of consecutive commits were selected
283 selected_range = range(idxs[0], idxs[-1] + 1)
284 export_range = len(idxs) > 1 and idxs == selected_range
285 output = self.model.format_patch(selection, export_range)
286 self.show_output(output)
288 def last_window_closed(self):
289 '''Save config settings and cleanup the any inotify threads.'''
291 self.model.save_window_geom()
293 if not self.inotify_thread: return
294 if not self.inotify_thread.isRunning(): return
296 self.inotify_thread.abort = True
297 self.inotify_thread.quit()
298 self.inotify_thread.wait()
300 def load_commitmsg(self):
301 file = qtutils.open_dialog(self.view,
302 'Load Commit Message...', defaults.DIRECTORY)
304 if file:
305 defaults.DIRECTORY = os.path.dirname(file)
306 slushy = utils.slurp(file)
307 if slushy: self.model.set_commitmsg(slushy)
309 def rebase(self):
310 branch = choose_branch('Rebase Branch',
311 self.view, self.model.get_local_branches())
312 if not branch: return
313 self.show_output(self.model.rebase(branch))
315 # use *rest to handle being called from the checkbox signal
316 def rescan(self, *rest):
317 '''Populates view widgets with results from "git status."'''
319 self.view.statusBar().showMessage(
320 self.tr('Scanning for modified files ...'))
322 self.model.update_status()
324 branch = self.model.get_branch()
325 status_text = self.tr('Current Branch:') + ' ' + branch
326 self.view.statusBar().showMessage(status_text)
328 title = '%s [%s]' % (self.model.get_project(), branch)
329 self.view.setWindowTitle(title)
331 if not self.model.has_squash_msg(): return
333 if self.model.get_commitmsg():
334 answer = qtutils.question(self.view,
335 self.tr('Import Commit Message?'),
336 self.tr('A commit message from an in-progress'
337 + ' merge was found.\nImport it?'))
339 if not answer: return
341 # Set the new commit message
342 self.model.set_squash_msg()
344 def push(self):
345 push_branches(self.model, self.view)
347 def show_diffstat(self):
348 '''Show the diffstat from the latest commit.'''
349 self.show_output(self.model.diff_stat(), rescan=False)
351 #####################################################################
352 # diff gui
354 def process_diff_selection(self, items, widget,
355 cached=True, selected=False, reverse=True, noop=False):
357 filename = qtutils.get_selected_item(widget, items)
358 if not filename: return
359 parser = utils.DiffParser(self.model, filename=filename,
360 cached=cached)
362 offset, selection = self.view.diff_selection()
363 parser.process_diff_selection(selected, offset, selection)
364 self.rescan()
366 def stage_hunk(self):
367 self.process_diff_selection(
368 self.model.get_unstaged(),
369 self.view.unstagedList,
370 cached=False)
372 def stage_hunks(self):
373 self.process_diff_selection(
374 self.model.get_unstaged(),
375 self.view.unstagedList,
376 cached=False,
377 selected=True)
379 def unstage_hunk(self, cached=True):
380 self.process_diff_selection(
381 self.model.get_staged(),
382 self.view.stagedList,
383 cached=True)
385 def unstage_hunks(self):
386 self.process_diff_selection(
387 self.model.get_staged(),
388 self.view.stagedList,
389 cached=True,
390 selected=True)
392 # #######################################################################
393 # end diff gui
395 # use *rest to handle being called from different signals
396 def stage_selected(self,*rest):
397 '''Use "git add" to add items to the git index.
398 This is a thin wrapper around __apply_to_list.'''
399 command = self.model.add_or_remove
400 widget = self.view.unstagedList
401 items = self.model.get_all_unstaged()
402 self.__apply_to_list(command,widget,items)
404 # use *rest to handle being called from different signals
405 def unstage_selected(self, *rest):
406 '''Use "git reset" to remove items from the git index.
407 This is a thin wrapper around __apply_to_list.'''
408 command = self.model.reset
409 widget = self.view.stagedList
410 items = self.model.get_staged()
411 self.__apply_to_list(command, widget, items)
413 def viz_all(self):
414 '''Visualizes the entire git history using gitk.'''
415 utils.fork('gitk','--all')
417 def viz_current(self):
418 '''Visualizes the current branch's history using gitk.'''
419 utils.fork('gitk', self.model.get_branch())
421 # These actions monitor window resizes, splitter changes, etc.
422 def move_event(self, event):
423 defaults.X = event.pos().x()
424 defaults.Y = event.pos().y()
426 def resize_event(self, event):
427 defaults.WIDTH = event.size().width()
428 defaults.HEIGHT = event.size().height()
430 def splitter_top_event(self,*rest):
431 sizes = self.view.splitter_top.sizes()
432 defaults.SPLITTER_TOP_0 = sizes[0]
433 defaults.SPLITTER_TOP_1 = sizes[1]
435 def splitter_bottom_event(self,*rest):
436 sizes = self.view.splitter_bottom.sizes()
437 defaults.SPLITTER_BOTTOM_0 = sizes[0]
438 defaults.SPLITTER_BOTTOM_1 = sizes[1]
440 def load_window_settings(self):
441 (w,h,x,y,
442 st0,st1,
443 sb0,sb1) = self.model.get_window_geom()
444 self.view.resize(w,h)
445 self.view.move(x,y)
446 self.view.splitter_top.setSizes([st0,st1])
447 self.view.splitter_bottom.setSizes([sb0,sb1])
449 def show_output(self, output, rescan=True):
450 '''Shows output and optionally rescans for changes.'''
451 qtutils.show_output(self.view, output)
452 self.rescan()
454 #####################################################################
457 def __apply_to_list(self, command, widget, items):
458 '''This is a helper method that retrieves the current
459 selection list, applies a command to that list,
460 displays a dialog showing the output of that command,
461 and calls rescan to pickup changes.'''
462 apply_items = qtutils.get_selection_list(widget, items)
463 output = command(apply_items)
464 self.rescan()
465 return output
467 def __menu_about_to_show(self):
469 unstaged_item = qtutils.get_selected_item(
470 self.view.unstagedList,
471 self.model.get_all_unstaged())
473 is_tracked= unstaged_item not in self.model.get_untracked()
475 enable_staged= (
476 unstaged_item
477 and not self.__staged_diff_in_view
478 and is_tracked)
480 enable_unstaged= (
481 self.__staged_diff_in_view
482 and qtutils.get_selected_item(
483 self.view.stagedList,
484 self.model.get_staged()))
486 self.__stage_hunk_action.setEnabled(bool(enable_staged))
487 self.__stage_hunks_action.setEnabled(bool(enable_staged))
489 self.__unstage_hunk_action.setEnabled(bool(enable_unstaged))
490 self.__unstage_hunks_action.setEnabled(bool(enable_unstaged))
492 def __menu_event(self, event):
493 self.__menu_setup()
494 textedit = self.view.displayText
495 self.__menu.exec_(textedit.mapToGlobal(event.pos()))
497 def __menu_setup(self):
498 if self.__menu: return
500 menu = self.__menu = QMenu(self.view)
501 self.__stage_hunk_action = menu.addAction(
502 self.tr('Stage Hunk For Commit'),
503 self.stage_hunk)
505 self.__stage_hunks_action = menu.addAction(
506 self.tr('Stage Selected Lines'),
507 self.stage_hunks)
509 self.__unstage_hunk_action = menu.addAction(
510 self.tr('Unstage Hunk From Commit'),
511 self.unstage_hunk)
513 self.__unstage_hunks_action = menu.addAction(
514 self.tr('Unstage Selected Lines'),
515 self.unstage_hunks)
517 self.__copy_action = menu.addAction(
518 self.tr('Copy'),
519 self.view.copy_display)
521 self.connect(self.__menu, 'aboutToShow()', self.__menu_about_to_show)
523 def select_commits_gui(self, revs, summaries):
524 return select_commits(self.model, self.view, revs, summaries)
526 def __start_inotify_thread(self):
527 # Do we have inotify? If not, return.
528 # Recommend installing inotify if we're on Linux.
529 self.inotify_thread = None
530 try:
531 from inotify import GitNotifier
532 except ImportError:
533 import platform
534 if platform.system() == 'Linux':
535 msg =(self.tr('Unable import pyinotify.\n'
536 + 'inotify support has been'
537 + 'disabled.')
538 + '\n\n')
540 plat = platform.platform().lower()
541 if 'debian' in plat or 'ubuntu' in plat:
542 msg += (self.tr('Hint:')
543 + 'sudo apt-get install'
544 + ' python-pyinotify')
546 qtutils.information(self.view,
547 self.tr('inotify disabled'), msg)
548 return
550 self.inotify_thread = GitNotifier(os.getcwd())
551 self.connect(self.inotify_thread,
552 'timeForRescan()', self.rescan)
554 # Start the notification thread
555 self.inotify_thread.start()