git: update 'git config' to use the refactored command line handler
[ugit.git] / ugitlibs / controllers.py
blob1ef408bdd7666d531213d41607d598d3d09ef042
1 #!/usr/bin/env python
2 import os
3 import time
5 from PyQt4 import QtGui
6 from PyQt4.QtGui import QDialog
7 from PyQt4.QtGui import QMessageBox
8 from PyQt4.QtGui import QMenu
9 from PyQt4.QtGui import QFont
11 import utils
12 import qtutils
13 import defaults
14 from qobserver import QObserver
15 from repobrowsercontroller import browse_git_branch
16 from createbranchcontroller import create_new_branch
17 from pushcontroller import push_branches
18 from utilcontroller import choose_branch
19 from utilcontroller import select_commits
20 from utilcontroller import find_revisions
21 from utilcontroller import update_options
22 from utilcontroller import log_window
24 class Controller(QObserver):
25 '''Controller manages the interaction between the model and views.'''
27 def __init__(self, model, view):
28 QObserver.__init__(self, model, view)
30 # parent-less log window
31 qtutils.LOGGER = log_window(model, QtGui.qApp.activeWindow())
33 # Avoids inotify floods from e.g. make
34 self.__last_inotify_event = time.time()
36 # The unstaged list context menu
37 self.__unstaged_menu = None
39 # The diff-display context menu
40 self.__diff_menu = None
41 self.__staged_diff_in_view = True
42 self.__diffgui_enabled = True
44 # Unstaged changes context menu
45 view.unstaged.contextMenuEvent = self.unstaged_context_menu_event
47 # Diff display context menu
48 view.display_text.contextMenuEvent = self.diff_context_menu_event
50 # Binds model params to their equivalent view widget
51 self.add_observables('commitmsg', 'staged', 'unstaged')
53 # When a model attribute changes, this runs a specific action
54 self.add_actions('staged', self.action_staged)
55 self.add_actions('unstaged', self.action_unstaged)
56 self.add_actions('global_ugit_fontdiff', self.update_diff_font)
57 self.add_actions('global_ugit_fontui', self.update_ui_font)
59 self.add_callbacks(
60 # Actions that delegate directly to the model
61 signoff_button = model.add_signoff,
62 menu_get_prev_commitmsg = model.get_prev_commitmsg,
63 menu_stage_modified =
64 lambda: self.log(self.model.stage_modified()),
65 menu_stage_untracked =
66 lambda: self.log(self.model.stage_untracked()),
67 menu_unstage_all =
68 lambda: self.log(self.model.unstage_all()),
70 # Actions that delegate direclty to the view
71 menu_cut = view.action_cut,
72 menu_copy = view.action_copy,
73 menu_paste = view.action_paste,
74 menu_delete = view.action_delete,
75 menu_select_all = view.action_select_all,
76 menu_undo = view.action_undo,
77 menu_redo = view.action_redo,
79 # Push Buttons
80 stage_button = self.stage_selected,
81 commit_button = self.commit,
82 push_button = self.push,
84 # List Widgets
85 staged = self.diff_staged,
86 unstaged = self.diff_unstaged,
88 # Checkboxes
89 untracked_checkbox = self.rescan,
91 # File Menu
92 menu_load_commitmsg = self.load_commitmsg,
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_cherry_pick = self.cherry_pick,
116 # Edit Menu
117 menu_options = self.options,
119 # Splitters
120 splitter_top = self.splitter_top_event,
121 splitter_bottom = self.splitter_bottom_event,
124 # These are vanilla signal/slots since QObserver
125 # is already handling these signals.
126 self.connect(view.unstaged,
127 'itemDoubleClicked(QListWidgetItem*)',
128 self.stage_selected)
129 self.connect(view.staged,
130 'itemDoubleClicked(QListWidgetItem*)',
131 self.unstage_selected)
133 # Toolbar log button
134 self.connect(self.view.toolbar_show_log,
135 'triggered()', self.show_log)
136 # Delegate window events here
137 view.moveEvent = self.move_event
138 view.resizeEvent = self.resize_event
139 view.closeEvent = self.quit_app
140 view.staged.mousePressEvent = self.click_staged
141 view.unstaged.mousePressEvent = self.click_unstaged
143 self.load_window_geom()
144 self.init_log_window()
145 self.start_inotify_thread()
146 self.rescan()
147 self.refresh_view()
149 #####################################################################
150 # handle when list item icons are clicked
151 def click_event(self, widget, action_callback, event):
152 result = QtGui.QListWidget.mousePressEvent(widget, event)
153 xpos = event.pos().x()
154 if xpos > 5 and xpos < 20:
155 action_callback()
156 return result
158 def click_staged(self, event):
159 return self.click_event(
160 self.view.staged,
161 self.unstage_selected,
162 event)
164 def click_unstaged(self, event):
165 return self.click_event(
166 self.view.unstaged,
167 self.stage_selected,
168 event)
171 #####################################################################
172 # event() is called in response to messages from the inotify thread
173 def event(self, msg):
174 if msg.type() == defaults.INOTIFY_EVENT:
175 self.rescan()
176 return True
177 else:
178 return False
180 #####################################################################
181 # Actions triggered during model updates
183 def action_staged(self, widget):
184 qtutils.update_listwidget(widget,
185 self.model.get_staged(), staged=True)
187 def action_unstaged(self, widget):
188 qtutils.update_listwidget(widget,
189 self.model.get_modified(), staged=False)
191 if self.view.untracked_checkbox.isChecked():
192 qtutils.update_listwidget(widget,
193 self.model.get_untracked(),
194 staged=False,
195 append=True,
196 untracked=True)
198 #####################################################################
199 # Qt callbacks
201 def show_log(self, *rest):
202 qtutils.toggle_log_window()
204 def options(self):
205 update_options(self.model, self.view)
207 def branch_create(self):
208 if create_new_branch(self.model, self.view):
209 self.rescan()
211 def branch_delete(self):
212 branch = choose_branch('Delete Branch',
213 self.view, self.model.get_local_branches())
214 if not branch: return
215 self.log(self.model.delete_branch(branch))
217 def browse_current(self):
218 branch = self.model.get_branch()
219 browse_git_branch(self.model, self.view, branch)
221 def browse_other(self):
222 # Prompt for a branch to browse
223 branch = choose_branch('Browse Branch Files',
224 self.view, self.model.get_all_branches())
225 if not branch: return
226 # Launch the repobrowser
227 browse_git_branch(self.model, self.view, branch)
229 def checkout_branch(self):
230 branch = choose_branch('Checkout Branch',
231 self.view, self.model.get_local_branches())
232 if not branch: return
233 self.log(self.model.checkout(branch))
235 def browse_commits(self):
236 self.select_commits_gui(self.tr('Browse Commits'),
237 *self.model.log(all=True))
239 def show_revision(self):
240 find_revisions(self.model, self.view)
242 def cherry_pick(self):
243 commits = self.select_commits_gui(self.tr('Cherry-Pick Commits'),
244 *self.model.log(all=True))
245 if not commits: return
246 self.log(self.model.cherry_pick(commits))
248 def commit(self):
249 msg = self.model.get_commitmsg()
250 if not msg:
251 error_msg = self.tr(""
252 + "Please supply a commit message.\n"
253 + "\n"
254 + "A good commit message has the following format:\n"
255 + "\n"
256 + "- First line: Describe in one sentence what you did.\n"
257 + "- Second line: Blank\n"
258 + "- Remaining lines: Describe why this change is good.\n")
259 self.log(error_msg)
260 return
262 files = self.model.get_staged()
263 if not files:
264 error_msg = self.tr(""
265 + "No changes to commit.\n"
266 + "\n"
267 + "You must stage at least 1 file before you can commit.\n")
268 self.log(error_msg)
269 return
271 # Perform the commit
272 output = self.model.commit(
273 msg, amend=self.view.amend_radio.isChecked())
275 # Reset state
276 self.view.new_commit_radio.setChecked(True)
277 self.view.amend_radio.setChecked(False)
278 self.model.set_commitmsg('')
279 self.log(output)
281 def view_diff(self, staged=True):
282 self.__staged_diff_in_view = staged
283 if self.__staged_diff_in_view:
284 widget = self.view.staged
285 else:
286 widget = self.view.unstaged
287 row, selected = qtutils.get_selected_row(widget)
288 if not selected:
289 self.view.reset_display()
290 self.__diffgui_enabled = False
291 return
292 (diff,
293 status) = self.model.get_diff_and_status(row, staged=staged)
295 self.view.set_display(diff)
296 self.view.set_info(self.tr(status))
297 self.__diffgui_enabled = True
299 # use *rest to handle being called from different signals
300 def diff_staged(self, *rest):
301 self.view_diff(staged=True)
303 # use *rest to handle being called from different signals
304 def diff_unstaged(self, *rest):
305 self.view_diff(staged=False)
307 def export_patches(self):
308 (revs, summaries) = self.model.log()
309 commits = self.select_commits_gui(self.tr('Export Patches'),
310 revs, summaries)
311 if not commits: return
312 self.log(self.model.format_patch(commits))
314 def quit_app(self,*rest):
315 '''Save config settings and cleanup any inotify threads.'''
317 self.model.save_window_geom()
318 qtutils.close_log_window()
319 self.view.hide()
321 if not self.inotify_thread: return
322 if not self.inotify_thread.isRunning(): return
324 self.inotify_thread.abort = True
325 self.inotify_thread.terminate()
326 self.inotify_thread.wait()
328 def load_commitmsg(self):
329 file = qtutils.open_dialog(self.view,
330 'Load Commit Message...', defaults.DIRECTORY)
332 if file:
333 defaults.DIRECTORY = os.path.dirname(file)
334 slushy = utils.slurp(file)
335 if slushy: self.model.set_commitmsg(slushy)
337 def rebase(self):
338 branch = choose_branch('Rebase Branch',
339 self.view, self.model.get_local_branches())
340 if not branch: return
341 self.log(self.model.rebase(branch))
343 # use *rest to handle being called from the checkbox signal
344 def rescan(self, *rest):
345 '''Populates view widgets with results from "git status."'''
347 # save entire selection
348 unstaged = qtutils.get_selection_list(
349 self.view.unstaged,
350 self.model.get_unstaged())
351 staged = qtutils.get_selection_list(
352 self.view.staged,
353 self.model.get_staged())
355 scrollbar = self.view.display_text.verticalScrollBar()
356 scrollvalue = scrollbar.value()
358 # save selected item
359 unstageditem = qtutils.get_selected_item(
360 self.view.unstaged,
361 self.model.get_unstaged())
363 stageditem = qtutils.get_selected_item(
364 self.view.staged,
365 self.model.get_staged())
367 # get new values
368 self.model.update_status()
370 # restore selection
371 update_staged = False
372 update_unstaged = False
373 updated_unstaged = self.model.get_unstaged()
374 updated_staged = self.model.get_staged()
376 for item in unstaged:
377 if item in updated_unstaged:
378 idx = updated_unstaged.index(item)
379 listitem = self.view.unstaged.item(idx)
380 if listitem:
381 listitem.setSelected(True)
382 self.view.unstaged\
383 .setItemSelected(listitem, True)
384 update_unstaged = True
385 self.view.unstaged.update()
386 for item in staged:
387 if item in updated_staged:
388 idx = updated_staged.index(item)
389 listitem = self.view.staged.item(idx)
390 if listitem:
391 listitem.setSelected(True)
392 self.view.staged\
393 .setItemSelected(listitem, True)
394 update_staged = True
396 # restore selected item
397 if update_staged and stageditem:
398 idx = updated_staged.index(stageditem)
399 item = self.view.staged.item(idx)
400 self.view.staged.setCurrentItem(item)
401 self.view_diff(True)
402 scrollbar.setValue(scrollvalue)
404 elif update_unstaged and unstageditem:
405 idx = updated_unstaged.index(unstageditem)
406 item = self.view.unstaged.item(idx)
407 self.view.unstaged.setCurrentItem(item)
408 self.view_diff(False)
409 scrollbar.setValue(scrollvalue)
411 self.view.setWindowTitle('%s [%s]' % (
412 self.model.get_project(),
413 self.model.get_branch()))
415 if self.model.has_squash_msg():
416 if self.model.get_commitmsg():
417 answer = qtutils.question(self.view,
418 self.tr('Import Commit Message?'),
419 self.tr('A commit message from an in-progress'
420 + ' merge was found.\nImport it?'))
422 if answer:
423 self.model.set_squash_msg()
424 else:
425 # Set the new commit message
426 self.model.set_squash_msg()
428 def push(self):
429 push_branches(self.model, self.view)
431 def show_diffstat(self):
432 '''Show the diffstat from the latest commit.'''
433 self.__diffgui_enabled = False
434 self.view.set_info(self.tr('Diffstat'))
435 self.view.set_display(self.model.diffstat())
437 def show_index(self):
438 self.__diffgui_enabled = False
439 self.view.set_info(self.tr('Index'))
440 self.view.set_display(self.model.diffindex())
442 #####################################################################
443 # diff gui
444 def process_diff_selection(self, items, widget,
445 cached=True, selected=False, reverse=True, noop=False):
447 filename = qtutils.get_selected_item(widget, items)
448 if not filename: return
449 parser = utils.DiffParser(self.model, filename=filename,
450 cached=cached)
451 offset, selection = self.view.diff_selection()
452 parser.process_diff_selection(selected, offset, selection)
453 self.rescan()
455 def stage_hunk(self):
456 self.process_diff_selection(
457 self.model.get_unstaged(),
458 self.view.unstaged,
459 cached=False)
461 def stage_hunk_selection(self):
462 self.process_diff_selection(
463 self.model.get_unstaged(),
464 self.view.unstaged,
465 cached=False,
466 selected=True)
468 def unstage_hunk(self, cached=True):
469 self.process_diff_selection(
470 self.model.get_staged(),
471 self.view.staged,
472 cached=True)
474 def unstage_hunk_selection(self):
475 self.process_diff_selection(
476 self.model.get_staged(),
477 self.view.staged,
478 cached=True,
479 selected=True)
481 # #######################################################################
482 # end diff gui
484 # use *rest to handle being called from different signals
485 def stage_selected(self,*rest):
486 '''Use "git add" to add items to the git index.
487 This is a thin wrapper around apply_to_list.'''
488 command = self.model.add_or_remove
489 widget = self.view.unstaged
490 items = self.model.get_unstaged()
491 self.apply_to_list(command,widget,items)
493 # use *rest to handle being called from different signals
494 def unstage_selected(self, *rest):
495 '''Use "git reset" to remove items from the git index.
496 This is a thin wrapper around apply_to_list.'''
497 command = self.model.reset
498 widget = self.view.staged
499 items = self.model.get_staged()
500 self.apply_to_list(command, widget, items)
502 def undo_changes(self):
503 """Reverts local changes back to whatever's in HEAD."""
504 widget = self.view.unstaged
505 items = self.model.get_unstaged()
506 potential_items = qtutils.get_selection_list(widget, items)
507 items_to_undo = []
508 untracked = self.model.get_untracked()
509 for item in potential_items:
510 if item not in untracked:
511 items_to_undo.append(item)
512 if items_to_undo:
513 answer = qtutils.question(self.view,
514 self.tr('Destroy Local Changes?'),
515 self.tr('This operation will drop all '
516 + ' uncommitted changes. Continue?'),
517 default=False)
519 if not answer: return
521 output = self.model.checkout('HEAD', '--',
522 *items_to_undo)
523 self.log('git checkout HEAD -- '
524 + ' '.join(items_to_undo)
525 + '\n' + output)
526 else:
527 msg = 'No files selected for checkout from HEAD.'
528 self.log(self.tr(msg))
530 def viz_all(self):
531 '''Visualizes the entire git history using gitk.'''
532 browser = self.model.get_global_ugit_historybrowser()
533 utils.fork(browser,'--all')
535 def viz_current(self):
536 '''Visualizes the current branch's history using gitk.'''
537 browser = self.model.get_global_ugit_historybrowser()
538 utils.fork(browser, self.model.get_branch())
540 # These actions monitor window resizes, splitter changes, etc.
541 def move_event(self, event):
542 defaults.X = event.pos().x()
543 defaults.Y = event.pos().y()
545 def resize_event(self, event):
546 defaults.WIDTH = event.size().width()
547 defaults.HEIGHT = event.size().height()
549 def splitter_top_event(self,*rest):
550 sizes = self.view.splitter_top.sizes()
551 defaults.SPLITTER_TOP_0 = sizes[0]
552 defaults.SPLITTER_TOP_1 = sizes[1]
554 def splitter_bottom_event(self,*rest):
555 sizes = self.view.splitter_bottom.sizes()
556 defaults.SPLITTER_BOTTOM_0 = sizes[0]
557 defaults.SPLITTER_BOTTOM_1 = sizes[1]
559 def load_window_geom(self):
560 (w,h,x,y,
561 st0,st1,
562 sb0,sb1) = self.model.get_window_geom()
563 self.view.resize(w,h)
564 self.view.move(x,y)
565 self.view.splitter_top.setSizes([st0,st1])
566 self.view.splitter_bottom.setSizes([sb0,sb1])
568 def log(self, output, rescan=True, quiet=False):
569 '''Logs output and optionally rescans for changes.'''
570 qtutils.log(output, quiet=quiet, doraise=False)
571 if rescan: self.rescan()
573 #####################################################################
576 def apply_to_list(self, command, widget, items):
577 '''This is a helper method that retrieves the current
578 selection list, applies a command to that list,
579 displays a dialog showing the output of that command,
580 and calls rescan to pickup changes.'''
581 apply_items = qtutils.get_selection_list(widget, items)
582 output = command(apply_items)
583 self.log(output, quiet=True)
585 def unstaged_context_menu_event(self, event):
586 self.unstaged_context_menu_setup()
587 unstaged = self.view.unstaged
588 self.__unstaged_menu.exec_(unstaged.mapToGlobal(event.pos()))
590 def unstaged_context_menu_setup(self):
591 if self.__unstaged_menu: return
593 menu = self.__unstaged_menu = QMenu(self.view)
594 self.__stage_selected_action = menu.addAction(
595 self.tr('Stage Selected'), self.stage_selected)
596 self.__undo_changes_action = menu.addAction(
597 self.tr('Undo Local Changes'), self.undo_changes)
598 self.connect(self.__unstaged_menu, 'aboutToShow()',
599 self.unstaged_context_menu_about_to_show)
601 def unstaged_context_menu_about_to_show(self):
602 unstaged_item = qtutils.get_selected_item(
603 self.view.unstaged,
604 self.model.get_unstaged())
606 is_tracked = unstaged_item not in self.model.get_untracked()
608 enable_staging = bool(self.__diffgui_enabled
609 and unstaged_item)
610 enable_undo = enable_staging and is_tracked
612 self.__stage_selected_action.setEnabled(enable_staging)
613 self.__undo_changes_action.setEnabled(enable_undo)
615 def diff_context_menu_about_to_show(self):
616 unstaged_item = qtutils.get_selected_item(
617 self.view.unstaged,
618 self.model.get_unstaged())
620 is_tracked= unstaged_item not in self.model.get_untracked()
622 enable_staged= (
623 self.__diffgui_enabled
624 and unstaged_item
625 and not self.__staged_diff_in_view
626 and is_tracked)
628 enable_unstaged= (
629 self.__diffgui_enabled
630 and self.__staged_diff_in_view
631 and qtutils.get_selected_item(
632 self.view.staged,
633 self.model.get_staged()))
635 self.__stage_hunk_action.setEnabled(bool(enable_staged))
636 self.__stage_hunk_selection_action.setEnabled(bool(enable_staged))
638 self.__unstage_hunk_action.setEnabled(bool(enable_unstaged))
639 self.__unstage_hunk_selection_action.setEnabled(bool(enable_unstaged))
641 def diff_context_menu_event(self, event):
642 self.diff_context_menu_setup()
643 textedit = self.view.display_text
644 self.__diff_menu.exec_(textedit.mapToGlobal(event.pos()))
646 def diff_context_menu_setup(self):
647 if self.__diff_menu: return
649 menu = self.__diff_menu = QMenu(self.view)
650 self.__stage_hunk_action = menu.addAction(
651 self.tr('Stage Hunk For Commit'), self.stage_hunk)
653 self.__stage_hunk_selection_action = menu.addAction(
654 self.tr('Stage Selected Lines'),
655 self.stage_hunk_selection)
657 self.__unstage_hunk_action = menu.addAction(
658 self.tr('Unstage Hunk From Commit'),
659 self.unstage_hunk)
661 self.__unstage_hunk_selection_action = menu.addAction(
662 self.tr('Unstage Selected Lines'),
663 self.unstage_hunk_selection)
665 self.__copy_action = menu.addAction(
666 self.tr('Copy'), self.view.copy_display)
668 self.connect(self.__diff_menu, 'aboutToShow()',
669 self.diff_context_menu_about_to_show)
671 def select_commits_gui(self, title, revs, summaries):
672 return select_commits(self.model, self.view, title, revs, summaries)
674 def update_diff_font(self):
675 font = self.model.get_global_ugit_fontdiff()
676 if not font: return
677 qfont = QFont()
678 qfont.fromString(font)
679 self.view.display_text.setFont(qfont)
680 self.view.commitmsg.setFont(qfont)
682 def update_ui_font(self):
683 font = self.model.get_global_ugit_fontui()
684 if not font: return
685 qfont = QFont()
686 qfont.fromString(font)
687 QtGui.qApp.setFont(qfont)
689 def init_log_window(self):
690 branch, version = self.model.get_branch(), defaults.VERSION
691 qtutils.log(self.model.get_git_version()
692 + '\nugit version '+ version
693 + '\nCurrent Branch: '+ branch)
695 def start_inotify_thread(self):
696 # Do we have inotify? If not, return.
697 # Recommend installing inotify if we're on Linux.
698 self.inotify_thread = None
699 try:
700 from inotify import GitNotifier
701 qtutils.log(self.tr('inotify support: enabled'))
702 except ImportError:
703 import platform
704 if platform.system() == 'Linux':
706 msg = self.tr(
707 'inotify: disabled\n'
708 'Note: To enable inotify, '
709 'install python-pyinotify.\n')
711 plat = platform.platform().lower()
712 if 'debian' in plat or 'ubuntu' in plat:
713 msg += self.tr(
714 'On Debian or Ubuntu systems, '
715 'try: sudo apt-get install '
716 'python-pyinotify')
717 qtutils.log(msg)
719 return
721 # Start the notification thread
722 self.inotify_thread = GitNotifier(self, os.getcwd())
723 self.inotify_thread.start()