diff --git a/tools/chatgui/src/member_files/browser.cpp b/tools/chatgui/src/member_files/browser.cpp index 34d840fd..7e31b559 100644 --- a/tools/chatgui/src/member_files/browser.cpp +++ b/tools/chatgui/src/member_files/browser.cpp @@ -24,6 +24,8 @@ #include #include #include +#include +#include #include #include extern "C" { @@ -75,6 +77,13 @@ 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); + 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"); + m_dropHint->setAttribute(Qt::WA_TransparentForMouseEvents); m_dropHint->setAlignment(Qt::AlignCenter); + m_dropHint->setStyleSheet("QLabel { border: 2px dashed palette(highlight); background: palette(window); color: palette(text); }"); + m_dropHint->setText(text("Copy to my files")); m_dropHint->hide(); + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "file browser: drop targets installed count=%d", int(dropTargets.size())); connect(reload, &QPushButton::clicked, this, [this]() { m_staleRetries = 0; refresh(); }); connect(up, &QPushButton::clicked, this, [this]() { if (m_path.isEmpty()) { m_owners->setCurrentIndex(0); return; } @@ -113,8 +122,45 @@ MemberFileBrowser::~MemberFileBrowser() { m_uploadPool.waitForDone(); } +bool MemberFileBrowser::eventFilter(QObject* target, QEvent* event) { + if (event->type() == QEvent::DragLeave) { m_dropHint->hide(); return true; } + if (event->type() != QEvent::DragEnter && event->type() != QEvent::DragMove && event->type() != QEvent::Drop) + 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"); + 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)) + reason = text("Drop local files to copy"); + QStringList paths; + if (reason.isEmpty()) for (const auto& url : drop->mimeData()->urls()) { + if (!url.isLocalFile() || url.toLocalFile().isEmpty()) { reason = text("Only local files can be uploaded"); break; } + paths.append(url.toLocalFile()); + } + if (!reason.isEmpty()) { + if (event->type() != QEvent::DragMove) + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "file browser: drop rejected group=%llu owner=%llu path=%s reason=%s", + (unsigned long long)m_group, (unsigned long long)owner(), qPrintable(m_path), qPrintable(reason)); + m_status->setText(reason); m_dropHint->hide(); drop->ignore(); return true; + } + drop->setDropAction(Qt::CopyAction); drop->accept(); + if (event->type() != QEvent::Drop) { + m_dropHint->setGeometry(m_entries->viewport()->rect()); m_dropHint->show(); m_dropHint->raise(); return true; + } + m_dropHint->hide(); m_dropId = requestId(); updateActions(); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "file browser: drop accepted group=%llu server=%llu owner=%llu path=%s files=%d", + (unsigned long long)m_group, (unsigned long long)server(), (unsigned long long)owner(), qPrintable(m_path), int(paths.size())); + quint64 id = m_dropId; + QTimer::singleShot(0, this, [this, id, paths] { + if (m_dropId != id) return; + m_dropId = 0; updateActions(); beginUploads(paths, false); + }); + return true; +} + void MemberFileBrowser::setChannel(const QString& channel, quint64 selectedOwner) { - if (m_uploadBatchId || m_transferId) { + if (m_dropId || m_uploadBatchId || m_transferId) { 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; } @@ -157,7 +203,7 @@ 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_uploadBatchId || m_transferId; + bool busy = m_dropId || m_uploadBatchId || m_transferId; 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); @@ -368,7 +414,7 @@ void MemberFileBrowser::upload() { } void MemberFileBrowser::uploadOwnFiles(const QString& channel, const QStringList& paths) { - if (m_uploadBatchId || m_transferId) { + if (m_dropId || m_uploadBatchId || m_transferId) { 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; } @@ -383,10 +429,10 @@ void MemberFileBrowser::uploadOwnFiles(const QString& channel, const QStringList // Сервер выбирается до первого запроса; batch ID защищает вложенные циклы диалогов от отмены/закрытия окна. void MemberFileBrowser::beginUploads(const QStringList& paths, bool chooseServer) { if (paths.isEmpty()) return; - if (m_uploadBatchId || m_transferId || !server() || !owner() || owner() != m_db->myNodeId() || !gui_bridge_is_uasync_ready()) { + if (m_dropId || m_uploadBatchId || m_transferId || !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_uploadBatchId || m_transferId), gui_bridge_is_uasync_ready()); + bool(m_dropId || m_uploadBatchId || m_transferId), gui_bridge_is_uasync_ready()); QMessageBox::warning(this, text("Group files"), !server() ? text("No media server in this group") : text("Upload unavailable")); return; } @@ -533,6 +579,7 @@ void MemberFileBrowser::mutate(uint8_t command) { } void MemberFileBrowser::cancel() { + 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 6f492d92..da20fb3d 100644 --- a/tools/chatgui/src/member_files/browser.h +++ b/tools/chatgui/src/member_files/browser.h @@ -28,6 +28,8 @@ public: void uploadOwnFiles(const QString& channel, const QStringList& paths); void onResponse(const uint8_t* data, int length); void onTransfer(const uint8_t* data, int length); +protected: + bool eventFilter(QObject* target, QEvent* event) override; private: void refresh(); void requestQuota(); @@ -55,6 +57,7 @@ private: QString m_path, m_local; bool m_openAfter = false; quint64 m_uploadBatchId = 0, m_uploadStatId = 0; + quint64 m_dropId = 0; QStringList m_uploadQueue; QThreadPool m_uploadPool; int m_uploadDone = 0, m_uploadSkipped = 0, m_uploadTotal = 0; @@ -68,6 +71,7 @@ private: QPushButton *m_upload, *m_download, *m_mkdir, *m_rename, *m_remove, *m_cancel; QLabel* m_status; QLabel* m_uploadStatus; + QLabel* m_dropHint; QLabel* m_quota; QProgressBar* m_progress; }; diff --git a/tools/chatgui/tests/test_member_files_browser.cpp b/tools/chatgui/tests/test_member_files_browser.cpp index 3609c8e0..3dfac178 100644 --- a/tools/chatgui/tests/test_member_files_browser.cpp +++ b/tools/chatgui/tests/test_member_files_browser.cpp @@ -18,9 +18,11 @@ #include #include #include +#include extern "C" { #include "member_files/member_files.h" #include "mem.h" +#include "debug_config.h" } static mf_gui_request captured; static mf_gui_request quotaCaptured; @@ -68,8 +70,66 @@ static QPushButton* cancelButton(MemberFileBrowser& browser) { class BrowserTest : public QObject { Q_OBJECT private slots: + void initTestCase() { + debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); + debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_DEBUG); + debug_set_category_level(DEBUG_CATEGORY_MEDIA, DEBUG_LEVEL_INFO); + } void init() { transfers.clear(); cancelled.clear(); ready = true; } + void browserDropsIntoCurrentDirectory() { + sqlite3* sql = nullptr; QCOMPARE(sqlite3_open(":memory:", &sql), SQLITE_OK); + QCOMPARE(sqlite3_exec(sql, "CREATE TABLE accounts(node_id INTEGER,display_name TEXT);" + "INSERT INTO accounts VALUES(11,'Other'),(22,'Me'),(33,'Server');" + "CREATE TABLE peers_42(node_id INTEGER,adm_tags TEXT,adm_tags_sig BLOB,deleted INTEGER,source INTEGER);" + "INSERT INTO peers_42 VALUES(11,'{}',NULL,0,0),(22,'{}',NULL,0,0)," + "(33,'{\"storage\":\"yes\"}',zeroblob(64),0,0);", nullptr, nullptr, nullptr), SQLITE_OK); + DbManager db; db.setDb(sql); db.setMyNodeId(22); + QTemporaryDir dir; QVERIFY(dir.isValid()); QString path = dir.filePath("dropped.txt"); + { QFile file(path); QVERIFY(file.open(QIODevice::WriteOnly)); QCOMPARE(file.write("test"), 4LL); } + QMimeData mime; mime.setUrls({QUrl::fromLocalFile(path)}); + { + MemberFileBrowser browser(&db); browser.show(); + auto* tree = browser.findChild(); auto* location = browser.findChild(); QVERIFY(location); + auto* cancel = cancelButton(browser); QVERIFY(cancel); + QList targets{&browser, tree->viewport(), location}; + for (auto* button : browser.findChildren()) if (button->text().startsWith("Upload")) targets.append(button); + for (auto* target : targets) { + browser.setChannel("42", 22); location->setText("documents"); location->returnPressed(); + QDragEnterEvent enter(QPoint(3, 3), Qt::CopyAction | Qt::MoveAction, &mime, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(target, &enter); + DEBUG_INFO(DEBUG_CATEGORY_MEDIA, "test browser drop: target=%s acceptDrops=%d accepted=%d", + target->metaObject()->className(), target->acceptDrops(), enter.isAccepted()); + QVERIFY(enter.isAccepted()); QCOMPARE(enter.dropAction(), Qt::CopyAction); + QVERIFY(browser.findChild("memberFilesDropHint")->isVisible()); + QDropEvent drop(QPointF(3, 3), Qt::CopyAction | Qt::MoveAction, &mime, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(target, &drop); QVERIFY(drop.isAccepted()); QCOMPARE(drop.dropAction(), Qt::CopyAction); + QVERIFY(!browser.findChild("memberFilesDropHint")->isVisible()); + browser.setChannel("43", 11); // Назначение уже зафиксировано до отложенного запуска очереди. + QTRY_COMPARE(captured.message.command, uint8_t(MF_STAT)); + QCOMPARE(captured.group, 42ULL); QCOMPARE(captured.server, 33ULL); QCOMPARE(captured.message.owner, 22ULL); + QCOMPARE(QString::fromUtf8(captured.message.path), QString("documents/dropped.txt")); + int count = transfers.size(); respond(browser, captured, MF_NOT_FOUND); QCOMPARE(transfers.size(), count + 1); + QCOMPARE(QString::fromUtf8(transfers.last().remote), QString("documents/dropped.txt")); + complete(browser, transfers.last()); + } + browser.setChannel("42", 11); + QDragEnterEvent other(QPoint(3, 3), Qt::CopyAction, &mime, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(tree->viewport(), &other); QVERIFY(!other.isAccepted()); + browser.setChannel("42", 22); + QDragEnterEvent enter(QPoint(3, 3), Qt::CopyAction, &mime, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(tree->viewport(), &enter); QVERIFY(enter.isAccepted()); + QDropEvent drop(QPointF(3, 3), Qt::CopyAction, &mime, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(tree->viewport(), &drop); QVERIFY(drop.isAccepted()); + quint64 previous = captured.message.request_id; cancel->click(); QCoreApplication::processEvents(); + QCOMPARE(captured.message.request_id, previous); QVERIFY(!cancel->isEnabled()); + ready = false; + QDragEnterEvent stopped(QPoint(3, 3), Qt::CopyAction, &mime, Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(tree->viewport(), &stopped); QVERIFY(!stopped.isAccepted()); ready = true; + } + db.setDb(nullptr); QCOMPARE(sqlite3_close(sql), SQLITE_OK); + } + void footerDropsCopyAndCaptureGroup() { DbManager db; db.setMyNodeId(22); ChannelList list(&db); list.resize(350, 400); list.show(); auto* view = list.findChild(); auto* model = qobject_cast(view->model()); QVERIFY(model);