4 from PyQt4
import QtGui
5 from PyQt4
.QtGui
import QDialog
6 from PyQt4
.QtGui
import QMessageBox
7 from PyQt4
.QtGui
import QMenu
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
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,
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
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()',
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
,
65 view
.browseBranch
, view
.browseOtherBranch
,
66 view
.visualizeAll
, view
.visualizeCurrent
,
67 view
.exportPatches
, view
.cherryPick
,
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
)
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.
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
,
107 'stageButton': self
.stage_selected
,
108 'commitButton': self
.commit
,
109 'pushButton': self
.push
,
111 'stagedList': self
.diff_staged
,
112 'unstagedList': self
.diff_unstaged
,
114 'untrackedCheckBox': self
.rescan
,
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
,
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*)',
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
153 self
.load_window_settings()
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(),
177 #####################################################################
180 def branch_create(self
):
181 if create_new_branch(self
.view
, self
.model
):
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
))
217 msg
= self
.model
.get_commitmsg()
219 error_msg
= self
.tr(""
220 + "Please supply a commit message.\n"
222 + "A good commit message has the following format:\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
)
231 files
= self
.model
.get_staged()
234 + "No changes to commit.\n"
236 + "You must stage at least 1 file before you can commit.\n")
237 self
.show_output(errmsg
)
241 output
= self
.model
.commit(msg
, amend
=self
.view
.amendRadio
.isChecked())
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
254 widget
= self
.view
.unstagedList
255 row
, selected
= qtutils
.get_selected_row(widget
)
257 self
.view
.reset_display()
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
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
)
305 defaults
.DIRECTORY
= os
.path
.dirname(file)
306 slushy
= utils
.slurp(file)
307 if slushy
: self
.model
.set_commitmsg(slushy
)
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()
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 #####################################################################
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
,
362 offset
, selection
= self
.view
.diff_selection()
363 parser
.process_diff_selection(selected
, offset
, selection
)
366 def stage_hunk(self
):
367 self
.process_diff_selection(
368 self
.model
.get_unstaged(),
369 self
.view
.unstagedList
,
372 def stage_hunks(self
):
373 self
.process_diff_selection(
374 self
.model
.get_unstaged(),
375 self
.view
.unstagedList
,
379 def unstage_hunk(self
, cached
=True):
380 self
.process_diff_selection(
381 self
.model
.get_staged(),
382 self
.view
.stagedList
,
385 def unstage_hunks(self
):
386 self
.process_diff_selection(
387 self
.model
.get_staged(),
388 self
.view
.stagedList
,
392 # #######################################################################
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
)
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
):
443 sb0
,sb1
) = self
.model
.get_window_geom()
444 self
.view
.resize(w
,h
)
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
)
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
)
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()
477 and not self
.__staged
_diff
_in
_view
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
):
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'),
505 self
.__stage
_hunks
_action
= menu
.addAction(
506 self
.tr('Stage Selected Lines'),
509 self
.__unstage
_hunk
_action
= menu
.addAction(
510 self
.tr('Unstage Hunk From Commit'),
513 self
.__unstage
_hunks
_action
= menu
.addAction(
514 self
.tr('Unstage Selected Lines'),
517 self
.__copy
_action
= menu
.addAction(
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
531 from inotify
import GitNotifier
534 if platform
.system() == 'Linux':
535 msg
=(self
.tr('Unable import pyinotify.\n'
536 + 'inotify support has been'
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
)
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()