3 from qtpy
.QtCore
import Qt
4 from qtpy
.QtCore
import Signal
5 from qtpy
import QtCore
7 from qtpy
import QtWidgets
9 from ..models
.browse
import GitRepoModel
10 from ..models
.browse
import GitRepoNameItem
11 from ..models
.selection
import State
13 from ..interaction
import Interaction
16 from .. import difftool
17 from .. import gitcmds
18 from .. import hotkeys
21 from .. import qtutils
22 from .selectcommits
import select_commits
25 from . import standard
28 def worktree_browser(context
, parent
=None, update
=True, show
=True):
29 """Create a new worktree browser"""
30 view
= Browser(context
, parent
, update
=update
)
32 context
.browser_windows
.append(view
)
33 view
.closed
.connect(context
.browser_windows
.remove
)
34 model
= GitRepoModel(context
, view
.tree
)
43 def save_path(context
, path
, model
):
44 """Choose an output filename based on the selected path"""
45 filename
= qtutils
.save_as(path
)
47 model
.filename
= filename
48 cmds
.do(SaveBlob
, context
, model
)
55 class Browser(standard
.Widget
):
56 """A repository branch file browser. Browses files provided by GitRepoModel"""
58 # Read-only mode property
59 mode
= property(lambda self
: self
.model
.mode
)
61 def __init__(self
, context
, parent
, update
=True):
62 standard
.Widget
.__init
__(self
, parent
)
63 self
.tree
= RepoTreeView(context
, self
)
64 self
.mainlayout
= qtutils
.hbox(defs
.no_margin
, defs
.spacing
, self
.tree
)
65 self
.setLayout(self
.mainlayout
)
67 self
.model
= context
.model
68 self
.model
.updated
.connect(self
._updated
_callback
, type=Qt
.QueuedConnection
)
70 qtutils
.add_close_action(self
)
72 self
._updated
_callback
()
74 self
.init_state(context
.settings
, self
.resize
, 720, 420)
76 def set_model(self
, model
):
78 self
.tree
.set_model(model
)
81 """Refresh the model triggering view updates"""
84 def _updated_callback(self
):
85 branch
= self
.model
.currentbranch
86 curdir
= core
.getcwd()
87 msg
= N_('Repository: %s') % curdir
89 msg
+= N_('Branch: %s') % branch
93 'project': self
.model
.project
,
96 title
= N_('%(project)s: %(branch)s - Browse') % scope
97 if self
.mode
== self
.model
.mode_amend
:
98 title
+= ' %s' % N_('(Amending)')
99 self
.setWindowTitle(title
)
102 # pylint: disable=too-many-ancestors
103 class RepoTreeView(standard
.TreeView
):
104 """Provides a filesystem-like view of a git repository."""
106 def __init__(self
, context
, parent
):
107 standard
.TreeView
.__init
__(self
, parent
)
109 self
.context
= context
110 self
.selection
= context
.selection
111 self
.saved_selection
= []
112 self
.saved_current_path
= None
113 self
.saved_open_folders
= set()
114 self
.restoring_selection
= False
115 self
._columns
_sized
= False
117 self
.setDragEnabled(True)
118 self
.setRootIsDecorated(False)
119 self
.setSortingEnabled(False)
120 self
.setSelectionMode(self
.ExtendedSelection
)
122 # Observe model updates
123 model
= context
.model
124 model
.about_to_update
.connect(self
.save_selection
, type=Qt
.QueuedConnection
)
125 model
.updated
.connect(self
.update_actions
, type=Qt
.QueuedConnection
)
126 self
.expanded
.connect(self
.index_expanded
)
128 self
.collapsed
.connect(lambda idx
: self
.size_columns())
129 self
.collapsed
.connect(self
.index_collapsed
)
131 # Sync selection before the key press event changes the model index
132 queued
= Qt
.QueuedConnection
133 self
.index_about_to_change
.connect(self
.sync_selection
, type=queued
)
135 self
.action_history
= qtutils
.add_action_with_tooltip(
137 N_('View History...'),
138 N_('View history for selected paths'),
143 self
.action_stage
= qtutils
.add_action_with_tooltip(
145 cmds
.StageOrUnstage
.name(),
146 N_('Stage/unstage selected paths for commit'),
147 cmds
.run(cmds
.StageOrUnstage
, context
),
148 hotkeys
.STAGE_SELECTION
,
151 self
.action_untrack
= qtutils
.add_action_with_tooltip(
153 N_('Untrack Selected'),
154 N_('Stop tracking paths'),
155 self
.untrack_selected
,
158 self
.action_rename
= qtutils
.add_action_with_tooltip(
159 self
, N_('Rename'), N_('Rename selected paths'), self
.rename_selected
162 self
.action_difftool
= qtutils
.add_action_with_tooltip(
164 difftool
.LaunchDifftool
.name(),
165 N_('Launch git-difftool on the current path'),
166 cmds
.run(difftool
.LaunchDifftool
, context
),
170 self
.action_difftool_predecessor
= qtutils
.add_action_with_tooltip(
172 N_('Diff Against Predecessor...'),
173 N_('Launch git-difftool against previous versions'),
174 self
.diff_predecessor
,
175 hotkeys
.DIFF_SECONDARY
,
178 self
.action_revert_unstaged
= qtutils
.add_action_with_tooltip(
180 cmds
.RevertUnstagedEdits
.name(),
181 N_('Revert unstaged changes to selected paths'),
182 cmds
.run(cmds
.RevertUnstagedEdits
, context
),
187 self
.action_revert_uncommitted
= qtutils
.add_action_with_tooltip(
189 cmds
.RevertUncommittedEdits
.name(),
190 N_('Revert uncommitted changes to selected paths'),
191 cmds
.run(cmds
.RevertUncommittedEdits
, context
),
195 self
.action_editor
= qtutils
.add_action_with_tooltip(
197 cmds
.LaunchEditor
.name(),
198 N_('Edit selected paths'),
199 cmds
.run(cmds
.LaunchEditor
, context
),
203 self
.action_blame
= qtutils
.add_action_with_tooltip(
205 cmds
.BlamePaths
.name(),
206 N_('Blame selected paths'),
207 cmds
.run(cmds
.BlamePaths
, context
),
210 self
.action_refresh
= common
.refresh_action(context
, self
)
212 self
.action_default_app
= common
.default_app_action(
213 context
, self
, self
.selected_paths
216 self
.action_parent_dir
= common
.parent_dir_action(
217 context
, self
, self
.selected_paths
220 self
.action_terminal
= common
.terminal_action(
221 context
, self
, func
=self
.selected_paths
224 self
.x_width
= qtutils
.text_width(self
.font(), 'x')
225 self
.size_columns(force
=True)
227 def index_expanded(self
, index
):
228 """Update information about a directory as it is expanded."""
229 # Remember open folders so that we can restore them when refreshing
230 item
= self
.name_item_from_index(index
)
231 self
.saved_open_folders
.add(item
.path
)
234 # update information about a directory as it is expanded
241 model
.update_entry(path
)
243 for row
in range(item
.rowCount()):
244 path
= item
.child(row
, 0).path
245 model
.update_entry(path
)
249 def index_collapsed(self
, index
):
250 item
= self
.name_item_from_index(index
)
251 self
.saved_open_folders
.remove(item
.path
)
254 self
.model().refresh()
256 def size_columns(self
, force
=False):
257 """Set the column widths."""
258 cfg
= self
.context
.cfg
259 should_resize
= cfg
.get('cola.resizebrowsercolumns', default
=False)
260 if not force
and not should_resize
:
262 self
.resizeColumnToContents(0)
263 self
.resizeColumnToContents(1)
264 self
.resizeColumnToContents(2)
265 self
.resizeColumnToContents(3)
266 self
.resizeColumnToContents(4)
268 def sizeHintForColumn(self
, column
):
269 x_width
= self
.x_width
284 # Filename and others use the actual content
285 size
= super().sizeHintForColumn(column
)
288 def save_selection(self
):
289 selection
= self
.selected_paths()
291 self
.saved_selection
= selection
293 current
= self
.current_item()
295 self
.saved_current_path
= current
.path
298 selection
= self
.selectionModel()
299 flags
= selection
.Select | selection
.Rows
301 self
.restoring_selection
= True
303 # Restore opened folders
305 for path
in sorted(self
.saved_open_folders
):
306 row
= model
.get(path
)
309 index
= row
[0].index()
311 self
.setExpanded(index
, True)
313 # Restore the current item. We do this first, otherwise
314 # setCurrentIndex() can mess with the selection we set below
316 current_path
= self
.saved_current_path
318 row
= model
.get(current_path
)
320 current_index
= row
[0].index()
322 if current_index
and current_index
.isValid():
323 self
.setCurrentIndex(current_index
)
325 # Restore selected items
326 for path
in self
.saved_selection
:
327 row
= model
.get(path
)
330 index
= row
[0].index()
333 selection
.select(index
, flags
)
335 self
.restoring_selection
= False
337 # Resize the columns once when cola.resizebrowsercolumns is False.
338 # This provides a good initial size since we will not be resizing
339 # the columns during expand/collapse.
340 if not self
._columns
_sized
:
341 self
._columns
_sized
= True
342 self
.size_columns(force
=True)
346 def update_actions(self
):
347 """Enable/disable actions."""
348 selection
= self
.selected_paths()
349 selected
= bool(selection
)
350 staged
= bool(self
.selected_staged_paths(selection
=selection
))
351 modified
= bool(self
.selected_modified_paths(selection
=selection
))
352 unstaged
= bool(self
.selected_unstaged_paths(selection
=selection
))
353 tracked
= bool(self
.selected_tracked_paths(selection
=selection
))
354 revertable
= staged
or modified
356 self
.action_editor
.setEnabled(selected
)
357 self
.action_history
.setEnabled(selected
)
358 self
.action_default_app
.setEnabled(selected
)
359 self
.action_parent_dir
.setEnabled(selected
)
361 if self
.action_terminal
is not None:
362 self
.action_terminal
.setEnabled(selected
)
364 self
.action_stage
.setEnabled(staged
or unstaged
)
365 self
.action_untrack
.setEnabled(tracked
)
366 self
.action_rename
.setEnabled(tracked
)
367 self
.action_difftool
.setEnabled(staged
or modified
)
368 self
.action_difftool_predecessor
.setEnabled(tracked
)
369 self
.action_revert_unstaged
.setEnabled(revertable
)
370 self
.action_revert_uncommitted
.setEnabled(revertable
)
372 def contextMenuEvent(self
, event
):
373 """Create a context menu."""
374 self
.update_actions()
375 menu
= qtutils
.create_menu(N_('Actions'), self
)
376 menu
.addAction(self
.action_editor
)
377 menu
.addAction(self
.action_stage
)
379 menu
.addAction(self
.action_history
)
380 menu
.addAction(self
.action_difftool
)
381 menu
.addAction(self
.action_difftool_predecessor
)
382 menu
.addAction(self
.action_blame
)
384 menu
.addAction(self
.action_revert_unstaged
)
385 menu
.addAction(self
.action_revert_uncommitted
)
386 menu
.addAction(self
.action_untrack
)
387 menu
.addAction(self
.action_rename
)
389 menu
.addAction(self
.action_default_app
)
390 menu
.addAction(self
.action_parent_dir
)
392 if self
.action_terminal
is not None:
393 menu
.addAction(self
.action_terminal
)
394 menu
.exec_(self
.mapToGlobal(event
.pos()))
396 def mousePressEvent(self
, event
):
397 """Synchronize the selection on mouse-press."""
398 result
= QtWidgets
.QTreeView
.mousePressEvent(self
, event
)
399 self
.sync_selection()
402 def sync_selection(self
):
403 """Push selection into the selection model."""
408 state
= State(staged
, unmerged
, modified
, untracked
)
410 paths
= self
.selected_paths()
411 model
= self
.context
.model
412 model_staged
= utils
.add_parents(model
.staged
)
413 model_modified
= utils
.add_parents(model
.modified
)
414 model_unmerged
= utils
.add_parents(model
.unmerged
)
415 model_untracked
= utils
.add_parents(model
.untracked
)
418 if path
in model_unmerged
:
419 unmerged
.append(path
)
420 elif path
in model_untracked
:
421 untracked
.append(path
)
422 elif path
in model_staged
:
424 elif path
in model_modified
:
425 modified
.append(path
)
428 # Push the new selection into the model.
429 self
.selection
.set_selection(state
)
432 def selectionChanged(self
, old
, new
):
433 """Override selectionChanged to update available actions."""
434 result
= QtWidgets
.QTreeView
.selectionChanged(self
, old
, new
)
435 if not self
.restoring_selection
:
436 self
.update_actions()
440 def update_diff(self
):
441 context
= self
.context
442 model
= context
.model
443 paths
= self
.sync_selection()
444 if paths
and self
.model().path_is_interesting(paths
[0]):
445 cached
= paths
[0] in model
.staged
446 cmds
.do(cmds
.Diff
, context
, paths
[0], cached
)
448 def set_model(self
, model
):
449 """Set the concrete QAbstractItemModel instance."""
451 model
.restore
.connect(self
.restore
, type=Qt
.QueuedConnection
)
453 def name_item_from_index(self
, model_index
):
454 """Return the name item corresponding to the model index."""
455 index
= model_index
.sibling(model_index
.row(), 0)
456 return self
.model().itemFromIndex(index
)
458 def paths_from_indexes(self
, indexes
):
459 return qtutils
.paths_from_indexes(
460 self
.model(), indexes
, item_type
=GitRepoNameItem
.TYPE
463 def selected_paths(self
):
464 """Return the selected paths."""
465 return self
.paths_from_indexes(self
.selectedIndexes())
467 def selected_staged_paths(self
, selection
=None):
468 """Return selected staged paths."""
469 if selection
is None:
470 selection
= self
.selected_paths()
471 model
= self
.context
.model
472 staged
= utils
.add_parents(model
.staged
)
473 return [p
for p
in selection
if p
in staged
]
475 def selected_modified_paths(self
, selection
=None):
476 """Return selected modified paths."""
477 if selection
is None:
478 selection
= self
.selected_paths()
479 model
= self
.context
.model
480 modified
= utils
.add_parents(model
.modified
)
481 return [p
for p
in selection
if p
in modified
]
483 def selected_unstaged_paths(self
, selection
=None):
484 """Return selected unstaged paths."""
485 if selection
is None:
486 selection
= self
.selected_paths()
487 model
= self
.context
.model
488 modified
= utils
.add_parents(model
.modified
)
489 untracked
= utils
.add_parents(model
.untracked
)
490 unstaged
= modified
.union(untracked
)
491 return [p
for p
in selection
if p
in unstaged
]
493 def selected_tracked_paths(self
, selection
=None):
494 """Return selected tracked paths."""
495 if selection
is None:
496 selection
= self
.selected_paths()
497 model
= self
.context
.model
498 staged
= set(self
.selected_staged_paths(selection
=selection
))
499 modified
= set(self
.selected_modified_paths(selection
=selection
))
500 untracked
= utils
.add_parents(model
.untracked
)
501 tracked
= staged
.union(modified
)
502 return [p
for p
in selection
if p
not in untracked
or p
in tracked
]
504 def view_history(self
):
505 """Launch the configured history browser path-limited to entries."""
506 paths
= self
.selected_paths()
507 cmds
.do(cmds
.VisualizePaths
, self
.context
, paths
)
509 def untrack_selected(self
):
510 """Untrack selected paths."""
511 context
= self
.context
512 cmds
.do(cmds
.Untrack
, context
, self
.selected_tracked_paths())
514 def rename_selected(self
):
515 """Untrack selected paths."""
516 context
= self
.context
517 cmds
.do(cmds
.Rename
, context
, self
.selected_tracked_paths())
519 def diff_predecessor(self
):
520 """Diff paths against previous versions."""
521 context
= self
.context
522 paths
= self
.selected_tracked_paths()
523 args
= ['--'] + paths
524 revs
, summaries
= gitcmds
.log_helper(context
, all
=False, extra_args
=args
)
525 commits
= select_commits(
526 context
, N_('Select Previous Version'), revs
, summaries
, multiselect
=False
531 difftool
.difftool_launch(context
, left
=commit
, paths
=paths
)
533 def current_path(self
):
534 """Return the path for the current item."""
535 index
= self
.currentIndex()
536 if not index
.isValid():
538 return self
.name_item_from_index(index
).path
542 """Context data used for browsing branches via git-ls-tree"""
544 def __init__(self
, ref
, filename
=None):
546 self
.relpath
= filename
547 self
.filename
= filename
550 class SaveBlob(cmds
.ContextCommand
):
551 def __init__(self
, context
, model
):
552 super().__init
__(context
)
553 self
.browse_model
= model
556 git
= self
.context
.git
557 model
= self
.browse_model
558 ref
= f
'{model.ref}:{model.relpath}'
559 with core
.xopen(model
.filename
, 'wb') as fp
:
560 status
, output
, err
= git
.show(ref
, _stdout
=fp
)
562 out
= '# git show {} >{}\n{}'.format(
564 shlex
.quote(model
.filename
),
567 Interaction
.command(N_('Error Saving File'), 'git show', status
, out
, err
)
571 msg
= N_('Saved "%(filename)s" from "%(ref)s" to "%(destination)s"') % {
572 'filename': model
.relpath
,
574 'destination': model
.filename
,
576 Interaction
.log_status(status
, msg
, '')
578 Interaction
.information(
579 N_('File Saved'), N_('File saved to "%s"') % model
.filename
583 class BrowseBranch(standard
.Dialog
):
585 def browse(cls
, context
, ref
):
586 model
= BrowseModel(ref
)
587 dlg
= cls(context
, model
, parent
=qtutils
.active_window())
588 dlg_model
= GitTreeModel(context
, ref
, dlg
)
589 dlg
.setModel(dlg_model
)
590 dlg
.setWindowTitle(N_('Browsing %s') % model
.ref
)
593 if dlg
.exec_() != dlg
.Accepted
:
597 def __init__(self
, context
, model
, parent
=None):
598 standard
.Dialog
.__init
__(self
, parent
=parent
)
599 if parent
is not None:
600 self
.setWindowModality(Qt
.WindowModal
)
602 # updated for use by commands
603 self
.context
= context
607 self
.tree
= GitTreeWidget(parent
=self
)
608 self
.close_button
= qtutils
.close_button()
611 self
.save
= qtutils
.create_button(text
=text
, enabled
=False, default
=True)
614 self
.btnlayt
= qtutils
.hbox(
615 defs
.margin
, defs
.spacing
, self
.close_button
, qtutils
.STRETCH
, self
.save
618 self
.layt
= qtutils
.vbox(defs
.margin
, defs
.spacing
, self
.tree
, self
.btnlayt
)
619 self
.setLayout(self
.layt
)
622 self
.tree
.path_chosen
.connect(self
.save_path
)
624 self
.tree
.selection_changed
.connect(
625 self
.selection_changed
, type=Qt
.QueuedConnection
628 qtutils
.connect_button(self
.close_button
, self
.close
)
629 qtutils
.connect_button(self
.save
, self
.save_blob
)
630 self
.init_size(parent
=parent
)
633 self
.tree
.expandAll()
635 def setModel(self
, model
):
636 self
.tree
.setModel(model
)
638 def path_chosen(self
, path
, close
=True):
639 """Update the model from the view"""
642 model
.filename
= path
646 def save_path(self
, path
):
647 """Choose an output filename based on the selected path"""
648 self
.path_chosen(path
, close
=False)
649 if save_path(self
.context
, path
, self
.model
):
653 """Save the currently selected file"""
654 filenames
= self
.tree
.selected_files()
657 self
.save_path(filenames
[0])
659 def selection_changed(self
):
660 """Update actions based on the current selection"""
661 filenames
= self
.tree
.selected_files()
662 self
.save
.setEnabled(bool(filenames
))
665 # pylint: disable=too-many-ancestors
666 class GitTreeWidget(standard
.TreeView
):
667 selection_changed
= Signal()
668 path_chosen
= Signal(object)
670 def __init__(self
, parent
=None):
671 standard
.TreeView
.__init
__(self
, parent
)
672 self
.setHeaderHidden(True)
673 # pylint: disable=no-member
674 self
.doubleClicked
.connect(self
.double_clicked
)
676 def double_clicked(self
, index
):
677 item
= self
.model().itemFromIndex(index
)
682 self
.path_chosen
.emit(item
.path
)
684 def selected_files(self
):
685 items
= self
.selected_items()
686 return [i
.path
for i
in items
if not i
.is_dir
]
688 def selectionChanged(self
, old_selection
, new_selection
):
689 QtWidgets
.QTreeView
.selectionChanged(self
, old_selection
, new_selection
)
690 self
.selection_changed
.emit()
692 def select_first_file(self
):
693 """Select the first filename in the tree"""
695 idx
= self
.indexAt(QtCore
.QPoint(0, 0))
696 item
= model
.itemFromIndex(idx
)
697 while idx
and idx
.isValid() and item
and item
.is_dir
:
698 idx
= self
.indexBelow(idx
)
699 item
= model
.itemFromIndex(idx
)
701 if idx
and idx
.isValid() and item
:
702 self
.setCurrentIndex(idx
)
705 class GitFileTreeModel(QtGui
.QStandardItemModel
):
706 """Presents a list of file paths as a hierarchical tree."""
708 def __init__(self
, parent
):
709 QtGui
.QStandardItemModel
.__init
__(self
, parent
)
710 self
.dir_entries
= {'': self
.invisibleRootItem()}
714 QtGui
.QStandardItemModel
.clear(self
)
716 self
.dir_entries
= {'': self
.invisibleRootItem()}
718 def add_files(self
, files
):
719 """Add a list of files"""
720 add_file
= self
.add_file
724 def add_file(self
, path
):
725 """Add a file to the model."""
726 dirname
= utils
.dirname(path
)
727 dir_entries
= self
.dir_entries
729 parent
= dir_entries
[dirname
]
731 parent
= dir_entries
[dirname
] = self
.create_dir_entry(dirname
)
733 row_items
= create_row(path
, False)
734 parent
.appendRow(row_items
)
736 def add_directory(self
, parent
, path
):
737 """Add a directory entry to the model."""
739 row_items
= create_row(path
, True)
742 parent_path
= parent
.path
743 except AttributeError: # root QStandardItem
746 # Insert directories before file paths
748 row
= self
.dir_rows
[parent_path
]
750 row
= self
.dir_rows
[parent_path
] = 0
752 parent
.insertRow(row
, row_items
)
753 self
.dir_rows
[parent_path
] += 1
754 self
.dir_entries
[path
] = row_items
[0]
758 def create_dir_entry(self
, dirname
):
760 Create a directory entry for the model.
762 This ensures that directories are always listed before files.
765 entries
= dirname
.split('/')
767 parent
= self
.invisibleRootItem()
768 curdir_append
= curdir
.append
769 self_add_directory
= self
.add_directory
770 dir_entries
= self
.dir_entries
771 for entry
in entries
:
773 path
= '/'.join(curdir
)
775 parent
= dir_entries
[path
]
778 parent
= self_add_directory(grandparent
, path
)
779 dir_entries
[path
] = parent
783 def create_row(path
, is_dir
):
784 """Return a list of items representing a row."""
785 return [GitTreeItem(path
, is_dir
)]
788 class GitTreeModel(GitFileTreeModel
):
789 def __init__(self
, context
, ref
, parent
):
790 GitFileTreeModel
.__init
__(self
, parent
)
791 self
.context
= context
795 def _initialize(self
):
796 """Iterate over git-ls-tree and create GitTreeItems."""
797 git
= self
.context
.git
798 status
, out
, err
= git
.ls_tree('--full-tree', '-r', '-t', '-z', self
.ref
)
800 Interaction
.log_status(status
, out
, err
)
806 for line
in out
[:-1].split('\0'):
807 # .....6 ...4 ......................................40
808 # 040000 tree c127cde9a0c644a3a8fef449a244f47d5272dfa6 relative
809 # 100644 blob 139e42bf4acaa4927ec9be1ec55a252b97d3f1e2 relative/path
811 relpath
= line
[6 + 1 + 4 + 1 + 40 + 1 :]
813 parent
= self
.dir_entries
[utils
.dirname(relpath
)]
814 self
.add_directory(parent
, relpath
)
816 self
.add_file(relpath
)
819 class GitTreeItem(QtGui
.QStandardItem
):
821 Represents a cell in a tree view.
823 Many GitRepoItems could map to a single repository path,
824 but this tree only has a single column.
825 Each GitRepoItem manages a different cell in the tree view.
829 def __init__(self
, path
, is_dir
):
830 QtGui
.QStandardItem
.__init
__(self
)
833 self
.setEditable(False)
834 self
.setDragEnabled(False)
835 self
.setText(utils
.basename(path
))
837 icon
= icons
.directory()
839 icon
= icons
.file_text()