gui: remember the saveatexit setting on first use
[ugit.git] / ugit / controllers.py
blobc7e0c2a8db4332c1838bb98b0ce101986e7e3599
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_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 # These are vanilla signal/slots since QObserver
123 # is already handling these signals.
124 self.connect(view.unstaged,
125 'itemDoubleClicked(QListWidgetItem*)',
126 self.stage_selected)
127 self.connect(view.staged,
128 'itemDoubleClicked(QListWidgetItem*)',
129 self.unstage_selected)
131 # Toolbar log button
132 self.connect(self.view.toolbar_show_log,
133 'triggered()', self.show_log)
135 # Delegate window events here
136 view.moveEvent = self.move_event
137 view.resizeEvent = self.resize_event
138 view.closeEvent = self.quit_app
139 view.staged.mousePressEvent = self.click_staged
140 view.unstaged.mousePressEvent = self.click_unstaged
142 self.init_log_window()
143 self.rescan()
144 self.load_gui_settings()
146 self.refresh_view()
147 self.start_inotify_thread()
149 self.connect(view.diff_dock,
150 'topLevelChanged(bool)',
151 lambda(b): self.setwindow(view.diff_dock, b))
153 self.connect(view.editor_dock,
154 'topLevelChanged(bool)',
155 lambda(b): self.setwindow(view.editor_dock, b))
157 self.connect(view.status_dock,
158 'topLevelChanged(bool)',
159 lambda(b): self.setwindow(view.status_dock, b))
161 def setwindow(self, dock, isfloating):
162 if isfloating:
163 flags = ( QtCore.Qt.Window
164 | QtCore.Qt.FramelessWindowHint )
165 dock.setWindowFlags( flags )
166 dock.show()
168 #####################################################################
169 # handle when the listitem icons are clicked
170 def click_event(self, widget, action_callback, event):
171 result = QtGui.QListWidget.mousePressEvent(widget, event)
172 xpos = event.pos().x()
173 if xpos > 5 and xpos < 20:
174 action_callback()
175 return result
177 def click_staged(self, event):
178 return self.click_event(
179 self.view.staged,
180 self.unstage_selected,
181 event)
183 def click_unstaged(self, event):
184 return self.click_event(
185 self.view.unstaged,
186 self.stage_selected,
187 event)
190 #####################################################################
191 # event() is called in response to messages from the inotify thread
192 def event(self, msg):
193 if msg.type() == defaults.INOTIFY_EVENT:
194 self.rescan()
195 return True
196 else:
197 return False
199 #####################################################################
200 # Actions triggered during model updates
202 def action_staged(self, widget):
203 qtutils.update_listwidget(widget,
204 self.model.get_staged(), staged=True)
206 def action_unstaged(self, widget):
207 qtutils.update_listwidget(widget,
208 self.model.get_modified(), staged=False)
210 if self.view.untracked_checkbox.isChecked():
211 qtutils.update_listwidget(widget,
212 self.model.get_untracked(),
213 staged=False,
214 append=True,
215 untracked=True)
217 #####################################################################
218 # Qt callbacks
220 def show_log(self, *rest):
221 qtutils.toggle_log_window()
223 def options(self):
224 update_options(self.model, self.view)
226 def branch_create(self):
227 if create_new_branch(self.model, self.view):
228 self.rescan()
230 def branch_delete(self):
231 branch = choose_branch('Delete Branch',
232 self.view, self.model.get_local_branches())
233 if not branch: return
234 self.log(self.model.delete_branch(branch))
236 def browse_current(self):
237 branch = self.model.get_branch()
238 browse_git_branch(self.model, self.view, branch)
240 def browse_other(self):
241 # Prompt for a branch to browse
242 branch = choose_branch('Browse Branch Files',
243 self.view, self.model.get_all_branches())
244 if not branch: return
245 # Launch the repobrowser
246 browse_git_branch(self.model, self.view, branch)
248 def checkout_branch(self):
249 branch = choose_branch('Checkout Branch',
250 self.view, self.model.get_local_branches())
251 if not branch: return
252 self.log(self.model.checkout(branch))
254 def browse_commits(self):
255 self.select_commits_gui(self.tr('Browse Commits'),
256 *self.model.log(all=True))
258 def show_revision(self):
259 find_revisions(self.model, self.view)
261 def cherry_pick(self):
262 commits = self.select_commits_gui(self.tr('Cherry-Pick Commits'),
263 *self.model.log(all=True))
264 if not commits: return
265 self.log(self.model.cherry_pick(commits))
267 def commit(self):
268 msg = self.model.get_commitmsg()
269 if not msg:
270 error_msg = self.tr(""
271 + "Please supply a commit message.\n"
272 + "\n"
273 + "A good commit message has the following format:\n"
274 + "\n"
275 + "- First line: Describe in one sentence what you did.\n"
276 + "- Second line: Blank\n"
277 + "- Remaining lines: Describe why this change is good.\n")
278 self.log(error_msg)
279 return
281 files = self.model.get_staged()
282 if not files:
283 error_msg = self.tr(""
284 + "No changes to commit.\n"
285 + "\n"
286 + "You must stage at least 1 file before you can commit.\n")
287 self.log(error_msg)
288 return
290 # Perform the commit
291 output = self.model.commit(
292 msg, amend=self.view.amend_radio.isChecked())
294 # Reset state
295 self.view.new_commit_radio.setChecked(True)
296 self.view.amend_radio.setChecked(False)
297 self.model.set_commitmsg('')
298 self.log(output)
300 def view_diff(self, staged=True):
301 self.__staged_diff_in_view = staged
302 if self.__staged_diff_in_view:
303 widget = self.view.staged
304 else:
305 widget = self.view.unstaged
306 row, selected = qtutils.get_selected_row(widget)
307 if not selected:
308 self.view.reset_display()
309 self.__diffgui_enabled = False
310 return
311 (diff,
312 status) = self.model.get_diff_and_status(row, staged=staged)
314 self.view.set_display(diff)
315 self.view.set_info(self.tr(status))
316 self.__diffgui_enabled = True
318 # use *rest to handle being called from different signals
319 def diff_staged(self, *rest):
320 self.view_diff(staged=True)
322 # use *rest to handle being called from different signals
323 def diff_unstaged(self, *rest):
324 self.view_diff(staged=False)
326 def export_patches(self):
327 (revs, summaries) = self.model.log()
328 commits = self.select_commits_gui(self.tr('Export Patches'),
329 revs, summaries)
330 if not commits: return
331 self.log(self.model.format_patch(commits))
333 def quit_app(self,*rest):
334 '''Save config settings and cleanup any inotify threads.'''
336 if self.model.save_at_exit():
337 self.model.save_gui_settings()
338 qtutils.close_log_window()
339 self.view.hide()
341 if not self.inotify_thread: return
342 if not self.inotify_thread.isRunning(): return
344 self.inotify_thread.abort = True
345 self.inotify_thread.terminate()
346 self.inotify_thread.wait()
348 def load_commitmsg(self):
349 file = qtutils.open_dialog(self.view,
350 'Load Commit Message...', defaults.DIRECTORY)
352 if file:
353 defaults.DIRECTORY = os.path.dirname(file)
354 slushy = utils.slurp(file)
355 if slushy: self.model.set_commitmsg(slushy)
357 def rebase(self):
358 branch = choose_branch('Rebase Branch',
359 self.view, self.model.get_local_branches())
360 if not branch: return
361 self.log(self.model.rebase(branch))
363 # use *rest to handle being called from the checkbox signal
364 def rescan(self, *rest):
365 '''Populates view widgets with results from "git status."'''
367 # save entire selection
368 unstaged = qtutils.get_selection_list(
369 self.view.unstaged,
370 self.model.get_unstaged())
371 staged = qtutils.get_selection_list(
372 self.view.staged,
373 self.model.get_staged())
375 scrollbar = self.view.display_text.verticalScrollBar()
376 scrollvalue = scrollbar.value()
378 # save selected item
379 unstageditem = qtutils.get_selected_item(
380 self.view.unstaged,
381 self.model.get_unstaged())
383 stageditem = qtutils.get_selected_item(
384 self.view.staged,
385 self.model.get_staged())
387 # get new values
388 self.model.update_status()
390 # restore selection
391 update_staged = False
392 update_unstaged = False
393 updated_unstaged = self.model.get_unstaged()
394 updated_staged = self.model.get_staged()
396 for item in unstaged:
397 if item in updated_unstaged:
398 idx = updated_unstaged.index(item)
399 listitem = self.view.unstaged.item(idx)
400 if listitem:
401 listitem.setSelected(True)
402 self.view.unstaged\
403 .setItemSelected(listitem, True)
404 update_unstaged = True
405 self.view.unstaged.update()
406 for item in staged:
407 if item in updated_staged:
408 idx = updated_staged.index(item)
409 listitem = self.view.staged.item(idx)
410 if listitem:
411 listitem.setSelected(True)
412 self.view.staged\
413 .setItemSelected(listitem, True)
414 update_staged = True
416 # restore selected item
417 if update_staged and stageditem:
418 idx = updated_staged.index(stageditem)
419 item = self.view.staged.item(idx)
420 self.view.staged.setCurrentItem(item)
421 self.view_diff(True)
422 scrollbar.setValue(scrollvalue)
424 elif update_unstaged and unstageditem:
425 idx = updated_unstaged.index(unstageditem)
426 item = self.view.unstaged.item(idx)
427 self.view.unstaged.setCurrentItem(item)
428 self.view_diff(False)
429 scrollbar.setValue(scrollvalue)
431 self.view.setWindowTitle('%s [%s]' % (
432 self.model.get_project(),
433 self.model.get_branch()))
435 if self.model.has_squash_msg():
436 if self.model.get_commitmsg():
437 answer = qtutils.question(self.view,
438 self.tr('Import Commit Message?'),
439 self.tr('A commit message from an in-progress'
440 + ' merge was found.\nImport it?'))
442 if answer:
443 self.model.set_squash_msg()
444 else:
445 # Set the new commit message
446 self.model.set_squash_msg()
448 def push(self):
449 push_branches(self.model, self.view)
451 def show_diffstat(self):
452 '''Show the diffstat from the latest commit.'''
453 self.__diffgui_enabled = False
454 self.view.set_info(self.tr('Diffstat'))
455 self.view.set_display(self.model.diffstat())
457 def show_index(self):
458 self.__diffgui_enabled = False
459 self.view.set_info(self.tr('Index'))
460 self.view.set_display(self.model.diffindex())
462 #####################################################################
463 # diff gui
464 def process_diff_selection(self, items, widget,
465 cached=True, selected=False, reverse=True, noop=False):
467 filename = qtutils.get_selected_item(widget, items)
468 if not filename: return
469 parser = utils.DiffParser(self.model, filename=filename,
470 cached=cached)
471 offset, selection = self.view.diff_selection()
472 parser.process_diff_selection(selected, offset, selection)
473 self.rescan()
475 def stage_hunk(self):
476 self.process_diff_selection(
477 self.model.get_unstaged(),
478 self.view.unstaged,
479 cached=False)
481 def stage_hunk_selection(self):
482 self.process_diff_selection(
483 self.model.get_unstaged(),
484 self.view.unstaged,
485 cached=False,
486 selected=True)
488 def unstage_hunk(self, cached=True):
489 self.process_diff_selection(
490 self.model.get_staged(),
491 self.view.staged,
492 cached=True)
494 def unstage_hunk_selection(self):
495 self.process_diff_selection(
496 self.model.get_staged(),
497 self.view.staged,
498 cached=True,
499 selected=True)
501 # #######################################################################
502 # end diff gui
504 # *rest handles being called from different signals
505 def stage_selected(self,*rest):
506 '''Use "git add" to add items to the git index.
507 This is a thin wrapper around apply_to_list.'''
508 command = self.model.add_or_remove
509 widget = self.view.unstaged
510 items = self.model.get_unstaged()
511 self.apply_to_list(command,widget,items)
513 # *rest handles being called from different signals
514 def unstage_selected(self, *rest):
515 '''Use "git reset" to remove items from the git index.
516 This is a thin wrapper around apply_to_list.'''
517 command = self.model.reset
518 widget = self.view.staged
519 items = self.model.get_staged()
520 self.apply_to_list(command, widget, items)
522 def undo_changes(self):
523 """Reverts local changes back to whatever's in HEAD."""
524 widget = self.view.unstaged
525 items = self.model.get_unstaged()
526 potential_items = qtutils.get_selection_list(widget, items)
527 items_to_undo = []
528 untracked = self.model.get_untracked()
529 for item in potential_items:
530 if item not in untracked:
531 items_to_undo.append(item)
532 if items_to_undo:
533 answer = qtutils.question(self.view,
534 self.tr('Destroy Local Changes?'),
535 self.tr('This operation will drop all '
536 + ' uncommitted changes. Continue?'),
537 default=False)
539 if not answer: return
541 output = self.model.checkout('HEAD', '--',
542 *items_to_undo)
543 self.log('git checkout HEAD -- '
544 + ' '.join(items_to_undo)
545 + '\n' + output)
546 else:
547 msg = 'No files selected for checkout from HEAD.'
548 self.log(self.tr(msg))
550 def viz_all(self):
551 '''Visualizes the entire git history using gitk.'''
552 browser = self.model.get_global_ugit_historybrowser()
553 utils.fork(browser,'--all')
555 def viz_current(self):
556 '''Visualizes the current branch's history using gitk.'''
557 browser = self.model.get_global_ugit_historybrowser()
558 utils.fork(browser, self.model.get_branch())
560 def move_event(self, event):
561 defaults.X = event.pos().x()
562 defaults.Y = event.pos().y()
564 def resize_event(self, event):
565 defaults.WIDTH = event.size().width()
566 defaults.HEIGHT = event.size().height()
568 def load_gui_settings(self):
569 if not self.model.remember_gui_settings():
570 return
571 (w,h,x,y,
572 st0,st1,
573 sb0,sb1) = self.model.get_window_geom()
574 self.view.resize(w,h)
575 self.view.move(x,y)
577 def log(self, output, rescan=True, quiet=False):
578 '''Logs output and optionally rescans for changes.'''
579 qtutils.log(output, quiet=quiet, doraise=False)
580 if rescan: self.rescan()
582 def apply_to_list(self, command, widget, items):
583 '''This is a helper method that retrieves the current
584 selection list, applies a command to that list,
585 displays a dialog showing the output of that command,
586 and calls rescan to pickup changes.'''
587 apply_items = qtutils.get_selection_list(widget, items)
588 output = command(apply_items)
589 self.log(output, quiet=True)
591 def unstaged_context_menu_event(self, event):
592 self.unstaged_context_menu_setup()
593 unstaged = self.view.unstaged
594 self.__unstaged_menu.exec_(unstaged.mapToGlobal(event.pos()))
596 def unstaged_context_menu_setup(self):
597 if self.__unstaged_menu: return
599 menu = self.__unstaged_menu = QMenu(self.view)
600 self.__stage_selected_action = menu.addAction(
601 self.tr('Stage Selected'), self.stage_selected)
602 self.__undo_changes_action = menu.addAction(
603 self.tr('Undo Local Changes'), self.undo_changes)
604 self.connect(self.__unstaged_menu, 'aboutToShow()',
605 self.unstaged_context_menu_about_to_show)
607 def unstaged_context_menu_about_to_show(self):
608 unstaged_item = qtutils.get_selected_item(
609 self.view.unstaged,
610 self.model.get_unstaged())
612 is_tracked = unstaged_item not in self.model.get_untracked()
614 enable_staging = bool(self.__diffgui_enabled
615 and unstaged_item)
616 enable_undo = enable_staging and is_tracked
618 self.__stage_selected_action.setEnabled(enable_staging)
619 self.__undo_changes_action.setEnabled(enable_undo)
621 def diff_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_staged= (
629 self.__diffgui_enabled
630 and unstaged_item
631 and not self.__staged_diff_in_view
632 and is_tracked)
634 enable_unstaged= (
635 self.__diffgui_enabled
636 and self.__staged_diff_in_view
637 and qtutils.get_selected_item(
638 self.view.staged,
639 self.model.get_staged()))
641 self.__stage_hunk_action.setEnabled(bool(enable_staged))
642 self.__stage_hunk_selection_action.setEnabled(bool(enable_staged))
644 self.__unstage_hunk_action.setEnabled(bool(enable_unstaged))
645 self.__unstage_hunk_selection_action.setEnabled(bool(enable_unstaged))
647 def diff_context_menu_event(self, event):
648 self.diff_context_menu_setup()
649 textedit = self.view.display_text
650 self.__diff_menu.exec_(textedit.mapToGlobal(event.pos()))
652 def diff_context_menu_setup(self):
653 if self.__diff_menu: return
655 menu = self.__diff_menu = QMenu(self.view)
656 self.__stage_hunk_action = menu.addAction(
657 self.tr('Stage Hunk For Commit'), self.stage_hunk)
659 self.__stage_hunk_selection_action = menu.addAction(
660 self.tr('Stage Selected Lines'),
661 self.stage_hunk_selection)
663 self.__unstage_hunk_action = menu.addAction(
664 self.tr('Unstage Hunk From Commit'),
665 self.unstage_hunk)
667 self.__unstage_hunk_selection_action = menu.addAction(
668 self.tr('Unstage Selected Lines'),
669 self.unstage_hunk_selection)
671 self.__copy_action = menu.addAction(
672 self.tr('Copy'), self.view.copy_display)
674 self.connect(self.__diff_menu, 'aboutToShow()',
675 self.diff_context_menu_about_to_show)
677 def select_commits_gui(self, title, revs, summaries):
678 return select_commits(self.model, self.view, title, revs, summaries)
680 def update_diff_font(self):
681 font = self.model.get_global_ugit_fontdiff()
682 if not font: return
683 qfont = QFont()
684 qfont.fromString(font)
685 self.view.display_text.setFont(qfont)
686 self.view.commitmsg.setFont(qfont)
688 def update_ui_font(self):
689 font = self.model.get_global_ugit_fontui()
690 if not font: return
691 qfont = QFont()
692 qfont.fromString(font)
693 QtGui.qApp.setFont(qfont)
695 def init_log_window(self):
696 branch, version = self.model.get_branch(), defaults.VERSION
697 qtutils.log(self.model.get_git_version()
698 + '\nugit version '+ version
699 + '\nCurrent Branch: '+ branch)
701 def start_inotify_thread(self):
702 # Do we have inotify? If not, return.
703 # Recommend installing inotify if we're on Linux.
704 self.inotify_thread = None
705 try:
706 from inotify import GitNotifier
707 qtutils.log(self.tr('inotify support: enabled'))
708 except ImportError:
709 import platform
710 if platform.system() == 'Linux':
712 msg = self.tr(
713 'inotify: disabled\n'
714 'Note: To enable inotify, '
715 'install python-pyinotify.\n')
717 plat = platform.platform().lower()
718 if 'debian' in plat or 'ubuntu' in plat:
719 msg += self.tr(
720 'On Debian or Ubuntu systems, '
721 'try: sudo apt-get install '
722 'python-pyinotify')
723 qtutils.log(msg)
725 return
727 # Start the notification thread
728 self.inotify_thread = GitNotifier(self, os.getcwd())
729 self.inotify_thread.start()