xbase: make "Ctrl+Enter" apply changes and rebase
[git-cola.git] / share / git-cola / bin / git-xbase
blob1db0de926167e94edb9cc851dd580c9cf3f6d5f7
1 #!/usr/bin/env python
3 import os
4 import sys
5 import re
6 from argparse import ArgumentParser
8 from os.path import abspath
9 from os.path import dirname
12 def setup_environment():
13 prefix = dirname(dirname(dirname(dirname(abspath(__file__)))))
14 source_tree = os.path.join(prefix, 'cola', '__init__.py')
15 if os.path.exists(source_tree):
16 modules = prefix
17 else:
18 modules = os.path.join(prefix, 'share', 'git-cola', 'lib')
19 sys.path.insert(1, modules)
20 setup_environment()
22 from cola import app
23 from cola import cmds
24 from cola import core
25 from cola import difftool
26 from cola import observable
27 from cola import qt
28 from cola import qtutils
29 from cola import utils
30 from cola.compat import json
31 from cola.i18n import N_
32 # TODO move these next 3 somewhere more neutral
33 from cola.dag.model import DAG
34 from cola.dag.model import RepoReader
35 from cola.prefs.view import diff_font
36 from cola.widgets import defs
37 from cola.widgets.diff import DiffWidget
38 from cola.widgets.diff import COMMITS_SELECTED
39 from cola.widgets.standard import TreeWidget
41 from PyQt4 import QtCore
42 from PyQt4 import QtGui
43 from PyQt4.QtCore import Qt
44 from PyQt4.QtCore import SIGNAL
47 XBASE_MIMETYPE = 'application/git-xbase-items'
48 PICK = 'pick'
49 REWORD = 'reword'
50 EDIT = 'edit'
51 FIXUP = 'fixup'
52 SQUASH = 'squash'
53 COMMANDS = (PICK, REWORD, EDIT, FIXUP, SQUASH,)
54 COMMAND_TO_IDX = dict([(cmd, idx) for idx, cmd in enumerate(COMMANDS)])
57 def main():
58 args = parse_args()
59 app.setup_environment()
60 new_app = app.new_application()
61 app.new_model(new_app, os.getcwd())
63 desktop = new_app.desktop()
64 window = new_window(args.filename)
65 window.resize(desktop.width(), desktop.height())
66 window.show()
67 window.raise_()
68 new_app.exec_()
69 return window.status
72 def parse_args():
73 parser = ArgumentParser()
74 parser.add_argument('filename', metavar='<filename>',
75 help='git-rebase-todo file to edit')
76 return parser.parse_args()
79 def new_window(filename):
80 editor = Editor(filename)
81 window = MainWindow(editor)
82 return window
85 class MainWindow(QtGui.QMainWindow):
87 def __init__(self, editor, parent=None):
88 super(MainWindow, self).__init__(parent)
89 self.status = 1
90 default_title = '%s - git xbase' % core.getcwd()
91 title = core.getenv('GIT_XBASE_TITLE', default_title)
92 self.setAttribute(Qt.WA_MacMetalStyle)
93 self.setWindowTitle(title)
94 self.setCentralWidget(editor)
95 self.connect(editor, SIGNAL('exit(int)'), self.exit)
96 editor.setFocus()
98 self.show_help_action = qtutils.add_action(self,
99 N_('Show Help'), show_help, Qt.Key_Question)
101 self.menubar = QtGui.QMenuBar(self)
102 self.help_menu = self.menubar.addMenu(N_('Help'))
103 self.help_menu.addAction(self.show_help_action)
104 self.setMenuBar(self.menubar)
106 qtutils.add_close_action(self)
108 def exit(self, status):
109 self.status = status
110 self.close()
113 class Editor(QtGui.QWidget):
115 def __init__(self, filename, parent=None):
116 super(Editor, self).__init__(parent)
118 self.widget_version = 1
119 self.filename = filename
121 self.notifier = notifier = observable.Observable()
122 self.diff = DiffWidget(notifier, self)
123 self.tree = RebaseTreeWidget(notifier, self)
124 self.setFocusProxy(self.tree)
126 self.rebase_button = qt.create_button(
127 text=core.getenv('GIT_XBASE_ACTION', N_('Rebase')),
128 tooltip=N_('Accept changes and rebase\n'
129 'Shortcut: Ctrl+Enter'),
130 icon=qtutils.apply_icon())
132 self.external_diff_button = qt.create_button(
133 text=N_('External Diff'),
134 tooltip=N_('Launch external diff\n'
135 'Shortcut: Ctrl+D'))
136 self.external_diff_button.setEnabled(False)
138 self.help_button = qt.create_button(text=N_('Help'),
139 tooltip=N_('Show help\n'
140 'Shortcut: ?'),
141 icon=qtutils.help_icon())
143 self.cancel_button = qt.create_button(text=N_('Cancel'),
144 tooltip=N_('Cancel rebase\n'
145 'Shortcut: Ctrl+Q'),
146 icon=qtutils.close_icon())
147 splitter = QtGui.QSplitter()
148 splitter.setHandleWidth(defs.handle_width)
149 splitter.setOrientation(Qt.Vertical)
150 splitter.insertWidget(0, self.tree)
151 splitter.insertWidget(1, self.diff)
152 splitter.setStretchFactor(0, 1)
153 splitter.setStretchFactor(1, 1)
155 controls_layout = QtGui.QHBoxLayout()
156 controls_layout.setMargin(defs.no_margin)
157 controls_layout.setSpacing(defs.button_spacing)
158 controls_layout.addWidget(self.rebase_button)
159 controls_layout.addWidget(self.external_diff_button)
160 controls_layout.addWidget(self.help_button)
161 controls_layout.addStretch()
162 controls_layout.addWidget(self.cancel_button)
164 layout = QtGui.QVBoxLayout()
165 layout.setMargin(defs.no_margin)
166 layout.setSpacing(defs.spacing)
167 layout.addWidget(splitter)
168 layout.addLayout(controls_layout)
169 self.setLayout(layout)
171 self.action_rebase = qtutils.add_action(self,
172 N_('Rebase'), self.rebase, 'Ctrl+Return')
174 notifier.add_observer(COMMITS_SELECTED, self.commits_selected)
176 qtutils.connect_button(self.rebase_button, self.rebase)
177 qtutils.connect_button(self.external_diff_button, self.external_diff)
178 qtutils.connect_button(self.help_button, show_help)
179 qtutils.connect_button(self.cancel_button, self.cancel)
180 self.connect(self.tree, SIGNAL('external_diff()'), self.external_diff)
182 insns = core.read(filename)
183 self.parse_sequencer_instructions(insns)
185 # notifier callbacks
186 def commits_selected(self, commits):
187 self.external_diff_button.setEnabled(bool(commits))
189 # helpers
190 def emit_exit(self, status):
191 self.emit(SIGNAL('exit(int)'), status)
193 def parse_sequencer_instructions(self, insns):
194 idx = 1
195 rebase_command = re.compile(
196 r'^(# )?(pick|fixup|squash) ([0-9a-f]{7,40}) (.+)$')
197 for line in insns.splitlines():
198 match = rebase_command.match(line)
199 if match is None:
200 continue
201 enabled = match.group(1) is None
202 command = match.group(2)
203 sha1hex = match.group(3)
204 summary = match.group(4)
205 self.tree.add_step(idx, enabled, command, sha1hex, summary)
206 idx += 1
207 self.tree.decorate()
208 self.tree.refit()
209 self.tree.select_first()
211 # actions
212 def cancel(self):
213 self.emit_exit(1)
215 def rebase(self):
216 lines = [item.value() for item in self.tree.items()]
217 sequencer_instructions = '\n'.join(lines) + '\n'
218 try:
219 core.write(self.filename, sequencer_instructions)
220 self.emit_exit(0)
221 except Exception as e:
222 msg, details = utils.format_exception(e)
223 sys.stderr.write(msg + '\n\n' + details)
224 self.emit_exit(128)
226 def external_diff(self):
227 items = self.tree.selected_items()
228 if not items:
229 return
230 item = items[0]
231 difftool.diff_expression(self, item.sha1hex + '^!',
232 hide_expr=True)
235 class RebaseTreeWidget(TreeWidget):
237 def __init__(self, notifier, parent=None):
238 super(RebaseTreeWidget, self).__init__()
239 self.notifier = notifier
240 # header
241 self.setHeaderLabels([N_('#'),
242 N_('Enabled'),
243 N_('Command'),
244 N_('SHA-1'),
245 N_('Summary')])
246 self.header().setStretchLastSection(True)
247 self.setColumnCount(5)
249 # drag+drop
250 self.clicked_item = None
251 self.clicked_idx = -1
252 self.dragged_items = []
253 self.inner_drag = False
254 self.setSelectionMode(self.SingleSelection)
255 self.setDragEnabled(True)
256 self.setAcceptDrops(True)
257 self.setDropIndicatorShown(True)
258 self.setDragDropMode(QtGui.QAbstractItemView.InternalMove)
259 self.setSortingEnabled(False)
261 # actions
262 self.copy_sha1_action = qtutils.add_action(self,
263 N_('Copy SHA-1'), self.copy_sha1, QtGui.QKeySequence.Copy)
265 self.external_diff_action = qtutils.add_action(self,
266 N_('External Diff'), self.external_diff,
267 cmds.LaunchDifftool.SHORTCUT)
269 self.toggle_enabled_action = qtutils.add_action(self,
270 N_('Toggle Enabled'), self.toggle_enabled,
271 Qt.Key_Space)
273 self.action_pick = qtutils.add_action(self,
274 N_('Pick'), lambda: self.set_selected_to(PICK),
275 Qt.Key_1, Qt.Key_P)
277 self.action_reword = qtutils.add_action(self,
278 N_('Reword'), lambda: self.set_selected_to(REWORD),
279 Qt.Key_2, Qt.Key_R)
281 self.action_edit = qtutils.add_action(self,
282 N_('Edit'), lambda: self.set_selected_to(EDIT),
283 Qt.Key_3, Qt.Key_E)
285 self.action_fixup = qtutils.add_action(self,
286 N_('Fixup'), lambda: self.set_selected_to(FIXUP),
287 Qt.Key_4, Qt.Key_F)
289 self.action_squash = qtutils.add_action(self,
290 N_('Squash'), lambda: self.set_selected_to(SQUASH),
291 Qt.Key_5, Qt.Key_S)
293 self.action_shift_down = qtutils.add_action(self,
294 N_('Shift Down'), self.shift_down, 'Shift+j')
296 self.action_shift_up = qtutils.add_action(self,
297 N_('Shift Up'), self.shift_up, 'Shift+k')
299 self.connect(self, SIGNAL('itemChanged(QTreeWidgetItem *, int)'),
300 self.item_changed)
302 self.connect(self, SIGNAL('itemSelectionChanged()'),
303 self.selection_changed)
305 self.connect(self, SIGNAL('move'), self.move)
307 def add_step(self, idx, enabled, command, sha1hex, summary):
308 item = RebaseTreeWidgetItem(idx, enabled, command,
309 sha1hex, summary)
310 self.invisibleRootItem().addChild(item)
312 def decorate(self):
313 for item in self.items():
314 self.decorate_item(item)
316 def refit(self):
317 self.resizeColumnToContents(0)
318 self.resizeColumnToContents(1)
319 self.resizeColumnToContents(2)
320 self.resizeColumnToContents(3)
321 self.resizeColumnToContents(4)
323 # actions
324 def item_changed(self, item, column):
325 if column == item.ENABLED_COLUMN:
326 self.validate()
328 def validate(self):
329 for item in self.items():
330 if not item.is_enabled():
331 continue
332 if item.command != PICK:
333 item.command = PICK
334 self.decorate_item(item)
335 break
337 def decorate_item(self, item):
338 item.combo = combo = QtGui.QComboBox()
339 combo.addItems(COMMANDS)
340 combo.setEditable(False)
341 combo.setCurrentIndex(COMMAND_TO_IDX[item.command])
342 combo.connect(combo, SIGNAL('currentIndexChanged(const QString &)'),
343 lambda s: self.set_command(item, unicode(s)))
344 self.setItemWidget(item, item.COMMAND_COLUMN, combo)
346 def set_selected_to(self, command):
347 for i in self.selected_items():
348 i.command = command
349 i.combo.setCurrentIndex(COMMAND_TO_IDX[command])
350 self.validate()
352 def set_command(self, item, command):
353 item.command = command
354 item.combo.setCurrentIndex(COMMAND_TO_IDX[command])
355 self.validate()
357 def copy_sha1(self):
358 item = self.selected_item()
359 if item is None:
360 return
361 sha1hex = item.sha1hex
362 qtutils.set_clipboard(sha1hex)
364 def selection_changed(self):
365 item = self.selected_item()
366 if item is None:
367 return
368 sha1hex = item.sha1hex
369 dag = DAG(sha1hex, 2)
370 repo = RepoReader(dag)
371 commits = []
372 for c in repo:
373 commits.append(c)
374 if commits:
375 commits = commits[-1:]
376 self.notifier.notify_observers(COMMITS_SELECTED, commits)
378 def external_diff(self):
379 self.emit(SIGNAL('external_diff()'))
381 def toggle_enabled(self):
382 item = self.selected_item()
383 if item is None:
384 return
385 item.toggle_enabled()
387 def select_first(self):
388 items = self.items()
389 if not items:
390 return
391 idx = self.model().index(0, 0)
392 if idx.isValid():
393 self.setCurrentIndex(idx)
395 def shift_down(self):
396 item = self.selected_item()
397 if item is None:
398 return
399 items = self.items()
400 idx = items.index(item)
401 if idx < len(items) - 1:
402 self.emit(SIGNAL('move'), [idx], idx + 1)
404 def shift_up(self):
405 item = self.selected_item()
406 if item is None:
407 return
408 items = self.items()
409 idx = items.index(item)
410 if idx > 0:
411 self.emit(SIGNAL('move'), [idx], idx - 1)
413 # Qt events
414 def mimeTypes(self):
415 return [XBASE_MIMETYPE]
417 def mousePressEvent(self, event):
418 self.clicked_item = self.itemAt(event.pos())
419 if self.clicked_item:
420 self.clicked_idx = self.items().index(self.clicked_item)
421 else:
422 self.clicked_idx = -1
423 self.clearSelection()
424 super(RebaseTreeWidget, self).mousePressEvent(event)
425 return QtGui.QTreeWidget.mousePressEvent(self, event)
427 def supportedDropActions(self):
428 return Qt.CopyAction
430 def dropMimeData(self, parent, index, data, action):
431 return False
433 def mimeData(self, items):
434 mime = QtCore.QMimeData()
435 data = [i.copy() for i in self.selected_items()]
436 mime.setData(XBASE_MIMETYPE, json.dumps(data))
437 self.dragged_items = self.selected_items()
438 return mime
440 def dragEnterEvent(self, event):
441 """Detect drag enter events to flag internal moves."""
442 self.inner_drag = event.source() == self
443 event.accept()
444 return super(RebaseTreeWidget, self).dragEnterEvent(event)
446 def dragLeaveEvent(self, event):
447 """Detect when drags leave so that we can flag it."""
448 self.inner_drag = False
449 return super(RebaseTreeWidget, self).dragLeaveEvent(event)
451 def dropEvent(self, event):
452 # Qt's default action is losing items so we handle the internal
453 # move ourselves. Ignore the event and reorder the tree.
454 event.accept()
456 if self.clicked_item is None:
457 return
459 drop_pos = event.pos()
460 dst_item = self.itemAt(drop_pos)
461 if dst_item:
462 dst_idx = self.items().index(dst_item)
463 else:
464 dst_idx = len(self.items())
466 src_idxs = [self.items().index(i) for i in self.dragged_items]
467 if src_idxs:
468 self.emit(SIGNAL('move'), src_idxs, dst_idx)
470 self.viewport().update()
471 self.dragged_items = []
473 def move(self, src_idxs, dst_idx):
474 new_items = []
475 items = self.items()
476 for idx in reversed(sorted(src_idxs)):
477 data = items[idx].copy()
478 self.invisibleRootItem().takeChild(idx)
479 item = RebaseTreeWidgetItem(data['idx'], data['enabled'],
480 data['command'], data['sha1hex'],
481 data['summary'])
482 new_items.insert(0, item)
484 if new_items:
485 self.invisibleRootItem().insertChildren(dst_idx, new_items)
486 self.setCurrentItem(new_items[0])
488 self.decorate()
489 self.validate()
491 def contextMenuEvent(self, event):
492 menu = QtGui.QMenu(self)
493 menu.addAction(self.action_pick)
494 menu.addAction(self.action_reword)
495 menu.addAction(self.action_edit)
496 menu.addAction(self.action_fixup)
497 menu.addAction(self.action_squash)
498 menu.addSeparator()
499 menu.addAction(self.toggle_enabled_action)
500 menu.addSeparator()
501 menu.addAction(self.copy_sha1_action)
502 menu.addAction(self.external_diff_action)
503 menu.exec_(self.mapToGlobal(event.pos()))
506 class RebaseTreeWidgetItem(QtGui.QTreeWidgetItem):
508 ENABLED_COLUMN = 1
509 COMMAND_COLUMN = 2
511 def __init__(self, idx, enabled, command, sha1hex, summary, parent=None):
512 QtGui.QTreeWidgetItem.__init__(self, parent)
513 self.combo = None
514 self.command = command
515 self.idx = idx
516 self.sha1hex = sha1hex
517 self.summary = summary
519 self.setText(0, '%02d' % idx)
520 self.set_enabled(enabled)
521 # combo box on 2
522 self.setText(3, sha1hex)
523 self.setText(4, summary)
525 flags = self.flags() | Qt.ItemIsUserCheckable
526 flags = flags | Qt.ItemIsDragEnabled
527 flags = flags & ~Qt.ItemIsDropEnabled
528 self.setFlags(flags)
530 def copy(self):
531 return {
532 'command': self.command,
533 'enabled': self.is_enabled(),
534 'idx': self.idx,
535 'sha1hex': self.sha1hex,
536 'summary': self.summary,
539 def value(self):
540 return '%s %s %s %s' % (
541 not self.is_enabled() and '# ' or '',
542 self.command, self.sha1hex, self.summary)
544 def is_enabled(self):
545 return self.checkState(self.ENABLED_COLUMN) == Qt.Checked
547 def set_enabled(self, enabled):
548 self.setCheckState(self.ENABLED_COLUMN,
549 enabled and Qt.Checked or Qt.Unchecked)
551 def toggle_enabled(self):
552 self.set_enabled(not self.is_enabled())
555 def show_help():
556 help_text = N_("""
557 Commands
558 --------
559 pick = use commit
560 reword = use commit, but edit the commit message
561 edit = use commit, but stop for amending
562 squash = use commit, but meld into previous commit
563 fixup = like "squash", but discard this commit's log message
565 These lines can be re-ordered; they are executed from top to bottom.
567 If you disable a line here THAT COMMIT WILL BE LOST.
569 However, if you disable everything, the rebase will be aborted.
571 Keyboard Shortcuts
572 ------------------
573 ? = show help
574 j = move down
575 k = move up
576 J = shift row down
577 K = shift row up
579 1, p = pick
580 2, r = reword
581 3, e = edit
582 4, f = fixup
583 5, s = squash
584 spacebar = toggle enabled
586 ctrl+enter = accept changes and rebase
587 ctrl+q = cancel and abort the rebase
588 ctrl+d = launch external diff
589 """)
591 parent = qtutils.active_window()
592 text = QtGui.QLabel(parent)
593 text.setFont(diff_font())
594 text.setText(help_text)
595 text.setTextInteractionFlags(Qt.NoTextInteraction)
597 layout = QtGui.QHBoxLayout()
598 layout.setMargin(defs.margin)
599 layout.setSpacing(defs.spacing)
600 layout.addWidget(text)
602 widget = QtGui.QDialog(parent)
603 widget.setWindowModality(Qt.WindowModal)
604 widget.setWindowTitle(N_('Help - git-xbase'))
605 widget.setLayout(layout)
607 qtutils.add_action(widget, N_('Close'), widget.accept,
608 Qt.Key_Question,
609 Qt.Key_Enter,
610 Qt.Key_Return)
611 widget.show()
612 return widget
615 if __name__ == '__main__':
616 sys.exit(main())