git-xbase: use DraggableTreeWidget
[git-cola.git] / share / git-cola / bin / git-xbase
blob4d633846996035e16853edd403b6c7fe1123398f
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 qtutils
28 from cola import utils
29 from cola.compat import set
30 from cola.i18n import N_
31 from cola.models.dag import DAG
32 from cola.models.dag import RepoReader
33 from cola.qtutils import diff_font
34 from cola.widgets import defs
35 from cola.widgets.diff import DiffWidget
36 from cola.widgets.diff import COMMITS_SELECTED
37 from cola.widgets.standard import DraggableTreeWidget
39 from PyQt4 import QtGui
40 from PyQt4.QtCore import Qt
41 from PyQt4.QtCore import SIGNAL
44 PICK = 'pick'
45 REWORD = 'reword'
46 EDIT = 'edit'
47 FIXUP = 'fixup'
48 SQUASH = 'squash'
49 COMMANDS = (PICK, REWORD, EDIT, FIXUP, SQUASH,)
50 COMMAND_TO_IDX = dict([(cmd, idx) for idx, cmd in enumerate(COMMANDS)])
53 def main():
54 args = parse_args()
55 app.setup_environment()
56 new_app = app.new_application()
57 app.new_model(new_app, os.getcwd())
59 desktop = new_app.desktop()
60 window = new_window(args.filename)
61 window.resize(desktop.width(), desktop.height())
62 window.show()
63 window.raise_()
64 new_app.exec_()
65 return window.status
68 def parse_args():
69 parser = ArgumentParser()
70 parser.add_argument('filename', metavar='<filename>',
71 help='git-rebase-todo file to edit')
72 return parser.parse_args()
75 def new_window(filename):
76 editor = Editor(filename)
77 window = MainWindow(editor)
78 return window
81 class MainWindow(QtGui.QMainWindow):
83 def __init__(self, editor, parent=None):
84 super(MainWindow, self).__init__(parent)
85 self.status = 1
86 default_title = '%s - git xbase' % core.getcwd()
87 title = core.getenv('GIT_XBASE_TITLE', default_title)
88 self.setAttribute(Qt.WA_MacMetalStyle)
89 self.setWindowTitle(title)
90 self.setCentralWidget(editor)
91 self.connect(editor, SIGNAL('exit(int)'), self.exit)
92 editor.setFocus()
94 self.show_help_action = qtutils.add_action(self,
95 N_('Show Help'), show_help, Qt.Key_Question)
97 self.menubar = QtGui.QMenuBar(self)
98 self.help_menu = self.menubar.addMenu(N_('Help'))
99 self.help_menu.addAction(self.show_help_action)
100 self.setMenuBar(self.menubar)
102 qtutils.add_close_action(self)
104 def exit(self, status):
105 self.status = status
106 self.close()
109 class Editor(QtGui.QWidget):
111 def __init__(self, filename, parent=None):
112 super(Editor, self).__init__(parent)
114 self.widget_version = 1
115 self.filename = filename
117 self.notifier = notifier = observable.Observable()
118 self.diff = DiffWidget(notifier, self)
119 self.tree = RebaseTreeWidget(notifier, self)
120 self.setFocusProxy(self.tree)
122 self.rebase_button = qtutils.create_button(
123 text=core.getenv('GIT_XBASE_ACTION', N_('Rebase')),
124 tooltip=N_('Accept changes and rebase\n'
125 'Shortcut: Ctrl+Enter'),
126 icon=qtutils.apply_icon())
128 self.external_diff_button = qtutils.create_button(
129 text=N_('External Diff'),
130 tooltip=N_('Launch external diff\n'
131 'Shortcut: Ctrl+D'))
132 self.external_diff_button.setEnabled(False)
134 self.help_button = qtutils.create_button(
135 text=N_('Help'),
136 tooltip=N_('Show help\nShortcut: ?'),
137 icon=qtutils.help_icon())
139 self.cancel_button = qtutils.create_button(
140 text=N_('Cancel'),
141 tooltip=N_('Cancel rebase\nShortcut: Ctrl+Q'),
142 icon=qtutils.close_icon())
143 splitter = QtGui.QSplitter()
144 splitter.setHandleWidth(defs.handle_width)
145 splitter.setOrientation(Qt.Vertical)
146 splitter.insertWidget(0, self.tree)
147 splitter.insertWidget(1, self.diff)
148 splitter.setStretchFactor(0, 1)
149 splitter.setStretchFactor(1, 1)
151 controls_layout = QtGui.QHBoxLayout()
152 controls_layout.setMargin(defs.no_margin)
153 controls_layout.setSpacing(defs.button_spacing)
154 controls_layout.addWidget(self.rebase_button)
155 controls_layout.addWidget(self.external_diff_button)
156 controls_layout.addWidget(self.help_button)
157 controls_layout.addStretch()
158 controls_layout.addWidget(self.cancel_button)
160 layout = QtGui.QVBoxLayout()
161 layout.setMargin(defs.no_margin)
162 layout.setSpacing(defs.spacing)
163 layout.addWidget(splitter)
164 layout.addLayout(controls_layout)
165 self.setLayout(layout)
167 self.action_rebase = qtutils.add_action(self,
168 N_('Rebase'), self.rebase, 'Ctrl+Return')
170 notifier.add_observer(COMMITS_SELECTED, self.commits_selected)
172 qtutils.connect_button(self.rebase_button, self.rebase)
173 qtutils.connect_button(self.external_diff_button, self.external_diff)
174 qtutils.connect_button(self.help_button, show_help)
175 qtutils.connect_button(self.cancel_button, self.cancel)
176 self.connect(self.tree, SIGNAL('external_diff()'), self.external_diff)
178 insns = core.read(filename)
179 self.parse_sequencer_instructions(insns)
181 # notifier callbacks
182 def commits_selected(self, commits):
183 self.external_diff_button.setEnabled(bool(commits))
185 # helpers
186 def emit_exit(self, status):
187 self.emit(SIGNAL('exit(int)'), status)
189 def parse_sequencer_instructions(self, insns):
190 idx = 1
191 rebase_command = re.compile(
192 r'^(# )?(pick|fixup|squash) ([0-9a-f]{7,40}) (.+)$')
193 for line in insns.splitlines():
194 match = rebase_command.match(line)
195 if match is None:
196 continue
197 enabled = match.group(1) is None
198 command = match.group(2)
199 sha1hex = match.group(3)
200 summary = match.group(4)
201 self.tree.add_step(idx, enabled, command, sha1hex, summary)
202 idx += 1
203 self.tree.decorate()
204 self.tree.refit()
205 self.tree.select_first()
207 # actions
208 def cancel(self):
209 self.emit_exit(1)
211 def rebase(self):
212 lines = [item.value() for item in self.tree.items()]
213 sequencer_instructions = '\n'.join(lines) + '\n'
214 try:
215 core.write(self.filename, sequencer_instructions)
216 self.emit_exit(0)
217 except Exception as e:
218 msg, details = utils.format_exception(e)
219 sys.stderr.write(msg + '\n\n' + details)
220 self.emit_exit(128)
222 def external_diff(self):
223 items = self.tree.selected_items()
224 if not items:
225 return
226 item = items[0]
227 difftool.diff_expression(self, item.sha1hex + '^!',
228 hide_expr=True)
231 class RebaseTreeWidget(DraggableTreeWidget):
233 def __init__(self, notifier, parent=None):
234 super(RebaseTreeWidget, self).__init__(parent=parent)
235 self.notifier = notifier
236 # header
237 self.setHeaderLabels([N_('#'),
238 N_('Enabled'),
239 N_('Command'),
240 N_('SHA-1'),
241 N_('Summary')])
242 self.header().setStretchLastSection(True)
243 self.setColumnCount(5)
245 # actions
246 self.copy_sha1_action = qtutils.add_action(self,
247 N_('Copy SHA-1'), self.copy_sha1, QtGui.QKeySequence.Copy)
249 self.external_diff_action = qtutils.add_action(self,
250 N_('External Diff'), self.external_diff,
251 cmds.LaunchDifftool.SHORTCUT)
253 self.toggle_enabled_action = qtutils.add_action(self,
254 N_('Toggle Enabled'), self.toggle_enabled,
255 Qt.Key_Space)
257 self.action_pick = qtutils.add_action(self,
258 N_('Pick'), lambda: self.set_selected_to(PICK),
259 Qt.Key_1, Qt.Key_P)
261 self.action_reword = qtutils.add_action(self,
262 N_('Reword'), lambda: self.set_selected_to(REWORD),
263 Qt.Key_2, Qt.Key_R)
265 self.action_edit = qtutils.add_action(self,
266 N_('Edit'), lambda: self.set_selected_to(EDIT),
267 Qt.Key_3, Qt.Key_E)
269 self.action_fixup = qtutils.add_action(self,
270 N_('Fixup'), lambda: self.set_selected_to(FIXUP),
271 Qt.Key_4, Qt.Key_F)
273 self.action_squash = qtutils.add_action(self,
274 N_('Squash'), lambda: self.set_selected_to(SQUASH),
275 Qt.Key_5, Qt.Key_S)
277 self.action_shift_down = qtutils.add_action(self,
278 N_('Shift Down'), self.shift_down, 'Shift+j')
280 self.action_shift_up = qtutils.add_action(self,
281 N_('Shift Up'), self.shift_up, 'Shift+k')
283 self.connect(self, SIGNAL('itemChanged(QTreeWidgetItem *, int)'),
284 self.item_changed)
286 self.connect(self, SIGNAL('itemSelectionChanged()'),
287 self.selection_changed)
289 self.connect(self, SIGNAL('move'), self.move)
291 def add_step(self, idx, enabled, command, sha1hex, summary):
292 item = RebaseTreeWidgetItem(idx, enabled, command,
293 sha1hex, summary)
294 self.invisibleRootItem().addChild(item)
296 def decorate(self):
297 for item in self.items():
298 self.decorate_item(item)
300 def refit(self):
301 self.resizeColumnToContents(0)
302 self.resizeColumnToContents(1)
303 self.resizeColumnToContents(2)
304 self.resizeColumnToContents(3)
305 self.resizeColumnToContents(4)
307 # actions
308 def item_changed(self, item, column):
309 if column == item.ENABLED_COLUMN:
310 self.validate()
312 def validate(self):
313 invalid_first_choice = set([FIXUP, SQUASH])
314 for item in self.items():
315 if not item.is_enabled():
316 continue
317 if item.command in invalid_first_choice:
318 item.command = PICK
319 self.decorate_item(item)
320 break
322 def decorate_item(self, item):
323 item.combo = combo = QtGui.QComboBox()
324 combo.addItems(COMMANDS)
325 combo.setEditable(False)
326 combo.setCurrentIndex(COMMAND_TO_IDX[item.command])
327 combo.connect(combo, SIGNAL('currentIndexChanged(const QString &)'),
328 lambda s: self.set_command(item, unicode(s)))
329 self.setItemWidget(item, item.COMMAND_COLUMN, combo)
331 def set_selected_to(self, command):
332 for i in self.selected_items():
333 i.command = command
334 i.combo.setCurrentIndex(COMMAND_TO_IDX[command])
335 self.validate()
337 def set_command(self, item, command):
338 item.command = command
339 item.combo.setCurrentIndex(COMMAND_TO_IDX[command])
340 self.validate()
342 def copy_sha1(self):
343 item = self.selected_item()
344 if item is None:
345 return
346 sha1hex = item.sha1hex
347 qtutils.set_clipboard(sha1hex)
349 def selection_changed(self):
350 item = self.selected_item()
351 if item is None:
352 return
353 sha1hex = item.sha1hex
354 dag = DAG(sha1hex, 2)
355 repo = RepoReader(dag)
356 commits = []
357 for c in repo:
358 commits.append(c)
359 if commits:
360 commits = commits[-1:]
361 self.notifier.notify_observers(COMMITS_SELECTED, commits)
363 def external_diff(self):
364 self.emit(SIGNAL('external_diff()'))
366 def toggle_enabled(self):
367 item = self.selected_item()
368 if item is None:
369 return
370 item.toggle_enabled()
372 def select_first(self):
373 items = self.items()
374 if not items:
375 return
376 idx = self.model().index(0, 0)
377 if idx.isValid():
378 self.setCurrentIndex(idx)
380 def shift_down(self):
381 item = self.selected_item()
382 if item is None:
383 return
384 items = self.items()
385 idx = items.index(item)
386 if idx < len(items) - 1:
387 self.emit(SIGNAL('move'), [idx], idx + 1)
389 def shift_up(self):
390 item = self.selected_item()
391 if item is None:
392 return
393 items = self.items()
394 idx = items.index(item)
395 if idx > 0:
396 self.emit(SIGNAL('move'), [idx], idx - 1)
398 def move(self, src_idxs, dst_idx):
399 new_items = []
400 items = self.items()
401 for idx in reversed(sorted(src_idxs)):
402 data = items[idx].copy()
403 self.invisibleRootItem().takeChild(idx)
404 item = RebaseTreeWidgetItem(data['idx'], data['enabled'],
405 data['command'], data['sha1hex'],
406 data['summary'])
407 new_items.insert(0, item)
409 if new_items:
410 self.invisibleRootItem().insertChildren(dst_idx, new_items)
411 self.setCurrentItem(new_items[0])
413 self.decorate()
414 self.validate()
416 # Qt events
418 def dropEvent(self, event):
419 super(RebaseTreeWidget, self).dropEvent(event)
420 self.decorate()
421 self.validate()
423 def contextMenuEvent(self, event):
424 menu = QtGui.QMenu(self)
425 menu.addAction(self.action_pick)
426 menu.addAction(self.action_reword)
427 menu.addAction(self.action_edit)
428 menu.addAction(self.action_fixup)
429 menu.addAction(self.action_squash)
430 menu.addSeparator()
431 menu.addAction(self.toggle_enabled_action)
432 menu.addSeparator()
433 menu.addAction(self.copy_sha1_action)
434 menu.addAction(self.external_diff_action)
435 menu.exec_(self.mapToGlobal(event.pos()))
438 class RebaseTreeWidgetItem(QtGui.QTreeWidgetItem):
440 ENABLED_COLUMN = 1
441 COMMAND_COLUMN = 2
443 def __init__(self, idx, enabled, command, sha1hex, summary, parent=None):
444 QtGui.QTreeWidgetItem.__init__(self, parent)
445 self.combo = None
446 self.command = command
447 self.idx = idx
448 self.sha1hex = sha1hex
449 self.summary = summary
451 self.setText(0, '%02d' % idx)
452 self.set_enabled(enabled)
453 # combo box on 2
454 self.setText(3, sha1hex)
455 self.setText(4, summary)
457 flags = self.flags() | Qt.ItemIsUserCheckable
458 flags = flags | Qt.ItemIsDragEnabled
459 flags = flags & ~Qt.ItemIsDropEnabled
460 self.setFlags(flags)
462 def copy(self):
463 return {
464 'command': self.command,
465 'enabled': self.is_enabled(),
466 'idx': self.idx,
467 'sha1hex': self.sha1hex,
468 'summary': self.summary,
471 def value(self):
472 return '%s %s %s %s' % (
473 not self.is_enabled() and '# ' or '',
474 self.command, self.sha1hex, self.summary)
476 def is_enabled(self):
477 return self.checkState(self.ENABLED_COLUMN) == Qt.Checked
479 def set_enabled(self, enabled):
480 self.setCheckState(self.ENABLED_COLUMN,
481 enabled and Qt.Checked or Qt.Unchecked)
483 def toggle_enabled(self):
484 self.set_enabled(not self.is_enabled())
487 def show_help():
488 help_text = N_("""
489 Commands
490 --------
491 pick = use commit
492 reword = use commit, but edit the commit message
493 edit = use commit, but stop for amending
494 squash = use commit, but meld into previous commit
495 fixup = like "squash", but discard this commit's log message
497 These lines can be re-ordered; they are executed from top to bottom.
499 If you disable a line here THAT COMMIT WILL BE LOST.
501 However, if you disable everything, the rebase will be aborted.
503 Keyboard Shortcuts
504 ------------------
505 ? = show help
506 j = move down
507 k = move up
508 J = shift row down
509 K = shift row up
511 1, p = pick
512 2, r = reword
513 3, e = edit
514 4, f = fixup
515 5, s = squash
516 spacebar = toggle enabled
518 ctrl+enter = accept changes and rebase
519 ctrl+q = cancel and abort the rebase
520 ctrl+d = launch external diff
521 """)
523 parent = qtutils.active_window()
524 text = QtGui.QLabel(parent)
525 text.setFont(diff_font())
526 text.setText(help_text)
527 text.setTextInteractionFlags(Qt.NoTextInteraction)
529 layout = QtGui.QHBoxLayout()
530 layout.setMargin(defs.margin)
531 layout.setSpacing(defs.spacing)
532 layout.addWidget(text)
534 widget = QtGui.QDialog(parent)
535 widget.setWindowModality(Qt.WindowModal)
536 widget.setWindowTitle(N_('Help - git-xbase'))
537 widget.setLayout(layout)
539 qtutils.add_action(widget, N_('Close'), widget.accept,
540 Qt.Key_Question,
541 Qt.Key_Enter,
542 Qt.Key_Return)
543 widget.show()
544 return widget
547 if __name__ == '__main__':
548 sys.exit(main())