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