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
,
460 self
.invisibleRootItem().addChild(item
)
462 def decorate(self
, items
):
467 """Resize columns to fit content"""
468 for i
in range(RebaseTreeWidgetItem
.COLUMN_COUNT
- 1):
469 self
.resizeColumnToContents(i
)
471 def item_changed(self
, item
, column
):
472 """Validate item ordering when toggling their enabled state"""
473 if column
== item
.ENABLED_COLUMN
:
477 invalid_first_choice
= {FIXUP
, SQUASH
}
478 for item
in self
.items():
479 if item
.is_enabled() and item
.is_commit():
480 if item
.command
in invalid_first_choice
:
481 item
.reset_command(PICK
)
484 def set_selected_to(self
, command
):
485 for i
in self
.selected_items():
486 i
.reset_command(command
)
489 def set_command(self
, item
, command
):
490 item
.reset_command(command
)
494 item
= self
.selected_item()
497 clipboard
= item
.oid
or item
.cmdexec
498 qtutils
.set_clipboard(clipboard
)
500 def selection_changed(self
):
501 item
= self
.selected_item()
502 if item
is None or not item
.is_commit():
504 context
= self
.context
506 params
= dag
.DAG(oid
, 2)
507 repo
= dag
.RepoReader(context
, params
)
509 for commit
in repo
.get():
510 commits
.append(commit
)
512 commits
= commits
[-1:]
513 self
.commits_selected
.emit(commits
)
515 def toggle_enabled(self
):
516 """Toggle the enabled state of each selected item"""
517 for item
in self
.selected_items():
518 item
.toggle_enabled()
520 def select_first(self
):
524 idx
= self
.model().index(0, 0)
526 self
.setCurrentIndex(idx
)
528 def shift_down(self
):
529 sel_items
= self
.selected_items()
530 all_items
= self
.items()
531 sel_idx
= sorted([all_items
.index(item
) for item
in sel_items
])
536 idx
> len(all_items
) - len(sel_items
)
537 or all_items
[sel_idx
[-1]] is all_items
[-1]
539 self
.move_rows
.emit(sel_idx
, idx
)
542 sel_items
= self
.selected_items()
543 all_items
= self
.items()
544 sel_idx
= sorted([all_items
.index(item
) for item
in sel_items
])
549 self
.move_rows
.emit(sel_idx
, idx
)
551 def toggle_remark(self
, remark
):
552 """Toggle remarks for all selected items"""
553 items
= self
.selected_items()
554 self
.toggle_remark_of_items(remark
, items
)
556 def toggle_remark_of_items(self
, remark
, items
):
557 """Toggle remarks for the specified items"""
559 if remark
in item
.remarks
:
560 item
.remove_remark(remark
)
562 item
.add_remark(remark
)
564 def move(self
, src_idxs
, dst_idx
):
566 src_base
= sorted(src_idxs
)[0]
567 for idx
in reversed(sorted(src_idxs
)):
568 item
= self
.invisibleRootItem().takeChild(idx
)
569 moved_items
.insert(0, [dst_idx
+ (idx
- src_base
), item
])
571 for item
in moved_items
:
572 self
.invisibleRootItem().insertChild(item
[0], item
[1])
573 self
.setCurrentItem(item
[1])
576 moved_items
= [item
[1] for item
in moved_items
]
577 # If we've moved to the top then we need to re-decorate all items.
578 # Otherwise, we can decorate just the new items.
580 self
.decorate(self
.items())
582 self
.decorate(moved_items
)
584 for item
in moved_items
:
585 item
.setSelected(True)
590 def dropEvent(self
, event
):
591 super().dropEvent(event
)
594 def contextMenuEvent(self
, event
):
595 items
= self
.selected_items()
596 menu
= qtutils
.create_menu(N_('Actions'), self
)
597 menu
.addAction(self
.action_pick
)
598 menu
.addAction(self
.action_reword
)
599 menu
.addAction(self
.action_edit
)
600 menu
.addAction(self
.action_fixup
)
601 menu
.addAction(self
.action_squash
)
603 menu
.addAction(self
.toggle_enabled_action
)
605 menu
.addAction(self
.copy_oid_action
)
606 self
.copy_oid_action
.setDisabled(len(items
) > 1)
607 menu
.addAction(self
.external_diff_action
)
608 self
.external_diff_action
.setDisabled(len(items
) > 1)
610 menu_toggle_remark
= menu
.addMenu(N_('Toggle Remark'))
611 for action
in self
.toggle_remark_actions
:
612 menu_toggle_remark
.addAction(action
)
613 menu
.exec_(self
.mapToGlobal(event
.pos()))
616 class ComboBox(QtWidgets
.QComboBox
):
620 class RebaseTreeWidgetItem(QtWidgets
.QTreeWidgetItem
):
621 """A single data row in the rebase tree widget"""
645 QtWidgets
.QTreeWidgetItem
.__init
__(self
, parent
)
647 self
.command
= command
650 self
.summary
= summary
651 self
.cmdexec
= cmdexec
653 self
.comment_char
= comment_char
655 # if core.abbrev is set to a higher value then we will notice by
656 # simply tracking the longest oid we've seen
657 oid_len
= self
.OID_LENGTH
658 self
.__class
__.OID_LENGTH
= max(len(oid
), oid_len
)
660 self
.setText(self
.NUMBER_COLUMN
, '%02d' % idx
)
661 self
.set_enabled(enabled
)
665 self
.setText(self
.COMMIT_COLUMN
, '')
666 self
.setText(self
.SUMMARY_COLUMN
, cmdexec
)
667 elif self
.is_update_ref():
668 self
.setText(self
.COMMIT_COLUMN
, '')
669 self
.setText(self
.SUMMARY_COLUMN
, branch
)
671 self
.setText(self
.COMMIT_COLUMN
, oid
)
672 self
.setText(self
.SUMMARY_COLUMN
, summary
)
674 self
.set_remarks(remarks
)
676 flags
= self
.flags() | Qt
.ItemIsUserCheckable
677 flags
= flags | Qt
.ItemIsDragEnabled
678 flags
= flags
& ~Qt
.ItemIsDropEnabled
681 def __eq__(self
, other
):
688 return self
.__class
__(
693 summary
=self
.summary
,
694 cmdexec
=self
.cmdexec
,
696 comment_char
=self
.comment_char
,
697 remarks
=self
.remarks
,
700 def decorate(self
, parent
):
704 elif self
.is_update_ref():
709 idx
= COMMAND_IDX
[self
.command
]
710 combo
= self
.combo
= ComboBox()
711 combo
.setEditable(False)
712 combo
.addItems(items
)
713 combo
.setCurrentIndex(idx
)
714 combo
.setEnabled(self
.is_commit())
716 signal
= combo
.currentIndexChanged
717 # pylint: disable=no-member
718 signal
.connect(lambda x
: self
.set_command_and_validate(combo
))
719 combo
.validate
.connect(parent
.validate
)
721 parent
.setItemWidget(self
, self
.COMMAND_COLUMN
, combo
)
724 return self
.command
== EXEC
726 def is_update_ref(self
):
727 return self
.command
== UPDATE_REF
731 not (self
.is_exec() or self
.is_update_ref()) and self
.oid
and self
.summary
735 """Return the serialized representation of an item"""
736 if self
.is_enabled():
739 comment
= self
.comment_char
+ ' '
741 return f
'{comment}{self.command} {self.cmdexec}'
742 if self
.is_update_ref():
743 return f
'{comment}{self.command} {self.branch}'
744 return f
'{comment}{self.command} {self.oid} {self.summary}'
746 def is_enabled(self
):
747 """Is the item enabled?"""
748 return self
.checkState(self
.ENABLED_COLUMN
) == Qt
.Checked
750 def set_enabled(self
, enabled
):
751 """Enable the item by checking its enabled checkbox"""
752 self
.setCheckState(self
.ENABLED_COLUMN
, enabled
and Qt
.Checked
or Qt
.Unchecked
)
754 def toggle_enabled(self
):
755 """Toggle the enabled state of the item"""
756 self
.set_enabled(not self
.is_enabled())
758 def add_remark(self
, remark
):
759 """Add a remark to the item"""
760 self
.set_remarks(tuple(sorted(set(self
.remarks
+ (remark
,)))))
762 def remove_remark(self
, remark
):
763 """Remove a remark from the item"""
764 self
.set_remarks(tuple(r
for r
in self
.remarks
if r
!= remark
))
766 def set_remarks(self
, remarks
):
767 """Set the remarks and update the remark text display"""
768 self
.remarks
= remarks
769 self
.setText(self
.REMARKS_COLUMN
, ''.join(remarks
))
771 def set_command(self
, command
):
772 """Set the item to a different command, no-op for exec items"""
775 self
.command
= command
778 """Update the view to match the updated state"""
780 command
= self
.command
781 self
.combo
.setCurrentIndex(COMMAND_IDX
[command
])
783 def reset_command(self
, command
):
784 """Set and refresh the item in one shot"""
785 self
.set_command(command
)
788 def set_command_and_validate(self
, combo
):
789 """Set the command and validate the command order"""
790 command
= COMMANDS
[combo
.currentIndex()]
791 self
.set_command(command
)
792 self
.combo
.validate
.emit()
795 def show_help(context
):
801 reword = use commit, but edit the commit message
802 edit = use commit, but stop for amending
803 squash = use commit, but meld into previous commit
804 fixup = like "squash", but discard this commit's log message
805 exec = run command (the rest of the line) using shell
806 update-ref = update branches that point to commits
808 These lines can be re-ordered; they are executed from top to bottom.
810 If you disable a line here THAT COMMIT WILL BE LOST.
812 However, if you disable everything, the rebase will be aborted.
827 spacebar = toggle enabled
829 ctrl+enter = accept changes and rebase
830 ctrl+q = cancel and abort the rebase
831 ctrl+d = launch difftool
834 title
= N_('Help - git-cola-sequence-editor')
835 return text
.text_dialog(context
, help_text
, title
)