cmds: simplify cherry-pick by using the dispatcher
[ugit.git] / ugit / controllers.py
blob4434b70470ed8b83d5c193bdcd92a40feec8bad3
1 #!/usr/bin/env python
2 import os
3 import time
5 from PyQt4 import QtCore
6 from PyQt4 import QtGui
7 from PyQt4.QtGui import QDialog
8 from PyQt4.QtGui import QMessageBox
9 from PyQt4.QtGui import QMenu
10 from PyQt4.QtGui import QFont
12 import utils
13 import qtutils
14 import defaults
15 from qobserver import QObserver
16 from repobrowsercontroller import browse_git_branch
17 from createbranchcontroller import create_new_branch
18 from pushcontroller import push_branches
19 from utilcontroller import choose_branch
20 from utilcontroller import select_commits
21 from utilcontroller import find_revisions
22 from utilcontroller import update_options
23 from utilcontroller import log_window
25 class Controller(QObserver):
26 """Controller manages the interaction between the model and views."""
28 def __init__(self, model, view):
29 QObserver.__init__(self, model, view)
31 # parent-less log window
32 qtutils.LOGGER = log_window(QtGui.qApp.activeWindow())
34 # Avoids inotify floods from e.g. make
35 self.__last_inotify_event = time.time()
37 # The unstaged list context menu
38 self.__unstaged_menu = None
40 # The diff-display context menu
41 self.__diff_menu = None
42 self.__staged_diff_in_view = True
43 self.__diffgui_enabled = True
45 # Unstaged changes context menu
46 view.unstaged.contextMenuEvent = self.unstaged_context_menu_event
48 # Diff display context menu
49 view.display_text.contextMenuEvent = self.diff_context_menu_event
51 # Binds model params to their equivalent view widget
52 self.add_observables('commitmsg', 'staged', 'unstaged')
54 # When a model attribute changes, this runs a specific action
55 self.add_actions('staged', self.action_staged)
56 self.add_actions('unstaged', self.action_unstaged)
57 self.add_actions('global_ugit_fontdiff', self.update_diff_font)
58 self.add_actions('global_ugit_fontui', self.update_ui_font)
60 self.add_callbacks(
61 # Actions that delegate directly to the model
62 signoff_button = model.add_signoff,
63 menu_get_prev_commitmsg = model.get_prev_commitmsg,
64 menu_stage_modified =
65 lambda: self.log(self.model.stage_modified()),
66 menu_stage_untracked =
67 lambda: self.log(self.model.stage_untracked()),
68 menu_unstage_all =
69 lambda: self.log(self.model.unstage_all()),
71 # Actions that delegate direclty to the view
72 menu_cut = view.action_cut,
73 menu_copy = view.action_copy,
74 menu_paste = view.action_paste,
75 menu_delete = view.action_delete,
76 menu_select_all = view.action_select_all,
77 menu_undo = view.action_undo,
78 menu_redo = view.action_redo,
80 # Push Buttons
81 stage_button = self.stage_selected,
82 commit_button = self.commit,
83 push_button = self.push,
85 # List Widgets
86 staged = self.diff_staged,
87 unstaged = self.diff_unstaged,
89 # Checkboxes
90 untracked_checkbox = self.rescan,
92 # File Menu
93 menu_quit = self.quit_app,
95 # Repository Menu
96 menu_visualize_current = self.viz_current,
97 menu_visualize_all = self.viz_all,
98 menu_show_revision = self.show_revision,
99 menu_browse_commits = self.browse_commits,
100 menu_browse_branch = self.browse_current,
101 menu_browse_other_branch = self.browse_other,
103 # Commit Menu
104 menu_rescan = self.rescan,
105 menu_create_branch = self.branch_create,
106 menu_delete_branch = self.branch_delete,
107 menu_checkout_branch = self.checkout_branch,
108 menu_rebase_branch = self.rebase,
109 menu_commit = self.commit,
110 menu_stage_selected = self.stage_selected,
111 menu_unstage_selected = self.unstage_selected,
112 menu_show_diffstat = self.show_diffstat,
113 menu_show_index = self.show_index,
114 menu_export_patches = self.export_patches,
115 menu_load_commitmsg = self.load_commitmsg,
116 menu_cherry_pick = self.cherry_pick,
118 # Edit Menu
119 menu_options = self.options,
122 # Delegate window events here
123 view.moveEvent = self.move_event
124 view.resizeEvent = self.resize_event
125 view.closeEvent = self.quit_app
126 view.staged.mousePressEvent = self.click_staged
127 view.unstaged.mousePressEvent = self.click_unstaged
129 # These are vanilla signal/slots since QObserver
130 # is already handling these signals.
131 self.connect(view.unstaged,
132 'itemDoubleClicked(QListWidgetItem*)',
133 self.stage_selected)
134 self.connect(view.staged,
135 'itemDoubleClicked(QListWidgetItem*)',
136 self.unstage_selected)
138 # Toolbar log button
139 self.connect(self.view.toolbar_show_log,
140 'triggered()', self.show_log)
142 self.connect(view.diff_dock,
143 'topLevelChanged(bool)',
144 lambda(b): self.setwindow(view.diff_dock, b))
146 self.connect(view.editor_dock,
147 'topLevelChanged(bool)',
148 lambda(b): self.setwindow(view.editor_dock, b))
150 self.connect(view.status_dock,
151 'topLevelChanged(bool)',
152 lambda(b): self.setwindow(view.status_dock, b))
154 self.init_log_window()
155 self.load_gui_settings()
156 self.rescan()
157 self.refresh_view(
158 'global_ugit_fontdiff',
159 'global_ugit_fontui',
161 self.start_inotify_thread()
163 def setwindow(self, dock, isfloating):
164 if isfloating:
165 flags = ( QtCore.Qt.Window
166 | QtCore.Qt.FramelessWindowHint )
167 dock.setWindowFlags( flags )
168 dock.show()
170 #####################################################################
171 # handle when the listitem icons are clicked
172 def click_event(self, widget, action_callback, event):
173 result = QtGui.QListWidget.mousePressEvent(widget, event)
174 xpos = event.pos().x()
175 if xpos > 5 and xpos < 20:
176 action_callback()
177 return result
179 def click_staged(self, event):
180 return self.click_event(
181 self.view.staged,
182 self.unstage_selected,
183 event)
185 def click_unstaged(self, event):
186 return self.click_event(
187 self.view.unstaged,
188 self.stage_selected,
189 event)
192 #####################################################################
193 # event() is called in response to messages from the inotify thread
194 def event(self, msg):
195 if msg.type() == defaults.INOTIFY_EVENT:
196 self.rescan()
197 return True
198 else:
199 return False
201 #####################################################################
202 # Actions triggered during model updates
204 def action_staged(self, widget):
205 qtutils.update_listwidget(widget,
206 self.model.get_staged(), staged=True)
208 def action_unstaged(self, widget):
209 qtutils.update_listwidget(widget,
210 self.model.get_modified(), staged=False)
212 if self.view.untracked_checkbox.isChecked():
213 qtutils.update_listwidget(widget,
214 self.model.get_untracked(),
215 staged=False,
216 append=True,
217 untracked=True)
219 #####################################################################
220 # Qt callbacks
222 def show_log(self, *rest):
223 qtutils.toggle_log_window()
225 def options(self):
226 update_options(self.model, self.view)
228 def branch_create(self):
229 if create_new_branch(self.model, self.view):
230 self.rescan()
232 def branch_delete(self):
233 branch = choose_branch('Delete Branch',
234 self.view, self.model.get_local_branches())
235 if not branch: return
236 self.log(self.model.delete_branch(branch))
238 def browse_current(self):
239 branch = self.model.get_branch()
240 browse_git_branch(self.model, self.view, branch)
242 def browse_other(self):
243 # Prompt for a branch to browse
244 branch = choose_branch('Browse Branch Files',
245 self.view, self.model.get_all_branches())
246 if not branch: return
247 # Launch the repobrowser
248 browse_git_branch(self.model, self.view, branch)
250 def checkout_branch(self):
251 branch = choose_branch('Checkout Branch',
252 self.view, self.model.get_local_branches())
253 if not branch: return
254 self.log(self.model.checkout(branch))
256 def browse_commits(self):
257 self.select_commits_gui(self.tr('Browse Commits'),
258 *self.model.log(all=True))
260 def show_revision(self):
261 find_revisions(self.model, self.view)
263 def cherry_pick(self):
264 commits = self.select_commits_gui(self.tr('Cherry-Pick Commits'),
265 *self.model.log(all=True))
266 if not commits: return
267 self.log(self.model.cherry_pick_list(commits))
269 def commit(self):
270 msg = self.model.get_commitmsg()
271 if not msg:
272 error_msg = self.tr(""
273 + "Please supply a commit message.\n"
274 + "\n"
275 + "A good commit message has the following format:\n"
276 + "\n"
277 + "- First line: Describe in one sentence what you did.\n"
278 + "- Second line: Blank\n"
279 + "- Remaining lines: Describe why this change is good.\n")
280 self.log(error_msg)
281 return
283 files = self.model.get_staged()
284 if not files:
285 error_msg = self.tr(""
286 + "No changes to commit.\n"
287 + "\n"
288 + "You must stage at least 1 file before you can commit.\n")
289 self.log(error_msg)
290 return
292 # Perform the commit
293 output = self.model.commit_with_msg(
294 msg, amend=self.view.amend_radio.isChecked())
296 # Reset state
297 self.view.new_commit_radio.setChecked(True)
298 self.view.amend_radio.setChecked(False)
299 self.model.set_commitmsg('')
300 self.log(output)
302 def view_diff(self, staged=True):
303 self.__staged_diff_in_view = staged
304 if self.__staged_diff_in_view:
305 widget = self.view.staged
306 else:
307 widget = self.view.unstaged
308 row, selected = qtutils.get_selected_row(widget)
309 if not selected:
310 self.view.reset_display()
311 self.__diffgui_enabled = False
312 return
313 (diff,
314 status) = self.model.get_diff_and_status(row, staged=staged)
316 self.view.set_display(diff)
317 self.view.set_info(self.tr(status))
318 self.view.diff_dock.raise_()
319 self.__diffgui_enabled = True
321 # use *rest to handle being called from different signals
322 def diff_staged(self, *rest):
323 self.view_diff(staged=True)
325 # use *rest to handle being called from different signals
326 def diff_unstaged(self, *rest):
327 self.view_diff(staged=False)
329 def export_patches(self):
330 (revs, summaries) = self.model.log()
331 commits = self.select_commits_gui(self.tr('Export Patches'),
332 revs, summaries)
333 if not commits: return
334 self.log(self.model.format_patch_helper(commits))
336 def quit_app(self,*rest):
337 """Save config settings and cleanup any inotify threads."""
339 if self.model.save_at_exit():
340 self.model.save_gui_settings()
341 qtutils.close_log_window()
342 self.view.hide()
344 if not self.inotify_thread: return
345 if not self.inotify_thread.isRunning(): return
347 self.inotify_thread.abort = True
348 self.inotify_thread.terminate()
349 self.inotify_thread.wait()
351 def load_commitmsg(self):
352 file = qtutils.open_dialog(self.view,
353 'Load Commit Message...', defaults.DIRECTORY)
355 if file:
356 defaults.DIRECTORY = os.path.dirname(file)
357 slushy = utils.slurp(file)
358 if slushy: self.model.set_commitmsg(slushy)
360 def rebase(self):
361 branch = choose_branch('Rebase Branch',
362 self.view, self.model.get_local_branches())
363 if not branch: return
364 self.log(self.model.rebase(branch))
366 # use *rest to handle being called from the checkbox signal
367 def rescan(self, *rest):
368 '''Populates view widgets with results from "git status."'''
370 # save entire selection
371 unstaged = qtutils.get_selection_list(
372 self.view.unstaged,
373 self.model.get_unstaged())
374 staged = qtutils.get_selection_list(
375 self.view.staged,
376 self.model.get_staged())
378 scrollbar = self.view.display_text.verticalScrollBar()
379 scrollvalue = scrollbar.value()
381 # save selected item
382 unstageditem = qtutils.get_selected_item(
383 self.view.unstaged,
384 self.model.get_unstaged())
386 stageditem = qtutils.get_selected_item(
387 self.view.staged,
388 self.model.get_staged())
390 # get new values
391 self.model.update_status()
393 # restore selection
394 update_staged = False
395 update_unstaged = False
396 updated_unstaged = self.model.get_unstaged()
397 updated_staged = self.model.get_staged()
399 for item in unstaged:
400 if item in updated_unstaged:
401 idx = updated_unstaged.index(item)
402 listitem = self.view.unstaged.item(idx)
403 if listitem:
404 listitem.setSelected(True)
405 self.view.unstaged\
406 .setItemSelected(listitem, True)
407 update_unstaged = True
408 self.view.unstaged.update()
409 for item in staged:
410 if item in updated_staged:
411 idx = updated_staged.index(item)
412 listitem = self.view.staged.item(idx)
413 if listitem:
414 listitem.setSelected(True)
415 self.view.staged\
416 .setItemSelected(listitem, True)
417 update_staged = True
419 # restore selected item
420 if update_staged and stageditem:
421 idx = updated_staged.index(stageditem)
422 item = self.view.staged.item(idx)
423 self.view.staged.setCurrentItem(item)
424 self.view_diff(True)
425 scrollbar.setValue(scrollvalue)
427 elif update_unstaged and unstageditem:
428 idx = updated_unstaged.index(unstageditem)
429 item = self.view.unstaged.item(idx)
430 self.view.unstaged.setCurrentItem(item)
431 self.view_diff(False)
432 scrollbar.setValue(scrollvalue)
434 self.view.setWindowTitle('%s [%s]' % (
435 self.model.get_project(),
436 self.model.get_branch()))
438 if self.model.has_squash_msg():
439 if self.model.get_commitmsg():
440 answer = qtutils.question(self.view,
441 self.tr('Import Commit Message?'),
442 self.tr('A commit message from an in-progress'
443 + ' merge was found.\nImport it?'))
445 if answer:
446 self.model.set_squash_msg()
447 else:
448 # Set the new commit message
449 self.model.set_squash_msg()
451 def push(self):
452 push_branches(self.model, self.view)
454 def show_diffstat(self):
455 """Show the diffstat from the latest commit."""
456 self.__diffgui_enabled = False
457 self.view.set_info(self.tr('Diffstat'))
458 self.view.set_display(self.model.diffstat())
460 def show_index(self):
461 self.__diffgui_enabled = False
462 self.view.set_info(self.tr('Index'))
463 self.view.set_display(self.model.diffindex())
465 #####################################################################
466 # diff gui
467 def process_diff_selection(self, items, widget,
468 cached=True, selected=False, reverse=True, noop=False):
470 filename = qtutils.get_selected_item(widget, items)
471 if not filename: return
472 parser = utils.DiffParser(self.model, filename=filename,
473 cached=cached)
474 offset, selection = self.view.diff_selection()
475 parser.process_diff_selection(selected, offset, selection)
476 self.rescan()
478 def stage_hunk(self):
479 self.process_diff_selection(
480 self.model.get_unstaged(),
481 self.view.unstaged,
482 cached=False)
484 def stage_hunk_selection(self):
485 self.process_diff_selection(
486 self.model.get_unstaged(),
487 self.view.unstaged,
488 cached=False,
489 selected=True)
491 def unstage_hunk(self, cached=True):
492 self.process_diff_selection(
493 self.model.get_staged(),
494 self.view.staged,
495 cached=True)
497 def unstage_hunk_selection(self):
498 self.process_diff_selection(
499 self.model.get_staged(),
500 self.view.staged,
501 cached=True,
502 selected=True)
504 # #######################################################################
505 # end diff gui
507 # *rest handles being called from different signals
508 def stage_selected(self,*rest):
509 """Use "git add" to add items to the git index.
510 This is a thin wrapper around map_to_listwidget."""
511 command = self.model.add_or_remove
512 widget = self.view.unstaged
513 items = self.model.get_unstaged()
514 self.map_to_listwidget(command, widget, items)
516 # *rest handles being called from different signals
517 def unstage_selected(self, *rest):
518 """Use "git reset" to remove items from the git index.
519 This is a thin wrapper around map_to_listwidget."""
520 command = self.model.reset
521 widget = self.view.staged
522 items = self.model.get_staged()
523 self.map_to_listwidget(command, widget, items)
525 def undo_changes(self):
526 """Reverts local changes back to whatever's in HEAD."""
527 widget = self.view.unstaged
528 items = self.model.get_unstaged()
529 potential_items = qtutils.get_selection_list(widget, items)
530 items_to_undo = []
531 untracked = self.model.get_untracked()
532 for item in potential_items:
533 if item not in untracked:
534 items_to_undo.append(item)
535 if items_to_undo:
536 answer = qtutils.question(self.view,
537 self.tr('Destroy Local Changes?'),
538 self.tr('This operation will drop all '
539 + ' uncommitted changes. Continue?'),
540 default=False)
542 if not answer: return
544 output = self.model.checkout('HEAD', '--',
545 *items_to_undo)
546 self.log('git checkout HEAD -- '
547 + ' '.join(items_to_undo)
548 + '\n' + output)
549 else:
550 msg = 'No files selected for checkout from HEAD.'
551 self.log(self.tr(msg))
553 def viz_all(self):
554 """Visualizes the entire git history using gitk."""
555 browser = self.model.get_global_ugit_historybrowser()
556 utils.fork(browser,'--all')
558 def viz_current(self):
559 """Visualizes the current branch's history using gitk."""
560 browser = self.model.get_global_ugit_historybrowser()
561 utils.fork(browser, self.model.get_branch())
563 def move_event(self, event):
564 defaults.X = event.pos().x()
565 defaults.Y = event.pos().y()
567 def resize_event(self, event):
568 defaults.WIDTH = event.size().width()
569 defaults.HEIGHT = event.size().height()
571 def load_gui_settings(self):
572 if not self.model.remember_gui_settings():
573 return
574 (w,h,x,y,
575 st0,st1,
576 sb0,sb1) = self.model.get_window_geom()
577 self.view.resize(w,h)
578 self.view.move(x,y)
580 def log(self, output, rescan=True, quiet=False):
581 """Logs output and optionally rescans for changes."""
582 qtutils.log(output, quiet=quiet, doraise=False)
583 if rescan: self.rescan()
585 def map_to_listwidget(self, command, widget, items):
586 """This is a helper method that retrieves the current
587 selection list, applies a command to that list,
588 displays a dialog showing the output of that command,
589 and calls rescan to pickup changes."""
590 apply_items = qtutils.get_selection_list(widget, items)
591 output = command(*apply_items)
592 self.log(output, quiet=True)
594 def unstaged_context_menu_event(self, event):
595 self.unstaged_context_menu_setup()
596 unstaged = self.view.unstaged
597 self.__unstaged_menu.exec_(unstaged.mapToGlobal(event.pos()))
599 def unstaged_context_menu_setup(self):
600 if self.__unstaged_menu: return
602 menu = self.__unstaged_menu = QMenu(self.view)
603 self.__stage_selected_action = menu.addAction(
604 self.tr('Stage Selected'), self.stage_selected)
605 self.__undo_changes_action = menu.addAction(
606 self.tr('Undo Local Changes'), self.undo_changes)
607 self.connect(self.__unstaged_menu, 'aboutToShow()',
608 self.unstaged_context_menu_about_to_show)
610 def unstaged_context_menu_about_to_show(self):
611 unstaged_item = qtutils.get_selected_item(
612 self.view.unstaged,
613 self.model.get_unstaged())
615 is_tracked = unstaged_item not in self.model.get_untracked()
617 enable_staging = bool(self.__diffgui_enabled
618 and unstaged_item)
619 enable_undo = enable_staging and is_tracked
621 self.__stage_selected_action.setEnabled(enable_staging)
622 self.__undo_changes_action.setEnabled(enable_undo)
624 def diff_context_menu_about_to_show(self):
625 unstaged_item = qtutils.get_selected_item(
626 self.view.unstaged,
627 self.model.get_unstaged())
629 is_tracked= unstaged_item not in self.model.get_untracked()
631 enable_staged= (
632 self.__diffgui_enabled
633 and unstaged_item
634 and not self.__staged_diff_in_view
635 and is_tracked)
637 enable_unstaged= (
638 self.__diffgui_enabled
639 and self.__staged_diff_in_view
640 and qtutils.get_selected_item(
641 self.view.staged,
642 self.model.get_staged()))
644 self.__stage_hunk_action.setEnabled(bool(enable_staged))
645 self.__stage_hunk_selection_action.setEnabled(bool(enable_staged))
647 self.__unstage_hunk_action.setEnabled(bool(enable_unstaged))
648 self.__unstage_hunk_selection_action.setEnabled(bool(enable_unstaged))
650 def diff_context_menu_event(self, event):
651 self.diff_context_menu_setup()
652 textedit = self.view.display_text
653 self.__diff_menu.exec_(textedit.mapToGlobal(event.pos()))
655 def diff_context_menu_setup(self):
656 if self.__diff_menu: return
658 menu = self.__diff_menu = QMenu(self.view)
659 self.__stage_hunk_action = menu.addAction(
660 self.tr('Stage Hunk For Commit'), self.stage_hunk)
662 self.__stage_hunk_selection_action = menu.addAction(
663 self.tr('Stage Selected Lines'),
664 self.stage_hunk_selection)
666 self.__unstage_hunk_action = menu.addAction(
667 self.tr('Unstage Hunk From Commit'),
668 self.unstage_hunk)
670 self.__unstage_hunk_selection_action = menu.addAction(
671 self.tr('Unstage Selected Lines'),
672 self.unstage_hunk_selection)
674 self.__copy_action = menu.addAction(
675 self.tr('Copy'), self.view.copy_display)
677 self.connect(self.__diff_menu, 'aboutToShow()',
678 self.diff_context_menu_about_to_show)
680 def select_commits_gui(self, title, revs, summaries):
681 return select_commits(self.model, self.view, title, revs, summaries)
683 def update_diff_font(self):
684 font = self.model.get_global_ugit_fontdiff()
685 if not font: return
686 qfont = QFont()
687 qfont.fromString(font)
688 self.view.display_text.setFont(qfont)
689 self.view.commitmsg.setFont(qfont)
691 def update_ui_font(self):
692 font = self.model.get_global_ugit_fontui()
693 if not font: return
694 qfont = QFont()
695 qfont.fromString(font)
696 QtGui.qApp.setFont(qfont)
698 def init_log_window(self):
699 branch, version = self.model.get_branch(), defaults.VERSION
700 qtutils.log(self.model.get_git_version()
701 + '\nugit version '+ version
702 + '\nCurrent Branch: '+ branch)
704 def start_inotify_thread(self):
705 # Do we have inotify? If not, return.
706 # Recommend installing inotify if we're on Linux.
707 self.inotify_thread = None
708 try:
709 from inotify import GitNotifier
710 qtutils.log(self.tr('inotify support: enabled'))
711 except ImportError:
712 import platform
713 if platform.system() == 'Linux':
715 msg = self.tr(
716 'inotify: disabled\n'
717 'Note: To enable inotify, '
718 'install python-pyinotify.\n')
720 plat = platform.platform().lower()
721 if 'debian' in plat or 'ubuntu' in plat:
722 msg += self.tr(
723 'On Debian or Ubuntu systems, '
724 'try: sudo apt-get install '
725 'python-pyinotify')
726 qtutils.log(msg)
728 return
730 # Start the notification thread
731 self.inotify_thread = GitNotifier(self, os.getcwd())
732 self.inotify_thread.start()