Add internationalization support to ugit
[ugit.git] / ugitlibs / controllers.py
blobb059da0d0f421d94fd5b81c592eb05f52cfd9100
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('unstaged_list', 'unstagedList')
45 # When a model attribute changes, this runs a specific action
46 self.add_actions('staged', self.action_staged)
47 self.add_actions('unstaged', self.action_unstaged)
48 self.add_actions('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, 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
166 def action_staged(self):
167 '''This action is called when the model's staged list
168 changes. This is a thin wrapper around update_list_widget.'''
169 list_widget = self.view.stagedList
170 staged = self.model.get_staged()
171 self.__update_list_widget(list_widget, staged, True)
173 def action_unstaged(self):
174 '''This action is called when the model's unstaged list
175 changes. This is a thin wrapper around update_list_widget.'''
176 list_widget = self.view.unstagedList
177 unstaged = self.model.get_unstaged()
178 self.__update_list_widget(list_widget, unstaged, False)
180 if self.view.untrackedCheckBox.isChecked():
181 untracked = self.model.get_untracked()
182 self.__update_list_widget(list_widget, untracked,
183 append=True,
184 staged=False,
185 untracked=True)
187 #####################################################################
188 # Qt callbacks
190 def branch_create(self):
191 view = GitCreateBranchDialog(self.view)
192 controller = GitCreateBranchController(self.model, view)
193 view.show()
194 result = view.exec_()
195 if result == QDialog.Accepted:
196 self.rescan()
198 def branch_delete(self):
199 dlg = GitBranchDialog(self.view, branches=cmds.git_branch())
200 branch = dlg.getSelectedBranch()
201 if not branch: return
202 qtutils.show_command(self.view,
203 cmds.git_branch(name=branch, delete=True))
205 def browse_current(self):
206 self.__browse_branch(cmds.git_current_branch())
208 def browse_other(self):
209 # Prompt for a branch to browse
210 branches = self.model.all_branches()
211 dialog = GitBranchDialog(self.view, branches=branches)
213 # Launch the repobrowser
214 self.__browse_branch(dialog.getSelectedBranch())
216 def checkout_branch(self):
217 dlg = GitBranchDialog(self.view, cmds.git_branch())
218 branch = dlg.getSelectedBranch()
219 if not branch: return
220 qtutils.show_command(self.view, cmds.git_checkout(branch))
221 self.rescan()
223 def cherry_pick(self):
224 '''Starts a cherry-picking session.'''
225 (revs, summaries) = cmds.git_log(all=True)
226 selection, idxs = self.__select_commits(revs, summaries)
227 if not selection: return
229 output = cmds.git_cherry_pick(selection)
230 self.__show_command(output)
232 def commit(self):
233 '''Sets up data and calls cmds.commit.'''
234 msg = self.model.get_commitmsg()
235 if not msg:
236 error_msg = (""
237 + "Please supply a commit message.\n"
238 + "\n"
239 + "A good commit message has the following format:\n"
240 + "\n"
241 + "- First line: Describe in one sentence what you did.\n"
242 + "- Second line: Blank\n"
243 + "- Remaining lines: Describe why this change is good.\n")
245 self.__show_command(self.tr(error_msg))
246 return
248 amend = self.view.amendRadio.isChecked()
249 commit_all = self.view.commitAllCheckBox.isChecked()
251 files = []
252 if commit_all:
253 files = self.model.get_staged()
254 else:
255 wlist = self.view.stagedList
256 mlist = self.model.get_staged()
257 files = qtutils.get_selection_list(wlist, mlist)
258 # Perform the commit
259 output = cmds.git_commit(msg, amend, files)
261 # Reset state
262 self.view.newCommitRadio.setChecked(True)
263 self.view.amendRadio.setChecked(False)
264 self.model.set_commitmsg('')
265 self.__show_command(output)
267 def commit_all(self):
268 '''Sets the commit-all checkbox and runs commit.'''
269 self.view.commitAllCheckBox.setChecked(True)
270 self.commit()
272 def commit_selected(self):
273 '''Unsets the commit-all checkbox and runs commit.'''
274 self.view.commitAllCheckBox.setChecked(False)
275 self.commit()
277 def commit_sha1_selected(self, browser, revs):
278 '''This callback is called when a commit browser's
279 item is selected. This callback puts the current
280 revision sha1 into the commitText field.
281 This callback also puts shows the commit in the
282 browser's commit textedit and copies it into
283 the global clipboard/selection.'''
284 current = browser.commitList.currentRow()
285 item = browser.commitList.item(current)
286 if not item.isSelected():
287 browser.commitText.setText('')
288 browser.revisionLine.setText('')
289 return
291 # Get the commit's sha1 and put it in the revision line
292 sha1 = revs[current]
293 browser.revisionLine.setText(sha1)
294 browser.revisionLine.selectAll()
296 # Lookup the info for that sha1 and display it
297 commit_diff = cmds.git_show(sha1)
298 browser.commitText.setText(commit_diff)
300 # Copy the sha1 into the clipboard
301 qtutils.set_clipboard(sha1)
303 # use *rest to handle being called from different signals
304 def diff_staged(self, *rest):
305 self.__staged_diff_in_view = True
306 list_widget = self.view.stagedList
307 row, selected = qtutils.get_selected_row(list_widget)
309 if not selected:
310 self.__reset_display()
311 return
313 filename = self.model.get_staged()[row]
314 diff = cmds.git_diff(filename, staged=True)
316 if os.path.exists(filename):
317 self.__set_info(self.tr('Staged for commit'))
318 else:
319 self.__set_info(self.tr('Staged for removal'))
321 self.view.displayText.setText(diff)
323 # use *rest to handle being called from different signals
324 def diff_unstaged(self,*rest):
325 self.__staged_diff_in_view = False
326 list_widget = self.view.unstagedList
327 row, selected = qtutils.get_selected_row(list_widget)
328 if not selected:
329 self.__reset_display()
330 return
331 filename =(self.model.get_unstaged()
332 + self.model.get_untracked())[row]
333 if os.path.isdir(filename):
334 self.__set_info('Untracked directory')
335 cmd = 'ls -la %s' % utils.shell_quote(filename)
336 output = commands.getoutput(cmd)
337 self.view.displayText.setText(output )
338 return
340 if filename in self.model.get_unstaged():
341 diff = cmds.git_diff(filename, staged=False)
342 msg = diff
343 self.__set_info(self.tr('Modified, not staged'))
344 else:
345 # untracked file
346 cmd = 'file -b %s' % utils.shell_quote(filename)
347 file_type = commands.getoutput(cmd)
349 if 'binary' in file_type or 'data' in file_type:
350 sq_filename = utils.shell_quote(filename)
351 cmd = 'hexdump -C %s' % sq_filename
352 contents = commands.getoutput(cmd)
353 else:
354 file = open(filename, 'r')
355 contents = file.read()
356 file.close()
358 self.__set_info(self.tr('Untracked, not staged')
359 + ': ' + file_type)
360 msg = contents
362 self.view.displayText.setText(msg)
364 def display_copy(self):
365 cursor = self.view.displayText.textCursor()
366 selection = cursor.selection().toPlainText()
367 qtutils.set_clipboard(selection)
369 def export_patches(self):
370 '''Launches the commit browser and exports the selected
371 patches.'''
373 (revs, summaries) = cmds.git_log()
374 selection, idxs = self.__select_commits(revs, summaries)
375 if not selection: return
377 # now get the selected indices to determine whether
378 # a range of consecutive commits were selected
379 selected_range = range(idxs[0], idxs[-1] + 1)
380 export_range = len(idxs) > 1 and idxs == selected_range
382 output = cmds.git_format_patch(selection, export_range)
383 self.__show_command(output)
385 def get_commit_msg(self):
386 self.model.retrieve_latest_commitmsg()
388 def last_window_closed(self):
389 '''Save config settings and cleanup the any inotify threads.'''
391 self.__save_config_settings()
393 if not self.inotify_thread: return
394 if not self.inotify_thread.isRunning(): return
396 self.inotify_thread.abort = True
397 self.inotify_thread.quit()
398 self.inotify_thread.wait()
400 def load_commitmsg(self):
401 file = qtutils.open_dialog(self.view,
402 'Load Commit Message...',
403 defaults.DIRECTORY)
405 if file:
406 defaults.DIRECTORY = os.path.dirname(file)
407 slushy = utils.slurp(file)
408 self.model.set_commitmsg(slushy)
411 def rebase(self):
412 dlg = GitBranchDialog(self.view, cmds.git_branch())
413 dlg.setWindowTitle("Select the current branch's new root")
414 branch = dlg.getSelectedBranch()
415 if not branch: return
416 qtutils.show_command(self.view, cmds.git_rebase(branch))
418 # use *rest to handle being called from the checkbox signal
419 def rescan(self, *rest):
420 '''Populates view widgets with results from "git status."'''
422 self.view.statusBar().showMessage(
423 self.tr('Scanning for modified files ...'))
425 # Rescan for repo updates
426 self.model.update_status()
428 # Scan for branch changes
429 self.__set_branch_ui_items()
431 if not self.model.has_squash_msg(): return
433 if self.model.get_commitmsg():
434 if not qtutils.question(self.view,
435 'Import Commit Message?',
436 ('A commit message from a '
437 + 'merge-in-progress was found.\n'
438 + 'Do you want to import it?')):
439 return
441 # Set the new commit message
442 self.model.set_commitmsg(self.model.get_squash_msg())
444 def push(self):
445 model = self.model.clone()
446 view = GitPushDialog(self.view)
447 controller = GitPushController(model,view)
448 view.show()
449 view.exec_()
451 def cut(self):
452 self.copy()
453 self.delete()
455 def copy(self):
456 cursor = self.view.commitText.textCursor()
457 selection = cursor.selection().toPlainText()
458 qtutils.set_clipboard(selection)
460 def paste(self): self.view.commitText.paste()
461 def undo(self): self.view.commitText.undo()
462 def redo(self): self.view.commitText.redo()
463 def select_all(self): self.view.commitText.selectAll()
464 def delete(self):
465 self.view.commitText.textCursor().removeSelectedText()
467 def show_diffstat(self):
468 '''Show the diffstat from the latest commit.'''
469 self.__show_command(cmds.git_diff_stat(), rescan=False)
471 def stage_changed(self):
472 '''Stage all changed files for commit.'''
473 output = cmds.git_add(self.model.get_unstaged())
474 self.__show_command(output)
476 def stage_hunk(self):
478 list_widget = self.view.unstagedList
479 row, selected = qtutils.get_selected_row(list_widget)
480 if not selected: return
482 filename = self.model.get_uncommitted_item(row)
484 if not os.path.exists(filename): return
485 if os.path.isdir(filename): return
487 cursor = self.view.displayText.textCursor()
488 offset = cursor.position()
490 selection = cursor.selection().toPlainText()
491 header, diff = cmds.git_diff(filename,
492 with_diff_header=True,
493 staged=False)
494 parser = utils.DiffParser(diff)
496 num_selected_lines = selection.count(os.linesep)
497 has_selection =(selection
498 and selection.count(os.linesep) > 0)
500 if has_selection:
501 start = diff.index(selection)
502 end = start + len(selection)
503 diffs = parser.get_diffs_for_range(start, end)
504 else:
505 diffs = [ parser.get_diff_for_offset(offset) ]
507 if not diffs: return
509 for diff in diffs:
510 tmpfile = utils.get_tmp_filename()
511 file = open(tmpfile, 'w')
512 file.write(header + os.linesep + diff + os.linesep)
513 file.close()
514 self.model.apply_diff(tmpfile)
515 os.unlink(tmpfile)
517 self.rescan()
519 def stage_untracked(self):
520 '''Stage all untracked files for commit.'''
521 output = cmds.git_add(self.model.get_untracked())
522 self.__show_command(output)
524 # use *rest to handle being called from different signals
525 def stage_selected(self,*rest):
526 '''Use "git add" to add items to the git index.
527 This is a thin wrapper around __apply_to_list.'''
528 command = cmds.git_add_or_remove
529 widget = self.view.unstagedList
530 items = self.model.get_unstaged() + self.model.get_untracked()
531 self.__apply_to_list(command, widget, items)
533 # use *rest to handle being called from different signals
534 def unstage_selected(self, *rest):
535 '''Use "git reset" to remove items from the git index.
536 This is a thin wrapper around __apply_to_list.'''
537 command = cmds.git_reset
538 widget = self.view.stagedList
539 items = self.model.get_staged()
540 self.__apply_to_list(command, widget, items)
542 def unstage_all(self):
543 '''Use "git reset" to remove all items from the git index.'''
544 output = cmds.git_reset(self.model.get_staged())
545 self.__show_command(output)
547 def viz_all(self):
548 '''Visualizes the entire git history using gitk.'''
549 os.system('gitk --all &')
551 def viz_current(self):
552 '''Visualizes the current branch's history using gitk.'''
553 branch = cmds.git_current_branch()
554 os.system('gitk %s &' % utils.shell_quote(branch))
556 # These actions monitor window resizes, splitter changes, etc.
557 def move_event(self, event):
558 defaults.X = event.pos().x()
559 defaults.Y = event.pos().y()
561 def resize_event(self, event):
562 defaults.WIDTH = event.size().width()
563 defaults.HEIGHT = event.size().height()
565 def splitter_top_moved(self,*rest):
566 sizes = self.view.splitter_top.sizes()
567 defaults.SPLITTER_TOP_0 = sizes[0]
568 defaults.SPLITTER_TOP_1 = sizes[1]
570 def splitter_bottom_moved(self,*rest):
571 sizes = self.view.splitter_bottom.sizes()
572 defaults.SPLITTER_BOTTOM_0 = sizes[0]
573 defaults.SPLITTER_BOTTOM_1 = sizes[1]
575 #####################################################################
578 def __apply_to_list(self, command, widget, items):
579 '''This is a helper method that retrieves the current
580 selection list, applies a command to that list,
581 displays a dialog showing the output of that command,
582 and calls rescan to pickup changes.'''
583 apply_items = qtutils.get_selection_list(widget, items)
584 command(apply_items)
585 self.rescan()
587 def __browse_branch(self, branch):
588 if not branch: return
589 # Clone the model to allow opening multiple browsers
590 # with different sets of data
591 model = self.model.clone()
592 model.set_branch(branch)
593 view = GitCommitBrowser()
594 controller = GitRepoBrowserController(model, view)
595 view.show()
596 view.exec_()
598 def __menu_about_to_show(self):
599 cursor = self.view.displayText.textCursor()
600 allow_hunk_staging = not self.__staged_diff_in_view
601 self.__stage_hunk_action.setEnabled(allow_hunk_staging)
603 def __menu_event(self, event):
604 self.__menu_setup()
605 textedit = self.view.displayText
606 self.__menu.exec_(textedit.mapToGlobal(event.pos()))
608 def __menu_setup(self):
609 if self.__menu: return
611 menu = QMenu(self.view)
612 stage = menu.addAction(self.tr('Stage Hunk For Commit'),
613 self.stage_hunk)
614 copy = menu.addAction(self.tr('Copy'), self.display_copy)
616 self.connect(menu, 'aboutToShow()', self.__menu_about_to_show)
618 self.__stage_hunk_action = stage
619 self.__copy_action = copy
620 self.__menu = menu
623 def __file_to_widget_item(self, filename, staged, untracked=False):
624 '''Given a filename, return a QListWidgetItem suitable
625 for adding to a QListWidget. "staged" controls whether
626 to use icons for the staged or unstaged list widget.'''
628 if staged:
629 icon_file = utils.get_staged_icon(filename)
630 elif untracked:
631 icon_file = utils.get_untracked_icon()
632 else:
633 icon_file = utils.get_icon(filename)
635 return qtutils.create_listwidget_item(filename, icon_file)
637 def __read_config_settings(self):
638 (w,h,x,y,
639 st0,st1,
640 sb0,sb1) = utils.parse_geom(cmds.git_config('ugit.geometry'))
641 self.view.resize(w,h)
642 self.view.move(x,y)
643 self.view.splitter_top.setSizes([st0,st1])
644 self.view.splitter_bottom.setSizes([sb0,sb1])
646 def __save_config_settings(self):
647 cmds.git_config('ugit.geometry', utils.get_geom())
649 def __select_commits(self, revs, summaries):
650 '''Use the GitCommitBrowser to select commits from a list.'''
651 if not summaries:
652 msg = 'ERROR: No commits exist in this branch.'''
653 self.__show_command(self.tr(msg))
654 return([],[])
656 browser = GitCommitBrowser(self.view)
657 self.connect(browser.commitList,
658 'itemSelectionChanged()',
659 lambda: self.commit_sha1_selected(
660 browser, revs) )
662 for summary in summaries:
663 browser.commitList.addItem(summary)
665 browser.show()
666 result = browser.exec_()
667 if result != QDialog.Accepted:
668 return([],[])
670 list_widget = browser.commitList
671 selection = qtutils.get_selection_list(list_widget, revs)
672 if not selection: return([],[])
674 # also return the selected index numbers
675 index_nums = range(len(revs))
676 idxs = qtutils.get_selection_list(list_widget, index_nums)
678 return(selection, idxs)
680 def __set_branch_ui_items(self):
681 '''Sets up items that mention the current branch name.'''
682 branch = cmds.git_current_branch()
684 status_text = self.tr('Current Branch:')
685 status_text += QtCore.QString(' ' + branch)
686 self.view.statusBar().showMessage(status_text)
688 project = self.model.get_project()
689 title = '%s [%s]' % ( project, branch )
691 self.view.setWindowTitle(title)
693 def __reset_display(self):
694 self.view.displayText.setText('')
695 self.__set_info('')
697 def __set_info(self,text):
698 self.view.displayLabel.setText(text)
700 def __start_inotify_thread(self):
701 # Do we have inotify? If not, return.
702 # Recommend installing inotify if we're on Linux.
703 self.inotify_thread = None
704 try:
705 from inotify import GitNotifier
706 except ImportError:
707 import platform
708 if platform.system() == 'Linux':
709 msg =('ugit could not find python-inotify.'
710 + '\nSupport for inotify is disabled.')
712 plat = platform.platform().lower()
713 if 'debian' in plat or 'ubuntu' in plat:
714 msg += '\n\nHint: sudo apt-get install python-pyinotify'
716 qtutils.information(self.view,
717 'inotify support disabled', msg)
718 return
720 self.inotify_thread = GitNotifier(os.getcwd())
721 self.connect(self.inotify_thread,
722 'timeForRescan()', self.rescan)
724 # Start the notification thread
725 self.inotify_thread.start()
727 def __show_command(self, output, rescan=True):
728 '''Shows output and optionally rescans for changes.'''
729 qtutils.show_command(self.view, output)
730 if rescan: self.rescan()
732 def __update_list_widget(self, list_widget, items,
733 staged, untracked=False, append=False):
734 '''A helper method to populate a QListWidget with the
735 contents of modelitems.'''
736 if not append:
737 list_widget.clear()
738 for item in items:
739 qitem = self.__file_to_widget_item(item,
740 staged, untracked)
741 list_widget.addItem(qitem)