示例#1
0
文件: utils.py 项目: cs210/Worldsight
class QmlInstantEngine(QQmlApplicationEngine):
    """
    QmlInstantEngine is an utility class helping developing QML applications.
    It reloads itself whenever one of the watched source files is modified.
    As it consumes resources, make sure to disable file watching in production mode.
    """
    def __init__(self,
                 sourceFile="",
                 watching=True,
                 verbose=False,
                 parent=None):
        """
        watching -- Defines whether the watcher is active (default: True)
        verbose -- if True, output log infos (default: False)
        """
        super(QmlInstantEngine, self).__init__(parent)

        self._fileWatcher = QFileSystemWatcher()  # Internal Qt File Watcher
        self._sourceFile = ""
        self._watchedFiles = []  # Internal watched files list
        self._verbose = verbose  # Verbose bool
        self._watching = False  #
        self._extensions = [
            "qml", "js"
        ]  # File extensions that defines files to watch when adding a folder

        self._rootItem = None

        def onObjectCreated(root, url):
            if not root:
                return
            # Restore root item geometry
            if self._rootItem:
                root.setGeometry(self._rootItem.geometry())
                self._rootItem.deleteLater()
            self._rootItem = root

        self.objectCreated.connect(onObjectCreated)

        # Update the watching status
        self.setWatching(watching)

        if sourceFile:
            self.load(sourceFile)

    def load(self, sourceFile):
        self._sourceFile = sourceFile
        super(QmlInstantEngine, self).load(sourceFile)

    def setWatching(self, watchValue):
        """
        Enable (True) or disable (False) the file watching.
        Tip: file watching should be enable only when developing.
        """
        if self._watching is watchValue:
            return

        self._watching = watchValue
        # Enable the watcher
        if self._watching:
            # 1. Add internal list of files to the internal Qt File Watcher
            self.addFiles(self._watchedFiles)
            # 2. Connect 'filechanged' signal
            self._fileWatcher.fileChanged.connect(self.onFileChanged)

        # Disabling the watcher
        else:
            # 1. Remove all files in the internal Qt File Watcher
            self._fileWatcher.removePaths(self._watchedFiles)
            # 2. Disconnect 'filechanged' signal
            self._fileWatcher.fileChanged.disconnect(self.onFileChanged)

    @property
    def watchedExtensions(self):
        """ Returns the list of extensions used when using addFilesFromDirectory. """
        return self._extensions

    @watchedExtensions.setter
    def watchedExtensions(self, extensions):
        """ Set the list of extensions to search for when using addFilesFromDirectory. """
        self._extensions = extensions

    def setVerbose(self, verboseValue):
        """ Activate (True) or desactivate (False) the verbose. """
        self._verbose = verboseValue

    def addFile(self, filename):
        """
        Add the given 'filename' to the watched files list.
        'filename' can be an absolute or relative path (str and QUrl accepted)
        """
        # Deal with QUrl type
        # NOTE: happens when using the source() method on a QQuickView
        if isinstance(filename, QUrl):
            filename = filename.path()

        # Make sure the file exists
        if not os.path.isfile(filename):
            raise ValueError("addFile: file %s doesn't exist." % filename)

        # Return if the file is already in our internal list
        if filename in self._watchedFiles:
            return

        # Add this file to the internal files list
        self._watchedFiles.append(filename)
        # And, if watching is active, add it to the internal watcher as well
        if self._watching:
            if self._verbose:
                print("instantcoding: addPath", filename)
            self._fileWatcher.addPath(filename)

    def addFiles(self, filenames):
        """
        Add the given 'filenames' to the watched files list.
        filenames -- a list of absolute or relative paths (str and QUrl accepted)
        """
        # Convert to list
        if not isinstance(filenames, list):
            filenames = [filenames]

        for filename in filenames:
            self.addFile(filename)

    def addFilesFromDirectory(self, dirname, recursive=False):
        """
        Add files from the given directory name 'dirname'.
        dirname -- an absolute or a relative path
        recursive -- if True, will search inside each subdirectories recursively.
        """
        if not os.path.isdir(dirname):
            raise RuntimeError(
                "addFilesFromDirectory : %s is not a valid directory." %
                dirname)

        if recursive:
            for dirpath, dirnames, filenames in os.walk(dirname):
                for filename in filenames:
                    # Removing the starting dot from extension
                    if os.path.splitext(filename)[1][1:] in self._extensions:
                        self.addFile(os.path.join(dirpath, filename))
        else:
            filenames = os.listdir(dirname)
            filenames = [
                os.path.join(dirname, filename) for filename in filenames
                if os.path.splitext(filename)[1][1:] in self._extensions
            ]
            self.addFiles(filenames)

    def removeFile(self, filename):
        """
        Remove the given 'filename' from the watched file list.
        Tip: make sure to use relative or absolute path according to how you add this file.
        """
        if filename in self._watchedFiles:
            self._watchedFiles.remove(filename)
        if self._watching:
            self._fileWatcher.removePath(filename)

    def getRegisteredFiles(self):
        """ Returns the list of watched files """
        return self._watchedFiles

    @Slot(str)
    def onFileChanged(self, filepath):
        """ Handle changes in a watched file. """
        if filepath not in self._watchedFiles:
            # could happen if a file has just been reloaded
            # and has not been re-added yet to the watched files
            return

        if self._verbose:
            print("Source file changed : ", filepath)
        # Clear the QQuickEngine cache
        self.clearComponentCache()
        # Remove the modified file from the watched list
        self.removeFile(filepath)
        cptTry = 0

        # Make sure file is available before doing anything
        # NOTE: useful to handle editors (Qt Creator) that deletes the source file and
        #       creates a new one when saving
        while not os.path.exists(filepath) and cptTry < 10:
            time.sleep(0.1)
            cptTry += 1

        self.reload()

        # Finally, re-add the modified file to the watch system
        # after a short cooldown to avoid multiple consecutive reloads
        QTimer.singleShot(200, lambda: self.addFile(filepath))

    def reload(self):
        print("Reloading {}".format(self._sourceFile))
        self.load(self._sourceFile)
示例#2
0
class DataConnection(ProjectItem):
    def __init__(self,
                 toolbox,
                 project,
                 logger,
                 name,
                 description,
                 x,
                 y,
                 references=None):
        """Data Connection class.

        Args:
            toolbox (ToolboxUI): QMainWindow instance
            project (SpineToolboxProject): the project this item belongs to
            logger (LoggerInterface): a logger instance
            name (str): Object name
            description (str): Object description
            x (float): Initial X coordinate of item icon
            y (float): Initial Y coordinate of item icon
            references (list): a list of file paths
        """
        super().__init__(name, description, x, y, project, logger)
        self._toolbox = toolbox
        self.reference_model = QStandardItemModel()  # References to files
        self.data_model = QStandardItemModel(
        )  # Paths of project internal files. These are found in DC data directory
        self.datapackage_icon = QIcon(QPixmap(":/icons/datapkg.png"))
        self.data_dir_watcher = None
        # Populate references model
        if references is None:
            references = list()
        # Convert relative paths to absolute
        self.references = [
            deserialize_path(r, self._project.project_dir) for r in references
        ]
        self.populate_reference_list(self.references)
        # Populate data (files) model
        data_files = self.data_files()
        self.populate_data_list(data_files)
        self.spine_datapackage_form = None

    def set_up(self):
        self.data_dir_watcher = QFileSystemWatcher(self)
        if os.path.isdir(self.data_dir):
            self.data_dir_watcher.addPath(self.data_dir)
        self.data_dir_watcher.directoryChanged.connect(self.refresh)

    @staticmethod
    def item_type():
        """See base class."""
        return ItemInfo.item_type()

    @staticmethod
    def item_category():
        """See base class."""
        return ItemInfo.item_category()

    def execution_item(self):
        """Creates DataConnection's execution counterpart."""
        data_files = [
            os.path.join(self.data_dir, f) for f in self.data_files()
        ]
        return ExecutableItem(self.name, self.file_references(), data_files,
                              self._logger)

    def make_signal_handler_dict(self):
        """Returns a dictionary of all shared signals and their handlers.
        This is to enable simpler connecting and disconnecting."""
        s = super().make_signal_handler_dict()
        # pylint: disable=unnecessary-lambda
        s[self._properties_ui.toolButton_dc_open_dir.
          clicked] = lambda checked=False: self.open_directory()
        s[self._properties_ui.toolButton_plus.clicked] = self.add_references
        s[self._properties_ui.toolButton_minus.
          clicked] = self.remove_references
        s[self._properties_ui.toolButton_add.clicked] = self.copy_to_project
        s[self._properties_ui.pushButton_datapackage.
          clicked] = self.show_spine_datapackage_form
        s[self._properties_ui.treeView_dc_references.
          doubleClicked] = self.open_reference
        s[self._properties_ui.treeView_dc_data.
          doubleClicked] = self.open_data_file
        s[self._properties_ui.treeView_dc_references.
          files_dropped] = self.add_files_to_references
        s[self._properties_ui.treeView_dc_data.
          files_dropped] = self.add_files_to_data_dir
        s[self.get_icon().
          files_dropped_on_icon] = self.receive_files_dropped_on_icon
        s[self._properties_ui.treeView_dc_references.
          del_key_pressed] = lambda: self.remove_references()
        s[self._properties_ui.treeView_dc_data.
          del_key_pressed] = lambda: self.remove_files()
        return s

    def restore_selections(self):
        """Restore selections into shared widgets when this project item is selected."""
        self._properties_ui.label_dc_name.setText(self.name)
        self._properties_ui.treeView_dc_references.setModel(
            self.reference_model)
        self._properties_ui.treeView_dc_data.setModel(self.data_model)

    @Slot("QVariant")
    def add_files_to_references(self, paths):
        """Add multiple file paths to reference list.

        Args:
            paths (list): A list of paths to files
        """
        repeated_paths = []
        new_paths = []
        for path in paths:
            if any(os.path.samefile(path, ref) for ref in self.references):
                repeated_paths.append(path)
            else:
                new_paths.append(path)
        repeated_paths = ", ".join(repeated_paths)
        if repeated_paths:
            self._logger.msg_warning.emit(
                f"Reference to file(s) <b>{repeated_paths}</b> already available"
            )
        if new_paths:
            self._toolbox.undo_stack.push(
                AddDCReferencesCommand(self, new_paths))

    def do_add_files_to_references(self, paths):
        abspaths = [os.path.abspath(path) for path in paths]
        self.references.extend(abspaths)
        self.populate_reference_list(self.references)

    @Slot("QGraphicsItem", list)
    def receive_files_dropped_on_icon(self, icon, file_paths):
        """Called when files are dropped onto a data connection graphics item.
        If the item is this Data Connection's graphics item, add the files to data."""
        if icon == self.get_icon():
            self.add_files_to_data_dir(file_paths)

    @Slot("QVariant")
    def add_files_to_data_dir(self, file_paths):
        """Add files to data directory"""
        for file_path in file_paths:
            filename = os.path.split(file_path)[1]
            self._logger.msg.emit(
                f"Copying file <b>{filename}</b> to <b>{self.name}</b>")
            try:
                shutil.copy(file_path, self.data_dir)
            except OSError:
                self._logger.msg_error.emit("[OSError] Copying failed")
                return

    @Slot(bool)
    def add_references(self, checked=False):
        """Let user select references to files for this data connection."""
        # noinspection PyCallByClass, PyTypeChecker, PyArgumentList
        answer = QFileDialog.getOpenFileNames(self._toolbox,
                                              "Add file references",
                                              self._project.project_dir, "*.*")
        file_paths = answer[0]
        if not file_paths:  # Cancel button clicked
            return
        self.add_files_to_references(file_paths)

    @Slot(bool)
    def remove_references(self, checked=False):
        """Remove selected references from reference list.
        Do not remove anything if there are no references selected.
        """
        indexes = self._properties_ui.treeView_dc_references.selectedIndexes()
        if not indexes:  # Nothing selected
            self._logger.msg.emit("Please select references to remove")
            return
        references = [ind.data(Qt.DisplayRole) for ind in indexes]
        self._toolbox.undo_stack.push(
            RemoveDCReferencesCommand(self, references))
        self._logger.msg.emit("Selected references removed")

    def do_remove_references(self, references):
        self.references = [
            r for r in self.references
            if not any(os.path.samefile(r, ref) for ref in references)
        ]
        self.populate_reference_list(self.references)

    @Slot(bool)
    def copy_to_project(self, checked=False):
        """Copy selected file references to this Data Connection's data directory."""
        selected_indexes = self._properties_ui.treeView_dc_references.selectedIndexes(
        )
        if not selected_indexes:
            self._logger.msg_warning.emit("No files to copy")
            return
        for index in selected_indexes:
            file_path = self.reference_model.itemFromIndex(index).data(
                Qt.DisplayRole)
            if not os.path.exists(file_path):
                self._logger.msg_error.emit(
                    f"File <b>{file_path}</b> does not exist")
                continue
            filename = os.path.split(file_path)[1]
            self._logger.msg.emit(
                f"Copying file <b>{filename}</b> to Data Connection <b>{self.name}</b>"
            )
            try:
                shutil.copy(file_path, self.data_dir)
            except OSError:
                self._logger.msg_error.emit("[OSError] Copying failed")
                continue

    @Slot("QModelIndex")
    def open_reference(self, index):
        """Open reference in default program."""
        if not index:
            return
        if not index.isValid():
            logging.error("Index not valid")
            return
        reference = self.file_references()[index.row()]
        url = "file:///" + reference
        # noinspection PyTypeChecker, PyCallByClass, PyArgumentList
        res = open_url(url)
        if not res:
            self._logger.msg_error.emit(
                f"Failed to open reference:<b>{reference}</b>")

    @Slot("QModelIndex")
    def open_data_file(self, index):
        """Open data file in default program."""
        if not index:
            return
        if not index.isValid():
            logging.error("Index not valid")
            return
        data_file = self.data_files()[index.row()]
        url = "file:///" + os.path.join(self.data_dir, data_file)
        # noinspection PyTypeChecker, PyCallByClass, PyArgumentList
        res = open_url(url)
        if not res:
            self._logger.msg_error.emit(
                f"Opening file <b>{data_file}</b> failed")

    @busy_effect
    def show_spine_datapackage_form(self):
        """Show spine_datapackage_form widget."""
        if self.spine_datapackage_form:
            if self.spine_datapackage_form.windowState() & Qt.WindowMinimized:
                # Remove minimized status and restore window with the previous state (maximized/normal state)
                self.spine_datapackage_form.setWindowState(
                    self.spine_datapackage_form.windowState()
                    & ~Qt.WindowMinimized | Qt.WindowActive)
                self.spine_datapackage_form.activateWindow()
            else:
                self.spine_datapackage_form.raise_()
            return
        self.spine_datapackage_form = SpineDatapackageWidget(self)
        self.spine_datapackage_form.destroyed.connect(
            self.datapackage_form_destroyed)
        self.spine_datapackage_form.show()

    @Slot()
    def datapackage_form_destroyed(self):
        """Notify a connection that datapackage form has been destroyed."""
        self.spine_datapackage_form = None

    def make_new_file(self):
        """Create a new blank file to this Data Connections data directory."""
        msg = "File name"
        # noinspection PyCallByClass, PyTypeChecker, PyArgumentList
        answer = QInputDialog.getText(self._toolbox,
                                      "Create new file",
                                      msg,
                                      flags=Qt.WindowTitleHint
                                      | Qt.WindowCloseButtonHint)
        file_name = answer[0]
        if not file_name.strip():
            return
        # Check that file name has no invalid chars
        if any(True for x in file_name if x in INVALID_FILENAME_CHARS):
            msg = f"File name <b>{file_name}</b> contains invalid characters."
            self._logger.information_box.emit("Creating file failed", msg)
            return
        file_path = os.path.join(self.data_dir, file_name)
        if os.path.exists(file_path):
            msg = f"File <b>{file_name}</b> already exists."
            self._logger.information_box.emit("Creating file failed", msg)
            return
        try:
            with open(file_path, "w"):
                self._logger.msg.emit(
                    f"File <b>{file_name}</b> created to Data Connection <b>{self.name}</b>"
                )
        except OSError:
            msg = "Please check directory permissions."
            self._logger.information_box.emit("Creating file failed", msg)
        return

    def remove_files(self):
        """Remove selected files from data directory."""
        indexes = self._properties_ui.treeView_dc_data.selectedIndexes()
        if not indexes:  # Nothing selected
            self._logger.msg.emit("Please select files to remove")
            return
        file_list = list()
        for index in indexes:
            file_at_index = self.data_model.itemFromIndex(index).data(
                Qt.DisplayRole)
            file_list.append(file_at_index)
        files = "\n".join(file_list)
        msg = (
            "The following files will be removed permanently from the project\n\n"
            "{0}\n\n"
            "Are you sure?".format(files))
        title = "Remove {0} File(s)".format(len(file_list))
        message_box = QMessageBox(QMessageBox.Question,
                                  title,
                                  msg,
                                  QMessageBox.Ok | QMessageBox.Cancel,
                                  parent=self._toolbox)
        message_box.button(QMessageBox.Ok).setText("Remove Files")
        answer = message_box.exec_()
        if answer == QMessageBox.Cancel:
            return
        for filename in file_list:
            path_to_remove = os.path.join(self.data_dir, filename)
            try:
                os.remove(path_to_remove)
                self._logger.msg.emit(f"File <b>{path_to_remove}</b> removed")
            except OSError:
                self._logger.msg_error.emit(
                    f"Removing file {path_to_remove} failed.\nCheck permissions."
                )
        return

    def file_references(self):
        """Returns a list of paths to files that are in this item as references."""
        return self.references

    def data_files(self):
        """Returns a list of files that are in the data directory."""
        if not os.path.isdir(self.data_dir):
            return []
        files = list()
        with os.scandir(self.data_dir) as scan_iterator:
            for entry in scan_iterator:
                if entry.is_file():
                    files.append(entry.path)
        return files

    @Slot("QString")
    def refresh(self, _=None):
        """Refresh data files in Data Connection Properties.
        NOTE: Might lead to performance issues."""
        d = self.data_files()
        self.populate_data_list(d)

    def populate_reference_list(self, items, emit_item_changed=True):
        """List file references in QTreeView.
        If items is None or empty list, model is cleared.
        """
        self.reference_model.clear()
        self.reference_model.setHorizontalHeaderItem(
            0, QStandardItem("References"))  # Add header
        if items is not None:
            for item in items:
                qitem = QStandardItem(item)
                qitem.setFlags(~Qt.ItemIsEditable)
                qitem.setData(item, Qt.ToolTipRole)
                qitem.setData(
                    self._toolbox.style().standardIcon(QStyle.SP_FileLinkIcon),
                    Qt.DecorationRole)
                self.reference_model.appendRow(qitem)
        if emit_item_changed:
            self.item_changed.emit()

    def populate_data_list(self, items):
        """List project internal data (files) in QTreeView.
        If items is None or empty list, model is cleared.
        """
        self.data_model.clear()
        self.data_model.setHorizontalHeaderItem(
            0, QStandardItem("Data"))  # Add header
        if items is not None:
            for item in items:
                qitem = QStandardItem(item)
                qitem.setFlags(~Qt.ItemIsEditable)
                if item == 'datapackage.json':
                    qitem.setData(self.datapackage_icon, Qt.DecorationRole)
                else:
                    qitem.setData(QFileIconProvider().icon(QFileInfo(item)),
                                  Qt.DecorationRole)
                full_path = os.path.join(self.data_dir,
                                         item)  # For drag and drop
                qitem.setData(full_path, Qt.UserRole)
                self.data_model.appendRow(qitem)
        self.item_changed.emit()

    def update_name_label(self):
        """Update Data Connection tab name label. Used only when renaming project items."""
        self._properties_ui.label_dc_name.setText(self.name)

    def resources_for_direct_successors(self):
        """see base class"""
        refs = self.file_references()
        f_list = [os.path.join(self.data_dir, f) for f in self.data_files()]
        resources = [
            ProjectItemResource(self, "file", url=pathlib.Path(ref).as_uri())
            for ref in refs + f_list
        ]
        return resources

    def _do_handle_dag_changed(self, resources):
        """See base class."""
        if not self.file_references() and not self.data_files():
            self.add_notification(
                "This Data Connection does not have any references or data. "
                "Add some in the Data Connection Properties panel.")

    def item_dict(self):
        """Returns a dictionary corresponding to this item."""
        d = super().item_dict()
        # Convert paths to relative before saving
        d["references"] = [
            serialize_path(f, self._project.project_dir)
            for f in self.file_references()
        ]
        return d

    def rename(self, new_name):
        """Rename this item.

        Args:
            new_name (str): New name
        Returns:
            bool: True if renaming succeeded, False otherwise
        """
        dirs = self.data_dir_watcher.directories()
        if dirs:
            self.data_dir_watcher.removePaths(dirs)
        if not super().rename(new_name):
            self.data_dir_watcher.addPaths(dirs)
            return False
        self.data_dir_watcher.addPath(self.data_dir)
        self.refresh()
        return True

    def tear_down(self):
        """Tears down this item. Called by toolbox just before closing.
        Closes the SpineDatapackageWidget instances opened."""
        if self.spine_datapackage_form:
            self.spine_datapackage_form.close()
        watched_paths = self.data_dir_watcher.directories()
        if watched_paths:
            self.data_dir_watcher.removePaths(watched_paths)
        self.data_dir_watcher.deleteLater()

    def notify_destination(self, source_item):
        """See base class."""
        if source_item.item_type() == "Tool":
            self._logger.msg.emit(
                f"Link established. Tool <b>{source_item.name}</b> output files will be "
                f"passed as references to item <b>{self.name}</b> after execution."
            )
        elif source_item.item_type() in ["Data Store", "Importer"]:
            # Does this type of link do anything?
            self._logger.msg.emit("Link established.")
        else:
            super().notify_destination(source_item)

    @staticmethod
    def default_name_prefix():
        """See base class."""
        return "Data Connection"
示例#3
0
class Pqgit(QMainWindow):
    """ main class / entry point """
    def __init__(self):
        super().__init__()
        self.setAttribute(
            Qt.WA_DeleteOnClose
        )  # let Qt delete stuff before the python garbage-collector gets to work
        self.repo = None
        self.branches_model = None

        # instantiate main window
        self.ui = ui.Ui_MainWindow()
        self.ui.setupUi(self)

        self.fs_watch = QFileSystemWatcher(self)
        self.fs_watch.fileChanged.connect(self.on_file_changed)
        self.fs_watch.directoryChanged.connect(self.on_dir_changed)

        self.settings = QSettings(QSettings.IniFormat, QSettings.UserScope,
                                  'pqgit', 'config')

        # for comparison
        self.new_c_id, self.old_c_id = None, None

        # window icon
        cwd = os.path.dirname(os.path.realpath(__file__))
        self.setWindowIcon(QIcon(os.path.join(cwd, 'Git-Icon-White.png')))
        self.setWindowTitle('pqgit')

        # size and position
        self.move(self.settings.value('w/pos', QPoint(200, 200)))
        self.resize(self.settings.value('w/size', QSize(1000, 1000)))
        self.ui.hist_splitter.setSizes([
            int(s) for s in self.settings.value('w/hist_splitter', [720, 360])
        ])
        self.ui.cinf_splitter.setSizes([
            int(s) for s in self.settings.value('w/cinf_splitter', [360, 360])
        ])
        self.ui.diff_splitter.setSizes([
            int(s)
            for s in self.settings.value('w/diff_splitter', [150, 1200, 230])
        ])

        # open repo dir
        open_shortcut = QShortcut(QKeySequence('Ctrl+O'), self)
        open_shortcut.activated.connect(self.open_dir)

        # set-up ui
        self.branches_model = BranchesModel()
        self.ui.tvBranches.setModel(self.branches_model)
        self.ui.tvBranches.selectionModel().selectionChanged.connect(
            self.branches_selection_changed)
        self.ui.tvBranches.resizeColumnsToContents()

        self.history_model = HistoryModel()
        self.ui.tvHistory.setModel(self.history_model)
        self.ui.tvHistory.selectionModel().selectionChanged.connect(
            self.history_selection_changed)

        self.files_model = FilesModel()
        self.ui.tvFiles.setModel(self.files_model)
        self.ui.tvFiles.selectionModel().selectionChanged.connect(
            self.files_selection_changed)

        self.ui.tvFiles.doubleClicked.connect(self.on_file_doubleclicked)

        for view in (self.ui.tvBranches, self.ui.tvHistory, self.ui.tvFiles):
            view.horizontalHeader().setSectionResizeMode(
                1, QHeaderView.Stretch)
            view.setSelectionBehavior(QAbstractItemView.SelectRows)
            view.setShowGrid(False)
            view.verticalHeader().setDefaultSectionSize(
                QApplication.font().pointSize() + 2)
            view.verticalHeader().hide()

        self.ui.teDiff.setFont(QFont('Monospace'))

        self.difftools = []

        timer = QTimer(self)
        timer.timeout.connect(self.on_timer)
        timer.start(5000)

        self.dir_name = self.settings.value('last_opened_repo', None)

        try:
            pygit2.Repository(self.dir_name)
        except Exception:  #pylint: disable=broad-except
            self.open_dir()
            return

        self.open_repo()

    def open_dir(self):
        """ show open dir dialog and open repo """
        last_dir = self.settings.value('last_fileopen_dir', '')

        fd = QFileDialog(self, 'Open .git', last_dir)
        fd.setFileMode(QFileDialog.DirectoryOnly)
        fd.setFilter(
            QDir.Filters(QDir.Dirs | QDir.Hidden | QDir.NoDot | QDir.NoDotDot))

        while True:
            if not fd.exec():
                return
            self.dir_name = fd.selectedFiles()[0]
            parent = os.path.dirname(self.dir_name)
            self.settings.setValue('last_fileopen_dir', parent)
            self.settings.setValue('last_opened_repo', self.dir_name)

            try:
                pygit2.Repository(self.dir_name)
                break
            except pygit2.GitError:
                QMessageBox(self,
                            text='Cannot open repo: ' + self.dir_name).exec()

        self.open_repo()

    def open_repo(self):
        """ called either on start or after open dialog """

        self.setWindowTitle(f'{self.dir_name} - pqgit ({VERSION})')
        self.repo = pygit2.Repository(self.dir_name)

        # remove existing files and folder from watch
        if self.fs_watch.files():
            self.fs_watch.removePaths(self.fs_watch.files())
        if self.fs_watch.directories():
            self.fs_watch.removePaths(self.fs_watch.directories())

        wd = self.repo.workdir
        self.fs_watch.addPath(wd)

        # get head tree for list of files in repo
        target = self.repo.head.target
        last_commit = self.repo[target]
        tree_id = last_commit.tree_id
        tree = self.repo[tree_id]
        # add those files and folder to watch
        self.fs_watch.addPaths([wd + o[0] for o in parse_tree_rec(tree, True)])
        # get files/folders not in repo from status
        self.fs_watch.addPaths([
            wd + p for p, f in self.repo.status().items()
            if GIT_STATUS[f] != 'I'
        ])
        # (doesn't matter some are in both lists, already monitored ones will not be added by Qt)

        # local branches
        branches = []
        selected_branch_row = 0
        for idx, b_str in enumerate(self.repo.branches.local):
            b = self.repo.branches[b_str]
            if b.is_checked_out():
                selected_branch_row = idx
            branches.append(
                Branch(name=b.branch_name, ref=b.name, c_o=b.is_checked_out()))

        # tags
        regex = re.compile('^refs/tags')
        tags = list(filter(regex.match, self.repo.listall_references()))

        branches += [Branch(name=t[10:], ref=t, c_o=False) for t in tags]

        self.branches_model.update(branches)

        idx1 = self.branches_model.index(selected_branch_row, 0)
        idx2 = self.branches_model.index(selected_branch_row,
                                         self.branches_model.columnCount() - 1)
        self.ui.tvBranches.selectionModel().select(QItemSelection(idx1, idx2),
                                                   QItemSelectionModel.Select)

        self.ui.tvHistory.resizeColumnsToContents()

    def on_timer(self):
        """ poll opened diff tools (like meld) and close temp files when finished """
        for dt in self.difftools:
            if subprocess.Popen.poll(dt.proc) is not None:
                if dt.old_f:
                    dt.old_f.close()
                if dt.new_f:
                    dt.new_f.close()
                dt.running = False

        self.difftools[:] = [dt for dt in self.difftools if dt.running]

    def on_file_changed(self, path):
        """ existing files edited """
        patch = self.files_model.patches[
            self.ui.tvFiles.selectionModel().selectedRows()[0].row()]
        if self.repo.workdir + patch.path == path:
            self.files_selection_changed()

    def on_dir_changed(self, path):
        """ file added/deleted; refresh history to show it in 'working' """
        # remember history selection
        history_ids = []
        for idx in self.ui.tvHistory.selectionModel().selectedRows():
            history_ids.append(self.history_model.commits[idx.row()].id)

        bak_path = self.files_model.patches[
            self.ui.tvFiles.selectionModel().selectedRows()[0].row()].path

        self.refresh_history()

        # restore history selection
        for i in history_ids:
            for row, c in enumerate(self.history_model.commits):
                if c.id == i:
                    idx1 = self.history_model.index(row, 0)
                    idx2 = self.history_model.index(
                        row,
                        self.history_model.columnCount() - 1)
                    self.ui.tvHistory.selectionModel().select(
                        QItemSelection(idx1, idx2), QItemSelectionModel.Select)

        # restore file selection
        if not bak_path:
            return
        for row, patch in enumerate(self.files_model.patches):
            if patch.path == bak_path:
                idx1 = self.files_model.index(row, 0)
                idx2 = self.files_model.index(
                    row,
                    self.files_model.columnCount() - 1)

                self.ui.tvFiles.selectionModel().select(
                    QItemSelection(idx1, idx2), QItemSelectionModel.Select)
                break

    def refresh_history(self):
        """ called and branch check-out (which is also called during start-up) to populate commit log """

        commits = []
        # working directory
        status = self.repo.status()
        if len(status.items()) > 0:
            commits.append(Commit('working', 'working', None, None, None,
                                  None))

        for c in self.repo.walk(self.repo.head.target,
                                pygit2.GIT_SORT_TOPOLOGICAL):
            commit = Commit(id=c.id.hex,
                            tree_id=c.tree_id.hex,
                            author=c.author,
                            dt=c.commit_time,
                            dt_offs=c.commit_time_offset,
                            message=c.message.strip())
            commits.append(commit)

        self.history_model.update(commits)
        self.ui.tvHistory.resizeColumnsToContents()

    def branches_selection_changed(self):
        """ checkout selected branch """
        selected_row = self.ui.tvBranches.selectionModel().selectedRows(
        )[0].row()
        self.repo.checkout(self.branches_model.branches[selected_row].ref,
                           strategy=pygit2.GIT_CHECKOUT_SAFE)
        self.refresh_history()

    def on_file_doubleclicked(self, index):
        """ get files contents for revisions and start diff tool """

        patch = self.files_model.patches[index.row()]
        if not patch.old_file_id:
            msg_box = QMessageBox(self)
            msg_box.setText("Nothing to compare to.")
            msg_box.exec()
            return

        old_f = tempfile.NamedTemporaryFile(
            prefix=f'old_{self.old_c_id[:7]}__')
        old_f.write(self.repo[patch.old_file_id].data)
        old_f.flush()

        new_f = None
        if patch.new_file_id:
            # compare 2 revisions
            new_f = tempfile.NamedTemporaryFile(
                prefix=f'new_{self.new_c_id[:7]}__')
            new_f.write(self.repo[patch.new_file_id].data)
            new_f.flush()
            new_f_name = new_f.name
        else:
            # compare some revision with working copy
            new_f_name = self.repo.workdir + patch.path.strip()

        proc = subprocess.Popen(
            [self.settings.value('diff_tool', 'meld'), old_f.name, new_f_name])
        self.difftools.append(Proc(proc, old_f, new_f, True))

    def history_selection_changed(self, selected):
        """ docstring """

        self.ui.teDiff.setText('')
        self.new_c_id, self.old_c_id = None, None

        selection_model = self.ui.tvHistory.selectionModel()
        selected_rows = selection_model.selectedRows()
        self.ui.teCommit.setPlainText('')

        commit = None
        fst_tid, fst_obj = None, None
        snd_tid, snd_obj = None, None

        if len(selected_rows) < 1:
            # nothing to do
            return

        if len(selected_rows) > 2:
            # don't allow more than 2 selected lines
            selection_model.select(selected, QItemSelectionModel.Deselect)
            return

        if len(selected_rows) == 1:
            # single revision selected

            commit = self.history_model.commits[selected_rows[0].row()]
            fst_tid = commit.tree_id

            if selected_rows[0].row() + 1 < self.history_model.rowCount():
                # there is a parent, get it's id to compare to it
                snd_commit = self.history_model.commits[selected_rows[0].row()
                                                        + 1]
                snd_tid = snd_commit.tree_id
                self.new_c_id = commit.id
                self.old_c_id = snd_commit.id

            # set commit details in view
            if commit.tree_id != 'working':
                text = 'Commit: ' + commit.id + '\n\n'
                text += 'Author: ' + commit.author.name + ' <' + commit.author.email + '>\n\n'
                text += commit.message + '\n'
                self.ui.teCommit.setPlainText(text)

        else:
            # 2 revisions selected
            fst_row, snd_row = tuple(
                sorted([selected_rows[0].row(), selected_rows[1].row()]))
            commit = self.history_model.commits[fst_row]
            fst_tid = commit.tree_id
            snd_commit = self.history_model.commits[snd_row]
            snd_tid = snd_commit.tree_id
            self.new_c_id = commit.id
            self.old_c_id = snd_commit.id

        if fst_tid != 'working':
            fst_obj = self.repo.revparse_single(fst_tid)

        if snd_tid:
            snd_obj = self.repo.revparse_single(snd_tid)

        diff = None
        if fst_tid == 'working':
            # diff for working directory only shows... some files; get them anyway, then insert the ones from status
            diff = self.repo.diff(
                snd_obj, None)  # regardless of snd_obj being something or None
            patches = [
                Patch(
                    p.delta.new_file.path.strip(),  #
                    p.delta.status_char(),
                    None,  # p.delta.new_file.id.hex is 'some' id, but it's somehow not ok...
                    p.delta.old_file.id.hex
                    if p.delta.old_file.id.hex.find('00000') < 0 else None,
                ) for p in diff
            ]
            inserted = [p.delta.new_file.path for p in diff]
            status = self.repo.status()
            for path, flags in status.items():
                if path not in inserted:
                    patches.append(
                        Patch(path.strip(), GIT_STATUS[flags], None, None))

        elif snd_obj:
            diff = self.repo.diff(snd_obj, fst_obj)

            patches = [
                Patch(
                    p.delta.new_file.path.strip(),  #
                    p.delta.status_char(),
                    p.delta.new_file.id.hex
                    if p.delta.new_file.id.hex.find('00000') < 0 else None,
                    p.delta.old_file.id.hex
                    if p.delta.old_file.id.hex.find('00000') < 0 else None,
                ) for p in diff
            ]

        else:
            # initial revision
            patches = [
                Patch(o[0], 'A', o[1], None) for o in parse_tree_rec(fst_obj)
            ]

        patches = sorted(patches, key=lambda p: p.path)
        self.files_model.update(patches)
        self.ui.tvFiles.resizeColumnsToContents()

    def files_selection_changed(self):
        """ show diff (or file content for new, ignored, ... files) """
        patch = self.files_model.patches[
            self.ui.tvFiles.selectionModel().selectedRows()[0].row()]

        nf_data, of_data = None, None  # new_file, old_file
        if patch.new_file_id:
            nf_data = self.repo[patch.new_file_id].data.decode('utf-8')
        if patch.old_file_id:
            of_data = self.repo[patch.old_file_id].data.decode('utf-8')

        if nf_data and of_data:
            html = _html_diff.make_file(
                fromlines=nf_data.splitlines(),  #
                tolines=of_data.splitlines(),
                fromdesc=f'old ({self.old_c_id[:7]})',
                todesc=f'new ({self.new_c_id[:7]})',
                context=True)
            self.ui.teDiff.setHtml(html)
        elif nf_data:
            self.ui.teDiff.setText(nf_data)
        elif of_data:
            if patch.status == 'M':
                # this should be working directory compared to something else
                with open(self.repo.workdir + patch.path.strip()) as f:
                    nf_data = f.read()
                html = _html_diff.make_file(
                    fromlines=nf_data.splitlines(),
                    tolines=of_data.splitlines(),
                    fromdesc=f'old ({self.old_c_id[:7]})',
                    todesc=f'new ({self.new_c_id[:7]})',
                    context=True)
                self.ui.teDiff.setHtml(html)
            else:
                self.ui.teDiff.setText(of_data)
        else:
            with open(self.repo.workdir + patch.path.strip()) as f:
                self.ui.teDiff.setPlainText(f.read())

        self.ui.diff_groupbox.setTitle(
            'Diff' if nf_data and of_data else 'File')

    def closeEvent(self, event):  # pylint: disable=invalid-name, no-self-use
        """ event handler for window closing; save settings """
        del event
        self.settings.setValue('w/pos', self.pos())
        self.settings.setValue('w/size', self.size())
        self.settings.setValue('w/hist_splitter',
                               self.ui.hist_splitter.sizes())
        self.settings.setValue('w/cinf_splitter',
                               self.ui.cinf_splitter.sizes())
        self.settings.setValue('w/diff_splitter',
                               self.ui.diff_splitter.sizes())

        # delete any left temp files
        self.on_timer()