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
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
)
64 # Actions that delegate directly to the model
65 signoff_button
= model
.add_signoff
,
66 menu_get_prev_commitmsg
= model
.get_prev_commitmsg
,
68 lambda: self
.log(self
.model
.stage_modified()),
69 menu_stage_untracked
=
70 lambda: self
.log(self
.model
.stage_untracked()),
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
,
82 stage_button
= self
.stage_selected
,
83 commit_button
= self
.commit
,
84 push_button
= self
.push
,
86 staged
= self
.diff_staged
,
87 unstaged
= self
.diff_unstaged
,
89 untracked_checkbox
= self
.rescan
,
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,
97 menu_options
= self
.options
,
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
),
108 self
.gen_search(search
.PATH
, True),
109 menu_search_date_range
=
110 self
.gen_search(search
.DATE_RANGE
),
112 self
.gen_search(search
.DIFF
),
114 self
.gen_search(search
.AUTHOR
),
115 menu_search_committer
=
116 self
.gen_search(search
.COMMITTER
),
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
,
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*)',
153 self
.connect(view
.staged
,
154 'itemDoubleClicked(QListWidgetItem*)',
155 self
.unstage_selected
)
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()
177 'global_ugit_fontdiff',
178 'global_ugit_fontui',
180 self
.start_inotify_thread()
182 def setwindow(self
, dock
, isfloating
):
184 flags
= ( QtCore
.Qt
.Window
185 | QtCore
.Qt
.FramelessWindowHint
)
186 dock
.setWindowFlags( flags
)
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:
198 def click_staged(self
, event
):
199 return self
.click_event(
201 self
.unstage_selected
,
204 def click_unstaged(self
, event
):
205 return self
.click_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
:
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(),
238 #####################################################################
240 def gen_search(self
, searchtype
, browse
=False):
241 def search_handler():
242 search_commits(self
.model
, searchtype
, browse
)
243 return search_handler
246 txt
, ok
= qtutils
.input("grep")
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()
256 update_options(self
.model
, self
.view
)
258 def branch_create(self
):
259 if create_new_branch(self
.model
, self
.view
):
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
))
297 msg
= self
.model
.get_commitmsg()
299 error_msg
= self
.tr(""
300 + "Please supply a commit message.\n"
302 + "A good commit message has the following format:\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")
310 files
= self
.model
.get_staged()
312 error_msg
= self
.tr(""
313 + "No changes to commit.\n"
315 + "You must stage at least 1 file before you can commit.\n")
320 output
= self
.model
.commit_with_msg(
321 msg
, amend
=self
.view
.amend_radio
.isChecked())
324 self
.view
.new_commit_radio
.setChecked(True)
325 self
.view
.amend_radio
.setChecked(False)
326 self
.model
.set_commitmsg('')
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
334 widget
= self
.view
.unstaged
335 row
, selected
= qtutils
.get_selected_row(widget
)
337 self
.view
.reset_display()
338 self
.__diffgui
_enabled
= False
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'),
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()
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
)
383 defaults
.DIRECTORY
= os
.path
.dirname(file)
384 slushy
= utils
.slurp(file)
385 if slushy
: self
.model
.set_commitmsg(slushy
)
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(
400 self
.model
.get_unstaged())
401 staged
= qtutils
.get_selection_list(
403 self
.model
.get_staged())
405 scrollbar
= self
.view
.display_text
.verticalScrollBar()
406 scrollvalue
= scrollbar
.value()
409 unstageditem
= qtutils
.get_selected_item(
411 self
.model
.get_unstaged())
413 stageditem
= qtutils
.get_selected_item(
415 self
.model
.get_staged())
418 self
.model
.update_status()
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
)
431 listitem
.setSelected(True)
433 .setItemSelected(listitem
, True)
434 update_unstaged
= True
435 self
.view
.unstaged
.update()
437 if item
in updated_staged
:
438 idx
= updated_staged
.index(item
)
439 listitem
= self
.view
.staged
.item(idx
)
441 listitem
.setSelected(True)
443 .setItemSelected(listitem
, 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
)
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?'))
473 self
.model
.set_squash_msg()
475 # Set the new commit message
476 self
.model
.set_squash_msg()
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 #####################################################################
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
,
501 offset
, selection
= self
.view
.diff_selection()
502 parser
.process_diff_selection(selected
, offset
, selection
)
505 def stage_hunk(self
):
506 self
.process_diff_selection(
507 self
.model
.get_unstaged(),
511 def stage_hunk_selection(self
):
512 self
.process_diff_selection(
513 self
.model
.get_unstaged(),
518 def unstage_hunk(self
, cached
=True):
519 self
.process_diff_selection(
520 self
.model
.get_staged(),
524 def unstage_hunk_selection(self
):
525 self
.process_diff_selection(
526 self
.model
.get_staged(),
531 # #######################################################################
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
)
558 untracked
= self
.model
.get_untracked()
559 for item
in potential_items
:
560 if item
not in untracked
:
561 items_to_undo
.append(item
)
563 answer
= qtutils
.question(self
.view
,
564 self
.tr('Destroy Local Changes?'),
565 self
.tr('This operation will drop all '
566 + ' uncommitted changes. Continue?'),
569 if not answer
: return
571 output
= self
.model
.checkout('HEAD', '--',
573 self
.log('git checkout HEAD -- '
574 + ' '.join(items_to_undo
)
577 msg
= 'No files selected for checkout from HEAD.'
578 self
.log(self
.tr(msg
))
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():
603 sb0
,sb1
) = self
.model
.get_window_geom()
604 self
.view
.resize(w
,h
)
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(
640 self
.model
.get_unstaged())
642 is_tracked
= unstaged_item
not in self
.model
.get_untracked()
644 enable_staging
= bool(self
.__diffgui
_enabled
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(
654 self
.model
.get_unstaged())
656 is_tracked
= unstaged_item
not in self
.model
.get_untracked()
659 self
.__diffgui
_enabled
661 and not self
.__staged
_diff
_in
_view
665 self
.__diffgui
_enabled
666 and self
.__staged
_diff
_in
_view
667 and qtutils
.get_selected_item(
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'),
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()
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()
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
736 from ugit
.inotify
import GitNotifier
737 qtutils
.log(self
.tr('inotify support: enabled'))
740 if platform
.system() == 'Linux':
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
:
750 'On Debian or Ubuntu systems, '
751 'try: sudo apt-get install '
757 # Start the notification thread
758 self
.inotify_thread
= GitNotifier(self
, os
.getcwd())
759 self
.inotify_thread
.start()