Allow launching multiple copies of the repo browser
[ugit.git] / py / controllers.py
blobc131f52241098ad48e63293e2223280ca67b7f5e
1 import os
2 import commands
3 from PyQt4.QtGui import QDialog
4 from PyQt4.QtGui import QMessageBox
5 from PyQt4.QtGui import QMenu
6 from qobserver import QObserver
7 import cmds
8 import utils
9 import qtutils
10 from views import GitCommitBrowser
11 from views import GitBranchDialog
12 from views import GitCreateBranchDialog
13 from repobrowsercontroller import GitRepoBrowserController
14 from createbranchcontroller import GitCreateBranchController
16 class GitController (QObserver):
17 '''The controller is a mediator between the model and view.
18 It allows for a clean decoupling between view and model classes.'''
20 def __init__ (self, model, view):
21 QObserver.__init__ (self, model, view)
23 # chdir to the root of the git tree. This is critical
24 # to being able to properly use the git porcelain.
25 cdup = cmds.git_show_cdup()
26 if cdup: os.chdir (cdup)
28 # The diff-display context menu
29 self.__menu = None
30 self.__staged_diff_in_view = True
32 # Diff display context menu
33 view.displayText.controller = self
34 view.displayText.contextMenuEvent = self.__menu_event
36 # Default to creating a new commit (i.e. not an amend commit)
37 view.newCommitRadio.setChecked (True)
39 # Binds a specific model attribute to a view widget,
40 # and vice versa.
41 self.model_to_view (model, 'commitmsg', 'commitText')
43 # When a model attribute changes, this runs a specific action
44 self.add_actions (model, 'staged', self.action_staged)
45 self.add_actions (model, 'unstaged', self.action_unstaged)
46 self.add_actions (model, 'untracked', self.action_unstaged)
48 # Routes signals for multiple widgets to our callbacks
49 # defined below.
50 self.add_signals ('textChanged()', view.commitText)
51 self.add_signals ('stateChanged(int)', view.untrackedCheckBox)
53 self.add_signals ('released()',
54 view.stageButton,
55 view.commitButton,
56 view.pushButton,
57 view.signOffButton,)
59 self.add_signals ('triggered()',
60 view.rescan,
61 view.createBranch,
62 view.checkoutBranch,
63 view.rebaseBranch,
64 view.deleteBranch,
65 view.commitAll,
66 view.commitSelected,
67 view.setCommitMessage,
68 view.stageChanged,
69 view.stageUntracked,
70 view.stageSelected,
71 view.unstageAll,
72 view.unstageSelected,
73 view.showDiffstat,
74 view.browseBranch,
75 view.browseOtherBranch,
76 view.visualizeAll,
77 view.visualizeCurrent,
78 view.exportPatches,
79 view.cherryPick,)
81 self.add_signals ('itemClicked (QListWidgetItem *)',
82 view.stagedList, view.unstagedList,)
84 self.add_signals ('itemSelectionChanged()',
85 view.stagedList, view.unstagedList,)
87 # App cleanup
88 self.connect ( qtutils.qapp(),
89 'lastWindowClosed()',
90 self.cb_last_window_closed )
92 # Handle double-clicks in the staged/unstaged lists.
93 # These are vanilla signal/slots since the qobserver
94 # signal routing is already handling these lists' signals.
95 self.connect ( view.unstagedList,
96 'itemDoubleClicked(QListWidgetItem*)',
97 lambda (x): self.cb_stage_selected (model) )
99 self.connect ( view.stagedList,
100 'itemDoubleClicked(QListWidgetItem*)',
101 lambda (x): self.cb_unstage_selected (model) )
103 # These callbacks are called in response to the signals
104 # defined above. One property of the QObserver callback
105 # mechanism is that the model is passed in as the first
106 # argument to the callback. This allows for a single
107 # controller to manage multiple models, though this
108 # isn't used at the moment.
109 self.add_callbacks (model, {
110 # Push Buttons
111 'stageButton': self.cb_stage_selected,
112 'signOffButton': lambda(m): m.add_signoff(),
113 'commitButton': self.cb_commit,
114 # Checkboxes
115 'untrackedCheckBox': self.cb_rescan,
116 # List Widgets
117 'stagedList': self.cb_diff_staged,
118 'unstagedList': self.cb_diff_unstaged,
119 # Menu Actions
120 'rescan': self.cb_rescan,
121 'createBranch': self.cb_branch_create,
122 'deleteBranch': self.cb_branch_delete,
123 'checkoutBranch': self.cb_checkout_branch,
124 'rebaseBranch': self.cb_rebase,
125 'commitAll': self.cb_commit_all,
126 'commitSelected': self.cb_commit_selected,
127 'setCommitMessage':
128 lambda(m): m.set_latest_commitmsg(),
129 'stageChanged': self.cb_stage_changed,
130 'stageUntracked': self.cb_stage_untracked,
131 'stageSelected': self.cb_stage_selected,
132 'unstageAll': self.cb_unstage_all,
133 'unstageSelected': self.cb_unstage_selected,
134 'showDiffstat': self.cb_show_diffstat,
135 'browseBranch': self.cb_browse_current,
136 'browseOtherBranch': self.cb_browse_other,
137 'visualizeCurrent': self.cb_viz_current,
138 'visualizeAll': self.cb_viz_all,
139 'exportPatches': self.cb_export_patches,
140 'cherryPick': self.cb_cherry_pick,
143 # Initialize the GUI
144 self.cb_rescan (model)
146 # Setup the inotify server
147 self.__start_inotify_thread (model)
149 #####################################################################
150 # MODEL ACTIONS
151 #####################################################################
153 def action_staged (self, model):
154 '''This action is called when the model's staged list
155 changes. This is a thin wrapper around update_list_widget.'''
156 list_widget = self.view.stagedList
157 staged = model.get_staged()
158 self.__update_list_widget (list_widget, staged, True)
160 def action_unstaged (self, model):
161 '''This action is called when the model's unstaged list
162 changes. This is a thin wrapper around update_list_widget.'''
163 list_widget = self.view.unstagedList
164 unstaged = model.get_unstaged()
165 self.__update_list_widget (list_widget, unstaged, False)
166 if self.view.untrackedCheckBox.isChecked():
167 untracked = model.get_untracked()
168 self.__update_list_widget (list_widget, untracked,
169 append=True,
170 staged=False,
171 untracked=True)
173 #####################################################################
174 # CALLBACKS
175 #####################################################################
177 def cb_branch_create (self, model):
178 view = GitCreateBranchDialog (self.view)
179 controller = GitCreateBranchController (model, view)
180 view.show()
181 result = view.exec_()
182 if result == QDialog.Accepted:
183 self.cb_rescan (model)
185 def cb_branch_delete (self, model):
186 dlg = GitBranchDialog(self.view, branches=cmds.git_branch())
187 branch = dlg.getSelectedBranch()
188 if not branch: return
189 qtutils.show_command (self.view,
190 cmds.git_branch(name=branch, delete=True))
193 def cb_browse_current (self, model):
194 self.__browse_branch (cmds.git_current_branch())
196 def cb_browse_other (self, model):
197 # Prompt for a branch to browse
198 branches = (cmds.git_branch (remote=False)
199 + cmds.git_branch (remote=True))
201 dialog = GitBranchDialog (self.view, branches=branches)
203 # Launch the repobrowser
204 self.__browse_branch (dialog.getSelectedBranch())
206 def cb_checkout_branch (self, model):
207 dlg = GitBranchDialog (self.view, cmds.git_branch())
208 branch = dlg.getSelectedBranch()
209 if not branch: return
210 qtutils.show_command (self.view, cmds.git_checkout(branch))
211 self.cb_rescan (model)
213 def cb_cherry_pick (self, model):
214 '''Starts a cherry-picking session.'''
215 (revs, summaries) = cmds.git_log (all=True)
216 selection, idxs = self.__select_commits (revs, summaries)
217 if not selection: return
219 output = cmds.git_cherry_pick (selection)
220 self.__show_command (output, model)
222 def cb_commit (self, model):
223 '''Sets up data and calls cmds.commit.'''
225 msg = model.get_commitmsg()
226 if not msg:
227 error_msg = 'ERROR: No commit message was provided.'
228 self.__show_command (error_msg)
229 return
231 amend = self.view.amendRadio.isChecked()
232 commit_all = self.view.commitAllCheckBox.isChecked()
234 files = []
235 if commit_all:
236 files = model.get_staged()
237 else:
238 wlist = self.view.stagedList
239 mlist = model.get_staged()
240 files = qtutils.get_selection_from_list (wlist, mlist)
241 # Perform the commit
242 output = cmds.git_commit (msg, amend, files)
244 # Reset state
245 self.view.newCommitRadio.setChecked (True)
246 self.view.amendRadio.setChecked (False)
247 model.set_commitmsg ('')
248 self.__show_command (output, model)
250 def cb_commit_all (self, model):
251 '''Sets the commit-all checkbox and runs cb_commit.'''
252 self.view.commitAllCheckBox.setChecked (True)
253 self.cb_commit (model)
255 def cb_commit_selected (self, model):
256 '''Unsets the commit-all checkbox and runs cb_commit.'''
257 self.view.commitAllCheckBox.setChecked (False)
258 self.cb_commit (model)
260 def cb_commit_sha1_selected (self, browser, revs):
261 '''This callback is called when a commit browser's
262 item is selected. This callback puts the current
263 revision sha1 into the commitText field.
264 This callback also puts shows the commit in the
265 browser's commit textedit and copies it into
266 the global clipboard/selection.'''
267 current = browser.commitList.currentRow()
268 item = browser.commitList.item (current)
269 if not item.isSelected():
270 browser.commitText.setText ('')
271 browser.revisionLine.setText ('')
272 return
274 # Get the commit's sha1 and put it in the revision line
275 sha1 = revs[current]
276 browser.revisionLine.setText (sha1)
277 browser.revisionLine.selectAll()
279 # Lookup the info for that sha1 and display it
280 commit_diff = cmds.git_show (sha1)
281 browser.commitText.setText (commit_diff)
283 # Copy the sha1 into the clipboard
284 qtutils.set_clipboard (sha1)
286 def cb_copy (self):
287 cursor = self.view.displayText.textCursor()
288 selection = cursor.selection().toPlainText()
289 qtutils.set_clipboard (selection)
291 # use *args to handle being called from different signals
292 def cb_diff_staged (self, model, *args):
293 self.__staged_diff_in_view = True
294 list_widget = self.view.stagedList
295 row, selected = qtutils.get_selected_row (list_widget)
297 if not selected:
298 self.view.displayText.setText ('')
299 return
301 filename = model.get_staged()[row]
302 diff = cmds.git_diff (filename, staged=True)
304 if os.path.exists (filename):
305 pre = utils.header ('Staged for commit')
306 else:
307 pre = utils.header ('Staged for removal')
309 self.view.displayText.setText (pre + diff)
311 # use *args to handle being called from different signals
312 def cb_diff_unstaged (self, model, *args):
313 self.__staged_diff_in_view = False
314 list_widget = self.view.unstagedList
315 row, selected = qtutils.get_selected_row (list_widget)
316 if not selected:
317 self.view.displayText.setText ('')
318 return
319 filename = (model.get_unstaged() + model.get_untracked())[row]
320 if os.path.isdir (filename):
321 pre = utils.header ('Untracked directory')
322 cmd = 'ls -la %s' % utils.shell_quote (filename)
323 output = commands.getoutput (cmd)
324 self.view.displayText.setText ( pre + output )
325 return
327 if filename in model.get_unstaged():
328 diff = cmds.git_diff (filename, staged=False)
329 msg = utils.header ('Modified, unstaged') + diff
330 else:
331 # untracked file
332 cmd = 'file -b %s' % utils.shell_quote (filename)
333 file_type = commands.getoutput (cmd)
335 if 'binary' in file_type or 'data' in file_type:
336 sq_filename = utils.shell_quote (filename)
337 cmd = 'hexdump -C %s' % sq_filename
338 contents = commands.getoutput (cmd)
339 else:
340 file = open (filename, 'r')
341 contents = file.read()
342 file.close()
344 msg = (utils.header ('Untracked file: ' + file_type)
345 + contents)
347 self.view.displayText.setText (msg)
349 def cb_export_patches (self, model):
350 '''Launches the commit browser and exports the selected
351 patches.'''
353 (revs, summaries) = cmds.git_log ()
354 selection, idxs = self.__select_commits (revs, summaries)
355 if not selection: return
357 # now get the selected indices to determine whether
358 # a range of consecutive commits were selected
359 selected_range = range (idxs[0], idxs[-1] + 1)
360 export_range = len (idxs) > 1 and idxs == selected_range
362 output = cmds.git_format_patch (selection, export_range)
363 self.__show_command (output)
365 def cb_get_commit_msg (self, model):
366 model.retrieve_latest_commitmsg()
368 def cb_last_window_closed (self):
369 '''Cleanup the inotify thread if it exists.'''
370 if not self.inotify_thread: return
371 if not self.inotify_thread.isRunning(): return
372 self.inotify_thread.abort = True
373 self.inotify_thread.quit()
374 self.inotify_thread.wait()
376 def cb_rebase (self, model):
377 dlg = GitBranchDialog(self.view, cmds.git_branch())
378 dlg.setWindowTitle ("Select the current branch's new root")
379 branch = dlg.getSelectedBranch()
380 if not branch: return
381 qtutils.show_command (self.view, cmds.git_rebase (branch))
383 def cb_rescan (self, model, *args):
384 '''Populates view widgets with results from "git status."'''
386 # Scan for branch changes
387 self.__set_branch_ui_items()
389 # This allows us to defer notification until the
390 # we finish processing data
391 model.set_notify(False)
393 # Reset the staged and unstaged model lists
394 # NOTE: the model's unstaged list is used to
395 # hold both unstaged and untracked files.
396 model.staged = []
397 model.unstaged = []
398 model.untracked = []
400 # Read git status items
401 ( staged_items,
402 unstaged_items,
403 untracked_items ) = cmds.git_status()
405 # Gather items to be committed
406 for staged in staged_items:
407 if staged not in model.get_staged():
408 model.add_staged (staged)
410 # Gather unindexed items
411 for unstaged in unstaged_items:
412 if unstaged not in model.get_unstaged():
413 model.add_unstaged (unstaged)
415 # Gather untracked items
416 for untracked in untracked_items:
417 if untracked not in model.get_untracked():
418 model.add_untracked (untracked)
420 # Re-enable notifications and emit changes
421 model.set_notify(True)
422 model.notify_observers ('staged', 'unstaged')
424 squash_msg = os.path.join (os.getcwd(), '.git', 'SQUASH_MSG')
425 if not os.path.exists (squash_msg): return
427 msg = model.get_commitmsg()
429 if msg:
430 result = qtutils.question (self.view,
431 'Import Commit Message?',
432 ('A commit message from a '
433 + 'merge-in-progress was found.\n'
434 + 'Do you want to import it?'))
435 if not result: return
437 file = open (squash_msg)
438 msg = file.read()
439 file.close()
441 # Set the new commit message
442 model.set_commitmsg (msg)
444 def cb_show_diffstat (self, model):
445 '''Show the diffstat from the latest commit.'''
446 self.__show_command (cmds.git_diff_stat(), rescan=False)
448 def cb_stage_changed (self, model):
449 '''Stage all changed files for commit.'''
450 output = cmds.git_add (model.get_unstaged())
451 self.__show_command (output, model)
453 def cb_stage_hunk (self):
455 list_widget = self.view.unstagedList
456 row, selected = qtutils.get_selected_row (list_widget)
457 if not selected: return
459 filename = model.get_uncommitted_item (row)
461 cursor = self.view.displayText.textCursor()
462 offset = cursor.position()
463 offset -= utils.HEADER_LENGTH + 1
464 if offset < 0: return
466 selection = cursor.selection().toPlainText()
468 num_selected_lines = selection.count (os.linesep)
469 has_selection = selection and nb_selected_lines > 0
472 if has_selection:
473 print "\nNUM_LINES", num_selected_lines
474 print "SELECTION:\n", selection
475 else:
476 print "SELECTION:\n", seleciton
478 print 'POSITION:', cursor.position()
482 def cb_stage_selected (self, model):
483 '''Use "git add" to add items to the git index.
484 This is a thin wrapper around __apply_to_list.'''
485 command = cmds.git_add_or_remove
486 widget = self.view.unstagedList
487 items = model.get_unstaged() + model.get_untracked()
488 self.__apply_to_list (command, model, widget, items)
490 def cb_stage_untracked (self, model):
491 '''Stage all untracked files for commit.'''
492 output = cmds.git_add (model.get_untracked())
493 self.__show_command (output, model)
495 def cb_unstage_all (self, model):
496 '''Use "git reset" to remove all items from the git index.'''
497 output = cmds.git_reset (model.get_staged())
498 self.__show_command (output, model)
500 def cb_unstage_selected (self, model):
501 '''Use "git reset" to remove items from the git index.
502 This is a thin wrapper around __apply_to_list.'''
504 command = cmds.git_reset
505 widget = self.view.stagedList
506 items = model.get_staged()
507 self.__apply_to_list (command, model, widget, items)
509 def cb_viz_all (self, model):
510 '''Visualizes the entire git history using gitk.'''
511 os.system ('gitk --all &')
513 def cb_viz_current (self, model):
514 '''Visualizes the current branch's history using gitk.'''
515 branch = cmds.git_current_branch()
516 os.system ('gitk %s &' % utils.shell_quote (branch))
518 #####################################################################
519 # PRIVATE HELPER METHODS
520 #####################################################################
522 def __apply_to_list (self, command, model, widget, items):
523 '''This is a helper method that retrieves the current
524 selection list, applies a command to that list,
525 displays a dialog showing the output of that command,
526 and calls cb_rescan to pickup changes.'''
527 apply_items = qtutils.get_selection_from_list (widget, items)
528 output = command (apply_items)
529 self.__show_command (output, model)
531 def __browse_branch (self, branch):
532 if not branch: return
533 # Clone the model to allow opening multiple browsers
534 # with different sets of data
535 model = self.model.clone()
536 model.set_branch (branch)
537 view = GitCommitBrowser()
538 controller = GitRepoBrowserController(model, view)
539 view.show()
540 view.exec_()
542 def __menu_about_to_show (self):
543 self.__stage_hunk_action.setEnabled (not self.__staged_diff_in_view)
545 def __menu_event (self, event):
546 self.__menu_setup()
547 textedit = self.view.displayText
548 self.__menu.exec_ (textedit.mapToGlobal (event.pos()))
550 def __menu_setup (self):
551 if self.__menu: return
553 menu = QMenu (self.view)
554 stage = menu.addAction ('Stage Hunk', self.cb_stage_hunk)
555 copy = menu.addAction ('Copy', self.cb_copy)
557 self.connect (menu, 'aboutToShow()', self.__menu_about_to_show)
559 self.__stage_hunk_action = stage
560 self.__copy_action = copy
561 self.__menu = menu
564 def __file_to_widget_item (self, filename, staged, untracked=False):
565 '''Given a filename, return a QListWidgetItem suitable
566 for adding to a QListWidget. "staged" controls whether
567 to use icons for the staged or unstaged list widget.'''
569 if staged:
570 icon_file = utils.get_staged_icon (filename)
571 elif untracked:
572 icon_file = utils.get_untracked_icon()
573 else:
574 icon_file = utils.get_icon (filename)
576 return qtutils.create_listwidget_item (filename, icon_file)
578 def __select_commits (self, revs, summaries):
579 '''Use the GitCommitBrowser to select commits from a list.'''
580 if not summaries:
581 msg = 'ERROR: No commits exist in this branch.'''
582 self.__show_command (output=msg)
583 return ([],[])
585 browser = GitCommitBrowser (self.view)
586 self.connect ( browser.commitList,
587 'itemSelectionChanged()',
588 lambda: self.cb_commit_sha1_selected(
589 browser, revs) )
591 for summary in summaries:
592 browser.commitList.addItem (summary)
594 browser.show()
595 result = browser.exec_()
596 if result != QDialog.Accepted:
597 return ([],[])
599 list_widget = browser.commitList
600 selection = qtutils.get_selection_from_list (list_widget, revs)
601 if not selection: return ([],[])
603 # also return the selected index numbers
604 index_nums = range (len (revs))
605 idxs = qtutils.get_selection_from_list (list_widget, index_nums)
607 return (selection, idxs)
609 def __set_branch_ui_items (self):
610 '''Sets up items that mention the current branch name.'''
611 current_branch = cmds.git_current_branch()
612 menu_text = 'Browse ' + current_branch + ' branch'
613 self.view.browseBranch.setText (menu_text)
615 status_text = 'Current branch: ' + current_branch
616 self.view.statusBar().showMessage (status_text)
618 def __start_inotify_thread (self, model):
619 # Do we have inotify? If not, return.
620 # Recommend installing inotify if we're on Linux.
621 self.inotify_thread = None
622 try:
623 from inotify import GitNotifier
624 except ImportError:
625 import platform
626 if platform.system() == 'Linux':
627 msg = ('ugit could not find python-inotify.'
628 + '\nSupport for inotify is disabled.')
630 plat = platform.platform().lower()
631 if 'debian' in plat or 'ubuntu' in plat:
632 msg += '\n\nHint: sudo apt-get install python-pyinotify'
634 qtutils.information (self.view,
635 'inotify support disabled',
636 msg)
637 return
639 self.inotify_thread = GitNotifier (os.getcwd())
640 self.connect ( self.inotify_thread, 'timeForRescan()',
641 lambda: self.cb_rescan (model) )
643 # Start the notification thread
644 self.inotify_thread.start()
646 def __show_command (self, output, model=None, rescan=True):
647 '''Shows output and optionally rescans for changes.'''
648 qtutils.show_command (self.view, output)
649 if rescan and model: self.cb_rescan (model)
651 def __update_list_widget (self, list_widget, items,
652 staged, untracked=False, append=False):
653 '''A helper method to populate a QListWidget with the
654 contents of modelitems.'''
655 if not append:
656 list_widget.clear()
657 for item in items:
658 qitem = self.__file_to_widget_item (item,
659 staged, untracked)
660 list_widget.addItem( qitem )