gui: finish Add URL -> File Info -> Progress dialog flow
Wires up the three dialogs from build order step 5 (docs/03-gui-spec.md §§2-3) and the MainWindow slots that were declared but never implemented: - AddUrlDialog: clipboard prefill is the explicit path docs/06 R2 calls for (no passive monitoring, not advertised). - FileInfoDialog: async download.probe never blocks the UI; ends by calling download.add itself (Now / Later / Add to Queue). buildSpec() is a pure static so the optional-field-omission logic is unit-testable without touching a widget. - ProgressDialog: non-modal, WA_DeleteOnClose, driven by the taskProgress/ taskStateChanged signals RpcClient already re-broadcasts; download.get seeds state once for a dialog opened mid-transfer. Hosts SegmentBarsWidget and SpeedGraphWidget. MainWindow: openAddUrlDialog/openPropertiesForSelection/showTableContextMenu now have bodies; category.list/queue.list responses are cached so File Info can populate its category combo and queue menu without a second round trip. The row context menu covers what already exists (Resume/Pause/Stop/Delete/ Properties) and deliberately leaves out Open/Open With/Move-Rename/ Redownload/Add to Queue — those need dialogs later build-order steps haven't reached yet. util/Format.hpp: pulled the bytes/rate/eta formatting out of MainWindow and DownloadTableModel once the dialogs wanted the same strings a third time. Verified end-to-end against a running mockd (category.list/queue.list, download.probe, download.add, download.get, and live event.task.progress/ event.task.state) under ASan+UBSan: all three dialogs render correctly against real fixture data and the flow runs clean with no leaks or sanitizer reports. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NSCdCWFXBTSBBK3MzWtJiC
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
#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"
|
||||
|
||||
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(QStringLiteral("color: #c0392b;"));
|
||||
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
|
||||
Reference in New Issue
Block a user