コード例 #1
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"
コード例 #2
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()