Renamed 'ugitlibs' to just plain 'ugit'
[ugit.git] / ugit / controllers.py
blobbdf00d19169f933626239de67d580fc8b3fff0ce
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(model, 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_load_commitmsg = self.load_commitmsg,
94 menu_quit = self.quit_app,
96 # Repository Menu
97 menu_visualize_current = self.viz_current,
98 menu_visualize_all = self.viz_all,
99 menu_show_revision = self.show_revision,
100 menu_browse_commits = self.browse_commits,
101 menu_browse_branch = self.browse_current,
102 menu_browse_other_branch = self.browse_other,
104 # Commit Menu
105 menu_rescan = self.rescan,
106 menu_create_branch = self.branch_create,
107 menu_delete_branch = self.branch_delete,
108 menu_checkout_branch = self.checkout_branch,
109 menu_rebase_branch = self.rebase,
110 menu_commit = self.commit,
111 menu_stage_selected = self.stage_selected,
112 menu_unstage_selected = self.unstage_selected,
113 menu_show_diffstat = self.show_diffstat,
114 menu_show_index = self.show_index,
115 menu_export_patches = self.export_patches,
116 menu_cherry_pick = self.cherry_pick,
117 # Edit Menu
118 menu_options = self.options,
121 # These are vanilla signal/slots since QObserver
122 # is already handling these signals.
123 self.connect(view.unstaged,
124 'itemDoubleClicked(QListWidgetItem*)',
125 self.stage_selected)
126 self.connect(view.staged,
127 'itemDoubleClicked(QListWidgetItem*)',
128 self.unstage_selected)
130 # Toolbar log button
131 self.connect(self.view.toolbar_show_log,
132 'triggered()', self.show_log)
134 self.connect(self.view.horizontal_checkbox,
135 'stateChanged(int)', self.flip_status)
137 # Delegate window events here
138 view.moveEvent = self.move_event
139 view.resizeEvent = self.resize_event
140 view.closeEvent = self.quit_app
141 view.staged.mousePressEvent = self.click_staged
142 view.unstaged.mousePressEvent = self.click_unstaged
144 self.init_log_window()
145 self.rescan()
146 self.load_gui_settings()
148 self.refresh_view()
149 self.start_inotify_thread()
151 self.connect(view.diff_dock,
152 'topLevelChanged(bool)',
153 lambda(b): self.setwindow(view.diff_dock, b))
155 self.connect(view.editor_dock,
156 'topLevelChanged(bool)',
157 lambda(b): self.setwindow(view.editor_dock, b))
159 self.connect(view.status_dock,
160 'topLevelChanged(bool)',
161 lambda(b): self.setwindow(view.status_dock, b))
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 def flip_status(self, value):
171 splitter = self.view.splitter
172 if value:
173 splitter.setOrientation(QtCore.Qt.Horizontal)
174 else:
175 splitter.setOrientation(QtCore.Qt.Vertical)
177 #####################################################################
178 # handle when the listitem icons are clicked
179 def click_event(self, widget, action_callback, event):
180 result = QtGui.QListWidget.mousePressEvent(widget, event)
181 xpos = event.pos().x()
182 if xpos > 5 and xpos < 20:
183 action_callback()
184 return result
186 def click_staged(self, event):
187 return self.click_event(
188 self.view.staged,
189 self.unstage_selected,
190 event)
192 def click_unstaged(self, event):
193 return self.click_event(
194 self.view.unstaged,
195 self.stage_selected,
196 event)
199 #####################################################################
200 # event() is called in response to messages from the inotify thread
201 def event(self, msg):
202 if msg.type() == defaults.INOTIFY_EVENT:
203 self.rescan()
204 return True
205 else:
206 return False
208 #####################################################################
209 # Actions triggered during model updates
211 def action_staged(self, widget):
212 qtutils.update_listwidget(widget,
213 self.model.get_staged(), staged=True)
215 def action_unstaged(self, widget):
216 qtutils.update_listwidget(widget,
217 self.model.get_modified(), staged=False)
219 if self.view.untracked_checkbox.isChecked():
220 qtutils.update_listwidget(widget,
221 self.model.get_untracked(),
222 staged=False,
223 append=True,
224 untracked=True)
226 #####################################################################
227 # Qt callbacks
229 def show_log(self, *rest):
230 qtutils.toggle_log_window()
232 def options(self):
233 update_options(self.model, self.view)
235 def branch_create(self):
236 if create_new_branch(self.model, self.view):
237 self.rescan()
239 def branch_delete(self):
240 branch = choose_branch('Delete Branch',
241 self.view, self.model.get_local_branches())
242 if not branch: return
243 self.log(self.model.delete_branch(branch))
245 def browse_current(self):
246 branch = self.model.get_branch()
247 browse_git_branch(self.model, self.view, branch)
249 def browse_other(self):
250 # Prompt for a branch to browse
251 branch = choose_branch('Browse Branch Files',
252 self.view, self.model.get_all_branches())
253 if not branch: return
254 # Launch the repobrowser
255 browse_git_branch(self.model, self.view, branch)
257 def checkout_branch(self):
258 branch = choose_branch('Checkout Branch',
259 self.view, self.model.get_local_branches())
260 if not branch: return
261 self.log(self.model.checkout(branch))
263 def browse_commits(self):
264 self.select_commits_gui(self.tr('Browse Commits'),
265 *self.model.log(all=True))
267 def show_revision(self):
268 find_revisions(self.model, self.view)
270 def cherry_pick(self):
271 commits = self.select_commits_gui(self.tr('Cherry-Pick Commits'),
272 *self.model.log(all=True))
273 if not commits: return
274 self.log(self.model.cherry_pick(commits))
276 def commit(self):
277 msg = self.model.get_commitmsg()
278 if not msg:
279 error_msg = self.tr(""
280 + "Please supply a commit message.\n"
281 + "\n"
282 + "A good commit message has the following format:\n"
283 + "\n"
284 + "- First line: Describe in one sentence what you did.\n"
285 + "- Second line: Blank\n"
286 + "- Remaining lines: Describe why this change is good.\n")
287 self.log(error_msg)
288 return
290 files = self.model.get_staged()
291 if not files:
292 error_msg = self.tr(""
293 + "No changes to commit.\n"
294 + "\n"
295 + "You must stage at least 1 file before you can commit.\n")
296 self.log(error_msg)
297 return
299 # Perform the commit
300 output = self.model.commit(
301 msg, amend=self.view.amend_radio.isChecked())
303 # Reset state
304 self.view.new_commit_radio.setChecked(True)
305 self.view.amend_radio.setChecked(False)
306 self.model.set_commitmsg('')
307 self.log(output)
309 def view_diff(self, staged=True):
310 self.__staged_diff_in_view = staged
311 if self.__staged_diff_in_view:
312 widget = self.view.staged
313 else:
314 widget = self.view.unstaged
315 row, selected = qtutils.get_selected_row(widget)
316 if not selected:
317 self.view.reset_display()
318 self.__diffgui_enabled = False
319 return
320 (diff,
321 status) = self.model.get_diff_and_status(row, staged=staged)
323 self.view.set_display(diff)
324 self.view.set_info(self.tr(status))
325 self.__diffgui_enabled = True
327 # use *rest to handle being called from different signals
328 def diff_staged(self, *rest):
329 self.view_diff(staged=True)
331 # use *rest to handle being called from different signals
332 def diff_unstaged(self, *rest):
333 self.view_diff(staged=False)
335 def export_patches(self):
336 (revs, summaries) = self.model.log()
337 commits = self.select_commits_gui(self.tr('Export Patches'),
338 revs, summaries)
339 if not commits: return
340 self.log(self.model.format_patch(commits))
342 def quit_app(self,*rest):
343 '''Save config settings and cleanup any inotify threads.'''
345 if self.model.save_at_exit():
346 self.model.save_gui_settings()
347 qtutils.close_log_window()
348 self.view.hide()
350 if not self.inotify_thread: return
351 if not self.inotify_thread.isRunning(): return
353 self.inotify_thread.abort = True
354 self.inotify_thread.terminate()
355 self.inotify_thread.wait()
357 def load_commitmsg(self):
358 file = qtutils.open_dialog(self.view,
359 'Load Commit Message...', defaults.DIRECTORY)
361 if file:
362 defaults.DIRECTORY = os.path.dirname(file)
363 slushy = utils.slurp(file)
364 if slushy: self.model.set_commitmsg(slushy)
366 def rebase(self):
367 branch = choose_branch('Rebase Branch',
368 self.view, self.model.get_local_branches())
369 if not branch: return
370 self.log(self.model.rebase(branch))
372 # use *rest to handle being called from the checkbox signal
373 def rescan(self, *rest):
374 '''Populates view widgets with results from "git status."'''
376 # save entire selection
377 unstaged = qtutils.get_selection_list(
378 self.view.unstaged,
379 self.model.get_unstaged())
380 staged = qtutils.get_selection_list(
381 self.view.staged,
382 self.model.get_staged())
384 scrollbar = self.view.display_text.verticalScrollBar()
385 scrollvalue = scrollbar.value()
387 # save selected item
388 unstageditem = qtutils.get_selected_item(
389 self.view.unstaged,
390 self.model.get_unstaged())
392 stageditem = qtutils.get_selected_item(
393 self.view.staged,
394 self.model.get_staged())
396 # get new values
397 self.model.update_status()
399 # restore selection
400 update_staged = False
401 update_unstaged = False
402 updated_unstaged = self.model.get_unstaged()
403 updated_staged = self.model.get_staged()
405 for item in unstaged:
406 if item in updated_unstaged:
407 idx = updated_unstaged.index(item)
408 listitem = self.view.unstaged.item(idx)
409 if listitem:
410 listitem.setSelected(True)
411 self.view.unstaged\
412 .setItemSelected(listitem, True)
413 update_unstaged = True
414 self.view.unstaged.update()
415 for item in staged:
416 if item in updated_staged:
417 idx = updated_staged.index(item)
418 listitem = self.view.staged.item(idx)
419 if listitem:
420 listitem.setSelected(True)
421 self.view.staged\
422 .setItemSelected(listitem, True)
423 update_staged = True
425 # restore selected item
426 if update_staged and stageditem:
427 idx = updated_staged.index(stageditem)
428 item = self.view.staged.item(idx)
429 self.view.staged.setCurrentItem(item)
430 self.view_diff(True)
431 scrollbar.setValue(scrollvalue)
433 elif update_unstaged and unstageditem:
434 idx = updated_unstaged.index(unstageditem)
435 item = self.view.unstaged.item(idx)
436 self.view.unstaged.setCurrentItem(item)
437 self.view_diff(False)
438 scrollbar.setValue(scrollvalue)
440 self.view.setWindowTitle('%s [%s]' % (
441 self.model.get_project(),
442 self.model.get_branch()))
444 if self.model.has_squash_msg():
445 if self.model.get_commitmsg():
446 answer = qtutils.question(self.view,
447 self.tr('Import Commit Message?'),
448 self.tr('A commit message from an in-progress'
449 + ' merge was found.\nImport it?'))
451 if answer:
452 self.model.set_squash_msg()
453 else:
454 # Set the new commit message
455 self.model.set_squash_msg()
457 def push(self):
458 push_branches(self.model, self.view)
460 def show_diffstat(self):
461 '''Show the diffstat from the latest commit.'''
462 self.__diffgui_enabled = False
463 self.view.set_info(self.tr('Diffstat'))
464 self.view.set_display(self.model.diffstat())
466 def show_index(self):
467 self.__diffgui_enabled = False
468 self.view.set_info(self.tr('Index'))
469 self.view.set_display(self.model.diffindex())
471 #####################################################################
472 # diff gui
473 def process_diff_selection(self, items, widget,
474 cached=True, selected=False, reverse=True, noop=False):
476 filename = qtutils.get_selected_item(widget, items)
477 if not filename: return
478 parser = utils.DiffParser(self.model, filename=filename,
479 cached=cached)
480 offset, selection = self.view.diff_selection()
481 parser.process_diff_selection(selected, offset, selection)
482 self.rescan()
484 def stage_hunk(self):
485 self.process_diff_selection(
486 self.model.get_unstaged(),
487 self.view.unstaged,
488 cached=False)
490 def stage_hunk_selection(self):
491 self.process_diff_selection(
492 self.model.get_unstaged(),
493 self.view.unstaged,
494 cached=False,
495 selected=True)
497 def unstage_hunk(self, cached=True):
498 self.process_diff_selection(
499 self.model.get_staged(),
500 self.view.staged,
501 cached=True)
503 def unstage_hunk_selection(self):
504 self.process_diff_selection(
505 self.model.get_staged(),
506 self.view.staged,
507 cached=True,
508 selected=True)
510 # #######################################################################
511 # end diff gui
513 # *rest handles being called from different signals
514 def stage_selected(self,*rest):
515 '''Use "git add" to add items to the git index.
516 This is a thin wrapper around apply_to_list.'''
517 command = self.model.add_or_remove
518 widget = self.view.unstaged
519 items = self.model.get_unstaged()
520 self.apply_to_list(command,widget,items)
522 # *rest handles being called from different signals
523 def unstage_selected(self, *rest):
524 '''Use "git reset" to remove items from the git index.
525 This is a thin wrapper around apply_to_list.'''
526 command = self.model.reset
527 widget = self.view.staged
528 items = self.model.get_staged()
529 self.apply_to_list(command, widget, items)
531 def undo_changes(self):
532 """Reverts local changes back to whatever's in HEAD."""
533 widget = self.view.unstaged
534 items = self.model.get_unstaged()
535 potential_items = qtutils.get_selection_list(widget, items)
536 items_to_undo = []
537 untracked = self.model.get_untracked()
538 for item in potential_items:
539 if item not in untracked:
540 items_to_undo.append(item)
541 if items_to_undo:
542 answer = qtutils.question(self.view,
543 self.tr('Destroy Local Changes?'),
544 self.tr('This operation will drop all '
545 + ' uncommitted changes. Continue?'),
546 default=False)
548 if not answer: return
550 output = self.model.checkout('HEAD', '--',
551 *items_to_undo)
552 self.log('git checkout HEAD -- '
553 + ' '.join(items_to_undo)
554 + '\n' + output)
555 else:
556 msg = 'No files selected for checkout from HEAD.'
557 self.log(self.tr(msg))
559 def viz_all(self):
560 '''Visualizes the entire git history using gitk.'''
561 browser = self.model.get_global_ugit_historybrowser()
562 utils.fork(browser,'--all')
564 def viz_current(self):
565 '''Visualizes the current branch's history using gitk.'''
566 browser = self.model.get_global_ugit_historybrowser()
567 utils.fork(browser, self.model.get_branch())
569 def move_event(self, event):
570 defaults.X = event.pos().x()
571 defaults.Y = event.pos().y()
573 def resize_event(self, event):
574 defaults.WIDTH = event.size().width()
575 defaults.HEIGHT = event.size().height()
577 def load_gui_settings(self):
578 if not self.model.remember_gui_settings():
579 return
580 (w,h,x,y,
581 st0,st1,
582 sb0,sb1) = self.model.get_window_geom()
583 self.view.resize(w,h)
584 self.view.move(x,y)
586 def log(self, output, rescan=True, quiet=False):
587 '''Logs output and optionally rescans for changes.'''
588 qtutils.log(output, quiet=quiet, doraise=False)
589 if rescan: self.rescan()
591 def apply_to_list(self, command, widget, items):
592 '''This is a helper method that retrieves the current
593 selection list, applies a command to that list,
594 displays a dialog showing the output of that command,
595 and calls rescan to pickup changes.'''
596 apply_items = qtutils.get_selection_list(widget, items)
597 output = command(apply_items)
598 self.log(output, quiet=True)
600 def unstaged_context_menu_event(self, event):
601 self.unstaged_context_menu_setup()
602 unstaged = self.view.unstaged
603 self.__unstaged_menu.exec_(unstaged.mapToGlobal(event.pos()))
605 def unstaged_context_menu_setup(self):
606 if self.__unstaged_menu: return
608 menu = self.__unstaged_menu = QMenu(self.view)
609 self.__stage_selected_action = menu.addAction(
610 self.tr('Stage Selected'), self.stage_selected)
611 self.__undo_changes_action = menu.addAction(
612 self.tr('Undo Local Changes'), self.undo_changes)
613 self.connect(self.__unstaged_menu, 'aboutToShow()',
614 self.unstaged_context_menu_about_to_show)
616 def unstaged_context_menu_about_to_show(self):
617 unstaged_item = qtutils.get_selected_item(
618 self.view.unstaged,
619 self.model.get_unstaged())
621 is_tracked = unstaged_item not in self.model.get_untracked()
623 enable_staging = bool(self.__diffgui_enabled
624 and unstaged_item)
625 enable_undo = enable_staging and is_tracked
627 self.__stage_selected_action.setEnabled(enable_staging)
628 self.__undo_changes_action.setEnabled(enable_undo)
630 def diff_context_menu_about_to_show(self):
631 unstaged_item = qtutils.get_selected_item(
632 self.view.unstaged,
633 self.model.get_unstaged())
635 is_tracked= unstaged_item not in self.model.get_untracked()
637 enable_staged= (
638 self.__diffgui_enabled
639 and unstaged_item
640 and not self.__staged_diff_in_view
641 and is_tracked)
643 enable_unstaged= (
644 self.__diffgui_enabled
645 and self.__staged_diff_in_view
646 and qtutils.get_selected_item(
647 self.view.staged,
648 self.model.get_staged()))
650 self.__stage_hunk_action.setEnabled(bool(enable_staged))
651 self.__stage_hunk_selection_action.setEnabled(bool(enable_staged))
653 self.__unstage_hunk_action.setEnabled(bool(enable_unstaged))
654 self.__unstage_hunk_selection_action.setEnabled(bool(enable_unstaged))
656 def diff_context_menu_event(self, event):
657 self.diff_context_menu_setup()
658 textedit = self.view.display_text
659 self.__diff_menu.exec_(textedit.mapToGlobal(event.pos()))
661 def diff_context_menu_setup(self):
662 if self.__diff_menu: return
664 menu = self.__diff_menu = QMenu(self.view)
665 self.__stage_hunk_action = menu.addAction(
666 self.tr('Stage Hunk For Commit'), self.stage_hunk)
668 self.__stage_hunk_selection_action = menu.addAction(
669 self.tr('Stage Selected Lines'),
670 self.stage_hunk_selection)
672 self.__unstage_hunk_action = menu.addAction(
673 self.tr('Unstage Hunk From Commit'),
674 self.unstage_hunk)
676 self.__unstage_hunk_selection_action = menu.addAction(
677 self.tr('Unstage Selected Lines'),
678 self.unstage_hunk_selection)
680 self.__copy_action = menu.addAction(
681 self.tr('Copy'), self.view.copy_display)
683 self.connect(self.__diff_menu, 'aboutToShow()',
684 self.diff_context_menu_about_to_show)
686 def select_commits_gui(self, title, revs, summaries):
687 return select_commits(self.model, self.view, title, revs, summaries)
689 def update_diff_font(self):
690 font = self.model.get_global_ugit_fontdiff()
691 if not font: return
692 qfont = QFont()
693 qfont.fromString(font)
694 self.view.display_text.setFont(qfont)
695 self.view.commitmsg.setFont(qfont)
697 def update_ui_font(self):
698 font = self.model.get_global_ugit_fontui()
699 if not font: return
700 qfont = QFont()
701 qfont.fromString(font)
702 QtGui.qApp.setFont(qfont)
704 def init_log_window(self):
705 branch, version = self.model.get_branch(), defaults.VERSION
706 qtutils.log(self.model.get_git_version()
707 + '\nugit version '+ version
708 + '\nCurrent Branch: '+ branch)
710 def start_inotify_thread(self):
711 # Do we have inotify? If not, return.
712 # Recommend installing inotify if we're on Linux.
713 self.inotify_thread = None
714 try:
715 from inotify import GitNotifier
716 qtutils.log(self.tr('inotify support: enabled'))
717 except ImportError:
718 import platform
719 if platform.system() == 'Linux':
721 msg = self.tr(
722 'inotify: disabled\n'
723 'Note: To enable inotify, '
724 'install python-pyinotify.\n')
726 plat = platform.platform().lower()
727 if 'debian' in plat or 'ubuntu' in plat:
728 msg += self.tr(
729 'On Debian or Ubuntu systems, '
730 'try: sudo apt-get install '
731 'python-pyinotify')
732 qtutils.log(msg)
734 return
736 # Start the notification thread
737 self.inotify_thread = GitNotifier(self, os.getcwd())
738 self.inotify_thread.start()