i18n: add new translations to git-cola.pot
[git-cola.git] / bin / git-cola-sequence-editor
blob39e8d11940230d3ce902f71ddd43765ca4653a2c
1 #!/usr/bin/env python
2 # flake8: noqa
3 from __future__ import absolute_import, division, unicode_literals
4 import os
5 import sys
6 import re
7 from argparse import ArgumentParser
8 from functools import partial
11 __copyright__ = """
12 Copyright (C) 2007-2020 David Aguilar and contributors
14 This program is free software; you can redistribute it and/or modify
15 it under the terms of the GNU General Public License version 2 as
16 published by the Free Software Foundation.
18 This program is distributed in the hope that it will be useful,
19 but WITHOUT ANY WARRANTY; without even the implied warranty of
20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 GNU General Public License for more details.
23 """
26 def setup_environment():
27 """Provide access to the cola modules"""
28 abspath = os.path.abspath(__file__)
29 prefix = os.path.dirname(os.path.dirname(os.path.realpath(abspath)))
30 install_lib = os.path.join(prefix, str('share'), str('git-cola'), str('lib'))
31 win_pkgs = os.path.join(prefix, str('pkgs'))
32 # Use the private modules from share/git-cola/lib when they exist.
33 # It is assumed that that our modules to be present in sys.path through python's
34 # site-packages directory when the private modules do not exist.
35 if os.path.exists(install_lib):
36 sys.path.insert(1, install_lib)
37 elif os.path.exists(win_pkgs):
38 sys.path.insert(1, win_pkgs)
41 setup_environment()
43 from cola import app # prints a message if Qt cannot be found
44 from qtpy import QtCore
45 from qtpy import QtGui
46 from qtpy import QtWidgets
47 from qtpy.QtCore import Qt
48 from qtpy.QtCore import Signal
50 # pylint: disable=ungrouped-imports
51 from cola import core
52 from cola import difftool
53 from cola import hotkeys
54 from cola import icons
55 from cola import observable
56 from cola import qtutils
57 from cola import utils
58 from cola.i18n import N_
59 from cola.models import dag
60 from cola.models import prefs
61 from cola.widgets import defs
62 from cola.widgets import filelist
63 from cola.widgets import diff
64 from cola.widgets import standard
65 from cola.widgets import text
67 PICK = 'pick'
68 REWORD = 'reword'
69 EDIT = 'edit'
70 FIXUP = 'fixup'
71 SQUASH = 'squash'
72 EXEC = 'exec'
73 COMMANDS = (
74 PICK,
75 REWORD,
76 EDIT,
77 FIXUP,
78 SQUASH,
80 COMMAND_IDX = dict([(cmd_, idx_) for idx_, cmd_ in enumerate(COMMANDS)])
81 ABBREV = {
82 'p': PICK,
83 'r': REWORD,
84 'e': EDIT,
85 'f': FIXUP,
86 's': SQUASH,
87 'x': EXEC,
91 def main():
92 """Start a git-cola-sequence-editor session"""
93 args = parse_args()
94 context = app.application_init(args)
95 view = new_window(context, args.filename)
96 app.application_run(context, view, start=view.start, stop=stop)
97 return view.status
100 def stop(_context, _view):
101 """All done, cleanup"""
102 QtCore.QThreadPool.globalInstance().waitForDone()
105 def parse_args():
106 parser = ArgumentParser()
107 parser.add_argument(
108 'filename', metavar='<filename>', help='git-rebase-todo file to edit'
110 app.add_common_arguments(parser)
111 return parser.parse_args()
114 def new_window(context, filename):
115 window = MainWindow(context)
116 editor = Editor(context, filename, parent=window)
117 window.set_editor(editor)
118 return window
121 def unabbrev(cmd):
122 """Expand shorthand commands into their full name"""
123 return ABBREV.get(cmd, cmd)
126 class MainWindow(standard.MainWindow):
127 """The main git-cola application window"""
129 def __init__(self, context, parent=None):
130 super(MainWindow, self).__init__(parent)
131 self.context = context
132 self.status = 1
133 self.editor = None
134 default_title = '%s - git cola seqeuence editor' % core.getcwd()
135 title = core.getenv('GIT_COLA_SEQ_EDITOR_TITLE', default_title)
136 self.setWindowTitle(title)
138 self.show_help_action = qtutils.add_action(
139 self, N_('Show Help'), partial(show_help, context), hotkeys.QUESTION
142 self.menubar = QtWidgets.QMenuBar(self)
143 self.help_menu = self.menubar.addMenu(N_('Help'))
144 self.help_menu.addAction(self.show_help_action)
145 self.setMenuBar(self.menubar)
147 qtutils.add_close_action(self)
148 self.init_state(context.settings, self.init_window_size)
150 def init_window_size(self):
151 """Set the window size on the first initial view"""
152 context = self.context
153 if utils.is_darwin():
154 desktop = context.app.desktop()
155 self.resize(desktop.width(), desktop.height())
156 else:
157 self.showMaximized()
159 def set_editor(self, editor):
160 self.editor = editor
161 self.setCentralWidget(editor)
162 editor.exit.connect(self.exit)
163 editor.setFocus()
165 def start(self, _context, _view):
166 self.editor.start()
168 def exit(self, status):
169 self.status = status
170 self.close()
173 class Editor(QtWidgets.QWidget):
174 exit = Signal(int)
176 def __init__(self, context, filename, parent=None):
177 super(Editor, self).__init__(parent)
179 self.widget_version = 1
180 self.status = 1
181 self.context = context
182 self.filename = filename
183 self.comment_char = comment_char = prefs.comment_char(context)
184 self.cancel_action = core.getenv('GIT_COLA_SEQ_EDITOR_CANCEL_ACTION', 'abort')
186 self.notifier = notifier = observable.Observable()
187 self.diff = diff.DiffWidget(context, notifier, self)
188 self.tree = RebaseTreeWidget(context, notifier, comment_char, self)
189 self.filewidget = filelist.FileWidget(context, notifier, self)
190 self.setFocusProxy(self.tree)
192 self.rebase_button = qtutils.create_button(
193 text=core.getenv('GIT_COLA_SEQ_EDITOR_ACTION', N_('Rebase')),
194 tooltip=N_('Accept changes and rebase\n' 'Shortcut: Ctrl+Enter'),
195 icon=icons.ok(),
196 default=True,
199 self.extdiff_button = qtutils.create_button(
200 text=N_('Launch Diff Tool'),
201 tooltip=N_('Launch external diff tool\n' 'Shortcut: Ctrl+D'),
203 self.extdiff_button.setEnabled(False)
205 self.help_button = qtutils.create_button(
206 text=N_('Help'), tooltip=N_('Show help\nShortcut: ?'), icon=icons.question()
209 self.cancel_button = qtutils.create_button(
210 text=N_('Cancel'),
211 tooltip=N_('Cancel rebase\nShortcut: Ctrl+Q'),
212 icon=icons.close(),
215 top = qtutils.splitter(Qt.Horizontal, self.tree, self.filewidget)
216 top.setSizes([75, 25])
218 main_split = qtutils.splitter(Qt.Vertical, top, self.diff)
219 main_split.setSizes([25, 75])
221 controls_layout = qtutils.hbox(
222 defs.no_margin,
223 defs.button_spacing,
224 self.cancel_button,
225 qtutils.STRETCH,
226 self.help_button,
227 self.extdiff_button,
228 self.rebase_button,
230 layout = qtutils.vbox(defs.no_margin, defs.spacing, main_split, controls_layout)
231 self.setLayout(layout)
233 self.action_rebase = qtutils.add_action(
234 self, N_('Rebase'), self.rebase, hotkeys.CTRL_RETURN, hotkeys.CTRL_ENTER
237 notifier.add_observer(diff.COMMITS_SELECTED, self.commits_selected)
238 self.tree.external_diff.connect(self.external_diff)
240 qtutils.connect_button(self.rebase_button, self.rebase)
241 qtutils.connect_button(self.extdiff_button, self.external_diff)
242 qtutils.connect_button(self.help_button, partial(show_help, context))
243 qtutils.connect_button(self.cancel_button, self.cancel)
245 def start(self):
246 insns = core.read(self.filename)
247 self.parse_sequencer_instructions(insns)
249 # notifier callbacks
250 def commits_selected(self, commits):
251 self.extdiff_button.setEnabled(bool(commits))
253 # helpers
254 def parse_sequencer_instructions(self, insns):
255 idx = 1
256 re_comment_char = re.escape(self.comment_char)
257 exec_rgx = re.compile(r'^\s*(%s)?\s*(x|exec)\s+(.+)$' % re_comment_char)
258 # The upper bound of 40 below must match git.OID_LENGTH.
259 # We'll have to update this to the new hash length when that happens.
260 pick_rgx = re.compile(
262 r'^\s*(%s)?\s*'
263 r'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
264 r'\s+([0-9a-f]{7,40})'
265 r'\s+(.+)$'
267 % re_comment_char
269 for line in insns.splitlines():
270 match = pick_rgx.match(line)
271 if match:
272 enabled = match.group(1) is None
273 command = unabbrev(match.group(2))
274 oid = match.group(3)
275 summary = match.group(4)
276 self.tree.add_item(idx, enabled, command, oid=oid, summary=summary)
277 idx += 1
278 continue
279 match = exec_rgx.match(line)
280 if match:
281 enabled = match.group(1) is None
282 command = unabbrev(match.group(2))
283 cmdexec = match.group(3)
284 self.tree.add_item(idx, enabled, command, cmdexec=cmdexec)
285 idx += 1
286 continue
288 self.tree.decorate(self.tree.items())
289 self.tree.refit()
290 self.tree.select_first()
292 # actions
293 def cancel(self):
294 if self.cancel_action == 'save':
295 status = self.save('')
296 else:
297 status = 1
299 self.status = status
300 self.exit.emit(status)
302 def rebase(self):
303 lines = [item.value() for item in self.tree.items()]
304 sequencer_instructions = '\n'.join(lines) + '\n'
305 status = self.save(sequencer_instructions)
306 self.status = status
307 self.exit.emit(status)
309 def save(self, string):
310 """Save the instruction sheet"""
311 try:
312 core.write(self.filename, string)
313 status = 0
314 except (OSError, IOError, ValueError) as e:
315 msg, details = utils.format_exception(e)
316 sys.stderr.write(msg + '\n\n' + details)
317 status = 128
318 return status
320 def external_diff(self):
321 items = self.tree.selected_items()
322 if not items:
323 return
324 item = items[0]
325 difftool.diff_expression(self.context, self, item.oid + '^!', hide_expr=True)
328 # pylint: disable=too-many-ancestors
329 class RebaseTreeWidget(standard.DraggableTreeWidget):
330 external_diff = Signal()
331 move_rows = Signal(object, object)
333 def __init__(self, context, notifier, comment_char, parent=None):
334 super(RebaseTreeWidget, self).__init__(parent=parent)
335 self.context = context
336 self.notifier = notifier
337 self.comment_char = comment_char
338 # header
339 self.setHeaderLabels(
341 N_('#'),
342 N_('Enabled'),
343 N_('Command'),
344 N_('SHA-1'),
345 N_('Summary'),
348 self.header().setStretchLastSection(True)
349 self.setColumnCount(5)
351 # actions
352 self.copy_oid_action = qtutils.add_action(
353 self, N_('Copy SHA-1'), self.copy_oid, QtGui.QKeySequence.Copy
356 self.external_diff_action = qtutils.add_action(
357 self, N_('Launch Diff Tool'), self.external_diff.emit, hotkeys.DIFF
360 self.toggle_enabled_action = qtutils.add_action(
361 self, N_('Toggle Enabled'), self.toggle_enabled, hotkeys.PRIMARY_ACTION
364 self.action_pick = qtutils.add_action(
365 self, N_('Pick'), lambda: self.set_selected_to(PICK), *hotkeys.REBASE_PICK
368 self.action_reword = qtutils.add_action(
369 self,
370 N_('Reword'),
371 lambda: self.set_selected_to(REWORD),
372 *hotkeys.REBASE_REWORD
375 self.action_edit = qtutils.add_action(
376 self, N_('Edit'), lambda: self.set_selected_to(EDIT), *hotkeys.REBASE_EDIT
379 self.action_fixup = qtutils.add_action(
380 self,
381 N_('Fixup'),
382 lambda: self.set_selected_to(FIXUP),
383 *hotkeys.REBASE_FIXUP
386 self.action_squash = qtutils.add_action(
387 self,
388 N_('Squash'),
389 lambda: self.set_selected_to(SQUASH),
390 *hotkeys.REBASE_SQUASH
393 self.action_shift_down = qtutils.add_action(
394 self, N_('Shift Down'), self.shift_down, hotkeys.MOVE_DOWN_TERTIARY
397 self.action_shift_up = qtutils.add_action(
398 self, N_('Shift Up'), self.shift_up, hotkeys.MOVE_UP_TERTIARY
401 # pylint: disable=no-member
402 self.itemChanged.connect(self.item_changed)
403 self.itemSelectionChanged.connect(self.selection_changed)
404 self.move_rows.connect(self.move)
405 self.items_moved.connect(self.decorate)
407 def add_item(self, idx, enabled, command, oid='', summary='', cmdexec=''):
408 comment_char = self.comment_char
409 item = RebaseTreeWidgetItem(
410 idx,
411 enabled,
412 command,
413 oid=oid,
414 summary=summary,
415 cmdexec=cmdexec,
416 comment_char=comment_char,
418 self.invisibleRootItem().addChild(item)
420 def decorate(self, items):
421 for item in items:
422 item.decorate(self)
424 def refit(self):
425 self.resizeColumnToContents(0)
426 self.resizeColumnToContents(1)
427 self.resizeColumnToContents(2)
428 self.resizeColumnToContents(3)
429 self.resizeColumnToContents(4)
431 # actions
432 def item_changed(self, item, column):
433 if column == item.ENABLED_COLUMN:
434 self.validate()
436 def validate(self):
437 invalid_first_choice = set([FIXUP, SQUASH])
438 for item in self.items():
439 if item.is_enabled() and item.is_commit():
440 if item.command in invalid_first_choice:
441 item.reset_command(PICK)
442 break
444 def set_selected_to(self, command):
445 for i in self.selected_items():
446 i.reset_command(command)
447 self.validate()
449 def set_command(self, item, command):
450 item.reset_command(command)
451 self.validate()
453 def copy_oid(self):
454 item = self.selected_item()
455 if item is None:
456 return
457 clipboard = item.oid or item.cmdexec
458 qtutils.set_clipboard(clipboard)
460 def selection_changed(self):
461 item = self.selected_item()
462 if item is None or not item.is_commit():
463 return
464 context = self.context
465 oid = item.oid
466 params = dag.DAG(oid, 2)
467 repo = dag.RepoReader(context, params)
468 commits = []
469 for c in repo.get():
470 commits.append(c)
471 if commits:
472 commits = commits[-1:]
473 self.notifier.notify_observers(diff.COMMITS_SELECTED, commits)
475 def toggle_enabled(self):
476 item = self.selected_item()
477 if item is None:
478 return
479 item.toggle_enabled()
481 def select_first(self):
482 items = self.items()
483 if not items:
484 return
485 idx = self.model().index(0, 0)
486 if idx.isValid():
487 self.setCurrentIndex(idx)
489 def shift_down(self):
490 item = self.selected_item()
491 if item is None:
492 return
493 items = self.items()
494 idx = items.index(item)
495 if idx < len(items) - 1:
496 self.move_rows.emit([idx], idx + 1)
498 def shift_up(self):
499 item = self.selected_item()
500 if item is None:
501 return
502 items = self.items()
503 idx = items.index(item)
504 if idx > 0:
505 self.move_rows.emit([idx], idx - 1)
507 def move(self, src_idxs, dst_idx):
508 new_items = []
509 items = self.items()
510 for idx in reversed(sorted(src_idxs)):
511 item = items[idx].copy()
512 self.invisibleRootItem().takeChild(idx)
513 new_items.insert(0, item)
515 if new_items:
516 self.invisibleRootItem().insertChildren(dst_idx, new_items)
517 self.setCurrentItem(new_items[0])
518 # If we've moved to the top then we need to re-decorate all items.
519 # Otherwise, we can decorate just the new items.
520 if dst_idx == 0:
521 self.decorate(self.items())
522 else:
523 self.decorate(new_items)
524 self.validate()
526 # Qt events
528 def dropEvent(self, event):
529 super(RebaseTreeWidget, self).dropEvent(event)
530 self.validate()
532 def contextMenuEvent(self, event):
533 menu = qtutils.create_menu(N_('Actions'), self)
534 menu.addAction(self.action_pick)
535 menu.addAction(self.action_reword)
536 menu.addAction(self.action_edit)
537 menu.addAction(self.action_fixup)
538 menu.addAction(self.action_squash)
539 menu.addSeparator()
540 menu.addAction(self.toggle_enabled_action)
541 menu.addSeparator()
542 menu.addAction(self.copy_oid_action)
543 menu.addAction(self.external_diff_action)
544 menu.exec_(self.mapToGlobal(event.pos()))
547 class ComboBox(QtWidgets.QComboBox):
548 validate = Signal()
551 class RebaseTreeWidgetItem(QtWidgets.QTreeWidgetItem):
553 ENABLED_COLUMN = 1
554 COMMAND_COLUMN = 2
555 OID_LENGTH = 7
557 def __init__(
558 self,
559 idx,
560 enabled,
561 command,
562 oid='',
563 summary='',
564 cmdexec='',
565 comment_char='#',
566 parent=None,
568 QtWidgets.QTreeWidgetItem.__init__(self, parent)
569 self.combo = None
570 self.command = command
571 self.idx = idx
572 self.oid = oid
573 self.summary = summary
574 self.cmdexec = cmdexec
575 self.comment_char = comment_char
577 # if core.abbrev is set to a higher value then we will notice by
578 # simply tracking the longest oid we've seen
579 oid_len = self.__class__.OID_LENGTH
580 self.__class__.OID_LENGTH = max(len(oid), oid_len)
582 self.setText(0, '%02d' % idx)
583 self.set_enabled(enabled)
584 # checkbox on 1
585 # combo box on 2
586 if self.is_exec():
587 self.setText(3, '')
588 self.setText(4, cmdexec)
589 else:
590 self.setText(3, oid)
591 self.setText(4, summary)
593 flags = self.flags() | Qt.ItemIsUserCheckable
594 flags = flags | Qt.ItemIsDragEnabled
595 flags = flags & ~Qt.ItemIsDropEnabled
596 self.setFlags(flags)
598 def __eq__(self, other):
599 return self is other
601 def __hash__(self):
602 return self.oid
604 def copy(self):
605 return self.__class__(
606 self.idx,
607 self.is_enabled(),
608 self.command,
609 oid=self.oid,
610 summary=self.summary,
611 cmdexec=self.cmdexec,
614 def decorate(self, parent):
615 if self.is_exec():
616 items = [EXEC]
617 idx = 0
618 else:
619 items = COMMANDS
620 idx = COMMAND_IDX[self.command]
621 combo = self.combo = ComboBox()
622 combo.setEditable(False)
623 combo.addItems(items)
624 combo.setCurrentIndex(idx)
625 combo.setEnabled(self.is_commit())
627 signal = combo.currentIndexChanged
628 # pylint: disable=no-member
629 signal.connect(lambda x: self.set_command_and_validate(combo))
630 combo.validate.connect(parent.validate)
632 parent.setItemWidget(self, self.COMMAND_COLUMN, combo)
634 def is_exec(self):
635 return self.command == EXEC
637 def is_commit(self):
638 return bool(self.command != EXEC and self.oid and self.summary)
640 def value(self):
641 """Return the serialized representation of an item"""
642 if self.is_enabled():
643 comment = ''
644 else:
645 comment = self.comment_char + ' '
646 if self.is_exec():
647 return '%s%s %s' % (comment, self.command, self.cmdexec)
648 return '%s%s %s %s' % (comment, self.command, self.oid, self.summary)
650 def is_enabled(self):
651 return self.checkState(self.ENABLED_COLUMN) == Qt.Checked
653 def set_enabled(self, enabled):
654 self.setCheckState(self.ENABLED_COLUMN, enabled and Qt.Checked or Qt.Unchecked)
656 def toggle_enabled(self):
657 self.set_enabled(not self.is_enabled())
659 def set_command(self, command):
660 """Set the item to a different command, no-op for exec items"""
661 if self.is_exec():
662 return
663 self.command = command
665 def refresh(self):
666 """Update the view to match the updated state"""
667 if self.is_commit():
668 command = self.command
669 self.combo.setCurrentIndex(COMMAND_IDX[command])
671 def reset_command(self, command):
672 """Set and refresh the item in one shot"""
673 self.set_command(command)
674 self.refresh()
676 def set_command_and_validate(self, combo):
677 command = COMMANDS[combo.currentIndex()]
678 self.set_command(command)
679 self.combo.validate.emit()
682 def show_help(context):
683 help_text = N_(
685 Commands
686 --------
687 pick = use commit
688 reword = use commit, but edit the commit message
689 edit = use commit, but stop for amending
690 squash = use commit, but meld into previous commit
691 fixup = like "squash", but discard this commit's log message
692 exec = run command (the rest of the line) using shell
694 These lines can be re-ordered; they are executed from top to bottom.
696 If you disable a line here THAT COMMIT WILL BE LOST.
698 However, if you disable everything, the rebase will be aborted.
700 Keyboard Shortcuts
701 ------------------
702 ? = show help
703 j = move down
704 k = move up
705 J = shift row down
706 K = shift row up
708 1, p = pick
709 2, r = reword
710 3, e = edit
711 4, f = fixup
712 5, s = squash
713 spacebar = toggle enabled
715 ctrl+enter = accept changes and rebase
716 ctrl+q = cancel and abort the rebase
717 ctrl+d = launch difftool
720 title = N_('Help - git-cola-sequence-editor')
721 return text.text_dialog(context, help_text, title)
724 if __name__ == '__main__':
725 sys.exit(main())