Pith - whittle
#include "core/ConfigStore.h"
#include "ui/AnalyzeDialog.h"
#include "ui/MainWindow.h"
#include "ui/Palette.h"
#include "ui/SchemeDialogs.h"
#include "ui/SettingsDialog.h"
#include <algorithm>
#include <utility>
#include <QAbstractButton>
#include <QAction>
#include <QApplication>
#include <QCheckBox>
#include <QComboBox>
#include <QDesktopServices>
#include <QDir>
#include <QFileInfo>
#include <QHeaderView>
#include <QIcon>
#include <QItemSelectionModel>
#include <QLabel>
#include <QLocale>
#include <QMenu>
#include <QMessageBox>
#include <QPalette>
#include <QProcess>
#include <QProgressBar>
#include <QProxyStyle>
#include <QPushButton>
#include <QResizeEvent>
#include <QShowEvent>
#include <QSplitter>
#include <QStorageInfo>
#include <QStyleOption>
#include <QTimer>
#include <QTreeView>
#include <QUrl>
#include <QVBoxLayout>
#include <QWidget>

namespace whittle {
	namespace {

		constexpr char kVersion[]   = "215.026.1 mlwrk";
		constexpr char kSourceUrl[] = "https://vgmlr.com/mlwrk";
		constexpr char kLinkStyle[] = "text-decoration:none; color:#000000;"
		" font-family:monospace; font-size:8pt;";

		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) {
			if (b < 1024) return QStringLiteral("%1 b").arg(b);
			return QLocale().formattedDataSize(b, 1, QLocale::DataSizeTraditionalFormat)
			.toLower();
		}

		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;
		constexpr int kHeaderGap = 4;
		constexpr int kButtonPad = 10;

		QPushButton* iconButton(const char* svg, const QString& text) {
			auto* b = new QPushButton(QIcon(QLatin1String(":/icons/") + QLatin1String(svg)), text);
			b->setIconSize(QSize(kIconPx, kIconPx));
			b->setMinimumWidth(b->sizeHint().width() + kButtonPad);
			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();
		}

		enum class DeleteChoice { Abort, Skip, Delete };

		DeleteChoice askDeleteMerge(QWidget* parent) {
			QMessageBox box(QMessageBox::Question, QObject::tr("Merge"),
				QObject::tr("Delete No-File(s) then Merge?"), QMessageBox::NoButton, parent);
			QPushButton* skip = box.addButton(QObject::tr("Skip Delete"),
				QMessageBox::RejectRole);
			QPushButton* go = box.addButton(QObject::tr("Agreed"),
				QMessageBox::AcceptRole);
			QPushButton* abort = box.addButton(QMessageBox::Cancel);
			abort->hide();
			box.setDefaultButton(skip);
			box.setEscapeButton(abort);
			for (QAbstractButton* b : box.buttons()) b->setIcon(QIcon());
				box.exec();
			if (box.clickedButton() == go)   return DeleteChoice::Delete;
			if (box.clickedButton() == skip) return DeleteChoice::Skip;
			return DeleteChoice::Abort;
		}

		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));
			}
		}

		constexpr int kMenuItemPad = 6;
		constexpr int kMenuEdgePad = 4;

		class MenuPadStyle : public QProxyStyle {
		public:
			int pixelMetric(PixelMetric m, const QStyleOption* opt,
				const QWidget* w) const override {
				const int v = QProxyStyle::pixelMetric(m, opt, w);
				return m == PM_MenuVMargin ? v + kMenuEdgePad : v;
			}

			QSize sizeFromContents(ContentsType ct, const QStyleOption* opt,
				const QSize& sz, const QWidget* w) const override {
				QSize s = QProxyStyle::sizeFromContents(ct, opt, sz, w);
				if (ct != CT_MenuItem) return s;
				const auto* mi = qstyleoption_cast<const QStyleOptionMenuItem*>(opt);
				if (mi && mi->menuItemType != QStyleOptionMenuItem::Separator)
					s.rheight() += kMenuItemPad;
				return s;
			}
		};

		void openContainingFolder(const FileRecord& rec) {
			const QString dir = rec.isDir ? rec.absPath
			: QFileInfo(rec.absPath).absolutePath();
			QDesktopServices::openUrl(QUrl::fromLocalFile(dir));
		}

		QAction* menuAction(QMenu& menu, const char* svg, const QString& text) {
			QAction* a = menu.addAction(
				QIcon(QLatin1String(":/icons/") + QLatin1String(svg)), text);
			a->setIconVisibleInMenu(true);
			return a;
		}

		void pruneNested(QList<FileRecord>& recs) {
			QSet<QString> dirs;
			for (const FileRecord& r : recs) if (r.isDir) dirs.insert(r.relPath);
				recs.removeIf([&](const FileRecord& r) {
					const QString& k = r.relPath;
					for (int p = k.indexOf('/'); p > 0; p = k.indexOf('/', p + 1))
						if (dirs.contains(k.left(p))) return true;
					return false;
				});
		}

		QHash<QString, QList<FileRecord>> filesUnder(const FileMap& map,
			const QList<FileRecord>& targets) {
			QSet<QString> keys;
			for (const FileRecord& t : targets) keys.insert(t.relPath);
				QHash<QString, QList<FileRecord>> out;
			for (auto it = map.constBegin(); it != map.constEnd(); ++it) {
				if (it->isDir) continue;
				const QString& k = it.key();
				if (keys.contains(k)) { out[k].append(*it); continue; }
				for (int p = k.indexOf('/'); p > 0; p = k.indexOf('/', p + 1))
					if (keys.contains(k.left(p))) { out[k.left(p)].append(*it); break; }
			}
			return out;
		}

	}

	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_menuStyle = new MenuPadStyle;
		m_menuStyle->setParent(this);

		QWidget* leftPanel = buildLeftPanel();
		QWidget* usbPanel  = buildUsbPanel();
		QWidget* midPanel  = buildMidPanel();

		int topH = qMax(m_usbCombo->sizeHint().height(),
			m_schemeCombo->sizeHint().height());
		topH = qMax(topH, m_progress->sizeHint().height());
		int btnH = 0;
		for (QPushButton* b : {m_saveSchemeBtn, m_resetBtn, m_scanBtn, m_analyzeBtn,
			m_mergeBtn, m_openBtn, m_ejectBtn, m_settingsBtn, m_quitBtn})
			btnH = qMax(btnH, b->sizeHint().height());
		const int headerH = topH + kHeaderGap + btnH;
		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);

		m_split = new QSplitter(Qt::Horizontal, this);
		m_split->addWidget(leftPanel);
		m_split->addWidget(m_panelSplit);
		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_midModel, &QAbstractItemModel::modelReset,
			this, &MainWindow::recountChecked);
		connect(m_midModel, &QAbstractItemModel::dataChanged, this,
			[this](const QModelIndex&, const QModelIndex&, const QList<int>& roles) {
				if (roles.contains(Qt::CheckStateRole)) recountChecked();
			});

		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* hv = new QVBoxLayout(m_schemeHeader);
		hv->setContentsMargins(0, 0, 0, 0);
		hv->setSpacing(kHeaderGap);
		m_schemeCombo = new QComboBox;

		m_schemeCombo->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon);
		m_schemeCombo->setMinimumContentsLength(8);
		connect(m_schemeCombo, QOverload<int>::of(&QComboBox::activated),
			this, &MainWindow::onSchemeActivated);

		auto* h = new QHBoxLayout;
		h->setContentsMargins(0, 0, 0, 0);
		h->addStretch(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);
		hv->addLayout(h);
		hv->addWidget(m_schemeCombo);
		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* hv = new QVBoxLayout(m_midHeader);
		hv->setContentsMargins(0, 0, 0, 0);
		hv->setSpacing(kHeaderGap);

		m_progress = new QProgressBar;
		m_progress->setTextVisible(true);
		m_progress->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
		setBarDormant();

		auto* h = new QHBoxLayout;
		h->setContentsMargins(0, 0, 0, 0);
		auto* versionLink = new QLabel(
			QStringLiteral("<a href=\"%1\" style=\"%2\">%3</a>")
			.arg(QLatin1String(kSourceUrl), QLatin1String(kLinkStyle),
				QLatin1String(kVersion)));
		versionLink->setTextFormat(Qt::RichText);
		versionLink->setOpenExternalLinks(true);
		versionLink->setTextInteractionFlags(Qt::LinksAccessibleByMouse);
		h->addWidget(versionLink);
		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_analyzeBtn = iconButton("analyze.svg", tr("Analyze"));
		m_analyzeBtn->setEnabled(false);
		connect(m_analyzeBtn, &QPushButton::clicked, this, &MainWindow::onAnalyze);
		h->addWidget(m_analyzeBtn);
		m_mergeBtn = iconButton("merge.svg", tr("Merge"));
		m_mergeBtn->setEnabled(false);
		connect(m_mergeBtn, &QPushButton::clicked, this, &MainWindow::onMerge);
		h->addWidget(m_mergeBtn);
		hv->addLayout(h);
		hv->addWidget(m_progress);
		v->addWidget(m_midHeader);

		m_midView = new PanelTableView;
		m_midView->setModel(m_midModel);

		m_midView->setSortingEnabled(true);
		m_midView->sortByColumn(DiffTableModel::Name, Qt::AscendingOrder);
		m_midView->setSelectionMode(QAbstractItemView::ExtendedSelection);
		m_midView->setSelectionBehavior(QAbstractItemView::SelectRows);
		m_midView->setFocusPolicy(Qt::StrongFocus);
		m_midView->setContextMenuPolicy(Qt::CustomContextMenu);
		connect(m_midView, &QWidget::customContextMenuRequested,
			this, &MainWindow::onMidContextMenu);
		applyViewPalette(m_midView, Palette::ListBg, Palette::ListText,
			Palette::ListLine);
		{
			QPalette p = m_midView->palette();
			p.setColor(QPalette::Highlight,       Palette::SelectBg);
			p.setColor(QPalette::HighlightedText, Palette::SelectText);
			m_midView->setPalette(p);
		}

		v->addWidget(m_midView, 1);
		return w;
	}

	QWidget* MainWindow::buildUsbPanel() {
		auto* w = new QWidget;
		auto* v = new QVBoxLayout(w);

		m_usbHeader = new QWidget;
		auto* hv = new QVBoxLayout(m_usbHeader);
		hv->setContentsMargins(0, 0, 0, 0);
		hv->setSpacing(kHeaderGap);
		m_usbCombo = new QComboBox;
		m_usbCombo->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon);
		m_usbCombo->setMinimumContentsLength(12);
		connect(m_usbCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
			this, &MainWindow::onUsbChanged);

		auto* h = new QHBoxLayout;
		h->setContentsMargins(0, 0, 0, 0);
		h->addStretch(1);

		m_openBtn = iconButton("open.svg", tr("Open"));
		m_openBtn->setEnabled(false);
		connect(m_openBtn, &QPushButton::clicked, this, &MainWindow::onOpenDrive);
		h->addWidget(m_openBtn);

		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);

		hv->addLayout(h);
		hv->addWidget(m_usbCombo);
		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->setSelectionMode(QAbstractItemView::ExtendedSelection);
		m_usbView->setSelectionBehavior(QAbstractItemView::SelectRows);
		m_usbView->setFocusPolicy(Qt::StrongFocus);
		m_usbView->setContextMenuPolicy(Qt::CustomContextMenu);
		connect(m_usbView, &QWidget::customContextMenuRequested,
			this, &MainWindow::onUsbContextMenu);
		applyViewPalette(m_usbView, Palette::ListBg, Palette::ListText,
			Palette::ListLine);
		{
			QPalette p = m_usbView->palette();
			p.setColor(QPalette::Highlight,       Palette::SelectBg);
			p.setColor(QPalette::HighlightedText, Palette::SelectText);
			m_usbView->setPalette(p);
		}
		v->addWidget(m_usbView, 1);
		return w;
	}

	QString MainWindow::currentUsbMount() const {
		return m_usbCombo->currentData().toString();
	}

	void MainWindow::onOpenDrive() {
		const QString mount = currentUsbMount();
		if (mount.isEmpty()) return;
		if (!QFileInfo(mount).isDir()
			|| !QDesktopServices::openUrl(QUrl::fromLocalFile(mount)))
			notify(this, QMessageBox::Warning, tr("Open"),
				tr("Could not open %1.").arg(mount));
	}

	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();
			setBarDormant();
			clearReport();
			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_expandPending.clear();
			m_expandScrollTo.clear();
			m_tree->collapseAll();
			m_tree->scrollToTop();
			m_midModel->setRows({}, {});
			m_usbModel->setRows({}, {});
			m_sourceMap.clear();
			m_usbMap.clear();
			m_diffPaths.clear();
			m_lastMerge.clear();
			clearLoadedScheme();
			m_schemeCombo->setCurrentIndex(0);
			setBarDormant();
			clearReport();
			updateActionStates();
		}

		void MainWindow::startScan(const QStringList& roots, bool recursive, const QString& usb) {
			if (m_controller && m_controller->isRunning()) return;
			setScanning(true);
			if (!m_postMergeMode) m_mergeErrors = 0;
			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) setBarScanning();
			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_analyzeBtn->setEnabled(m_report != Report::None && !m_scanning && !m_merging);
			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_openBtn->setEnabled(haveUsb && !m_ejecting);
			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_progress->setFormat(countsText());
		}

		void MainWindow::recountChecked() {
			if (m_scanning) return;
			m_countFolders = m_countNew = m_countDiff = 0;
			m_newBytes = m_diffBytes = 0;
			const QSet<QString> checked = m_midModel->checkedPaths();
			for (const QString& rel : checked) {
				const auto it = m_sourceMap.constFind(rel);
				if (it == m_sourceMap.constEnd()) continue;
				if (it->isDir) {
					if (!m_usbMap.contains(rel)) ++m_countFolders;
					continue;
				}
				if (m_diffPaths.contains(rel)) {
					++m_countDiff;
					m_diffBytes += it->size;
				} else if (!m_usbMap.contains(rel)) {
					++m_countNew;
					m_newBytes += it->size;
				}
			}
			updateCounts();
		}

		QString MainWindow::countsText() const {
			const auto countBytes = [](int n, qint64 bytes) {
				return n > 0 ? QStringLiteral("%1 (%2)").arg(n).arg(human(bytes))
				: QStringLiteral("0");
			};
			QString t = tr("New %1   Diffs %2   Folders %3   Total %4   No File %5")
			.arg(countBytes(m_countNew, m_newBytes),
				countBytes(m_countDiff, m_diffBytes),
				QString::number(m_countFolders),
				countBytes(m_countNew + m_countDiff,
					m_newBytes + m_diffBytes),
				QString::number(m_usbModel->missingCount()));
			if (m_mergeErrors > 0) t += tr("   Errors %1").arg(m_mergeErrors);
			return t;
		}

		void MainWindow::setBarDormant() {
			m_progress->setRange(0, 1000);
			m_progress->setValue(0);
			m_progress->setFormat(tr(" "));
		}

		void MainWindow::setBarScanning() {
			m_progress->setRange(0, 0);
		}

		void MainWindow::retireMergedRow(const QString& rel) {
			if (rel.isEmpty() || m_mergeSkipped.contains(rel)) return;
			const FileRecord r = m_sourceMap.value(rel);
			if (!r.isDir && m_midModel->checkedPaths().contains(rel)) {
				if (m_diffPaths.contains(rel)) {
					m_countDiff = qMax(0, m_countDiff - 1);
					m_diffBytes = qMax(qint64(0), m_diffBytes - r.size);
				} else if (!m_usbMap.contains(rel)) {
					m_countNew = qMax(0, m_countNew - 1);
					m_newBytes = qMax(qint64(0), m_newBytes - r.size);
				}
			}
			m_midModel->removeEntry(rel);
			updateCounts();
		}

		void MainWindow::onResults(DiffResult result, FileMap sourceMap, FileMap usbMap) {
			const bool wasPostMerge = m_postMergeMode;
			m_sourceMap     = sourceMap;
			m_usbMap        = usbMap;
			m_diffPaths     = result.diffPaths;
			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, result.destNewerPaths);
				m_usbModel->setRows(result.usbOnlyRows, sourceMap);
				m_lastMerge = result.mergeList;
				clearReport();
				m_report = Report::Scan;
			}

			setScanning(false);
			recountChecked();
			updateActionStates();
			m_progress->setRange(0, 1000);
			m_progress->setValue(wasPostMerge ? 1000 : 0);
		}

		void MainWindow::recompareFromCache() {
			if (m_sourceMap.isEmpty() && m_usbMap.isEmpty()) return;
			DiffResult r = Comparator::compare(m_sourceMap, m_usbMap, m_settings);
			m_diffPaths = r.diffPaths;
			m_midModel->setRows(r.sourceRows, m_usbMap, r.destNewerPaths);
			m_usbModel->setRows(r.usbOnlyRows, m_sourceMap);
			m_lastMerge = r.mergeList;
			updateCounts();
			updateActionStates();
		}

		void MainWindow::onUsbContextMenu(const QPoint& pos) {
			const QModelIndex ix = m_usbView->indexAt(pos);
			if (!ix.isValid()) return;
			const FileRecord* clicked = m_usbModel->recordAt(ix.row());
			if (!clicked) return;
			const FileRecord rec = *clicked;

			bool inSelection = false;
			for (const QModelIndex& r : m_usbView->selectionModel()->selectedRows())
				if (r.row() == ix.row()) { inSelection = true; break; }
			if (!inSelection) {
				m_usbView->clearSelection();
				m_usbView->selectRow(ix.row());
			}

			QList<FileRecord> items = selectedUsbRecords();
			if (items.isEmpty()) items.append(rec);

			QMenu menu(this);
			menu.setStyle(m_menuStyle);
			QAction* openAct = menuAction(menu, "open.svg",   tr("Open Folder"));
			QAction* selAct  = menuAction(menu, "select.svg", tr("Select All"));
			selAct->setEnabled(m_usbView->selectionModel()->selectedRows().size()
				< m_usbModel->rowCount());
			QAction* delAct  = menuAction(menu, "delete.svg",
				items.size() > 1
				? tr("Delete (%1)").arg(items.size())
				: tr("Delete"));
			QAction* chosen  = menu.exec(m_usbView->viewport()->mapToGlobal(pos));

			if (chosen == openAct) {
				openContainingFolder(rec);
				return;
			}
			if (chosen == selAct) { m_usbView->selectAll(); return; }
			if (chosen == delAct) deleteUsbRecords(items);
		}

		void MainWindow::onMidContextMenu(const QPoint& pos) {
			const int rows = m_midModel->rowCount();
			if (rows == 0) return;

			const QModelIndex ix = m_midView->indexAt(pos);
			const FileRecord* clicked = ix.isValid() ? m_midModel->recordAt(ix.row())
			: nullptr;
			const FileRecord rec = clicked ? *clicked : FileRecord{};

			if (clicked && !m_midView->selectionModel()->isRowSelected(ix.row())) {
				m_midView->clearSelection();
				m_midView->selectRow(ix.row());
			}

			QSet<QString> selected;
			for (const QModelIndex& r : m_midView->selectionModel()->selectedRows())
				if (const FileRecord* f = m_midModel->recordAt(r.row()))
					selected.insert(f->relPath);

				const QSet<QString> covered = m_midModel->withDescendants(selected);
				const QSet<QString> checkedSet = m_midModel->checkedPaths();
				const int checked = int(checkedSet.size());
				int selChecked = 0;
				for (const QString& p : covered)
					if (checkedSet.contains(p)) ++selChecked;

				QMenu menu(this);
				menu.setStyle(m_menuStyle);
				QAction* openAct       = menuAction(menu, "open.svg",    tr("Open Folder"));
				QAction* uncheckSelAct = menuAction(menu, "empty.svg",   tr("Unselect Selected"));
				QAction* checkSelAct   = menuAction(menu, "checked.svg", tr("Select Selected"));
				QAction* uncheckAct    = menuAction(menu, "empty.svg",   tr("Unselect All"));
				QAction* checkAct      = menuAction(menu, "select.svg",  tr("Select All"));
				openAct->setEnabled(clicked != nullptr);
				uncheckSelAct->setEnabled(selChecked > 0);
				checkSelAct->setEnabled(selChecked < covered.size());
				uncheckAct->setEnabled(checked > 0);
				checkAct->setEnabled(checked < rows);

				QAction* chosen = menu.exec(m_midView->viewport()->mapToGlobal(pos));
				if (chosen == openAct)            openContainingFolder(rec);
				else if (chosen == uncheckSelAct) m_midModel->setChecked(selected, false);
				else if (chosen == checkSelAct)   m_midModel->setChecked(selected, true);
				else if (chosen == uncheckAct)    m_midModel->setAllChecked(false);
				else if (chosen == checkAct)      m_midModel->setAllChecked(true);
			}

			QList<FileRecord> MainWindow::selectedUsbRecords() const {
				QList<FileRecord> out;
				for (const QModelIndex& ix : m_usbView->selectionModel()->selectedRows())
					if (const FileRecord* r = m_usbModel->recordAt(ix.row())) out.append(*r);

				pruneNested(out);
				return out;
			}

			QList<FileRecord> MainWindow::usbOnlyRecords() const {
				QList<FileRecord> out;
				for (int i = 0; i < m_usbModel->rowCount(); ++i)
					if (const FileRecord* r = m_usbModel->recordAt(i)) out.append(*r);
				pruneNested(out);
				return out;
			}

			void MainWindow::deleteUsbRecords(const QList<FileRecord>& items) {
				if (items.isEmpty()) return;
				const QString name = items.first().relPath.section('/', -1);
				const QString prompt =
				items.size() > 1
				? tr("Delete (%1) Files?").arg(items.size())
				: (items.first().isDir ? tr("Delete \"%1\" and contents?").arg(name)
					: tr("Delete \"%1\"?").arg(name));
				if (askYesNo(this, tr("Delete"), prompt) != QMessageBox::Yes) return;

				QStringList failed;
				for (const FileRecord& r : items) {
					const bool ok = r.isDir ? QDir(r.absPath).removeRecursively()
					: QFile::remove(r.absPath);
					if (!ok) { failed << r.relPath.section('/', -1); continue; }

					m_usbModel->removeRecord(r.relPath);
					m_usbMap.remove(r.relPath);
					const QString prefix = r.relPath + '/';
					for (auto it = m_usbMap.begin(); it != m_usbMap.end(); )
						it = it.key().startsWith(prefix) ? m_usbMap.erase(it) : ++it;
				}
				updateCounts();
				if (failed.isEmpty()) return;

				notify(this, QMessageBox::Warning, tr("Delete"),
					failed.size() == 1
					? tr("Could not delete \"%1\".").arg(failed.first())
					: tr("Could not delete %1 items.").arg(failed.size()));
			}

			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();
				clearReport();
				updateActionStates();
				m_countFolders = m_countFiles = m_countNew = m_countDiff = 0;
				m_countBytes = m_newBytes = m_diffBytes = 0;
				updateCounts();
				setBarDormant();
				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;
				}

				QList<FileRecord> deletes = usbOnlyRecords();
				m_deleteFiles.clear();
				qint64 freed = 0;
				if (!deletes.isEmpty()) {
					const DeleteChoice choice = askDeleteMerge(this);
					if (choice == DeleteChoice::Abort) return;
					if (choice == DeleteChoice::Delete) {
						m_deleteFiles = filesUnder(m_usbMap, deletes);
						for (const QList<FileRecord>& files : std::as_const(m_deleteFiles))
							for (const FileRecord& f : files) freed += f.size;
						} else {
							deletes.clear();
						}
					}

						const qint64 avail = QStorageInfo(m_lastUsbMount).bytesAvailable();
						if (avail >= 0 && bytes > avail + freed) {
							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), std::move(deletes));
					}

					void MainWindow::startMerge(QList<FileRecord> items, QList<FileRecord> deletes) {
						if (m_merger && m_merger->isRunning()) return;
						m_merging      = true;
						m_deviceLost   = false;
						m_mergeErrors  = 0;
						m_mergeSkipped.clear();
						m_mergedFiles.clear();
						m_deletedFiles.clear();
						const bool deleting = !deletes.isEmpty();

						m_merger = new MergeController(std::move(items), std::move(deletes),
							m_lastUsbMount, this);
						m_mergeTotalBytes = m_merger->totalBytes();
						connect(m_merger, &MergeController::progress,   this, &MainWindow::onMergeProgress);
						connect(m_merger, &MergeController::copied,     this, &MainWindow::onMergeCopied);
						connect(m_merger, &MergeController::removed,    this, &MainWindow::onMergeRemoved);
						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_progress->setRange(0, deleting ? 0 : 1000);
						m_progress->setValue(0);
						updateCounts();
						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) {
						if (m_progress->maximum() == 0) m_progress->setRange(0, 1000);
						if (rel != m_mergeCurrent) {
							retireMergedRow(m_mergeCurrent);
							m_mergeCurrent = rel;
						}
						const qint64 permille = m_mergeTotalBytes > 0
						? bytesDone * 1000 / m_mergeTotalBytes : 1000;
						m_progress->setValue(int(std::clamp(permille, qint64(0), qint64(1000))));
					}

					void MainWindow::onMergeSkipped(QString rel, int) {
						if (m_progress->maximum() == 0) m_progress->setRange(0, 1000);
						m_mergeSkipped.insert(rel);
						if (rel == m_mergeCurrent) m_mergeCurrent.clear();
						m_midModel->setSkipped(m_mergeSkipped);
					}

					void MainWindow::onMergeDeviceLost() { m_deviceLost = true; }

					void MainWindow::onMergeDone(bool canceled) {
						m_merging     = false;
						if (!canceled) retireMergedRow(m_mergeCurrent);
						m_mergeCurrent.clear();
						m_deleteFiles.clear();
						m_report = Report::Merge;
						m_mergeErrors = int(m_mergeSkipped.size());
						m_progress->setRange(0, 1000);
						m_progress->setValue(1000);
						updateCounts();
						if (m_deviceLost)
							QMessageBox::warning(this, tr("Drive Unavailable"),
								tr("Drive Removed or Read-Only. 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);
						setBarDormant();
					}

					void MainWindow::onMergeCopied(QString rel) {
						m_mergedFiles << rel;
					}

					void MainWindow::onMergeRemoved(QString rel, bool ok) {
						for (const FileRecord& f : m_deleteFiles.value(rel))
							if (ok || !QFileInfo::exists(f.absPath)) m_deletedFiles << f.relPath;
					}

					void MainWindow::clearReport() {
						m_report = Report::None;
						m_mergedFiles.clear();
						m_deletedFiles.clear();
					}

					void MainWindow::onAnalyze() {
						QStringList files;
						QStringList deleted;
						const bool merged = (m_report == Report::Merge);
						if (merged) {
							files   = m_mergedFiles;
							deleted = m_deletedFiles;
						} else if (m_report == Report::Scan) {
							const QSet<QString> checked = m_midModel->checkedPaths();
							for (const FileRecord& r : m_lastMerge)
								if (!r.isDir && checked.contains(r.relPath)) files << r.relPath;
						} else {
							return;
						}
						AnalyzeDialog dlg(files, deleted, merged, this);
						dlg.exec();
					}					

				}