Added commit message menu, other fixes
[ugit.git] / ugitlibs / controllers.py
blobcb49912004f6573252502f011aa34d27ea42de5f
1 import os
2 import commands
3 from PyQt4 import QtGui
4 from PyQt4.QtGui import QDialog
5 from PyQt4.QtGui import QMessageBox
6 from PyQt4.QtGui import QMenu
7 from qobserver import QObserver
8 import cmds
9 import utils
10 import qtutils
11 import defaults
12 from views import GitCommitBrowser
13 from views import GitBranchDialog
14 from views import GitCreateBranchDialog
15 from repobrowsercontroller import GitRepoBrowserController
16 from createbranchcontroller import GitCreateBranchController
18 class GitController(QObserver):
19 '''The controller is a mediator between the model and view.
20 It allows for a clean decoupling between view and model classes.'''
22 def __init__(self, model, view):
23 QObserver.__init__(self, model, view)
25 # chdir to the root of the git tree. This is critical
26 # to being able to properly use the git porcelain.
27 cdup = cmds.git_show_cdup()
28 if cdup: os.chdir(cdup)
30 # The diff-display context menu
31 self.__menu = None
32 self.__staged_diff_in_view = True
34 # Diff display context menu
35 view.displayText.controller = self
36 view.displayText.contextMenuEvent = self.__menu_event
38 # Default to creating a new commit(i.e. not an amend commit)
39 view.newCommitRadio.setChecked(True)
41 # Binds a specific model attribute to a view widget,
42 # and vice versa.
43 self.model_to_view(model, 'commitmsg', 'commitText')
45 # When a model attribute changes, this runs a specific action
46 self.add_actions(model, 'staged', self.action_staged)
47 self.add_actions(model, 'unstaged', self.action_unstaged)
48 self.add_actions(model, 'untracked', self.action_unstaged)
50 # Routes signals for multiple widgets to our callbacks
51 # defined below.
52 self.add_signals('textChanged()', view.commitText)
53 self.add_signals('stateChanged(int)', view.untrackedCheckBox)
55 self.add_signals('released()',
56 view.stageButton,
57 view.commitButton,
58 view.pushButton,
59 view.signOffButton,)
61 self.add_signals('triggered()',
62 view.rescan,
63 view.createBranch,
64 view.checkoutBranch,
65 view.rebaseBranch,
66 view.deleteBranch,
67 view.commitAll,
68 view.commitSelected,
69 view.setCommitMessage,
70 view.stageChanged,
71 view.stageUntracked,
72 view.stageSelected,
73 view.unstageAll,
74 view.unstageSelected,
75 view.showDiffstat,
76 view.browseBranch,
77 view.browseOtherBranch,
78 view.visualizeAll,
79 view.visualizeCurrent,
80 view.exportPatches,
81 view.cherryPick,
82 view.loadCommitMsg,)
84 self.add_signals('itemClicked(QListWidgetItem *)',
85 view.stagedList, view.unstagedList,)
87 self.add_signals('itemSelectionChanged()',
88 view.stagedList, view.unstagedList,)
90 # App cleanup
91 self.connect(qtutils.qapp(),
92 'lastWindowClosed()',
93 self.last_window_closed)
95 # These callbacks are called in response to the signals
96 # defined above. One property of the QObserver callback
97 # mechanism is that the model is passed in as the first
98 # argument to the callback. This allows for a single
99 # controller to manage multiple models, though this
100 # isn't used at the moment.
101 self.add_callbacks(model, {
102 # Actions that delegate directly to the model
103 'signOffButton': model.add_signoff,
104 'setCommitMessage': model.get_prev_commitmsg,
105 # Push Buttons
106 'stageButton': self.stage_selected,
107 'commitButton': self.commit,
108 # List Widgets
109 'stagedList': self.diff_staged,
110 'unstagedList': self.diff_unstaged,
111 # Menu Actions
112 'rescan': self.rescan,
113 'untrackedCheckBox': self.rescan,
114 'createBranch': self.branch_create,
115 'deleteBranch': self.branch_delete,
116 'checkoutBranch': self.checkout_branch,
117 'rebaseBranch': self.rebase,
118 'commitAll': self.commit_all,
119 'commitSelected': self.commit_selected,
120 'stageChanged': self.stage_changed,
121 'stageUntracked': self.stage_untracked,
122 'stageSelected': self.stage_selected,
123 'unstageAll': self.unstage_all,
124 'unstageSelected': self.unstage_selected,
125 'showDiffstat': self.show_diffstat,
126 'browseBranch': self.browse_current,
127 'browseOtherBranch': self.browse_other,
128 'visualizeCurrent': self.viz_current,
129 'visualizeAll': self.viz_all,
130 'exportPatches': self.export_patches,
131 'cherryPick': self.cherry_pick,
132 'loadCommitMsg': self.load_commitmsg,
135 # Handle double-clicks in the staged/unstaged lists.
136 # These are vanilla signal/slots since the qobserver
137 # signal routing is already handling these lists' signals.
138 self.connect(view.unstagedList,
139 'itemDoubleClicked(QListWidgetItem*)',
140 self.stage_selected)
142 self.connect(view.stagedList,
143 'itemDoubleClicked(QListWidgetItem*)',
144 self.unstage_selected )
146 # Initialize the GUI
147 self.rescan()
149 # Setup the inotify server
150 self.__start_inotify_thread()
152 #####################################################################
153 # Actions
155 def action_staged(self,*rest):
156 '''This action is called when the model's staged list
157 changes. This is a thin wrapper around update_list_widget.'''
158 list_widget = self.view.stagedList
159 staged = self.model.get_staged()
160 self.__update_list_widget(list_widget, staged, True)
162 def action_unstaged(self,*rest):
163 '''This action is called when the model's unstaged list
164 changes. This is a thin wrapper around update_list_widget.'''
165 list_widget = self.view.unstagedList
166 unstaged = self.model.get_unstaged()
167 self.__update_list_widget(list_widget, unstaged, False)
169 if self.view.untrackedCheckBox.isChecked():
170 untracked = self.model.get_untracked()
171 self.__update_list_widget(list_widget, untracked,
172 append=True,
173 staged=False,
174 untracked=True)
176 #####################################################################
177 # Qt callbacks
179 def branch_create(self,*rest):
180 view = GitCreateBranchDialog(self.view)
181 controller = GitCreateBranchController(self.model, view)
182 view.show()
183 result = view.exec_()
184 if result == QDialog.Accepted:
185 self.rescan()
187 def branch_delete(self,*rest):
188 dlg = GitBranchDialog(self.view, branches=cmds.git_branch())
189 branch = dlg.getSelectedBranch()
190 if not branch: return
191 qtutils.show_command(self.view,
192 cmds.git_branch(name=branch, delete=True))
194 def browse_current(self,*rest):
195 self.__browse_branch(cmds.git_current_branch())
197 def browse_other(self,*rest):
198 # Prompt for a branch to browse
199 branches = self.model.all_branches()
200 dialog = GitBranchDialog(self.view, branches=branches)
202 # Launch the repobrowser
203 self.__browse_branch(dialog.getSelectedBranch())
205 def checkout_branch(self,*rest):
206 dlg = GitBranchDialog(self.view, cmds.git_branch())
207 branch = dlg.getSelectedBranch()
208 if not branch: return
209 qtutils.show_command(self.view, cmds.git_checkout(branch))
210 self.rescan()
212 def cherry_pick(self,*rest):
213 '''Starts a cherry-picking session.'''
214 (revs, summaries) = cmds.git_log(all=True)
215 selection, idxs = self.__select_commits(revs, summaries)
216 if not selection: return
218 output = cmds.git_cherry_pick(selection)
219 self.__show_command(output)
221 def commit(self, *rest):
222 '''Sets up data and calls cmds.commit.'''
223 msg = self.model.get_commitmsg()
224 if not msg:
225 error_msg = 'ERROR: No commit message was provided.'
226 self.__show_command(error_msg)
227 return
229 amend = self.view.amendRadio.isChecked()
230 commit_all = self.view.commitAllCheckBox.isChecked()
232 files = []
233 if commit_all:
234 files = self.model.get_staged()
235 else:
236 wlist = self.view.stagedList
237 mlist = self.model.get_staged()
238 files = qtutils.get_selection_from_list(wlist, mlist)
239 # Perform the commit
240 output = cmds.git_commit(msg, amend, files)
242 # Reset state
243 self.view.newCommitRadio.setChecked(True)
244 self.view.amendRadio.setChecked(False)
245 self.model.set_commitmsg('')
246 self.__show_command(output)
248 def commit_all(self,*rest):
249 '''Sets the commit-all checkbox and runs commit.'''
250 self.view.commitAllCheckBox.setChecked(True)
251 self.commit()
253 def commit_selected(self,*rest):
254 '''Unsets the commit-all checkbox and runs commit.'''
255 self.view.commitAllCheckBox.setChecked(False)
256 self.commit()
258 def commit_sha1_selected(self, browser, revs):
259 '''This callback is called when a commit browser's
260 item is selected. This callback puts the current
261 revision sha1 into the commitText field.
262 This callback also puts shows the commit in the
263 browser's commit textedit and copies it into
264 the global clipboard/selection.'''
265 current = browser.commitList.currentRow()
266 item = browser.commitList.item(current)
267 if not item.isSelected():
268 browser.commitText.setText('')
269 browser.revisionLine.setText('')
270 return
272 # Get the commit's sha1 and put it in the revision line
273 sha1 = revs[current]
274 browser.revisionLine.setText(sha1)
275 browser.revisionLine.selectAll()
277 # Lookup the info for that sha1 and display it
278 commit_diff = cmds.git_show(sha1)
279 browser.commitText.setText(commit_diff)
281 # Copy the sha1 into the clipboard
282 qtutils.set_clipboard(sha1)
284 def copy(self):
285 cursor = self.view.displayText.textCursor()
286 selection = cursor.selection().toPlainText()
287 qtutils.set_clipboard(selection)
289 # use *args to handle being called from different signals
290 def diff_staged(self, *rest):
291 self.__staged_diff_in_view = True
292 list_widget = self.view.stagedList
293 row, selected = qtutils.get_selected_row(list_widget)
295 if not selected:
296 self.view.displayText.setText('')
297 return
299 filename = self.model.get_staged()[row]
300 diff = cmds.git_diff(filename, staged=True)
302 if os.path.exists(filename):
303 pre = utils.header('Staged for commit')
304 else:
305 pre = utils.header('Staged for removal')
307 self.view.displayText.setText(pre + diff)
309 # use *args to handle being called from different signals
310 def diff_unstaged(self,*rest):
311 self.__staged_diff_in_view = False
312 list_widget = self.view.unstagedList
313 row, selected = qtutils.get_selected_row(list_widget)
314 if not selected:
315 self.view.displayText.setText('')
316 return
317 filename =(self.model.get_unstaged()
318 + self.model.get_untracked())[row]
319 if os.path.isdir(filename):
320 pre = utils.header('Untracked directory')
321 cmd = 'ls -la %s' % utils.shell_quote(filename)
322 output = commands.getoutput(cmd)
323 self.view.displayText.setText(pre + output )
324 return
326 if filename in self.model.get_unstaged():
327 diff = cmds.git_diff(filename, staged=False)
328 msg = utils.header('Modified, unstaged') + diff
329 else:
330 # untracked file
331 cmd = 'file -b %s' % utils.shell_quote(filename)
332 file_type = commands.getoutput(cmd)
334 if 'binary' in file_type or 'data' in file_type:
335 sq_filename = utils.shell_quote(filename)
336 cmd = 'hexdump -C %s' % sq_filename
337 contents = commands.getoutput(cmd)
338 else:
339 file = open(filename, 'r')
340 contents = file.read()
341 file.close()
343 msg =(utils.header('Untracked file: ' + file_type)
344 + contents)
346 self.view.displayText.setText(msg)
348 def export_patches(self,*rest):
349 '''Launches the commit browser and exports the selected
350 patches.'''
352 (revs, summaries) = cmds.git_log()
353 selection, idxs = self.__select_commits(revs, summaries)
354 if not selection: return
356 # now get the selected indices to determine whether
357 # a range of consecutive commits were selected
358 selected_range = range(idxs[0], idxs[-1] + 1)
359 export_range = len(idxs) > 1 and idxs == selected_range
361 output = cmds.git_format_patch(selection, export_range)
362 self.__show_command(output)
364 def get_commit_msg(self,*rest):
365 self.model.retrieve_latest_commitmsg()
367 def last_window_closed(self):
368 '''Cleanup the inotify thread if it exists.'''
369 if not self.inotify_thread: return
370 if not self.inotify_thread.isRunning(): return
371 self.inotify_thread.abort = True
372 self.inotify_thread.quit()
373 self.inotify_thread.wait()
375 def load_commitmsg(self,*args):
376 file = qtutils.open_dialog(self.view,
377 'Load Commit Message...',
378 defaults.DIRECTORY)
380 if file:
381 defaults.DIRECTORY = os.path.dirname(file)
382 slushy = utils.slurp(file)
383 self.model.set_commitmsg(slushy)
386 def rebase(self,*rest):
387 dlg = GitBranchDialog(self.view, cmds.git_branch())
388 dlg.setWindowTitle("Select the current branch's new root")
389 branch = dlg.getSelectedBranch()
390 if not branch: return
391 qtutils.show_command(self.view, cmds.git_rebase(branch))
393 def rescan(self, *args):
394 '''Populates view widgets with results from "git status."'''
395 # Scan for branch changes
396 self.__set_branch_ui_items()
398 # Rescan for repo updates
399 self.model.update_status()
401 if not self.model.has_squash_msg(): return
403 if self.model.get_commitmsg():
404 if not qtutils.question(self.view,
405 'Import Commit Message?',
406 ('A commit message from a '
407 + 'merge-in-progress was found.\n'
408 + 'Do you want to import it?')):
409 return
411 # Set the new commit message
412 self.model.set_commitmsg(self.model.get_squash_msg())
414 def show_diffstat(self,*rest):
415 '''Show the diffstat from the latest commit.'''
416 self.__show_command(cmds.git_diff_stat(), rescan=False)
418 def stage_changed(self,*rest):
419 '''Stage all changed files for commit.'''
420 output = cmds.git_add(self.model.get_unstaged())
421 self.__show_command(output)
423 def stage_hunk(self):
425 list_widget = self.view.unstagedList
426 row, selected = qtutils.get_selected_row(list_widget)
427 if not selected: return
429 filename = self.model.get_uncommitted_item(row)
431 if not os.path.exists(filename): return
432 if os.path.isdir(filename): return
434 cursor = self.view.displayText.textCursor()
435 offset = cursor.position()
436 offset -= utils.HEADER_LENGTH + 1
437 if offset < 0: return
439 selection = cursor.selection().toPlainText()
440 header, diff = cmds.git_diff(filename,
441 with_diff_header=True,
442 staged=False)
443 parser = utils.DiffParser(diff)
445 num_selected_lines = selection.count(os.linesep)
446 has_selection =(selection
447 and selection.count(os.linesep) > 0)
449 if has_selection:
450 start = diff.index(selection)
451 end = start + len(selection)
452 diffs = parser.get_diffs_for_range(start, end)
453 else:
454 diffs = [ parser.get_diff_for_offset(offset) ]
456 if not diffs: return
458 for diff in diffs:
459 tmpfile = utils.get_tmp_filename()
460 file = open(tmpfile, 'w')
461 file.write(header + os.linesep + diff + os.linesep)
462 file.close()
463 self.model.apply_diff(tmpfile)
464 os.unlink(tmpfile)
466 self.rescan()
468 def stage_untracked(self,*rest):
469 '''Stage all untracked files for commit.'''
470 output = cmds.git_add(self.model.get_untracked())
471 self.__show_command(output)
473 def stage_selected(self,*rest):
474 '''Use "git add" to add items to the git index.
475 This is a thin wrapper around __apply_to_list.'''
476 command = cmds.git_add_or_remove
477 widget = self.view.unstagedList
478 items = self.model.get_unstaged() + self.model.get_untracked()
479 self.__apply_to_list(command, widget, items)
481 def unstage_selected(self, *args):
482 '''Use "git reset" to remove items from the git index.
483 This is a thin wrapper around __apply_to_list.'''
484 command = cmds.git_reset
485 widget = self.view.stagedList
486 items = self.model.get_staged()
487 self.__apply_to_list(command, widget, items)
489 def unstage_all(self,*rest):
490 '''Use "git reset" to remove all items from the git index.'''
491 output = cmds.git_reset(self.model.get_staged())
492 self.__show_command(output)
494 def viz_all(self,*rest):
495 '''Visualizes the entire git history using gitk.'''
496 os.system('gitk --all &')
498 def viz_current(self,*rest):
499 '''Visualizes the current branch's history using gitk.'''
500 branch = cmds.git_current_branch()
501 os.system('gitk %s &' % utils.shell_quote(branch))
503 #####################################################################
506 def __apply_to_list(self, command, widget, items):
507 '''This is a helper method that retrieves the current
508 selection list, applies a command to that list,
509 displays a dialog showing the output of that command,
510 and calls rescan to pickup changes.'''
511 apply_items = qtutils.get_selection_from_list(widget, items)
512 command(apply_items)
513 self.rescan()
515 def __browse_branch(self, branch):
516 if not branch: return
517 # Clone the model to allow opening multiple browsers
518 # with different sets of data
519 model = self.model.clone()
520 model.set_branch(branch)
521 view = GitCommitBrowser()
522 controller = GitRepoBrowserController(model, view)
523 view.show()
524 view.exec_()
526 def __menu_about_to_show(self):
527 cursor = self.view.displayText.textCursor()
528 allow_hunk_staging =(not self.__staged_diff_in_view
529 and cursor.position() > utils.HEADER_LENGTH )
531 self.__stage_hunk_action.setEnabled(allow_hunk_staging)
533 def __menu_event(self, event):
534 self.__menu_setup()
535 textedit = self.view.displayText
536 self.__menu.exec_(textedit.mapToGlobal(event.pos()))
538 def __menu_setup(self):
539 if self.__menu: return
541 menu = QMenu(self.view)
542 stage = menu.addAction('Stage Hunk(s)', self.stage_hunk)
543 copy = menu.addAction('Copy', self.copy)
545 self.connect(menu, 'aboutToShow()', self.__menu_about_to_show)
547 self.__stage_hunk_action = stage
548 self.__copy_action = copy
549 self.__menu = menu
552 def __file_to_widget_item(self, filename, staged, untracked=False):
553 '''Given a filename, return a QListWidgetItem suitable
554 for adding to a QListWidget. "staged" controls whether
555 to use icons for the staged or unstaged list widget.'''
557 if staged:
558 icon_file = utils.get_staged_icon(filename)
559 elif untracked:
560 icon_file = utils.get_untracked_icon()
561 else:
562 icon_file = utils.get_icon(filename)
564 return qtutils.create_listwidget_item(filename, icon_file)
566 def __select_commits(self, revs, summaries):
567 '''Use the GitCommitBrowser to select commits from a list.'''
568 if not summaries:
569 msg = 'ERROR: No commits exist in this branch.'''
570 self.__show_command(output=msg)
571 return([],[])
573 browser = GitCommitBrowser(self.view)
574 self.connect(browser.commitList,
575 'itemSelectionChanged()',
576 lambda: self.commit_sha1_selected(
577 browser, revs) )
579 for summary in summaries:
580 browser.commitList.addItem(summary)
582 browser.show()
583 result = browser.exec_()
584 if result != QDialog.Accepted:
585 return([],[])
587 list_widget = browser.commitList
588 selection = qtutils.get_selection_from_list(list_widget, revs)
589 if not selection: return([],[])
591 # also return the selected index numbers
592 index_nums = range(len(revs))
593 idxs = qtutils.get_selection_from_list(list_widget, index_nums)
595 return(selection, idxs)
597 def __set_branch_ui_items(self):
598 '''Sets up items that mention the current branch name.'''
599 current_branch = cmds.git_current_branch()
600 menu_text = 'Browse ' + current_branch + ' branch'
601 self.view.browseBranch.setText(menu_text)
603 status_text = 'Current branch: ' + current_branch
604 self.view.statusBar().showMessage(status_text)
606 def __start_inotify_thread(self):
607 # Do we have inotify? If not, return.
608 # Recommend installing inotify if we're on Linux.
609 self.inotify_thread = None
610 try:
611 from inotify import GitNotifier
612 except ImportError:
613 import platform
614 if platform.system() == 'Linux':
615 msg =('ugit could not find python-inotify.'
616 + '\nSupport for inotify is disabled.')
618 plat = platform.platform().lower()
619 if 'debian' in plat or 'ubuntu' in plat:
620 msg += '\n\nHint: sudo apt-get install python-pyinotify'
622 qtutils.information(self.view,
623 'inotify support disabled', msg)
624 return
626 self.inotify_thread = GitNotifier(os.getcwd())
627 self.connect(self.inotify_thread,
628 'timeForRescan()', self.rescan)
630 # Start the notification thread
631 self.inotify_thread.start()
633 def __show_command(self, output, rescan=True):
634 '''Shows output and optionally rescans for changes.'''
635 qtutils.show_command(self.view, output)
636 if rescan: self.rescan()
638 def __update_list_widget(self, list_widget, items,
639 staged, untracked=False, append=False):
640 '''A helper method to populate a QListWidget with the
641 contents of modelitems.'''
642 if not append:
643 list_widget.clear()
644 for item in items:
645 qitem = self.__file_to_widget_item(item,
646 staged, untracked)
647 list_widget.addItem(qitem)