sequenceeditor: can toggle a remark of all commits touching selected file(s)
[git-cola.git] / cola / sequenceeditor.py
blob9fad7510be195ac529876f51f6754eba8539897c
1 import sys
2 import re
3 from argparse import ArgumentParser
4 from functools import partial, reduce
6 from cola import app # prints a message if Qt cannot be found
7 from qtpy import QtCore
8 from qtpy import QtGui
9 from qtpy import QtWidgets
10 from qtpy.QtCore import Qt
11 from qtpy.QtCore import Signal
13 # pylint: disable=ungrouped-imports
14 from cola import core
15 from cola import difftool
16 from cola import hotkeys
17 from cola import icons
18 from cola import qtutils
19 from cola import utils
20 from cola.i18n import N_
21 from cola.models import dag
22 from cola.models import prefs
23 from cola.widgets import defs
24 from cola.widgets import filelist
25 from cola.widgets import diff
26 from cola.widgets import standard
27 from cola.widgets import text
30 PICK = 'pick'
31 REWORD = 'reword'
32 EDIT = 'edit'
33 FIXUP = 'fixup'
34 SQUASH = 'squash'
35 UPDATE_REF = 'update-ref'
36 EXEC = 'exec'
37 COMMANDS = (
38 PICK,
39 REWORD,
40 EDIT,
41 FIXUP,
42 SQUASH,
44 COMMAND_IDX = {cmd_: idx_ for idx_, cmd_ in enumerate(COMMANDS)}
45 ABBREV = {
46 'p': PICK,
47 'r': REWORD,
48 'e': EDIT,
49 'f': FIXUP,
50 's': SQUASH,
51 'x': EXEC,
52 'u': UPDATE_REF,
56 def main():
57 """Start a git-cola-sequence-editor session"""
58 args = parse_args()
59 context = app.application_init(args)
60 view = new_window(context, args.filename)
61 app.application_run(context, view, start=view.start, stop=stop)
62 return view.status
65 def stop(_context, _view):
66 """All done, cleanup"""
67 QtCore.QThreadPool.globalInstance().waitForDone()
70 def parse_args():
71 parser = ArgumentParser()
72 parser.add_argument(
73 'filename', metavar='<filename>', help='git-rebase-todo file to edit'
75 app.add_common_arguments(parser)
76 return parser.parse_args()
79 def new_window(context, filename):
80 window = MainWindow(context)
81 editor = Editor(context, filename, parent=window)
82 window.set_editor(editor)
83 return window
86 def unabbrev(cmd):
87 """Expand shorthand commands into their full name"""
88 return ABBREV.get(cmd, cmd)
91 class MainWindow(standard.MainWindow):
92 """The main git-cola application window"""
94 def __init__(self, context, parent=None):
95 super().__init__(parent)
96 self.context = context
97 self.status = 1
98 self.editor = None
99 default_title = '%s - git cola sequence editor' % core.getcwd()
100 title = core.getenv('GIT_COLA_SEQ_EDITOR_TITLE', default_title)
101 self.setWindowTitle(title)
103 self.show_help_action = qtutils.add_action(
104 self, N_('Show Help'), partial(show_help, context), hotkeys.QUESTION
107 self.menubar = QtWidgets.QMenuBar(self)
108 self.help_menu = self.menubar.addMenu(N_('Help'))
109 self.help_menu.addAction(self.show_help_action)
110 self.setMenuBar(self.menubar)
112 qtutils.add_close_action(self)
113 self.init_state(context.settings, self.init_window_size)
115 def init_window_size(self):
116 """Set the window size on the first initial view"""
117 if utils.is_darwin():
118 width, height = qtutils.desktop_size()
119 self.resize(width, height)
120 else:
121 self.showMaximized()
123 def set_editor(self, editor):
124 self.editor = editor
125 self.setCentralWidget(editor)
126 editor.exit.connect(self.exit)
127 editor.setFocus()
129 def start(self, _context, _view):
130 self.editor.start()
132 def exit(self, status):
133 self.status = status
134 self.close()
137 class Editor(QtWidgets.QWidget):
138 exit = Signal(int)
140 def __init__(self, context, filename, parent=None):
141 super().__init__(parent)
143 self.widget_version = 1
144 self.status = 1
145 self.context = context
146 self.filename = filename
147 self.comment_char = comment_char = prefs.comment_char(context)
148 self.cancel_action = core.getenv('GIT_COLA_SEQ_EDITOR_CANCEL_ACTION', 'abort')
150 self.diff = diff.DiffWidget(context, self)
151 self.tree = RebaseTreeWidget(context, comment_char, self)
152 self.filewidget = filelist.FileWidget(context, self, remarks=True)
153 self.setFocusProxy(self.tree)
155 self.rebase_button = qtutils.create_button(
156 text=core.getenv('GIT_COLA_SEQ_EDITOR_ACTION', N_('Rebase')),
157 tooltip=N_('Accept changes and rebase\nShortcut: Ctrl+Enter'),
158 icon=icons.ok(),
159 default=True,
162 self.extdiff_button = qtutils.create_button(
163 text=N_('Launch Diff Tool'),
164 tooltip=N_('Launch external diff tool\nShortcut: Ctrl+D'),
166 self.extdiff_button.setEnabled(False)
168 self.help_button = qtutils.create_button(
169 text=N_('Help'), tooltip=N_('Show help\nShortcut: ?'), icon=icons.question()
172 self.cancel_button = qtutils.create_button(
173 text=N_('Cancel'),
174 tooltip=N_('Cancel rebase\nShortcut: Ctrl+Q'),
175 icon=icons.close(),
178 top = qtutils.splitter(Qt.Horizontal, self.tree, self.filewidget)
179 top.setSizes([75, 25])
181 main_split = qtutils.splitter(Qt.Vertical, top, self.diff)
182 main_split.setSizes([25, 75])
184 controls_layout = qtutils.hbox(
185 defs.no_margin,
186 defs.button_spacing,
187 self.cancel_button,
188 qtutils.STRETCH,
189 self.help_button,
190 self.extdiff_button,
191 self.rebase_button,
193 layout = qtutils.vbox(defs.no_margin, defs.spacing, main_split, controls_layout)
194 self.setLayout(layout)
196 self.action_rebase = qtutils.add_action(
197 self, N_('Rebase'), self.rebase, hotkeys.CTRL_RETURN, hotkeys.CTRL_ENTER
200 self.tree.commits_selected.connect(self.commits_selected)
201 self.tree.commits_selected.connect(self.filewidget.commits_selected)
202 self.tree.commits_selected.connect(self.diff.commits_selected)
203 self.tree.external_diff.connect(self.external_diff)
205 self.filewidget.files_selected.connect(self.diff.files_selected)
206 self.filewidget.remark_toggled.connect(self.remark_toggled_for_files)
208 qtutils.connect_button(self.rebase_button, self.rebase)
209 qtutils.connect_button(self.extdiff_button, self.external_diff)
210 qtutils.connect_button(self.help_button, partial(show_help, context))
211 qtutils.connect_button(self.cancel_button, self.cancel)
213 def start(self):
214 insns = core.read(self.filename)
215 self.parse_sequencer_instructions(insns)
217 # signal callbacks
218 def commits_selected(self, commits):
219 self.extdiff_button.setEnabled(bool(commits))
221 def remark_toggled_for_files(self, remark, filenames):
222 filenames = set(filenames)
224 items = self.tree.items()
225 touching_items = []
227 git = self.context.git
229 for item in items:
230 if not item.is_commit():
231 continue
232 oid = item.oid
234 status, out, _ = git.show(
235 oid, z=True, numstat=True, oneline=True, no_renames=True
237 if status != 0:
238 continue
239 paths = [f for f in out.rstrip('\0').split('\0') if f]
240 if paths:
241 # Skip over the summary on the first line.
242 paths = paths[1:]
244 # Drop numbers. Only path is needed.
245 paths = [f.split()[-1] for f in paths]
247 if filenames.intersection(paths):
248 touching_items.append(item)
250 self.tree.toggle_remark_of_items(remark, touching_items)
252 # helpers
253 def parse_sequencer_instructions(self, insns):
254 idx = 1
255 re_comment_char = re.escape(self.comment_char)
256 exec_rgx = re.compile(r'^\s*(%s)?\s*(x|exec)\s+(.+)$' % re_comment_char)
257 update_ref_rgx = re.compile(
258 r'^\s*(%s)?\s*(u|update-ref)\s+(.+)$' % re_comment_char
260 # The upper bound of 40 below must match git.OID_LENGTH.
261 # We'll have to update this to the new hash length when that happens.
262 pick_rgx = re.compile(
264 r'^\s*(%s)?\s*'
265 + r'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
266 + r'\s+([0-9a-f]{7,40})'
267 + r'\s+(.+)$'
269 % re_comment_char
271 for line in insns.splitlines():
272 match = pick_rgx.match(line)
273 if match:
274 enabled = match.group(1) is None
275 command = unabbrev(match.group(2))
276 oid = match.group(3)
277 summary = match.group(4)
278 self.tree.add_item(idx, enabled, command, oid=oid, summary=summary)
279 idx += 1
280 continue
281 match = exec_rgx.match(line)
282 if match:
283 enabled = match.group(1) is None
284 command = unabbrev(match.group(2))
285 cmdexec = match.group(3)
286 self.tree.add_item(idx, enabled, command, cmdexec=cmdexec)
287 idx += 1
288 continue
289 match = update_ref_rgx.match(line)
290 if match:
291 enabled = match.group(1) is None
292 command = unabbrev(match.group(2))
293 branch = match.group(3)
294 self.tree.add_item(idx, enabled, command, branch=branch)
295 idx += 1
296 continue
298 self.tree.decorate(self.tree.items())
299 self.tree.refit()
300 self.tree.select_first()
302 # actions
303 def cancel(self):
304 if self.cancel_action == 'save':
305 status = self.save('')
306 else:
307 status = 1
309 self.status = status
310 self.exit.emit(status)
312 def rebase(self):
313 lines = [item.value() for item in self.tree.items()]
314 sequencer_instructions = '\n'.join(lines) + '\n'
315 status = self.save(sequencer_instructions)
316 self.status = status
317 self.exit.emit(status)
319 def save(self, string):
320 """Save the instruction sheet"""
321 try:
322 core.write(self.filename, string)
323 status = 0
324 except (OSError, ValueError) as exc:
325 msg, details = utils.format_exception(exc)
326 sys.stderr.write(msg + '\n\n' + details)
327 status = 128
328 return status
330 def external_diff(self):
331 items = self.tree.selected_items()
332 if not items:
333 return
334 item = items[0]
335 difftool.diff_expression(self.context, self, item.oid + '^!', hide_expr=True)
338 # pylint: disable=too-many-ancestors
339 class RebaseTreeWidget(standard.DraggableTreeWidget):
340 commits_selected = Signal(object)
341 external_diff = Signal()
342 move_rows = Signal(object, object)
344 def __init__(self, context, comment_char, parent):
345 super().__init__(parent=parent)
346 self.context = context
347 self.comment_char = comment_char
348 # header
349 self.setHeaderLabels([
350 N_('#'),
351 N_('Enabled'),
352 N_('Command'),
353 N_('SHA-1'),
354 N_('Remarks'),
355 N_('Summary'),
357 self.header().setStretchLastSection(True)
358 self.setColumnCount(6)
359 self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
361 # actions
362 self.copy_oid_action = qtutils.add_action(
363 self, N_('Copy SHA-1'), self.copy_oid, QtGui.QKeySequence.Copy
366 self.external_diff_action = qtutils.add_action(
367 self, N_('Launch Diff Tool'), self.external_diff.emit, hotkeys.DIFF
370 self.toggle_enabled_action = qtutils.add_action(
371 self, N_('Toggle Enabled'), self.toggle_enabled, hotkeys.PRIMARY_ACTION
374 self.action_pick = qtutils.add_action(
375 self, N_('Pick'), lambda: self.set_selected_to(PICK), *hotkeys.REBASE_PICK
378 self.action_reword = qtutils.add_action(
379 self,
380 N_('Reword'),
381 lambda: self.set_selected_to(REWORD),
382 *hotkeys.REBASE_REWORD,
385 self.action_edit = qtutils.add_action(
386 self, N_('Edit'), lambda: self.set_selected_to(EDIT), *hotkeys.REBASE_EDIT
389 self.action_fixup = qtutils.add_action(
390 self,
391 N_('Fixup'),
392 lambda: self.set_selected_to(FIXUP),
393 *hotkeys.REBASE_FIXUP,
396 self.action_squash = qtutils.add_action(
397 self,
398 N_('Squash'),
399 lambda: self.set_selected_to(SQUASH),
400 *hotkeys.REBASE_SQUASH,
403 self.action_shift_down = qtutils.add_action(
404 self, N_('Shift Down'), self.shift_down, hotkeys.MOVE_DOWN_TERTIARY
407 self.action_shift_up = qtutils.add_action(
408 self, N_('Shift Up'), self.shift_up, hotkeys.MOVE_UP_TERTIARY
411 self.toggle_remark_actions = tuple(
412 qtutils.add_action(
413 self,
415 lambda remark = r: self.toggle_remark(remark),
416 hotkeys.hotkey(Qt.CTRL | getattr(Qt, "Key_" + r))
417 ) for r in map(str, range(10))
420 # pylint: disable=no-member
421 self.itemChanged.connect(self.item_changed)
422 self.itemSelectionChanged.connect(self.selection_changed)
423 self.move_rows.connect(self.move)
424 self.items_moved.connect(self.decorate)
426 def add_item(
427 self, idx, enabled, command, oid='', summary='', cmdexec='', branch=''
429 comment_char = self.comment_char
430 item = RebaseTreeWidgetItem(
431 idx,
432 enabled,
433 command,
434 oid=oid,
435 summary=summary,
436 cmdexec=cmdexec,
437 branch=branch,
438 comment_char=comment_char,
440 self.invisibleRootItem().addChild(item)
442 def decorate(self, items):
443 for item in items:
444 item.decorate(self)
446 def refit(self):
447 self.resizeColumnToContents(0)
448 self.resizeColumnToContents(1)
449 self.resizeColumnToContents(2)
450 self.resizeColumnToContents(3)
451 self.resizeColumnToContents(4)
452 self.resizeColumnToContents(5)
454 # actions
455 def item_changed(self, item, column):
456 if column == item.ENABLED_COLUMN:
457 self.validate()
459 def validate(self):
460 invalid_first_choice = {FIXUP, SQUASH}
461 for item in self.items():
462 if item.is_enabled() and item.is_commit():
463 if item.command in invalid_first_choice:
464 item.reset_command(PICK)
465 break
467 def set_selected_to(self, command):
468 for i in self.selected_items():
469 i.reset_command(command)
470 self.validate()
472 def set_command(self, item, command):
473 item.reset_command(command)
474 self.validate()
476 def copy_oid(self):
477 item = self.selected_item()
478 if item is None:
479 return
480 clipboard = item.oid or item.cmdexec
481 qtutils.set_clipboard(clipboard)
483 def selection_changed(self):
484 item = self.selected_item()
485 if item is None or not item.is_commit():
486 return
487 context = self.context
488 oid = item.oid
489 params = dag.DAG(oid, 2)
490 repo = dag.RepoReader(context, params)
491 commits = []
492 for commit in repo.get():
493 commits.append(commit)
494 if commits:
495 commits = commits[-1:]
496 self.commits_selected.emit(commits)
498 def toggle_enabled(self):
499 items = self.selected_items()
500 logic_or = reduce(lambda res, item: res or item.is_enabled(), items, False)
501 for item in items:
502 item.set_enabled(not logic_or)
504 def select_first(self):
505 items = self.items()
506 if not items:
507 return
508 idx = self.model().index(0, 0)
509 if idx.isValid():
510 self.setCurrentIndex(idx)
512 def shift_down(self):
513 sel_items = self.selected_items()
514 all_items = self.items()
515 sel_idx = sorted([all_items.index(item) for item in sel_items])
516 if not sel_idx:
517 return
518 idx = sel_idx[0] + 1
519 if not (
520 idx > len(all_items) - len(sel_items)
521 or all_items[sel_idx[-1]] is all_items[-1]
523 self.move_rows.emit(sel_idx, idx)
525 def shift_up(self):
526 sel_items = self.selected_items()
527 all_items = self.items()
528 sel_idx = sorted([all_items.index(item) for item in sel_items])
529 if not sel_idx:
530 return
531 idx = sel_idx[0] - 1
532 if idx >= 0:
533 self.move_rows.emit(sel_idx, idx)
535 def toggle_remark(self, remark):
536 items = self.selected_items()
537 self.toggle_remark_of_items(remark, items)
539 def toggle_remark_of_items(self, remark, items):
540 logic_or = reduce(
541 lambda res, item: res or remark in item.remarks,
542 items,
543 False
545 if logic_or:
546 for item in items:
547 item.remove_remark(remark)
548 else:
549 for item in items:
550 item.add_remark(remark)
552 def move(self, src_idxs, dst_idx):
553 moved_items = []
554 src_base = sorted(src_idxs)[0]
555 for idx in reversed(sorted(src_idxs)):
556 item = self.invisibleRootItem().takeChild(idx)
557 moved_items.insert(0, [dst_idx + (idx - src_base), item])
559 for item in moved_items:
560 self.invisibleRootItem().insertChild(item[0], item[1])
561 self.setCurrentItem(item[1])
563 if moved_items:
564 moved_items = [item[1] for item in moved_items]
565 # If we've moved to the top then we need to re-decorate all items.
566 # Otherwise, we can decorate just the new items.
567 if dst_idx == 0:
568 self.decorate(self.items())
569 else:
570 self.decorate(moved_items)
572 for item in moved_items:
573 item.setSelected(True)
574 self.validate()
576 # Qt events
578 def dropEvent(self, event):
579 super().dropEvent(event)
580 self.validate()
582 def contextMenuEvent(self, event):
583 items = self.selected_items()
584 menu = qtutils.create_menu(N_('Actions'), self)
585 menu.addAction(self.action_pick)
586 menu.addAction(self.action_reword)
587 menu.addAction(self.action_edit)
588 menu.addAction(self.action_fixup)
589 menu.addAction(self.action_squash)
590 menu.addSeparator()
591 menu.addAction(self.toggle_enabled_action)
592 menu.addSeparator()
593 menu.addAction(self.copy_oid_action)
594 self.copy_oid_action.setDisabled(len(items) > 1)
595 menu.addAction(self.external_diff_action)
596 self.external_diff_action.setDisabled(len(items) > 1)
597 menu.addSeparator()
598 menu_toggle_remark = menu.addMenu(N_('Toggle remark'))
599 tuple(map(menu_toggle_remark.addAction, self.toggle_remark_actions))
600 menu.exec_(self.mapToGlobal(event.pos()))
603 class ComboBox(QtWidgets.QComboBox):
604 validate = Signal()
607 class RebaseTreeWidgetItem(QtWidgets.QTreeWidgetItem):
608 ENABLED_COLUMN = 1
609 COMMAND_COLUMN = 2
610 OID_LENGTH = 7
612 def __init__(
613 self,
614 idx,
615 enabled,
616 command,
617 oid='',
618 summary='',
619 cmdexec='',
620 branch='',
621 comment_char='#',
622 remarks=tuple(),
623 parent=None,
625 QtWidgets.QTreeWidgetItem.__init__(self, parent)
626 self.combo = None
627 self.command = command
628 self.idx = idx
629 self.oid = oid
630 self.summary = summary
631 self.cmdexec = cmdexec
632 self.branch = branch
633 self.comment_char = comment_char
635 # if core.abbrev is set to a higher value then we will notice by
636 # simply tracking the longest oid we've seen
637 oid_len = self.__class__.OID_LENGTH
638 self.__class__.OID_LENGTH = max(len(oid), oid_len)
640 self.setText(0, '%02d' % idx)
641 self.set_enabled(enabled)
642 # checkbox on 1
643 # combo box on 2
644 if self.is_exec():
645 self.setText(3, '')
646 self.setText(5, cmdexec)
647 elif self.is_update_ref():
648 self.setText(3, '')
649 self.setText(5, branch)
650 else:
651 self.setText(3, oid)
652 self.setText(5, summary)
654 self.set_remarks(remarks)
656 flags = self.flags() | Qt.ItemIsUserCheckable
657 flags = flags | Qt.ItemIsDragEnabled
658 flags = flags & ~Qt.ItemIsDropEnabled
659 self.setFlags(flags)
661 def __eq__(self, other):
662 return self is other
664 def __hash__(self):
665 return self.oid
667 def copy(self):
668 return self.__class__(
669 self.idx,
670 self.is_enabled(),
671 self.command,
672 oid=self.oid,
673 summary=self.summary,
674 cmdexec=self.cmdexec,
675 branch=self.branch,
676 comment_char=self.comment_char,
677 remarks=self.remarks,
680 def decorate(self, parent):
681 if self.is_exec():
682 items = [EXEC]
683 idx = 0
684 elif self.is_update_ref():
685 items = [UPDATE_REF]
686 idx = 0
687 else:
688 items = COMMANDS
689 idx = COMMAND_IDX[self.command]
690 combo = self.combo = ComboBox()
691 combo.setEditable(False)
692 combo.addItems(items)
693 combo.setCurrentIndex(idx)
694 combo.setEnabled(self.is_commit())
696 signal = combo.currentIndexChanged
697 # pylint: disable=no-member
698 signal.connect(lambda x: self.set_command_and_validate(combo))
699 combo.validate.connect(parent.validate)
701 parent.setItemWidget(self, self.COMMAND_COLUMN, combo)
703 def is_exec(self):
704 return self.command == EXEC
706 def is_update_ref(self):
707 return self.command == UPDATE_REF
709 def is_commit(self):
710 return bool(
711 not (self.is_exec() or self.is_update_ref()) and self.oid and self.summary
714 def value(self):
715 """Return the serialized representation of an item"""
716 if self.is_enabled():
717 comment = ''
718 else:
719 comment = self.comment_char + ' '
720 if self.is_exec():
721 return f'{comment}{self.command} {self.cmdexec}'
722 if self.is_update_ref():
723 return f'{comment}{self.command} {self.branch}'
724 return f'{comment}{self.command} {self.oid} {self.summary}'
726 def is_enabled(self):
727 return self.checkState(self.ENABLED_COLUMN) == Qt.Checked
729 def set_enabled(self, enabled):
730 self.setCheckState(self.ENABLED_COLUMN, enabled and Qt.Checked or Qt.Unchecked)
732 def toggle_enabled(self):
733 self.set_enabled(not self.is_enabled())
735 def add_remark(self, remark):
736 self.set_remarks(tuple(sorted(set(self.remarks + (remark,)))))
738 def remove_remark(self, remark):
739 self.set_remarks(tuple(r for r in self.remarks if r != remark))
741 def set_remarks(self, remarks):
742 self.remarks = remarks
743 self.setText(4, "".join(remarks))
745 def set_command(self, command):
746 """Set the item to a different command, no-op for exec items"""
747 if self.is_exec():
748 return
749 self.command = command
751 def refresh(self):
752 """Update the view to match the updated state"""
753 if self.is_commit():
754 command = self.command
755 self.combo.setCurrentIndex(COMMAND_IDX[command])
757 def reset_command(self, command):
758 """Set and refresh the item in one shot"""
759 self.set_command(command)
760 self.refresh()
762 def set_command_and_validate(self, combo):
763 command = COMMANDS[combo.currentIndex()]
764 self.set_command(command)
765 self.combo.validate.emit()
768 def show_help(context):
769 help_text = N_(
771 Commands
772 --------
773 pick = use commit
774 reword = use commit, but edit the commit message
775 edit = use commit, but stop for amending
776 squash = use commit, but meld into previous commit
777 fixup = like "squash", but discard this commit's log message
778 exec = run command (the rest of the line) using shell
779 update-ref = update branches that point to commits
781 These lines can be re-ordered; they are executed from top to bottom.
783 If you disable a line here THAT COMMIT WILL BE LOST.
785 However, if you disable everything, the rebase will be aborted.
787 Keyboard Shortcuts
788 ------------------
789 ? = show help
790 j = move down
791 k = move up
792 J = shift row down
793 K = shift row up
795 1, p = pick
796 2, r = reword
797 3, e = edit
798 4, f = fixup
799 5, s = squash
800 spacebar = toggle enabled
802 ctrl+enter = accept changes and rebase
803 ctrl+q = cancel and abort the rebase
804 ctrl+d = launch difftool
807 title = N_('Help - git-cola-sequence-editor')
808 return text.text_dialog(context, help_text, title)