From e8ca27b590b8c4679122e8307142645e52fb68f4 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 10 Oct 2026 00:16:46 +0200 Subject: [PATCH] Export group files through the clipboard and native file drags --- tools/chatgui/src/member_files/browser.cpp | 111 +++++++++++++++++++-- tools/chatgui/src/member_files/browser.h | 8 ++ 2 files changed, 112 insertions(+), 7 deletions(-) diff --git a/tools/chatgui/src/member_files/browser.cpp b/tools/chatgui/src/member_files/browser.cpp index 7e31b559..c80a9db6 100644 --- a/tools/chatgui/src/member_files/browser.cpp +++ b/tools/chatgui/src/member_files/browser.cpp @@ -26,6 +26,14 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include extern "C" { @@ -36,6 +44,15 @@ extern "C" { } static QString text(const char* value) { return QCoreApplication::translate("ChatGui", value); } + +// Внешний drag выдаёт только готовый локальный файл; обычный MIME дерева не подходит системному менеджеру. +class MemberFileTree : public QTreeWidget { +public: + using QTreeWidget::QTreeWidget; + std::function exportRequested; +protected: + void startDrag(Qt::DropActions) override { exportRequested(); } +}; static QString quotaSize(quint64 bytes) { return bytes <= (quint64)std::numeric_limits::max() ? QLocale().formattedDataSize((qint64)bytes, 2) : text("%1 bytes").arg(QLocale().toString(bytes)); @@ -64,7 +81,8 @@ MemberFileBrowser::MemberFileBrowser(DbManager* db, QWidget* parent) : QDialog(p auto* up = new QPushButton(text("Up"), this); auto* reload = new QPushButton(text("Refresh"), this); m_location = new QLineEdit(this); m_location->setPlaceholderText(text("Path inside the member directory")); navigation->addWidget(up); navigation->addWidget(m_location, 1); navigation->addWidget(reload); layout->addWidget(m_navigation); - m_entries = new QTreeWidget(this); m_entries->setRootIsDecorated(false); + auto* tree = new MemberFileTree(this); m_entries = tree; m_entries->setRootIsDecorated(false); + tree->exportRequested = [this] { exportFile(false); }; m_entries->setDragEnabled(true); m_entries->setHeaderLabels({text("Name"), text("Type"), text("Size"), text("Modified")}); m_entries->header()->setSectionResizeMode(0, QHeaderView::Stretch); m_entries->setSelectionMode(QAbstractItemView::SingleSelection); layout->addWidget(m_entries, 1); @@ -77,6 +95,10 @@ MemberFileBrowser::MemberFileBrowser(DbManager* db, QWidget* parent) : QDialog(p m_uploadStatus = new QLabel(this); m_uploadStatus->setObjectName("memberFilesUploadStatus"); m_uploadStatus->setWordWrap(true); layout->addWidget(m_uploadStatus); m_progress = new QProgressBar(this); m_progress->setRange(0, 1000); m_progress->hide(); layout->addWidget(m_progress); + m_copyAction = new QAction(text("Copy file"), this); m_copyAction->setObjectName("memberFilesCopy"); + m_copyAction->setShortcut(QKeySequence::Copy); m_copyAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); + m_entries->addAction(m_copyAction); + connect(m_copyAction, &QAction::triggered, this, [this] { exportFile(true); }); auto dropTargets = findChildren(); dropTargets.append(this); for (auto* target : dropTargets) { target->setAcceptDrops(true); target->installEventFilter(this); } m_dropHint = new QLabel(m_entries->viewport()); m_dropHint->setObjectName("memberFilesDropHint"); @@ -105,6 +127,7 @@ MemberFileBrowser::MemberFileBrowser(DbManager* db, QWidget* parent) : QDialog(p auto* item = m_entries->itemAt(point); if (!item) return; m_entries->setCurrentItem(item); QMenu menu(this); auto* open = menu.addAction(text("Open")); auto* info = menu.addAction(text("Properties")); + menu.addAction(m_copyAction); auto* selected = menu.exec(m_entries->viewport()->mapToGlobal(point)); if (selected == open) enter(item); else if (selected == info && owner()) { @@ -128,7 +151,8 @@ bool MemberFileBrowser::eventFilter(QObject* target, QEvent* event) { return QDialog::eventFilter(target, event); auto* drop = static_cast(event); QString reason; - if (m_dropId || m_uploadBatchId || m_transferId) reason = text("Finish or cancel the current transfer first"); + if (drop->source() == m_entries) { m_dropHint->hide(); drop->ignore(); return true; } + if (m_dropId || m_uploadBatchId || m_transferId || m_exportId) reason = text("Finish or cancel the current transfer first"); else if (!m_group || !server() || !m_db || !owner() || owner() != m_db->myNodeId() || !gui_bridge_is_uasync_ready()) reason = text("Upload unavailable"); else if (!drop->mimeData()->hasUrls() || drop->mimeData()->urls().isEmpty() || !(drop->possibleActions() & Qt::CopyAction)) @@ -160,7 +184,7 @@ bool MemberFileBrowser::eventFilter(QObject* target, QEvent* event) { } void MemberFileBrowser::setChannel(const QString& channel, quint64 selectedOwner) { - if (m_dropId || m_uploadBatchId || m_transferId) { + if (m_dropId || m_uploadBatchId || m_transferId || m_exportId) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: context change blocked during transfer channel=%s", qPrintable(channel)); m_status->setText(text("Finish or cancel the current transfer first")); return; } @@ -203,11 +227,12 @@ void MemberFileBrowser::updateActions() { auto* item = m_entries->currentItem(); bool own = owner() && owner() == m_db->myNodeId(); bool available = server() && gui_bridge_is_uasync_ready(); bool file = item && item->data(0, Qt::UserRole + 1).toInt() == MF_FILE; - bool busy = m_dropId || m_uploadBatchId || m_transferId; + bool busy = m_dropId || m_uploadBatchId || m_transferId || m_exportId; m_servers->setEnabled(!busy); m_owners->setEnabled(!busy); m_navigation->setEnabled(!busy); m_entries->setEnabled(!busy); m_upload->setEnabled(available && own && !busy); m_mkdir->setEnabled(available && own && !busy); m_rename->setEnabled(available && own && item && !busy); m_remove->setEnabled(available && own && item && !busy); m_download->setEnabled(available && owner() && file && !busy); m_cancel->setEnabled(busy); + m_copyAction->setEnabled(available && owner() && file && !busy); } bool MemberFileBrowser::post(mf_message& message) { @@ -414,7 +439,7 @@ void MemberFileBrowser::upload() { } void MemberFileBrowser::uploadOwnFiles(const QString& channel, const QStringList& paths) { - if (m_dropId || m_uploadBatchId || m_transferId) { + if (m_dropId || m_uploadBatchId || m_transferId || m_exportId) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: drop rejected while busy files=%d", int(paths.size())); QMessageBox::warning(this, text("Group files"), text("Finish or cancel the current transfer first")); return; } @@ -429,10 +454,11 @@ void MemberFileBrowser::uploadOwnFiles(const QString& channel, const QStringList // Сервер выбирается до первого запроса; batch ID защищает вложенные циклы диалогов от отмены/закрытия окна. void MemberFileBrowser::beginUploads(const QStringList& paths, bool chooseServer) { if (paths.isEmpty()) return; - if (m_dropId || m_uploadBatchId || m_transferId || !server() || !owner() || owner() != m_db->myNodeId() || !gui_bridge_is_uasync_ready()) { + if (m_dropId || m_uploadBatchId || m_transferId || m_exportId || !server() || !owner() || + owner() != m_db->myNodeId() || !gui_bridge_is_uasync_ready()) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: upload unavailable group=%llu server=%llu owner=%llu busy=%d ready=%d", (unsigned long long)m_group, (unsigned long long)server(), (unsigned long long)owner(), - bool(m_dropId || m_uploadBatchId || m_transferId), gui_bridge_is_uasync_ready()); + bool(m_dropId || m_uploadBatchId || m_transferId || m_exportId), gui_bridge_is_uasync_ready()); QMessageBox::warning(this, text("Group files"), !server() ? text("No media server in this group") : text("Upload unavailable")); return; } @@ -510,6 +536,60 @@ void MemberFileBrowser::download(bool open) { m_openAfter = open; startTransfer(selectedPath(), local, false, selectedVersion()); } +void MemberFileBrowser::publishExport(const QString& local, bool clipboard) { + auto* mime = new QMimeData; QUrl url = QUrl::fromLocalFile(local); mime->setUrls({url}); + // Nautilus и KDE также получают явное действие copy, без удаления исходного файла. + mime->setData("x-special/gnome-copied-files", QByteArray("copy\n") + url.toEncoded()); + mime->setData("application/x-kde-cutselection", "0"); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file browser: exporting local=%s clipboard=%d", qPrintable(local), clipboard); + if (clipboard) { + QGuiApplication::clipboard()->setMimeData(mime); + m_status->setText(text("File copied. Paste it in your file manager.")); + } else { + auto* drag = new QDrag(m_entries); drag->setMimeData(mime); drag->exec(Qt::CopyAction, Qt::CopyAction); + } +} + +// Кеш сохраняется после закрытия окна: URI в системном буфере должен оставаться пригодным для вставки. +void MemberFileBrowser::exportFile(bool clipboard) { + if (!m_copyAction->isEnabled()) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: export unavailable group=%llu owner=%llu busy=%d", + (unsigned long long)m_group, (unsigned long long)owner(), bool(m_dropId || m_uploadBatchId || m_transferId || m_exportId)); + m_status->setText(text("Select a file and finish the current transfer first")); return; + } + QString remote = selectedPath(); QByteArray version = selectedVersion(); + if (version.size() != 16 || !mf_path_valid(remote.toUtf8().constData(), 0)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: invalid export entry path=%s version_bytes=%d", qPrintable(remote), int(version.size())); + m_status->setText(text("Invalid directory response")); return; + } + QString key = QString("%1:%2:%3:%4:%5").arg(m_group).arg(server()).arg(owner()).arg(remote, QString::fromLatin1(version.toHex())); + if (m_exportCache.contains(key)) { publishExport(m_exportCache.value(key), clipboard); return; } + QString base = QStandardPaths::writableLocation(QStandardPaths::CacheLocation); + if (base.isEmpty()) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: no cache location for export"); + m_status->setText(text("Cannot prepare a local copy")); return; + } + m_exportId = requestId(); m_exportKey = key; m_exportClipboard = clipboard; m_openAfter = false; updateActions(); + m_status->setText(text("Downloading a local copy for your file manager…")); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file browser: preparing export group=%llu server=%llu owner=%llu path=%s clipboard=%d", + (unsigned long long)m_group, (unsigned long long)server(), (unsigned long long)owner(), qPrintable(remote), clipboard); + quint64 id = m_exportId; QString name = QFileInfo(remote).fileName(); + m_uploadPool.start(QRunnable::create([this, id, base, name, remote, version] { + QString root = base + "/file-exports", local; + if (QDir().mkpath(root)) { + QTemporaryDir directory(root + "/XXXXXX"); + if (directory.isValid()) { local = directory.filePath(name); directory.setAutoRemove(false); } + } + QMetaObject::invokeMethod(this, [this, id, local, remote, version] { + if (m_exportId != id) return; + if (local.isEmpty() || !startTransfer(remote, local, false, version)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: export could not start remote=%s local=%s", qPrintable(remote), qPrintable(local)); + m_exportId = 0; updateActions(); m_status->setText(text("Cannot prepare a local copy")); + } + }, Qt::QueuedConnection); + })); +} + bool MemberFileBrowser::startTransfer(const QString& remote, const QString& local, bool uploading, const QByteArray& version) { auto* q = (mf_transfer_request*)u_calloc(1, sizeof(mf_transfer_request)); if (!q) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "file browser: transfer allocation failed"); return false; } @@ -534,6 +614,19 @@ void MemberFileBrowser::onTransfer(const uint8_t* data, int length) { m_status->setText(text("Transferred %1 / %2 bytes").arg(done).arg(total)); if (data[50] != 2) return; m_transferId = 0; m_progress->hide(); updateActions(); + if (m_exportId) { + m_exportId = 0; updateActions(); + if (status) { + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: export download failed local=%s status=%s", qPrintable(m_local), mf_status_text(status)); + m_status->setText(QString::fromUtf8(mf_status_text(status))); + } else { + m_exportCache.insert(m_exportKey, m_local); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file browser: local copy ready path=%s", qPrintable(m_local)); + if (m_exportClipboard) publishExport(m_local, true); + else m_status->setText(text("Local copy ready. Drag the file again, or use Copy file.")); + } + return; + } if (m_uploadBatchId) { if (status) { DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: upload failed local=%s status=%s", qPrintable(m_local), mf_status_text(status)); @@ -579,6 +672,10 @@ void MemberFileBrowser::mutate(uint8_t command) { } void MemberFileBrowser::cancel() { + if (m_exportId) { + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file browser: export cancelled id=%llu", (unsigned long long)m_exportId); + m_exportId = 0; m_status->setText(text("Copy cancelled")); updateActions(); + } if (m_dropId) { m_dropId = 0; m_uploadStatus->setText(text("Upload queue cancelled")); updateActions(); } if (m_uploadBatchId) finishUploads(text("Upload queue cancelled")); cancelRequest(m_transferId); diff --git a/tools/chatgui/src/member_files/browser.h b/tools/chatgui/src/member_files/browser.h index da20fb3d..704ce44d 100644 --- a/tools/chatgui/src/member_files/browser.h +++ b/tools/chatgui/src/member_files/browser.h @@ -16,6 +16,7 @@ class QTreeWidgetItem; class QPushButton; class QLabel; class QProgressBar; +class QAction; struct mf_message; class MemberFileBrowser : public QDialog { @@ -46,6 +47,8 @@ private: void nextUpload(); void finishUploads(const QString& status); void download(bool open); + void exportFile(bool clipboard); + void publishExport(const QString& local, bool clipboard); bool startTransfer(const QString& remote, const QString& local, bool upload, const QByteArray& version); void mutate(uint8_t command); void cancel(); @@ -58,6 +61,10 @@ private: bool m_openAfter = false; quint64 m_uploadBatchId = 0, m_uploadStatId = 0; quint64 m_dropId = 0; + quint64 m_exportId = 0; + bool m_exportClipboard = false; + QString m_exportKey; + QHash m_exportCache; QStringList m_uploadQueue; QThreadPool m_uploadPool; int m_uploadDone = 0, m_uploadSkipped = 0, m_uploadTotal = 0; @@ -74,4 +81,5 @@ private: QLabel* m_dropHint; QLabel* m_quota; QProgressBar* m_progress; + QAction* m_copyAction; };