diff --git a/plugins/projectmanagerview/CMakeLists.txt b/plugins/projectmanagerview/CMakeLists.txt --- a/plugins/projectmanagerview/CMakeLists.txt +++ b/plugins/projectmanagerview/CMakeLists.txt @@ -9,6 +9,7 @@ projectbuildsetwidget.cpp vcsoverlayproxymodel.cpp projectmodelitemdelegate.cpp + cutcopypastehelpers.cpp ) ki18n_wrap_ui( kdevprojectmanagerview_PLUGIN_SRCS projectbuildsetwidget.ui projectmanagerview.ui ) diff --git a/plugins/projectmanagerview/cutcopypastehelpers.h b/plugins/projectmanagerview/cutcopypastehelpers.h new file mode 100644 --- /dev/null +++ b/plugins/projectmanagerview/cutcopypastehelpers.h @@ -0,0 +1,71 @@ +/* This file is part of KDevelop + Copyright (C) 2017 Alexander Potashev + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Library General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#ifndef KDEVPLATFORM_PLUGIN_PROJECTMANAGERVIEW_CUTCOPYPASTEHELPERS_H +#define KDEVPLATFORM_PLUGIN_PROJECTMANAGERVIEW_CUTCOPYPASTEHELPERS_H + +#include +#include + +namespace CutCopyPasteHelpers +{ + +enum class TaskStatus +{ + SUCCESS, + FAILURE, + SKIPPED, +}; + +enum class TaskType +{ + COPY, + MOVE, + DELETION, +}; + +struct TaskInfo +{ + TaskInfo() = default; + TaskInfo(const TaskStatus status, const TaskType type, + const KDevelop::Path::List& src, const KDevelop::Path& dest); + + static TaskInfo createMove(const bool ok, const KDevelop::Path::List& src, const KDevelop::Path& dest); + static TaskInfo createCopy(const bool ok, const KDevelop::Path::List& src, const KDevelop::Path& dest); + static TaskInfo createDeletion(const bool ok, const KDevelop::Path::List& src, const KDevelop::Path& dest); + + TaskStatus m_status; + TaskType m_type; + KDevelop::Path::List m_src; + KDevelop::Path m_dest; +}; + +void getSrcDestMapping(const KDevelop::Path::List& paths, const KDevelop::Path& destPath, + KDevelop::Path::List& filteredPaths, + QHash& mapping); + +QVector copyMoveItems(const KDevelop::Path::List& paths, KDevelop::ProjectBaseItem* destItem, const bool isCut); + +void warningPasteFailed(QWidget* parent, const QVector& tasks); + +} // namespace CutCopyPasteHelpers + +Q_DECLARE_TYPEINFO(CutCopyPasteHelpers::TaskInfo, Q_MOVABLE_TYPE); + +#endif // KDEVPLATFORM_PLUGIN_PROJECTMANAGERVIEW_CUTCOPYPASTEHELPERS_H diff --git a/plugins/projectmanagerview/cutcopypastehelpers.cpp b/plugins/projectmanagerview/cutcopypastehelpers.cpp new file mode 100644 --- /dev/null +++ b/plugins/projectmanagerview/cutcopypastehelpers.cpp @@ -0,0 +1,342 @@ +/* This file is part of KDevelop + Copyright (C) 2017 Alexander Potashev + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Library General Public License + along with this library; see the file COPYING.LIB. If not, write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA 02110-1301, USA. +*/ + +#include "cutcopypastehelpers.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +using namespace KDevelop; + +namespace CutCopyPasteHelpers +{ + +TaskInfo::TaskInfo(const TaskStatus status, const TaskType type, + const Path::List& src, const Path& dest) + : m_status(status), + m_type(type), + m_src(src), + m_dest(dest) +{ +} + +TaskInfo TaskInfo::createMove(const bool ok, const Path::List& src, const Path& dest) +{ + return TaskInfo(ok ? TaskStatus::SUCCESS : TaskStatus::FAILURE, + TaskType::MOVE, src, dest); +} + +TaskInfo TaskInfo::createCopy(const bool ok, const Path::List& src, const Path& dest) +{ + return TaskInfo(ok ? TaskStatus::SUCCESS : TaskStatus::FAILURE, + TaskType::COPY, src, dest); +} + +TaskInfo TaskInfo::createDeletion(const bool ok, const Path::List& src, const Path& dest) +{ + return TaskInfo(ok ? TaskStatus::SUCCESS : TaskStatus::FAILURE, + TaskType::DELETION, src, dest); +} + +static QWidget* createPasteStatsWidget(QWidget *parent, const QVector& tasks) +{ + QTreeWidget* treeWidget = new QTreeWidget(parent); + QList items; + foreach (const TaskInfo& task, tasks) { + int srcCount = task.m_src.size(); + const bool withChildren = srcCount != 1; + + const QString destPath = task.m_dest.pathOrUrl(); + + QString text; + if (withChildren) { + // Multiple source items in the current suboperation + switch (task.m_type) { + case TaskType::MOVE: + text = i18np("Move %1 item into %2", "Move %1 items into %2", srcCount, destPath); + break; + case TaskType::COPY: + text = i18np("Copy %1 item into %2", "Copy %1 items into %2", srcCount, destPath); + break; + case TaskType::DELETION: + text = i18np("Delete %1 item", "Delete %1 items", srcCount); + break; + } + } else { + // One source item in the current suboperation + const QString srcPath = task.m_src[0].pathOrUrl(); + + switch (task.m_type) { + case TaskType::MOVE: + text = i18n("Move item %1 into %2", srcPath, destPath); + break; + case TaskType::COPY: + text = i18n("Copy item %1 into %2", srcPath, destPath); + break; + case TaskType::DELETION: + text = i18n("Delete item %1", srcPath); + break; + } + } + + QString tooltip; + QString iconName; + switch (task.m_status) { + case TaskStatus::SUCCESS: + tooltip = i18n("Suboperation succeeded"); + iconName = QStringLiteral("dialog-ok"); + break; + case TaskStatus::FAILURE: + tooltip = i18n("Suboperation failed"); + iconName = QStringLiteral("dialog-error"); + break; + case TaskStatus::SKIPPED: + tooltip = i18n("Suboperation skipped to prevent data loss"); + iconName = QStringLiteral("dialog-warning"); + break; + } + + QTreeWidgetItem* item = new QTreeWidgetItem; + item->setText(0, text); + item->setIcon(0, QIcon::fromTheme(iconName)); + item->setToolTip(0, tooltip); + items.append(item); + + if (withChildren) { + foreach (const Path& src, task.m_src) { + QTreeWidgetItem* childItem = new QTreeWidgetItem; + childItem->setText(0, src.pathOrUrl()); + item->addChild(childItem); + } + } + } + treeWidget->insertTopLevelItems(0, items); + treeWidget->headerItem()->setHidden(true); + + return treeWidget; +} + +void getSrcDestMapping(const Path::List& paths, const Path& destPath, + Path::List& filteredPaths, QHash& mapping) +{ + // For example you are moving the following items into /dest/ + // * /tests/ + // * /tests/abc.cpp + // If you pass them as is, moveFilesAndFolders() will crash (see note: + // "Do not attempt to move subitems along with their parents"). + // Thus we filter out subitems from "Path::List filteredPaths". + // + // /tests/abc.cpp will be implicitly moved to /dest/tests/abc.cpp, for + // that reason we add "/dest/tests/abc.cpp" into "mapping" as well as + // "/dest/tests". + // + // "mapping" will be used to highlight destination items after + // copy/move. + Path::List sortedPaths = paths; + std::sort(sortedPaths.begin(), sortedPaths.end()); + + foreach (const Path& path, sortedPaths) { + if (!filteredPaths.isEmpty() && filteredPaths.rbegin()->isParentOf(path)) { + // think: "/tests" + const Path& previousPath = *filteredPaths.rbegin(); + // think: "/dest" + "/".relativePath("/tests/abc.cpp") = /dest/tests/abc.cpp + mapping[previousPath].append(Path(destPath, previousPath.parent().relativePath(path))); + } else { + // think: "/tests" + filteredPaths.append(path); + // think: "/dest" + "tests" = "/dest/tests" + mapping[path].append(Path(destPath, path.lastPathSegment())); + } + } +} + +static void classifyPaths(const Path::List& paths, KDevelop::ProjectModel* projectModel, + QHash>& itemsPerProject, + Path::List& alienSrcPaths) +{ + foreach (const Path& path, paths) { + QList items = projectModel->itemsForPath(IndexedString(path.path())); + if (!items.empty()) { + foreach (ProjectBaseItem* item, items) { + IProject* project = item->project(); + if (!itemsPerProject.contains(project)) + itemsPerProject[project] = QList(); + + itemsPerProject[project].append(item); + } + } else { + alienSrcPaths.append(path); + } + } +} + +QVector copyMoveItems(const Path::List& paths, ProjectBaseItem* destItem, const bool isCut) +{ + // Items originating from projects open in this KDevelop session + QHash> itemsPerProject; + // Items that do not belong to known projects + Path::List alienSrcPaths; + + KDevelop::ProjectModel* projectModel = KDevelop::ICore::self()->projectController()->projectModel(); + classifyPaths(paths, projectModel, itemsPerProject, alienSrcPaths); + + QVector tasks; + + IProject* destProject = destItem->project(); + IProjectFileManager* destProjectFileManager = destProject->projectFileManager(); + ProjectFolderItem* destFolder = destItem->folder(); + Path destPath = destFolder->path(); + foreach (IProject* srcProject, itemsPerProject.keys()) { + const auto& itemsList = itemsPerProject[srcProject]; + + Path::List pathsList; + foreach (KDevelop::ProjectBaseItem* item, itemsList) + pathsList.append(item->path()); + + if (srcProject == destProject) { + if (isCut) { + // Move inside project + const bool ok = destProjectFileManager->moveFilesAndFolders(itemsList, destFolder); + tasks.append(TaskInfo::createMove(ok, pathsList, destPath)); + } else { + // Copy inside project + const bool ok = destProjectFileManager->copyFilesAndFolders(pathsList, destFolder); + tasks.append(TaskInfo::createCopy(ok, pathsList, destPath)); + } + } else { + // Copy/move between projects: + // 1. Copy and add into destination project; + // 2. Remove from source project. + const bool copy_ok = destProjectFileManager->copyFilesAndFolders(pathsList, destFolder); + tasks.append(TaskInfo::createCopy(copy_ok, pathsList, destPath)); + + if (isCut) { + if (copy_ok) { + IProjectFileManager* srcProjectFileManager = srcProject->projectFileManager(); + const bool deletion_ok = srcProjectFileManager->removeFilesAndFolders(itemsList); + tasks.append(TaskInfo::createDeletion(deletion_ok, pathsList, destPath)); + } else { + tasks.append(TaskInfo(TaskStatus::SKIPPED, TaskType::DELETION, pathsList, destPath)); + } + } + } + } + + // Copy/move items from outside of all open projects + if (!alienSrcPaths.isEmpty()) { + const bool alien_copy_ok = destProjectFileManager->copyFilesAndFolders(alienSrcPaths, destFolder); + tasks.append(TaskInfo::createCopy(alien_copy_ok, alienSrcPaths, destPath)); + + if (isCut) { + if (alien_copy_ok) { + QList urlsToDelete; + foreach (Path path, alienSrcPaths) { + urlsToDelete.append(path.toUrl()); + } + + KIO::DeleteJob* deleteJob = KIO::del(urlsToDelete); + const bool deletion_ok = deleteJob->exec(); + tasks.append(TaskInfo::createDeletion(deletion_ok, alienSrcPaths, destPath)); + } else { + tasks.append(TaskInfo(TaskStatus::SKIPPED, TaskType::DELETION, alienSrcPaths, destPath)); + } + } + } + + return tasks; +} + +void warningPasteFailed(QWidget* parent, const QVector& tasks) +{ + QDialog* dialog = new QDialog(parent); + + dialog->setWindowTitle(i18nc("@title:window", "Paste Failed")); + + QDialogButtonBox *buttonBox = new QDialogButtonBox(dialog); + buttonBox->setStandardButtons(QDialogButtonBox::Ok); + QObject::connect(buttonBox, &QDialogButtonBox::clicked, dialog, &QDialog::accept); + + dialog->setWindowModality(Qt::WindowModal); + dialog->setModal(true); + + QWidget* mainWidget = new QWidget(dialog); + QVBoxLayout* mainLayout = new QVBoxLayout(mainWidget); + const int spacingHint = mainWidget->style()->pixelMetric(QStyle::PM_DefaultLayoutSpacing); + mainLayout->setSpacing(spacingHint * 2); // provide extra spacing + mainLayout->setMargin(0); + + QHBoxLayout* hLayout = new QHBoxLayout; + hLayout->setMargin(0); + hLayout->setSpacing(-1); // use default spacing + mainLayout->addLayout(hLayout, 0); + + QLabel* iconLabel = new QLabel(mainWidget); + + // Icon + QStyleOption option; + option.initFrom(mainWidget); + QIcon icon = QIcon::fromTheme(QStringLiteral("dialog-warning")); + iconLabel->setPixmap(icon.pixmap(mainWidget->style()->pixelMetric(QStyle::PM_MessageBoxIconSize, &option, mainWidget))); + + QVBoxLayout* iconLayout = new QVBoxLayout(); + iconLayout->addStretch(1); + iconLayout->addWidget(iconLabel); + iconLayout->addStretch(5); + + hLayout->addLayout(iconLayout, 0); + hLayout->addSpacing(spacingHint); + + const QString text = i18n("Failed to paste. Below is a list of suboperations that have been attempted."); + QLabel* messageLabel = new QLabel(text, mainWidget); + messageLabel->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard); + hLayout->addWidget(messageLabel, 5); + + QWidget* statsWidget = createPasteStatsWidget(dialog, tasks); + + QVBoxLayout* topLayout = new QVBoxLayout; + dialog->setLayout(topLayout); + topLayout->addWidget(mainWidget); + topLayout->addWidget(statsWidget, 1); + topLayout->addWidget(buttonBox); + + dialog->setMinimumSize(300, qMax(150, qMax(iconLabel->sizeHint().height(), messageLabel->sizeHint().height()))); + + QPointer guardedDialog = dialog; + guardedDialog->exec(); + delete guardedDialog; +} + +} // namespace CutCopyPasteHelpers diff --git a/plugins/projectmanagerview/projectmanagerviewplugin.cpp b/plugins/projectmanagerview/projectmanagerviewplugin.cpp --- a/plugins/projectmanagerview/projectmanagerviewplugin.cpp +++ b/plugins/projectmanagerview/projectmanagerviewplugin.cpp @@ -1,6 +1,7 @@ /* This file is part of KDevelop Copyright 2004 Roberto Raggi Copyright 2007 Andreas Pakulat + Copyright 2017 Alexander Potashev This library is free software; you can redistribute it and/or modify it under the terms of the GNU Library General Public @@ -32,6 +33,7 @@ #include #include #include +#include #include #include @@ -49,9 +51,11 @@ #include #include #include +#include #include "projectmanagerview.h" #include "debug.h" +#include "cutcopypastehelpers.h" using namespace KDevelop; @@ -673,6 +677,21 @@ } } +static void selectItemsByPaths(ProjectManagerView* view, const Path::List& paths) +{ + KDevelop::ProjectModel* projectModel = KDevelop::ICore::self()->projectController()->projectModel(); + + QList newItems; + foreach (const Path& path, paths) { + QList items = projectModel->itemsForPath(IndexedString(path.path())); + newItems.append(items); + foreach (ProjectBaseItem* item, items) { + view->expandItem(item->parent()); + } + } + view->selectItems(newItems); +} + void ProjectManagerViewPlugin::pasteFromContextMenu() { KDevelop::ProjectItemContext* ctx = dynamic_cast(ICore::self()->selectionController()->currentSelection()); @@ -685,28 +704,43 @@ const QMimeData* data = qApp->clipboard()->mimeData(); qCDebug(PLUGIN_PROJECTMANAGERVIEW) << data->urls(); - const Path::List paths = toPathList(data->urls()); - bool success = destItem->project()->projectFileManager()->copyFilesAndFolders(paths, destItem->folder()); - - if (success) { - ProjectManagerViewItemContext* viewCtx = dynamic_cast(ICore::self()->selectionController()->currentSelection()); - if (viewCtx) { - - //expand target folder - viewCtx->view()->expandItem(destItem); - - //and select new items - QList newItems; - foreach (const Path &path, paths) { - const Path targetPath(destItem->path(), path.lastPathSegment()); - foreach (ProjectBaseItem *item, destItem->children()) { - if (item->path() == targetPath) { - newItems << item; - } - } + Path::List origPaths = toPathList(data->urls()); + const bool isCut = KIO::isClipboardDataCut(data); + + Path::List paths; + // finalPaths is a map: source path -> new paths. If source path + // succeeds to copy/move, then the new paths must be highlighted + // in the project manager view. + // Highlighting of all destination files without regard of which + // operations were successful won't work in the case when the destination + // file already exists and a replacing copy/move fails. + QHash finalPaths; + CutCopyPasteHelpers::getSrcDestMapping(origPaths, destItem->folder()->path(), paths, finalPaths); + + QVector tasks = CutCopyPasteHelpers::copyMoveItems(paths, destItem, isCut); + + // Select new items in the project manager view + ProjectManagerViewItemContext* itemCtx = dynamic_cast(ICore::self()->selectionController()->currentSelection()); + if (itemCtx) { + Path::List finalPathsList; + foreach (const auto& task, tasks) { + if (task.m_status == CutCopyPasteHelpers::TaskStatus::SUCCESS && task.m_type != CutCopyPasteHelpers::TaskType::DELETION) { + foreach (const Path& src, task.m_src) + finalPathsList.append(finalPaths[src]); } - viewCtx->view()->selectItems(newItems); } + + selectItemsByPaths(itemCtx->view(), finalPathsList); + } + + // If there was a single failure, display a warning dialog. + const bool anyFailed = std::any_of(tasks.begin(), tasks.end(), + [](const CutCopyPasteHelpers::TaskInfo& task) { + return task.m_status != CutCopyPasteHelpers::TaskStatus::SUCCESS; + }); + if (anyFailed) { + QWidget* window = ICore::self()->uiController()->activeMainWindow()->window(); + warningPasteFailed(window, tasks); } }