Log window improvements
[ugit.git] / ugitlibs / controllers.py
blob9585f3ce223848f8626861ee80327fcccdb9db42
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 update_options
21 from utilcontroller import log_window
23 class Controller(QObserver):
24 '''The controller is a mediator between the model and view.
25 It allows for a clean decoupling between view and model classes.'''
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 self.__last_inotify_event = time.time()
35 # The diff-display context menu
36 self.__menu = None
37 self.__staged_diff_in_view = True
39 # Diff display context menu
40 view.display_text.contextMenuEvent = self.diff_context_menu_event
42 # Binds a specific model attribute to a view widget,
43 # and vice versa.
44 self.model_to_view('commitmsg', 'commit_text')
45 self.model_to_view('staged', 'staged_list')
46 self.model_to_view('all_unstaged', 'unstaged_list')
48 # When a model attribute changes, this runs a specific action
49 self.add_actions('staged', self.action_staged)
50 self.add_actions('all_unstaged', self.action_all_unstaged)
51 self.add_actions('global.ugit.fontdiff', self.update_diff_font)
52 self.add_actions('global.ugit.fontui', self.update_ui_font)
54 # Routes signals for multiple widgets to our callbacks
55 # defined below.
56 self.add_signals('textChanged()', view.commit_text)
57 self.add_signals('stateChanged(int)', view.untracked_checkbox)
59 self.add_signals('released()',
60 view.stage_button,
61 view.commit_button,
62 view.push_button,
63 view.signoff_button,)
65 self.add_signals('triggered()',
66 view.menu_rescan,
67 view.menu_options,
68 view.menu_create_branch,
69 view.menu_checkout_branch,
70 view.menu_rebase_branch,
71 view.menu_delete_branch,
72 view.menu_get_prev_commitmsg,
73 view.menu_commit,
74 view.menu_stage_changed,
75 view.menu_stage_untracked,
76 view.menu_stage_selected,
77 view.menu_unstage_all,
78 view.menu_unstage_selected,
79 view.menu_show_diffstat,
80 view.menu_browse_branch,
81 view.menu_browse_other_branch,
82 view.menu_visualize_all,
83 view.menu_visualize_current,
84 view.menu_export_patches,
85 view.menu_cherry_pick,
86 view.menu_load_commitmsg,
87 view.menu_cut,
88 view.menu_copy,
89 view.menu_paste,
90 view.menu_delete,
91 view.menu_select_all,
92 view.menu_undo,
93 view.menu_redo)
95 self.add_signals('itemClicked(QListWidgetItem *)',
96 view.staged_list,
97 view.unstaged_list)
99 self.add_signals('itemSelectionChanged()',
100 view.staged_list,
101 view.unstaged_list)
103 self.add_signals('splitterMoved(int,int)',
104 view.splitter_top, view.splitter_bottom)
106 # Vanilla signal/slots
107 self.connect(self.view.toolbar_show_log,
108 'triggered()', self.show_log)
110 # App cleanup
111 self.connect(QtGui.qApp, 'lastWindowClosed()',
112 self.last_window_closed)
114 # These callbacks are called in response to the signals
115 # defined above. One property of the QObserver callback
116 # mechanism is that the model is passed in as the first
117 # argument to the callback. This allows for a single
118 # controller to manage multiple models, though this
119 # isn't used at the moment.
120 self.add_callbacks(
121 # Actions that delegate directly to the model
122 signoff_button = model.add_signoff,
123 menu_get_prev_commitmsg = model.get_prev_commitmsg,
124 menu_stage_changed = self.model.stage_changed,
125 menu_stage_untracked = self.model.stage_untracked,
126 menu_unstage_all = self.model.unstage_all,
128 # Actions that delegate direclty to the view
129 menu_cut = view.action_cut,
130 menu_copy = view.action_copy,
131 menu_paste = view.action_paste,
132 menu_delete = view.action_delete,
133 menu_select_all = view.action_select_all,
134 menu_undo = view.action_undo,
135 menu_redo = view.action_redo,
137 # Push Buttons
138 stage_button = self.stage_selected,
139 commit_button = self.commit,
140 push_button = self.push,
142 # List Widgets
143 staged_list = self.diff_staged,
144 unstaged_list = self.diff_unstaged,
146 # Checkboxes
147 untracked_checkbox = self.rescan,
149 # Menu Actions
150 menu_options = self.options,
151 menu_rescan = self.rescan,
152 menu_create_branch = self.branch_create,
153 menu_delete_branch = self.branch_delete,
154 menu_checkout_branch = self.checkout_branch,
155 menu_rebase_branch = self.rebase,
156 menu_commit = self.commit,
157 menu_stage_selected = self.stage_selected,
158 menu_unstage_selected = self.unstage_selected,
159 menu_show_diffstat = self.show_diffstat,
160 menu_browse_branch = self.browse_current,
161 menu_browse_other_branch = self.browse_other,
162 menu_visualize_current = self.viz_current,
163 menu_visualize_all = self.viz_all,
164 menu_export_patches = self.export_patches,
165 menu_cherry_pick = self.cherry_pick,
166 menu_load_commitmsg = self.load_commitmsg,
168 # Splitters
169 splitter_top = self.splitter_top_event,
170 splitter_bottom = self.splitter_bottom_event,
173 # Handle double-clicks in the staged/unstaged lists.
174 # These are vanilla signal/slots since the qobserver
175 # signal routing is already handling these lists' signals.
176 self.connect(view.unstaged_list,
177 'itemDoubleClicked(QListWidgetItem*)',
178 self.stage_selected)
180 self.connect(view.staged_list,
181 'itemDoubleClicked(QListWidgetItem*)',
182 self.unstage_selected )
184 # Delegate window move events here
185 view.moveEvent = self.move_event
186 view.resizeEvent = self.resize_event
188 # Initialize the GUI
189 self.load_window_settings()
191 # Setup the inotify watchdog
192 self.start_inotify_thread()
193 self.refresh_view()
195 self.init_log()
196 self.rescan()
198 #####################################################################
199 # event() is called in response to messages from the inotify thread
201 def event(self, msg):
202 if msg.type() == defaults.INOTIFY_EVENT:
203 self.rescan()
204 return True
205 else:
206 return False
208 #####################################################################
209 # Actions triggered during model updates
211 def action_staged(self, widget):
212 qtutils.update_listwidget(widget,
213 self.model.get_staged(), staged=True)
215 def action_all_unstaged(self, widget):
216 qtutils.update_listwidget(widget,
217 self.model.get_unstaged(), staged=False)
219 if self.view.untracked_checkbox.isChecked():
220 qtutils.update_listwidget(widget,
221 self.model.get_untracked(),
222 staged=False,
223 append=True,
224 untracked=True)
226 #####################################################################
227 # Qt callbacks
229 def show_log(self, *rest):
230 qtutils.toggle_log_window()
232 def options(self):
233 update_options(self.model, self.view)
235 def branch_create(self):
236 if create_new_branch(self.model, self.view):
237 self.rescan()
239 def branch_delete(self):
240 branch = choose_branch('Delete Branch',
241 self.view, self.model.get_local_branches())
242 if not branch: return
243 self.log_output(self.model.delete_branch(branch))
245 def browse_current(self):
246 branch = self.model.get_branch()
247 browse_git_branch(self.model, self.view, branch)
249 def browse_other(self):
250 # Prompt for a branch to browse
251 branch = choose_branch('Browse Branch Files',
252 self.view, self.model.get_all_branches())
253 if not branch: return
254 # Launch the repobrowser
255 browse_git_branch(self.model, self.view, branch)
257 def checkout_branch(self):
258 branch = choose_branch('Checkout Branch',
259 self.view, self.model.get_local_branches())
260 if not branch: return
261 self.log_output(self.model.checkout(branch))
263 def cherry_pick(self):
264 commits = self.select_commits_gui(*self.model.log(all=True))
265 if not commits: return
266 self.log_output(self.model.cherry_pick(commits))
268 def commit(self):
269 msg = self.model.get_commitmsg()
270 if not msg:
271 error_msg = self.tr(""
272 + "Please supply a commit message.\n"
273 + "\n"
274 + "A good commit message has the following format:\n"
275 + "\n"
276 + "- First line: Describe in one sentence what you did.\n"
277 + "- Second line: Blank\n"
278 + "- Remaining lines: Describe why this change is good.\n")
279 qtutils.show_output(error_msg)
280 return
282 files = self.model.get_staged()
283 if not files:
284 error_msg = self.tr(""
285 + "No changes to commit.\n"
286 + "\n"
287 + "You must stage at least 1 file before you can commit.\n")
288 qtutils.show_output(error_msg)
289 return
291 # Perform the commit
292 output = self.model.commit(
293 msg, amend=self.view.amend_radio.isChecked())
295 # Reset state
296 self.view.new_commit_radio.setChecked(True)
297 self.view.amend_radio.setChecked(False)
298 self.model.set_commitmsg('')
299 self.log_output(output, alert=True)
301 def view_diff(self, staged=True):
302 self.__staged_diff_in_view = staged
303 if self.__staged_diff_in_view:
304 widget = self.view.staged_list
305 else:
306 widget = self.view.unstaged_list
307 row, selected = qtutils.get_selected_row(widget)
308 if not selected:
309 self.view.reset_display()
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))
317 # use *rest to handle being called from different signals
318 def diff_staged(self, *rest):
319 self.view_diff(staged=True)
321 # use *rest to handle being called from different signals
322 def diff_unstaged(self,*rest):
323 self.view_diff(staged=False)
325 def export_patches(self):
326 (revs, summaries) = self.model.log()
327 commits = self.select_commits_gui(revs, summaries)
328 if not commits: return
329 qtutils.show_output(self.model.format_patch(commits))
331 def last_window_closed(self):
332 '''Save config settings and cleanup any inotify threads.'''
334 self.model.save_window_geom()
336 if not self.inotify_thread: return
337 if not self.inotify_thread.isRunning(): return
339 self.inotify_thread.abort = True
340 self.inotify_thread.terminate()
341 self.inotify_thread.wait()
343 def load_commitmsg(self):
344 file = qtutils.open_dialog(self.view,
345 'Load Commit Message...', defaults.DIRECTORY)
347 if file:
348 defaults.DIRECTORY = os.path.dirname(file)
349 slushy = utils.slurp(file)
350 if slushy: self.model.set_commitmsg(slushy)
352 def rebase(self):
353 branch = choose_branch('Rebase Branch',
354 self.view, self.model.get_local_branches())
355 if not branch: return
356 self.log_output(self.model.rebase(branch))
358 # use *rest to handle being called from the checkbox signal
359 def rescan(self, *rest):
360 '''Populates view widgets with results from "git status."'''
361 self.model.update_status()
362 self.view.setWindowTitle('%s [%s]' % (
363 self.model.get_project(),
364 self.model.get_branch()))
366 if not self.model.has_squash_msg(): return
368 if self.model.get_commitmsg():
369 answer = qtutils.question(self.view,
370 self.tr('Import Commit Message?'),
371 self.tr('A commit message from an in-progress'
372 + ' merge was found.\nImport it?'))
374 if not answer: return
376 # Set the new commit message
377 self.model.set_squash_msg()
379 def push(self):
380 push_branches(self.model, self.view)
382 def show_diffstat(self):
383 '''Show the diffstat from the latest commit.'''
384 qtutils.show_output(self.model.diff_stat())
386 #####################################################################
387 # diff gui
388 def process_diff_selection(self, items, widget,
389 cached=True, selected=False, reverse=True, noop=False):
391 filename = qtutils.get_selected_item(widget, items)
392 if not filename: return
393 parser = utils.DiffParser(self.model, filename=filename,
394 cached=cached)
395 offset, selection = self.view.diff_selection()
396 parser.process_diff_selection(selected, offset, selection)
397 self.rescan()
398 def stage_hunk(self):
399 self.process_diff_selection(
400 self.model.get_unstaged(),
401 self.view.unstaged_list,
402 cached=False)
403 def stage_hunk_selection(self):
404 self.process_diff_selection(
405 self.model.get_unstaged(),
406 self.view.unstaged_list,
407 cached=False,
408 selected=True)
409 def unstage_hunk(self, cached=True):
410 self.process_diff_selection(
411 self.model.get_staged(),
412 self.view.staged_list,
413 cached=True)
414 def unstage_hunk_selection(self):
415 self.process_diff_selection(
416 self.model.get_staged(),
417 self.view.staged_list,
418 cached=True,
419 selected=True)
421 # #######################################################################
422 # end diff gui
424 # use *rest to handle being called from different signals
425 def stage_selected(self,*rest):
426 '''Use "git add" to add items to the git index.
427 This is a thin wrapper around apply_to_list.'''
428 command = self.model.add_or_remove
429 widget = self.view.unstaged_list
430 items = self.model.get_all_unstaged()
431 self.apply_to_list(command,widget,items)
433 # use *rest to handle being called from different signals
434 def unstage_selected(self, *rest):
435 '''Use "git reset" to remove items from the git index.
436 This is a thin wrapper around apply_to_list.'''
437 command = self.model.reset
438 widget = self.view.staged_list
439 items = self.model.get_staged()
440 self.apply_to_list(command, widget, items)
442 def viz_all(self):
443 '''Visualizes the entire git history using gitk.'''
444 utils.fork('gitk','--all')
446 def viz_current(self):
447 '''Visualizes the current branch's history using gitk.'''
448 utils.fork('gitk', self.model.get_branch())
450 # These actions monitor window resizes, splitter changes, etc.
451 def move_event(self, event):
452 defaults.X = event.pos().x()
453 defaults.Y = event.pos().y()
455 def resize_event(self, event):
456 defaults.WIDTH = event.size().width()
457 defaults.HEIGHT = event.size().height()
459 def splitter_top_event(self,*rest):
460 sizes = self.view.splitter_top.sizes()
461 defaults.SPLITTER_TOP_0 = sizes[0]
462 defaults.SPLITTER_TOP_1 = sizes[1]
464 def splitter_bottom_event(self,*rest):
465 sizes = self.view.splitter_bottom.sizes()
466 defaults.SPLITTER_BOTTOM_0 = sizes[0]
467 defaults.SPLITTER_BOTTOM_1 = sizes[1]
469 def load_window_settings(self):
470 (w,h,x,y,
471 st0,st1,
472 sb0,sb1) = self.model.get_window_geom()
473 self.view.resize(w,h)
474 self.view.move(x,y)
475 self.view.splitter_top.setSizes([st0,st1])
476 self.view.splitter_bottom.setSizes([sb0,sb1])
478 def log_output(self, output, rescan=True, alert=False):
479 '''Logs output and optionally rescans for changes.'''
480 if rescan: self.rescan()
481 qtutils.log_output(output, alert=alert)
483 #####################################################################
486 def apply_to_list(self, command, widget, items):
487 '''This is a helper method that retrieves the current
488 selection list, applies a command to that list,
489 displays a dialog showing the output of that command,
490 and calls rescan to pickup changes.'''
491 apply_items = qtutils.get_selection_list(widget, items)
492 output = command(apply_items)
493 self.rescan()
494 return output
496 def diff_context_menu_about_to_show(self):
497 unstaged_item = qtutils.get_selected_item(
498 self.view.unstaged_list,
499 self.model.get_all_unstaged())
501 is_tracked= unstaged_item not in self.model.get_untracked()
503 enable_staged= (
504 unstaged_item
505 and not self.__staged_diff_in_view
506 and is_tracked)
508 enable_unstaged= (
509 self.__staged_diff_in_view
510 and qtutils.get_selected_item(
511 self.view.staged_list,
512 self.model.get_staged()))
514 self.__stage_hunk_action.setEnabled(bool(enable_staged))
515 self.__stage_hunk_selection_action.setEnabled(bool(enable_staged))
517 self.__unstage_hunk_action.setEnabled(bool(enable_unstaged))
518 self.__unstage_hunk_selection_action.setEnabled(bool(enable_unstaged))
520 def diff_context_menu_event(self, event):
521 self.diff_context_menu_setup()
522 textedit = self.view.display_text
523 self.__menu.exec_(textedit.mapToGlobal(event.pos()))
525 def diff_context_menu_setup(self):
526 if self.__menu: return
528 menu = self.__menu = QMenu(self.view)
529 self.__stage_hunk_action = menu.addAction(
530 self.tr('Stage Hunk For Commit'), self.stage_hunk)
532 self.__stage_hunk_selection_action = menu.addAction(
533 self.tr('Stage Selected Lines'),
534 self.stage_hunk_selection)
536 self.__unstage_hunk_action = menu.addAction(
537 self.tr('Unstage Hunk From Commit'),
538 self.unstage_hunk)
540 self.__unstage_hunk_selection_action = menu.addAction(
541 self.tr('Unstage Selected Lines'),
542 self.unstage_hunk_selection)
544 self.__copy_action = menu.addAction(
545 self.tr('Copy'), self.view.copy_display)
547 self.connect(self.__menu, 'aboutToShow()',
548 self.diff_context_menu_about_to_show)
550 def select_commits_gui(self, revs, summaries):
551 return select_commits(self.model, self.view, revs, summaries)
553 def update_diff_font(self):
554 font = self.model.get_param('global.ugit.fontdiff')
555 if not font: return
556 qfont = QFont()
557 qfont.fromString(font)
558 self.view.display_text.setFont(qfont)
560 def update_ui_font(self):
561 font = self.model.get_param('global.ugit.fontui')
562 if not font: return
563 qfont = QFont()
564 qfont.fromString(font)
565 QtGui.qApp.setFont(qfont)
567 def init_log(self):
568 qtutils.log_output(self.model.get_git_version()
569 + os.linesep
570 + 'ugit version ' + defaults.VERSION
571 + os.linesep
572 + 'Current Branch: ' + self.model.get_branch())
574 def start_inotify_thread(self):
575 # Do we have inotify? If not, return.
576 # Recommend installing inotify if we're on Linux.
577 self.inotify_thread = None
578 try:
579 from inotify import GitNotifier
580 except ImportError:
581 import platform
582 if platform.system() == 'Linux':
583 msg =(self.tr('Unable import pyinotify.\n'
584 + 'inotify support has been'
585 + 'disabled.')
586 + '\n\n')
588 plat = platform.platform().lower()
589 if 'debian' in plat or 'ubuntu' in plat:
590 msg += (self.tr('Hint:')
591 + 'sudo apt-get install'
592 + ' python-pyinotify')
594 qtutils.information(self.view,
595 self.tr('inotify disabled'), msg)
596 return
597 # Start the notification thread
598 self.inotify_thread = GitNotifier(self, os.getcwd())
599 self.inotify_thread.start()