widgets.editremotes: Add a GUI for editing remotes
[git-cola.git] / cola / main / view.py
blob1d6faf4143365f1c1c07b3142a1633ad928276c6
1 """This view provides the main git-cola user interface.
2 """
3 import os
5 from PyQt4 import QtCore
6 from PyQt4 import QtGui
7 from PyQt4.QtCore import Qt
8 from PyQt4.QtCore import SIGNAL
10 import cola
11 from cola import core
12 from cola import gitcmds
13 from cola import guicmds
14 from cola import merge
15 from cola import signals
16 from cola import gitcfg
17 from cola import qtutils
18 from cola import qtcompat
19 from cola import qt
20 from cola import resources
21 from cola import stash
22 from cola import utils
23 from cola import version
24 from cola.bookmarks import manage_bookmarks
25 from cola.classic import cola_classic
26 from cola.classic import classic_widget
27 from cola.dag import git_dag
28 from cola.git import git
29 from cola.prefs import diff_font
30 from cola.prefs import PreferencesModel
31 from cola.prefs import preferences
32 from cola.qt import create_button
33 from cola.qt import create_dock
34 from cola.qt import create_menu
35 from cola.qtutils import add_action
36 from cola.qtutils import connect_action
37 from cola.qtutils import connect_action_bool
38 from cola.qtutils import connect_button
39 from cola.qtutils import emit
40 from cola.qtutils import log
41 from cola.qtutils import relay_signal
42 from cola.qtutils import tr
43 from cola.widgets import cfgactions
44 from cola.widgets import editremotes
45 from cola.widgets import remote
46 from cola.widgets import standard
47 from cola.widgets.about import launch_about_dialog
48 from cola.widgets.about import show_shortcuts
49 from cola.widgets.archive import GitArchiveDialog
50 from cola.widgets.commitmsg import CommitMessageEditor
51 from cola.widgets.compare import compare_branches
52 from cola.widgets.createtag import create_tag
53 from cola.widgets.createbranch import create_new_branch
54 from cola.widgets.diff import DiffEditor
55 from cola.widgets.recent import browse_recent
56 from cola.widgets.status import StatusWidget
57 from cola.widgets.search import search
60 class MainView(standard.MainWindow):
61 def __init__(self, model, parent):
62 super(MainView, self).__init__(parent)
63 # Default size; this is thrown out when save/restore is used
64 self.resize(987, 610)
65 self.model = model
66 self.prefs_model = prefs_model = PreferencesModel()
68 # Internal field used by import/export_state().
69 # Change this whenever dockwidgets are removed.
70 self.widget_version = 1
72 # Keeps track of merge messages we've seen
73 self.merge_message_hash = ''
75 self.setAcceptDrops(True)
76 self.setAttribute(Qt.WA_MacMetalStyle)
78 # Dockwidget options
79 qtcompat.set_common_dock_options(self)
81 cfg = gitcfg.instance()
82 self.classic_dockable = (cfg.get('cola.browserdockable') or
83 cfg.get('cola.classicdockable'))
84 if self.classic_dockable:
85 self.classicdockwidget = create_dock('Browser', self)
86 self.classicwidget = classic_widget(self)
87 self.classicdockwidget.setWidget(self.classicwidget)
89 # "Actions" widget
90 self.actionsdockwidget = create_dock('Action', self)
91 self.actionsdockwidgetcontents = qt.QFlowLayoutWidget(self)
92 layout = self.actionsdockwidgetcontents.layout()
93 self.stage_button = create_button(text='Stage', layout=layout)
94 self.unstage_button = create_button(text='Unstage', layout=layout)
95 self.rescan_button = create_button(text='Rescan', layout=layout)
96 self.fetch_button = create_button(text='Fetch...', layout=layout)
97 self.push_button = create_button(text='Push...', layout=layout)
98 self.pull_button = create_button(text='Pull...', layout=layout)
99 self.stash_button = create_button(text='Stash...', layout=layout)
100 layout.addStretch()
101 self.actionsdockwidget.setWidget(self.actionsdockwidgetcontents)
103 # "Repository Status" widget
104 self.statusdockwidget = create_dock('Status', self)
105 self.statusdockwidget.setWidget(StatusWidget(self))
107 # "Commit Message Editor" widget
108 self.position_label = QtGui.QLabel()
109 font = qtutils.default_monospace_font()
110 font.setPointSize(int(font.pointSize() * 0.8))
111 self.position_label.setFont(font)
112 self.commitdockwidget = create_dock('Commit', self)
113 titlebar = self.commitdockwidget.titleBarWidget()
114 titlebar.add_corner_widget(self.position_label)
116 self.commitmsgeditor = CommitMessageEditor(model, self)
117 relay_signal(self, self.commitmsgeditor, SIGNAL(signals.amend_mode))
118 relay_signal(self, self.commitmsgeditor, SIGNAL(signals.signoff))
119 relay_signal(self, self.commitmsgeditor,
120 SIGNAL(signals.load_previous_message))
121 self.commitdockwidget.setWidget(self.commitmsgeditor)
123 # "Console" widget
124 logwidget = qtutils.logger()
125 logwidget.setFont(diff_font())
126 self.logdockwidget = create_dock('Console', self)
127 self.logdockwidget.setWidget(logwidget)
129 # "Diff Viewer" widget
130 self.diffdockwidget = create_dock('Diff', self)
131 self.diff_editor = DiffEditor(self.diffdockwidget)
132 self.diffdockwidget.setWidget(self.diff_editor)
134 # All Actions
135 self.menu_unstage_all = add_action(self,
136 'Unstage All', emit(self, signals.unstage_all))
137 self.menu_unstage_all.setIcon(qtutils.icon('remove.svg'))
139 self.menu_unstage_selected = add_action(self,
140 'Unstage From Commit', emit(self, signals.unstage_selected))
141 self.menu_unstage_selected.setIcon(qtutils.icon('remove.svg'))
143 self.menu_show_diffstat = add_action(self,
144 'Diffstat', emit(self, signals.diffstat), 'Alt+D')
146 self.menu_stage_modified = add_action(self,
147 'Stage Changed Files To Commit',
148 emit(self, signals.stage_modified), 'Alt+A')
149 self.menu_stage_modified.setIcon(qtutils.icon('add.svg'))
151 self.menu_stage_untracked = add_action(self,
152 'Stage All Untracked', emit(self, signals.stage_untracked), 'Alt+U')
153 self.menu_stage_untracked.setIcon(qtutils.icon('add.svg'))
155 self.menu_export_patches = add_action(self,
156 'Export Patches...', guicmds.export_patches, 'Alt+E')
157 self.menu_preferences = add_action(self,
158 'Preferences', lambda: preferences(model=prefs_model),
159 QtGui.QKeySequence.Preferences, 'Ctrl+O')
161 self.menu_edit_remotes = add_action(self,
162 'Edit Remotes...', editremotes.edit)
163 self.menu_rescan = add_action(self,
164 'Rescan', emit(self, signals.rescan_and_refresh), 'Ctrl+R')
165 self.menu_rescan.setIcon(qtutils.reload_icon())
167 self.menu_browse_recent = add_action(self,
168 'Recently Modified Files...', browse_recent, 'Shift+Ctrl+E')
170 self.menu_cherry_pick = add_action(self,
171 'Cherry-Pick...', guicmds.cherry_pick, 'Ctrl+P')
173 self.menu_load_commitmsg = add_action(self,
174 'Load Commit Message...', guicmds.load_commitmsg)
176 self.menu_save_tarball = add_action(self,
177 'Save As Tarball/Zip...', self.save_archive)
179 self.menu_quit = add_action(self,
180 'Quit', self.close, 'Ctrl+Q')
181 self.menu_manage_bookmarks = add_action(self,
182 'Bookmarks...', manage_bookmarks)
183 self.menu_grep = add_action(self,
184 'Grep', guicmds.grep)
185 self.menu_merge_local = add_action(self,
186 'Merge...', merge.local_merge)
188 self.menu_merge_abort = add_action(self,
189 'Abort Merge...', merge.abort_merge)
191 self.menu_fetch = add_action(self,
192 'Fetch...', remote.fetch)
193 self.menu_push = add_action(self,
194 'Push...', remote.push)
195 self.menu_pull = add_action(self,
196 'Pull...', remote.pull)
198 self.menu_open_repo = add_action(self,
199 'Open...', guicmds.open_repo)
200 self.menu_open_repo.setIcon(qtutils.open_icon())
202 self.menu_stash = add_action(self,
203 'Stash...', stash.stash, 'Alt+Shift+S')
205 self.menu_clone_repo = add_action(self,
206 'Clone...', guicmds.clone_repo)
207 self.menu_clone_repo.setIcon(qtutils.git_icon())
209 self.menu_help_docs = add_action(self,
210 'Documentation', resources.show_html_docs,
211 QtGui.QKeySequence.HelpContents)
213 self.menu_help_shortcuts = add_action(self,
214 'Keyboard Shortcuts',
215 show_shortcuts,
216 QtCore.Qt.Key_Question)
218 self.menu_visualize_current = add_action(self,
219 'Visualize Current Branch...',
220 emit(self, signals.visualize_current))
221 self.menu_visualize_all = add_action(self,
222 'Visualize All Branches...',
223 emit(self, signals.visualize_all))
224 self.menu_search_commits = add_action(self,
225 'Search...', search)
226 self.menu_browse_branch = add_action(self,
227 'Browse Current Branch...', guicmds.browse_current)
228 self.menu_browse_other_branch = add_action(self,
229 'Browse Other Branch...', guicmds.browse_other)
230 self.menu_load_commitmsg_template = add_action(self,
231 'Get Commit Message Template',
232 emit(self, signals.load_commit_template))
233 self.menu_help_about = add_action(self,
234 'About', launch_about_dialog)
236 self.menu_branch_diff = add_action(self,
237 'SHA-1...', guicmds.diff_revision)
238 self.menu_diff_expression = add_action(self,
239 'Expression...', guicmds.diff_expression)
240 self.menu_branch_compare = add_action(self,
241 'Branches...', compare_branches)
243 self.menu_create_tag = add_action(self,
244 'Create Tag...', create_tag)
246 self.menu_create_branch = add_action(self,
247 'Create...', create_new_branch, 'Ctrl+B')
249 self.menu_delete_branch = add_action(self,
250 'Delete...', guicmds.branch_delete)
252 self.menu_checkout_branch = add_action(self,
253 'Checkout...', guicmds.checkout_branch, 'Alt+B')
254 self.menu_rebase_branch = add_action(self,
255 'Rebase...', guicmds.rebase)
256 self.menu_branch_review = add_action(self,
257 'Review...', guicmds.review_branch)
259 self.menu_classic = add_action(self,
260 'Browser...', cola_classic)
261 self.menu_classic.setIcon(qtutils.git_icon())
263 self.menu_dag = add_action(self,
264 'DAG...', lambda: git_dag(self.model))
265 self.menu_dag.setIcon(qtutils.git_icon())
267 # Relayed actions
268 if not self.classic_dockable:
269 # These shortcuts conflict with those from the
270 # 'Browser' widget so don't register them when
271 # the browser is a dockable tool.
272 status_tree = self.statusdockwidget.widget().tree
273 self.addAction(status_tree.up)
274 self.addAction(status_tree.down)
275 self.addAction(status_tree.process_selection)
276 self.addAction(status_tree.launch_difftool)
278 # Create the application menu
279 self.menubar = QtGui.QMenuBar(self)
281 # File Menu
282 self.file_menu = create_menu('&File', self.menubar)
283 self.file_menu.addAction(self.menu_preferences)
284 self.file_menu.addSeparator()
285 self.file_menu.addAction(self.menu_open_repo)
286 self.file_menu.addAction(self.menu_clone_repo)
287 self.file_menu.addAction(self.menu_manage_bookmarks)
288 self.file_menu.addSeparator()
289 self.file_menu.addAction(self.menu_edit_remotes)
290 self.file_menu.addAction(self.menu_rescan)
291 self.file_menu.addSeparator()
292 self.file_menu.addAction(self.menu_browse_recent)
293 self.file_menu.addSeparator()
294 self.file_menu.addAction(self.menu_load_commitmsg)
295 self.file_menu.addAction(self.menu_load_commitmsg_template)
296 self.file_menu.addSeparator()
297 self.file_menu.addAction(self.menu_save_tarball)
298 self.file_menu.addAction(self.menu_quit)
299 # Add to menubar
300 self.menubar.addAction(self.file_menu.menuAction())
302 # Commit Menu
303 self.commit_menu = create_menu('Co&mmit', self.menubar)
304 self.commit_menu.setTitle(tr('Commit@@verb'))
305 self.commit_menu.addAction(self.menu_stage_modified)
306 self.commit_menu.addAction(self.menu_stage_untracked)
307 self.commit_menu.addSeparator()
308 self.commit_menu.addAction(self.menu_unstage_all)
309 self.commit_menu.addAction(self.menu_unstage_selected)
310 self.commit_menu.addSeparator()
311 self.commit_menu.addAction(self.menu_search_commits)
312 # Add to menubar
313 self.menubar.addAction(self.commit_menu.menuAction())
315 # Branch Menu
316 self.branch_menu = create_menu('B&ranch', self.menubar)
317 self.branch_menu.addAction(self.menu_branch_review)
318 self.branch_menu.addSeparator()
319 self.branch_menu.addAction(self.menu_create_branch)
320 self.branch_menu.addAction(self.menu_checkout_branch)
321 self.branch_menu.addAction(self.menu_rebase_branch)
322 self.branch_menu.addAction(self.menu_delete_branch)
323 self.branch_menu.addSeparator()
324 self.branch_menu.addAction(self.menu_browse_branch)
325 self.branch_menu.addAction(self.menu_browse_other_branch)
326 self.branch_menu.addSeparator()
327 self.branch_menu.addAction(self.menu_visualize_current)
328 self.branch_menu.addAction(self.menu_visualize_all)
329 # Add to menubar
330 self.menubar.addAction(self.branch_menu.menuAction())
332 # Actions menu
333 self.actions_menu = create_menu('Act&ions', self.menubar)
334 self.actions_menu.addAction(self.menu_merge_local)
335 self.actions_menu.addAction(self.menu_stash)
336 self.actions_menu.addSeparator()
337 self.actions_menu.addAction(self.menu_fetch)
338 self.actions_menu.addAction(self.menu_push)
339 self.actions_menu.addAction(self.menu_pull)
340 self.actions_menu.addSeparator()
341 self.actions_menu.addAction(self.menu_create_tag)
342 self.actions_menu.addSeparator()
343 self.actions_menu.addAction(self.menu_export_patches)
344 self.actions_menu.addAction(self.menu_cherry_pick)
345 self.actions_menu.addSeparator()
346 self.actions_menu.addAction(self.menu_merge_abort)
347 self.actions_menu.addAction(self.menu_grep)
348 # Add to menubar
349 self.menubar.addAction(self.actions_menu.menuAction())
351 # Diff Menu
352 self.diff_menu = create_menu('&Diff', self.menubar)
353 self.diff_menu.addAction(self.menu_branch_diff)
354 self.diff_menu.addAction(self.menu_diff_expression)
355 self.diff_menu.addAction(self.menu_branch_compare)
356 self.diff_menu.addSeparator()
357 self.diff_menu.addAction(self.menu_show_diffstat)
358 # Add to menubar
359 self.menubar.addAction(self.diff_menu.menuAction())
361 # Tools Menu
362 self.tools_menu = create_menu('&Tools', self.menubar)
363 self.tools_menu.addAction(self.menu_classic)
364 self.tools_menu.addAction(self.menu_dag)
365 self.tools_menu.addSeparator()
366 if self.classic_dockable:
367 self.tools_menu.addAction(self.classicdockwidget.toggleViewAction())
369 self.setup_dockwidget_tools_menu()
370 self.menubar.addAction(self.tools_menu.menuAction())
372 # Help Menu
373 self.help_menu = create_menu('&Help', self.menubar)
374 self.help_menu.addAction(self.menu_help_docs)
375 self.help_menu.addAction(self.menu_help_shortcuts)
376 self.help_menu.addAction(self.menu_help_about)
377 # Add to menubar
378 self.menubar.addAction(self.help_menu.menuAction())
380 # Set main menu
381 self.setMenuBar(self.menubar)
383 # Arrange dock widgets
384 top = Qt.TopDockWidgetArea
385 bottom = Qt.BottomDockWidgetArea
387 self.addDockWidget(top, self.commitdockwidget)
388 if self.classic_dockable:
389 self.addDockWidget(top, self.classicdockwidget)
390 self.addDockWidget(top, self.statusdockwidget)
391 self.addDockWidget(top, self.actionsdockwidget)
392 self.addDockWidget(bottom, self.logdockwidget)
393 if self.classic_dockable:
394 self.tabifyDockWidget(self.classicdockwidget, self.commitdockwidget)
395 self.tabifyDockWidget(self.logdockwidget, self.diffdockwidget)
397 # Listen for model notifications
398 model.add_observer(model.message_updated, self._update_view)
400 prefs_model.add_observer(prefs_model.message_config_updated,
401 self._config_updated)
403 # Set a default value
404 self.show_cursor_position(1, 0)
406 # Add button callbacks
407 connect_button(self.rescan_button,
408 emit(self, signals.rescan_and_refresh))
409 connect_button(self.fetch_button, remote.fetch)
410 connect_button(self.push_button, remote.push)
411 connect_button(self.pull_button, remote.pull)
412 connect_button(self.stash_button, stash.stash)
414 connect_button(self.stage_button, self.stage)
415 connect_button(self.unstage_button, self.unstage)
417 self.connect(self.commitmsgeditor, SIGNAL('cursorPosition(int,int)'),
418 self.show_cursor_position)
419 self.connect(self, SIGNAL('update'), self._update_callback)
420 self.connect(self, SIGNAL('install_config_actions'),
421 self._install_config_actions)
423 # Install .git-config-defined actions
424 self._config_task = None
425 self.install_config_actions()
427 # Restore saved settings
428 qtutils.apply_state(self)
430 self.statusdockwidget.widget().setFocus()
432 log(0, version.git_version_str() + '\ncola version ' + version.version())
434 # Qt overrides
435 def closeEvent(self, event):
436 """Save state in the settings manager."""
437 qtutils.save_state(self)
438 standard.MainWindow.closeEvent(self, event)
440 # Accessors
441 mode = property(lambda self: self.model.mode)
443 def _config_updated(self, source, config, value):
444 if config == 'cola.fontdiff':
445 font = QtGui.QFont()
446 if not font.fromString(value):
447 return
448 qtutils.logger().setFont(font)
449 self.diff_editor.setFont(font)
450 self.commitmsgeditor.setFont(font)
452 elif config == 'cola.tabwidth':
453 # variable-tab-width setting
454 self.diff_editor.set_tab_width(value)
456 def install_config_actions(self):
457 """Install .gitconfig-defined actions"""
458 self._config_task = self._start_config_actions_task()
460 def _start_config_actions_task(self):
461 """Do the expensive "get_config_actions()" call in the background"""
462 class ConfigActionsTask(QtCore.QRunnable):
463 def __init__(self, sender):
464 QtCore.QRunnable.__init__(self)
465 self._sender = sender
466 def run(self):
467 names = cfgactions.get_config_actions()
468 self._sender.emit(SIGNAL('install_config_actions'), names)
470 task = ConfigActionsTask(self)
471 QtCore.QThreadPool.globalInstance().start(task)
472 return task
474 def _install_config_actions(self, names):
475 """Install .gitconfig-defined actions"""
476 if not names:
477 return
478 menu = self.actions_menu
479 menu.addSeparator()
480 for name in names:
481 menu.addAction(name, emit(self, signals.run_config_action, name))
483 def _update_view(self):
484 self.emit(SIGNAL('update'))
486 def _update_callback(self):
487 """Update the title with the current branch and directory name."""
488 branch = self.model.currentbranch
489 curdir = core.decode(os.getcwd())
490 msg = 'Repository: %s\nBranch: %s' % (curdir, branch)
491 self.commitdockwidget.setToolTip(msg)
493 title = '%s: %s' % (self.model.project, branch)
494 if self.mode == self.model.mode_amend:
495 title += ' ** amending **'
496 self.setWindowTitle(title)
498 self.commitmsgeditor.set_mode(self.mode)
500 if not self.model.read_only() and self.mode != self.model.mode_amend:
501 # Check if there's a message file in .git/
502 merge_msg_path = gitcmds.merge_message_path()
503 if merge_msg_path is None:
504 return
505 merge_msg_hash = utils.checksum(core.decode(merge_msg_path))
506 if merge_msg_hash == self.merge_message_hash:
507 return
508 self.merge_message_hash = merge_msg_hash
509 cola.notifier().broadcast(signals.load_commit_message,
510 core.decode(merge_msg_path))
512 def apply_state(self, state):
513 """Imports data for save/restore"""
514 # 1 is the widget version; change when widgets are added/removed
515 super(MainView, self).apply_state(state)
516 qtutils.apply_window_state(self, state, 1)
518 def export_state(self):
519 """Exports data for save/restore"""
520 state = super(MainView, self).export_state()
521 return qtutils.export_window_state(self, state, self.widget_version)
523 def setup_dockwidget_tools_menu(self):
524 # Hotkeys for toggling the dock widgets
525 dockwidgets = (
526 ('Alt+0', self.logdockwidget),
527 ('Alt+1', self.commitdockwidget),
528 ('Alt+2', self.statusdockwidget),
529 ('Alt+3', self.diffdockwidget),
530 ('Alt+4', self.actionsdockwidget),
532 for shortcut, dockwidget in dockwidgets:
533 # Associate the action with the shortcut
534 action = dockwidget.toggleViewAction()
535 action.setShortcut(shortcut)
536 self.tools_menu.addAction(action)
537 def showdock(show, dockwidget=dockwidget):
538 if show:
539 dockwidget.raise_()
540 dockwidget.widget().setFocus(True)
541 else:
542 self.setFocus(True)
543 self.addAction(action)
544 connect_action_bool(action, showdock)
546 # Create a new shortcut Shift+<shortcut> that gives focus
547 action = QtGui.QAction(self)
548 action.setShortcut('Shift+' + shortcut)
549 def focusdock(dockwidget=dockwidget, showdock=showdock):
550 if dockwidget.toggleViewAction().isChecked():
551 showdock(True)
552 else:
553 dockwidget.toggleViewAction().trigger()
554 self.addAction(action)
555 connect_action(action, focusdock)
557 def save_archive(self):
558 ref = git.rev_parse('HEAD')
559 shortref = ref[:7]
560 GitArchiveDialog.save(ref, shortref, self)
562 def stage(self):
563 """Stage selected files, or all files if no selection exists."""
564 paths = cola.selection_model().unstaged
565 if not paths:
566 cola.notifier().broadcast(signals.stage_modified)
567 else:
568 cola.notifier().broadcast(signals.stage, paths)
570 def unstage(self):
571 """Unstage selected files, or all files if no selection exists."""
572 paths = cola.selection_model().staged
573 if not paths:
574 cola.notifier().broadcast(signals.unstage_all)
575 else:
576 cola.notifier().broadcast(signals.unstage, paths)
578 def dragEnterEvent(self, event):
579 """Accepts drops"""
580 standard.MainWindow.dragEnterEvent(self, event)
581 event.acceptProposedAction()
583 def dropEvent(self, event):
584 """Apply dropped patches with git-am"""
585 event.accept()
586 urls = event.mimeData().urls()
587 if not urls:
588 return
589 paths = map(lambda x: unicode(x.path()), urls)
590 patches = [p for p in paths if p.endswith('.patch')]
591 dirs = [p for p in paths if os.path.isdir(p)]
592 dirs.sort()
593 for d in dirs:
594 patches.extend(self._gather_patches(d))
595 # Broadcast the patches to apply
596 cola.notifier().broadcast(signals.apply_patches, patches)
598 def _gather_patches(self, path):
599 """Find patches in a subdirectory"""
600 patches = []
601 for root, subdirs, files in os.walk(path):
602 for name in [f for f in files if f.endswith('.patch')]:
603 patches.append(os.path.join(root, name))
604 return patches
606 def show_cursor_position(self, rows, cols):
607 display = '&nbsp;%02d:%02d&nbsp;' % (rows, cols)
608 if cols > 78:
609 display = ('<span style="color: white; '
610 ' background-color: red;"'
611 '>%s</span>' % display)
612 elif cols > 72:
613 display = ('<span style="color: black; '
614 ' background-color: orange;"'
615 '>%s</span>' % display)
616 elif cols > 64:
617 display = ('<span style="color: black; '
618 ' background-color: yellow;"'
619 '>%s</span>' % display)
620 else:
621 display = ('<span style="color: grey;">%s</span>' % display)
623 self.position_label.setText(display)