1 """This view provides the main git-cola user interface.
5 from PyQt4
import QtGui
6 from PyQt4
import QtCore
7 from PyQt4
.QtCore
import Qt
8 from PyQt4
.QtCore
import SIGNAL
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."""
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
,
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
)
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."""
180 self
.rescan_button
.hide()
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:
201 merge_msg_hash
= utils
.checksum(merge_message_path
)
202 if merge_msg_hash
== self
.merge_msg_hash
:
204 self
.merge_msg_hash
= merge_msg_hash
205 cola
.notifier().broadcast(signals
.load_commit_message
,
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()
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()
222 self
.display_text
.setText(text
)
223 scrollbar
.setValue(scrollvalue
)
225 def action_cut(self
):
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())
253 and contents
[offset
-1]
254 and contents
[offset
-1] != '\n'):
256 data
= contents
[offset
:]
258 line
, rest
= data
.split('\n', 1)
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
)
272 display
= ('<span style="background-color: red;">%s</span>' %
273 display
.replace(' ', ' '))
275 display
= ('<span style="background-color: orange;">%s</span>' %
276 display
.replace(' ', ' '))
278 display
= ('<span style="background-color: yellow;">%s</span>' %
279 display
.replace(' ', ' '))
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())
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() +
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',
312 self
.model
.all_branches() +
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() +
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',
332 self
.model
.all_branches() +
336 zfiles_str
= self
.model
.git
.diff(branch
, name_only
=True,
339 files
= zfiles_str
.split('\0')
340 filename
= choose_from_list('Select File', files
)
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...',
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')
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
:
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
:
375 elif self
.mode
== self
.model
.mode_index
:
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,
386 """Implement un/staging of selected lines or hunks."""
387 offset
, selection
= self
.diff_selection()
388 cola
.notifier().broadcast(signals
.apply_diff_selection
,
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'
404 self
.process_diff_selection(staged
=False, apply_to_worktree
=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'
416 self
.process_diff_selection(staged
=False, apply_to_worktree
=True,
417 reverse
=True, selected
=True)
420 """Stage selected files."""
421 paths
= cola
.selection_model().unstaged
423 cola
.notifier().broadcast(signals
.stage_modified
)
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
:
456 menu
.addAction(self
.tr('Stage &Hunk For Commit'),
458 menu
.addAction(self
.tr('Stage &Selected Lines'),
459 self
.stage_hunk_selection
)
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
)
476 menu
.addAction(self
.tr('Copy'), self
.copy_display
)
480 """Launch the 'fetch' remote dialog."""
481 remote_action(self
, 'fetch')
484 """Launch the 'push' remote dialog."""
485 remote_action(self
, 'push')
488 """Launch the 'pull' remote dialog."""
489 remote_action(self
, 'pull')
492 """Attempt to create a commit from the index and commit message."""
494 msg
= self
.model
.commitmsg
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',
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
,
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'
529 cola
.notifier().broadcast(signals
.commit
, amend
, msg
)
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')
537 cola
.notifier().broadcast(signals
.grep
, txt
)
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
)
547 """Spawn a new cola session."""
548 dirname
= qtutils
.opendir_dialog(self
,
549 'Open Git Repository...',
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
:
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]
572 # The URL is the current repo
573 default
= os
.path
.basename(os
.getcwd())
577 cola
.notifier().broadcast(signals
.information
,
579 'Could not parse: "%s"' % url
)
580 qtutils
.log(1, 'Oops, could not parse git url: "%s"' % url
)
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())
589 destdir
= os
.path
.join(dirname
, default
)
591 if os
.path
.exists(destdir
):
592 # An existing path can be specified
593 msg
= ('"%s" already exists, cola will create a new directory' %
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
)
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)
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
)
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',
638 self
.model
.all_branches())
641 # Launch the repobrowser
642 browse_git_branch(branch
)
644 def branch_create(self
):
645 """Launch the 'Create Branch' dialog."""
648 def branch_delete(self
):
649 """Launch the 'Delete Branch' dialog."""
650 branch
= choose_from_combo('Delete Branch',
651 self
.model
.local_branches
)
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
)
662 cola
.notifier().broadcast(signals
.checkout_branch
, branch
)
665 """Rebase onto a branch."""
666 branch
= choose_from_combo('Rebase Branch',
667 self
.model
.all_branches())
671 status
, output
= self
.model
.git
.rebase(branch
,
674 qtutils
.log(status
, output
)