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
27 from merge
import local_merge
28 from merge
import abort_merge
30 class Controller(QObserver
):
31 """Manages the interaction between models and views."""
33 def 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
)
65 signoff_button
= self
.model
.add_signoff
,
66 stage_button
= self
.stage_selected
,
67 commit_button
= self
.commit
,
68 push_button
= self
.push
,
70 staged
= self
.diff_staged
,
71 unstaged
= self
.diff_unstaged
,
73 untracked_checkbox
= self
.rescan
,
76 menu_quit
= self
.quit_app
,
77 # menu_load_bookmark = self.load_bookmark,
78 # menu_save_bookmark = self.save_bookmark,
79 # menu_manage_bookmarks = self.manage_bookmarks,
82 menu_options
= self
.options
,
83 menu_cut
= self
.view
.action_cut
,
84 menu_copy
= self
.view
.action_copy
,
85 menu_paste
= self
.view
.action_paste
,
86 menu_delete
= self
.view
.action_delete
,
87 menu_select_all
= self
.view
.action_select_all
,
88 menu_undo
= self
.view
.action_undo
,
89 menu_redo
= self
.view
.action_redo
,
92 menu_search_grep
= self
.grep
,
93 menu_search_revision
=
94 self
.gen_search( search
.REVISION_ID
),
95 menu_search_revision_range
=
96 self
.gen_search( search
.REVISION_RANGE
),
98 self
.gen_search( search
.MESSAGE
),
100 self
.gen_search( search
.PATH
, True ),
101 menu_search_date_range
=
102 self
.gen_search( search
.DATE_RANGE
),
104 self
.gen_search( search
.DIFF
),
106 self
.gen_search( search
.AUTHOR
),
107 menu_search_committer
=
108 self
.gen_search( search
.COMMITTER
),
112 lambda: local_merge( self
.model
, self
.view
),
114 lambda: abort_merge( self
.model
, self
.view
),
117 menu_visualize_current
= self
.viz_current
,
118 menu_visualize_all
= self
.viz_all
,
119 menu_browse_commits
= self
.browse_commits
,
120 menu_browse_branch
= self
.browse_current
,
121 menu_browse_other_branch
= self
.browse_other
,
124 menu_rescan
= self
.rescan
,
125 menu_create_branch
= self
.branch_create
,
126 menu_delete_branch
= self
.branch_delete
,
127 menu_checkout_branch
= self
.checkout_branch
,
128 menu_rebase_branch
= self
.rebase
,
129 menu_commit
= self
.commit
,
130 menu_stage_selected
= self
.stage_selected
,
131 menu_unstage_selected
= self
.unstage_selected
,
132 menu_show_diffstat
= self
.show_diffstat
,
133 menu_show_index
= self
.show_index
,
134 menu_export_patches
= self
.export_patches
,
135 menu_load_commitmsg
= self
.load_commitmsg
,
136 menu_cherry_pick
= self
.cherry_pick
,
137 menu_get_prev_commitmsg
= model
.get_prev_commitmsg
,
138 menu_stage_modified
=
139 lambda: self
.log(self
.model
.stage_modified()),
140 menu_stage_untracked
=
141 lambda: self
.log(self
.model
.stage_untracked()),
143 lambda: self
.log(self
.model
.unstage_all()),
146 # Delegate window events here
147 view
.moveEvent
= self
.move_event
148 view
.resizeEvent
= self
.resize_event
149 view
.closeEvent
= self
.quit_app
150 view
.staged
.mousePressEvent
= self
.click_staged
151 view
.unstaged
.mousePressEvent
= self
.click_unstaged
153 # These are vanilla signal/slots since QObserver
154 # is already handling these signals.
155 self
.connect(view
.unstaged
,
156 'itemDoubleClicked(QListWidgetItem*)',
158 self
.connect(view
.staged
,
159 'itemDoubleClicked(QListWidgetItem*)',
160 self
.unstage_selected
)
163 self
.connect(view
.toolbar_show_log
,
164 'triggered()', self
.show_log
)
166 self
.connect(view
.diff_dock
,
167 'topLevelChanged(bool)',
168 lambda(b
): self
.setwindow(view
.diff_dock
, b
))
170 self
.connect(view
.editor_dock
,
171 'topLevelChanged(bool)',
172 lambda(b
): self
.setwindow(view
.editor_dock
, b
))
174 self
.connect(view
.status_dock
,
175 'topLevelChanged(bool)',
176 lambda(b
): self
.setwindow(view
.status_dock
, b
))
178 self
.init_log_window()
179 self
.load_gui_settings()
182 'global_ugit_fontdiff',
183 'global_ugit_fontui',
185 self
.start_inotify_thread()
187 def setwindow(self
, dock
, isfloating
):
189 flags
= ( QtCore
.Qt
.Window
190 | QtCore
.Qt
.FramelessWindowHint
)
191 dock
.setWindowFlags( flags
)
194 #####################################################################
195 # handle when the listitem icons are clicked
196 def click_event(self
, widget
, action_callback
, event
):
197 result
= QtGui
.QListWidget
.mousePressEvent(widget
, event
)
198 xpos
= event
.pos().x()
199 if xpos
> 5 and xpos
< 20:
203 def click_staged(self
, event
):
204 return self
.click_event(
206 self
.unstage_selected
,
209 def click_unstaged(self
, event
):
210 return self
.click_event(
216 #####################################################################
217 # event() is called in response to messages from the inotify thread
218 def event(self
, msg
):
219 if msg
.type() == defaults
.INOTIFY_EVENT
:
225 #####################################################################
226 # Actions triggered during model updates
228 def action_staged(self
, widget
):
229 qtutils
.update_listwidget(widget
,
230 self
.model
.get_staged(), staged
=True)
231 self
.view
.editor_dock
.raise_()
233 def action_unstaged(self
, widget
):
234 qtutils
.update_listwidget(widget
,
235 self
.model
.get_modified(), staged
=False)
237 if self
.view
.untracked_checkbox
.isChecked():
238 qtutils
.update_listwidget(widget
,
239 self
.model
.get_untracked(),
244 #####################################################################
246 def gen_search(self
, searchtype
, browse
=False):
247 def search_handler():
248 search_commits(self
.model
, searchtype
, browse
)
249 return search_handler
252 txt
, ok
= qtutils
.input("grep")
254 stuff
= self
.model
.grep(txt
)
255 self
.view
.display_text
.setText(stuff
)
256 self
.view
.diff_dock
.raise_()
258 def show_log(self
, *rest
):
259 qtutils
.toggle_log_window()
262 update_options(self
.model
, self
.view
)
264 def branch_create(self
):
265 if create_new_branch(self
.model
, self
.view
):
268 def branch_delete(self
):
269 branch
= choose_branch('Delete Branch',
270 self
.view
, self
.model
.get_local_branches())
271 if not branch
: return
272 self
.log(self
.model
.delete_branch(branch
))
274 def browse_current(self
):
275 branch
= self
.model
.get_currentbranch()
276 browse_git_branch(self
.model
, self
.view
, branch
)
278 def browse_other(self
):
279 # Prompt for a branch to browse
280 branch
= choose_branch('Browse Branch Files',
281 self
.view
, self
.model
.get_all_branches())
282 if not branch
: return
283 # Launch the repobrowser
284 browse_git_branch(self
.model
, self
.view
, branch
)
286 def checkout_branch(self
):
287 branch
= choose_branch('Checkout Branch',
288 self
.view
, self
.model
.get_local_branches())
289 if not branch
: return
290 self
.log(self
.model
.checkout(branch
))
292 def browse_commits(self
):
293 self
.select_commits_gui(self
.tr('Browse Commits'),
294 *self
.model
.log_helper(all
=True))
296 def cherry_pick(self
):
297 commits
= self
.select_commits_gui(self
.tr('Cherry-Pick Commits'),
298 *self
.model
.log_helper(all
=True))
299 if not commits
: return
300 self
.log(self
.model
.cherry_pick_list(commits
))
303 msg
= self
.model
.get_commitmsg()
305 error_msg
= self
.tr(""
306 + "Please supply a commit message.\n"
308 + "A good commit message has the following format:\n"
310 + "- First line: Describe in one sentence what you did.\n"
311 + "- Second line: Blank\n"
312 + "- Remaining lines: Describe why this change is good.\n")
316 files
= self
.model
.get_staged()
317 if not files
and not self
.view
.amend_radio
.isChecked():
318 error_msg
= self
.tr(""
319 + "No changes to commit.\n"
321 + "You must stage at least 1 file before you can commit.\n")
326 output
= self
.model
.commit_with_msg(
327 msg
, amend
=self
.view
.amend_radio
.isChecked())
330 self
.view
.new_commit_radio
.setChecked(True)
331 self
.view
.amend_radio
.setChecked(False)
332 self
.model
.set_commitmsg('')
335 def view_diff(self
, staged
=True):
336 self
.__staged
_diff
_in
_view
= staged
337 if self
.__staged
_diff
_in
_view
:
338 widget
= self
.view
.staged
340 widget
= self
.view
.unstaged
341 row
, selected
= qtutils
.get_selected_row(widget
)
343 self
.view
.reset_display()
344 self
.__diffgui
_enabled
= False
347 status
) = self
.model
.get_diff_and_status(row
, staged
=staged
)
349 self
.view
.set_display(diff
)
350 self
.view
.set_info(self
.tr(status
))
351 self
.view
.diff_dock
.raise_()
352 self
.__diffgui
_enabled
= True
354 # use *rest to handle being called from different signals
355 def diff_staged(self
, *rest
):
356 self
.view_diff(staged
=True)
358 # use *rest to handle being called from different signals
359 def diff_unstaged(self
, *rest
):
360 self
.view_diff(staged
=False)
362 def export_patches(self
):
363 (revs
, summaries
) = self
.model
.log_helper()
364 commits
= self
.select_commits_gui(self
.tr('Export Patches'),
366 if not commits
: return
367 self
.log(self
.model
.format_patch_helper(*commits
))
369 def quit_app(self
,*rest
):
370 """Save config settings and cleanup any inotify threads."""
372 if self
.model
.save_at_exit():
373 self
.model
.save_gui_settings()
374 qtutils
.close_log_window()
377 if not self
.inotify_thread
: return
378 if not self
.inotify_thread
.isRunning(): return
380 self
.inotify_thread
.abort
= True
381 self
.inotify_thread
.terminate()
382 self
.inotify_thread
.wait()
384 def load_commitmsg(self
):
385 file = qtutils
.open_dialog(self
.view
,
386 'Load Commit Message...', defaults
.DIRECTORY
)
389 defaults
.DIRECTORY
= os
.path
.dirname(file)
390 slushy
= utils
.slurp(file)
391 if slushy
: self
.model
.set_commitmsg(slushy
)
394 branch
= choose_branch('Rebase Branch',
395 self
.view
, self
.model
.get_local_branches())
396 if not branch
: return
397 self
.log(self
.model
.rebase(branch
))
399 # use *rest to handle being called from the checkbox signal
400 def rescan(self
, *rest
):
401 '''Populates view widgets with results from "git status."'''
403 # save entire selection
404 unstaged
= qtutils
.get_selection_list(
406 self
.model
.get_unstaged())
407 staged
= qtutils
.get_selection_list(
409 self
.model
.get_staged())
411 scrollbar
= self
.view
.display_text
.verticalScrollBar()
412 scrollvalue
= scrollbar
.value()
415 unstageditem
= qtutils
.get_selected_item(
417 self
.model
.get_unstaged())
419 stageditem
= qtutils
.get_selected_item(
421 self
.model
.get_staged())
424 self
.model
.update_status()
427 update_staged
= False
428 update_unstaged
= False
429 updated_unstaged
= self
.model
.get_unstaged()
430 updated_staged
= self
.model
.get_staged()
432 for item
in unstaged
:
433 if item
in updated_unstaged
:
434 idx
= updated_unstaged
.index(item
)
435 listitem
= self
.view
.unstaged
.item(idx
)
437 listitem
.setSelected(True)
439 .setItemSelected(listitem
, True)
440 update_unstaged
= True
441 self
.view
.unstaged
.update()
443 if item
in updated_staged
:
444 idx
= updated_staged
.index(item
)
445 listitem
= self
.view
.staged
.item(idx
)
447 listitem
.setSelected(True)
449 .setItemSelected(listitem
, True)
452 # restore selected item
453 if update_staged
and stageditem
:
454 idx
= updated_staged
.index(stageditem
)
455 item
= self
.view
.staged
.item(idx
)
456 self
.view
.staged
.setCurrentItem(item
)
458 scrollbar
.setValue(scrollvalue
)
460 elif update_unstaged
and unstageditem
:
461 idx
= updated_unstaged
.index(unstageditem
)
462 item
= self
.view
.unstaged
.item(idx
)
463 self
.view
.unstaged
.setCurrentItem(item
)
464 self
.view_diff(False)
465 scrollbar
.setValue(scrollvalue
)
467 # Update the title with the current branch
468 self
.view
.setWindowTitle('%s [%s]' % (
469 self
.model
.get_project(),
470 self
.model
.get_currentbranch()))
472 # Check if there's a message file in .git/
473 merge_msg_path
= self
.model
.get_merge_message_path()
474 if merge_msg_path
is None:
477 # A merge message file exists.
479 if self
.model
.get_commitmsg():
480 # The commit message editor contains data.
481 # Prompt before overwriting the commit message
482 # with the contents of the merge message.
483 answer
= qtutils
.question(self
.view
,
484 self
.tr('Import Commit Message?'),
485 self
.tr('A commit message from an in-progress'
486 + ' merge was found.\nImport it?'))
492 # Set the new commit message
494 self
.model
.load_commitmsg(merge_msg_path
)
495 self
.view
.editor_dock
.raise_()
498 push_branches(self
.model
, self
.view
)
500 def show_diffstat(self
):
501 """Show the diffstat from the latest commit."""
502 self
.__diffgui
_enabled
= False
503 self
.view
.set_info(self
.tr('Diffstat'))
504 self
.view
.set_display(self
.model
.diffstat())
506 def show_index(self
):
507 self
.__diffgui
_enabled
= False
508 self
.view
.set_info(self
.tr('Index'))
509 self
.view
.set_display(self
.model
.diffindex())
511 #####################################################################
513 def process_diff_selection(self
, items
, widget
,
514 cached
=True, selected
=False, reverse
=True, noop
=False):
516 filename
= qtutils
.get_selected_item(widget
, items
)
517 if not filename
: return
518 parser
= utils
.DiffParser(self
.model
, filename
=filename
,
520 offset
, selection
= self
.view
.diff_selection()
521 parser
.process_diff_selection(selected
, offset
, selection
)
524 def stage_hunk(self
):
525 self
.process_diff_selection(
526 self
.model
.get_unstaged(),
530 def stage_hunk_selection(self
):
531 self
.process_diff_selection(
532 self
.model
.get_unstaged(),
537 def unstage_hunk(self
, cached
=True):
538 self
.process_diff_selection(
539 self
.model
.get_staged(),
543 def unstage_hunk_selection(self
):
544 self
.process_diff_selection(
545 self
.model
.get_staged(),
550 # #######################################################################
553 # *rest handles being called from different signals
554 def stage_selected(self
,*rest
):
555 """Use "git add" to add items to the git index.
556 This is a thin wrapper around map_to_listwidget."""
557 command
= self
.model
.add_or_remove
558 widget
= self
.view
.unstaged
559 items
= self
.model
.get_unstaged()
560 self
.map_to_listwidget(command
, widget
, items
)
562 # *rest handles being called from different signals
563 def unstage_selected(self
, *rest
):
564 """Use "git reset" to remove items from the git index.
565 This is a thin wrapper around map_to_listwidget."""
566 command
= self
.model
.reset_helper
567 widget
= self
.view
.staged
568 items
= self
.model
.get_staged()
569 self
.map_to_listwidget(command
, widget
, items
)
571 def undo_changes(self
):
572 """Reverts local changes back to whatever's in HEAD."""
573 widget
= self
.view
.unstaged
574 items
= self
.model
.get_unstaged()
575 potential_items
= qtutils
.get_selection_list(widget
, items
)
577 untracked
= self
.model
.get_untracked()
578 for item
in potential_items
:
579 if item
not in untracked
:
580 items_to_undo
.append(item
)
582 answer
= qtutils
.question(self
.view
,
583 self
.tr('Destroy Local Changes?'),
584 self
.tr('This operation will drop all '
585 + ' uncommitted changes. Continue?'),
588 if not answer
: return
590 output
= self
.model
.checkout('HEAD', '--',
592 self
.log('git checkout HEAD -- '
593 + ' '.join(items_to_undo
)
596 msg
= 'No files selected for checkout from HEAD.'
597 self
.log(self
.tr(msg
))
600 """Visualizes the entire git history using gitk."""
601 browser
= self
.model
.get_global_ugit_historybrowser()
602 utils
.fork(browser
,'--all')
604 def viz_current(self
):
605 """Visualizes the current branch's history using gitk."""
606 browser
= self
.model
.get_global_ugit_historybrowser()
607 utils
.fork(browser
, self
.model
.get_currentbranch())
609 def move_event(self
, event
):
610 defaults
.X
= event
.pos().x()
611 defaults
.Y
= event
.pos().y()
613 def resize_event(self
, event
):
614 defaults
.WIDTH
= event
.size().width()
615 defaults
.HEIGHT
= event
.size().height()
617 def load_gui_settings(self
):
618 if not self
.model
.remember_gui_settings():
622 sb0
,sb1
) = self
.model
.get_window_geom()
623 self
.view
.resize(w
,h
)
626 def log(self
, output
, rescan
=True, quiet
=False):
627 """Logs output and optionally rescans for changes."""
628 qtutils
.log(output
, quiet
=quiet
, doraise
=False)
629 if rescan
: self
.rescan()
631 def map_to_listwidget(self
, command
, widget
, items
):
632 """This is a helper method that retrieves the current
633 selection list, applies a command to that list,
634 displays a dialog showing the output of that command,
635 and calls rescan to pickup changes."""
636 apply_items
= qtutils
.get_selection_list(widget
, items
)
637 output
= command(*apply_items
)
638 self
.log(output
, quiet
=True)
640 def unstaged_context_menu_event(self
, event
):
641 self
.unstaged_context_menu_setup()
642 unstaged
= self
.view
.unstaged
643 self
.__unstaged
_menu
.exec_(unstaged
.mapToGlobal(event
.pos()))
645 def unstaged_context_menu_setup(self
):
646 if self
.__unstaged
_menu
: return
648 menu
= self
.__unstaged
_menu
= QMenu(self
.view
)
649 self
.__stage
_selected
_action
= menu
.addAction(
650 self
.tr('Stage Selected'), self
.stage_selected
)
651 self
.__undo
_changes
_action
= menu
.addAction(
652 self
.tr('Undo Local Changes'), self
.undo_changes
)
653 self
.connect(self
.__unstaged
_menu
, 'aboutToShow()',
654 self
.unstaged_context_menu_about_to_show
)
656 def unstaged_context_menu_about_to_show(self
):
657 unstaged_item
= qtutils
.get_selected_item(
659 self
.model
.get_unstaged())
661 is_tracked
= unstaged_item
not in self
.model
.get_untracked()
663 enable_staging
= bool(self
.__diffgui
_enabled
665 enable_undo
= enable_staging
and is_tracked
667 self
.__stage
_selected
_action
.setEnabled(enable_staging
)
668 self
.__undo
_changes
_action
.setEnabled(enable_undo
)
670 def diff_context_menu_about_to_show(self
):
671 unstaged_item
= qtutils
.get_selected_item(
673 self
.model
.get_unstaged())
675 is_tracked
= unstaged_item
not in self
.model
.get_untracked()
678 self
.__diffgui
_enabled
680 and not self
.__staged
_diff
_in
_view
684 self
.__diffgui
_enabled
685 and self
.__staged
_diff
_in
_view
686 and qtutils
.get_selected_item(
688 self
.model
.get_staged()))
690 self
.__stage
_hunk
_action
.setEnabled(bool(enable_staged
))
691 self
.__stage
_hunk
_selection
_action
.setEnabled(bool(enable_staged
))
693 self
.__unstage
_hunk
_action
.setEnabled(bool(enable_unstaged
))
694 self
.__unstage
_hunk
_selection
_action
.setEnabled(bool(enable_unstaged
))
696 def diff_context_menu_event(self
, event
):
697 self
.diff_context_menu_setup()
698 textedit
= self
.view
.display_text
699 self
.__diff
_menu
.exec_(textedit
.mapToGlobal(event
.pos()))
701 def diff_context_menu_setup(self
):
702 if self
.__diff
_menu
: return
704 menu
= self
.__diff
_menu
= QMenu(self
.view
)
705 self
.__stage
_hunk
_action
= menu
.addAction(
706 self
.tr('Stage Hunk For Commit'), self
.stage_hunk
)
708 self
.__stage
_hunk
_selection
_action
= menu
.addAction(
709 self
.tr('Stage Selected Lines'),
710 self
.stage_hunk_selection
)
712 self
.__unstage
_hunk
_action
= menu
.addAction(
713 self
.tr('Unstage Hunk From Commit'),
716 self
.__unstage
_hunk
_selection
_action
= menu
.addAction(
717 self
.tr('Unstage Selected Lines'),
718 self
.unstage_hunk_selection
)
720 self
.__copy
_action
= menu
.addAction(
721 self
.tr('Copy'), self
.view
.copy_display
)
723 self
.connect(self
.__diff
_menu
, 'aboutToShow()',
724 self
.diff_context_menu_about_to_show
)
726 def select_commits_gui(self
, title
, revs
, summaries
):
727 return select_commits(self
.model
, self
.view
, title
, revs
, summaries
)
729 def update_diff_font(self
):
730 font
= self
.model
.get_global_ugit_fontdiff()
733 qfont
.fromString(font
)
734 self
.view
.display_text
.setFont(qfont
)
735 self
.view
.commitmsg
.setFont(qfont
)
737 def update_ui_font(self
):
738 font
= self
.model
.get_global_ugit_fontui()
741 qfont
.fromString(font
)
742 QtGui
.qApp
.setFont(qfont
)
744 def init_log_window(self
):
745 branch
= self
.model
.get_currentbranch()
746 version
= defaults
.VERSION
747 qtutils
.log(self
.model
.get_git_version()
748 + '\nugit version '+ version
749 + '\nCurrent Branch: '+ branch
)
751 def start_inotify_thread(self
):
752 # Do we have inotify? If not, return.
753 # Recommend installing inotify if we're on Linux.
754 self
.inotify_thread
= None
756 from ugit
.inotify
import GitNotifier
757 qtutils
.log(self
.tr('inotify support: enabled'))
760 if platform
.system() == 'Linux':
763 'inotify: disabled\n'
764 'Note: To enable inotify, '
765 'install python-pyinotify.\n')
767 plat
= platform
.platform().lower()
768 if 'debian' in plat
or 'ubuntu' in plat
:
770 'On Debian or Ubuntu systems, '
771 'try: sudo apt-get install '
777 # Start the notification thread
778 self
.inotify_thread
= GitNotifier(self
, os
.getcwd())
779 self
.inotify_thread
.start()