Add a merge button to the uber-search gui
[ugit.git] / ugit / controllers.py
blob7a273a5df2c28772f419c1cfa96e7d3b477831d4
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 search_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,
94 # menu_load_bookmark = self.load_bookmark,
95 # menu_save_bookmark = self.save_bookmark,
96 # menu_manage_bookmarks = self.manage_bookmarks,
98 # Seaarch Menu
99 menu_search_revision = self.search_revision,
100 # menu_search_revision_range = self.search_revision_range,
101 # menu_search_messages = self.search_messages,
102 # menu_search_date = self.search_date,
103 # menu_search_date_range = self.search_date_range,
104 # menu_search_diffs = self.search_diffs,
106 # Repository Menu
107 menu_visualize_current = self.viz_current,
108 menu_visualize_all = self.viz_all,
109 menu_browse_commits = self.browse_commits,
110 menu_browse_branch = self.browse_current,
111 menu_browse_other_branch = self.browse_other,
113 # Commit Menu
114 menu_rescan = self.rescan,
115 menu_create_branch = self.branch_create,
116 menu_delete_branch = self.branch_delete,
117 menu_checkout_branch = self.checkout_branch,
118 menu_rebase_branch = self.rebase,
119 menu_commit = self.commit,
120 menu_stage_selected = self.stage_selected,
121 menu_unstage_selected = self.unstage_selected,
122 menu_show_diffstat = self.show_diffstat,
123 menu_show_index = self.show_index,
124 menu_export_patches = self.export_patches,
125 menu_load_commitmsg = self.load_commitmsg,
126 menu_cherry_pick = self.cherry_pick,
128 # Edit Menu
129 menu_options = self.options,
132 # Delegate window events here
133 view.moveEvent = self.move_event
134 view.resizeEvent = self.resize_event
135 view.closeEvent = self.quit_app
136 view.staged.mousePressEvent = self.click_staged
137 view.unstaged.mousePressEvent = self.click_unstaged
139 # These are vanilla signal/slots since QObserver
140 # is already handling these signals.
141 self.connect(view.unstaged,
142 'itemDoubleClicked(QListWidgetItem*)',
143 self.stage_selected)
144 self.connect(view.staged,
145 'itemDoubleClicked(QListWidgetItem*)',
146 self.unstage_selected)
148 # Toolbar log button
149 self.connect(self.view.toolbar_show_log,
150 'triggered()', self.show_log)
152 self.connect(view.diff_dock,
153 'topLevelChanged(bool)',
154 lambda(b): self.setwindow(view.diff_dock, b))
156 self.connect(view.editor_dock,
157 'topLevelChanged(bool)',
158 lambda(b): self.setwindow(view.editor_dock, b))
160 self.connect(view.status_dock,
161 'topLevelChanged(bool)',
162 lambda(b): self.setwindow(view.status_dock, b))
164 self.init_log_window()
165 self.load_gui_settings()
166 self.rescan()
167 self.refresh_view(
168 'global_ugit_fontdiff',
169 'global_ugit_fontui',
171 self.start_inotify_thread()
173 def setwindow(self, dock, isfloating):
174 if isfloating:
175 flags = ( QtCore.Qt.Window
176 | QtCore.Qt.FramelessWindowHint )
177 dock.setWindowFlags( flags )
178 dock.show()
180 #####################################################################
181 # handle when the listitem icons are clicked
182 def click_event(self, widget, action_callback, event):
183 result = QtGui.QListWidget.mousePressEvent(widget, event)
184 xpos = event.pos().x()
185 if xpos > 5 and xpos < 20:
186 action_callback()
187 return result
189 def click_staged(self, event):
190 return self.click_event(
191 self.view.staged,
192 self.unstage_selected,
193 event)
195 def click_unstaged(self, event):
196 return self.click_event(
197 self.view.unstaged,
198 self.stage_selected,
199 event)
202 #####################################################################
203 # event() is called in response to messages from the inotify thread
204 def event(self, msg):
205 if msg.type() == defaults.INOTIFY_EVENT:
206 self.rescan()
207 return True
208 else:
209 return False
211 #####################################################################
212 # Actions triggered during model updates
214 def action_staged(self, widget):
215 qtutils.update_listwidget(widget,
216 self.model.get_staged(), staged=True)
218 def action_unstaged(self, widget):
219 qtutils.update_listwidget(widget,
220 self.model.get_modified(), staged=False)
222 if self.view.untracked_checkbox.isChecked():
223 qtutils.update_listwidget(widget,
224 self.model.get_untracked(),
225 staged=False,
226 append=True,
227 untracked=True)
229 #####################################################################
230 # Qt callbacks
232 def show_log(self, *rest):
233 qtutils.toggle_log_window()
235 def options(self):
236 update_options(self.model, self.view)
238 def branch_create(self):
239 if create_new_branch(self.model, self.view):
240 self.rescan()
242 def branch_delete(self):
243 branch = choose_branch('Delete Branch',
244 self.view, self.model.get_local_branches())
245 if not branch: return
246 self.log(self.model.delete_branch(branch))
248 def browse_current(self):
249 branch = self.model.get_branch()
250 browse_git_branch(self.model, self.view, branch)
252 def browse_other(self):
253 # Prompt for a branch to browse
254 branch = choose_branch('Browse Branch Files',
255 self.view, self.model.get_all_branches())
256 if not branch: return
257 # Launch the repobrowser
258 browse_git_branch(self.model, self.view, branch)
260 def checkout_branch(self):
261 branch = choose_branch('Checkout Branch',
262 self.view, self.model.get_local_branches())
263 if not branch: return
264 self.log(self.model.checkout(branch))
266 def browse_commits(self):
267 self.select_commits_gui(self.tr('Browse Commits'),
268 *self.model.log_helper(all=True))
270 def search_revision(self):
271 search_revisions(self.model, self.view)
273 def cherry_pick(self):
274 commits = self.select_commits_gui(self.tr('Cherry-Pick Commits'),
275 *self.model.log_helper(all=True))
276 if not commits: return
277 self.log(self.model.cherry_pick_list(commits))
279 def commit(self):
280 msg = self.model.get_commitmsg()
281 if not msg:
282 error_msg = self.tr(""
283 + "Please supply a commit message.\n"
284 + "\n"
285 + "A good commit message has the following format:\n"
286 + "\n"
287 + "- First line: Describe in one sentence what you did.\n"
288 + "- Second line: Blank\n"
289 + "- Remaining lines: Describe why this change is good.\n")
290 self.log(error_msg)
291 return
293 files = self.model.get_staged()
294 if not files:
295 error_msg = self.tr(""
296 + "No changes to commit.\n"
297 + "\n"
298 + "You must stage at least 1 file before you can commit.\n")
299 self.log(error_msg)
300 return
302 # Perform the commit
303 output = self.model.commit_with_msg(
304 msg, amend=self.view.amend_radio.isChecked())
306 # Reset state
307 self.view.new_commit_radio.setChecked(True)
308 self.view.amend_radio.setChecked(False)
309 self.model.set_commitmsg('')
310 self.log(output)
312 def view_diff(self, staged=True):
313 self.__staged_diff_in_view = staged
314 if self.__staged_diff_in_view:
315 widget = self.view.staged
316 else:
317 widget = self.view.unstaged
318 row, selected = qtutils.get_selected_row(widget)
319 if not selected:
320 self.view.reset_display()
321 self.__diffgui_enabled = False
322 return
323 (diff,
324 status) = self.model.get_diff_and_status(row, staged=staged)
326 self.view.set_display(diff)
327 self.view.set_info(self.tr(status))
328 self.view.diff_dock.raise_()
329 self.__diffgui_enabled = True
331 # use *rest to handle being called from different signals
332 def diff_staged(self, *rest):
333 self.view_diff(staged=True)
335 # use *rest to handle being called from different signals
336 def diff_unstaged(self, *rest):
337 self.view_diff(staged=False)
339 def export_patches(self):
340 (revs, summaries) = self.model.log_helper()
341 commits = self.select_commits_gui(self.tr('Export Patches'),
342 revs, summaries)
343 if not commits: return
344 self.log(self.model.format_patch_helper(*commits))
346 def quit_app(self,*rest):
347 """Save config settings and cleanup any inotify threads."""
349 if self.model.save_at_exit():
350 self.model.save_gui_settings()
351 qtutils.close_log_window()
352 self.view.hide()
354 if not self.inotify_thread: return
355 if not self.inotify_thread.isRunning(): return
357 self.inotify_thread.abort = True
358 self.inotify_thread.terminate()
359 self.inotify_thread.wait()
361 def load_commitmsg(self):
362 file = qtutils.open_dialog(self.view,
363 'Load Commit Message...', defaults.DIRECTORY)
365 if file:
366 defaults.DIRECTORY = os.path.dirname(file)
367 slushy = utils.slurp(file)
368 if slushy: self.model.set_commitmsg(slushy)
370 def rebase(self):
371 branch = choose_branch('Rebase Branch',
372 self.view, self.model.get_local_branches())
373 if not branch: return
374 self.log(self.model.rebase(branch))
376 # use *rest to handle being called from the checkbox signal
377 def rescan(self, *rest):
378 '''Populates view widgets with results from "git status."'''
380 # save entire selection
381 unstaged = qtutils.get_selection_list(
382 self.view.unstaged,
383 self.model.get_unstaged())
384 staged = qtutils.get_selection_list(
385 self.view.staged,
386 self.model.get_staged())
388 scrollbar = self.view.display_text.verticalScrollBar()
389 scrollvalue = scrollbar.value()
391 # save selected item
392 unstageditem = qtutils.get_selected_item(
393 self.view.unstaged,
394 self.model.get_unstaged())
396 stageditem = qtutils.get_selected_item(
397 self.view.staged,
398 self.model.get_staged())
400 # get new values
401 self.model.update_status()
403 # restore selection
404 update_staged = False
405 update_unstaged = False
406 updated_unstaged = self.model.get_unstaged()
407 updated_staged = self.model.get_staged()
409 for item in unstaged:
410 if item in updated_unstaged:
411 idx = updated_unstaged.index(item)
412 listitem = self.view.unstaged.item(idx)
413 if listitem:
414 listitem.setSelected(True)
415 self.view.unstaged\
416 .setItemSelected(listitem, True)
417 update_unstaged = True
418 self.view.unstaged.update()
419 for item in staged:
420 if item in updated_staged:
421 idx = updated_staged.index(item)
422 listitem = self.view.staged.item(idx)
423 if listitem:
424 listitem.setSelected(True)
425 self.view.staged\
426 .setItemSelected(listitem, True)
427 update_staged = True
429 # restore selected item
430 if update_staged and stageditem:
431 idx = updated_staged.index(stageditem)
432 item = self.view.staged.item(idx)
433 self.view.staged.setCurrentItem(item)
434 self.view_diff(True)
435 scrollbar.setValue(scrollvalue)
437 elif update_unstaged and unstageditem:
438 idx = updated_unstaged.index(unstageditem)
439 item = self.view.unstaged.item(idx)
440 self.view.unstaged.setCurrentItem(item)
441 self.view_diff(False)
442 scrollbar.setValue(scrollvalue)
444 self.view.setWindowTitle('%s [%s]' % (
445 self.model.get_project(),
446 self.model.get_branch()))
448 if self.model.has_squash_msg():
449 if self.model.get_commitmsg():
450 answer = qtutils.question(self.view,
451 self.tr('Import Commit Message?'),
452 self.tr('A commit message from an in-progress'
453 + ' merge was found.\nImport it?'))
455 if answer:
456 self.model.set_squash_msg()
457 else:
458 # Set the new commit message
459 self.model.set_squash_msg()
461 def push(self):
462 push_branches(self.model, self.view)
464 def show_diffstat(self):
465 """Show the diffstat from the latest commit."""
466 self.__diffgui_enabled = False
467 self.view.set_info(self.tr('Diffstat'))
468 self.view.set_display(self.model.diffstat())
470 def show_index(self):
471 self.__diffgui_enabled = False
472 self.view.set_info(self.tr('Index'))
473 self.view.set_display(self.model.diffindex())
475 #####################################################################
476 # diff gui
477 def process_diff_selection(self, items, widget,
478 cached=True, selected=False, reverse=True, noop=False):
480 filename = qtutils.get_selected_item(widget, items)
481 if not filename: return
482 parser = utils.DiffParser(self.model, filename=filename,
483 cached=cached)
484 offset, selection = self.view.diff_selection()
485 parser.process_diff_selection(selected, offset, selection)
486 self.rescan()
488 def stage_hunk(self):
489 self.process_diff_selection(
490 self.model.get_unstaged(),
491 self.view.unstaged,
492 cached=False)
494 def stage_hunk_selection(self):
495 self.process_diff_selection(
496 self.model.get_unstaged(),
497 self.view.unstaged,
498 cached=False,
499 selected=True)
501 def unstage_hunk(self, cached=True):
502 self.process_diff_selection(
503 self.model.get_staged(),
504 self.view.staged,
505 cached=True)
507 def unstage_hunk_selection(self):
508 self.process_diff_selection(
509 self.model.get_staged(),
510 self.view.staged,
511 cached=True,
512 selected=True)
514 # #######################################################################
515 # end diff gui
517 # *rest handles being called from different signals
518 def stage_selected(self,*rest):
519 """Use "git add" to add items to the git index.
520 This is a thin wrapper around map_to_listwidget."""
521 command = self.model.add_or_remove
522 widget = self.view.unstaged
523 items = self.model.get_unstaged()
524 self.map_to_listwidget(command, widget, items)
526 # *rest handles being called from different signals
527 def unstage_selected(self, *rest):
528 """Use "git reset" to remove items from the git index.
529 This is a thin wrapper around map_to_listwidget."""
530 command = self.model.reset
531 widget = self.view.staged
532 items = self.model.get_staged()
533 self.map_to_listwidget(command, widget, items)
535 def undo_changes(self):
536 """Reverts local changes back to whatever's in HEAD."""
537 widget = self.view.unstaged
538 items = self.model.get_unstaged()
539 potential_items = qtutils.get_selection_list(widget, items)
540 items_to_undo = []
541 untracked = self.model.get_untracked()
542 for item in potential_items:
543 if item not in untracked:
544 items_to_undo.append(item)
545 if items_to_undo:
546 answer = qtutils.question(self.view,
547 self.tr('Destroy Local Changes?'),
548 self.tr('This operation will drop all '
549 + ' uncommitted changes. Continue?'),
550 default=False)
552 if not answer: return
554 output = self.model.checkout('HEAD', '--',
555 *items_to_undo)
556 self.log('git checkout HEAD -- '
557 + ' '.join(items_to_undo)
558 + '\n' + output)
559 else:
560 msg = 'No files selected for checkout from HEAD.'
561 self.log(self.tr(msg))
563 def viz_all(self):
564 """Visualizes the entire git history using gitk."""
565 browser = self.model.get_global_ugit_historybrowser()
566 utils.fork(browser,'--all')
568 def viz_current(self):
569 """Visualizes the current branch's history using gitk."""
570 browser = self.model.get_global_ugit_historybrowser()
571 utils.fork(browser, self.model.get_branch())
573 def move_event(self, event):
574 defaults.X = event.pos().x()
575 defaults.Y = event.pos().y()
577 def resize_event(self, event):
578 defaults.WIDTH = event.size().width()
579 defaults.HEIGHT = event.size().height()
581 def load_gui_settings(self):
582 if not self.model.remember_gui_settings():
583 return
584 (w,h,x,y,
585 st0,st1,
586 sb0,sb1) = self.model.get_window_geom()
587 self.view.resize(w,h)
588 self.view.move(x,y)
590 def log(self, output, rescan=True, quiet=False):
591 """Logs output and optionally rescans for changes."""
592 qtutils.log(output, quiet=quiet, doraise=False)
593 if rescan: self.rescan()
595 def map_to_listwidget(self, command, widget, items):
596 """This is a helper method that retrieves the current
597 selection list, applies a command to that list,
598 displays a dialog showing the output of that command,
599 and calls rescan to pickup changes."""
600 apply_items = qtutils.get_selection_list(widget, items)
601 output = command(*apply_items)
602 self.log(output, quiet=True)
604 def unstaged_context_menu_event(self, event):
605 self.unstaged_context_menu_setup()
606 unstaged = self.view.unstaged
607 self.__unstaged_menu.exec_(unstaged.mapToGlobal(event.pos()))
609 def unstaged_context_menu_setup(self):
610 if self.__unstaged_menu: return
612 menu = self.__unstaged_menu = QMenu(self.view)
613 self.__stage_selected_action = menu.addAction(
614 self.tr('Stage Selected'), self.stage_selected)
615 self.__undo_changes_action = menu.addAction(
616 self.tr('Undo Local Changes'), self.undo_changes)
617 self.connect(self.__unstaged_menu, 'aboutToShow()',
618 self.unstaged_context_menu_about_to_show)
620 def unstaged_context_menu_about_to_show(self):
621 unstaged_item = qtutils.get_selected_item(
622 self.view.unstaged,
623 self.model.get_unstaged())
625 is_tracked = unstaged_item not in self.model.get_untracked()
627 enable_staging = bool(self.__diffgui_enabled
628 and unstaged_item)
629 enable_undo = enable_staging and is_tracked
631 self.__stage_selected_action.setEnabled(enable_staging)
632 self.__undo_changes_action.setEnabled(enable_undo)
634 def diff_context_menu_about_to_show(self):
635 unstaged_item = qtutils.get_selected_item(
636 self.view.unstaged,
637 self.model.get_unstaged())
639 is_tracked= unstaged_item not in self.model.get_untracked()
641 enable_staged= (
642 self.__diffgui_enabled
643 and unstaged_item
644 and not self.__staged_diff_in_view
645 and is_tracked)
647 enable_unstaged= (
648 self.__diffgui_enabled
649 and self.__staged_diff_in_view
650 and qtutils.get_selected_item(
651 self.view.staged,
652 self.model.get_staged()))
654 self.__stage_hunk_action.setEnabled(bool(enable_staged))
655 self.__stage_hunk_selection_action.setEnabled(bool(enable_staged))
657 self.__unstage_hunk_action.setEnabled(bool(enable_unstaged))
658 self.__unstage_hunk_selection_action.setEnabled(bool(enable_unstaged))
660 def diff_context_menu_event(self, event):
661 self.diff_context_menu_setup()
662 textedit = self.view.display_text
663 self.__diff_menu.exec_(textedit.mapToGlobal(event.pos()))
665 def diff_context_menu_setup(self):
666 if self.__diff_menu: return
668 menu = self.__diff_menu = QMenu(self.view)
669 self.__stage_hunk_action = menu.addAction(
670 self.tr('Stage Hunk For Commit'), self.stage_hunk)
672 self.__stage_hunk_selection_action = menu.addAction(
673 self.tr('Stage Selected Lines'),
674 self.stage_hunk_selection)
676 self.__unstage_hunk_action = menu.addAction(
677 self.tr('Unstage Hunk From Commit'),
678 self.unstage_hunk)
680 self.__unstage_hunk_selection_action = menu.addAction(
681 self.tr('Unstage Selected Lines'),
682 self.unstage_hunk_selection)
684 self.__copy_action = menu.addAction(
685 self.tr('Copy'), self.view.copy_display)
687 self.connect(self.__diff_menu, 'aboutToShow()',
688 self.diff_context_menu_about_to_show)
690 def select_commits_gui(self, title, revs, summaries):
691 return select_commits(self.model, self.view, title, revs, summaries)
693 def update_diff_font(self):
694 font = self.model.get_global_ugit_fontdiff()
695 if not font: return
696 qfont = QFont()
697 qfont.fromString(font)
698 self.view.display_text.setFont(qfont)
699 self.view.commitmsg.setFont(qfont)
701 def update_ui_font(self):
702 font = self.model.get_global_ugit_fontui()
703 if not font: return
704 qfont = QFont()
705 qfont.fromString(font)
706 QtGui.qApp.setFont(qfont)
708 def init_log_window(self):
709 branch, version = self.model.get_branch(), defaults.VERSION
710 qtutils.log(self.model.get_git_version()
711 + '\nugit version '+ version
712 + '\nCurrent Branch: '+ branch)
714 def start_inotify_thread(self):
715 # Do we have inotify? If not, return.
716 # Recommend installing inotify if we're on Linux.
717 self.inotify_thread = None
718 try:
719 from inotify import GitNotifier
720 qtutils.log(self.tr('inotify support: enabled'))
721 except ImportError:
722 import platform
723 if platform.system() == 'Linux':
725 msg = self.tr(
726 'inotify: disabled\n'
727 'Note: To enable inotify, '
728 'install python-pyinotify.\n')
730 plat = platform.platform().lower()
731 if 'debian' in plat or 'ubuntu' in plat:
732 msg += self.tr(
733 'On Debian or Ubuntu systems, '
734 'try: sudo apt-get install '
735 'python-pyinotify')
736 qtutils.log(msg)
738 return
740 # Start the notification thread
741 self.inotify_thread = GitNotifier(self, os.getcwd())
742 self.inotify_thread.start()