Simplified inotify
[git-cola.git] / ugitlibs / controllers.py
blob6e1579bfca952c954a54fd853c282886caa107a9
1 #!/usr/bin/env python
2 import os
3 import time
5 from PyQt4 import QtGui
6 from PyQt4.QtGui import QDialog
7 from PyQt4.QtGui import QMessageBox
8 from PyQt4.QtGui import QMenu
10 import utils
11 import qtutils
12 import defaults
13 from qobserver import QObserver
14 from repobrowsercontroller import browse_git_branch
15 from createbranchcontroller import create_new_branch
16 from pushcontroller import push_branches
17 from utilcontroller import choose_branch
18 from utilcontroller import select_commits
19 from utilcontroller import update_options
21 class Controller(QObserver):
22 '''The controller is a mediator between the model and view.
23 It allows for a clean decoupling between view and model classes.'''
25 def __init__(self, model, view):
26 QObserver.__init__(self, model, view)
28 self.__last_inotify_event = time.time()
30 # The diff-display context menu
31 self.__menu = None
32 self.__staged_diff_in_view = True
34 # Diff display context menu
35 view.displayText.controller = self
36 view.displayText.contextMenuEvent = self.diff_context_menu_event
38 # Default to creating a new commit(i.e. not an amend commit)
39 view.newCommitRadio.setChecked(True)
41 # Binds a specific model attribute to a view widget,
42 # and vice versa.
43 self.model_to_view('commitmsg', 'commitText')
44 self.model_to_view('staged', 'stagedList')
45 self.model_to_view('all_unstaged', 'unstagedList')
47 # When a model attribute changes, this runs a specific action
48 self.add_actions('staged', self.action_staged)
49 self.add_actions('all_unstaged', self.action_all_unstaged)
51 # Routes signals for multiple widgets to our callbacks
52 # defined below.
53 self.add_signals('textChanged()', view.commitText)
54 self.add_signals('stateChanged(int)', view.untrackedCheckBox)
56 self.add_signals('released()',
57 view.stageButton, view.commitButton,
58 view.pushButton, view.signOffButton,)
60 self.add_signals('triggered()',
61 view.rescan, view.options,
62 view.createBranch, view.checkoutBranch,
63 view.rebaseBranch, view.deleteBranch,
64 view.setCommitMessage, view.commit,
65 view.stageChanged, view.stageUntracked,
66 view.stageSelected, view.unstageAll,
67 view.unstageSelected,
68 view.showDiffstat,
69 view.browseBranch, view.browseOtherBranch,
70 view.visualizeAll, view.visualizeCurrent,
71 view.exportPatches, view.cherryPick,
72 view.loadCommitMsg,
73 view.cut, view.copy, view.paste, view.delete,
74 view.selectAll, view.undo, view.redo,)
76 self.add_signals('itemClicked(QListWidgetItem *)',
77 view.stagedList, view.unstagedList,)
79 self.add_signals('itemSelectionChanged()',
80 view.stagedList, view.unstagedList,)
82 self.add_signals('splitterMoved(int,int)',
83 view.splitter_top, view.splitter_bottom)
85 # App cleanup
86 self.connect(QtGui.qApp, 'lastWindowClosed()',
87 self.last_window_closed)
89 # These callbacks are called in response to the signals
90 # defined above. One property of the QObserver callback
91 # mechanism is that the model is passed in as the first
92 # argument to the callback. This allows for a single
93 # controller to manage multiple models, though this
94 # isn't used at the moment.
95 self.add_callbacks(
96 # Actions that delegate directly to the model
97 signOffButton = model.add_signoff,
98 setCommitMessage = model.get_prev_commitmsg,
99 stageChanged = self.model.stage_changed,
100 stageUntracked = self.model.stage_untracked,
101 unstageAll = self.model.unstage_all,
103 # Actions that delegate direclty to the view
104 cut = view.action_cut,
105 copy = view.action_copy,
106 paste = view.action_paste,
107 delete = view.action_delete,
108 selectAll = view.action_select_all,
109 undo = view.action_undo,
110 redo = view.action_redo,
112 # Push Buttons
113 stageButton = self.stage_selected,
114 commitButton = self.commit,
115 pushButton = self.push,
117 # List Widgets
118 stagedList = self.diff_staged,
119 unstagedList = self.diff_unstaged,
121 # Checkboxes
122 untrackedCheckBox = self.rescan,
124 # Menu Actions
125 options = self.options,
126 rescan = self.rescan,
127 createBranch = self.branch_create,
128 deleteBranch = self.branch_delete,
129 checkoutBranch = self.checkout_branch,
130 rebaseBranch = self.rebase,
131 commit = self.commit,
132 stageSelected = self.stage_selected,
133 unstageSelected = self.unstage_selected,
134 showDiffstat = self.show_diffstat,
135 browseBranch = self.browse_current,
136 browseOtherBranch = self.browse_other,
137 visualizeCurrent = self.viz_current,
138 visualizeAll = self.viz_all,
139 exportPatches = self.export_patches,
140 cherryPick = self.cherry_pick,
141 loadCommitMsg = self.load_commitmsg,
143 # Splitters
144 splitter_top = self.splitter_top_event,
145 splitter_bottom = self.splitter_bottom_event,
148 # Handle double-clicks in the staged/unstaged lists.
149 # These are vanilla signal/slots since the qobserver
150 # signal routing is already handling these lists' signals.
151 self.connect(view.unstagedList,
152 'itemDoubleClicked(QListWidgetItem*)',
153 self.stage_selected)
155 self.connect(view.stagedList,
156 'itemDoubleClicked(QListWidgetItem*)',
157 self.unstage_selected )
159 # Delegate window move events here
160 self.view.moveEvent = self.move_event
161 self.view.resizeEvent = self.resize_event
163 # Initialize the GUI
164 self.load_window_settings()
165 self.rescan()
167 # Setup the inotify watchdog
168 self.start_inotify_thread()
170 #####################################################################
171 # event() is called in response to messages from the inotify thread
173 def event(self, msg):
174 if msg.type() == defaults.INOTIFY_EVENT:
175 self.rescan()
176 return True
177 else:
178 return False
180 #####################################################################
181 # Actions triggered during model updates
183 def action_staged(self, widget):
184 qtutils.update_listwidget(widget,
185 self.model.get_staged(), staged=True)
187 def action_all_unstaged(self, widget):
188 qtutils.update_listwidget(widget,
189 self.model.get_unstaged(), staged=False)
191 if self.view.untrackedCheckBox.isChecked():
192 qtutils.update_listwidget(widget,
193 self.model.get_untracked(),
194 staged=False,
195 append=True,
196 untracked=True)
198 #####################################################################
199 # Qt callbacks
201 def options(self):
202 update_options(self.model, self.view)
204 def branch_create(self):
205 if create_new_branch(self.model, self.view):
206 self.rescan()
208 def branch_delete(self):
209 branch = choose_branch('Delete Branch',
210 self.view, self.model.get_local_branches())
211 if not branch: return
212 self.show_output(self.model.delete_branch(branch))
214 def browse_current(self):
215 branch = self.model.get_branch()
216 browse_git_branch(self.model, self.view, branch)
218 def browse_other(self):
219 # Prompt for a branch to browse
220 branch = choose_branch('Browse Branch Files',
221 self.view, self.model.get_all_branches())
222 if not branch: return
223 # Launch the repobrowser
224 browse_git_branch(self.model, self.view, branch)
226 def checkout_branch(self):
227 branch = choose_branch('Checkout Branch',
228 self.view, self.model.get_local_branches())
229 if not branch: return
230 self.show_output(self.model.checkout(branch))
232 def cherry_pick(self):
233 commits = self.select_commits_gui(*self.model.log(all=True))
234 if not commits: return
235 self.show_output(self.model.cherry_pick(commits))
237 def commit(self):
238 msg = self.model.get_commitmsg()
239 if not msg:
240 error_msg = self.tr(""
241 + "Please supply a commit message.\n"
242 + "\n"
243 + "A good commit message has the following format:\n"
244 + "\n"
245 + "- First line: Describe in one sentence what you did.\n"
246 + "- Second line: Blank\n"
247 + "- Remaining lines: Describe why this change is good.\n")
249 self.show_output(error_msg)
250 return
252 files = self.model.get_staged()
253 if not files:
254 errmsg = self.tr(""
255 + "No changes to commit.\n"
256 + "\n"
257 + "You must stage at least 1 file before you can commit.\n")
258 self.show_output(errmsg)
259 return
261 # Perform the commit
262 output = self.model.commit(msg, amend=self.view.amendRadio.isChecked())
264 # Reset state
265 self.view.newCommitRadio.setChecked(True)
266 self.view.amendRadio.setChecked(False)
267 self.model.set_commitmsg('')
268 self.show_output(output)
270 def view_diff(self, staged=True):
271 self.__staged_diff_in_view = staged
272 if self.__staged_diff_in_view:
273 widget = self.view.stagedList
274 else:
275 widget = self.view.unstagedList
276 row, selected = qtutils.get_selected_row(widget)
277 if not selected:
278 self.view.reset_display()
279 return
280 (diff,
281 status) = self.model.get_diff_and_status(row, staged=staged)
283 self.view.set_display(diff)
284 self.view.set_info(self.tr(status))
286 # use *rest to handle being called from different signals
287 def diff_staged(self, *rest):
288 self.view_diff(staged=True)
290 # use *rest to handle being called from different signals
291 def diff_unstaged(self,*rest):
292 self.view_diff(staged=False)
294 def export_patches(self):
295 (revs, summaries) = self.model.log()
296 commits = self.select_commits_gui(revs, summaries)
297 if not commits: return
298 self.show_output(self.model.format_patch(commits))
300 def last_window_closed(self):
301 '''Save config settings and cleanup any inotify threads.'''
303 self.model.save_window_geom()
305 if not self.inotify_thread: return
306 if not self.inotify_thread.isRunning(): return
308 self.inotify_thread.abort = True
309 self.inotify_thread.terminate()
310 self.inotify_thread.wait()
312 def load_commitmsg(self):
313 file = qtutils.open_dialog(self.view,
314 'Load Commit Message...', defaults.DIRECTORY)
316 if file:
317 defaults.DIRECTORY = os.path.dirname(file)
318 slushy = utils.slurp(file)
319 if slushy: self.model.set_commitmsg(slushy)
321 def rebase(self):
322 branch = choose_branch('Rebase Branch',
323 self.view, self.model.get_local_branches())
324 if not branch: return
325 self.show_output(self.model.rebase(branch))
327 # use *rest to handle being called from the checkbox signal
328 def rescan(self, *rest):
329 '''Populates view widgets with results from "git status."'''
331 self.view.statusBar().showMessage(
332 self.tr('Scanning for modified files ...'))
334 self.model.update_status()
336 branch = self.model.get_branch()
337 status_text = self.tr('Current Branch:') + ' ' + branch
338 self.view.statusBar().showMessage(status_text)
340 title = '%s [%s]' % (self.model.get_project(), branch)
341 self.view.setWindowTitle(title)
343 if not self.model.has_squash_msg(): return
345 if self.model.get_commitmsg():
346 answer = qtutils.question(self.view,
347 self.tr('Import Commit Message?'),
348 self.tr('A commit message from an in-progress'
349 + ' merge was found.\nImport it?'))
351 if not answer: return
353 # Set the new commit message
354 self.model.set_squash_msg()
356 def push(self):
357 push_branches(self.model, self.view)
359 def show_diffstat(self):
360 '''Show the diffstat from the latest commit.'''
361 self.show_output(self.model.diff_stat(), rescan=False)
363 #####################################################################
364 # diff gui
366 def process_diff_selection(self, items, widget,
367 cached=True, selected=False, reverse=True, noop=False):
369 filename = qtutils.get_selected_item(widget, items)
370 if not filename: return
371 parser = utils.DiffParser(self.model, filename=filename,
372 cached=cached)
374 offset, selection = self.view.diff_selection()
375 parser.process_diff_selection(selected, offset, selection)
376 self.rescan()
378 def stage_hunk(self):
379 self.process_diff_selection(
380 self.model.get_unstaged(),
381 self.view.unstagedList,
382 cached=False)
384 def stage_hunks(self):
385 self.process_diff_selection(
386 self.model.get_unstaged(),
387 self.view.unstagedList,
388 cached=False,
389 selected=True)
391 def unstage_hunk(self, cached=True):
392 self.process_diff_selection(
393 self.model.get_staged(),
394 self.view.stagedList,
395 cached=True)
397 def unstage_hunks(self):
398 self.process_diff_selection(
399 self.model.get_staged(),
400 self.view.stagedList,
401 cached=True,
402 selected=True)
404 # #######################################################################
405 # end diff gui
407 # use *rest to handle being called from different signals
408 def stage_selected(self,*rest):
409 '''Use "git add" to add items to the git index.
410 This is a thin wrapper around apply_to_list.'''
411 command = self.model.add_or_remove
412 widget = self.view.unstagedList
413 items = self.model.get_all_unstaged()
414 self.apply_to_list(command,widget,items)
416 # use *rest to handle being called from different signals
417 def unstage_selected(self, *rest):
418 '''Use "git reset" to remove items from the git index.
419 This is a thin wrapper around apply_to_list.'''
420 command = self.model.reset
421 widget = self.view.stagedList
422 items = self.model.get_staged()
423 self.apply_to_list(command, widget, items)
425 def viz_all(self):
426 '''Visualizes the entire git history using gitk.'''
427 utils.fork('gitk','--all')
429 def viz_current(self):
430 '''Visualizes the current branch's history using gitk.'''
431 utils.fork('gitk', self.model.get_branch())
433 # These actions monitor window resizes, splitter changes, etc.
434 def move_event(self, event):
435 defaults.X = event.pos().x()
436 defaults.Y = event.pos().y()
438 def resize_event(self, event):
439 defaults.WIDTH = event.size().width()
440 defaults.HEIGHT = event.size().height()
442 def splitter_top_event(self,*rest):
443 sizes = self.view.splitter_top.sizes()
444 defaults.SPLITTER_TOP_0 = sizes[0]
445 defaults.SPLITTER_TOP_1 = sizes[1]
447 def splitter_bottom_event(self,*rest):
448 sizes = self.view.splitter_bottom.sizes()
449 defaults.SPLITTER_BOTTOM_0 = sizes[0]
450 defaults.SPLITTER_BOTTOM_1 = sizes[1]
452 def load_window_settings(self):
453 (w,h,x,y,
454 st0,st1,
455 sb0,sb1) = self.model.get_window_geom()
456 self.view.resize(w,h)
457 self.view.move(x,y)
458 self.view.splitter_top.setSizes([st0,st1])
459 self.view.splitter_bottom.setSizes([sb0,sb1])
461 def show_output(self, output, rescan=True):
462 '''Shows output and optionally rescans for changes.'''
463 qtutils.show_output(self.view, output)
464 self.rescan()
466 #####################################################################
469 def apply_to_list(self, command, widget, items):
470 '''This is a helper method that retrieves the current
471 selection list, applies a command to that list,
472 displays a dialog showing the output of that command,
473 and calls rescan to pickup changes.'''
474 apply_items = qtutils.get_selection_list(widget, items)
475 output = command(apply_items)
476 self.rescan()
477 return output
479 def diff_context_menu_about_to_show(self):
480 unstaged_item = qtutils.get_selected_item(
481 self.view.unstagedList,
482 self.model.get_all_unstaged())
484 is_tracked= unstaged_item not in self.model.get_untracked()
486 enable_staged= (
487 unstaged_item
488 and not self.__staged_diff_in_view
489 and is_tracked)
491 enable_unstaged= (
492 self.__staged_diff_in_view
493 and qtutils.get_selected_item(
494 self.view.stagedList,
495 self.model.get_staged()))
497 self.__stage_hunk_action.setEnabled(bool(enable_staged))
498 self.__stage_hunks_action.setEnabled(bool(enable_staged))
500 self.__unstage_hunk_action.setEnabled(bool(enable_unstaged))
501 self.__unstage_hunks_action.setEnabled(bool(enable_unstaged))
503 def diff_context_menu_event(self, event):
504 self.diff_context_menu_setup()
505 textedit = self.view.displayText
506 self.__menu.exec_(textedit.mapToGlobal(event.pos()))
508 def diff_context_menu_setup(self):
509 if self.__menu: return
511 menu = self.__menu = QMenu(self.view)
512 self.__stage_hunk_action = menu.addAction(
513 self.tr('Stage Hunk For Commit'), self.stage_hunk)
515 self.__stage_hunks_action = menu.addAction(
516 self.tr('Stage Selected Lines'), self.stage_hunks)
518 self.__unstage_hunk_action = menu.addAction(
519 self.tr('Unstage Hunk From Commit'), self.unstage_hunk)
521 self.__unstage_hunks_action = menu.addAction(
522 self.tr('Unstage Selected Lines'), self.unstage_hunks)
524 self.__copy_action = menu.addAction(
525 self.tr('Copy'), self.view.copy_display)
527 self.connect(self.__menu, 'aboutToShow()',
528 self.diff_context_menu_about_to_show)
530 def select_commits_gui(self, revs, summaries):
531 return select_commits(self.model, self.view, revs, summaries)
533 def start_inotify_thread(self):
534 # Do we have inotify? If not, return.
535 # Recommend installing inotify if we're on Linux.
536 self.inotify_thread = None
537 try:
538 from inotify import GitNotifier
539 except ImportError:
540 import platform
541 if platform.system() == 'Linux':
542 msg =(self.tr('Unable import pyinotify.\n'
543 + 'inotify support has been'
544 + 'disabled.')
545 + '\n\n')
547 plat = platform.platform().lower()
548 if 'debian' in plat or 'ubuntu' in plat:
549 msg += (self.tr('Hint:')
550 + 'sudo apt-get install'
551 + ' python-pyinotify')
553 qtutils.information(self.view,
554 self.tr('inotify disabled'), msg)
555 return
556 # Start the notification thread
557 self.inotify_thread = GitNotifier(self, os.getcwd())
558 self.inotify_thread.start()