layout: move views and controllers into a subdirectory
[ugit.git] / ugit / controllers / __init__.py
blob74ee740c51f405583a220c4f0293a167a911156b
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 from ugit import utils
13 from ugit import qtutils
14 from ugit import defaults
15 from ugit.qobserver import QObserver
17 from push import push_branches
18 from util import choose_branch
19 from util import select_commits
20 from util import search_revisions
21 from util import update_options
22 from util import log_window
23 from repobrowser import browse_git_branch
24 from createbranch import create_new_branch
26 class Controller(QObserver):
27 """Controller manages the interaction between the model and views."""
29 def __init__(self, model, view):
30 QObserver.__init__(self, model, view)
32 # parent-less log window
33 qtutils.LOGGER = log_window(QtGui.qApp.activeWindow())
35 # Avoids inotify floods from e.g. make
36 self.__last_inotify_event = time.time()
38 # The unstaged list context menu
39 self.__unstaged_menu = None
41 # The diff-display context menu
42 self.__diff_menu = None
43 self.__staged_diff_in_view = True
44 self.__diffgui_enabled = True
46 # Unstaged changes context menu
47 view.unstaged.contextMenuEvent = self.unstaged_context_menu_event
49 # Diff display context menu
50 view.display_text.contextMenuEvent = self.diff_context_menu_event
52 # Binds model params to their equivalent view widget
53 self.add_observables('commitmsg', 'staged', 'unstaged')
55 # When a model attribute changes, this runs a specific action
56 self.add_actions('staged', self.action_staged)
57 self.add_actions('unstaged', self.action_unstaged)
58 self.add_actions('global_ugit_fontdiff', self.update_diff_font)
59 self.add_actions('global_ugit_fontui', self.update_ui_font)
61 self.add_callbacks(
62 # Actions that delegate directly to the model
63 signoff_button = model.add_signoff,
64 menu_get_prev_commitmsg = model.get_prev_commitmsg,
65 menu_stage_modified =
66 lambda: self.log(self.model.stage_modified()),
67 menu_stage_untracked =
68 lambda: self.log(self.model.stage_untracked()),
69 menu_unstage_all =
70 lambda: self.log(self.model.unstage_all()),
72 # Actions that delegate direclty to the view
73 menu_cut = view.action_cut,
74 menu_copy = view.action_copy,
75 menu_paste = view.action_paste,
76 menu_delete = view.action_delete,
77 menu_select_all = view.action_select_all,
78 menu_undo = view.action_undo,
79 menu_redo = view.action_redo,
81 # Push Buttons
82 stage_button = self.stage_selected,
83 commit_button = self.commit,
84 push_button = self.push,
86 # List Widgets
87 staged = self.diff_staged,
88 unstaged = self.diff_unstaged,
90 # Checkboxes
91 untracked_checkbox = self.rescan,
93 # File Menu
94 menu_quit = self.quit_app,
95 # menu_load_bookmark = self.load_bookmark,
96 # menu_save_bookmark = self.save_bookmark,
97 # menu_manage_bookmarks = self.manage_bookmarks,
99 # Seaarch Menu
100 menu_search_revision = self.search_revision,
101 # menu_search_revision_range = self.search_revision_range,
102 # menu_search_messages = self.search_messages,
103 # menu_search_date = self.search_date,
104 # menu_search_date_range = self.search_date_range,
105 # menu_search_diffs = self.search_diffs,
107 # Repository Menu
108 menu_visualize_current = self.viz_current,
109 menu_visualize_all = self.viz_all,
110 menu_browse_commits = self.browse_commits,
111 menu_browse_branch = self.browse_current,
112 menu_browse_other_branch = self.browse_other,
114 # Commit Menu
115 menu_rescan = self.rescan,
116 menu_create_branch = self.branch_create,
117 menu_delete_branch = self.branch_delete,
118 menu_checkout_branch = self.checkout_branch,
119 menu_rebase_branch = self.rebase,
120 menu_commit = self.commit,
121 menu_stage_selected = self.stage_selected,
122 menu_unstage_selected = self.unstage_selected,
123 menu_show_diffstat = self.show_diffstat,
124 menu_show_index = self.show_index,
125 menu_export_patches = self.export_patches,
126 menu_load_commitmsg = self.load_commitmsg,
127 menu_cherry_pick = self.cherry_pick,
129 # Edit Menu
130 menu_options = self.options,
133 # Delegate window events here
134 view.moveEvent = self.move_event
135 view.resizeEvent = self.resize_event
136 view.closeEvent = self.quit_app
137 view.staged.mousePressEvent = self.click_staged
138 view.unstaged.mousePressEvent = self.click_unstaged
140 # These are vanilla signal/slots since QObserver
141 # is already handling these signals.
142 self.connect(view.unstaged,
143 'itemDoubleClicked(QListWidgetItem*)',
144 self.stage_selected)
145 self.connect(view.staged,
146 'itemDoubleClicked(QListWidgetItem*)',
147 self.unstage_selected)
149 # Toolbar log button
150 self.connect(self.view.toolbar_show_log,
151 'triggered()', self.show_log)
153 self.connect(view.diff_dock,
154 'topLevelChanged(bool)',
155 lambda(b): self.setwindow(view.diff_dock, b))
157 self.connect(view.editor_dock,
158 'topLevelChanged(bool)',
159 lambda(b): self.setwindow(view.editor_dock, b))
161 self.connect(view.status_dock,
162 'topLevelChanged(bool)',
163 lambda(b): self.setwindow(view.status_dock, b))
165 self.init_log_window()
166 self.load_gui_settings()
167 self.rescan()
168 self.refresh_view(
169 'global_ugit_fontdiff',
170 'global_ugit_fontui',
172 self.start_inotify_thread()
174 def setwindow(self, dock, isfloating):
175 if isfloating:
176 flags = ( QtCore.Qt.Window
177 | QtCore.Qt.FramelessWindowHint )
178 dock.setWindowFlags( flags )
179 dock.show()
181 #####################################################################
182 # handle when the listitem icons are clicked
183 def click_event(self, widget, action_callback, event):
184 result = QtGui.QListWidget.mousePressEvent(widget, event)
185 xpos = event.pos().x()
186 if xpos > 5 and xpos < 20:
187 action_callback()
188 return result
190 def click_staged(self, event):
191 return self.click_event(
192 self.view.staged,
193 self.unstage_selected,
194 event)
196 def click_unstaged(self, event):
197 return self.click_event(
198 self.view.unstaged,
199 self.stage_selected,
200 event)
203 #####################################################################
204 # event() is called in response to messages from the inotify thread
205 def event(self, msg):
206 if msg.type() == defaults.INOTIFY_EVENT:
207 self.rescan()
208 return True
209 else:
210 return False
212 #####################################################################
213 # Actions triggered during model updates
215 def action_staged(self, widget):
216 qtutils.update_listwidget(widget,
217 self.model.get_staged(), staged=True)
219 def action_unstaged(self, widget):
220 qtutils.update_listwidget(widget,
221 self.model.get_modified(), staged=False)
223 if self.view.untracked_checkbox.isChecked():
224 qtutils.update_listwidget(widget,
225 self.model.get_untracked(),
226 staged=False,
227 append=True,
228 untracked=True)
230 #####################################################################
231 # Qt callbacks
233 def show_log(self, *rest):
234 qtutils.toggle_log_window()
236 def options(self):
237 update_options(self.model, self.view)
239 def branch_create(self):
240 if create_new_branch(self.model, self.view):
241 self.rescan()
243 def branch_delete(self):
244 branch = choose_branch('Delete Branch',
245 self.view, self.model.get_local_branches())
246 if not branch: return
247 self.log(self.model.delete_branch(branch))
249 def browse_current(self):
250 branch = self.model.get_branch()
251 browse_git_branch(self.model, self.view, branch)
253 def browse_other(self):
254 # Prompt for a branch to browse
255 branch = choose_branch('Browse Branch Files',
256 self.view, self.model.get_all_branches())
257 if not branch: return
258 # Launch the repobrowser
259 browse_git_branch(self.model, self.view, branch)
261 def checkout_branch(self):
262 branch = choose_branch('Checkout Branch',
263 self.view, self.model.get_local_branches())
264 if not branch: return
265 self.log(self.model.checkout(branch))
267 def browse_commits(self):
268 self.select_commits_gui(self.tr('Browse Commits'),
269 *self.model.log_helper(all=True))
271 def search_revision(self):
272 search_revisions(self.model, self.view)
274 def cherry_pick(self):
275 commits = self.select_commits_gui(self.tr('Cherry-Pick Commits'),
276 *self.model.log_helper(all=True))
277 if not commits: return
278 self.log(self.model.cherry_pick_list(commits))
280 def commit(self):
281 msg = self.model.get_commitmsg()
282 if not msg:
283 error_msg = self.tr(""
284 + "Please supply a commit message.\n"
285 + "\n"
286 + "A good commit message has the following format:\n"
287 + "\n"
288 + "- First line: Describe in one sentence what you did.\n"
289 + "- Second line: Blank\n"
290 + "- Remaining lines: Describe why this change is good.\n")
291 self.log(error_msg)
292 return
294 files = self.model.get_staged()
295 if not files:
296 error_msg = self.tr(""
297 + "No changes to commit.\n"
298 + "\n"
299 + "You must stage at least 1 file before you can commit.\n")
300 self.log(error_msg)
301 return
303 # Perform the commit
304 output = self.model.commit_with_msg(
305 msg, amend=self.view.amend_radio.isChecked())
307 # Reset state
308 self.view.new_commit_radio.setChecked(True)
309 self.view.amend_radio.setChecked(False)
310 self.model.set_commitmsg('')
311 self.log(output)
313 def view_diff(self, staged=True):
314 self.__staged_diff_in_view = staged
315 if self.__staged_diff_in_view:
316 widget = self.view.staged
317 else:
318 widget = self.view.unstaged
319 row, selected = qtutils.get_selected_row(widget)
320 if not selected:
321 self.view.reset_display()
322 self.__diffgui_enabled = False
323 return
324 (diff,
325 status) = self.model.get_diff_and_status(row, staged=staged)
327 self.view.set_display(diff)
328 self.view.set_info(self.tr(status))
329 self.view.diff_dock.raise_()
330 self.__diffgui_enabled = True
332 # use *rest to handle being called from different signals
333 def diff_staged(self, *rest):
334 self.view_diff(staged=True)
336 # use *rest to handle being called from different signals
337 def diff_unstaged(self, *rest):
338 self.view_diff(staged=False)
340 def export_patches(self):
341 (revs, summaries) = self.model.log_helper()
342 commits = self.select_commits_gui(self.tr('Export Patches'),
343 revs, summaries)
344 if not commits: return
345 self.log(self.model.format_patch_helper(*commits))
347 def quit_app(self,*rest):
348 """Save config settings and cleanup any inotify threads."""
350 if self.model.save_at_exit():
351 self.model.save_gui_settings()
352 qtutils.close_log_window()
353 self.view.hide()
355 if not self.inotify_thread: return
356 if not self.inotify_thread.isRunning(): return
358 self.inotify_thread.abort = True
359 self.inotify_thread.terminate()
360 self.inotify_thread.wait()
362 def load_commitmsg(self):
363 file = qtutils.open_dialog(self.view,
364 'Load Commit Message...', defaults.DIRECTORY)
366 if file:
367 defaults.DIRECTORY = os.path.dirname(file)
368 slushy = utils.slurp(file)
369 if slushy: self.model.set_commitmsg(slushy)
371 def rebase(self):
372 branch = choose_branch('Rebase Branch',
373 self.view, self.model.get_local_branches())
374 if not branch: return
375 self.log(self.model.rebase(branch))
377 # use *rest to handle being called from the checkbox signal
378 def rescan(self, *rest):
379 '''Populates view widgets with results from "git status."'''
381 # save entire selection
382 unstaged = qtutils.get_selection_list(
383 self.view.unstaged,
384 self.model.get_unstaged())
385 staged = qtutils.get_selection_list(
386 self.view.staged,
387 self.model.get_staged())
389 scrollbar = self.view.display_text.verticalScrollBar()
390 scrollvalue = scrollbar.value()
392 # save selected item
393 unstageditem = qtutils.get_selected_item(
394 self.view.unstaged,
395 self.model.get_unstaged())
397 stageditem = qtutils.get_selected_item(
398 self.view.staged,
399 self.model.get_staged())
401 # get new values
402 self.model.update_status()
404 # restore selection
405 update_staged = False
406 update_unstaged = False
407 updated_unstaged = self.model.get_unstaged()
408 updated_staged = self.model.get_staged()
410 for item in unstaged:
411 if item in updated_unstaged:
412 idx = updated_unstaged.index(item)
413 listitem = self.view.unstaged.item(idx)
414 if listitem:
415 listitem.setSelected(True)
416 self.view.unstaged\
417 .setItemSelected(listitem, True)
418 update_unstaged = True
419 self.view.unstaged.update()
420 for item in staged:
421 if item in updated_staged:
422 idx = updated_staged.index(item)
423 listitem = self.view.staged.item(idx)
424 if listitem:
425 listitem.setSelected(True)
426 self.view.staged\
427 .setItemSelected(listitem, True)
428 update_staged = True
430 # restore selected item
431 if update_staged and stageditem:
432 idx = updated_staged.index(stageditem)
433 item = self.view.staged.item(idx)
434 self.view.staged.setCurrentItem(item)
435 self.view_diff(True)
436 scrollbar.setValue(scrollvalue)
438 elif update_unstaged and unstageditem:
439 idx = updated_unstaged.index(unstageditem)
440 item = self.view.unstaged.item(idx)
441 self.view.unstaged.setCurrentItem(item)
442 self.view_diff(False)
443 scrollbar.setValue(scrollvalue)
445 self.view.setWindowTitle('%s [%s]' % (
446 self.model.get_project(),
447 self.model.get_branch()))
449 if self.model.has_squash_msg():
450 if self.model.get_commitmsg():
451 answer = qtutils.question(self.view,
452 self.tr('Import Commit Message?'),
453 self.tr('A commit message from an in-progress'
454 + ' merge was found.\nImport it?'))
456 if answer:
457 self.model.set_squash_msg()
458 else:
459 # Set the new commit message
460 self.model.set_squash_msg()
462 def push(self):
463 push_branches(self.model, self.view)
465 def show_diffstat(self):
466 """Show the diffstat from the latest commit."""
467 self.__diffgui_enabled = False
468 self.view.set_info(self.tr('Diffstat'))
469 self.view.set_display(self.model.diffstat())
471 def show_index(self):
472 self.__diffgui_enabled = False
473 self.view.set_info(self.tr('Index'))
474 self.view.set_display(self.model.diffindex())
476 #####################################################################
477 # diff gui
478 def process_diff_selection(self, items, widget,
479 cached=True, selected=False, reverse=True, noop=False):
481 filename = qtutils.get_selected_item(widget, items)
482 if not filename: return
483 parser = utils.DiffParser(self.model, filename=filename,
484 cached=cached)
485 offset, selection = self.view.diff_selection()
486 parser.process_diff_selection(selected, offset, selection)
487 self.rescan()
489 def stage_hunk(self):
490 self.process_diff_selection(
491 self.model.get_unstaged(),
492 self.view.unstaged,
493 cached=False)
495 def stage_hunk_selection(self):
496 self.process_diff_selection(
497 self.model.get_unstaged(),
498 self.view.unstaged,
499 cached=False,
500 selected=True)
502 def unstage_hunk(self, cached=True):
503 self.process_diff_selection(
504 self.model.get_staged(),
505 self.view.staged,
506 cached=True)
508 def unstage_hunk_selection(self):
509 self.process_diff_selection(
510 self.model.get_staged(),
511 self.view.staged,
512 cached=True,
513 selected=True)
515 # #######################################################################
516 # end diff gui
518 # *rest handles being called from different signals
519 def stage_selected(self,*rest):
520 """Use "git add" to add items to the git index.
521 This is a thin wrapper around map_to_listwidget."""
522 command = self.model.add_or_remove
523 widget = self.view.unstaged
524 items = self.model.get_unstaged()
525 self.map_to_listwidget(command, widget, items)
527 # *rest handles being called from different signals
528 def unstage_selected(self, *rest):
529 """Use "git reset" to remove items from the git index.
530 This is a thin wrapper around map_to_listwidget."""
531 command = self.model.reset
532 widget = self.view.staged
533 items = self.model.get_staged()
534 self.map_to_listwidget(command, widget, items)
536 def undo_changes(self):
537 """Reverts local changes back to whatever's in HEAD."""
538 widget = self.view.unstaged
539 items = self.model.get_unstaged()
540 potential_items = qtutils.get_selection_list(widget, items)
541 items_to_undo = []
542 untracked = self.model.get_untracked()
543 for item in potential_items:
544 if item not in untracked:
545 items_to_undo.append(item)
546 if items_to_undo:
547 answer = qtutils.question(self.view,
548 self.tr('Destroy Local Changes?'),
549 self.tr('This operation will drop all '
550 + ' uncommitted changes. Continue?'),
551 default=False)
553 if not answer: return
555 output = self.model.checkout('HEAD', '--',
556 *items_to_undo)
557 self.log('git checkout HEAD -- '
558 + ' '.join(items_to_undo)
559 + '\n' + output)
560 else:
561 msg = 'No files selected for checkout from HEAD.'
562 self.log(self.tr(msg))
564 def viz_all(self):
565 """Visualizes the entire git history using gitk."""
566 browser = self.model.get_global_ugit_historybrowser()
567 utils.fork(browser,'--all')
569 def viz_current(self):
570 """Visualizes the current branch's history using gitk."""
571 browser = self.model.get_global_ugit_historybrowser()
572 utils.fork(browser, self.model.get_branch())
574 def move_event(self, event):
575 defaults.X = event.pos().x()
576 defaults.Y = event.pos().y()
578 def resize_event(self, event):
579 defaults.WIDTH = event.size().width()
580 defaults.HEIGHT = event.size().height()
582 def load_gui_settings(self):
583 if not self.model.remember_gui_settings():
584 return
585 (w,h,x,y,
586 st0,st1,
587 sb0,sb1) = self.model.get_window_geom()
588 self.view.resize(w,h)
589 self.view.move(x,y)
591 def log(self, output, rescan=True, quiet=False):
592 """Logs output and optionally rescans for changes."""
593 qtutils.log(output, quiet=quiet, doraise=False)
594 if rescan: self.rescan()
596 def map_to_listwidget(self, command, widget, items):
597 """This is a helper method that retrieves the current
598 selection list, applies a command to that list,
599 displays a dialog showing the output of that command,
600 and calls rescan to pickup changes."""
601 apply_items = qtutils.get_selection_list(widget, items)
602 output = command(*apply_items)
603 self.log(output, quiet=True)
605 def unstaged_context_menu_event(self, event):
606 self.unstaged_context_menu_setup()
607 unstaged = self.view.unstaged
608 self.__unstaged_menu.exec_(unstaged.mapToGlobal(event.pos()))
610 def unstaged_context_menu_setup(self):
611 if self.__unstaged_menu: return
613 menu = self.__unstaged_menu = QMenu(self.view)
614 self.__stage_selected_action = menu.addAction(
615 self.tr('Stage Selected'), self.stage_selected)
616 self.__undo_changes_action = menu.addAction(
617 self.tr('Undo Local Changes'), self.undo_changes)
618 self.connect(self.__unstaged_menu, 'aboutToShow()',
619 self.unstaged_context_menu_about_to_show)
621 def unstaged_context_menu_about_to_show(self):
622 unstaged_item = qtutils.get_selected_item(
623 self.view.unstaged,
624 self.model.get_unstaged())
626 is_tracked = unstaged_item not in self.model.get_untracked()
628 enable_staging = bool(self.__diffgui_enabled
629 and unstaged_item)
630 enable_undo = enable_staging and is_tracked
632 self.__stage_selected_action.setEnabled(enable_staging)
633 self.__undo_changes_action.setEnabled(enable_undo)
635 def diff_context_menu_about_to_show(self):
636 unstaged_item = qtutils.get_selected_item(
637 self.view.unstaged,
638 self.model.get_unstaged())
640 is_tracked= unstaged_item not in self.model.get_untracked()
642 enable_staged= (
643 self.__diffgui_enabled
644 and unstaged_item
645 and not self.__staged_diff_in_view
646 and is_tracked)
648 enable_unstaged= (
649 self.__diffgui_enabled
650 and self.__staged_diff_in_view
651 and qtutils.get_selected_item(
652 self.view.staged,
653 self.model.get_staged()))
655 self.__stage_hunk_action.setEnabled(bool(enable_staged))
656 self.__stage_hunk_selection_action.setEnabled(bool(enable_staged))
658 self.__unstage_hunk_action.setEnabled(bool(enable_unstaged))
659 self.__unstage_hunk_selection_action.setEnabled(bool(enable_unstaged))
661 def diff_context_menu_event(self, event):
662 self.diff_context_menu_setup()
663 textedit = self.view.display_text
664 self.__diff_menu.exec_(textedit.mapToGlobal(event.pos()))
666 def diff_context_menu_setup(self):
667 if self.__diff_menu: return
669 menu = self.__diff_menu = QMenu(self.view)
670 self.__stage_hunk_action = menu.addAction(
671 self.tr('Stage Hunk For Commit'), self.stage_hunk)
673 self.__stage_hunk_selection_action = menu.addAction(
674 self.tr('Stage Selected Lines'),
675 self.stage_hunk_selection)
677 self.__unstage_hunk_action = menu.addAction(
678 self.tr('Unstage Hunk From Commit'),
679 self.unstage_hunk)
681 self.__unstage_hunk_selection_action = menu.addAction(
682 self.tr('Unstage Selected Lines'),
683 self.unstage_hunk_selection)
685 self.__copy_action = menu.addAction(
686 self.tr('Copy'), self.view.copy_display)
688 self.connect(self.__diff_menu, 'aboutToShow()',
689 self.diff_context_menu_about_to_show)
691 def select_commits_gui(self, title, revs, summaries):
692 return select_commits(self.model, self.view, title, revs, summaries)
694 def update_diff_font(self):
695 font = self.model.get_global_ugit_fontdiff()
696 if not font: return
697 qfont = QFont()
698 qfont.fromString(font)
699 self.view.display_text.setFont(qfont)
700 self.view.commitmsg.setFont(qfont)
702 def update_ui_font(self):
703 font = self.model.get_global_ugit_fontui()
704 if not font: return
705 qfont = QFont()
706 qfont.fromString(font)
707 QtGui.qApp.setFont(qfont)
709 def init_log_window(self):
710 branch, version = self.model.get_branch(), defaults.VERSION
711 qtutils.log(self.model.get_git_version()
712 + '\nugit version '+ version
713 + '\nCurrent Branch: '+ branch)
715 def start_inotify_thread(self):
716 # Do we have inotify? If not, return.
717 # Recommend installing inotify if we're on Linux.
718 self.inotify_thread = None
719 try:
720 from inotify import GitNotifier
721 qtutils.log(self.tr('inotify support: enabled'))
722 except ImportError:
723 import platform
724 if platform.system() == 'Linux':
726 msg = self.tr(
727 'inotify: disabled\n'
728 'Note: To enable inotify, '
729 'install python-pyinotify.\n')
731 plat = platform.platform().lower()
732 if 'debian' in plat or 'ubuntu' in plat:
733 msg += self.tr(
734 'On Debian or Ubuntu systems, '
735 'try: sudo apt-get install '
736 'python-pyinotify')
737 qtutils.log(msg)
739 return
741 # Start the notification thread
742 self.inotify_thread = GitNotifier(self, os.getcwd())
743 self.inotify_thread.start()