main: Move the 'Export Patches' shortcut to 'Alt-E'
[git-cola.git] / cola / main / view.py
blobecd467745cf2baf9842858e87f2fa2aea71bb6d0
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 settings
16 from cola import signals
17 from cola import gitcfg
18 from cola import qtutils
19 from cola import qtcompat
20 from cola import qt
21 from cola import resources
22 from cola import stash
23 from cola import utils
24 from cola import version
25 from cola.bookmarks import manage_bookmarks
26 from cola.classic import cola_classic
27 from cola.classic import classic_widget
28 from cola.controllers import compare
29 from cola.controllers import createtag
30 from cola.controllers import search
31 from cola.controllers.createbranch import create_new_branch
32 from cola.dag import git_dag
33 from cola.prefs import diff_font
34 from cola.prefs import PreferencesModel
35 from cola.prefs import preferences
36 from cola.qt import create_button
37 from cola.qt import create_dock
38 from cola.qt import create_menu
39 from cola.qtutils import add_action
40 from cola.qtutils import connect_button
41 from cola.qtutils import emit
42 from cola.qtutils import log
43 from cola.qtutils import relay_signal
44 from cola.qtutils import tr
45 from cola.views import standard
46 from cola.widgets import cfgactions
47 from cola.widgets.about import launch_about_dialog
48 from cola.widgets.about import show_shortcuts
49 from cola.widgets.commitmsg import CommitMessageEditor
50 from cola.widgets.diff import DiffTextEdit
51 from cola.widgets.status import StatusWidget
54 class MainView(standard.MainWindow):
55 def __init__(self, model, parent):
56 standard.MainWindow.__init__(self, parent)
57 # Default size; this is thrown out when save/restore is used
58 self.resize(987, 610)
59 self.model = model
60 self.prefs_model = prefs_model = PreferencesModel()
62 # Internal field used by import/export_state().
63 # Change this whenever dockwidgets are removed.
64 self.widget_version = 1
66 # Keeps track of merge messages we've seen
67 self.merge_message_hash = ''
69 self.setAcceptDrops(True)
71 # Dockwidget options
72 qtcompat.set_common_dock_options(self)
74 self.classic_dockable = gitcfg.instance().get('cola.classicdockable')
76 if self.classic_dockable:
77 self.classicdockwidget = create_dock('Cola Classic', self)
78 self.classicwidget = classic_widget(self)
79 self.classicdockwidget.setWidget(self.classicwidget)
81 # "Actions" widget
82 self.actiondockwidget = create_dock('Actions', self)
83 self.actiondockwidgetcontents = qt.QFlowLayoutWidget(self)
84 layout = self.actiondockwidgetcontents.layout()
85 self.stage_button = create_button('Stage', layout)
86 self.unstage_button = create_button('Unstage', layout)
87 self.rescan_button = create_button('Rescan', layout)
88 self.fetch_button = create_button('Fetch...', layout)
89 self.push_button = create_button('Push...', layout)
90 self.pull_button = create_button('Pull...', layout)
91 self.stash_button = create_button('Stash...', layout)
92 self.alt_button = create_button('Exit Diff Mode', layout)
93 self.alt_button.hide()
94 layout.addStretch()
95 self.actiondockwidget.setWidget(self.actiondockwidgetcontents)
97 # "Repository Status" widget
98 self.statusdockwidget = create_dock('Repository Status', self)
99 self.statusdockwidget.setWidget(StatusWidget(self))
101 # "Commit Message Editor" widget
102 self.commitdockwidget = create_dock('Commit Message Editor', self)
103 self.commitmsgeditor = CommitMessageEditor(model, self)
104 relay_signal(self, self.commitmsgeditor, SIGNAL(signals.amend_mode))
105 relay_signal(self, self.commitmsgeditor, SIGNAL(signals.signoff))
106 relay_signal(self, self.commitmsgeditor,
107 SIGNAL(signals.load_previous_message))
108 self.commitdockwidget.setWidget(self.commitmsgeditor)
110 # "Command Output" widget
111 logwidget = qtutils.logger()
112 logwidget.setFont(diff_font())
113 self.logdockwidget = create_dock('Command Output', self)
114 self.logdockwidget.setWidget(logwidget)
116 # "Diff Viewer" widget
117 self.diffdockwidget = create_dock('Diff Viewer', self)
118 self.diff_viewer = DiffTextEdit(self.diffdockwidget)
119 self.diffdockwidget.setWidget(self.diff_viewer)
121 # All Actions
122 self.menu_unstage_all = add_action(self,
123 'Unstage All', emit(self, signals.unstage_all))
124 self.menu_unstage_all.setIcon(qtutils.icon('remove.svg'))
126 self.menu_unstage_selected = add_action(self,
127 'Unstage From Commit', emit(self, signals.unstage_selected))
128 self.menu_unstage_selected.setIcon(qtutils.icon('remove.svg'))
130 self.menu_show_diffstat = add_action(self,
131 'Diffstat', emit(self, signals.diffstat), 'Alt+D')
133 self.menu_stage_modified = add_action(self,
134 'Stage Changed Files To Commit',
135 emit(self, signals.stage_modified), 'Alt+A')
136 self.menu_stage_modified.setIcon(qtutils.icon('add.svg'))
138 self.menu_stage_untracked = add_action(self,
139 'Stage All Untracked', emit(self, signals.stage_untracked), 'Alt+U')
140 self.menu_stage_untracked.setIcon(qtutils.icon('add.svg'))
142 self.menu_export_patches = add_action(self,
143 'Export Patches...', guicmds.export_patches, 'Alt+E')
144 self.menu_preferences = add_action(self,
145 'Preferences', lambda: preferences(model=prefs_model),
146 QtGui.QKeySequence.Preferences, 'Ctrl+O')
148 self.menu_rescan = add_action(self,
149 'Rescan', emit(self, signals.rescan_and_refresh), 'Ctrl+R')
150 self.menu_rescan.setIcon(qtutils.reload_icon())
152 self.menu_cherry_pick = add_action(self,
153 'Cherry-Pick...', guicmds.cherry_pick, 'Ctrl+P')
155 self.menu_load_commitmsg = add_action(self,
156 'Load Commit Message...', guicmds.load_commitmsg)
158 self.menu_quit = add_action(self,
159 'Quit', self.close, 'Ctrl+Q')
160 self.menu_manage_bookmarks = add_action(self,
161 'Bookmarks...', manage_bookmarks)
162 self.menu_grep = add_action(self,
163 'Grep', guicmds.grep)
164 self.menu_merge_local = add_action(self,
165 'Merge...', merge.local_merge)
167 self.menu_merge_abort = add_action(self,
168 'Abort Merge...', merge.abort_merge)
170 self.menu_fetch = add_action(self,
171 'Fetch...', guicmds.fetch)
172 self.menu_push = add_action(self,
173 'Push...', guicmds.push)
174 self.menu_pull = add_action(self,
175 'Pull...', guicmds.pull)
177 self.menu_open_repo = add_action(self,
178 'Open...', guicmds.open_repo)
179 self.menu_open_repo.setIcon(qtutils.open_icon())
181 self.menu_stash = add_action(self,
182 'Stash...', stash.stash, 'Alt+Shift+S')
183 self.menu_diff_branch = add_action(self,
184 'Apply Changes From Branch...', guicmds.diff_branch)
185 self.menu_branch_compare = add_action(self,
186 'Branches...', compare.branch_compare)
188 self.menu_clone_repo = add_action(self,
189 'Clone...', guicmds.clone_repo)
190 self.menu_clone_repo.setIcon(qtutils.git_icon())
192 self.menu_help_docs = add_action(self,
193 'Documentation',
194 lambda: self.model.git.web__browse(resources.html_docs()),
195 QtGui.QKeySequence.HelpContents)
197 self.menu_help_shortcuts = add_action(self,
198 'Keyboard Shortcuts',
199 show_shortcuts,
200 QtCore.Qt.Key_Question)
202 self.menu_commit_compare = add_action(self,
203 'Commits...', compare.compare)
204 self.menu_commit_compare_file = add_action(self,
205 'Commits Touching File...', compare.compare_file)
206 self.menu_visualize_current = add_action(self,
207 'Visualize Current Branch...',
208 emit(self, signals.visualize_current))
209 self.menu_visualize_all = add_action(self,
210 'Visualize All Branches...',
211 emit(self, signals.visualize_all))
212 self.menu_search_commits = add_action(self,
213 'Search...', search.search)
214 self.menu_browse_branch = add_action(self,
215 'Browse Current Branch...', guicmds.browse_current)
216 self.menu_browse_other_branch = add_action(self,
217 'Browse Other Branch...', guicmds.browse_other)
218 self.menu_load_commitmsg_template = add_action(self,
219 'Get Commit Message Template',
220 emit(self, signals.load_commit_template))
221 self.menu_help_about = add_action(self,
222 'About', launch_about_dialog)
223 self.menu_branch_diff = add_action(self,
224 'SHA-1...', guicmds.branch_diff)
225 self.menu_diff_expression = add_action(self,
226 'Expression...', guicmds.diff_expression)
227 self.menu_create_tag = add_action(self,
228 'Create Tag...', createtag.create_tag)
230 self.menu_create_branch = add_action(self,
231 'Create...', create_new_branch, 'Ctrl+B')
233 self.menu_delete_branch = add_action(self,
234 'Delete...', guicmds.branch_delete)
236 self.menu_checkout_branch = add_action(self,
237 'Checkout...', guicmds.checkout_branch, 'Alt+B')
238 self.menu_rebase_branch = add_action(self,
239 'Rebase...', guicmds.rebase)
240 self.menu_branch_review = add_action(self,
241 'Review...', guicmds.review_branch)
243 self.menu_classic = add_action(self,
244 'Cola Classic...', cola_classic)
245 self.menu_classic.setIcon(qtutils.git_icon())
247 self.menu_dag = add_action(self,
248 'DAG...', lambda: git_dag(self.model))
249 self.menu_dag.setIcon(qtutils.git_icon())
251 # Relayed actions
252 status_tree = self.statusdockwidget.widget().tree
253 self.addAction(status_tree.up)
254 self.addAction(status_tree.down)
255 self.addAction(status_tree.process_selection)
256 self.addAction(status_tree.launch_difftool)
258 # Create the application menu
259 self.menubar = QtGui.QMenuBar(self)
261 # File Menu
262 self.file_menu = create_menu('&File', self.menubar)
263 self.file_menu.addAction(self.menu_preferences)
264 self.file_menu.addSeparator()
265 self.file_menu.addAction(self.menu_open_repo)
266 self.file_menu.addAction(self.menu_clone_repo)
267 self.file_menu.addAction(self.menu_manage_bookmarks)
268 self.file_menu.addSeparator()
269 self.file_menu.addAction(self.menu_rescan)
270 self.file_menu.addSeparator()
271 self.file_menu.addSeparator()
272 self.file_menu.addAction(self.menu_load_commitmsg)
273 self.file_menu.addAction(self.menu_load_commitmsg_template)
274 self.file_menu.addSeparator()
275 self.file_menu.addAction(self.menu_quit)
276 # Add to menubar
277 self.menubar.addAction(self.file_menu.menuAction())
279 # Commit Menu
280 self.commit_menu = create_menu('Co&mmit', self.menubar)
281 self.commit_menu.setTitle(tr('Commit@@verb'))
282 self.commit_menu.addAction(self.menu_stage_modified)
283 self.commit_menu.addAction(self.menu_stage_untracked)
284 self.commit_menu.addSeparator()
285 self.commit_menu.addAction(self.menu_unstage_all)
286 self.commit_menu.addAction(self.menu_unstage_selected)
287 self.commit_menu.addSeparator()
288 self.commit_menu.addAction(self.menu_search_commits)
289 # Add to menubar
290 self.menubar.addAction(self.commit_menu.menuAction())
292 # Branch Menu
293 self.branch_menu = create_menu('B&ranch', self.menubar)
294 self.branch_menu.addAction(self.menu_branch_review)
295 self.branch_menu.addSeparator()
296 self.branch_menu.addAction(self.menu_create_branch)
297 self.branch_menu.addAction(self.menu_checkout_branch)
298 self.branch_menu.addAction(self.menu_rebase_branch)
299 self.branch_menu.addAction(self.menu_delete_branch)
300 self.branch_menu.addSeparator()
301 self.branch_menu.addAction(self.menu_browse_branch)
302 self.branch_menu.addAction(self.menu_browse_other_branch)
303 self.branch_menu.addSeparator()
304 self.branch_menu.addAction(self.menu_visualize_current)
305 self.branch_menu.addAction(self.menu_visualize_all)
306 self.branch_menu.addSeparator()
307 self.branch_menu.addAction(self.menu_diff_branch)
308 # Add to menubar
309 self.menubar.addAction(self.branch_menu.menuAction())
311 # Actions menu
312 self.actions_menu = create_menu('Act&ions', self.menubar)
313 self.actions_menu.addAction(self.menu_merge_local)
314 self.actions_menu.addAction(self.menu_stash)
315 self.actions_menu.addSeparator()
316 self.actions_menu.addAction(self.menu_fetch)
317 self.actions_menu.addAction(self.menu_push)
318 self.actions_menu.addAction(self.menu_pull)
319 self.actions_menu.addSeparator()
320 self.actions_menu.addAction(self.menu_create_tag)
321 self.actions_menu.addSeparator()
322 self.actions_menu.addAction(self.menu_export_patches)
323 self.actions_menu.addAction(self.menu_cherry_pick)
324 self.actions_menu.addSeparator()
325 self.actions_menu.addAction(self.menu_merge_abort)
326 self.actions_menu.addAction(self.menu_grep)
327 # Add to menubar
328 self.menubar.addAction(self.actions_menu.menuAction())
330 # Diff Menu
331 self.diff_menu = create_menu('&Diff', self.menubar)
332 self.diff_menu.addAction(self.menu_branch_diff)
333 self.diff_menu.addAction(self.menu_diff_expression)
334 self.diff_menu.addSeparator()
335 self.diff_menu.addAction(self.menu_branch_compare)
336 self.diff_menu.addAction(self.menu_commit_compare)
337 self.diff_menu.addAction(self.menu_commit_compare_file)
338 self.diff_menu.addSeparator()
339 self.diff_menu.addAction(self.menu_show_diffstat)
340 # Add to menubar
341 self.menubar.addAction(self.diff_menu.menuAction())
343 # Tools Menu
344 self.tools_menu = create_menu('&Tools', self.menubar)
345 self.tools_menu.addAction(self.menu_classic)
346 self.tools_menu.addAction(self.menu_dag)
347 self.tools_menu.addSeparator()
348 if self.classic_dockable:
349 self.tools_menu.addAction(self.classicdockwidget.toggleViewAction())
350 self.tools_menu.addAction(self.diffdockwidget.toggleViewAction())
351 self.tools_menu.addAction(self.actiondockwidget.toggleViewAction())
352 self.tools_menu.addAction(self.commitdockwidget.toggleViewAction())
353 self.tools_menu.addAction(self.statusdockwidget.toggleViewAction())
354 self.tools_menu.addAction(self.logdockwidget.toggleViewAction())
355 self.menubar.addAction(self.tools_menu.menuAction())
357 # Help Menu
358 self.help_menu = create_menu('&Help', self.menubar)
359 self.help_menu.addAction(self.menu_help_docs)
360 self.help_menu.addAction(self.menu_help_shortcuts)
361 self.help_menu.addAction(self.menu_help_about)
362 # Add to menubar
363 self.menubar.addAction(self.help_menu.menuAction())
365 # Set main menu
366 self.setMenuBar(self.menubar)
368 # Arrange dock widgets
369 top = Qt.TopDockWidgetArea
370 bottom = Qt.BottomDockWidgetArea
372 self.addDockWidget(top, self.commitdockwidget)
373 if self.classic_dockable:
374 self.addDockWidget(top, self.classicdockwidget)
375 self.addDockWidget(top, self.statusdockwidget)
376 self.addDockWidget(top, self.actiondockwidget)
377 self.addDockWidget(bottom, self.logdockwidget)
378 if self.classic_dockable:
379 self.tabifyDockWidget(self.classicdockwidget, self.commitdockwidget)
380 self.tabifyDockWidget(self.logdockwidget, self.diffdockwidget)
382 # Listen for model notifications
383 model.add_message_observer(model.message_mode_changed,
384 self._mode_changed)
386 model.add_message_observer(model.message_updated,
387 self._update_view)
389 prefs_model.add_message_observer(prefs_model.message_config_updated,
390 self._config_updated)
393 # Add button callbacks
394 connect_button(self.rescan_button,
395 emit(self, signals.rescan_and_refresh))
396 connect_button(self.alt_button, emit(self, signals.reset_mode))
397 connect_button(self.fetch_button, guicmds.fetch)
398 connect_button(self.push_button, guicmds.push)
399 connect_button(self.pull_button, guicmds.pull)
400 connect_button(self.stash_button, stash.stash)
402 connect_button(self.stage_button, self.stage)
403 connect_button(self.unstage_button, self.unstage)
405 self.connect(self, SIGNAL('update'), self._update_callback)
406 self.connect(self, SIGNAL('apply_state'), self.apply_state)
407 self.connect(self, SIGNAL('install_config_actions'),
408 self._install_config_actions)
410 # Install .git-config-defined actions
411 self._config_task = None
412 self.install_config_actions()
414 # Restore saved settings
415 self._gui_state_task = None
416 self._load_gui_state()
418 log(0, self.model.git_version + '\ncola version ' + version.version())
420 # Qt overrides
421 def closeEvent(self, event):
422 """Save state in the settings manager."""
423 qtutils.save_state(self)
424 standard.MainWindow.closeEvent(self, event)
426 # Accessors
427 mode = property(lambda self: self.model.mode)
429 def _mode_changed(self, mode):
430 """React to mode changes; hide/show the "Exit Diff Mode" button."""
431 if mode in (self.model.mode_branch,
432 self.model.mode_review,
433 self.model.mode_diff,
434 self.model.mode_diff_expr):
435 height = self.stage_button.minimumHeight()
436 self.alt_button.setMinimumHeight(height)
437 self.alt_button.show()
438 else:
439 self.alt_button.setMinimumHeight(1)
440 self.alt_button.hide()
442 def _config_updated(self, source, config, value):
443 if config == 'cola.fontdiff':
444 font = QtGui.QFont()
445 if not font.fromString(value):
446 return
447 qtutils.logger().setFont(font)
448 self.diff_viewer.setFont(font)
449 self.commitmsgeditor.commitmsg.setFont(font)
451 elif config == 'cola.tabwidth':
452 # variable-tab-width setting
453 self.diff_viewer.set_tab_width(value)
455 def install_config_actions(self):
456 """Install .gitconfig-defined actions"""
457 self._config_task = self._start_config_actions_task()
459 def _start_config_actions_task(self):
460 """Do the expensive "get_config_actions()" call in the background"""
461 class ConfigActionsTask(QtCore.QRunnable):
462 def __init__(self, sender):
463 QtCore.QRunnable.__init__(self)
464 self._sender = sender
465 def run(self):
466 names = cfgactions.get_config_actions()
467 self._sender.emit(SIGNAL('install_config_actions'), names)
469 task = ConfigActionsTask(self)
470 QtCore.QThreadPool.globalInstance().start(task)
471 return task
473 def _install_config_actions(self, names):
474 """Install .gitconfig-defined actions"""
475 if not names:
476 return
477 menu = self.actions_menu
478 menu.addSeparator()
479 for name in names:
480 menu.addAction(name, emit(self, signals.run_config_action, name))
482 def _update_view(self):
483 self.emit(SIGNAL('update'))
485 def _update_callback(self):
486 """Update the title with the current branch and directory name."""
487 branch = self.model.currentbranch
488 curdir = core.decode(os.getcwd())
489 msg = 'Repository: %s\nBranch: %s' % (curdir, branch)
490 self.commitdockwidget.setToolTip(msg)
492 title = '%s: %s' % (self.model.project, branch)
493 if self.mode in (self.model.mode_diff, self.model.mode_diff_expr):
494 title += ' *** diff mode***'
495 elif self.mode == self.model.mode_review:
496 title += ' *** review mode***'
497 elif self.mode == self.model.mode_amend:
498 title += ' *** amending ***'
499 self.setWindowTitle(title)
501 self.commitmsgeditor.set_mode(self.mode)
503 if not self.model.read_only() and self.mode != self.model.mode_amend:
504 # Check if there's a message file in .git/
505 merge_msg_path = gitcmds.merge_message_path()
506 if merge_msg_path is None:
507 return
508 merge_msg_hash = utils.checksum(core.decode(merge_msg_path))
509 if merge_msg_hash == self.merge_message_hash:
510 return
511 self.merge_message_hash = merge_msg_hash
512 cola.notifier().broadcast(signals.load_commit_message,
513 core.decode(merge_msg_path))
515 def apply_state(self, state):
516 """Imports data for save/restore"""
517 # 1 is the widget version; change when widgets are added/removed
518 standard.MainWindow.apply_state(self, state)
519 qtutils.apply_window_state(self, state, 1)
521 def export_state(self):
522 """Exports data for save/restore"""
523 state = standard.MainWindow.export_state(self)
524 return qtutils.export_window_state(self, state, self.widget_version)
526 def _load_gui_state(self):
527 """Restores the gui from the preferences file."""
528 self._gui_state_task = self._start_gui_state_loading_thread()
530 def _start_gui_state_loading_thread(self):
531 """Do expensive file reading and json decoding in the background"""
532 class LoadGUIStateTask(QtCore.QRunnable):
533 def __init__(self, sender):
534 QtCore.QRunnable.__init__(self)
535 self._sender = sender
536 def run(self):
537 state = settings.Settings().get_gui_state(self._sender)
538 self._sender.emit(SIGNAL('apply_state'), state)
540 task = LoadGUIStateTask(self)
541 QtCore.QThreadPool.globalInstance().start(task)
542 return task
544 def stage(self):
545 """Stage selected files, or all files if no selection exists."""
546 paths = cola.selection_model().unstaged
547 if not paths:
548 cola.notifier().broadcast(signals.stage_modified)
549 else:
550 cola.notifier().broadcast(signals.stage, paths)
552 def unstage(self):
553 """Unstage selected files, or all files if no selection exists."""
554 paths = cola.selection_model().staged
555 if not paths:
556 cola.notifier().broadcast(signals.unstage_all)
557 else:
558 cola.notifier().broadcast(signals.unstage, paths)
560 def dragEnterEvent(self, event):
561 """Accepts drops"""
562 standard.MainWindow.dragEnterEvent(self, event)
563 event.acceptProposedAction()
565 def dropEvent(self, event):
566 """Apply dropped patches with git-am"""
567 event.accept()
568 urls = event.mimeData().urls()
569 if not urls:
570 return
571 paths = map(lambda x: unicode(x.path()), urls)
572 patches = [p for p in paths if p.endswith('.patch')]
573 dirs = [p for p in paths if os.path.isdir(p)]
574 dirs.sort()
575 for d in dirs:
576 patches.extend(self._gather_patches(d))
577 # Broadcast the patches to apply
578 cola.notifier().broadcast(signals.apply_patches, patches)
580 def _gather_patches(self, path):
581 """Find patches in a subdirectory"""
582 patches = []
583 for root, subdirs, files in os.walk(path):
584 for name in [f for f in files if f.endswith('.patch')]:
585 patches.append(os.path.join(root, name))
586 return patches