#include "core/ConfigStore.h"
#include "ui/MainWindow.h"
#include "ui/Palette.h"
#include "ui/SchemeDialogs.h"
#include "ui/SettingsDialog.h"
#include "ui/StatusStrip.h"
#include <QAbstractButton>
#include <QAction>
#include <QApplication>
#include <QCheckBox>
#include <QComboBox>
#include <QDesktopServices>
#include <QDir>
#include <QFileInfo>
#include <QHeaderView>
#include <QIcon>
#include <QLocale>
#include <QMenu>
#include <QMessageBox>
#include <QPalette>
#include <QProcess>
#include <QPushButton>
#include <QResizeEvent>
#include <QShowEvent>
#include <QSplitter>
#include <QStorageInfo>
#include <QTimer>
#include <QTreeView>
#include <QUrl>
#include <QVBoxLayout>
#include <QWidget>
namespace whittle {
namespace {
struct UsbInfo { QString label, mount; qint64 free = 0, total = 0; };
QList<UsbInfo> enumerateUsb() {
QList<UsbInfo> out;
for (const QStorageInfo& si : QStorageInfo::mountedVolumes()) {
if (!si.isValid() || !si.isReady()) continue;
const QString mp = si.rootPath();
if (!(mp.startsWith("/media/") || mp.startsWith("/run/media/"))) continue;
UsbInfo u;
u.mount = mp;
u.label = si.displayName().isEmpty() ? mp : si.displayName();
u.free = si.bytesAvailable();
u.total = si.bytesTotal();
out.push_back(u);
}
return out;
}
QString human(qint64 b) {
return QLocale().formattedDataSize(b, 1, QLocale::DataSizeTraditionalFormat);
}
QString driveText(const UsbInfo& u) {
return QObject::tr("Drive: %1 — %2 free / %3")
.arg(u.label, human(u.free), human(u.total));
}
constexpr int kIconPx = 16;
// leading icon, resource-backed
QPushButton* iconButton(const char* svg, const QString& text) {
auto* b = new QPushButton(QIcon(QLatin1String(":/icons/") + QLatin1String(svg)), text);
b->setIconSize(QSize(kIconPx, kIconPx));
return b;
}
QMessageBox::StandardButton askYesNo(QWidget* parent, const QString& title,
const QString& text) {
QMessageBox box(QMessageBox::Question, title, text,
QMessageBox::Yes | QMessageBox::No, parent);
box.setDefaultButton(QMessageBox::No);
for (QAbstractButton* b : box.buttons()) b->setIcon(QIcon());
box.exec();
return box.standardButton(box.clickedButton());
}
void notify(QWidget* parent, QMessageBox::Icon icon, const QString& title,
const QString& text) {
QMessageBox box(icon, title, text, QMessageBox::Ok, parent);
for (QAbstractButton* b : box.buttons()) b->setIcon(QIcon());
box.exec();
}
void applyViewPalette(QAbstractItemView* view, const QColor& bg,
const QColor& text, const QColor& line = QColor()) {
QPalette p = view->palette();
p.setColor(QPalette::Base, bg);
p.setColor(QPalette::AlternateBase, bg);
p.setColor(QPalette::Text, text);
if (line.isValid()) p.setColor(QPalette::Mid, line);
view->setPalette(p);
}
void applyDriveText(QComboBox* combo, const QList<UsbInfo>& drives) {
for (const UsbInfo& u : drives) {
const int i = combo->findData(u.mount);
if (i >= 0) combo->setItemText(i, driveText(u));
}
}
}
MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
setWindowTitle(tr("Whittle"));
// window size onload
resize(1550, 800);
m_settings = ConfigStore::loadFilter();
m_treeModel = new FolderTreeModel(this);
m_treeModel->setRecursive(m_settings.recursive);
m_midModel = new DiffTableModel(/*checkable=*/true, this);
m_usbModel = new DiffTableModel(/*checkable=*/false, this);
m_status = new StatusStrip;
QWidget* leftPanel = buildLeftPanel();
QWidget* usbPanel = buildUsbPanel();
QWidget* midPanel = buildMidPanel();
int headerH = qMax(m_usbCombo->sizeHint().height(),
m_schemeCombo->sizeHint().height());
headerH = qMax(headerH, m_scanBtn->sizeHint().height());
headerH = qMax(headerH, m_mergeBtn->sizeHint().height());
headerH = qMax(headerH, m_saveSchemeBtn->sizeHint().height());
headerH = qMax(headerH, m_resetBtn->sizeHint().height());
headerH = qMax(headerH, m_ejectBtn->sizeHint().height());
headerH = qMax(headerH, m_settingsBtn->sizeHint().height());
headerH = qMax(headerH, m_quitBtn->sizeHint().height());
m_schemeHeader->setFixedHeight(headerH);
m_usbHeader->setFixedHeight(headerH);
m_midHeader->setFixedHeight(headerH);
m_panelSplit = new QSplitter(Qt::Horizontal);
m_panelSplit->addWidget(midPanel);
m_panelSplit->addWidget(usbPanel);
m_panelSplit->setChildrenCollapsible(false);
m_panelSplit->setStretchFactor(0, 1);
m_panelSplit->setStretchFactor(1, 1);
auto* rightColumn = new QWidget;
auto* rc = new QVBoxLayout(rightColumn);
rc->setContentsMargins(0, 0, 0, 0);
rc->setSpacing(0);
rc->addWidget(m_panelSplit, 1);
rc->addWidget(m_status);
m_split = new QSplitter(Qt::Horizontal, this);
m_split->addWidget(leftPanel);
m_split->addWidget(rightColumn);
m_split->setChildrenCollapsible(false);
m_split->setStretchFactor(0, 20);
m_split->setStretchFactor(1, 80);
setCentralWidget(m_split);
connect(m_split, &QSplitter::splitterMoved,
this, [this] { m_userSplit = true; });
connect(m_panelSplit, &QSplitter::splitterMoved,
this, [this] { m_userSplit = true; });
connect(m_treeModel, &FolderTreeModel::selectionChanged, this, [this] {
if (!m_applyingScheme) setSchemeDirty(true);
updateActionStates();
});
connect(m_treeModel, &QFileSystemModel::directoryLoaded, this, [this] {
if (!m_expandPending.isEmpty() || !m_expandScrollTo.isEmpty()) stepExpand();
});
m_usbPoll = new QTimer(this);
m_usbPoll->setInterval(2000);
connect(m_usbPoll, &QTimer::timeout, this, &MainWindow::pollUsbDrives);
m_usbPoll->start();
refreshUsbList();
refreshSchemeCombo();
if (m_settings.loadLastScheme) {
const QString last = ConfigStore::loadLastSchemeName();
if (!last.isEmpty() && ConfigStore::hasScheme(last)) {
refreshSchemeCombo(last);
applyScheme(last);
}
}
}
void MainWindow::showEvent(QShowEvent* e) {
QMainWindow::showEvent(e);
applySplitProportions();
}
void MainWindow::resizeEvent(QResizeEvent* e) {
QMainWindow::resizeEvent(e);
applySplitProportions();
}
void MainWindow::applySplitProportions() {
if (m_userSplit) return;
const int w = m_split->width() - m_split->handleWidth();
if (w <= 0) return;
const int left = w * 20 / 100;
m_split->setSizes({left, w - left});
const int pw = m_panelSplit->width() - m_panelSplit->handleWidth();
if (pw <= 0) return;
m_panelSplit->setSizes({pw / 2, pw - pw / 2});
}
QWidget* MainWindow::buildLeftPanel() {
auto* w = new QWidget;
auto* v = new QVBoxLayout(w);
m_schemeHeader = new QWidget;
auto* h = new QHBoxLayout(m_schemeHeader);
h->setContentsMargins(0, 0, 0, 0);
m_schemeCombo = new QComboBox;
m_schemeCombo->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon);
m_schemeCombo->setMinimumContentsLength(8);
connect(m_schemeCombo, QOverload<int>::of(&QComboBox::activated),
this, &MainWindow::onSchemeActivated);
h->addWidget(m_schemeCombo, 1);
m_saveSchemeBtn = iconButton("save.svg", tr("Save"));
connect(m_saveSchemeBtn, &QPushButton::clicked, this, &MainWindow::onSaveScheme);
h->addWidget(m_saveSchemeBtn);
m_resetBtn = iconButton("reset.svg", tr("Reset"));
connect(m_resetBtn, &QPushButton::clicked, this, &MainWindow::onReset);
h->addWidget(m_resetBtn);
v->addWidget(m_schemeHeader);
m_tree = new QTreeView;
m_tree->setModel(m_treeModel);
m_tree->setSelectionMode(QAbstractItemView::NoSelection);
m_tree->setFocusPolicy(Qt::NoFocus);
m_tree->setRootIndex(m_treeModel->index(QDir::rootPath()));
for (int c = 1; c < m_treeModel->columnCount(); ++c) m_tree->hideColumn(c);
m_tree->setHeaderHidden(true);
applyViewPalette(m_tree, Palette::TreeBg, Palette::TreeText);
v->addWidget(m_tree, 1);
return w;
}
QWidget* MainWindow::buildMidPanel() {
auto* w = new QWidget;
auto* v = new QVBoxLayout(w);
m_midHeader = new QWidget;
auto* h = new QHBoxLayout(m_midHeader);
h->setContentsMargins(0, 0, 0, 0);
h->addStretch(1);
m_scanBtn = iconButton("scan.svg", tr("Scan"));
m_scanBtn->setEnabled(false);
connect(m_scanBtn, &QPushButton::clicked, this, &MainWindow::onScanButton);
h->addWidget(m_scanBtn);
m_mergeBtn = iconButton("merge.svg", tr("Merge"));
m_mergeBtn->setEnabled(false);
connect(m_mergeBtn, &QPushButton::clicked, this, &MainWindow::onMerge);
h->addWidget(m_mergeBtn);
v->addWidget(m_midHeader);
m_midView = new PanelTableView;
m_midView->setModel(m_midModel);
m_midView->setSortingEnabled(true);
m_midView->sortByColumn(DiffTableModel::Name, Qt::AscendingOrder);
applyViewPalette(m_midView, Palette::ListBg, Palette::ListText,
Palette::ListLine);
v->addWidget(m_midView, 1);
return w;
}
QWidget* MainWindow::buildUsbPanel() {
auto* w = new QWidget;
auto* v = new QVBoxLayout(w);
m_usbHeader = new QWidget;
auto* h = new QHBoxLayout(m_usbHeader);
h->setContentsMargins(0, 0, 0, 0);
m_usbCombo = new QComboBox;
m_usbCombo->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon);
m_usbCombo->setMinimumContentsLength(12);
connect(m_usbCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &MainWindow::onUsbChanged);
h->addWidget(m_usbCombo, 1);
m_ejectBtn = iconButton("eject.svg", tr("Eject"));
m_ejectBtn->setEnabled(false);
connect(m_ejectBtn, &QPushButton::clicked, this, &MainWindow::onEject);
h->addWidget(m_ejectBtn);
m_settingsBtn = iconButton("settings.svg", tr("Settings"));
connect(m_settingsBtn, &QPushButton::clicked, this, &MainWindow::onSettings);
h->addWidget(m_settingsBtn);
m_quitBtn = iconButton("quit.svg", tr("Quit"));
connect(m_quitBtn, &QPushButton::clicked, this, &QWidget::close);
h->addWidget(m_quitBtn);
v->addWidget(m_usbHeader);
m_usbView = new PanelTableView;
m_usbView->setModel(m_usbModel);
m_usbView->setSortingEnabled(true);
m_usbView->sortByColumn(DiffTableModel::Name, Qt::AscendingOrder);
m_usbView->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_usbView, &QWidget::customContextMenuRequested,
this, &MainWindow::onUsbContextMenu);
applyViewPalette(m_usbView, Palette::ListBg, Palette::ListText,
Palette::ListLine);
v->addWidget(m_usbView, 1);
return w;
}
QString MainWindow::currentUsbMount() const {
return m_usbCombo->currentData().toString();
}
void MainWindow::pollUsbDrives() {
if (m_scanning || m_merging || m_ejecting || QApplication::activeModalWidget()) return;
refreshUsbList();
}
void MainWindow::refreshUsbList() {
const QList<UsbInfo> drives = enumerateUsb();
QStringList signature;
for (const UsbInfo& u : drives) signature << u.mount;
if (!m_usbFirstBuild && signature == m_usbSignature) {
applyDriveText(m_usbCombo, drives);
return;
}
m_usbSignature = signature;
const QString prev = currentUsbMount();
m_usbCombo->blockSignals(true);
m_usbCombo->clear();
for (const UsbInfo& u : drives)
m_usbCombo->addItem(driveText(u), u.mount);
if (m_usbCombo->count() == 0)
m_usbCombo->addItem(tr("No drive detected"), QString());
const int idx = m_usbCombo->findData(prev);
if (idx >= 0) m_usbCombo->setCurrentIndex(idx);
m_usbCombo->blockSignals(false);
if (m_usbFirstBuild || currentUsbMount() != prev) {
m_usbFirstBuild = false;
onUsbChanged();
}
}
void MainWindow::onUsbChanged() {
m_usbModel->setRows({}, {});
m_midModel->setRows({}, {});
m_sourceMap.clear();
m_usbMap.clear();
m_lastUsbMount.clear();
m_lastMerge.clear();
m_countFolders = m_countFiles = m_countNew = m_countDiff = 0;
m_countBytes = m_newBytes = m_diffBytes = 0;
updateCounts();
m_status->setDormant();
updateActionStates();
}
void MainWindow::onScanButton() {
if (m_scanning) { cancelScan(); return; }
onScan();
}
void MainWindow::onScan() {
if (currentUsbMount().isEmpty()) return;
startScan(m_treeModel->scanRoots(), m_settings.recursive, currentUsbMount());
}
void MainWindow::onReset() {
m_applyingScheme = true;
m_treeModel->clearChecks();
m_applyingScheme = false;
m_midModel->setRows({}, {});
m_usbModel->setRows({}, {});
m_lastMerge.clear();
clearLoadedScheme();
m_schemeCombo->setCurrentIndex(0);
m_status->setDormant();
updateActionStates();
}
void MainWindow::startScan(const QStringList& roots, bool recursive, const QString& usb) {
if (m_controller && m_controller->isRunning()) return;
setScanning(true);
m_countFolders = m_countFiles = m_countNew = m_countDiff = 0;
m_countBytes = m_newBytes = m_diffBytes = 0;
updateCounts();
m_pendingRoots = roots;
m_pendingRecursive = recursive;
const QStringList excluded = m_treeModel->excludedPaths();
m_pendingExcluded = QSet<QString>(excluded.begin(), excluded.end());
m_pendingUsb = usb;
m_controller = new ScanController(roots, m_pendingExcluded, recursive,
usb, m_settings, this);
connect(m_controller, &ScanController::progress, this, &MainWindow::onProgress);
connect(m_controller, &ScanController::resultsReady, this, &MainWindow::onResults);
connect(m_controller, &ScanController::canceled, this, &MainWindow::onCanceled);
connect(m_controller, &ScanController::finished, m_controller, &QObject::deleteLater);
m_controller->start();
}
void MainWindow::cancelScan() {
if (!m_controller) return;
m_controller->cancel();
m_scanBtn->setEnabled(false);
}
void MainWindow::setScanning(bool on) {
m_scanning = on;
if (on) m_status->setScanning();
updateActionStates();
}
void MainWindow::updateActionStates() {
const bool haveUsb = !currentUsbMount().isEmpty();
m_scanBtn->setText(m_scanning ? tr("Cancel") : tr("Scan"));
m_scanBtn->setEnabled(!m_merging && !m_ejecting && (m_scanning || haveUsb));
m_mergeBtn->setText(m_merging ? tr("Cancel") : tr("Merge"));
m_mergeBtn->setEnabled(m_merging ||
(haveUsb && !m_scanning && !m_ejecting &&
!m_lastMerge.isEmpty() &&
!m_lastUsbMount.isEmpty()));
m_ejectBtn->setEnabled(haveUsb && !m_scanning && !m_merging && !m_ejecting);
}
void MainWindow::onProgress(int folders, int files, qint64 bytes) {
m_countFolders = folders;
m_countFiles = files;
m_countBytes = bytes;
updateCounts();
}
void MainWindow::updateCounts() {
if (m_postMergeMode) return;
m_status->setCounts(m_countFolders, m_countFiles, m_countBytes,
m_countNew, m_newBytes, m_countDiff, m_diffBytes);
}
void MainWindow::onResults(DiffResult result, FileMap sourceMap, FileMap usbMap) {
const bool wasPostMerge = m_postMergeMode;
m_sourceMap = sourceMap;
m_usbMap = usbMap;
m_lastUsbMount = m_pendingUsb;
m_lastRoots = m_pendingRoots;
m_lastRecursive = m_pendingRecursive;
m_lastExcluded = m_pendingExcluded;
if (m_postMergeMode) {
m_postMergeMode = false;
QList<FileRecord> onlyIssues;
for (const FileRecord& r : result.sourceRows)
if (m_postMergeSkipped.contains(r.relPath)) onlyIssues.append(r);
m_midModel->setRows(onlyIssues, usbMap);
m_midModel->setSkipped(m_postMergeSkipped);
m_usbModel->setRows({}, sourceMap);
m_lastMerge.clear();
for (const FileRecord& r : result.mergeList)
if (m_postMergeSkipped.contains(r.relPath)) m_lastMerge.append(r);
m_postMergeSkipped.clear();
} else {
m_midModel->setRows(result.sourceRows, usbMap);
m_usbModel->setRows(result.usbOnlyRows, sourceMap);
m_lastMerge = result.mergeList;
}
m_countNew = result.newFileCount;
m_countDiff = result.diffFileCount;
m_newBytes = result.newBytes;
m_diffBytes = result.diffBytes;
updateCounts();
setScanning(false);
updateActionStates();
if (wasPostMerge) m_status->setWhittled(m_mergedFiles, m_mergedBytes, m_mergeErrors);
else m_status->setScanned();
}
void MainWindow::recompareFromCache() {
if (m_sourceMap.isEmpty() && m_usbMap.isEmpty()) return;
DiffResult r = Comparator::compare(m_sourceMap, m_usbMap, m_settings);
m_midModel->setRows(r.sourceRows, m_usbMap);
m_usbModel->setRows(r.usbOnlyRows, m_sourceMap);
m_lastMerge = r.mergeList;
m_countNew = r.newFileCount;
m_countDiff = r.diffFileCount;
m_newBytes = r.newBytes;
m_diffBytes = r.diffBytes;
updateCounts();
updateActionStates();
}
void MainWindow::onUsbContextMenu(const QPoint& pos) {
const QModelIndex ix = m_usbView->indexAt(pos);
if (!ix.isValid()) return;
const FileRecord* found = m_usbModel->recordAt(ix.row());
if (!found) return;
const FileRecord rec = *found;
QMenu menu(this);
QAction* openAct = menu.addAction(tr("Open Folder"));
QAction* delAct = menu.addAction(tr("Delete"));
QAction* chosen = menu.exec(m_usbView->viewport()->mapToGlobal(pos));
if (chosen == openAct) {
const QString dir = rec.isDir ? rec.absPath
: QFileInfo(rec.absPath).absolutePath();
QDesktopServices::openUrl(QUrl::fromLocalFile(dir));
return;
}
if (chosen != delAct) return;
const QString name = rec.relPath.section('/', -1);
const auto btn = askYesNo(this, tr("Delete"),
rec.isDir ? tr("Delete \"%1\" and contents?").arg(name)
: tr("Delete \"%1\"?").arg(name));
if (btn != QMessageBox::Yes) return;
const bool ok = rec.isDir ? QDir(rec.absPath).removeRecursively()
: QFile::remove(rec.absPath);
if (!ok) {
QMessageBox::warning(this, tr("Delete"),
tr("Could not delete \"%1\".").arg(name));
return;
}
m_usbModel->removeRecord(rec.relPath);
m_usbMap.remove(rec.relPath);
const QString prefix = rec.relPath + '/';
for (auto it = m_usbMap.begin(); it != m_usbMap.end(); )
it = it.key().startsWith(prefix) ? m_usbMap.erase(it) : ++it;
}
void MainWindow::runUdisks(const QStringList& args, UdisksHandler done) {
auto* p = new QProcess(this);
p->setProcessChannelMode(QProcess::MergedChannels);
connect(p, &QProcess::errorOccurred, this, [p, done](QProcess::ProcessError e) {
if (e != QProcess::FailedToStart) return;
p->deleteLater();
done(-1, MainWindow::tr("udisksctl not found."));
});
connect(p, &QProcess::finished, this,
[p, done](int code, QProcess::ExitStatus st) {
const QString out = QString::fromLocal8Bit(p->readAll()).trimmed();
p->deleteLater();
done(st == QProcess::NormalExit ? code : -1, out);
});
p->start(QStringLiteral("udisksctl"), args);
}
void MainWindow::onEject() {
if (m_ejecting) return;
const QString mount = currentUsbMount();
if (mount.isEmpty()) return;
const QString dev = QString::fromLocal8Bit(QStorageInfo(mount).device());
if (!dev.startsWith(QLatin1String("/dev/"))) {
notify(this, QMessageBox::Warning, tr("Eject"),
tr("No device found for %1.").arg(mount));
return;
}
m_ejecting = true;
m_usbPoll->stop();
updateActionStates();
runUdisks({QStringLiteral("unmount"), QStringLiteral("-b"), dev},
[this, dev](int code, QString out) {
if (code != 0) {
finishEject(tr("Drive could not unmount.\n%1").arg(out), false);
return;
}
runUdisks({QStringLiteral("power-off"), QStringLiteral("-b"), dev},
[this](int code2, QString out2) {
if (code2 != 0)
finishEject(tr("Drive could not be powered off.\n%1").arg(out2), true);
else
finishEject(QString(), true);
});
});
}
void MainWindow::finishEject(const QString& message, bool unmounted) {
m_ejecting = false;
m_usbPoll->start();
refreshUsbList();
updateActionStates();
if (message.isEmpty()) return;
notify(this, unmounted ? QMessageBox::Information : QMessageBox::Warning,
tr("Eject"), message);
}
void MainWindow::onSettings() {
const bool wasRecursive = m_settings.recursive;
SettingsDialog dlg(m_settings, this);
if (dlg.exec() == QDialog::Accepted) {
m_settings = dlg.settings();
ConfigStore::saveFilter(m_settings);
if (m_settings.recursive != wasRecursive)
m_treeModel->setRecursive(m_settings.recursive);
recompareFromCache();
}
}
void MainWindow::onSaveScheme() {
const QStringList roots = m_treeModel->scanRoots();
SaveSchemeDialog dlg(ConfigStore::schemeNames(), !roots.isEmpty(), this);
const bool accepted = (dlg.exec() == QDialog::Accepted);
if (!accepted && !dlg.listChanged()) return;
QString select = m_schemeCombo->currentData().toString();
if (accepted) {
ConfigStore::Scheme s;
s.roots = roots;
s.excluded = m_treeModel->excludedPaths();
s.recursive = m_settings.recursive;
if (ConfigStore::saveScheme(dlg.schemeName(), s)) {
select = dlg.schemeName();
m_loadedScheme = select;
m_schemeDirty = false;
} else
QMessageBox::warning(this, tr("Save"),
tr("Could not write to %1.").arg(ConfigStore::configDir()));
}
if (!select.isEmpty() && !ConfigStore::hasScheme(select)) select.clear();
if (!m_loadedScheme.isEmpty() && !ConfigStore::hasScheme(m_loadedScheme))
clearLoadedScheme();
refreshSchemeCombo(select);
}
void MainWindow::refreshSchemeCombo(const QString& select) {
m_schemeCombo->blockSignals(true);
m_schemeCombo->clear();
m_schemeCombo->addItem(tr("(no scheme)"), QString());
for (const QString& n : ConfigStore::schemeNames())
m_schemeCombo->addItem(schemeLabel(n), n);
const int i = select.isEmpty() ? 0 : m_schemeCombo->findData(select);
m_schemeCombo->setCurrentIndex(i >= 0 ? i : 0);
m_schemeCombo->blockSignals(false);
}
QString MainWindow::schemeLabel(const QString& name) const {
return (m_schemeDirty && name == m_loadedScheme)
? name + QStringLiteral(" *") : name;
}
void MainWindow::updateSchemeLabel() {
if (m_loadedScheme.isEmpty()) return;
const int i = m_schemeCombo->findData(m_loadedScheme);
if (i >= 0) m_schemeCombo->setItemText(i, schemeLabel(m_loadedScheme));
}
void MainWindow::setSchemeDirty(bool dirty) {
dirty = dirty && !m_loadedScheme.isEmpty();
if (dirty == m_schemeDirty) return;
m_schemeDirty = dirty;
updateSchemeLabel();
}
void MainWindow::clearLoadedScheme() {
setSchemeDirty(false);
m_loadedScheme.clear();
}
void MainWindow::onSchemeActivated(int) {
const QString name = m_schemeCombo->currentData().toString();
if (name.isEmpty()) {
clearLoadedScheme();
return;
}
applyScheme(name);
}
void MainWindow::applyScheme(const QString& name) {
bool found = false;
const ConfigStore::Scheme s = ConfigStore::loadScheme(name, &found);
if (!found) return;
clearLoadedScheme();
m_applyingScheme = true;
QStringList live;
for (const QString& p : s.roots)
if (QFileInfo(p).isDir()) live << p;
QStringList liveExcluded;
for (const QString& p : s.excluded)
if (QFileInfo(p).isDir()) liveExcluded << p;
if (s.recursive != m_settings.recursive) {
m_settings.recursive = s.recursive;
ConfigStore::saveFilter(m_settings);
}
m_treeModel->setRecursive(s.recursive);
m_treeModel->setCheckedRoots(live, liveExcluded);
m_applyingScheme = false;
m_loadedScheme = name;
expandToPaths(live + liveExcluded);
m_midModel->setRows({}, {});
m_usbModel->setRows({}, {});
m_sourceMap.clear();
m_usbMap.clear();
m_lastMerge.clear();
updateActionStates();
m_countFolders = m_countFiles = m_countNew = m_countDiff = 0;
m_countBytes = m_newBytes = m_diffBytes = 0;
updateCounts();
m_status->setDormant();
ConfigStore::saveLastSchemeName(name);
}
void MainWindow::expandToPaths(const QStringList& paths) {
m_expandPending = paths;
m_expandScrollTo = paths.value(0);
stepExpand();
}
void MainWindow::stepExpand() {
QStringList pending;
pending.swap(m_expandPending);
for (const QString& p : pending)
if (!revealPath(p)) m_expandPending << p;
if (m_expandScrollTo.isEmpty()) return;
const QModelIndex ix = m_treeModel->index(m_expandScrollTo);
if (ix.isValid()) { m_tree->scrollTo(ix); m_expandScrollTo.clear(); }
}
bool MainWindow::revealPath(const QString& path) {
QModelIndex parent = m_treeModel->index(QDir::rootPath());
for (int pos = 0;;) {
pos = path.indexOf('/', pos + 1);
const QString prefix = (pos < 0) ? path : path.left(pos);
if (m_treeModel->canFetchMore(parent)) m_treeModel->fetchMore(parent);
const QModelIndex ix = m_treeModel->index(prefix);
if (!ix.isValid()) return false;
if (pos < 0) return true;
m_tree->expand(ix);
parent = ix;
}
}
void MainWindow::onMerge() {
if (m_merging) { cancelMerge(); return; }
const QSet<QString> checked = m_midModel->checkedPaths();
QList<FileRecord> items;
qint64 bytes = 0;
for (const FileRecord& r : m_lastMerge)
if (checked.contains(r.relPath)) { items.append(r); if (!r.isDir) bytes += r.size; }
if (items.isEmpty()) {
QMessageBox::information(this, tr("Merge"), tr("Nothing selected to merge."));
return;
}
const qint64 avail = QStorageInfo(m_lastUsbMount).bytesAvailable();
if (avail >= 0 && bytes > avail) {
const auto btn = QMessageBox::warning(this, tr("Low space"),
tr("Insufficient space. Proceed?"),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (btn != QMessageBox::Yes) return;
}
startMerge(std::move(items));
}
void MainWindow::startMerge(QList<FileRecord> items) {
if (m_merger && m_merger->isRunning()) return;
m_merging = true;
m_deviceLost = false;
m_mergedFiles = 0;
m_mergedBytes = 0;
m_skippedBytes = 0;
m_mergeErrors = 0;
m_mergeSkipped.clear();
m_merger = new MergeController(std::move(items), m_lastUsbMount, this);
m_mergeTotalBytes = m_merger->totalBytes();
connect(m_merger, &MergeController::progress, this, &MainWindow::onMergeProgress);
connect(m_merger, &MergeController::skipped, this, &MainWindow::onMergeSkipped);
connect(m_merger, &MergeController::deviceLost, this, &MainWindow::onMergeDeviceLost);
connect(m_merger, &MergeController::done, this, &MainWindow::onMergeDone);
connect(m_merger, &MergeController::finished, m_merger, &QObject::deleteLater);
m_status->setMerging(QString(), 0, 0, m_mergeTotalBytes);
updateActionStates();
m_merger->start();
}
void MainWindow::cancelMerge() {
if (!m_merger) return;
m_merger->cancel();
m_mergeBtn->setEnabled(false);
}
void MainWindow::onMergeProgress(QString rel, qint64 bytesDone, int filesDone) {
m_mergedFiles = filesDone;
m_mergedBytes = qMax(qint64(0), bytesDone - m_skippedBytes);
m_status->setMerging(rel, m_sourceMap.value(rel).size,
bytesDone, m_mergeTotalBytes);
}
void MainWindow::onMergeSkipped(QString rel, int) {
m_mergeSkipped.insert(rel);
m_skippedBytes += m_sourceMap.value(rel).size;
}
void MainWindow::onMergeDeviceLost() { m_deviceLost = true; }
void MainWindow::onMergeDone(bool) {
m_merging = false;
m_mergeErrors = int(m_mergeSkipped.size());
m_status->setWhittled(m_mergedFiles, m_mergedBytes, m_mergeErrors);
if (m_deviceLost)
QMessageBox::warning(this, tr("Drive Removed"),
tr("Drive removed. Whittle stopped."));
m_postMergeMode = true;
m_postMergeSkipped = m_mergeSkipped;
startScan(m_lastRoots, m_lastRecursive, m_lastUsbMount);
}
void MainWindow::onCanceled() {
m_postMergeMode = false;
m_postMergeSkipped.clear();
setScanning(false);
m_status->setDormant();
}
}