A few more i18n fixes
[ugit.git] / ugitlibs / controllers.py
blobbbd8612068f0cc525e7a9a902dc7a7aafc6f546a
1 #!/usr/bin/env python
2 import os
3 import commands
4 from PyQt4 import QtGui
5 from PyQt4 import QtCore
6 from PyQt4.QtGui import QDialog
7 from PyQt4.QtGui import QMessageBox
8 from PyQt4.QtGui import QMenu
9 from qobserver import QObserver
10 import cmds
11 import utils
12 import qtutils
13 import defaults
14 from views import GitPushDialog
15 from views import GitBranchDialog
16 from views import GitCreateBranchDialog
17 from views import GitCommitBrowser
18 from repobrowsercontroller import GitRepoBrowserController
19 from createbranchcontroller import GitCreateBranchController
20 from pushcontroller import GitPushController
22 class GitController(QObserver):
23 '''The controller is a mediator between the model and view.
24 It allows for a clean decoupling between view and model classes.'''
26 def __init__(self, model, view):
27 QObserver.__init__(self, model, view)
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('commitmsg', 'commitText')
43 self.model_to_view('staged', 'stagedList')
44 self.model_to_view('all_unstaged', 'unstagedList')
46 # When a model attribute changes, this runs a specific action
47 self.add_actions('staged', self.action_staged)
48 self.add_actions('all_unstaged', self.action_all_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, view.commitButton,
57 view.pushButton, view.signOffButton,)
59 self.add_signals('triggered()',
60 view.rescan,
61 view.createBranch, view.checkoutBranch,
62 view.rebaseBranch, view.deleteBranch,
63 view.commitAll, view.commitSelected,
64 view.setCommitMessage,
65 view.stageChanged, view.stageUntracked,
66 view.stageSelected, view.unstageAll,
67 view.unstageSelected,
68 view.showDiffstat,
69 view.browseBranch, view.browseOtherBranch,
70 view.visualizeAll, view.visualizeCurrent,
71 view.exportPatches, view.cherryPick,
72 view.loadCommitMsg,
73 view.cut, view.copy, view.paste, view.delete,
74 view.selectAll, view.undo, view.redo,)
76 self.add_signals('itemClicked(QListWidgetItem *)',
77 view.stagedList, view.unstagedList,)
79 self.add_signals('itemSelectionChanged()',
80 view.stagedList, view.unstagedList,)
82 self.add_signals('splitterMoved(int,int)',
83 view.splitter_top, view.splitter_bottom)
85 # App cleanup
86 self.connect(QtGui.qApp, 'lastWindowClosed()',
87 self.last_window_closed)
89 # These callbacks are called in response to the signals
90 # defined above. One property of the QObserver callback
91 # mechanism is that the model is passed in as the first
92 # argument to the callback. This allows for a single
93 # controller to manage multiple models, though this
94 # isn't used at the moment.
95 self.add_callbacks({
96 # Actions that delegate directly to the model
97 'signOffButton': model.add_signoff,
98 'setCommitMessage': model.get_prev_commitmsg,
99 # Push Buttons
100 'stageButton': self.stage_selected,
101 'commitButton': self.commit,
102 'pushButton': self.push,
103 # List Widgets
104 'stagedList': self.diff_staged,
105 'unstagedList': self.diff_unstaged,
106 # Checkboxes
107 'untrackedCheckBox': self.rescan,
108 # Menu Actions
109 'rescan': self.rescan,
110 'createBranch': self.branch_create,
111 'deleteBranch': self.branch_delete,
112 'checkoutBranch': self.checkout_branch,
113 'rebaseBranch': self.rebase,
114 'commitAll': self.commit_all,
115 'commitSelected': self.commit_selected,
116 'stageChanged': self.stage_changed,
117 'stageUntracked': self.stage_untracked,
118 'stageSelected': self.stage_selected,
119 'unstageAll': self.unstage_all,
120 'unstageSelected': self.unstage_selected,
121 'showDiffstat': self.show_diffstat,
122 'browseBranch': self.browse_current,
123 'browseOtherBranch': self.browse_other,
124 'visualizeCurrent': self.viz_current,
125 'visualizeAll': self.viz_all,
126 'exportPatches': self.export_patches,
127 'cherryPick': self.cherry_pick,
128 'loadCommitMsg': self.load_commitmsg,
129 'cut': self.cut,
130 'copy': self.copy,
131 'paste': self.paste,
132 'delete': self.delete,
133 'selectAll': self.select_all,
134 'undo': self.view.commitText.undo,
135 'redo': self.redo,
136 # Splitters
137 'splitter_top': self.splitter_top_moved,
138 'splitter_bottom': self.splitter_bottom_moved,
141 # Handle double-clicks in the staged/unstaged lists.
142 # These are vanilla signal/slots since the qobserver
143 # signal routing is already handling these lists' signals.
144 self.connect(view.unstagedList,
145 'itemDoubleClicked(QListWidgetItem*)',
146 self.stage_selected)
148 self.connect(view.stagedList,
149 'itemDoubleClicked(QListWidgetItem*)',
150 self.unstage_selected )
152 # Delegate window move events here
153 self.view.moveEvent = self.move_event
154 self.view.resizeEvent = self.resize_event
156 # Initialize the GUI
157 self.__read_config_settings()
158 self.rescan()
160 # Setup the inotify watchdog
161 self.__start_inotify_thread()
163 #####################################################################
164 # Actions
165 # Notify callbacks from the model
167 def action_staged(self, widget):
168 self.__update_listwidget(widget,
169 self.model.get_staged(), staged=True)
171 def action_all_unstaged(self, widget):
172 self.__update_listwidget(widget,
173 self.model.get_unstaged(), staged=False)
175 if self.view.untrackedCheckBox.isChecked():
176 self.__update_listwidget(widget,
177 self.model.get_untracked(),
178 append=True,
179 staged=False,
180 untracked=True)
182 #####################################################################
183 # Qt callbacks
185 def branch_create(self):
186 view = GitCreateBranchDialog(self.view)
187 controller = GitCreateBranchController(self.model, view)
188 view.show()
189 result = view.exec_()
190 if result == QDialog.Accepted:
191 self.rescan()
193 def branch_delete(self):
194 dlg = GitBranchDialog(self.view, branches=cmds.git_branch())
195 branch = dlg.getSelectedBranch()
196 if not branch: return
197 qtutils.show_command(self.view,
198 cmds.git_branch(name=branch, delete=True))
200 def browse_current(self):
201 self.__browse_branch(cmds.git_current_branch())
203 def browse_other(self):
204 # Prompt for a branch to browse
205 branches = self.model.all_branches()
206 dialog = GitBranchDialog(self.view, branches=branches)
208 # Launch the repobrowser
209 self.__browse_branch(dialog.getSelectedBranch())
211 def checkout_branch(self):
212 dlg = GitBranchDialog(self.view, cmds.git_branch())
213 branch = dlg.getSelectedBranch()
214 if not branch: return
215 qtutils.show_command(self.view, cmds.git_checkout(branch))
216 self.rescan()
218 def cherry_pick(self):
219 '''Starts a cherry-picking session.'''
220 (revs, summaries) = cmds.git_log(all=True)
221 selection, idxs = self.__select_commits(revs, summaries)
222 output = cmds.git_cherry_pick(selection)
223 self.__show_command(self.tr(output))
225 def commit(self):
226 '''Sets up data and calls cmds.commit.'''
227 msg = self.model.get_commitmsg()
228 if not msg:
229 error_msg = self.tr(""
230 + "Please supply a commit message.\n"
231 + "\n"
232 + "A good commit message has the following format:\n"
233 + "\n"
234 + "- First line: Describe in one sentence what you did.\n"
235 + "- Second line: Blank\n"
236 + "- Remaining lines: Describe why this change is good.\n")
238 self.__show_command(error_msg)
239 return
241 amend = self.view.amendRadio.isChecked()
242 commit_all = self.view.commitAllCheckBox.isChecked()
244 files = []
245 if commit_all:
246 files = self.model.get_staged()
247 if not files:
248 errmsg = self.tr(""
249 + "No changes to commit.\n"
250 + "\n"
251 + "You must stage at least 1 file before you can commit.\n")
252 self.__show_command(errmsg)
253 return
254 else:
255 wlist = self.view.stagedList
256 mlist = self.model.get_staged()
257 files = qtutils.get_selection_list(wlist, mlist)
258 if not files:
259 errmsg = self.tr('No files selected.')
260 self.__show_command(errmsg)
261 return
262 # Perform the commit
263 output = cmds.git_commit(msg, amend, files)
265 # Reset state
266 self.view.newCommitRadio.setChecked(True)
267 self.view.amendRadio.setChecked(False)
268 self.model.set_commitmsg('')
269 self.__show_command(output)
271 def commit_all(self):
272 '''Sets the commit-all checkbox and runs commit.'''
273 self.view.commitAllCheckBox.setChecked(True)
274 self.commit()
276 def commit_selected(self):
277 '''Unsets the commit-all checkbox and runs commit.'''
278 self.view.commitAllCheckBox.setChecked(False)
279 self.commit()
281 def commit_sha1_selected(self, browser, revs):
282 '''This callback is called when a commit browser's
283 item is selected. This callback puts the current
284 revision sha1 into the commitText field.
285 This callback also puts shows the commit in the
286 browser's commit textedit and copies it into
287 the global clipboard/selection.'''
288 current = browser.commitList.currentRow()
289 item = browser.commitList.item(current)
290 if not item.isSelected():
291 browser.commitText.setText('')
292 browser.revisionLine.setText('')
293 return
295 # Get the commit's sha1 and put it in the revision line
296 sha1 = revs[current]
297 browser.revisionLine.setText(sha1)
298 browser.revisionLine.selectAll()
300 # Lookup the info for that sha1 and display it
301 commit_diff = cmds.git_show(sha1)
302 browser.commitText.setText(commit_diff)
304 # Copy the sha1 into the clipboard
305 qtutils.set_clipboard(sha1)
307 # use *rest to handle being called from different signals
308 def diff_staged(self, *rest):
309 self.__staged_diff_in_view = True
310 widget = self.view.stagedList
311 row, selected = qtutils.get_selected_row(widget)
313 if not selected:
314 self.__reset_display()
315 return
317 filename = self.model.get_staged()[row]
318 diff = cmds.git_diff(filename, staged=True)
320 if os.path.exists(filename):
321 self.__set_info(self.tr('Staged for commit'))
322 else:
323 self.__set_info(self.tr('Staged for removal'))
325 self.view.displayText.setText(diff)
327 # use *rest to handle being called from different signals
328 def diff_unstaged(self,*rest):
329 self.__staged_diff_in_view = False
330 widget = self.view.unstagedList
332 row, selected = qtutils.get_selected_row(widget)
333 if not selected:
334 self.__reset_display()
335 return
337 filename =(self.model.get_unstaged()
338 + self.model.get_untracked())[row]
339 if os.path.isdir(filename):
340 self.__set_info(self.tr('Untracked directory'))
341 cmd = 'ls -la %s' % utils.shell_quote(filename)
342 output = commands.getoutput(cmd)
343 self.view.displayText.setText(output )
344 return
346 if filename in self.model.get_unstaged():
347 diff = cmds.git_diff(filename, staged=False)
348 msg = diff
349 self.__set_info(self.tr('Modified, not staged'))
350 else:
351 # untracked file
352 cmd = 'file -b %s' % utils.shell_quote(filename)
353 file_type = commands.getoutput(cmd)
355 if 'binary' in file_type or 'data' in file_type:
356 sq_filename = utils.shell_quote(filename)
357 cmd = 'hexdump -C %s' % sq_filename
358 contents = commands.getoutput(cmd)
359 else:
360 if os.path.exists(filename):
361 file = open(filename, 'r')
362 contents = file.read()
363 file.close()
365 else: contents = ''
367 self.__set_info(self.tr('Untracked, not staged')
368 + ': ' + file_type)
369 msg = contents
371 self.view.displayText.setText(msg)
373 def display_copy(self):
374 cursor = self.view.displayText.textCursor()
375 selection = cursor.selection().toPlainText()
376 qtutils.set_clipboard(selection)
378 def export_patches(self):
379 '''Launches the commit browser and exports the selected
380 patches.'''
382 (revs, summaries) = cmds.git_log()
383 selection, idxs = self.__select_commits(revs, summaries)
384 if not selection: return
386 # now get the selected indices to determine whether
387 # a range of consecutive commits were selected
388 selected_range = range(idxs[0], idxs[-1] + 1)
389 export_range = len(idxs) > 1 and idxs == selected_range
391 output = cmds.git_format_patch(selection, export_range)
392 self.__show_command(output)
394 def get_commit_msg(self):
395 self.model.retrieve_latest_commitmsg()
397 def last_window_closed(self):
398 '''Save config settings and cleanup the any inotify threads.'''
400 self.__save_config_settings()
402 if not self.inotify_thread: return
403 if not self.inotify_thread.isRunning(): return
405 self.inotify_thread.abort = True
406 self.inotify_thread.quit()
407 self.inotify_thread.wait()
409 def load_commitmsg(self):
410 file = qtutils.open_dialog(self.view,
411 self.tr('Load Commit Message...'),
412 defaults.DIRECTORY)
414 if file:
415 defaults.DIRECTORY = os.path.dirname(file)
416 slushy = utils.slurp(file)
417 self.model.set_commitmsg(slushy)
420 def rebase(self):
421 dlg = GitBranchDialog(self.view, cmds.git_branch())
422 dlg.setWindowTitle("Select the current branch's new root")
423 branch = dlg.getSelectedBranch()
424 if not branch: return
425 qtutils.show_command(self.view, cmds.git_rebase(branch))
427 # use *rest to handle being called from the checkbox signal
428 def rescan(self, *rest):
429 '''Populates view widgets with results from "git status."'''
431 self.view.statusBar().showMessage(
432 self.tr('Scanning for modified files ...'))
434 # Rescan for repo updates
435 self.model.update_status()
437 # Scan for branch changes
438 self.__set_branch_ui_items()
440 if not self.model.has_squash_msg(): return
442 if self.model.get_commitmsg():
443 answer = qtutils.question(self.view,
444 self.tr('Import Commit Message?'),
445 self.tr('A commit message from an in-progress'
446 + ' merge was found.\nImport it?'))
448 if not answer: return
450 # Set the new commit message
451 self.model.set_commitmsg(self.model.get_squash_msg())
453 def push(self):
454 model = self.model.clone()
455 view = GitPushDialog(self.view)
456 controller = GitPushController(model,view)
457 view.show()
458 view.exec_()
460 def cut(self):
461 self.copy()
462 self.delete()
464 def copy(self):
465 cursor = self.view.commitText.textCursor()
466 selection = cursor.selection().toPlainText()
467 qtutils.set_clipboard(selection)
469 def paste(self): self.view.commitText.paste()
470 def undo(self): self.view.commitText.undo()
471 def redo(self): self.view.commitText.redo()
472 def select_all(self): self.view.commitText.selectAll()
473 def delete(self):
474 self.view.commitText.textCursor().removeSelectedText()
476 def show_diffstat(self):
477 '''Show the diffstat from the latest commit.'''
478 self.__show_command(cmds.git_diff_stat(), rescan=False)
480 def stage_changed(self):
481 '''Stage all changed files for commit.'''
482 output = cmds.git_add(self.model.get_unstaged())
483 self.__show_command(output)
485 def stage_hunk(self):
487 list_widget = self.view.unstagedList
488 row, selected = qtutils.get_selected_row(list_widget)
489 if not selected: return
491 filename = self.model.get_uncommitted_item(row)
493 if not os.path.exists(filename): return
494 if os.path.isdir(filename): return
496 cursor = self.view.displayText.textCursor()
497 offset = cursor.position()
499 selection = cursor.selection().toPlainText()
500 header, diff = cmds.git_diff(filename,
501 with_diff_header=True,
502 staged=False)
503 parser = utils.DiffParser(diff)
505 num_selected_lines = selection.count(os.linesep)
506 has_selection =(selection
507 and selection.count(os.linesep) > 0)
509 if has_selection:
510 start = diff.index(selection)
511 end = start + len(selection)
512 diffs = parser.get_diffs_for_range(start, end)
513 else:
514 diffs = [ parser.get_diff_for_offset(offset) ]
516 if not diffs: return
518 for diff in diffs:
519 tmpfile = utils.get_tmp_filename()
520 file = open(tmpfile, 'w')
521 file.write(header + os.linesep + diff + os.linesep)
522 file.close()
523 self.model.apply_diff(tmpfile)
524 os.unlink(tmpfile)
526 self.rescan()
528 def stage_untracked(self):
529 '''Stage all untracked files for commit.'''
530 output = cmds.git_add(self.model.get_untracked())
531 self.__show_command(output)
533 # use *rest to handle being called from different signals
534 def stage_selected(self,*rest):
535 '''Use "git add" to add items to the git index.
536 This is a thin wrapper around __apply_to_list.'''
537 command = cmds.git_add_or_remove
538 widget = self.view.unstagedList
539 items = self.model.get_unstaged() + self.model.get_untracked()
540 self.__show_command(
541 self.__apply_to_list(command, widget, items))
543 # use *rest to handle being called from different signals
544 def unstage_selected(self, *rest):
545 '''Use "git reset" to remove items from the git index.
546 This is a thin wrapper around __apply_to_list.'''
547 command = cmds.git_reset
548 widget = self.view.stagedList
549 items = self.model.get_staged()
550 self.__show_command(self.__apply_to_list(command, widget, items))
552 def unstage_all(self):
553 '''Use "git reset" to remove all items from the git index.'''
554 output = cmds.git_reset(self.model.get_staged())
555 self.__show_command(output)
557 def viz_all(self):
558 '''Visualizes the entire git history using gitk.'''
559 os.system('gitk --all &')
561 def viz_current(self):
562 '''Visualizes the current branch's history using gitk.'''
563 branch = cmds.git_current_branch()
564 os.system('gitk %s &' % utils.shell_quote(branch))
566 # These actions monitor window resizes, splitter changes, etc.
567 def move_event(self, event):
568 defaults.X = event.pos().x()
569 defaults.Y = event.pos().y()
571 def resize_event(self, event):
572 defaults.WIDTH = event.size().width()
573 defaults.HEIGHT = event.size().height()
575 def splitter_top_moved(self,*rest):
576 sizes = self.view.splitter_top.sizes()
577 defaults.SPLITTER_TOP_0 = sizes[0]
578 defaults.SPLITTER_TOP_1 = sizes[1]
580 def splitter_bottom_moved(self,*rest):
581 sizes = self.view.splitter_bottom.sizes()
582 defaults.SPLITTER_BOTTOM_0 = sizes[0]
583 defaults.SPLITTER_BOTTOM_1 = sizes[1]
585 #####################################################################
588 def __apply_to_list(self, command, widget, items):
589 '''This is a helper method that retrieves the current
590 selection list, applies a command to that list,
591 displays a dialog showing the output of that command,
592 and calls rescan to pickup changes.'''
593 apply_items = qtutils.get_selection_list(widget, items)
594 output = command(apply_items)
595 self.rescan()
596 return output
598 def __browse_branch(self, branch):
599 if not branch: return
600 # Clone the model to allow opening multiple browsers
601 # with different sets of data
602 model = self.model.clone()
603 model.set_branch(branch)
604 view = GitCommitBrowser()
605 controller = GitRepoBrowserController(model, view)
606 view.show()
607 view.exec_()
609 def __menu_about_to_show(self):
610 cursor = self.view.displayText.textCursor()
611 allow_hunk_staging = not self.__staged_diff_in_view
612 self.__stage_hunk_action.setEnabled(allow_hunk_staging)
614 def __menu_event(self, event):
615 self.__menu_setup()
616 textedit = self.view.displayText
617 self.__menu.exec_(textedit.mapToGlobal(event.pos()))
619 def __menu_setup(self):
620 if self.__menu: return
622 menu = QMenu(self.view)
623 stage = menu.addAction(self.tr('Stage Hunk For Commit'),
624 self.stage_hunk)
625 copy = menu.addAction(self.tr('Copy'), self.display_copy)
627 self.connect(menu, 'aboutToShow()', self.__menu_about_to_show)
629 self.__stage_hunk_action = stage
630 self.__copy_action = copy
631 self.__menu = menu
634 def __file_to_widget_item(self, filename, staged, untracked=False):
635 '''Given a filename, return a QListWidgetItem suitable
636 for adding to a QListWidget. "staged" controls whether
637 to use icons for the staged or unstaged list widget.'''
639 if staged:
640 icon_file = utils.get_staged_icon(filename)
641 elif untracked:
642 icon_file = utils.get_untracked_icon()
643 else:
644 icon_file = utils.get_icon(filename)
646 return qtutils.create_listwidget_item(filename, icon_file)
648 def __read_config_settings(self):
649 (w,h,x,y,
650 st0,st1,
651 sb0,sb1) = utils.parse_geom(cmds.git_config('ugit.geometry'))
652 self.view.resize(w,h)
653 self.view.move(x,y)
654 self.view.splitter_top.setSizes([st0,st1])
655 self.view.splitter_bottom.setSizes([sb0,sb1])
657 def __save_config_settings(self):
658 cmds.git_config('ugit.geometry', utils.get_geom())
660 def __select_commits(self, revs, summaries):
661 '''Use the GitCommitBrowser to select commits from a list.'''
662 if not summaries:
663 msg = self.tr('ERROR: No commits exist in this branch.')
664 self.__show_command(msg)
665 return([],[])
667 browser = GitCommitBrowser(self.view)
668 self.connect(browser.commitList,
669 'itemSelectionChanged()',
670 lambda: self.commit_sha1_selected(
671 browser, revs) )
673 for summary in summaries:
674 browser.commitList.addItem(summary)
676 browser.show()
677 result = browser.exec_()
678 if result != QDialog.Accepted:
679 return([],[])
681 list_widget = browser.commitList
682 selection = qtutils.get_selection_list(list_widget, revs)
683 if not selection: return([],[])
685 # also return the selected index numbers
686 index_nums = range(len(revs))
687 idxs = qtutils.get_selection_list(list_widget, index_nums)
689 return(selection, idxs)
691 def __set_branch_ui_items(self):
692 '''Sets up items that mention the current branch name.'''
693 branch = cmds.git_current_branch()
695 status_text = self.tr('Current Branch:') + ' ' + branch
696 self.view.statusBar().showMessage(status_text)
698 project = self.model.get_project()
699 title = '%s [%s]' % ( project, branch )
701 self.view.setWindowTitle(title)
703 def __reset_display(self):
704 self.view.displayText.setText('')
705 self.__set_info('')
707 def __set_info(self,text):
708 self.view.displayLabel.setText(text)
710 def __start_inotify_thread(self):
711 # Do we have inotify? If not, return.
712 # Recommend installing inotify if we're on Linux.
713 self.inotify_thread = None
714 try:
715 from inotify import GitNotifier
716 except ImportError:
717 import platform
718 if platform.system() == 'Linux':
719 msg =(self.tr('Unable import pyinotify.\n'
720 + 'inotify support has been'
721 + 'disabled.')
722 + '\n\n')
724 plat = platform.platform().lower()
725 if 'debian' in plat or 'ubuntu' in plat:
726 msg += (self.tr('Hint:')
727 + 'sudo apt-get install'
728 + ' python-pyinotify')
730 qtutils.information(self.view,
731 self.tr('inotify disabled'), msg)
732 return
734 self.inotify_thread = GitNotifier(os.getcwd())
735 self.connect(self.inotify_thread,
736 'timeForRescan()', self.rescan)
738 # Start the notification thread
739 self.inotify_thread.start()
741 def __show_command(self, output, rescan=True):
742 '''Shows output and optionally rescans for changes.'''
743 qtutils.show_command(self.view, output)
744 if rescan: self.rescan()
746 def __update_listwidget(self, widget, items,
747 staged, untracked=False, append=False):
748 '''A helper method to populate a QListWidget with the
749 contents of modelitems.'''
750 if not append:
751 widget.clear()
752 for item in items:
753 qitem = self.__file_to_widget_item(item,
754 staged, untracked)
755 widget.addItem(qitem)