qtpy: make git-cola pyside-compatible
[git-cola.git] / share / git-cola / bin / git-xbase
blob988b64b81dbf364d890757ce3c692c09eac50548
1 #!/usr/bin/env python
2 from __future__ import absolute_import, division, unicode_literals
3 import os
4 import sys
5 import re
6 from argparse import ArgumentParser
9 def setup_environment():
10 abspath = os.path.abspath
11 dirname = os.path.dirname
12 prefix = dirname(dirname(dirname(dirname(abspath(__file__)))))
13 source_tree = os.path.join(prefix, 'cola', '__init__.py')
14 if os.path.exists(source_tree):
15 modules = prefix
16 else:
17 modules = os.path.join(prefix, 'share', 'git-cola', 'lib')
18 sys.path.insert(1, modules)
20 setup_environment()
22 from cola import app
24 from qtpy import QtGui
25 from qtpy import QtWidgets
26 from qtpy.QtCore import Qt
27 from qtpy.QtCore import Signal
29 from cola import core
30 from cola import difftool
31 from cola import hotkeys
32 from cola import icons
33 from cola import observable
34 from cola import qtutils
35 from cola import utils
36 from cola.i18n import N_
37 from cola.models import dag
38 from cola.models import prefs
39 from cola.widgets import defs
40 from cola.widgets import diff
41 from cola.widgets import standard
42 from cola.widgets import text
45 PICK = 'pick'
46 REWORD = 'reword'
47 EDIT = 'edit'
48 FIXUP = 'fixup'
49 SQUASH = 'squash'
50 EXEC = 'exec'
51 COMMANDS = (PICK, REWORD, EDIT, FIXUP, SQUASH,)
52 COMMAND_IDX = dict([(cmd, idx) for idx, cmd in enumerate(COMMANDS)])
53 ABBREV = {
54 'p': PICK,
55 'r': REWORD,
56 'e': EDIT,
57 'f': FIXUP,
58 's': SQUASH,
59 'x': EXEC,
63 def main():
64 args = parse_args()
65 context = app.application_init(args)
67 desktop = context.app.desktop()
68 window = new_window(args.filename)
69 window.resize(desktop.width(), desktop.height())
70 window.show()
71 window.raise_()
72 context.app.exec_()
73 return window.status
76 def parse_args():
77 parser = ArgumentParser()
78 parser.add_argument('filename', metavar='<filename>',
79 help='git-rebase-todo file to edit')
80 app.add_common_arguments(parser)
81 return parser.parse_args()
84 def new_window(filename):
85 window = MainWindow()
86 editor = Editor(filename, parent=window)
87 window.set_editor(editor)
88 return window
91 def unabbrev(cmd):
92 """Expand shorthand commands into their full name"""
93 return ABBREV.get(cmd, cmd)
96 class MainWindow(QtWidgets.QMainWindow):
98 def __init__(self, parent=None):
99 super(MainWindow, self).__init__(parent)
100 self.status = 1
101 self.editor = None
102 default_title = '%s - git xbase' % core.getcwd()
103 title = core.getenv('GIT_XBASE_TITLE', default_title)
104 self.setAttribute(Qt.WA_MacMetalStyle)
105 self.setWindowTitle(title)
107 self.show_help_action = qtutils.add_action(
108 self, N_('Show Help'), show_help, hotkeys.QUESTION)
110 self.menubar = QtWidgets.QMenuBar(self)
111 self.help_menu = self.menubar.addMenu(N_('Help'))
112 self.help_menu.addAction(self.show_help_action)
113 self.setMenuBar(self.menubar)
115 qtutils.add_close_action(self)
117 def set_editor(self, editor):
118 self.editor = editor
119 self.setCentralWidget(editor)
120 editor.exit.connect(self.exit)
121 editor.setFocus()
123 def exit(self, status):
124 self.status = status
125 self.close()
128 class Editor(QtWidgets.QWidget):
129 exit = Signal(int)
131 def __init__(self, filename, parent=None):
132 super(Editor, self).__init__(parent)
134 self.widget_version = 1
135 self.status = 1
136 self.filename = filename
137 self.comment_char = comment_char = prefs.comment_char()
139 self.notifier = notifier = observable.Observable()
140 self.diff = diff.DiffWidget(notifier, self)
141 self.tree = RebaseTreeWidget(notifier, comment_char, self)
142 self.setFocusProxy(self.tree)
144 self.rebase_button = qtutils.create_button(
145 text=core.getenv('GIT_XBASE_ACTION', N_('Rebase')),
146 tooltip=N_('Accept changes and rebase\n'
147 'Shortcut: Ctrl+Enter'),
148 icon=icons.ok())
150 self.extdiff_button = qtutils.create_button(
151 text=N_('External Diff'),
152 tooltip=N_('Launch external diff\n'
153 'Shortcut: Ctrl+D'))
154 self.extdiff_button.setEnabled(False)
156 self.help_button = qtutils.create_button(
157 text=N_('Help'), tooltip=N_('Show help\nShortcut: ?'),
158 icon=icons.question())
160 self.cancel_button = qtutils.create_button(
161 text=N_('Cancel'), tooltip=N_('Cancel rebase\nShortcut: Ctrl+Q'),
162 icon=icons.close())
164 splitter = qtutils.splitter(Qt.Vertical, self.tree, self.diff)
166 controls_layout = qtutils.hbox(defs.no_margin, defs.button_spacing,
167 self.rebase_button, self.extdiff_button,
168 self.help_button, qtutils.STRETCH,
169 self.cancel_button)
170 layout = qtutils.vbox(defs.no_margin, defs.spacing,
171 splitter, controls_layout)
172 self.setLayout(layout)
174 self.action_rebase = qtutils.add_action(
175 self, N_('Rebase'), self.rebase,
176 hotkeys.CTRL_RETURN, hotkeys.CTRL_ENTER)
178 notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected)
179 self.tree.external_diff.connect(self.external_diff)
181 qtutils.connect_button(self.rebase_button, self.rebase)
182 qtutils.connect_button(self.extdiff_button, self.external_diff)
183 qtutils.connect_button(self.help_button, show_help)
184 qtutils.connect_button(self.cancel_button, self.cancel)
186 insns = core.read(filename)
187 self.parse_sequencer_instructions(insns)
189 # notifier callbacks
190 def commits_selected(self, commits):
191 self.extdiff_button.setEnabled(bool(commits))
193 # helpers
194 def parse_sequencer_instructions(self, insns):
195 idx = 1
196 re_comment_char = re.escape(self.comment_char)
197 exec_rgx = re.compile(r'^\s*(%s)?\s*(x|exec)\s+(.+)$'
198 % re_comment_char)
199 pick_rgx = re.compile((r'^\s*(%s)?\s*'
200 r'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
201 r'\s+([0-9a-f]{7,40})'
202 r'\s+(.+)$') % re_comment_char)
203 for line in insns.splitlines():
204 match = pick_rgx.match(line)
205 if match:
206 enabled = match.group(1) is None
207 command = unabbrev(match.group(2))
208 sha1hex = match.group(3)
209 summary = match.group(4)
210 self.tree.add_item(idx, enabled, command,
211 sha1hex=sha1hex, summary=summary)
212 idx += 1
213 continue
214 match = exec_rgx.match(line)
215 if match:
216 enabled = match.group(1) is None
217 command = unabbrev(match.group(2))
218 cmdexec = match.group(3)
219 self.tree.add_item(idx, enabled, command, cmdexec=cmdexec)
220 idx += 1
221 continue
223 self.tree.decorate(self.tree.items())
224 self.tree.refit()
225 self.tree.select_first()
227 # actions
228 def cancel(self):
229 self.status = 1
230 self.exit.emit(1)
232 def rebase(self):
233 lines = [item.value() for item in self.tree.items()]
234 sequencer_instructions = '\n'.join(lines) + '\n'
235 try:
236 core.write(self.filename, sequencer_instructions)
237 self.status = 0
238 self.exit.emit(0)
239 except Exception as e:
240 msg, details = utils.format_exception(e)
241 sys.stderr.write(msg + '\n\n' + details)
242 self.status = 128
243 self.exit.emit(128)
245 def external_diff(self):
246 items = self.tree.selected_items()
247 if not items:
248 return
249 item = items[0]
250 difftool.diff_expression(self, item.sha1hex + '^!',
251 hide_expr=True)
254 class RebaseTreeWidget(standard.DraggableTreeWidget):
255 external_diff = Signal()
256 move_rows = Signal(object, object)
258 def __init__(self, notifier, comment_char, parent=None):
259 super(RebaseTreeWidget, self).__init__(parent=parent)
260 self.notifier = notifier
261 self.comment_char = comment_char
262 # header
263 self.setHeaderLabels([N_('#'),
264 N_('Enabled'),
265 N_('Command'),
266 N_('SHA-1'),
267 N_('Summary')])
268 self.header().setStretchLastSection(True)
269 self.setColumnCount(5)
271 # actions
272 self.copy_sha1_action = qtutils.add_action(
273 self, N_('Copy SHA-1'), self.copy_sha1, QtGui.QKeySequence.Copy)
275 self.external_diff_action = qtutils.add_action(
276 self, N_('External Diff'), self.external_diff.emit,
277 hotkeys.DIFF)
279 self.toggle_enabled_action = qtutils.add_action(
280 self, N_('Toggle Enabled'), self.toggle_enabled,
281 hotkeys.PRIMARY_ACTION)
283 self.action_pick = qtutils.add_action(
284 self, N_('Pick'), lambda: self.set_selected_to(PICK),
285 *hotkeys.REBASE_PICK)
287 self.action_reword = qtutils.add_action(
288 self, N_('Reword'), lambda: self.set_selected_to(REWORD),
289 *hotkeys.REBASE_REWORD)
291 self.action_edit = qtutils.add_action(
292 self, N_('Edit'), lambda: self.set_selected_to(EDIT),
293 *hotkeys.REBASE_EDIT)
295 self.action_fixup = qtutils.add_action(
296 self, N_('Fixup'), lambda: self.set_selected_to(FIXUP),
297 *hotkeys.REBASE_FIXUP)
299 self.action_squash = qtutils.add_action(
300 self, N_('Squash'), lambda: self.set_selected_to(SQUASH),
301 *hotkeys.REBASE_SQUASH)
303 self.action_shift_down = qtutils.add_action(
304 self, N_('Shift Down'), self.shift_down,
305 hotkeys.MOVE_DOWN_TERTIARY)
307 self.action_shift_up = qtutils.add_action(
308 self, N_('Shift Up'), self.shift_up, hotkeys.MOVE_UP_TERTIARY)
310 self.itemChanged.connect(self.item_changed)
311 self.itemSelectionChanged.connect(self.selection_changed)
312 self.move_rows.connect(self.move)
313 self.items_moved.connect(self.decorate)
315 def add_item(self, idx, enabled, command,
316 sha1hex='', summary='', cmdexec=''):
317 comment_char = self.comment_char
318 item = RebaseTreeWidgetItem(idx, enabled, command,
319 sha1hex=sha1hex, summary=summary,
320 cmdexec=cmdexec, comment_char=comment_char)
321 self.invisibleRootItem().addChild(item)
323 def decorate(self, items):
324 for item in items:
325 item.decorate(self)
327 def refit(self):
328 self.resizeColumnToContents(0)
329 self.resizeColumnToContents(1)
330 self.resizeColumnToContents(2)
331 self.resizeColumnToContents(3)
332 self.resizeColumnToContents(4)
334 # actions
335 def item_changed(self, item, column):
336 if column == item.ENABLED_COLUMN:
337 self.validate()
339 def validate(self):
340 invalid_first_choice = set([FIXUP, SQUASH])
341 for item in self.items():
342 if item.is_enabled() and item.is_commit():
343 if item.command in invalid_first_choice:
344 item.reset_command(PICK)
345 break
347 def set_selected_to(self, command):
348 for i in self.selected_items():
349 i.reset_command(command)
350 self.validate()
352 def set_command(self, item, command):
353 item.reset_command(command)
354 self.validate()
356 def copy_sha1(self):
357 item = self.selected_item()
358 if item is None:
359 return
360 clipboard = item.sha1hex or item.cmdexec
361 qtutils.set_clipboard(clipboard)
363 def selection_changed(self):
364 item = self.selected_item()
365 if item is None or not item.is_commit():
366 return
367 sha1hex = item.sha1hex
368 ctx = dag.DAG(sha1hex, 2)
369 repo = dag.RepoReader(ctx)
370 commits = []
371 for c in repo:
372 commits.append(c)
373 if commits:
374 commits = commits[-1:]
375 self.notifier.notify_observers(diff.COMMITS_SELECTED, commits)
377 def toggle_enabled(self):
378 item = self.selected_item()
379 if item is None:
380 return
381 item.toggle_enabled()
383 def select_first(self):
384 items = self.items()
385 if not items:
386 return
387 idx = self.model().index(0, 0)
388 if idx.isValid():
389 self.setCurrentIndex(idx)
391 def shift_down(self):
392 item = self.selected_item()
393 if item is None:
394 return
395 items = self.items()
396 idx = items.index(item)
397 if idx < len(items) - 1:
398 self.move_rows.emit([idx], idx + 1)
400 def shift_up(self):
401 item = self.selected_item()
402 if item is None:
403 return
404 items = self.items()
405 idx = items.index(item)
406 if idx > 0:
407 self.move_rows.emit([idx], idx - 1)
409 def move(self, src_idxs, dst_idx):
410 new_items = []
411 items = self.items()
412 for idx in reversed(sorted(src_idxs)):
413 item = items[idx].copy()
414 self.invisibleRootItem().takeChild(idx)
415 new_items.insert(0, item)
417 if new_items:
418 self.invisibleRootItem().insertChildren(dst_idx, new_items)
419 self.setCurrentItem(new_items[0])
420 # If we've moved to the top then we need to re-decorate all items.
421 # Otherwise, we can decorate just the new items.
422 if dst_idx == 0:
423 self.decorate(self.items())
424 else:
425 self.decorate(new_items)
426 self.validate()
428 # Qt events
430 def dropEvent(self, event):
431 super(RebaseTreeWidget, self).dropEvent(event)
432 self.validate()
434 def contextMenuEvent(self, event):
435 menu = qtutils.create_menu(N_('Actions'), self)
436 menu.addAction(self.action_pick)
437 menu.addAction(self.action_reword)
438 menu.addAction(self.action_edit)
439 menu.addAction(self.action_fixup)
440 menu.addAction(self.action_squash)
441 menu.addSeparator()
442 menu.addAction(self.toggle_enabled_action)
443 menu.addSeparator()
444 menu.addAction(self.copy_sha1_action)
445 menu.addAction(self.external_diff_action)
446 menu.exec_(self.mapToGlobal(event.pos()))
449 class ComboBox(QtWidgets.QComboBox):
450 validate = Signal()
453 class RebaseTreeWidgetItem(QtWidgets.QTreeWidgetItem):
455 ENABLED_COLUMN = 1
456 COMMAND_COLUMN = 2
457 SHA1LEN = 7
459 def __init__(self, idx, enabled, command,
460 sha1hex='', summary='', cmdexec='', comment_char='#',
461 parent=None):
462 QtWidgets.QTreeWidgetItem.__init__(self, parent)
463 self.combo = None
464 self.command = command
465 self.idx = idx
466 self.sha1hex = sha1hex
467 self.summary = summary
468 self.cmdexec = cmdexec
469 self.comment_char = comment_char
471 # if core.abbrev is set to a higher value then we will notice by
472 # simply tracking the longest sha1 we've seen
473 sha1len = self.__class__.SHA1LEN
474 sha1len = self.__class__.SHA1LEN = max(len(sha1hex), sha1len)
476 self.setText(0, '%02d' % idx)
477 self.set_enabled(enabled)
478 # checkbox on 1
479 # combo box on 2
480 if self.is_exec():
481 self.setText(3, '')
482 self.setText(4, cmdexec)
483 else:
484 self.setText(3, sha1hex)
485 self.setText(4, summary)
487 flags = self.flags() | Qt.ItemIsUserCheckable
488 flags = flags | Qt.ItemIsDragEnabled
489 flags = flags & ~Qt.ItemIsDropEnabled
490 self.setFlags(flags)
492 def __eq__(self, other):
493 return self is other
495 def copy(self):
496 return self.__class__(self.idx, self.is_enabled(), self.command,
497 sha1hex=self.sha1hex, summary=self.summary,
498 cmdexec=self.cmdexec)
500 def decorate(self, parent):
501 if self.is_exec():
502 items = [EXEC]
503 idx = 0
504 else:
505 items = COMMANDS
506 idx = COMMAND_IDX[self.command]
507 combo = self.combo = ComboBox()
508 combo.setEditable(False)
509 combo.addItems(items)
510 combo.setCurrentIndex(idx)
511 combo.setEnabled(self.is_commit())
513 signal = combo.currentIndexChanged
514 signal.connect(lambda x: self.set_command_and_validate(combo))
515 combo.validate.connect(parent.validate)
517 parent.setItemWidget(self, self.COMMAND_COLUMN, combo)
519 def is_exec(self):
520 return self.command == EXEC
522 def is_commit(self):
523 return bool(self.command != EXEC and self.sha1hex and self.summary)
525 def value(self):
526 """Return the serialized representation of an item"""
527 if self.is_enabled():
528 comment = ''
529 else:
530 comment = self.comment_char + ' '
531 if self.is_exec():
532 return ('%s%s %s' % (comment, self.command, self.cmdexec))
533 return ('%s%s %s %s' %
534 (comment, self.command, self.sha1hex, self.summary))
536 def is_enabled(self):
537 return self.checkState(self.ENABLED_COLUMN) == Qt.Checked
539 def set_enabled(self, enabled):
540 self.setCheckState(self.ENABLED_COLUMN,
541 enabled and Qt.Checked or Qt.Unchecked)
543 def toggle_enabled(self):
544 self.set_enabled(not self.is_enabled())
546 def set_command(self, command):
547 """Set the item to a different command, no-op for exec items"""
548 if self.is_exec():
549 return
550 self.command = command
552 def refresh(self):
553 """Update the view to match the updated state"""
554 if self.is_commit():
555 command = self.command
556 self.combo.setCurrentIndex(COMMAND_IDX[command])
558 def reset_command(self, command):
559 """Set and refresh the item in one shot"""
560 self.set_command(command)
561 self.refresh()
563 def set_command_and_validate(self, combo):
564 command = COMMANDS[combo.currentIndex()]
565 self.set_command(command)
566 self.combo.validate.emit()
569 def show_help():
570 help_text = N_("""
571 Commands
572 --------
573 pick = use commit
574 reword = use commit, but edit the commit message
575 edit = use commit, but stop for amending
576 squash = use commit, but meld into previous commit
577 fixup = like "squash", but discard this commit's log message
578 exec = run command (the rest of the line) using shell
580 These lines can be re-ordered; they are executed from top to bottom.
582 If you disable a line here THAT COMMIT WILL BE LOST.
584 However, if you disable everything, the rebase will be aborted.
586 Keyboard Shortcuts
587 ------------------
588 ? = show help
589 j = move down
590 k = move up
591 J = shift row down
592 K = shift row up
594 1, p = pick
595 2, r = reword
596 3, e = edit
597 4, f = fixup
598 5, s = squash
599 spacebar = toggle enabled
601 ctrl+enter = accept changes and rebase
602 ctrl+q = cancel and abort the rebase
603 ctrl+d = launch external diff
604 """)
605 title = N_('Help - git-xbase')
606 return text.text_dialog(help_text, title)
609 if __name__ == '__main__':
610 sys.exit(main())