cola: Integrate the SelectionModel
[git-cola.git] / cola / views / main.py
blobfe8f915b4b55aa0a6d3b09ce99a281385104cc6e
1 """This view provides the main git-cola user interface.
2 """
3 import os
5 from PyQt4 import QtGui
6 from PyQt4 import QtCore
7 from PyQt4.QtCore import Qt
8 from PyQt4.QtCore import SIGNAL
10 import cola
11 from cola import core
12 from cola import utils
13 from cola import qtutils
14 from cola import settings
15 from cola import signals
16 from cola import resources
17 from cola.qtutils import SLOT
18 from cola.views import about
19 from cola.views.syntax import DiffSyntaxHighlighter
20 from cola.views.mainwindow import MainWindow
21 from cola.controllers import compare
22 from cola.controllers import search as smod
23 from cola.controllers.bookmark import manage_bookmarks
24 from cola.controllers.bookmark import save_bookmark
25 from cola.controllers.createbranch import create_new_branch
26 from cola.controllers.merge import local_merge
27 from cola.controllers.merge import abort_merge
28 from cola.controllers.options import update_options
29 from cola.controllers.util import choose_from_combo
30 from cola.controllers.util import choose_from_list
31 from cola.controllers.remote import remote_action
32 from cola.controllers.repobrowser import browse_git_branch
33 from cola.controllers.stash import stash
34 from cola.controllers.selectcommits import select_commits
36 class MainView(MainWindow):
37 """The main cola interface."""
38 idx_header = -1
39 idx_staged = 0
40 idx_modified = 1
41 idx_unmerged = 2
42 idx_untracked = 3
43 idx_end = 4
45 # Read-only mode property
46 mode = property(lambda self: self.model.mode)
48 def __init__(self, parent=None):
49 MainWindow.__init__(self, parent)
50 self.amend_is_checked = self.amend_checkbox.isChecked
52 # Qt does not support noun/verbs
53 self.commit_button.setText(qtutils.tr('Commit@@verb'))
54 self.commit_menu.setTitle(qtutils.tr('Commit@@verb'))
56 # Diff/patch syntax highlighter
57 self.syntax = DiffSyntaxHighlighter(self.display_text.document())
59 # Display the current column
60 self.connect(self.commitmsg,
61 SIGNAL('cursorPositionChanged()'),
62 self.show_cursor_position)
64 # Keeps track of merge messages we've seen
65 self.merge_message_hash = ''
67 # Initialize the seen tree widget indexes
68 self._seen_indexes = set()
70 # Initialize the GUI to show 'Column: 00'
71 self.show_cursor_position()
73 # Internal field used by import/export_state().
74 # Change this whenever dockwidgets are removed.
75 self._widget_version = 1
77 self.model = cola.model()
78 self.model.add_message_observer(self.model.message_updated,
79 self._update_view)
81 # Listen for text and amend messages
82 cola.notifier().listen(signals.diff_text, self.set_display)
83 cola.notifier().listen(signals.mode, self._mode_changed)
84 cola.notifier().listen(signals.inotify, self._inotify_enabled)
85 cola.notifier().listen(signals.amend, self.amend_checkbox.setChecked)
87 # Broadcast the amend mode
88 self.connect(self.amend_checkbox, SIGNAL('toggled(bool)'),
89 SLOT(signals.amend_mode))
91 # Add button callbacks
92 self._relay_button(self.alt_button, signals.reset_mode)
93 self._relay_button(self.rescan_button, signals.rescan)
94 self._relay_button(self.signoff_button, signals.add_signoff)
96 self._connect_button(self.stage_button, self.stage)
97 self._connect_button(self.commit_button, self.commit)
98 self._connect_button(self.fetch_button, self.fetch)
99 self._connect_button(self.push_button, self.push)
100 self._connect_button(self.pull_button, self.pull)
101 self._connect_button(self.stash_button, stash)
103 # Menu actions
104 actions = (
105 (self.menu_quit, self.close),
106 (self.menu_branch_compare, compare.branch_compare),
107 (self.menu_branch_diff, self.branch_diff),
108 (self.menu_branch_review, self.review_branch),
109 (self.menu_browse_branch, self.browse_current),
110 (self.menu_browse_other_branch, self.browse_other),
111 (self.menu_browse_commits, self.browse_commits),
112 (self.menu_create_branch, create_new_branch),
113 (self.menu_checkout_branch, self.checkout_branch),
114 (self.menu_delete_branch, self.branch_delete),
115 (self.menu_rebase_branch, self.rebase),
116 (self.menu_clone_repo, self.clone_repo),
117 (self.menu_commit_compare, compare.compare),
118 (self.menu_commit_compare_file, compare.compare_file),
119 (self.menu_cherry_pick, self.cherry_pick),
120 (self.menu_diff_expression, self.diff_expression),
121 (self.menu_diff_branch, self.diff_branch),
122 (self.menu_export_patches, self.export_patches),
123 (self.menu_help_about, about.launch_about_dialog),
124 (self.menu_help_docs,
125 lambda: self.model.git.web__browse(resources.html_docs())),
126 (self.menu_load_commitmsg, self.load_commitmsg),
127 (self.menu_load_commitmsg_template, self.load_template),
128 (self.menu_manage_bookmarks, manage_bookmarks),
129 (self.menu_save_bookmark, save_bookmark),
130 (self.menu_merge_local, local_merge),
131 (self.menu_merge_abort, abort_merge),
132 (self.menu_open_repo, self.open_repo),
133 (self.menu_options, update_options),
134 (self.menu_rescan, SLOT(signals.rescan)),
135 (self.menu_search_grep, self.grep),
136 (self.menu_search_revision, smod.search(smod.REVISION_ID)),
137 (self.menu_search_revision_range, smod.search(smod.REVISION_RANGE)),
138 (self.menu_search_message, smod.search(smod.MESSAGE)),
139 (self.menu_search_path, smod.search(smod.PATH, True)),
140 (self.menu_search_date_range, smod.search(smod.DATE_RANGE)),
141 (self.menu_search_diff, smod.search(smod.DIFF)),
142 (self.menu_search_author, smod.search(smod.AUTHOR)),
143 (self.menu_search_committer, smod.search(smod.COMMITTER)),
144 (self.menu_show_diffstat, SLOT(signals.diffstat)),
145 (self.menu_stash, stash),
146 (self.menu_stage_modified, SLOT(signals.stage_modified)),
147 (self.menu_stage_untracked, SLOT(signals.stage_untracked)),
148 (self.menu_unstage_all, SLOT(signals.unstage_all)),
149 (self.menu_visualize_all, SLOT(signals.visualize_all)),
150 (self.menu_visualize_current, SLOT(signals.visualize_current)),
151 # TODO This edit menu stuff should/could be command objects
152 (self.menu_cut, self.action_cut),
153 (self.menu_copy, self.action_copy),
154 (self.menu_paste, self.commitmsg.paste),
155 (self.menu_delete, self.action_delete),
156 (self.menu_select_all, self.commitmsg.selectAll),
157 (self.menu_undo, self.commitmsg.undo),
158 (self.menu_redo, self.commitmsg.redo),
160 for menu, callback in actions:
161 self.connect(menu, SIGNAL('triggered()'), callback)
163 # Install diff shortcut keys for stage/unstage
164 self.display_text.keyPressEvent = self.diff_key_press_event
165 self.display_text.contextMenuEvent = self.diff_context_menu_event
167 # Restore saved settings
168 self._load_gui_state()
170 def _relay_button(self, button, signal):
171 callback = SLOT(signal)
172 self._connect_button(button, callback)
174 def _connect_button(self, button, callback):
175 self.connect(button, SIGNAL('clicked()'), callback)
177 def _inotify_enabled(self, enabled):
178 """Hide the rescan button when inotify is enabled."""
179 if enabled:
180 self.rescan_button.hide()
181 else:
182 self.rescan_button.show()
184 def _update_view(self):
185 """Update the title with the current branch and directory name."""
186 title = '%s [%s]' % (self.model.project,
187 self.model.currentbranch)
188 if self.mode in (self.model.mode_diff, self.model.mode_diff_expr):
189 title += ' *** diff mode***'
190 elif self.mode == self.model.mode_review:
191 title += ' *** review mode***'
192 elif self.mode == self.model.mode_amend:
193 title += ' *** amending ***'
194 self.setWindowTitle(title)
196 if not self.model.read_only() and self.mode != self.model.mode_amend:
197 # Check if there's a message file in .git/
198 merge_msg_path = self.model.merge_message_path()
199 if merge_msg_path is None:
200 return
201 merge_msg_hash = utils.checksum(merge_message_path)
202 if merge_msg_hash == self.merge_msg_hash:
203 return
204 self.merge_msg_hash = merge_msg_hash
205 cola.notifier().broadcast(signals.load_commit_message,
206 merge_msg_path)
208 def _mode_changed(self, mode):
209 """React to mode changes; hide/show the "Exit Diff Mode" button."""
210 if mode in (self.model.mode_review, self.model.mode_diff):
211 self.alt_button.setMinimumHeight(40)
212 self.alt_button.show()
213 else:
214 self.alt_button.setMinimumHeight(1)
215 self.alt_button.hide()
217 def set_display(self, text):
218 """Set the diff text display."""
219 scrollbar = self.display_text.verticalScrollBar()
220 scrollvalue = scrollbar.value()
221 if text is not None:
222 self.display_text.setText(text)
223 scrollbar.setValue(scrollvalue)
225 def action_cut(self):
226 self.action_copy()
227 self.action_delete()
229 def action_copy(self):
230 cursor = self.commitmsg.textCursor()
231 selection = cursor.selection().toPlainText()
232 qtutils.set_clipboard(selection)
234 def action_delete(self):
235 self.commitmsg.textCursor().removeSelectedText()
237 def copy_display(self):
238 cursor = self.display_text.textCursor()
239 selection = cursor.selection().toPlainText()
240 qtutils.set_clipboard(selection)
242 def diff_selection(self):
243 cursor = self.display_text.textCursor()
244 offset = cursor.position()
245 selection = unicode(cursor.selection().toPlainText())
246 return offset, selection
248 def selected_line(self):
249 cursor = self.display_text.textCursor()
250 offset = cursor.position()
251 contents = unicode(self.display_text.toPlainText())
252 while (offset >= 1
253 and contents[offset-1]
254 and contents[offset-1] != '\n'):
255 offset -= 1
256 data = contents[offset:]
257 if '\n' in data:
258 line, rest = data.split('\n', 1)
259 else:
260 line = data
261 return line
263 def show_cursor_position(self):
264 """Update the UI with the current row and column."""
265 cursor = self.commitmsg.textCursor()
266 position = cursor.position()
267 txt = unicode(self.commitmsg.toPlainText())
268 rows = txt[:position].count('\n') + 1
269 cols = cursor.columnNumber()
270 display = ' %d,%d ' % (rows, cols)
271 if cols > 78:
272 display = ('<span style="background-color: red;">%s</span>' %
273 display.replace(' ', '&nbsp;'))
274 elif cols > 72:
275 display = ('<span style="background-color: orange;">%s</span>' %
276 display.replace(' ', '&nbsp;'))
277 elif cols > 64:
278 display = ('<span style="background-color: yellow;">%s</span>' %
279 display.replace(' ', '&nbsp;'))
280 self.position_label.setText(display)
282 def import_state(self, state):
283 """Imports data for save/restore"""
284 MainWindow.import_state(self, state)
285 # Restore the dockwidget, etc. window state
286 if 'windowstate' in state:
287 windowstate = state['windowstate']
288 self.restoreState(QtCore.QByteArray.fromBase64(str(windowstate)),
289 self._widget_version)
291 def export_state(self):
292 """Exports data for save/restore"""
293 state = MainWindow.export_state(self)
294 # Save the window state
295 windowstate = self.saveState(self._widget_version)
296 state['windowstate'] = unicode(windowstate.toBase64().data())
297 return state
299 def review_branch(self):
300 """Diff against an arbitrary revision, branch, tag, etc."""
301 branch = choose_from_combo('Select Branch, Tag, or Commit-ish',
302 self.model.all_branches() +
303 self.model.tags)
304 if not branch:
305 return
306 cola.notifier().broadcast(signals.review_branch_mode, branch)
308 def branch_diff(self):
309 """Diff against an arbitrary revision, branch, tag, etc."""
310 branch = choose_from_combo('Select Branch, Tag, or Commit-ish',
311 ['HEAD^'] +
312 self.model.all_branches() +
313 self.model.tags)
314 if not branch:
315 return
316 cola.notifier().broadcast(signals.diff_mode, branch)
318 def diff_expression(self):
319 """Diff using an arbitrary expression."""
320 expr = choose_from_combo('Enter Diff Expression',
321 self.model.all_branches() +
322 self.model.tags)
323 if not expr:
324 return
325 cola.notifier().broadcast(signals.diff_expr_mode, expr)
328 def diff_branch(self):
329 """Launches a diff against a branch."""
330 branch = choose_from_combo('Select Branch, Tag, or Commit-ish',
331 ['HEAD^'] +
332 self.model.all_branches() +
333 self.model.tags)
334 if not branch:
335 return
336 zfiles_str = self.model.git.diff(branch, name_only=True,
337 no_color=True,
338 z=True).rstrip('\0')
339 files = zfiles_str.split('\0')
340 filename = choose_from_list('Select File', files)
341 if not filename:
342 return
343 cola.notifier().broadcast(signals.branch_mode, branch, filename)
345 def _load_gui_state(self):
346 """Restores the gui from the preferences file."""
347 state = settings.SettingsManager.gui_state(self)
348 self.import_state(state)
350 def load_commitmsg(self):
351 """Load a commit message from a file."""
352 filename = qtutils.open_dialog(self,
353 'Load Commit Message...',
354 self.model.getcwd())
355 if filename:
356 cola.notifier().broadcast(signals.load_commit_message, filename)
359 def load_template(self):
360 """Load the configured commit message template."""
361 template = self.model.global_config('commit.template')
362 if template:
363 cola.notifier().broadcast(signals.load_commit_message, template)
366 def diff_key_press_event(self, event):
367 """Handle shortcut keys in the diff view."""
368 if event.key() != QtCore.Qt.Key_H and event.key() != QtCore.Qt.Key_S:
369 event.ignore()
370 return
371 staged, modified, unmerged, untracked = cola.single_selection()
372 if event.key() == QtCore.Qt.Key_H:
373 if self.mode == self.model.mode_worktree and modified:
374 self.stage_hunk()
375 elif self.mode == self.model.mode_index:
376 self.unstage_hunk()
377 elif event.key() == QtCore.Qt.Key_S:
378 if self.mode == self.model.mode_worktree and modified:
379 self.stage_hunk_selection()
380 elif self.mode == self.model.mode_index:
381 self.unstage_hunk_selection()
383 def process_diff_selection(self, selected=False,
384 staged=True, apply_to_worktree=False,
385 reverse=False):
386 """Implement un/staging of selected lines or hunks."""
387 offset, selection = self.diff_selection()
388 cola.notifier().broadcast(signals.apply_diff_selection,
389 staged,
390 selected,
391 offset,
392 selection,
393 apply_to_worktree)
395 def undo_hunk(self):
396 """Destructively remove a hunk from a worktree file."""
397 if not qtutils.question(self,
398 'Destroy Local Changes?',
399 'This operation will drop '
400 'uncommitted changes.\n'
401 'Continue?',
402 default=False):
403 return
404 self.process_diff_selection(staged=False, apply_to_worktree=True,
405 reverse=True)
407 def undo_selection(self):
408 """Destructively check out content for the selected file from $head."""
409 if not qtutils.question(self,
410 'Destroy Local Changes?',
411 'This operation will drop '
412 'uncommitted changes.\n'
413 'Continue?',
414 default=False):
415 return
416 self.process_diff_selection(staged=False, apply_to_worktree=True,
417 reverse=True, selected=True)
419 def stage(self):
420 """Stage selected files."""
421 paths = cola.selection_model().unstaged
422 if not paths:
423 cola.notifier().broadcast(signals.stage_modified)
424 else:
425 cola.notifier().broadcast(signals.stage, paths)
427 def stage_hunk(self):
428 """Stage a specific hunk."""
429 self.process_diff_selection(staged=False)
431 def stage_hunk_selection(self):
432 """Stage selected lines."""
433 self.process_diff_selection(staged=False, selected=True)
435 def unstage_hunk(self, cached=True):
436 """Unstage a hunk."""
437 self.process_diff_selection(staged=True)
439 def unstage_hunk_selection(self):
440 """Unstage selected lines."""
441 self.process_diff_selection(staged=True, selected=True)
443 def diff_context_menu_event(self, event):
444 """Create the context menu for the diff display."""
445 menu = self.diff_context_menu_setup()
446 textedit = self.display_text
447 menu.exec_(textedit.mapToGlobal(event.pos()))
449 def diff_context_menu_setup(self):
450 """Set up the context menu for the diff display."""
451 menu = QtGui.QMenu(self)
452 staged, modified, unmerged, untracked = cola.selection()
454 if self.mode == self.model.mode_worktree:
455 if modified:
456 menu.addAction(self.tr('Stage &Hunk For Commit'),
457 self.stage_hunk)
458 menu.addAction(self.tr('Stage &Selected Lines'),
459 self.stage_hunk_selection)
460 menu.addSeparator()
461 menu.addAction(self.tr('Undo Hunk'), self.undo_hunk)
462 menu.addAction(self.tr('Undo Selection'), self.undo_selection)
464 elif self.mode == self.model.mode_index:
465 menu.addAction(self.tr('Unstage &Hunk From Commit'), self.unstage_hunk)
466 menu.addAction(self.tr('Unstage &Selected Lines'), self.unstage_hunk_selection)
468 elif self.mode == self.model.mode_branch:
469 menu.addAction(self.tr('Apply Diff to Work Tree'), self.stage_hunk)
470 menu.addAction(self.tr('Apply Diff Selection to Work Tree'), self.stage_hunk_selection)
472 elif self.mode == self.model.mode_grep:
473 menu.addAction(self.tr('Go Here'), self.goto_grep)
475 menu.addSeparator()
476 menu.addAction(self.tr('Copy'), self.copy_display)
477 return menu
479 def fetch(self):
480 """Launch the 'fetch' remote dialog."""
481 remote_action(self, 'fetch')
483 def push(self):
484 """Launch the 'push' remote dialog."""
485 remote_action(self, 'push')
487 def pull(self):
488 """Launch the 'pull' remote dialog."""
489 remote_action(self, 'pull')
491 def commit(self):
492 """Attempt to create a commit from the index and commit message."""
493 #self.reset_mode()
494 msg = self.model.commitmsg
495 if not msg:
496 # Describe a good commit message
497 error_msg = self.tr(''
498 'Please supply a commit message.\n\n'
499 'A good commit message has the following format:\n\n'
500 '- First line: Describe in one sentence what you did.\n'
501 '- Second line: Blank\n'
502 '- Remaining lines: Describe why this change is good.\n')
503 qtutils.log(1, error_msg)
504 cola.notifier().broadcast(signals.information,
505 'Missing Commit Message',
506 error_msg)
507 return
508 if not self.model.staged:
509 error_msg = self.tr(''
510 'No changes to commit.\n\n'
511 'You must stage at least 1 file before you can commit.\n')
512 qtutils.log(1, error_msg)
513 cola.notifier().broadcast(signals.information,
514 'No Staged Changes',
515 error_msg)
516 return
517 # Warn that amending published commits is generally bad
518 amend = self.amend_is_checked()
519 if (amend and self.model.is_commit_published() and
520 not qtutils.question(self,
521 'Rewrite Published Commit?',
522 'This commit has already been published.\n'
523 'You are rewriting published history.\n'
524 'You probably don\'t want to do this.\n\n'
525 'Continue?',
526 default=False)):
527 return
528 # Perform the commit
529 cola.notifier().broadcast(signals.commit, amend, msg)
531 def grep(self):
532 """Prompt and use 'git grep' to find the content."""
533 # This should be a command in cola.commands.
534 txt, ok = qtutils.prompt('grep')
535 if not ok:
536 return
537 cola.notifier().broadcast(signals.grep, txt)
539 def goto_grep(self):
540 """Called when Search -> Grep's right-click 'goto' action."""
541 line = self.selected_line()
542 filename, line_number, contents = line.split(':', 2)
543 filename = core.encode(filename)
544 cola.notifier().broadcast(signals.edit, [filename], line_number=line_number)
546 def open_repo(self):
547 """Spawn a new cola session."""
548 dirname = qtutils.opendir_dialog(self,
549 'Open Git Repository...',
550 self.model.getcwd())
551 if not dirname:
552 return
553 cola.notifier().broadcast(signals.open_repo, dirname)
555 def clone_repo(self):
556 """Clone a git repository."""
557 url, ok = qtutils.prompt('Path or URL to clone (Env. $VARS okay)')
558 url = os.path.expandvars(url)
559 if not ok or not url:
560 return
561 try:
562 # Pick a suitable basename by parsing the URL
563 newurl = url.replace('\\', '/')
564 default = newurl.rsplit('/', 1)[-1]
565 if default == '.git':
566 # The end of the URL is /.git, so assume it's a file path
567 default = os.path.basename(os.path.dirname(newurl))
568 if default.endswith('.git'):
569 # The URL points to a bare repo
570 default = default[:-4]
571 if url == '.':
572 # The URL is the current repo
573 default = os.path.basename(os.getcwd())
574 if not default:
575 raise
576 except:
577 cola.notifier().broadcast(signals.information,
578 'Error Cloning',
579 'Could not parse: "%s"' % url)
580 qtutils.log(1, 'Oops, could not parse git url: "%s"' % url)
581 return
583 # Prompt the user for a directory to use as the parent directory
584 msg = 'Select a parent directory for the new clone'
585 dirname = qtutils.opendir_dialog(self, msg, self.model.getcwd())
586 if not dirname:
587 return
588 count = 1
589 destdir = os.path.join(dirname, default)
590 olddestdir = destdir
591 if os.path.exists(destdir):
592 # An existing path can be specified
593 msg = ('"%s" already exists, cola will create a new directory' %
594 destdir)
595 cola.notifier().broadcast(signals.information,
596 'Directory Exists', msg)
598 # Make sure the new destdir doesn't exist
599 while os.path.exists(destdir):
600 destdir = olddestdir + str(count)
601 count += 1
602 cola.notifier().broadcast(signals.clone, url, destdir)
604 def cherry_pick(self):
605 """Launch the 'Cherry-Pick' dialog."""
606 revs, summaries = self.model.log_helper(all=True)
607 commits = select_commits('Cherry-Pick Commit',
608 revs, summaries, multiselect=False)
609 if not commits:
610 return
611 cola.notifier().broadcast(signals.cherry_pick, commits)
613 def browse_commits(self):
614 """Launch the 'Browse Commits' dialog."""
615 revs, summaries = self.model.log_helper(all=True)
616 select_commits('Browse Commits', revs, summaries)
618 def export_patches(self):
619 """Run 'git format-patch' on a list of commits."""
620 revs, summaries = self.model.log_helper()
621 to_export = select_commits('Export Patches', revs, summaries)
622 if not to_export:
623 return
624 to_export.reverse()
625 revs.reverse()
626 cola.notifier().broadcast(signals.format_patch, to_export, revs)
628 def browse_current(self):
629 """Launch the 'Browse Current Branch' dialog."""
630 branch = self.model.currentbranch
631 browse_git_branch(branch)
633 def browse_other(self):
634 """Prompt for a branch and inspect content at that point in time."""
635 # Prompt for a branch to browse
636 branch = choose_from_combo('Browse Branch Files',
637 self.view,
638 self.model.all_branches())
639 if not branch:
640 return
641 # Launch the repobrowser
642 browse_git_branch(branch)
644 def branch_create(self):
645 """Launch the 'Create Branch' dialog."""
646 create_new_branch()
648 def branch_delete(self):
649 """Launch the 'Delete Branch' dialog."""
650 branch = choose_from_combo('Delete Branch',
651 self.model.local_branches)
652 if not branch:
653 return
654 cola.notifier().broadcast(signals.delete_branch, branch)
656 def checkout_branch(self):
657 """Launch the 'Checkout Branch' dialog."""
658 branch = choose_from_combo('Checkout Branch',
659 self.model.local_branches)
660 if not branch:
661 return
662 cola.notifier().broadcast(signals.checkout_branch, branch)
664 def rebase(self):
665 """Rebase onto a branch."""
666 branch = choose_from_combo('Rebase Branch',
667 self.model.all_branches())
668 if not branch:
669 return
670 #TODO cmd
671 status, output = self.model.git.rebase(branch,
672 with_stderr=True,
673 with_status=True)
674 qtutils.log(status, output)