Pith - whittle
whittle/src/ui/MainWindow.cpp [28.2 kb]
Modified: 19:51:46 130 026 (27 Jul 026)
0 Days Ago
#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();
}

}
Updates
Whittle - Linux 130.026
Wedge - Android 126.026
Wedge - Linux 124.026
Shim - Android 117.026
Miter - 114.026
Dev
TVShow (227) 'CSA'
TVShow (228) 'APT'
TVProgram (83) 'BXT'
Miter Update(s)
Peen (Messaging)

Menu
Calendar
Project Tin (024/029)
Miter
RSS Feed
User Avatar
@vgmlr
=SUM(parts)