Added interactive diff gui
[ugit.git] / py / controllers.py
blob05db82ac45ee7d4c69190a3a3cf0f6feff53920a
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 from views import GitCommitBrowser
12 from views import GitBranchDialog
13 from views import GitCreateBranchDialog
14 from repobrowsercontroller import GitRepoBrowserController
15 from createbranchcontroller import GitCreateBranchController
17 class GitController (QObserver):
18 '''The controller is a mediator between the model and view.
19 It allows for a clean decoupling between view and model classes.'''
21 def __init__ (self, model, view):
22 QObserver.__init__ (self, model, view)
24 # chdir to the root of the git tree. This is critical
25 # to being able to properly use the git porcelain.
26 cdup = cmds.git_show_cdup()
27 if cdup: os.chdir (cdup)
29 # The diff-display context menu
30 self.__menu = None
31 self.__staged_diff_in_view = True
33 # Diff display context menu
34 view.displayText.controller = self
35 view.displayText.contextMenuEvent = self.__menu_event
37 # Default to creating a new commit (i.e. not an amend commit)
38 view.newCommitRadio.setChecked (True)
40 # Binds a specific model attribute to a view widget,
41 # and vice versa.
42 self.model_to_view (model, 'commitmsg', 'commitText')
44 # When a model attribute changes, this runs a specific action
45 self.add_actions (model, 'staged', self.action_staged)
46 self.add_actions (model, 'unstaged', self.action_unstaged)
47 self.add_actions (model, 'untracked', self.action_unstaged)
49 # Routes signals for multiple widgets to our callbacks
50 # defined below.
51 self.add_signals ('textChanged()', view.commitText)
52 self.add_signals ('stateChanged(int)', view.untrackedCheckBox)
54 self.add_signals ('released()',
55 view.stageButton,
56 view.commitButton,
57 view.pushButton,
58 view.signOffButton,)
60 self.add_signals ('triggered()',
61 view.rescan,
62 view.createBranch,
63 view.checkoutBranch,
64 view.rebaseBranch,
65 view.deleteBranch,
66 view.commitAll,
67 view.commitSelected,
68 view.setCommitMessage,
69 view.stageChanged,
70 view.stageUntracked,
71 view.stageSelected,
72 view.unstageAll,
73 view.unstageSelected,
74 view.showDiffstat,
75 view.browseBranch,
76 view.browseOtherBranch,
77 view.visualizeAll,
78 view.visualizeCurrent,
79 view.exportPatches,
80 view.cherryPick,)
82 self.add_signals ('itemClicked (QListWidgetItem *)',
83 view.stagedList, view.unstagedList,)
85 self.add_signals ('itemSelectionChanged()',
86 view.stagedList, view.unstagedList,)
88 # App cleanup
89 self.connect ( qtutils.qapp(),
90 'lastWindowClosed()',
91 self.cb_last_window_closed )
93 # Handle double-clicks in the staged/unstaged lists.
94 # These are vanilla signal/slots since the qobserver
95 # signal routing is already handling these lists' signals.
96 self.connect ( view.unstagedList,
97 'itemDoubleClicked(QListWidgetItem*)',
98 lambda (x): self.cb_stage_selected (model) )
100 self.connect ( view.stagedList,
101 'itemDoubleClicked(QListWidgetItem*)',
102 lambda (x): self.cb_unstage_selected (model) )
104 # These callbacks are called in response to the signals
105 # defined above. One property of the QObserver callback
106 # mechanism is that the model is passed in as the first
107 # argument to the callback. This allows for a single
108 # controller to manage multiple models, though this
109 # isn't used at the moment.
110 self.add_callbacks (model, {
111 # Push Buttons
112 'stageButton': self.cb_stage_selected,
113 'signOffButton': lambda(m): m.add_signoff(),
114 'commitButton': self.cb_commit,
115 # Checkboxes
116 'untrackedCheckBox': self.cb_rescan,
117 # List Widgets
118 'stagedList': self.cb_diff_staged,
119 'unstagedList': self.cb_diff_unstaged,
120 # Menu Actions
121 'rescan': self.cb_rescan,
122 'createBranch': self.cb_branch_create,
123 'deleteBranch': self.cb_branch_delete,
124 'checkoutBranch': self.cb_checkout_branch,
125 'rebaseBranch': self.cb_rebase,
126 'commitAll': self.cb_commit_all,
127 'commitSelected': self.cb_commit_selected,
128 'setCommitMessage':
129 lambda(m): m.set_latest_commitmsg(),
130 'stageChanged': self.cb_stage_changed,
131 'stageUntracked': self.cb_stage_untracked,
132 'stageSelected': self.cb_stage_selected,
133 'unstageAll': self.cb_unstage_all,
134 'unstageSelected': self.cb_unstage_selected,
135 'showDiffstat': self.cb_show_diffstat,
136 'browseBranch': self.cb_browse_current,
137 'browseOtherBranch': self.cb_browse_other,
138 'visualizeCurrent': self.cb_viz_current,
139 'visualizeAll': self.cb_viz_all,
140 'exportPatches': self.cb_export_patches,
141 'cherryPick': self.cb_cherry_pick,
144 # Initialize the GUI
145 self.cb_rescan (model)
147 # Setup the inotify server
148 self.__start_inotify_thread (model)
150 #####################################################################
151 # MODEL ACTIONS
152 #####################################################################
154 def action_staged (self, model):
155 '''This action is called when the model's staged list
156 changes. This is a thin wrapper around update_list_widget.'''
157 list_widget = self.view.stagedList
158 staged = model.get_staged()
159 self.__update_list_widget (list_widget, staged, True)
161 def action_unstaged (self, model):
162 '''This action is called when the model's unstaged list
163 changes. This is a thin wrapper around update_list_widget.'''
164 list_widget = self.view.unstagedList
165 unstaged = model.get_unstaged()
166 self.__update_list_widget (list_widget, unstaged, False)
167 if self.view.untrackedCheckBox.isChecked():
168 untracked = model.get_untracked()
169 self.__update_list_widget (list_widget, untracked,
170 append=True,
171 staged=False,
172 untracked=True)
174 #####################################################################
175 # CALLBACKS
176 #####################################################################
178 def cb_branch_create (self, model):
179 view = GitCreateBranchDialog (self.view)
180 controller = GitCreateBranchController (model, view)
181 view.show()
182 result = view.exec_()
183 if result == QDialog.Accepted:
184 self.cb_rescan (model)
186 def cb_branch_delete (self, model):
187 dlg = GitBranchDialog(self.view, branches=cmds.git_branch())
188 branch = dlg.getSelectedBranch()
189 if not branch: return
190 qtutils.show_command (self.view,
191 cmds.git_branch(name=branch, delete=True))
194 def cb_browse_current (self, model):
195 self.__browse_branch (cmds.git_current_branch())
197 def cb_browse_other (self, model):
198 # Prompt for a branch to browse
199 branches = (cmds.git_branch (remote=False)
200 + cmds.git_branch (remote=True))
202 dialog = GitBranchDialog (self.view, branches=branches)
204 # Launch the repobrowser
205 self.__browse_branch (dialog.getSelectedBranch())
207 def cb_checkout_branch (self, model):
208 dlg = GitBranchDialog (self.view, cmds.git_branch())
209 branch = dlg.getSelectedBranch()
210 if not branch: return
211 qtutils.show_command (self.view, cmds.git_checkout(branch))
212 self.cb_rescan (model)
214 def cb_cherry_pick (self, model):
215 '''Starts a cherry-picking session.'''
216 (revs, summaries) = cmds.git_log (all=True)
217 selection, idxs = self.__select_commits (revs, summaries)
218 if not selection: return
220 output = cmds.git_cherry_pick (selection)
221 self.__show_command (output, model)
223 def cb_commit (self, model):
224 '''Sets up data and calls cmds.commit.'''
226 msg = model.get_commitmsg()
227 if not msg:
228 error_msg = 'ERROR: No commit message was provided.'
229 self.__show_command (error_msg)
230 return
232 amend = self.view.amendRadio.isChecked()
233 commit_all = self.view.commitAllCheckBox.isChecked()
235 files = []
236 if commit_all:
237 files = model.get_staged()
238 else:
239 wlist = self.view.stagedList
240 mlist = model.get_staged()
241 files = qtutils.get_selection_from_list (wlist, mlist)
242 # Perform the commit
243 output = cmds.git_commit (msg, amend, files)
245 # Reset state
246 self.view.newCommitRadio.setChecked (True)
247 self.view.amendRadio.setChecked (False)
248 model.set_commitmsg ('')
249 self.__show_command (output, model)
251 def cb_commit_all (self, model):
252 '''Sets the commit-all checkbox and runs cb_commit.'''
253 self.view.commitAllCheckBox.setChecked (True)
254 self.cb_commit (model)
256 def cb_commit_selected (self, model):
257 '''Unsets the commit-all checkbox and runs cb_commit.'''
258 self.view.commitAllCheckBox.setChecked (False)
259 self.cb_commit (model)
261 def cb_commit_sha1_selected (self, browser, revs):
262 '''This callback is called when a commit browser's
263 item is selected. This callback puts the current
264 revision sha1 into the commitText field.
265 This callback also puts shows the commit in the
266 browser's commit textedit and copies it into
267 the global clipboard/selection.'''
268 current = browser.commitList.currentRow()
269 item = browser.commitList.item (current)
270 if not item.isSelected():
271 browser.commitText.setText ('')
272 browser.revisionLine.setText ('')
273 return
275 # Get the commit's sha1 and put it in the revision line
276 sha1 = revs[current]
277 browser.revisionLine.setText (sha1)
278 browser.revisionLine.selectAll()
280 # Lookup the info for that sha1 and display it
281 commit_diff = cmds.git_show (sha1)
282 browser.commitText.setText (commit_diff)
284 # Copy the sha1 into the clipboard
285 qtutils.set_clipboard (sha1)
287 def cb_copy (self):
288 cursor = self.view.displayText.textCursor()
289 selection = cursor.selection().toPlainText()
290 qtutils.set_clipboard (selection)
292 # use *args to handle being called from different signals
293 def cb_diff_staged (self, model, *args):
294 self.__staged_diff_in_view = True
295 list_widget = self.view.stagedList
296 row, selected = qtutils.get_selected_row (list_widget)
298 if not selected:
299 self.view.displayText.setText ('')
300 return
302 filename = model.get_staged()[row]
303 diff = cmds.git_diff (filename, staged=True)
305 if os.path.exists (filename):
306 pre = utils.header ('Staged for commit')
307 else:
308 pre = utils.header ('Staged for removal')
310 self.view.displayText.setText (pre + diff)
312 # use *args to handle being called from different signals
313 def cb_diff_unstaged (self, model, *args):
314 self.__staged_diff_in_view = False
315 list_widget = self.view.unstagedList
316 row, selected = qtutils.get_selected_row (list_widget)
317 if not selected:
318 self.view.displayText.setText ('')
319 return
320 filename = (model.get_unstaged() + model.get_untracked())[row]
321 if os.path.isdir (filename):
322 pre = utils.header ('Untracked directory')
323 cmd = 'ls -la %s' % utils.shell_quote (filename)
324 output = commands.getoutput (cmd)
325 self.view.displayText.setText ( pre + output )
326 return
328 if filename in model.get_unstaged():
329 diff = cmds.git_diff (filename, staged=False)
330 msg = utils.header ('Modified, unstaged') + diff
331 else:
332 # untracked file
333 cmd = 'file -b %s' % utils.shell_quote (filename)
334 file_type = commands.getoutput (cmd)
336 if 'binary' in file_type or 'data' in file_type:
337 sq_filename = utils.shell_quote (filename)
338 cmd = 'hexdump -C %s' % sq_filename
339 contents = commands.getoutput (cmd)
340 else:
341 file = open (filename, 'r')
342 contents = file.read()
343 file.close()
345 msg = (utils.header ('Untracked file: ' + file_type)
346 + contents)
348 self.view.displayText.setText (msg)
350 def cb_export_patches (self, model):
351 '''Launches the commit browser and exports the selected
352 patches.'''
354 (revs, summaries) = cmds.git_log ()
355 selection, idxs = self.__select_commits (revs, summaries)
356 if not selection: return
358 # now get the selected indices to determine whether
359 # a range of consecutive commits were selected
360 selected_range = range (idxs[0], idxs[-1] + 1)
361 export_range = len (idxs) > 1 and idxs == selected_range
363 output = cmds.git_format_patch (selection, export_range)
364 self.__show_command (output)
366 def cb_get_commit_msg (self, model):
367 model.retrieve_latest_commitmsg()
369 def cb_last_window_closed (self):
370 '''Cleanup the inotify thread if it exists.'''
371 if not self.inotify_thread: return
372 if not self.inotify_thread.isRunning(): return
373 self.inotify_thread.abort = True
374 self.inotify_thread.quit()
375 self.inotify_thread.wait()
377 def cb_rebase (self, model):
378 dlg = GitBranchDialog(self.view, cmds.git_branch())
379 dlg.setWindowTitle ("Select the current branch's new root")
380 branch = dlg.getSelectedBranch()
381 if not branch: return
382 qtutils.show_command (self.view, cmds.git_rebase (branch))
384 def cb_rescan (self, model, *args):
385 '''Populates view widgets with results from "git status."'''
387 # Scan for branch changes
388 self.__set_branch_ui_items()
390 # Rescan for repo updates
391 model.update_status()
393 if not model.has_squash_msg(): return
395 if model.get_commitmsg():
396 if not qtutils.question (self.view,
397 'Import Commit Message?',
398 ('A commit message from a '
399 + 'merge-in-progress was found.\n'
400 + 'Do you want to import it?')):
401 return
403 # Set the new commit message
404 model.set_commitmsg (model.get_squash_msg())
406 def cb_show_diffstat (self, model):
407 '''Show the diffstat from the latest commit.'''
408 self.__show_command (cmds.git_diff_stat(), rescan=False)
410 def cb_stage_changed (self, model):
411 '''Stage all changed files for commit.'''
412 output = cmds.git_add (model.get_unstaged())
413 self.__show_command (output, model)
415 def cb_stage_hunk (self):
417 list_widget = self.view.unstagedList
418 row, selected = qtutils.get_selected_row (list_widget)
419 if not selected: return
421 model = self.model
422 filename = model.get_uncommitted_item (row)
424 if not os.path.exists (filename): return
425 if os.path.isdir (filename): return
427 cursor = self.view.displayText.textCursor()
428 offset = cursor.position()
429 offset -= utils.HEADER_LENGTH + 1
430 if offset < 0: return
432 selection = cursor.selection().toPlainText()
434 num_selected_lines = selection.count (os.linesep)
435 has_selection = selection and num_selected_lines > 0
437 header, diff = cmds.git_diff (filename,
438 with_diff_header=True,
439 staged=False)
441 parser = utils.DiffParser (diff)
443 if has_selection:
444 start = diff.index (selection)
445 end = start + len (selection)
446 diffs = parser.get_diffs_for_range (start, end)
447 else:
448 diffs = [ parser.get_diff_for_offset (offset) ]
450 if not diffs: return
452 for diff in diffs:
453 tmpfile = utils.get_tmp_filename()
454 file = open (tmpfile, 'w')
455 file.write (header + os.linesep + diff + os.linesep)
456 file.close()
457 model.apply_diff (tmpfile)
458 os.unlink (tmpfile)
460 self.cb_rescan (model)
462 def cb_stage_selected (self, model):
463 '''Use "git add" to add items to the git index.
464 This is a thin wrapper around __apply_to_list.'''
465 command = cmds.git_add_or_remove
466 widget = self.view.unstagedList
467 items = model.get_unstaged() + model.get_untracked()
468 self.__apply_to_list (command, model, widget, items)
470 def cb_stage_untracked (self, model):
471 '''Stage all untracked files for commit.'''
472 output = cmds.git_add (model.get_untracked())
473 self.__show_command (output, model)
475 def cb_unstage_all (self, model):
476 '''Use "git reset" to remove all items from the git index.'''
477 output = cmds.git_reset (model.get_staged())
478 self.__show_command (output, model)
480 def cb_unstage_selected (self, model):
481 '''Use "git reset" to remove items from the git index.
482 This is a thin wrapper around __apply_to_list.'''
484 command = cmds.git_reset
485 widget = self.view.stagedList
486 items = model.get_staged()
487 self.__apply_to_list (command, model, widget, items)
489 def cb_viz_all (self, model):
490 '''Visualizes the entire git history using gitk.'''
491 os.system ('gitk --all &')
493 def cb_viz_current (self, model):
494 '''Visualizes the current branch's history using gitk.'''
495 branch = cmds.git_current_branch()
496 os.system ('gitk %s &' % utils.shell_quote (branch))
498 #####################################################################
499 # PRIVATE HELPER METHODS
500 #####################################################################
502 def __apply_to_list (self, command, model, widget, items):
503 '''This is a helper method that retrieves the current
504 selection list, applies a command to that list,
505 displays a dialog showing the output of that command,
506 and calls cb_rescan to pickup changes.'''
507 apply_items = qtutils.get_selection_from_list (widget, items)
508 output = command (apply_items)
509 self.__show_command (output, model)
511 def __browse_branch (self, branch):
512 if not branch: return
513 # Clone the model to allow opening multiple browsers
514 # with different sets of data
515 model = self.model.clone()
516 model.set_branch (branch)
517 view = GitCommitBrowser()
518 controller = GitRepoBrowserController(model, view)
519 view.show()
520 view.exec_()
522 def __menu_about_to_show (self):
523 cursor = self.view.displayText.textCursor()
524 allow_hunk_staging = ( not self.__staged_diff_in_view
525 and cursor.position() > utils.HEADER_LENGTH )
527 self.__stage_hunk_action.setEnabled (allow_hunk_staging)
529 def __menu_event (self, event):
530 self.__menu_setup()
531 textedit = self.view.displayText
532 self.__menu.exec_ (textedit.mapToGlobal (event.pos()))
534 def __menu_setup (self):
535 if self.__menu: return
537 menu = QMenu (self.view)
538 stage = menu.addAction ('Stage Hunk(s)', self.cb_stage_hunk)
539 copy = menu.addAction ('Copy', self.cb_copy)
541 self.connect (menu, 'aboutToShow()', self.__menu_about_to_show)
543 self.__stage_hunk_action = stage
544 self.__copy_action = copy
545 self.__menu = menu
548 def __file_to_widget_item (self, filename, staged, untracked=False):
549 '''Given a filename, return a QListWidgetItem suitable
550 for adding to a QListWidget. "staged" controls whether
551 to use icons for the staged or unstaged list widget.'''
553 if staged:
554 icon_file = utils.get_staged_icon (filename)
555 elif untracked:
556 icon_file = utils.get_untracked_icon()
557 else:
558 icon_file = utils.get_icon (filename)
560 return qtutils.create_listwidget_item (filename, icon_file)
562 def __select_commits (self, revs, summaries):
563 '''Use the GitCommitBrowser to select commits from a list.'''
564 if not summaries:
565 msg = 'ERROR: No commits exist in this branch.'''
566 self.__show_command (output=msg)
567 return ([],[])
569 browser = GitCommitBrowser (self.view)
570 self.connect ( browser.commitList,
571 'itemSelectionChanged()',
572 lambda: self.cb_commit_sha1_selected(
573 browser, revs) )
575 for summary in summaries:
576 browser.commitList.addItem (summary)
578 browser.show()
579 result = browser.exec_()
580 if result != QDialog.Accepted:
581 return ([],[])
583 list_widget = browser.commitList
584 selection = qtutils.get_selection_from_list (list_widget, revs)
585 if not selection: return ([],[])
587 # also return the selected index numbers
588 index_nums = range (len (revs))
589 idxs = qtutils.get_selection_from_list (list_widget, index_nums)
591 return (selection, idxs)
593 def __set_branch_ui_items (self):
594 '''Sets up items that mention the current branch name.'''
595 current_branch = cmds.git_current_branch()
596 menu_text = 'Browse ' + current_branch + ' branch'
597 self.view.browseBranch.setText (menu_text)
599 status_text = 'Current branch: ' + current_branch
600 self.view.statusBar().showMessage (status_text)
602 def __start_inotify_thread (self, model):
603 # Do we have inotify? If not, return.
604 # Recommend installing inotify if we're on Linux.
605 self.inotify_thread = None
606 try:
607 from inotify import GitNotifier
608 except ImportError:
609 import platform
610 if platform.system() == 'Linux':
611 msg = ('ugit could not find python-inotify.'
612 + '\nSupport for inotify is disabled.')
614 plat = platform.platform().lower()
615 if 'debian' in plat or 'ubuntu' in plat:
616 msg += '\n\nHint: sudo apt-get install python-pyinotify'
618 qtutils.information (self.view,
619 'inotify support disabled',
620 msg)
621 return
623 self.inotify_thread = GitNotifier (os.getcwd())
624 self.connect ( self.inotify_thread, 'timeForRescan()',
625 lambda: self.cb_rescan (model) )
627 # Start the notification thread
628 self.inotify_thread.start()
630 def __show_command (self, output, model=None, rescan=True):
631 '''Shows output and optionally rescans for changes.'''
632 qtutils.show_command (self.view, output)
633 if rescan and model: self.cb_rescan (model)
635 def __update_list_widget (self, list_widget, items,
636 staged, untracked=False, append=False):
637 '''A helper method to populate a QListWidget with the
638 contents of modelitems.'''
639 if not append:
640 list_widget.clear()
641 for item in items:
642 qitem = self.__file_to_widget_item (item,
643 staged, untracked)
644 list_widget.addItem( qitem )