Files
vdm/gui/src/dialogs/FileInfoDialog.cpp
T
samiandClaude Sonnet 5 c2eef96175 gui: floating drop target, clipboard global shortcut, theming, UI watchdog
Continues the build order past Options/Scheduler/Speed Limiter/Batch/
Grabber/tray.

- DropTargetWidget: frameless always-on-top drop target (docs/03-gui-spec.md
  §5), position persisted, accepts a dropped http(s) URL or link text and
  opens FileInfoDialog directly (skipping Add URL, since the URL is already
  known). Shown/hidden from general.showDropTarget, live via
  event.settings.changed, same pattern MainWindow already used for
  general.minimizeToTray.
- Clipboard, explicit path #2 (docs/06-risks-and-spikes.md R2):
  GlobalShortcut wraps org.freedesktop.portal.GlobalShortcuts
  (CreateSession -> BindShortcuts -> Activated), triggering the same Add URL
  flow. Guarded end-to-end on `if(TARGET Qt6::DBus)` / VELOX_GUI_HAVE_DBUS
  so a build without the component degrades to "feature skipped," not
  broken (gui/docs/pkg-qa-requests-m1.md R4). Best-effort by design per the
  risk doc: fails silent, never advertised.

  Verified live against the real portal (a real Wayland session, not just
  offscreen): `CreateSession` refuses every caller with "An app id is
  required" — reproduced identically via a bare `busctl` call with no Qt
  involved at all, so this is the portal requiring a sandboxed caller
  identity, not something fixable from an unconfined process. Recorded as
  a partial Spike S2 answer in docs/06-risks-and-spikes.md: this explicit
  path likely doesn't work for Velox as a traditionally-packaged app on
  stock GNOME, only if/when it ships confined. Also fixed a real leak this
  verification caught: QDBusInterface's introspection cache reads as a
  LeakSanitizer leak the first time anything touches D-Bus (tst_rtl went
  red under ASan) — switched to QDBusMessage::createMethodCall, which
  needs no introspection.
- Theming (docs/03-gui-spec.md §7): gui/resources/qss/{idm-like,dark}.qss,
  each with a documented palette block up top (QSS itself has no variable
  syntax), applied by ThemeManager and kept live via
  QStyleHints::colorSchemeChanged. util/Theme.hpp gives the handful of
  inline C++ styles (status dot, offline banner, the eleven identical
  error-label styles across dialogs) named constants instead of a twelfth
  copy of the same hex.
- UiThreadWatchdog: the M1 DoD's 200 ms debug-build watchdog. A background
  std::thread pings the UI thread every 50 ms via a queued invokeMethod and
  warns once (not per-poll) if a ping goes unanswered past 200 ms; no
  QThread, no Qt event loop of its own, so the watchdog itself can never be
  what blocks the thread it watches. No-op in a release build. Proven both
  ways in tst_uithreadwatchdog: fires on a genuinely blocked UI thread
  (synchronous sleep, no processEvents) and stays silent on a responsive
  one.

Full non-conformance suite (55 tests across every lane, `ctest -LE
conformance`) passes clean at this point, including the whole gui label
under ASan+UBSan.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NSCdCWFXBTSBBK3MzWtJiC
2026-09-12 21:30:13 +04:00

270 lines
9.7 KiB
C++

#include "dialogs/FileInfoDialog.hpp"
#include <QCheckBox>
#include <QComboBox>
#include <QFileDialog>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QJsonArray>
#include <QLabel>
#include <QLineEdit>
#include <QLocale>
#include <QMenu>
#include <QPointer>
#include <QPushButton>
#include <QSpinBox>
#include <QToolButton>
#include <QUrl>
#include <QVBoxLayout>
#include "rpc/RpcClient.hpp"
#include "util/Theme.hpp"
namespace velox::gui {
namespace {
QString filenameFromUrl(const QString &url) {
const QString path = QUrl(url).path();
const QString name = path.section('/', -1);
return name.isEmpty() ? QStringLiteral("download") : name;
}
struct BufferChoice {
const char *label;
qint64 bytes;
};
constexpr BufferChoice kBufferChoices[] = {
{"256 KiB", 256 * 1024}, {"512 KiB", 512 * 1024}, {"1 MiB", 1024 * 1024},
{"2 MiB", 2 * 1024 * 1024}, {"4 MiB", 4 * 1024 * 1024}, {"8 MiB", 8 * 1024 * 1024},
{"16 MiB", 16 * 1024 * 1024},
};
} // namespace
FileInfoDialog::FileInfoDialog(rpc::RpcClient *client, QString url, QJsonArray categories,
QJsonArray queues, QWidget *parent)
: QDialog(parent),
client_(client),
url_(std::move(url)),
filenameEdit_(new QLineEdit(this)),
saveDirEdit_(new QLineEdit(this)),
categoryCombo_(new QComboBox(this)),
sizeLabel_(new QLabel(tr("Probing…"), this)),
resumeLabel_(new QLabel(tr("Probing…"), this)),
descriptionEdit_(new QLineEdit(this)),
connectionsSpin_(new QSpinBox(this)),
bufferCombo_(new QComboBox(this)),
addToQueueButton_(new QToolButton(this)),
errorLabel_(new QLabel(this)) {
setWindowTitle(tr("Download File Info"));
filenameEdit_->setText(filenameFromUrl(url_));
connect(filenameEdit_, &QLineEdit::textEdited, this, [this] { filenameEditedByUser_ = true; });
connect(saveDirEdit_, &QLineEdit::textEdited, this, [this] { saveDirEditedByUser_ = true; });
populateCategories(categories);
populateQueueMenu(queues);
connectionsSpin_->setRange(1, 32);
connectionsSpin_->setValue(8);
for (const auto &choice : kBufferChoices) {
bufferCombo_->addItem(QString::fromLatin1(choice.label), QVariant::fromValue(choice.bytes));
}
bufferCombo_->setCurrentIndex(4); // 4 MiB, matching the spec's mockup default
auto *browse = new QPushButton(tr("Browse…"), this);
connect(browse, &QPushButton::clicked, this, &FileInfoDialog::browseSaveDir);
auto *saveDirRow = new QWidget(this);
auto *saveDirLayout = new QHBoxLayout(saveDirRow);
saveDirLayout->setContentsMargins(0, 0, 0, 0);
saveDirLayout->addWidget(saveDirEdit_, 1);
saveDirLayout->addWidget(browse);
auto *sizeResumeRow = new QWidget(this);
auto *sizeResumeLayout = new QHBoxLayout(sizeResumeRow);
sizeResumeLayout->setContentsMargins(0, 0, 0, 0);
sizeResumeLayout->addWidget(sizeLabel_);
sizeResumeLayout->addSpacing(16);
sizeResumeLayout->addWidget(resumeLabel_);
sizeResumeLayout->addStretch();
auto *remember = new QCheckBox(tr("Remember for this file type"), this);
remember->setEnabled(false); // not wired to settings yet — Options dialog isn't built
auto *form = new QFormLayout;
form->addRow(tr("File Name:"), filenameEdit_);
form->addRow(tr("Save As:"), saveDirRow);
form->addRow(tr("Category:"), categoryCombo_);
form->addRow(QString(), sizeResumeRow);
form->addRow(tr("Description:"), descriptionEdit_);
form->addRow(tr("Connections:"), connectionsSpin_);
form->addRow(tr("Buffer:"), bufferCombo_);
form->addRow(QString(), remember);
errorLabel_->setStyleSheet(theme::errorLabelStyle());
errorLabel_->setWordWrap(true);
errorLabel_->hide();
addToQueueButton_->setText(tr("Add to Queue"));
addToQueueButton_->setPopupMode(QToolButton::MenuButtonPopup);
auto *downloadNow = new QPushButton(tr("Download Now"), this);
downloadNow->setDefault(true);
auto *downloadLater = new QPushButton(tr("Download Later"), this);
auto *cancel = new QPushButton(tr("Cancel"), this);
connect(downloadNow, &QPushButton::clicked, this,
[this] { submit(QStringLiteral("now"), {}); });
connect(downloadLater, &QPushButton::clicked, this,
[this] { submit(QStringLiteral("later"), {}); });
connect(cancel, &QPushButton::clicked, this, &QDialog::reject);
auto *buttonRow = new QHBoxLayout;
buttonRow->addStretch();
buttonRow->addWidget(downloadNow);
buttonRow->addWidget(downloadLater);
buttonRow->addWidget(addToQueueButton_);
buttonRow->addWidget(cancel);
auto *outer = new QVBoxLayout(this);
outer->addLayout(form);
outer->addWidget(errorLabel_);
outer->addLayout(buttonRow);
resize(560, sizeHint().height());
QPointer<FileInfoDialog> self(this);
client_->call(QStringLiteral("download.probe"), QJsonObject{{"url", url_}},
[self](const rpc::RpcReply &reply) {
if (!self) {
return; // dialog closed before the probe answered
}
self->onProbeFinished(reply.ok(), reply.result.toObject());
});
}
void FileInfoDialog::populateCategories(const QJsonArray &categories) {
categoryCombo_->addItem(tr("(Automatic)"), QString());
for (const QJsonValue &v : categories) {
const QJsonObject c = v.toObject();
categoryCombo_->addItem(c.value("name").toString(), c.value("categoryId").toString());
}
}
void FileInfoDialog::populateQueueMenu(const QJsonArray &queues) {
if (queues.isEmpty()) {
addToQueueButton_->setEnabled(false);
return;
}
auto *menu = new QMenu(addToQueueButton_);
for (const QJsonValue &v : queues) {
const QJsonObject q = v.toObject();
const QString queueId = q.value("queueId").toString();
menu->addAction(q.value("name").toString(), this,
[this, queueId] { submit(QStringLiteral("queue"), queueId); });
}
addToQueueButton_->setMenu(menu);
connect(addToQueueButton_, &QToolButton::clicked, addToQueueButton_, &QToolButton::showMenu);
}
QString FileInfoDialog::selectedCategoryId() const {
return categoryCombo_->currentData().toString();
}
void FileInfoDialog::onProbeFinished(bool ok, const QJsonObject &result) {
if (!ok) {
sizeLabel_->setText(tr("Size: unknown"));
resumeLabel_->setText(tr("Resume capability: unknown"));
errorLabel_->setText(
tr("Could not probe this URL; you can still try to download it as entered."));
errorLabel_->show();
return;
}
if (!filenameEditedByUser_) {
const QString probed = result.value("filename").toString();
if (!probed.isEmpty()) {
filenameEdit_->setText(probed);
}
}
if (!saveDirEditedByUser_) {
const QString suggested = result.value("suggestedSaveDir").toString();
if (!suggested.isEmpty()) {
saveDirEdit_->setText(suggested);
}
}
const QJsonValue size = result.value("sizeBytes");
sizeLabel_->setText(tr("Size: %1")
.arg(size.isDouble() ? QLocale().formattedDataSize(
static_cast<qint64>(size.toDouble()), 2)
: tr("unknown")));
resumeLabel_->setText(
tr("Resume capability: %1").arg(result.value("resumable").toBool() ? tr("Yes") : tr("No")));
const QString suggestedCategory = result.value("suggestedCategoryId").toString();
if (!suggestedCategory.isEmpty()) {
const int idx = categoryCombo_->findData(suggestedCategory);
if (idx >= 0) {
categoryCombo_->setCurrentIndex(idx);
}
}
}
void FileInfoDialog::browseSaveDir() {
const QString dir =
QFileDialog::getExistingDirectory(this, tr("Save As"), saveDirEdit_->text());
if (!dir.isEmpty()) {
saveDirEdit_->setText(dir);
saveDirEditedByUser_ = true;
}
}
void FileInfoDialog::submit(const QString &startMode, const QString &queueId) {
const QJsonObject spec = buildSpec(
url_, filenameEdit_->text().trimmed(), saveDirEdit_->text().trimmed(), selectedCategoryId(),
descriptionEdit_->text().trimmed(), connectionsSpin_->value(),
bufferCombo_->currentData().toLongLong(), startMode, queueId);
QPointer<FileInfoDialog> self(this);
client_->call(QStringLiteral("download.add"), spec, [self](const rpc::RpcReply &reply) {
if (!self) {
return;
}
if (reply.ok()) {
self->accept();
return;
}
self->errorLabel_->setText(tr("Could not add the download: %1").arg(reply.error.message));
self->errorLabel_->show();
});
}
QJsonObject FileInfoDialog::buildSpec(const QString &url, const QString &filename,
const QString &saveDir, const QString &categoryId,
const QString &description, int segments, qint64 bufferBytes,
const QString &startMode, const QString &queueId) {
QJsonObject spec{{"url", url}, {"startMode", startMode}};
if (!filename.isEmpty()) {
spec["filename"] = filename;
}
if (!saveDir.isEmpty()) {
spec["saveDir"] = saveDir;
}
if (!categoryId.isEmpty()) {
spec["categoryId"] = categoryId;
}
if (!description.isEmpty()) {
spec["description"] = description;
}
if (segments > 0) {
spec["segments"] = segments;
}
if (bufferBytes > 0) {
spec["bufferBytes"] = bufferBytes;
}
if (startMode == QLatin1String("queue") && !queueId.isEmpty()) {
spec["queueId"] = queueId;
}
return spec;
}
} // namespace velox::gui