feat(filemanager): add archive compression and extraction

main
reionwong 3 weeks ago
parent 6a6ca03f27
commit 291b5bdf31

@ -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
# ---------------------------------------------------------------------------

@ -0,0 +1,721 @@
#include "archivejob.h"
#include <zip.h>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QFileInfoList>
#include <QUuid>
#include <cstdlib>
#include <cstring>
#ifdef Q_OS_UNIX
#include <sys/stat.h>
#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<double>(processed) / static_cast<double>(total);
emit progressChanged(qBound(0, static_cast<int>(ratio * 100.0), 100));
}
bool ArchiveJob::collectCompressionEntries(const QString &sourcePath,
const QString &archivePath,
QList<CompressionEntry> *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<quint64>(info.size()), false, QString()});
return true;
}
bool ArchiveJob::compress(QString *error)
{
if (m_inputs.isEmpty()) {
*error = QStringLiteral("No files were selected");
return false;
}
QList<CompressionEntry> 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<size_t>(linkTarget.size(), 1);
char *buffer = static_cast<char *>(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<size_t>(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<zip_uint64_t>(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<zip_uint64_t>(entryIndex),
0,
ZIP_OPSYS_UNIX,
static_cast<zip_uint32_t>(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<mode_t>(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<zip_uint64_t>(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<zip_uint64_t>(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<quint64>(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<zip_uint64_t>(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<qsizetype>(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<ArchiveJob *>(state);
if (!job)
return;
job->emitProgress(static_cast<quint64>(progress * 100.0), 100);
}
int ArchiveJob::zipCancelCallback(zip_t *archive, void *state)
{
Q_UNUSED(archive)
auto *job = static_cast<ArchiveJob *>(state);
return job && job->isCanceled() ? 1 : 0;
}

@ -0,0 +1,75 @@
#ifndef ARCHIVEJOB_H
#define ARCHIVEJOB_H
#include <QThread>
#include <QString>
#include <QStringList>
#include <zip.h>
#include <atomic>
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<CompressionEntry> *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

1
debian/control vendored

@ -5,6 +5,7 @@ Maintainer: CutefishOS <support@cutefishos.com>
Build-Depends: cmake,
debhelper (>= 9),
extra-cmake-modules,
libzip-dev,
libkf6kio-dev,
libkf6solid-dev,
libkf6windowsystem-dev,

@ -36,6 +36,7 @@
#include "../helper/fm.h"
#include "../cio/cfilesizejob.h"
#include "../cio/archivejob.h"
// Qt
#include <QSet>
@ -88,6 +89,64 @@ static bool isDropBetweenSharedViews(const QList<QUrl> &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<QVariant> arguments;
arguments << QStringLiteral("cutefish-filemanager");
arguments << static_cast<uint>(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<QUrl> 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());

@ -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<QUrl> 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<ArchiveJob> m_archiveJob;
bool m_archiveBusy;
bool m_archiveCancelling;
int m_archiveProgress;
QString m_archiveOperation;
QString m_archiveCurrentFile;
int m_currentIndex;
QTimer *m_updateNeedSelectTimer;

@ -2,6 +2,7 @@
<qresource prefix="/">
<file>qml/main.qml</file>
<file>qml/FolderPage.qml</file>
<file>qml/ArchiveProgressDialog.qml</file>
<file>qml/SideBar.qml</file>
<file>qml/FolderListItem.qml</file>
<file>qml/Dialogs/PropertiesDialog.qml</file>

@ -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()
}
}

@ -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()

@ -54,6 +54,12 @@ FishUI.Window {
id: optionsMenu
}
ArchiveProgressDialog {
id: archiveProgressDialog
archiveModel: _folderPage.model
hostWindow: root
}
headerItem: Item {
RowLayout {
anchors.fill: parent

Loading…
Cancel
Save