3 from argparse
import ArgumentParser
4 from functools
import partial
6 from cola
import app
# prints a message if Qt cannot be found
8 from qtpy
import QtWidgets
9 from qtpy
.QtCore
import Qt
10 from qtpy
.QtCore
import Signal
12 # pylint: disable=ungrouped-imports
14 from cola
import difftool
15 from cola
import gitcmds
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
35 UPDATE_REF
= 'update-ref'
44 COMMAND_IDX
= {cmd_
: idx_
for idx_
, cmd_
in enumerate(COMMANDS
)}
57 """Start a git-cola-sequence-editor session"""
59 context
= app
.application_init(args
)
60 view
= new_window(context
, args
.filename
)
61 app
.application_run(context
, view
, start
=view
.start
, stop
=stop
)
65 def stop(context
, _view
):
66 """All done, cleanup"""
68 context
.runtask
.wait()
72 parser
= ArgumentParser()
74 'filename', metavar
='<filename>', help='git-rebase-todo file to edit'
76 app
.add_common_arguments(parser
)
77 return parser
.parse_args()
80 def new_window(context
, filename
):
81 window
= MainWindow(context
)
82 editor
= Editor(context
, filename
, parent
=window
)
83 window
.set_editor(editor
)
88 """Expand shorthand commands into their full name"""
89 return ABBREV
.get(cmd
, cmd
)
92 class MainWindow(standard
.MainWindow
):
93 """The main git-cola application window"""
95 def __init__(self
, context
, parent
=None):
96 super().__init
__(parent
)
97 self
.context
= context
99 # If the user closes the window without confirmation it's considered cancelled.
100 self
.cancelled
= True
102 default_title
= '%s - git cola sequence editor' % core
.getcwd()
103 title
= core
.getenv('GIT_COLA_SEQ_EDITOR_TITLE', default_title
)
104 self
.setWindowTitle(title
)
105 self
.show_help_action
= qtutils
.add_action(
106 self
, N_('Show Help'), partial(show_help
, context
), hotkeys
.QUESTION
108 self
.menubar
= QtWidgets
.QMenuBar(self
)
109 self
.help_menu
= self
.menubar
.addMenu(N_('Help'))
110 self
.help_menu
.addAction(self
.show_help_action
)
111 self
.setMenuBar(self
.menubar
)
113 qtutils
.add_close_action(self
)
114 self
.init_state(context
.settings
, self
.init_window_size
)
116 def init_window_size(self
):
117 """Set the window size on the first initial view"""
118 if utils
.is_darwin():
119 width
, height
= qtutils
.desktop_size()
120 self
.resize(width
, height
)
124 def set_editor(self
, editor
):
126 self
.setCentralWidget(editor
)
127 editor
.cancel
.connect(self
.close
)
128 editor
.rebase
.connect(self
.rebase
)
131 def start(self
, _context
, _view
):
132 """Start background tasks"""
136 """Stop background tasks"""
140 """Exit the editor and initiate a rebase"""
141 self
.status
= self
.editor
.save()
145 class Editor(QtWidgets
.QWidget
):
149 def __init__(self
, context
, filename
, parent
=None):
150 super().__init
__(parent
)
152 self
.widget_version
= 1
153 self
.context
= context
154 self
.filename
= filename
155 self
.comment_char
= comment_char
= prefs
.comment_char(context
)
157 self
.diff
= diff
.DiffWidget(context
, self
)
158 self
.tree
= RebaseTreeWidget(context
, comment_char
, self
)
159 self
.filewidget
= filelist
.FileWidget(context
, self
, remarks
=True)
160 self
.setFocusProxy(self
.tree
)
162 self
.rebase_button
= qtutils
.create_button(
163 text
=core
.getenv('GIT_COLA_SEQ_EDITOR_ACTION', N_('Rebase')),
164 tooltip
=N_('Accept changes and rebase\nShortcut: Ctrl+Enter'),
169 self
.extdiff_button
= qtutils
.create_button(
170 text
=N_('Launch Diff Tool'),
171 tooltip
=N_('Launch external diff tool\nShortcut: Ctrl+D'),
173 self
.extdiff_button
.setEnabled(False)
175 self
.help_button
= qtutils
.create_button(
176 text
=N_('Help'), tooltip
=N_('Show help\nShortcut: ?'), icon
=icons
.question()
179 self
.cancel_button
= qtutils
.create_button(
181 tooltip
=N_('Cancel rebase\nShortcut: Ctrl+Q'),
185 top
= qtutils
.splitter(Qt
.Horizontal
, self
.tree
, self
.filewidget
)
186 top
.setSizes([75, 25])
188 main_split
= qtutils
.splitter(Qt
.Vertical
, top
, self
.diff
)
189 main_split
.setSizes([25, 75])
191 controls_layout
= qtutils
.hbox(
200 layout
= qtutils
.vbox(defs
.no_margin
, defs
.spacing
, main_split
, controls_layout
)
201 self
.setLayout(layout
)
203 self
.action_rebase
= qtutils
.add_action(
211 self
.tree
.commits_selected
.connect(self
.commits_selected
)
212 self
.tree
.commits_selected
.connect(self
.filewidget
.commits_selected
)
213 self
.tree
.commits_selected
.connect(self
.diff
.commits_selected
)
214 self
.tree
.external_diff
.connect(self
.external_diff
)
216 self
.filewidget
.files_selected
.connect(self
.diff
.files_selected
)
217 self
.filewidget
.remark_toggled
.connect(self
.remark_toggled_for_files
)
219 # `git` calls are expensive. When user toggles a remark of all commits touching
220 # selected paths the GUI freezes for a while on a big enough sequence. This
221 # cache is used (commit ID to paths tuple) to minimize calls to git.
222 self
.oid_to_paths
= {}
223 self
.task
= None # A task fills the cache in the background.
224 self
.running
= False # This flag stops it.
226 qtutils
.connect_button(self
.rebase_button
, self
.rebase
.emit
)
227 qtutils
.connect_button(self
.extdiff_button
, self
.external_diff
)
228 qtutils
.connect_button(self
.help_button
, partial(show_help
, context
))
229 qtutils
.connect_button(self
.cancel_button
, self
.cancel
.emit
)
232 insns
= core
.read(self
.filename
)
233 self
.parse_sequencer_instructions(insns
)
235 # Assume that the tree is filled at this point.
237 self
.task
= qtutils
.SimpleTask(self
.calculate_oid_to_paths
)
238 self
.context
.runtask
.start(self
.task
)
244 def commits_selected(self
, commits
):
245 self
.extdiff_button
.setEnabled(bool(commits
))
247 def remark_toggled_for_files(self
, remark
, filenames
):
248 filenames
= set(filenames
)
250 items
= self
.tree
.items()
254 if not item
.is_commit():
257 paths
= self
.paths_touched_by_oid(oid
)
258 if filenames
.intersection(paths
):
259 touching_items
.append(item
)
261 self
.tree
.toggle_remark_of_items(remark
, touching_items
)
263 def external_diff(self
):
264 items
= self
.tree
.selected_items()
268 difftool
.diff_expression(self
.context
, self
, item
.oid
+ '^!', hide_expr
=True)
272 def paths_touched_by_oid(self
, oid
):
274 return self
.oid_to_paths
[oid
]
278 paths
= gitcmds
.changed_files(self
.context
, oid
)
279 self
.oid_to_paths
[oid
] = paths
283 def calculate_oid_to_paths(self
):
284 """Fills the oid_to_paths cache in the background"""
285 for item
in self
.tree
.items():
288 self
.paths_touched_by_oid(item
.oid
)
290 def parse_sequencer_instructions(self
, insns
):
292 re_comment_char
= re
.escape(self
.comment_char
)
293 exec_rgx
= re
.compile(r
'^\s*(%s)?\s*(x|exec)\s+(.+)$' % re_comment_char
)
294 update_ref_rgx
= re
.compile(
295 r
'^\s*(%s)?\s*(u|update-ref)\s+(.+)$' % re_comment_char
297 # The upper bound of 40 below must match git.OID_LENGTH.
298 # We'll have to update this to the new hash length when that happens.
299 pick_rgx
= re
.compile(
302 + r
'(p|pick|r|reword|e|edit|f|fixup|s|squash)'
303 + r
'\s+([0-9a-f]{7,40})'
308 for line
in insns
.splitlines():
309 match
= pick_rgx
.match(line
)
311 enabled
= match
.group(1) is None
312 command
= unabbrev(match
.group(2))
314 summary
= match
.group(4)
315 self
.tree
.add_item(idx
, enabled
, command
, oid
=oid
, summary
=summary
)
318 match
= exec_rgx
.match(line
)
320 enabled
= match
.group(1) is None
321 command
= unabbrev(match
.group(2))
322 cmdexec
= match
.group(3)
323 self
.tree
.add_item(idx
, enabled
, command
, cmdexec
=cmdexec
)
326 match
= update_ref_rgx
.match(line
)
328 enabled
= match
.group(1) is None
329 command
= unabbrev(match
.group(2))
330 branch
= match
.group(3)
331 self
.tree
.add_item(idx
, enabled
, command
, branch
=branch
)
335 self
.tree
.decorate(self
.tree
.items())
337 self
.tree
.select_first()
339 def save(self
, string
=None):
340 """Save the instruction sheet"""
343 lines
= [item
.value() for item
in self
.tree
.items()]
344 # sequencer instructions
345 string
= '\n'.join(lines
) + '\n'
348 core
.write(self
.filename
, string
)
350 except (OSError, ValueError) as exc
:
351 msg
, details
= utils
.format_exception(exc
)
352 sys
.stderr
.write(msg
+ '\n\n' + details
)
357 # pylint: disable=too-many-ancestors
358 class RebaseTreeWidget(standard
.DraggableTreeWidget
):
359 commits_selected
= Signal(object)
360 external_diff
= Signal()
361 move_rows
= Signal(object, object)
363 def __init__(self
, context
, comment_char
, parent
):
364 super().__init
__(parent
=parent
)
365 self
.context
= context
366 self
.comment_char
= comment_char
368 self
.setHeaderLabels([
376 self
.header().setStretchLastSection(True)
377 self
.setColumnCount(6)
378 self
.setSelectionMode(QtWidgets
.QAbstractItemView
.ExtendedSelection
)
381 self
.copy_oid_action
= qtutils
.add_action(
382 self
, N_('Copy SHA-1'), self
.copy_oid
, QtGui
.QKeySequence
.Copy
385 self
.external_diff_action
= qtutils
.add_action(
386 self
, N_('Launch Diff Tool'), self
.external_diff
.emit
, hotkeys
.DIFF
389 self
.toggle_enabled_action
= qtutils
.add_action(
390 self
, N_('Toggle Enabled'), self
.toggle_enabled
, hotkeys
.PRIMARY_ACTION
393 self
.action_pick
= qtutils
.add_action(
394 self
, N_('Pick'), lambda: self
.set_selected_to(PICK
), *hotkeys
.REBASE_PICK
397 self
.action_reword
= qtutils
.add_action(
400 lambda: self
.set_selected_to(REWORD
),
401 *hotkeys
.REBASE_REWORD
,
404 self
.action_edit
= qtutils
.add_action(
405 self
, N_('Edit'), lambda: self
.set_selected_to(EDIT
), *hotkeys
.REBASE_EDIT
408 self
.action_fixup
= qtutils
.add_action(
411 lambda: self
.set_selected_to(FIXUP
),
412 *hotkeys
.REBASE_FIXUP
,
415 self
.action_squash
= qtutils
.add_action(
418 lambda: self
.set_selected_to(SQUASH
),
419 *hotkeys
.REBASE_SQUASH
,
422 self
.action_shift_down
= qtutils
.add_action(
423 self
, N_('Shift Down'), self
.shift_down
, hotkeys
.MOVE_DOWN_TERTIARY
426 self
.action_shift_up
= qtutils
.add_action(
427 self
, N_('Shift Up'), self
.shift_up
, hotkeys
.MOVE_UP_TERTIARY
430 self
.toggle_remark_actions
= tuple(
434 lambda remark
=r
: self
.toggle_remark(remark
),
435 hotkeys
.hotkey(Qt
.CTRL |
getattr(Qt
, 'Key_' + r
)),
437 for r
in map(str, range(10))
440 # pylint: disable=no-member
441 self
.itemChanged
.connect(self
.item_changed
)
442 self
.itemSelectionChanged
.connect(self
.selection_changed
)
443 self
.move_rows
.connect(self
.move
)
444 self
.items_moved
.connect(self
.decorate
)
447 self
, idx
, enabled
, command
, oid
='', summary
='', cmdexec
='', branch
=''
449 comment_char
= self
.comment_char
450 item
= RebaseTreeWidgetItem(
458 comment_char
=comment_char
,
461 self
.invisibleRootItem().addChild(item
)
463 def decorate(self
, items
):
468 """Resize columns to fit content"""
469 for i
in range(RebaseTreeWidgetItem
.COLUMN_COUNT
- 1):
470 self
.resizeColumnToContents(i
)
472 def item_changed(self
, item
, column
):
473 """Validate item ordering when toggling their enabled state"""
474 if column
== item
.ENABLED_COLUMN
:
478 invalid_first_choice
= {FIXUP
, SQUASH
}
479 for item
in self
.items():
480 if item
.is_enabled() and item
.is_commit():
481 if item
.command
in invalid_first_choice
:
482 item
.reset_command(PICK
)
485 def set_selected_to(self
, command
):
486 for i
in self
.selected_items():
487 i
.reset_command(command
)
490 def set_command(self
, item
, command
):
491 item
.reset_command(command
)
495 item
= self
.selected_item()
498 clipboard
= item
.oid
or item
.cmdexec
499 qtutils
.set_clipboard(clipboard
)
501 def selection_changed(self
):
502 item
= self
.selected_item()
503 if item
is None or not item
.is_commit():
505 context
= self
.context
507 params
= dag
.DAG(oid
, 2)
508 repo
= dag
.RepoReader(context
, params
)
510 for commit
in repo
.get():
511 commits
.append(commit
)
513 commits
= commits
[-1:]
514 self
.commits_selected
.emit(commits
)
516 def toggle_enabled(self
):
517 """Toggle the enabled state of each selected item"""
518 for item
in self
.selected_items():
519 item
.toggle_enabled()
521 def select_first(self
):
525 idx
= self
.model().index(0, 0)
527 self
.setCurrentIndex(idx
)
529 def shift_down(self
):
530 sel_items
= self
.selected_items()
531 all_items
= self
.items()
532 sel_idx
= sorted([all_items
.index(item
) for item
in sel_items
])
537 idx
> len(all_items
) - len(sel_items
)
538 or all_items
[sel_idx
[-1]] is all_items
[-1]
540 self
.move_rows
.emit(sel_idx
, idx
)
543 sel_items
= self
.selected_items()
544 all_items
= self
.items()
545 sel_idx
= sorted([all_items
.index(item
) for item
in sel_items
])
550 self
.move_rows
.emit(sel_idx
, idx
)
552 def toggle_remark(self
, remark
):
553 """Toggle remarks for all selected items"""
554 items
= self
.selected_items()
555 self
.toggle_remark_of_items(remark
, items
)
557 def toggle_remark_of_items(self
, remark
, items
):
558 """Toggle remarks for the specified items"""
560 if remark
in item
.remarks
:
561 item
.remove_remark(remark
)
563 item
.add_remark(remark
)
565 def move(self
, src_idxs
, dst_idx
):
567 src_base
= sorted(src_idxs
)[0]
568 for idx
in reversed(sorted(src_idxs
)):
569 item
= self
.invisibleRootItem().takeChild(idx
)
570 moved_items
.insert(0, [dst_idx
+ (idx
- src_base
), item
])
572 for item
in moved_items
:
573 self
.invisibleRootItem().insertChild(item
[0], item
[1])
574 self
.setCurrentItem(item
[1])
577 moved_items
= [item
[1] for item
in moved_items
]
578 # If we've moved to the top then we need to re-decorate all items.
579 # Otherwise, we can decorate just the new items.
581 self
.decorate(self
.items())
583 self
.decorate(moved_items
)
585 for item
in moved_items
:
586 item
.setSelected(True)
591 def dropEvent(self
, event
):
592 super().dropEvent(event
)
595 def contextMenuEvent(self
, event
):
596 items
= self
.selected_items()
597 menu
= qtutils
.create_menu(N_('Actions'), self
)
598 menu
.addAction(self
.action_pick
)
599 menu
.addAction(self
.action_reword
)
600 menu
.addAction(self
.action_edit
)
601 menu
.addAction(self
.action_fixup
)
602 menu
.addAction(self
.action_squash
)
604 menu
.addAction(self
.toggle_enabled_action
)
606 menu
.addAction(self
.copy_oid_action
)
607 self
.copy_oid_action
.setDisabled(len(items
) > 1)
608 menu
.addAction(self
.external_diff_action
)
609 self
.external_diff_action
.setDisabled(len(items
) > 1)
611 menu_toggle_remark
= menu
.addMenu(N_('Toggle Remark'))
612 for action
in self
.toggle_remark_actions
:
613 menu_toggle_remark
.addAction(action
)
614 menu
.exec_(self
.mapToGlobal(event
.pos()))
617 class ComboBox(QtWidgets
.QComboBox
):
621 class RebaseTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
622 """A single data row in the rebase tree widget"""
634 '0': ('white', 'darkred'),
635 '1': ('black', 'salmon'),
636 '2': ('black', 'sandybrown'),
637 '3': ('black', 'yellow'),
638 '4': ('black', 'yellowgreen'),
639 '5': ('white', 'forestgreen'),
640 '6': ('white', 'dodgerblue'),
641 '7': ('white', 'royalblue'),
642 '8': ('white', 'slateblue'),
643 '9': ('black', 'rosybrown'),
659 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
661 self
.command
= command
664 self
.summary
= summary
665 self
.cmdexec
= cmdexec
667 self
.comment_char
= comment_char
668 self
._parent
= parent
670 # if core.abbrev is set to a higher value then we will notice by
671 # simply tracking the longest oid we've seen
672 oid_len
= self
.OID_LENGTH
673 self
.__class
__.OID_LENGTH
= max(len(oid
), oid_len
)
675 self
.setText(self
.NUMBER_COLUMN
, '%02d' % idx
)
676 self
.set_enabled(enabled
)
680 self
.setText(self
.COMMIT_COLUMN
, '')
681 self
.setText(self
.SUMMARY_COLUMN
, cmdexec
)
682 elif self
.is_update_ref():
683 self
.setText(self
.COMMIT_COLUMN
, '')
684 self
.setText(self
.SUMMARY_COLUMN
, branch
)
686 self
.setText(self
.COMMIT_COLUMN
, oid
)
687 self
.setText(self
.SUMMARY_COLUMN
, summary
)
689 self
.set_remarks(remarks
)
691 flags
= self
.flags() | Qt
.ItemIsUserCheckable
692 flags
= flags | Qt
.ItemIsDragEnabled
693 flags
= flags
& ~Qt
.ItemIsDropEnabled
696 def __eq__(self
, other
):
703 return self
.__class
__(
708 summary
=self
.summary
,
709 cmdexec
=self
.cmdexec
,
711 comment_char
=self
.comment_char
,
712 remarks
=self
.remarks
,
715 def decorate(self
, parent
):
719 elif self
.is_update_ref():
724 idx
= COMMAND_IDX
[self
.command
]
725 combo
= self
.combo
= ComboBox()
726 combo
.setEditable(False)
727 combo
.addItems(items
)
728 combo
.setCurrentIndex(idx
)
729 combo
.setEnabled(self
.is_commit())
731 signal
= combo
.currentIndexChanged
732 # pylint: disable=no-member
733 signal
.connect(lambda x
: self
.set_command_and_validate(combo
))
734 combo
.validate
.connect(parent
.validate
)
736 parent
.setItemWidget(self
, self
.COMMAND_COLUMN
, combo
)
739 return self
.command
== EXEC
741 def is_update_ref(self
):
742 return self
.command
== UPDATE_REF
746 not (self
.is_exec() or self
.is_update_ref()) and self
.oid
and self
.summary
750 """Return the serialized representation of an item"""
751 if self
.is_enabled():
754 comment
= self
.comment_char
+ ' '
756 return f
'{comment}{self.command} {self.cmdexec}'
757 if self
.is_update_ref():
758 return f
'{comment}{self.command} {self.branch}'
759 return f
'{comment}{self.command} {self.oid} {self.summary}'
761 def is_enabled(self
):
762 """Is the item enabled?"""
763 return self
.checkState(self
.ENABLED_COLUMN
) == Qt
.Checked
765 def set_enabled(self
, enabled
):
766 """Enable the item by checking its enabled checkbox"""
767 self
.setCheckState(self
.ENABLED_COLUMN
, enabled
and Qt
.Checked
or Qt
.Unchecked
)
769 def toggle_enabled(self
):
770 """Toggle the enabled state of the item"""
771 self
.set_enabled(not self
.is_enabled())
773 def add_remark(self
, remark
):
774 """Add a remark to the item"""
775 self
.set_remarks(tuple(sorted(set(self
.remarks
+ (remark
,)))))
777 def remove_remark(self
, remark
):
778 """Remove a remark from the item"""
779 self
.set_remarks(tuple(r
for r
in self
.remarks
if r
!= remark
))
781 def set_remarks(self
, remarks
):
782 """Set the remarks and update the remark display"""
783 self
.remarks
= remarks
784 label
= QtWidgets
.QLabel()
786 for remark
in remarks
:
787 fg_color
, bg_color
= self
.COLORS
[remark
]
791 background-color: {bg_color};
792 "> {remark} </span>
795 self
._parent
.setItemWidget(self
, self
.REMARKS_COLUMN
, label
)
796 self
._parent
.resizeColumnToContents(self
.REMARKS_COLUMN
)
798 def set_command(self
, command
):
799 """Set the item to a different command, no-op for exec items"""
802 self
.command
= command
805 """Update the view to match the updated state"""
807 command
= self
.command
808 self
.combo
.setCurrentIndex(COMMAND_IDX
[command
])
810 def reset_command(self
, command
):
811 """Set and refresh the item in one shot"""
812 self
.set_command(command
)
815 def set_command_and_validate(self
, combo
):
816 """Set the command and validate the command order"""
817 command
= COMMANDS
[combo
.currentIndex()]
818 self
.set_command(command
)
819 self
.combo
.validate
.emit()
822 def show_help(context
):
828 reword = use commit, but edit the commit message
829 edit = use commit, but stop for amending
830 squash = use commit, but meld into previous commit
831 fixup = like "squash", but discard this commit's log message
832 exec = run command (the rest of the line) using shell
833 update-ref = update branches that point to commits
835 These lines can be re-ordered; they are executed from top to bottom.
837 If you disable a line here THAT COMMIT WILL BE LOST.
839 However, if you disable everything, the rebase will be aborted.
854 spacebar = toggle enabled
856 ctrl+enter = accept changes and rebase
857 ctrl+q = cancel and abort the rebase
858 ctrl+d = launch difftool
861 title
= N_('Help - git-cola-sequence-editor')
862 return text
.text_dialog(context
, help_text
, title
)