1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2002 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4#
5
6"""
7Module implementing the baseclass for the various project browsers.
8"""
9
10import os
11import contextlib
12
13from PyQt5.QtCore import (
14    QModelIndex, pyqtSignal, Qt, QCoreApplication, QItemSelectionModel,
15    QItemSelection, QElapsedTimer
16)
17from PyQt5.QtWidgets import (
18    QTreeView, QApplication, QMenu, QDialog, QAbstractItemView
19)
20
21from E5Gui.E5Application import e5App
22from E5Gui import E5MessageBox
23from E5Gui.E5OverrideCursor import E5OverrideCursor
24
25from UI.Browser import Browser
26from UI.BrowserModel import BrowserDirectoryItem, BrowserFileItem
27
28from .ProjectBrowserModel import (
29    ProjectBrowserSimpleDirectoryItem, ProjectBrowserDirectoryItem,
30    ProjectBrowserFileItem
31)
32from .ProjectBrowserSortFilterProxyModel import (
33    ProjectBrowserSortFilterProxyModel
34)
35
36
37class ProjectBaseBrowser(Browser):
38    """
39    Baseclass implementing common functionality for the various project
40    browsers.
41
42    @signal closeSourceWindow(str) emitted to close a source file
43    """
44    closeSourceWindow = pyqtSignal(str)
45
46    def __init__(self, project, type_, parent=None):
47        """
48        Constructor
49
50        @param project reference to the project object
51        @param type_ project browser type (string)
52        @param parent parent widget of this browser
53        """
54        QTreeView.__init__(self, parent)
55
56        self.project = project
57
58        self._model = project.getModel()
59        self._sortModel = ProjectBrowserSortFilterProxyModel(type_)
60        self._sortModel.setSourceModel(self._model)
61        self.setModel(self._sortModel)
62
63        self.selectedItemsFilter = [ProjectBrowserFileItem]
64
65        # contains codes for special menu entries
66        # 1 = specials for Others browser
67        self.specialMenuEntries = []
68        self.isTranslationsBrowser = False
69        self.expandedNames = []
70
71        self.SelectFlags = QItemSelectionModel.SelectionFlags(
72            QItemSelectionModel.SelectionFlag.Select |
73            QItemSelectionModel.SelectionFlag.Rows
74        )
75        self.DeselectFlags = QItemSelectionModel.SelectionFlags(
76            QItemSelectionModel.SelectionFlag.Deselect |
77            QItemSelectionModel.SelectionFlag.Rows
78        )
79
80        self._activating = False
81
82        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
83        self.customContextMenuRequested.connect(self._contextMenuRequested)
84        self.activated.connect(self._openItem)
85        self._model.rowsInserted.connect(self.__modelRowsInserted)
86        self._connectExpandedCollapsed()
87
88        self._createPopupMenus()
89
90        self.currentItemName = None
91
92        self._init()    # perform common initialization tasks
93
94        self._keyboardSearchString = ""
95        self._keyboardSearchTimer = QElapsedTimer()
96        self._keyboardSearchTimer.invalidate()
97
98        self._initHookMethods()     # perform initialization of the hooks
99        self.hooksMenuEntries = {}
100
101    def _connectExpandedCollapsed(self):
102        """
103        Protected method to connect the expanded and collapsed signals.
104        """
105        self.expanded.connect(self._resizeColumns)
106        self.collapsed.connect(self._resizeColumns)
107
108    def _disconnectExpandedCollapsed(self):
109        """
110        Protected method to disconnect the expanded and collapsed signals.
111        """
112        self.expanded.disconnect(self._resizeColumns)
113        self.collapsed.disconnect(self._resizeColumns)
114
115    def _createPopupMenus(self):
116        """
117        Protected overloaded method to generate the popup menus.
118        """
119        # create the popup menu for source files
120        self.sourceMenu = QMenu(self)
121        self.sourceMenu.addAction(
122            QCoreApplication.translate('ProjectBaseBrowser', 'Open'),
123            self._openItem)
124
125        # create the popup menu for general use
126        self.menu = QMenu(self)
127        self.menu.addAction(
128            QCoreApplication.translate('ProjectBaseBrowser', 'Open'),
129            self._openItem)
130
131        # create the menu for multiple selected files
132        self.multiMenu = QMenu(self)
133        self.multiMenu.addAction(
134            QCoreApplication.translate('ProjectBaseBrowser', 'Open'),
135            self._openItem)
136
137        # create the background menu
138        self.backMenu = None
139
140        # create the directories menu
141        self.dirMenu = None
142
143        # create the directory for multiple selected directories
144        self.dirMultiMenu = None
145
146        self.menuActions = []
147        self.multiMenuActions = []
148        self.dirMenuActions = []
149        self.dirMultiMenuActions = []
150
151        self.mainMenu = None
152
153    def _contextMenuRequested(self, coord):
154        """
155        Protected slot to show the context menu.
156
157        @param coord the position of the mouse pointer (QPoint)
158        """
159        if not self.project.isOpen():
160            return
161
162        cnt = self.getSelectedItemsCount()
163        if cnt > 1:
164            self.multiMenu.popup(self.mapToGlobal(coord))
165        else:
166            index = self.indexAt(coord)
167
168            if index.isValid():
169                self.menu.popup(self.mapToGlobal(coord))
170            else:
171                self.backMenu and self.backMenu.popup(self.mapToGlobal(coord))
172
173    def _selectSingleItem(self, index):
174        """
175        Protected method to select a single item.
176
177        @param index index of item to be selected (QModelIndex)
178        """
179        if index.isValid():
180            self.setCurrentIndex(index)
181            flags = QItemSelectionModel.SelectionFlags(
182                QItemSelectionModel.SelectionFlag.ClearAndSelect |
183                QItemSelectionModel.SelectionFlag.Rows
184            )
185            self.selectionModel().select(index, flags)
186
187    def _setItemSelected(self, index, selected):
188        """
189        Protected method to set the selection status of an item.
190
191        @param index index of item to set (QModelIndex)
192        @param selected flag giving the new selection status (boolean)
193        """
194        if index.isValid():
195            self.selectionModel().select(
196                index, selected and self.SelectFlags or self.DeselectFlags)
197
198    def _setItemRangeSelected(self, startIndex, endIndex, selected):
199        """
200        Protected method to set the selection status of a range of items.
201
202        @param startIndex start index of range of items to set (QModelIndex)
203        @param endIndex end index of range of items to set (QModelIndex)
204        @param selected flag giving the new selection status (boolean)
205        """
206        selection = QItemSelection(startIndex, endIndex)
207        self.selectionModel().select(
208            selection, selected and self.SelectFlags or self.DeselectFlags)
209
210    def __modelRowsInserted(self, parent, start, end):
211        """
212        Private slot called after rows have been inserted into the model.
213
214        @param parent parent index of inserted rows (QModelIndex)
215        @param start start row number (integer)
216        @param end end row number (integer)
217        """
218        self._resizeColumns(parent)
219
220    def _projectClosed(self):
221        """
222        Protected slot to handle the projectClosed signal.
223        """
224        self.layoutDisplay()
225        if self.backMenu is not None:
226            self.backMenu.setEnabled(False)
227
228        self._createPopupMenus()
229
230    def _projectOpened(self):
231        """
232        Protected slot to handle the projectOpened signal.
233        """
234        self.layoutDisplay()
235        self.sortByColumn(0, Qt.SortOrder.DescendingOrder)
236        self.sortByColumn(0, Qt.SortOrder.AscendingOrder)
237        self._initMenusAndVcs()
238
239    def _initMenusAndVcs(self):
240        """
241        Protected slot to initialize the menus and the Vcs interface.
242        """
243        self._createPopupMenus()
244
245        if self.backMenu is not None:
246            self.backMenu.setEnabled(True)
247
248        if self.project.vcs is not None:
249            self.vcsHelper = self.project.vcs.vcsGetProjectBrowserHelper(
250                self, self.project, self.isTranslationsBrowser)
251            self.vcsHelper.addVCSMenus(
252                self.mainMenu, self.multiMenu, self.backMenu,
253                self.dirMenu, self.dirMultiMenu)
254
255    def _newProject(self):
256        """
257        Protected slot to handle the newProject signal.
258        """
259        # default to perform same actions as opening a project
260        self._projectOpened()
261
262    def _removeFile(self):
263        """
264        Protected method to remove a file or files from the project.
265        """
266        itmList = self.getSelectedItems()
267
268        for itm in itmList[:]:
269            fn = itm.fileName()
270            self.closeSourceWindow.emit(fn)
271            self.project.removeFile(fn)
272
273    def _removeDir(self):
274        """
275        Protected method to remove a (single) directory from the project.
276        """
277        itmList = self.getSelectedItems(
278            [ProjectBrowserSimpleDirectoryItem, ProjectBrowserDirectoryItem])
279        for itm in itmList[:]:
280            dn = itm.dirName()
281            self.project.removeDirectory(dn)
282
283    def _deleteDirectory(self):
284        """
285        Protected method to delete the selected directory from the project
286        data area.
287        """
288        itmList = self.getSelectedItems()
289
290        dirs = []
291        fullNames = []
292        for itm in itmList:
293            dn = itm.dirName()
294            fullNames.append(dn)
295            dn = self.project.getRelativePath(dn)
296            dirs.append(dn)
297
298        from UI.DeleteFilesConfirmationDialog import (
299            DeleteFilesConfirmationDialog
300        )
301        dlg = DeleteFilesConfirmationDialog(
302            self.parent(),
303            QCoreApplication.translate(
304                "ProjectBaseBrowser", "Delete directories"),
305            QCoreApplication.translate(
306                "ProjectBaseBrowser",
307                "Do you really want to delete these directories from"
308                " the project?"),
309            dirs)
310
311        if dlg.exec() == QDialog.DialogCode.Accepted:
312            for dn in fullNames:
313                self.project.deleteDirectory(dn)
314
315    def _renameFile(self):
316        """
317        Protected method to rename a file of the project.
318        """
319        itm = self.model().item(self.currentIndex())
320        fn = itm.fileName()
321        self.project.renameFile(fn)
322
323    def _copyToClipboard(self):
324        """
325        Protected method to copy the path of an entry to the clipboard.
326        """
327        itm = self.model().item(self.currentIndex())
328        try:
329            fn = itm.fileName()
330        except AttributeError:
331            try:
332                fn = itm.dirName()
333            except AttributeError:
334                fn = ""
335
336        cb = QApplication.clipboard()
337        cb.setText(fn)
338
339    def selectFile(self, fn):
340        """
341        Public method to highlight a node given its filename.
342
343        @param fn filename of file to be highlighted (string)
344        """
345        newfn = os.path.abspath(fn)
346        newfn = self.project.getRelativePath(newfn)
347        sindex = self._model.itemIndexByName(newfn)
348        if sindex.isValid():
349            index = self.model().mapFromSource(sindex)
350            if index.isValid():
351                self._selectSingleItem(index)
352                self.scrollTo(index,
353                              QAbstractItemView.ScrollHint.PositionAtTop)
354
355    def selectFileLine(self, fn, lineno):
356        """
357        Public method to highlight a node given its filename.
358
359        @param fn filename of file to be highlighted (string)
360        @param lineno one based line number of the item (integer)
361        """
362        newfn = os.path.abspath(fn)
363        newfn = self.project.getRelativePath(newfn)
364        sindex = self._model.itemIndexByNameAndLine(newfn, lineno)
365        if sindex.isValid():
366            index = self.model().mapFromSource(sindex)
367            if index.isValid():
368                self._selectSingleItem(index)
369                self.scrollTo(index)
370
371    def _expandAllDirs(self):
372        """
373        Protected slot to handle the 'Expand all directories' menu action.
374        """
375        self._disconnectExpandedCollapsed()
376        with E5OverrideCursor():
377            index = self.model().index(0, 0)
378            while index.isValid():
379                itm = self.model().item(index)
380                if (
381                    isinstance(
382                        itm,
383                        (ProjectBrowserSimpleDirectoryItem,
384                         ProjectBrowserDirectoryItem)) and
385                    not self.isExpanded(index)
386                ):
387                    self.expand(index)
388                index = self.indexBelow(index)
389            self.layoutDisplay()
390        self._connectExpandedCollapsed()
391
392    def _collapseAllDirs(self):
393        """
394        Protected slot to handle the 'Collapse all directories' menu action.
395        """
396        self._disconnectExpandedCollapsed()
397        with E5OverrideCursor():
398            # step 1: find last valid index
399            vindex = QModelIndex()
400            index = self.model().index(0, 0)
401            while index.isValid():
402                vindex = index
403                index = self.indexBelow(index)
404
405            # step 2: go up collapsing all directory items
406            index = vindex
407            while index.isValid():
408                itm = self.model().item(index)
409                if (
410                    isinstance(
411                        itm,
412                        (ProjectBrowserSimpleDirectoryItem,
413                         ProjectBrowserDirectoryItem)) and
414                    self.isExpanded(index)
415                ):
416                    self.collapse(index)
417                index = self.indexAbove(index)
418            self.layoutDisplay()
419        self._connectExpandedCollapsed()
420
421    def _showContextMenu(self, menu):
422        """
423        Protected slot called before the context menu is shown.
424
425        It enables/disables the VCS menu entries depending on the overall
426        VCS status and the file status.
427
428        @param menu reference to the menu to be shown (QMenu)
429        """
430        if self.project.vcs is None:
431            for act in self.menuActions:
432                act.setEnabled(True)
433        else:
434            self.vcsHelper.showContextMenu(menu, self.menuActions)
435
436    def _showContextMenuMulti(self, menu):
437        """
438        Protected slot called before the context menu (multiple selections) is
439        shown.
440
441        It enables/disables the VCS menu entries depending on the overall
442        VCS status and the files status.
443
444        @param menu reference to the menu to be shown (QMenu)
445        """
446        if self.project.vcs is None:
447            for act in self.multiMenuActions:
448                act.setEnabled(True)
449        else:
450            self.vcsHelper.showContextMenuMulti(menu, self.multiMenuActions)
451
452    def _showContextMenuDir(self, menu):
453        """
454        Protected slot called before the context menu is shown.
455
456        It enables/disables the VCS menu entries depending on the overall
457        VCS status and the directory status.
458
459        @param menu reference to the menu to be shown (QMenu)
460        """
461        if self.project.vcs is None:
462            for act in self.dirMenuActions:
463                act.setEnabled(True)
464        else:
465            self.vcsHelper.showContextMenuDir(menu, self.dirMenuActions)
466
467    def _showContextMenuDirMulti(self, menu):
468        """
469        Protected slot called before the context menu is shown.
470
471        It enables/disables the VCS menu entries depending on the overall
472        VCS status and the directory status.
473
474        @param menu reference to the menu to be shown (QMenu)
475        """
476        if self.project.vcs is None:
477            for act in self.dirMultiMenuActions:
478                act.setEnabled(True)
479        else:
480            self.vcsHelper.showContextMenuDirMulti(
481                menu, self.dirMultiMenuActions)
482
483    def _showContextMenuBack(self, menu):
484        """
485        Protected slot called before the context menu is shown.
486
487        @param menu reference to the menu to be shown (QMenu)
488        """
489        # nothing to do for now
490        return
491
492    def _selectEntries(self, local=True, filterList=None):
493        """
494        Protected method to select entries based on their VCS status.
495
496        @param local flag indicating local (i.e. non VCS controlled)
497            file/directory entries should be selected (boolean)
498        @param filterList list of classes to check against
499        """
500        if self.project.vcs is None:
501            return
502
503        compareString = (
504            QCoreApplication.translate('ProjectBaseBrowser', "local")
505            if local else
506            self.project.vcs.vcsName()
507        )
508
509        # expand all directories in order to iterate over all entries
510        self._expandAllDirs()
511
512        self.selectionModel().clear()
513
514        with E5OverrideCursor():
515            # now iterate over all entries
516            startIndex = None
517            endIndex = None
518            selectedEntries = 0
519            index = self.model().index(0, 0)
520            while index.isValid():
521                itm = self.model().item(index)
522                if (
523                    self.wantedItem(itm, filterList) and
524                    compareString == itm.data(1)
525                ):
526                    if (
527                        startIndex is not None and
528                        startIndex.parent() != index.parent()
529                    ):
530                        self._setItemRangeSelected(startIndex, endIndex, True)
531                        startIndex = None
532                    selectedEntries += 1
533                    if startIndex is None:
534                        startIndex = index
535                    endIndex = index
536                else:
537                    if startIndex is not None:
538                        self._setItemRangeSelected(startIndex, endIndex, True)
539                        startIndex = None
540                index = self.indexBelow(index)
541            if startIndex is not None:
542                self._setItemRangeSelected(startIndex, endIndex, True)
543
544        if selectedEntries == 0:
545            E5MessageBox.information(
546                self,
547                QCoreApplication.translate(
548                    'ProjectBaseBrowser', "Select entries"),
549                QCoreApplication.translate(
550                    'ProjectBaseBrowser',
551                    """There were no matching entries found."""))
552
553    def selectLocalEntries(self):
554        """
555        Public slot to handle the select local files context menu entries.
556        """
557        self._selectEntries(local=True, filterList=[ProjectBrowserFileItem])
558
559    def selectVCSEntries(self):
560        """
561        Public slot to handle the select VCS files context menu entries.
562        """
563        self._selectEntries(local=False, filterList=[ProjectBrowserFileItem])
564
565    def selectLocalDirEntries(self):
566        """
567        Public slot to handle the select local directories context menu
568        entries.
569        """
570        self._selectEntries(
571            local=True,
572            filterList=[ProjectBrowserSimpleDirectoryItem,
573                        ProjectBrowserDirectoryItem])
574
575    def selectVCSDirEntries(self):
576        """
577        Public slot to handle the select VCS directories context menu entries.
578        """
579        self._selectEntries(
580            local=False,
581            filterList=[ProjectBrowserSimpleDirectoryItem,
582                        ProjectBrowserDirectoryItem])
583
584    def getExpandedItemNames(self):
585        """
586        Public method to get the file/directory names of all expanded items.
587
588        @return list of expanded items names (list of string)
589        """
590        expandedNames = []
591
592        childIndex = self.model().index(0, 0)
593        while childIndex.isValid():
594            if self.isExpanded(childIndex):
595                with contextlib.suppress(AttributeError):
596                    expandedNames.append(
597                        self.model().item(childIndex).name())
598                    # only items defining the name() method are returned
599            childIndex = self.indexBelow(childIndex)
600
601        return expandedNames
602
603    def expandItemsByName(self, names):
604        """
605        Public method to expand items given their names.
606
607        @param names list of item names to be expanded (list of string)
608        """
609        model = self.model()
610        for name in names:
611            childIndex = model.index(0, 0)
612            while childIndex.isValid():
613                with contextlib.suppress(AttributeError):
614                    if model.item(childIndex).name() == name:
615                        self.setExpanded(childIndex, True)
616                        break
617                    # ignore items not supporting this method
618                childIndex = self.indexBelow(childIndex)
619
620    def _prepareRepopulateItem(self, name):
621        """
622        Protected slot to handle the prepareRepopulateItem signal.
623
624        @param name relative name of file item to be repopulated (string)
625        """
626        itm = self.currentItem()
627        if itm is not None:
628            self.currentItemName = itm.data(0)
629        self.expandedNames = []
630        sindex = self._model.itemIndexByName(name)
631        if not sindex.isValid():
632            return
633
634        index = self.model().mapFromSource(sindex)
635        if not index.isValid():
636            return
637
638        childIndex = self.indexBelow(index)
639        while childIndex.isValid():
640            if childIndex.parent() == index.parent():
641                break
642            if self.isExpanded(childIndex):
643                self.expandedNames.append(
644                    self.model().item(childIndex).data(0))
645            childIndex = self.indexBelow(childIndex)
646
647    def _completeRepopulateItem(self, name):
648        """
649        Protected slot to handle the completeRepopulateItem signal.
650
651        @param name relative name of file item to be repopulated (string)
652        """
653        sindex = self._model.itemIndexByName(name)
654        if sindex.isValid():
655            index = self.model().mapFromSource(sindex)
656            if index.isValid():
657                if self.isExpanded(index):
658                    childIndex = self.indexBelow(index)
659                    while childIndex.isValid():
660                        if (
661                            not childIndex.isValid() or
662                            childIndex.parent() == index.parent()
663                        ):
664                            break
665                        itm = self.model().item(childIndex)
666                        if itm is not None:
667                            itemData = itm.data(0)
668                            if (
669                                self.currentItemName and
670                                self.currentItemName == itemData
671                            ):
672                                self._selectSingleItem(childIndex)
673                            if itemData in self.expandedNames:
674                                self.setExpanded(childIndex, True)
675                        childIndex = self.indexBelow(childIndex)
676                else:
677                    self._selectSingleItem(index)
678                self.expandedNames = []
679        self.currentItemName = None
680        self._resort()
681
682    def currentItem(self):
683        """
684        Public method to get a reference to the current item.
685
686        @return reference to the current item
687        """
688        itm = self.model().item(self.currentIndex())
689        return itm
690
691    def _keyboardSearchType(self, item):
692        """
693        Protected method to check, if the item is of the correct type.
694
695        @param item reference to the item
696        @type BrowserItem
697        @return flag indicating a correct type
698        @rtype bool
699        """
700        return isinstance(
701            item, (BrowserDirectoryItem, BrowserFileItem,
702                   ProjectBrowserSimpleDirectoryItem,
703                   ProjectBrowserDirectoryItem, ProjectBrowserFileItem))
704
705    ###########################################################################
706    ## Support for hooks below
707    ###########################################################################
708
709    def _initHookMethods(self):
710        """
711        Protected method to initialize the hooks dictionary.
712
713        This method should be overridden by subclasses. All supported
714        hook methods should be initialized with a None value. The keys
715        must be strings.
716        """
717        self.hooks = {}
718
719    def __checkHookKey(self, key):
720        """
721        Private method to check a hook key.
722
723        @param key key of the hook to check (string)
724        @exception KeyError raised to indicate an invalid hook
725        """
726        if len(self.hooks) == 0:
727            raise KeyError("Hooks are not initialized.")
728
729        if key not in self.hooks:
730            raise KeyError(key)
731
732    def addHookMethod(self, key, method):
733        """
734        Public method to add a hook method to the dictionary.
735
736        @param key for the hook method (string)
737        @param method reference to the hook method (method object)
738        """
739        self.__checkHookKey(key)
740        self.hooks[key] = method
741
742    def addHookMethodAndMenuEntry(self, key, method, menuEntry):
743        """
744        Public method to add a hook method to the dictionary.
745
746        @param key for the hook method (string)
747        @param method reference to the hook method (method object)
748        @param menuEntry entry to be shown in the context menu (string)
749        """
750        self.addHookMethod(key, method)
751        self.hooksMenuEntries[key] = menuEntry
752
753    def removeHookMethod(self, key):
754        """
755        Public method to remove a hook method from the dictionary.
756
757        @param key for the hook method (string)
758        """
759        self.__checkHookKey(key)
760        self.hooks[key] = None
761        if key in self.hooksMenuEntries:
762            del self.hooksMenuEntries[key]
763
764    ##################################################################
765    ## Configure method below
766    ##################################################################
767
768    def _configure(self):
769        """
770        Protected method to open the configuration dialog.
771        """
772        e5App().getObject("UserInterface").showPreferences(
773            "projectBrowserPage")
774