diff --git a/CMakeLists.txt b/CMakeLists.txt index 1d08681..da7d37d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,6 +15,12 @@ include(GNUInstallDirs) find_package(Qt6 COMPONENTS Core DBus Quick LinguistTools REQUIRED) +find_path(LIBZIP_INCLUDE_DIR zip.h) +find_library(LIBZIP_LIBRARY NAMES zip) +if (NOT LIBZIP_INCLUDE_DIR OR NOT LIBZIP_LIBRARY) + message(FATAL_ERROR "libzip development files are required to build filemanager") +endif () + find_package(KF6KIO) find_package(KF6Solid) find_package(KF6WindowSystem) @@ -59,6 +65,7 @@ add_library(cutefish-filemanager-core STATIC cio/cfilejob.cpp cio/cfilesizejob.cpp + cio/archivejob.cpp dialogs/createfolderdialog.cpp dialogs/filepropertiesdialog.cpp @@ -109,8 +116,11 @@ target_link_libraries(cutefish-filemanager-core KF6::WindowSystem KF6::ConfigCore KF6::XmlGui + ${LIBZIP_LIBRARY} ) +target_include_directories(cutefish-filemanager-core PRIVATE ${LIBZIP_INCLUDE_DIR}) + # --------------------------------------------------------------------------- # cutefish-filemanager -- the file browser window # --------------------------------------------------------------------------- diff --git a/cio/archivejob.cpp b/cio/archivejob.cpp new file mode 100644 index 0000000..726d6ea --- /dev/null +++ b/cio/archivejob.cpp @@ -0,0 +1,721 @@ +#include "archivejob.h" + +#include + +#include +#include +#include +#include +#include + +#include +#include + +#ifdef Q_OS_UNIX +#include +#endif + +namespace { + +QString zipError(zip_t *archive) +{ + if (!archive) + return QStringLiteral("Unknown ZIP error"); + + return QString::fromUtf8(zip_strerror(archive)); +} + +QString zipOpenError(int errorCode) +{ + zip_error_t error; + zip_error_init_with_code(&error, errorCode); + const QString message = QString::fromUtf8(zip_error_strerror(&error)); + zip_error_fini(&error); + return message; +} + +QString temporaryPath(const QString &parentPath, const QString &prefix) +{ + return QDir(parentPath).filePath(QStringLiteral(".%1-%2") + .arg(prefix, QUuid::createUuid().toString(QUuid::WithoutBraces))); +} + +bool isDirectoryEntry(const QString &name) +{ + return name.endsWith(QLatin1Char('/')); +} + +bool hasSymbolicLinkParent(const QString &rootPath, const QString &relativePath) +{ + const QStringList components = relativePath.split(QLatin1Char('/'), Qt::SkipEmptyParts); + QString currentPath = rootPath; + + for (qsizetype index = 0; index + 1 < components.size(); ++index) { + currentPath = QDir(currentPath).filePath(components.at(index)); + const QFileInfo info(currentPath); + if (info.isSymLink()) + return true; + + if (!info.exists()) + break; + } + + return false; +} + +QString topLevelArchiveName(const QString &archivePath) +{ + const qsizetype separator = archivePath.indexOf(QLatin1Char('/')); + return separator < 0 ? archivePath : archivePath.left(separator); +} + +} // namespace + +ArchiveJob::ArchiveJob(Operation operation, + const QStringList &inputs, + const QString &outputPath, + QObject *parent) + : QThread(parent) + , m_operation(operation) + , m_inputs(inputs) + , m_outputPath(outputPath) + , m_cancelRequested(false) +{ +} + +ArchiveJob::~ArchiveJob() +{ + cancel(); + wait(); +} + +void ArchiveJob::cancel() +{ + m_cancelRequested.store(true); +} + +bool ArchiveJob::isCanceled() const +{ + return m_cancelRequested.load(); +} + +void ArchiveJob::run() +{ + QString error; + const bool success = m_operation == Compress ? compress(&error) : extract(&error); + // Once the final rename has succeeded, the operation is committed. A + // cancellation arriving in the tiny window before this function reads + // the flag must not turn a completed operation into a false cancellation. + const bool canceled = !success && isCanceled(); + + emit completed(success && !canceled, canceled, error, + success && !canceled ? m_outputPath : QString()); +} + +void ArchiveJob::emitProgress(quint64 processed, quint64 total) +{ + if (total == 0) { + emit progressChanged(processed == 0 ? 0 : 100); + return; + } + + const double ratio = static_cast(processed) / static_cast(total); + emit progressChanged(qBound(0, static_cast(ratio * 100.0), 100)); +} + +bool ArchiveJob::collectCompressionEntries(const QString &sourcePath, + const QString &archivePath, + QList *entries, + QString *error) const +{ + if (isCanceled()) + return false; + + const QFileInfo info(sourcePath); + if (!info.exists() && !info.isSymLink()) { + *error = QStringLiteral("The source no longer exists: %1").arg(sourcePath); + return false; + } + + if (info.isSymLink()) { +#ifdef Q_OS_UNIX + const QString linkTarget = info.symLinkTarget(); + if (linkTarget.isEmpty()) { + *error = QStringLiteral("Could not read symbolic link: %1").arg(sourcePath); + return false; + } + + entries->append({sourcePath, archivePath, false, 0, true, linkTarget}); + return true; +#else + *error = QStringLiteral("Symbolic links are not supported: %1").arg(sourcePath); + return false; +#endif + } + + if (info.isDir()) { + const QString directoryArchivePath = archivePath.endsWith(QLatin1Char('/')) + ? archivePath + : archivePath + QLatin1Char('/'); + entries->append({sourcePath, directoryArchivePath, true, 0, false, QString()}); + + QDir directory(sourcePath); + const QFileInfoList children = directory.entryInfoList( + QDir::AllEntries | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot, + QDir::DirsFirst | QDir::Name); + + for (const QFileInfo &child : children) { + const QString childArchivePath = directoryArchivePath + child.fileName(); + if (!collectCompressionEntries(child.absoluteFilePath(), childArchivePath, entries, error)) + return false; + } + + return true; + } + + if (!info.isFile()) { + *error = QStringLiteral("The source is not a regular file: %1").arg(sourcePath); + return false; + } + + entries->append({sourcePath, archivePath, false, static_cast(info.size()), false, QString()}); + return true; +} + +bool ArchiveJob::compress(QString *error) +{ + if (m_inputs.isEmpty()) { + *error = QStringLiteral("No files were selected"); + return false; + } + + QList entries; + for (const QString &input : m_inputs) { + const QFileInfo info(input); + if (!collectCompressionEntries(input, info.fileName(), &entries, error)) + return false; + } + + if (isCanceled()) + return false; + + const QString parentPath = QFileInfo(m_outputPath).absolutePath(); + if (!QDir().exists(parentPath)) { + *error = QStringLiteral("The destination folder does not exist: %1").arg(parentPath); + return false; + } + + const QString tempPath = temporaryPath(parentPath, QStringLiteral("cutefish-archive")); + int openError = 0; + const QByteArray tempPathBytes = QFile::encodeName(tempPath); + zip_t *archive = zip_open(tempPathBytes.constData(), ZIP_CREATE | ZIP_TRUNCATE, &openError); + if (!archive) { + QFile::remove(tempPath); + *error = zipOpenError(openError); + return false; + } + + if (zip_register_progress_callback_with_state(archive, 0.01, &ArchiveJob::zipProgressCallback, + nullptr, this) != 0 || + zip_register_cancel_callback_with_state(archive, &ArchiveJob::zipCancelCallback, + nullptr, this) != 0) { + *error = zipError(archive); + zip_discard(archive); + QFile::remove(tempPath); + return false; + } + + emit progressChanged(0); + for (const CompressionEntry &entry : entries) { + if (isCanceled()) { + zip_discard(archive); + QFile::remove(tempPath); + return false; + } + + // Keep the progress UI focused on the selected item. A folder may + // contain many entries, but its internal archive paths are not useful + // to show while the archive is being created. + emit currentFileChanged(topLevelArchiveName(entry.archivePath)); + + const QByteArray archiveName = entry.archivePath.toUtf8(); + if (entry.directory) { + if (zip_dir_add(archive, archiveName.constData(), ZIP_FL_ENC_UTF_8) < 0) { + *error = zipError(archive); + zip_discard(archive); + QFile::remove(tempPath); + return false; + } + continue; + } + + zip_source_t *source = nullptr; + if (entry.symbolicLink) { + const QByteArray linkTarget = entry.linkTarget.toUtf8(); + const size_t bufferSize = qMax(linkTarget.size(), 1); + char *buffer = static_cast(std::malloc(bufferSize)); + if (!buffer) { + *error = QStringLiteral("Could not allocate memory for symbolic link"); + zip_discard(archive); + QFile::remove(tempPath); + return false; + } + + std::memcpy(buffer, linkTarget.constData(), static_cast(linkTarget.size())); + source = zip_source_buffer(archive, buffer, linkTarget.size(), 1); + if (!source) + std::free(buffer); + } else { + const QByteArray sourceName = QFile::encodeName(entry.sourcePath); + source = zip_source_file(archive, sourceName.constData(), 0, -1); + } + + if (!source) { + *error = zipError(archive); + zip_discard(archive); + QFile::remove(tempPath); + return false; + } + + const zip_int64_t entryIndex = zip_file_add(archive, archiveName.constData(), source, + ZIP_FL_ENC_UTF_8); + if (entryIndex < 0) { + zip_source_free(source); + *error = zipError(archive); + zip_discard(archive); + QFile::remove(tempPath); + return false; + } + + // Use a fast, low-level DEFLATE profile. This keeps compression close + // to Finder's quick archive behavior while avoiding an unnecessarily + // expensive maximum-compression pass. + if (zip_set_file_compression(archive, + static_cast(entryIndex), + ZIP_CM_DEFLATE, + 1) != 0) { + *error = zipError(archive); + zip_discard(archive); + QFile::remove(tempPath); + return false; + } + + if (entry.symbolicLink && + zip_file_set_external_attributes(archive, + static_cast(entryIndex), + 0, + ZIP_OPSYS_UNIX, + static_cast(S_IFLNK | 0777) << 16) != 0) { + *error = zipError(archive); + zip_discard(archive); + QFile::remove(tempPath); + return false; + } + + } + + if (isCanceled()) { + zip_discard(archive); + QFile::remove(tempPath); + return false; + } + + if (zip_close(archive) != 0) { + *error = zipError(archive); + zip_discard(archive); + QFile::remove(tempPath); + return false; + } + + if (isCanceled() || !QFile::rename(tempPath, m_outputPath)) { + QFile::remove(tempPath); + if (!isCanceled()) + *error = QStringLiteral("Could not create %1").arg(m_outputPath); + return false; + } + + emit progressChanged(100); + return true; +} + +bool ArchiveJob::archiveEntryPath(const QString &archiveName, + QString *relativePath, + bool *directory) const +{ + QString name = archiveName; + name.replace(QLatin1Char('\\'), QLatin1Char('/')); + + *directory = isDirectoryEntry(name); + while (name.endsWith(QLatin1Char('/'))) + name.chop(1); + + if (name.isEmpty()) { + relativePath->clear(); + return true; + } + + if (name.startsWith(QLatin1Char('/')) || + (name.size() >= 2 && name.at(1) == QLatin1Char(':')) || + name.contains(QChar::Null)) + return false; + + const QStringList components = name.split(QLatin1Char('/'), Qt::SkipEmptyParts); + for (const QString &component : components) { + if (component == QLatin1String("..")) + return false; + } + + const QString cleanName = QDir::cleanPath(name); + if (cleanName == QLatin1String(".") || cleanName.startsWith(QLatin1String("../")) || + cleanName == QLatin1String("..") || cleanName.startsWith(QLatin1Char('/'))) { + return false; + } + + *relativePath = cleanName; + return true; +} + +bool ArchiveJob::zipEntryIsSymbolicLink(zip_t *archive, zip_uint64_t index) +{ +#ifdef Q_OS_UNIX + zip_uint8_t operatingSystem = ZIP_OPSYS_UNIX; + zip_uint32_t externalAttributes = 0; + if (zip_file_get_external_attributes(archive, index, 0, + &operatingSystem, &externalAttributes) != 0) { + return false; + } + + if (operatingSystem != ZIP_OPSYS_UNIX && operatingSystem != ZIP_OPSYS_OS_X) + return false; + + const mode_t mode = static_cast(externalAttributes >> 16); + return S_ISLNK(mode); +#else + Q_UNUSED(archive) + Q_UNUSED(index) + return false; +#endif +} + +bool ArchiveJob::extract(QString *error) +{ + if (m_inputs.size() != 1) { + *error = QStringLiteral("Only one archive can be extracted at a time"); + return false; + } + + const QString archivePath = m_inputs.first(); + const QByteArray archivePathBytes = QFile::encodeName(archivePath); + int openError = 0; + zip_t *archive = zip_open(archivePathBytes.constData(), 0, &openError); + if (!archive) { + *error = zipOpenError(openError); + return false; + } + + quint64 total = 0; + const zip_int64_t entriesCount = zip_get_num_entries(archive, 0); + if (entriesCount < 0) { + *error = zipError(archive); + zip_discard(archive); + return false; + } + + for (zip_uint64_t index = 0; index < static_cast(entriesCount); ++index) { + if (isCanceled()) { + zip_discard(archive); + return false; + } + + zip_stat_t stat; + zip_stat_init(&stat); + if (zip_stat_index(archive, index, 0, &stat) != 0 || !stat.name) { + *error = zipError(archive); + zip_discard(archive); + return false; + } + + QString relativePath; + bool directory = false; + if (!archiveEntryPath(QString::fromUtf8(stat.name), &relativePath, &directory)) { + *error = QStringLiteral("Unsafe path in archive: %1").arg(QString::fromUtf8(stat.name)); + zip_discard(archive); + return false; + } + + if (!directory && !zipEntryIsSymbolicLink(archive, index)) + total += stat.size; + } + + const QString parentPath = QFileInfo(m_outputPath).absolutePath(); + if (!QDir().exists(parentPath)) { + *error = QStringLiteral("The destination folder does not exist: %1").arg(parentPath); + zip_discard(archive); + return false; + } + + const QString tempPath = temporaryPath(parentPath, QStringLiteral("cutefish-extract")); + if (!QDir().mkpath(tempPath)) { + *error = QStringLiteral("Could not create a temporary extraction folder"); + zip_discard(archive); + return false; + } + + quint64 processed = 0; + emit progressChanged(0); + + for (zip_uint64_t index = 0; index < static_cast(entriesCount); ++index) { + if (isCanceled()) { + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + + zip_stat_t stat; + zip_stat_init(&stat); + if (zip_stat_index(archive, index, 0, &stat) != 0 || !stat.name) { + *error = zipError(archive); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + + QString relativePath; + bool directory = false; + if (!archiveEntryPath(QString::fromUtf8(stat.name), &relativePath, &directory)) { + *error = QStringLiteral("Unsafe path in archive: %1").arg(QString::fromUtf8(stat.name)); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + + if (zipEntryIsSymbolicLink(archive, index)) + continue; + + if (relativePath.isEmpty()) + continue; + + const QString destinationPath = QDir(tempPath).filePath(relativePath); + emit currentFileChanged(relativePath); + + if (directory) { + if (!QDir().mkpath(destinationPath)) { + *error = QStringLiteral("Could not create %1").arg(relativePath); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + continue; + } + + if (!QDir().mkpath(QFileInfo(destinationPath).absolutePath())) { + *error = QStringLiteral("Could not create the parent folder for %1").arg(relativePath); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + + zip_file_t *file = zip_fopen_index(archive, index, 0); + if (!file) { + *error = zipError(archive); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + + QFile output(destinationPath); + if (!output.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + *error = output.errorString(); + zip_fclose(file); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + + char buffer[64 * 1024]; + while (true) { + if (isCanceled()) { + output.close(); + zip_fclose(file); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + + const zip_int64_t bytesRead = zip_fread(file, buffer, sizeof(buffer)); + if (bytesRead < 0) { + *error = zipError(archive); + output.close(); + zip_fclose(file); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + + if (bytesRead == 0) + break; + + if (output.write(buffer, bytesRead) != bytesRead) { + *error = output.errorString(); + output.close(); + zip_fclose(file); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + + processed += static_cast(bytesRead); + emitProgress(processed, total); + } + + output.close(); + if (zip_fclose(file) != 0) { + *error = zipError(archive); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + } + + // Create symbolic links only after all regular files have been written. + // A link inside an archive must never redirect extraction of a later file. + for (zip_uint64_t index = 0; index < static_cast(entriesCount); ++index) { + if (isCanceled()) { + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + + if (!zipEntryIsSymbolicLink(archive, index)) + continue; + + zip_stat_t stat; + zip_stat_init(&stat); + if (zip_stat_index(archive, index, 0, &stat) != 0 || !stat.name) { + *error = zipError(archive); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + + QString relativePath; + bool directory = false; + if (!archiveEntryPath(QString::fromUtf8(stat.name), &relativePath, &directory) || + directory || relativePath.isEmpty()) { + *error = QStringLiteral("Invalid symbolic link path in archive: %1") + .arg(QString::fromUtf8(stat.name)); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + + emit currentFileChanged(relativePath); + + const QString destinationPath = QDir(tempPath).filePath(relativePath); + if (hasSymbolicLinkParent(tempPath, relativePath)) { + *error = QStringLiteral("Symbolic link parent in archive: %1").arg(relativePath); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + + if (!QDir().mkpath(QFileInfo(destinationPath).absolutePath())) { + *error = QStringLiteral("Could not create the parent folder for %1").arg(relativePath); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + + const QFileInfo existing(destinationPath); + if (existing.exists() || existing.isSymLink()) { + *error = QStringLiteral("Duplicate path in archive: %1").arg(relativePath); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + + zip_file_t *file = zip_fopen_index(archive, index, 0); + if (!file) { + *error = zipError(archive); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + + QByteArray target; + char buffer[4096]; + bool readError = false; + while (true) { + const zip_int64_t bytesRead = zip_fread(file, buffer, sizeof(buffer)); + if (bytesRead < 0) { + readError = true; + break; + } + if (bytesRead == 0) + break; + + if (target.size() > 16 * 1024 - bytesRead) { + *error = QStringLiteral("Symbolic link target is too long: %1").arg(relativePath); + readError = true; + break; + } + target.append(buffer, static_cast(bytesRead)); + } + + const int closeResult = zip_fclose(file); + if (readError || closeResult != 0 || target.contains('\0')) { + if (readError && error->isEmpty()) + *error = zipError(archive); + if (error->isEmpty()) + *error = QStringLiteral("Invalid symbolic link target: %1").arg(relativePath); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } + +#ifdef Q_OS_UNIX + if (!QFile::link(QString::fromUtf8(target), destinationPath)) { + *error = QStringLiteral("Could not restore symbolic link: %1").arg(relativePath); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; + } +#else + *error = QStringLiteral("Symbolic links are not supported on this platform"); + zip_discard(archive); + QDir(tempPath).removeRecursively(); + return false; +#endif + } + + zip_discard(archive); + + if (isCanceled() || !QFile::rename(tempPath, m_outputPath)) { + QDir(tempPath).removeRecursively(); + if (!isCanceled()) + *error = QStringLiteral("Could not create %1").arg(m_outputPath); + return false; + } + + emit progressChanged(100); + return true; +} + +void ArchiveJob::zipProgressCallback(zip_t *archive, double progress, void *state) +{ + Q_UNUSED(archive) + + auto *job = static_cast(state); + if (!job) + return; + + job->emitProgress(static_cast(progress * 100.0), 100); +} + +int ArchiveJob::zipCancelCallback(zip_t *archive, void *state) +{ + Q_UNUSED(archive) + + auto *job = static_cast(state); + return job && job->isCanceled() ? 1 : 0; +} diff --git a/cio/archivejob.h b/cio/archivejob.h new file mode 100644 index 0000000..20c7788 --- /dev/null +++ b/cio/archivejob.h @@ -0,0 +1,75 @@ +#ifndef ARCHIVEJOB_H +#define ARCHIVEJOB_H + +#include +#include +#include + +#include + +#include + +class ArchiveJob : public QThread +{ + Q_OBJECT + +public: + enum Operation { + Compress, + Extract, + }; + + ArchiveJob(Operation operation, + const QStringList &inputs, + const QString &outputPath, + QObject *parent = nullptr); + ~ArchiveJob() override; + + void cancel(); + +signals: + void progressChanged(int progress); + void currentFileChanged(const QString &fileName); + void completed(bool success, + bool canceled, + const QString &error, + const QString &outputPath); + +protected: + void run() override; + +private: + struct CompressionEntry { + QString sourcePath; + QString archivePath; + bool directory; + quint64 size; + bool symbolicLink; + QString linkTarget; + }; + + bool compress(QString *error); + bool extract(QString *error); + + bool collectCompressionEntries(const QString &sourcePath, + const QString &archivePath, + QList *entries, + QString *error) const; + bool archiveEntryPath(const QString &archiveName, + QString *relativePath, + bool *directory) const; + static bool zipEntryIsSymbolicLink(zip_t *archive, zip_uint64_t index); + + void emitProgress(quint64 processed, quint64 total); + bool isCanceled() const; + + static void zipProgressCallback(zip_t *archive, double progress, void *state); + static int zipCancelCallback(zip_t *archive, void *state); + + Operation m_operation; + QStringList m_inputs; + QString m_outputPath; + std::atomic_bool m_cancelRequested; +}; + +#endif // ARCHIVEJOB_H diff --git a/debian/control b/debian/control index 0d6f6f9..37e1a8f 100644 --- a/debian/control +++ b/debian/control @@ -5,6 +5,7 @@ Maintainer: CutefishOS Build-Depends: cmake, debhelper (>= 9), extra-cmake-modules, + libzip-dev, libkf6kio-dev, libkf6solid-dev, libkf6windowsystem-dev, diff --git a/model/foldermodel.cpp b/model/foldermodel.cpp index 30cc5d0..3aa251f 100644 --- a/model/foldermodel.cpp +++ b/model/foldermodel.cpp @@ -36,6 +36,7 @@ #include "../helper/fm.h" #include "../cio/cfilesizejob.h" +#include "../cio/archivejob.h" // Qt #include @@ -88,6 +89,64 @@ static bool isDropBetweenSharedViews(const QList &urls, const QUrl &folder return true; } +static QString uniquePath(const QString &directory, const QString &name, bool directoryPath) +{ + QString stem = name; + QString suffix; + + if (!directoryPath) { + const QFileInfo info(name); + if (!info.suffix().isEmpty()) { + suffix = QStringLiteral(".") + info.suffix(); + stem.chop(suffix.size()); + } + } + + QString candidate = QDir(directory).filePath(name); + int index = 1; + while (QFileInfo::exists(candidate)) { + candidate = QDir(directory).filePath(QStringLiteral("%1 %2%3") + .arg(stem) + .arg(index++) + .arg(suffix)); + } + + return candidate; +} + +static bool isZipFile(const KFileItem &item) +{ + if (!item.isLocalFile()) + return false; + + const QString mimeType = item.mimetype(); + return mimeType == QLatin1String("application/zip") || + mimeType == QLatin1String("application/x-zip-compressed") || + item.url().path().endsWith(QLatin1String(".zip"), Qt::CaseInsensitive); +} + +static bool sendSystemNotification(const QString &summary, const QString &body) +{ + QDBusInterface interface(QStringLiteral("org.freedesktop.Notifications"), + QStringLiteral("/org/freedesktop/Notifications"), + QStringLiteral("org.freedesktop.Notifications"), + QDBusConnection::sessionBus()); + if (!interface.isValid()) + return false; + + QList arguments; + arguments << QStringLiteral("cutefish-filemanager"); + arguments << static_cast(0); + arguments << QStringLiteral("system-file-manager"); + arguments << summary; + arguments << body; + arguments << QStringList(); + arguments << QVariantMap(); + arguments << 5000; + interface.asyncCallWithArgumentList(QStringLiteral("Notify"), arguments); + return true; +} + FolderModel::FolderModel(QObject *parent) : QSortFilterProxyModel(parent) , m_dirWatch(nullptr) @@ -107,6 +166,12 @@ FolderModel::FolderModel(QObject *parent) , m_viewAdapter(nullptr) , m_mimeAppManager(MimeAppManager::self()) , m_sizeJob(nullptr) + , m_archiveJob(nullptr) + , m_archiveBusy(false) + , m_archiveCancelling(false) + , m_archiveProgress(-1) + , m_archiveOperation() + , m_archiveCurrentFile() , m_currentIndex(-1) , m_updateNeedSelectTimer(new QTimer(this)) { @@ -190,7 +255,10 @@ FolderModel::FolderModel(QObject *parent) FolderModel::~FolderModel() { - + if (m_archiveJob) { + m_archiveJob->cancel(); + m_archiveJob->wait(); + } } void FolderModel::classBegin() @@ -1027,6 +1095,11 @@ void FolderModel::openSelected() } } + if (urls.size() == 1 && isZipFile(KFileItem(urls.first()))) { + extractSelectedArchive(); + return; + } + for (const QUrl &url : urls) { KFileItem item(url); QString mimeType = item.mimetype(); @@ -1073,6 +1146,171 @@ void FolderModel::openSelected() } } +void FolderModel::startArchiveJob(ArchiveJob *job, const QString &operation) +{ + if (!job) + return; + + m_archiveJob = job; + + m_archiveBusy = true; + emit archiveBusyChanged(); + + m_archiveCancelling = false; + emit archiveCancellingChanged(); + + m_archiveProgress = -1; + emit archiveProgressChanged(); + + m_archiveOperation = operation; + emit archiveOperationChanged(); + + m_archiveCurrentFile.clear(); + emit archiveCurrentFileChanged(); + + connect(job, &ArchiveJob::progressChanged, this, [this](int progress) { + if (m_archiveProgress == progress) + return; + + m_archiveProgress = progress; + emit archiveProgressChanged(); + }, Qt::QueuedConnection); + + connect(job, &ArchiveJob::currentFileChanged, this, [this](const QString &fileName) { + if (m_archiveCurrentFile == fileName) + return; + + m_archiveCurrentFile = fileName; + emit archiveCurrentFileChanged(); + }, Qt::QueuedConnection); + + connect(job, &ArchiveJob::completed, this, &FolderModel::archiveJobFinished, + Qt::QueuedConnection); + + // Do not delete the QThread from the completed callback: run() is still + // unwinding at that point. Waiting for QThread::finished keeps cleanup + // safe even when a very small archive completes immediately. + connect(job, &QThread::finished, this, [this, job]() { + if (m_archiveJob == job) + m_archiveJob = nullptr; + job->deleteLater(); + }, Qt::QueuedConnection); + + job->start(); +} + +void FolderModel::compressSelected() +{ + if (m_archiveJob || !m_selectionModel->hasSelection()) + return; + + if (resolvedUrl().scheme() == QLatin1String("trash") || !rootItem().isWritable()) + return; + + const QList urls = selectedUrls(); + QStringList paths; + paths.reserve(urls.size()); + + for (const QUrl &url : urls) { + if (!url.isLocalFile() || url.toLocalFile().isEmpty()) { + emit notification(tr("Only local files can be compressed.")); + return; + } + paths.append(url.toLocalFile()); + } + + const QString destinationDirectory = resolvedUrl().toLocalFile(); + if (destinationDirectory.isEmpty()) + return; + + const QString baseName = urls.size() == 1 + ? QFileInfo(paths.first()).fileName() + : QStringLiteral("Archive"); + const QString outputPath = uniquePath(destinationDirectory, baseName + QStringLiteral(".zip"), false); + + startArchiveJob(new ArchiveJob(ArchiveJob::Compress, paths, outputPath, this), + tr("Compressing")); +} + +void FolderModel::extractSelectedArchive() +{ + if (m_archiveJob || m_selectionModel->selectedIndexes().size() != 1) + return; + + const QUrl archiveUrl = selectedUrls().first(); + const KFileItem item(archiveUrl); + if (!isZipFile(item)) + return; + + const QString archivePath = archiveUrl.toLocalFile(); + const QFileInfo archiveInfo(archivePath); + if (!archiveInfo.isFile()) + return; + + QString baseName = archiveInfo.completeBaseName(); + if (baseName.isEmpty()) + baseName = QStringLiteral("Extracted"); + + const QString outputPath = uniquePath(archiveInfo.absolutePath(), baseName, true); + startArchiveJob(new ArchiveJob(ArchiveJob::Extract, + QStringList() << archivePath, + outputPath, + this), + tr("Extracting")); +} + +void FolderModel::cancelArchive() +{ + if (!m_archiveJob || !m_archiveBusy || m_archiveCancelling) + return; + + m_archiveCancelling = true; + emit archiveCancellingChanged(); + m_archiveJob->cancel(); +} + +void FolderModel::archiveJobFinished(bool success, + bool canceled, + const QString &error, + const QString &outputPath) +{ + if (!m_archiveBusy) + return; + + const QString operation = m_archiveOperation; + + m_archiveBusy = false; + emit archiveBusyChanged(); + + m_archiveCancelling = false; + emit archiveCancellingChanged(); + + m_archiveProgress = success ? 100 : -1; + emit archiveProgressChanged(); + + m_archiveCurrentFile.clear(); + emit archiveCurrentFileChanged(); + + QString message; + if (success) { + m_needSelectUrls.append(QUrl::fromLocalFile(outputPath)); + refresh(); + delayUpdateNeedSelectUrls(); + message = tr("%1 completed: %2").arg(operation, QFileInfo(outputPath).fileName()); + emit notification(message); + } else if (canceled) { + message = tr("%1 canceled.").arg(operation); + emit notification(message); + } else { + message = tr("%1 failed: %2").arg(operation, error); + // Errors are important enough to leave the operation surface and go + // to the system notification center. Fall back to the existing + // in-window toast if no notification service is available. + if (!sendSystemNotification(tr("File Manager"), message)) + emit notification(message); + } +} + void FolderModel::showOpenWithDialog() { if (!m_selectionModel->hasSelection()) @@ -1315,6 +1553,7 @@ void FolderModel::openContextMenu(QQuickItem *visualParent, Qt::KeyboardModifier menu->addAction(m_actionCollection.action("openInNewWindow")); menu->addAction(m_actionCollection.action("openWith")); + menu->addAction(m_actionCollection.action("compress")); menu->addSeparator(); menu->addAction(m_actionCollection.action("cut")); menu->addAction(m_actionCollection.action("copy")); @@ -1738,6 +1977,31 @@ QString FolderModel::selectedItemSize() const return m_selectedItemSize; } +bool FolderModel::archiveBusy() const +{ + return m_archiveBusy; +} + +bool FolderModel::archiveCancelling() const +{ + return m_archiveCancelling; +} + +int FolderModel::archiveProgress() const +{ + return m_archiveProgress; +} + +QString FolderModel::archiveOperation() const +{ + return m_archiveOperation; +} + +QString FolderModel::archiveCurrentFile() const +{ + return m_archiveCurrentFile; +} + bool FolderModel::isDesktop() const { return m_isDesktop; @@ -1776,6 +2040,9 @@ void FolderModel::createActions() QAction *openWith = new QAction(tr("Open with"), this); connect(openWith, &QAction::triggered, this, &FolderModel::showOpenWithDialog); + QAction *compress = new QAction(tr("Compress"), this); + connect(compress, &QAction::triggered, this, &FolderModel::compressSelected); + QAction *cut = new QAction(tr("Cut"), this); connect(cut, &QAction::triggered, this, &FolderModel::cut); @@ -1830,6 +2097,7 @@ void FolderModel::createActions() m_actionCollection.addAction(QStringLiteral("open"), open); m_actionCollection.addAction(QStringLiteral("openWith"), openWith); + m_actionCollection.addAction(QStringLiteral("compress"), compress); m_actionCollection.addAction(QStringLiteral("cut"), cut); m_actionCollection.addAction(QStringLiteral("copy"), copy); m_actionCollection.addAction(QStringLiteral("paste"), paste); @@ -1907,6 +2175,19 @@ void FolderModel::updateActions() openWith->setVisible(items.count() == 1 && !isTrash); } + if (QAction *compress = m_actionCollection.action(QStringLiteral("compress"))) { + const bool canCompress = !indexes.isEmpty() && !isTrash && !hasRemoteFiles && + rootItem().isWritable(); + compress->setVisible(canCompress); + compress->setEnabled(canCompress && !m_archiveJob); + + if (indexes.count() == 1 && !items.isEmpty()) { + compress->setText(tr("Compress “%1”").arg(items.first().url().fileName())); + } else { + compress->setText(tr("Compress Items")); + } + } + if (QAction *newFolder = m_actionCollection.action(QStringLiteral("newFolder"))) { newFolder->setVisible(!isTrash); newFolder->setEnabled(rootItem().isWritable()); diff --git a/model/foldermodel.h b/model/foldermodel.h index a1ee61b..a5ef858 100644 --- a/model/foldermodel.h +++ b/model/foldermodel.h @@ -45,6 +45,7 @@ class QMenu; class QDrag; class CFileSizeJob; +class ArchiveJob; class FolderModel : public QSortFilterProxyModel, public QQmlParserStatus { Q_OBJECT @@ -65,6 +66,11 @@ class FolderModel : public QSortFilterProxyModel, public QQmlParserStatus Q_PROPERTY(QString selectedItemSize READ selectedItemSize NOTIFY selectedItemSizeChanged) Q_PROPERTY(bool showHiddenFiles READ showHiddenFiles WRITE setShowHiddenFiles NOTIFY showHiddenFilesChanged) Q_PROPERTY(int currentIndex READ currentIndex NOTIFY currentIndexChanged) + Q_PROPERTY(bool archiveBusy READ archiveBusy NOTIFY archiveBusyChanged) + Q_PROPERTY(bool archiveCancelling READ archiveCancelling NOTIFY archiveCancellingChanged) + Q_PROPERTY(int archiveProgress READ archiveProgress NOTIFY archiveProgressChanged) + Q_PROPERTY(QString archiveOperation READ archiveOperation NOTIFY archiveOperationChanged) + Q_PROPERTY(QString archiveCurrentFile READ archiveCurrentFile NOTIFY archiveCurrentFileChanged) public: enum DataRole { @@ -217,6 +223,9 @@ public: Q_INVOKABLE void openChangeWallpaperDialog(); Q_INVOKABLE void openDeleteDialog(); Q_INVOKABLE void openInNewWindow(const QString &url = QString()); + Q_INVOKABLE void compressSelected(); + Q_INVOKABLE void extractSelectedArchive(); + Q_INVOKABLE void cancelArchive(); Q_INVOKABLE void updateSelectedItemsSize(); Q_INVOKABLE void keyboardSearch(const QString &text); @@ -230,6 +239,12 @@ public: QString selectedItemSize() const; + bool archiveBusy() const; + bool archiveCancelling() const; + int archiveProgress() const; + QString archiveOperation() const; + QString archiveCurrentFile() const; + bool showHiddenFiles() const; void setShowHiddenFiles(bool showHiddenFiles); @@ -259,6 +274,11 @@ signals: void move(int x, int y, QList urls); void currentIndexChanged(); + void archiveBusyChanged(); + void archiveCancellingChanged(); + void archiveProgressChanged(); + void archiveOperationChanged(); + void archiveCurrentFileChanged(); private slots: void selectionChanged(const QItemSelection &selected, const QItemSelection &deselected); @@ -266,6 +286,11 @@ private slots: void onRowsInserted(const QModelIndex &parent, int first, int last); void delayUpdateNeedSelectUrls(); void updateNeedSelectUrls(); + void startArchiveJob(ArchiveJob *job, const QString &operation); + void archiveJobFinished(bool success, + bool canceled, + const QString &error, + const QString &outputPath); private: void invalidateIfComplete(); @@ -334,6 +359,13 @@ private: CFileSizeJob *m_sizeJob; + QPointer m_archiveJob; + bool m_archiveBusy; + bool m_archiveCancelling; + int m_archiveProgress; + QString m_archiveOperation; + QString m_archiveCurrentFile; + int m_currentIndex; QTimer *m_updateNeedSelectTimer; diff --git a/qml.qrc b/qml.qrc index f28455f..9aae416 100644 --- a/qml.qrc +++ b/qml.qrc @@ -2,6 +2,7 @@ qml/main.qml qml/FolderPage.qml + qml/ArchiveProgressDialog.qml qml/SideBar.qml qml/FolderListItem.qml qml/Dialogs/PropertiesDialog.qml diff --git a/qml/ArchiveProgressDialog.qml b/qml/ArchiveProgressDialog.qml new file mode 100644 index 0000000..aa8a596 --- /dev/null +++ b/qml/ArchiveProgressDialog.qml @@ -0,0 +1,128 @@ +import QtQuick 2.12 +import QtQuick.Controls 2.12 +import QtQuick.Layouts 1.12 +import QtQuick.Window 2.12 + +import FishUI 1.0 as FishUI + +FishUI.Window { + id: control + + property QtObject archiveModel + property Window hostWindow + + flags: Qt.Dialog | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint + modality: Qt.NonModal + minimizeButtonVisible: false + visible: archiveModel !== null && archiveModel.archiveBusy + + width: 420 + height: 178 + minimumWidth: width + minimumHeight: height + maximumWidth: width + maximumHeight: height + + x: hostWindow ? hostWindow.x + Math.round((hostWindow.width - width) / 2) : 0 + y: hostWindow ? hostWindow.y + Math.round((hostWindow.height - height) / 2) : 0 + + header.height: 40 + headerBackground.color: FishUI.Theme.secondBackgroundColor + background.color: FishUI.Theme.secondBackgroundColor + + headerItem: Item { + Label { + anchors.left: parent.left + anchors.leftMargin: FishUI.Units.largeSpacing + anchors.verticalCenter: parent.verticalCenter + width: parent.width - FishUI.Units.largeSpacing * 2 + text: control.archiveModel === null ? qsTr("File operation") + : control.archiveModel.archiveOperation + elide: Text.ElideRight + font.pointSize: 11 + } + } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: FishUI.Units.largeSpacing + anchors.rightMargin: FishUI.Units.largeSpacing + anchors.topMargin: FishUI.Units.smallSpacing + anchors.bottomMargin: FishUI.Units.largeSpacing + spacing: FishUI.Units.largeSpacing + + Label { + Layout.fillWidth: true + text: control.archiveModel === null ? "" + : control.archiveModel.archiveCurrentFile + visible: text.length > 0 + elide: Text.ElideMiddle + font.pointSize: 10 + } + + Item { + Layout.fillWidth: true + implicitHeight: 20 + + FishUI.BusyIndicator { + anchors.centerIn: parent + width: 20 + height: 20 + visible: control.archiveModel !== null + && control.archiveModel.archiveProgress < 0 + running: visible + } + + ProgressBar { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: control.archiveModel !== null + && control.archiveModel.archiveProgress >= 0 + from: 0 + to: 100 + value: control.archiveModel === null ? 0 + : control.archiveModel.archiveProgress + } + } + + RowLayout { + Layout.fillWidth: true + + Item { + Layout.fillWidth: true + } + + Button { + text: control.archiveModel !== null && control.archiveModel.archiveCancelling + ? qsTr("Cancelling…") : qsTr("Cancel") + enabled: control.archiveModel !== null + && control.archiveModel.archiveBusy + && !control.archiveModel.archiveCancelling + flat: true + onClicked: control.archiveModel.cancelArchive() + } + } + } + + onClosing: function(closeEvent) { + if (control.archiveModel !== null && control.archiveModel.archiveBusy) { + control.archiveModel.cancelArchive() + closeEvent.accepted = false + } + } + + function updateTransientParent() { + if (hostWindow) + control.transientParent = hostWindow + } + + onHostWindowChanged: updateTransientParent() + + Component.onCompleted: updateTransientParent() + + onVisibleChanged: { + if (visible) + requestActivate() + } +} diff --git a/qml/Desktop/Main.qml b/qml/Desktop/Main.qml index 83fedcd..09edc00 100644 --- a/qml/Desktop/Main.qml +++ b/qml/Desktop/Main.qml @@ -62,6 +62,12 @@ Item { _folderView.contentWidth, _folderView.contentHeight) } + ArchiveProgressDialog { + id: archiveProgressDialog + archiveModel: dirModel + hostWindow: rootItem.Window.window + } + MouseArea { anchors.fill: parent onClicked: _folderView.forceActiveFocus() diff --git a/qml/main.qml b/qml/main.qml index 4a57e5b..1f43492 100644 --- a/qml/main.qml +++ b/qml/main.qml @@ -54,6 +54,12 @@ FishUI.Window { id: optionsMenu } + ArchiveProgressDialog { + id: archiveProgressDialog + archiveModel: _folderPage.model + hostWindow: root + } + headerItem: Item { RowLayout { anchors.fill: parent