diff options
| author | Mason Reed <mason@vector35.com> | 2025-08-26 23:59:38 -0400 |
|---|---|---|
| committer | Mason Reed <mason@vector35.com> | 2025-10-01 21:38:39 -0400 |
| commit | ede39aee7e00c40a43b67ca18dd8ab80ee863d85 (patch) | |
| tree | 67c5eda347ece2282e3c888f38066b496e06dec8 /plugins/warp/ui | |
| parent | a1c46813e7f279aa4cfdb9dbb91c45b559ebeacd (diff) | |
[WARP] Enhanced network support
Diffstat (limited to 'plugins/warp/ui')
| -rw-r--r-- | plugins/warp/ui/CMakeLists.txt | 5 | ||||
| -rw-r--r-- | plugins/warp/ui/containers.cpp | 274 | ||||
| -rw-r--r-- | plugins/warp/ui/containers.h | 175 | ||||
| -rw-r--r-- | plugins/warp/ui/matches.cpp | 89 | ||||
| -rw-r--r-- | plugins/warp/ui/matches.h | 12 | ||||
| -rw-r--r-- | plugins/warp/ui/plugin.cpp | 90 | ||||
| -rw-r--r-- | plugins/warp/ui/plugin.h | 2 | ||||
| -rw-r--r-- | plugins/warp/ui/shared/fetchdialog.cpp | 227 | ||||
| -rw-r--r-- | plugins/warp/ui/shared/fetchdialog.h | 56 | ||||
| -rw-r--r-- | plugins/warp/ui/shared/fetcher.cpp | 109 | ||||
| -rw-r--r-- | plugins/warp/ui/shared/fetcher.h | 85 | ||||
| -rw-r--r-- | plugins/warp/ui/shared/function.cpp | 26 | ||||
| -rw-r--r-- | plugins/warp/ui/shared/misc.cpp | 63 | ||||
| -rw-r--r-- | plugins/warp/ui/shared/misc.h | 32 | ||||
| -rw-r--r-- | plugins/warp/ui/shared/search.cpp | 301 | ||||
| -rw-r--r-- | plugins/warp/ui/shared/search.h | 234 |
16 files changed, 1666 insertions, 114 deletions
diff --git a/plugins/warp/ui/CMakeLists.txt b/plugins/warp/ui/CMakeLists.txt index c3e5bcf6..88cc6a60 100644 --- a/plugins/warp/ui/CMakeLists.txt +++ b/plugins/warp/ui/CMakeLists.txt @@ -9,7 +9,10 @@ file(GLOB SOURCES CONFIGURE_DEPENDS shared/misc.cpp shared/misc.h shared/constraint.cpp shared/constraint.h shared/function.cpp shared/function.h - shared/container.cpp shared/container.h) + containers.cpp containers.h + shared/search.cpp shared/search.h + shared/fetcher.cpp shared/fetcher.h + shared/fetchdialog.cpp shared/fetchdialog.h) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) diff --git a/plugins/warp/ui/containers.cpp b/plugins/warp/ui/containers.cpp new file mode 100644 index 00000000..689d695f --- /dev/null +++ b/plugins/warp/ui/containers.cpp @@ -0,0 +1,274 @@ +#include "containers.h" + +QVariant WarpSourcesModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid()) + return {}; + if (index.row() < 0 || index.row() >= rowCount()) + return {}; + + const auto &r = m_rows[static_cast<size_t>(index.row())]; + + // Build a small two-dot status icon (left: writable, right: uncommitted) + auto statusIcon = [](bool writable, bool uncommitted) -> QIcon { + static QIcon cache[2][2]; // [writable][uncommitted] + QIcon &cached = cache[writable ? 1 : 0][uncommitted ? 1 : 0]; + if (!cached.isNull()) + return cached; + + const int w = 16, h = 12, radius = 4; + QPixmap pm(w, h); + pm.fill(Qt::transparent); + QPainter p(&pm); + p.setRenderHint(QPainter::Antialiasing, true); + + // Colors + QColor writableOn(76, 175, 80); // green + QColor writableOff(158, 158, 158); // grey + QColor uncommittedOn(255, 193, 7); // amber + QColor uncommittedOff(158, 158, 158); // grey + + // Left dot: writable + p.setBrush(writable ? writableOn : writableOff); + p.setPen(Qt::NoPen); + p.drawEllipse(QPoint(4, h / 2), radius, radius); + + // Right dot: uncommitted + p.setBrush(uncommitted ? uncommittedOn : uncommittedOff); + p.drawEllipse(QPoint(w - 6, h / 2), radius, radius); + + p.end(); + cached = QIcon(pm); + return cached; + }; + + if (role == Qt::DecorationRole && index.column() == PathCol) + { + return statusIcon(r.writable, r.uncommitted); + } + + if (role == Qt::ToolTipRole && index.column() == PathCol) + { + QStringList parts; + parts << (r.writable ? "Writable" : "Read-only"); + parts << (r.uncommitted ? "Uncommitted changes" : "No uncommitted changes"); + return parts.join(" • "); + } + + if (role == Qt::DisplayRole) + { + switch (index.column()) + { + case GuidCol: return r.guid; + case PathCol: return r.path; + case WritableCol: return r.writable ? "Yes" : "No"; + case UncommittedCol: return r.uncommitted ? "Yes" : "No"; + default: return {}; + } + } + + if (role == Qt::CheckStateRole) + { + // Optional: expose as checkboxes if someone ever shows these columns + switch (index.column()) + { + case WritableCol: return r.writable ? Qt::Checked : Qt::Unchecked; + case UncommittedCol: return r.uncommitted ? Qt::Checked : Qt::Unchecked; + default: break; + } + } + + return {}; +} + +WarpContainerWidget::WarpContainerWidget(Warp::Ref<Warp::Container> container, QWidget *parent) : QWidget(parent) +{ + m_container = std::move(container); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + m_tabs = new QTabWidget(this); + layout->addWidget(m_tabs); + + // Sources tab + m_sourcesPage = new QWidget(this); + auto *sourcesLayout = new QVBoxLayout(m_sourcesPage); + m_sourcesView = new QTableView(m_sourcesPage); + m_sourcesModel = new WarpSourcesModel(m_sourcesPage); + m_sourcesModel->setContainer(m_container); + m_sourcesView->setModel(m_sourcesModel); + m_sourcesView->horizontalHeader()->setStretchLastSection(true); + m_sourcesView->setSelectionBehavior(QAbstractItemView::SelectRows); + m_sourcesView->setSelectionMode(QAbstractItemView::SingleSelection); + + // Make the table look like a simple list that shows only the source path + m_sourcesView->setShowGrid(false); + m_sourcesView->verticalHeader()->setVisible(false); + m_sourcesView->horizontalHeader()->setVisible(false); + m_sourcesView->setAlternatingRowColors(false); + m_sourcesView->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_sourcesView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_sourcesView->setWordWrap(false); + m_sourcesView->setIconSize(QSize(16, 12)); + // Ensure long paths truncate from the left: "...tail/of/the/path" + m_sourcesView->setTextElideMode(Qt::ElideLeft); + // Hide GUID column, keep only the Path column visible + m_sourcesView->setColumnHidden(WarpSourcesModel::GuidCol, true); + // Also hide boolean columns; their state is shown as an icon next to the path + m_sourcesView->setColumnHidden(WarpSourcesModel::WritableCol, true); + m_sourcesView->setColumnHidden(WarpSourcesModel::UncommittedCol, true); + // Ensure the remaining (Path) column fills the width + m_sourcesView->horizontalHeader()->setSectionResizeMode(WarpSourcesModel::PathCol, QHeaderView::Stretch); + + // Per-item context menu + m_sourcesView->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_sourcesView, &QWidget::customContextMenuRequested, this, [this](const QPoint &pos) { + QMenu menu(m_sourcesView); + const QModelIndex index = m_sourcesView->indexAt(pos); + + if (!index.isValid()) + { + QAction *actAdd = menu.addAction(tr("Add Source")); + QAction *chosen = menu.exec(m_sourcesView->viewport()->mapToGlobal(pos)); + if (!chosen) + return; + if (chosen == actAdd) + { + std::string sourceName; + if (!BinaryNinja::GetTextLineInput(sourceName, "Source name:", "Add Source")) + return; + if (const auto sourceId = m_container->AddSource(sourceName); !sourceId.has_value()) + { + BinaryNinja::LogAlertF("Failed to add source: {}", sourceName); + return; + } + m_sourcesModel->reload(); + } + } else + { + m_sourcesView->setCurrentIndex(index.sibling(index.row(), WarpSourcesModel::PathCol)); + + const int row = index.row(); + const QModelIndex pathIdx = m_sourcesModel->index(row, WarpSourcesModel::PathCol); + const QModelIndex guidIdx = m_sourcesModel->index(row, WarpSourcesModel::GuidCol); + const QString path = m_sourcesModel->data(pathIdx, Qt::DisplayRole).toString(); + const QFileInfo fi(path); + + const QString guid = m_sourcesModel->data(guidIdx, Qt::DisplayRole).toString(); + + QAction *actReveal = menu.addAction(tr("Reveal in File Browser")); + actReveal->setEnabled(fi.exists()); + QAction *actCopyPath = menu.addAction(tr("Copy Path")); + QAction *actCopyGuid = menu.addAction(tr("Copy GUID")); + + QAction *chosen = menu.exec(m_sourcesView->viewport()->mapToGlobal(pos)); + if (!chosen) + return; + if (chosen == actCopyPath) + QGuiApplication::clipboard()->setText(path); + else if (chosen == actCopyGuid) + QGuiApplication::clipboard()->setText(guid); + else if (chosen == actReveal) + QDesktopServices::openUrl(QUrl::fromLocalFile(fi.absoluteFilePath())); + } + }); + + + sourcesLayout->addWidget(m_sourcesView); + m_tabs->addTab(m_sourcesPage, tr("Sources")); + + // Search tab + m_searchTab = new WarpSearchWidget(m_container, this); + m_tabs->addTab(m_searchTab, tr("Search")); + + // Periodic refresh timer for the Sources view + m_refreshTimer = new QTimer(this); + m_refreshTimer->setInterval(5000); + connect(m_refreshTimer, &QTimer::timeout, this, [this]() { + // Only refresh if the widget and the Sources page are actually visible + if (!this->isVisible() || !m_sourcesPage || !m_sourcesPage->isVisible()) + return; + + // Preserve selection by GUID across reloads + QString currentGuid; + if (const QModelIndex currentIdx = m_sourcesView->currentIndex(); currentIdx.isValid()) + { + const int row = currentIdx.row(); + const QModelIndex guidIdx = m_sourcesModel->index(row, WarpSourcesModel::GuidCol); + currentGuid = m_sourcesModel->data(guidIdx, Qt::DisplayRole).toString(); + } + + m_sourcesModel->reload(); + + if (!currentGuid.isEmpty()) + { + for (int r = 0; r < m_sourcesModel->rowCount(); ++r) + { + const QModelIndex gIdx = m_sourcesModel->index(r, WarpSourcesModel::GuidCol); + if (m_sourcesModel->data(gIdx, Qt::DisplayRole).toString() == currentGuid) + { + m_sourcesView->setCurrentIndex(m_sourcesModel->index(r, WarpSourcesModel::PathCol)); + break; + } + } + } + }); + m_refreshTimer->start(); + + // Optional: force a refresh when switching back to the Sources tab + connect(m_tabs, &QTabWidget::currentChanged, this, [this](const int idx) { + QWidget *w = m_tabs->widget(idx); + if (w == m_sourcesPage) + m_sourcesModel->reload(); + }); +} + +WarpContainersPane::WarpContainersPane(QWidget *parent) : QWidget(parent) +{ + auto *splitter = new QSplitter(Qt::Vertical, this); + splitter->setContentsMargins(0, 0, 0, 0); + auto *mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(0, 0, 0, 0); + mainLayout->setSpacing(0); + mainLayout->addWidget(splitter); + auto newPalette = palette(); + newPalette.setColor(QPalette::Window, getThemeColor(SidebarWidgetBackgroundColor)); + setAutoFillBackground(true); + setPalette(newPalette); + + // List on top + m_list = new QListWidget(splitter); + m_list->setSelectionMode(QAbstractItemView::SingleSelection); + m_list->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_list->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents); + m_list->setUniformItemSizes(true); + + // Make names larger and show end of long strings (elide at the start) + { + QFont f = m_list->font(); + f.setPointSizeF(f.pointSizeF() + 2.0); // bump size + m_list->setFont(f); + m_list->setTextElideMode(Qt::ElideLeft); + } + + // Container view (tabs) below + m_stack = new QStackedWidget(splitter); + m_stack->setContentsMargins(0, 0, 0, 0); + + splitter->setStretchFactor(0, 0); // list: minimal growth + splitter->setStretchFactor(1, 1); // stack: takes remaining space + splitter->setCollapsible(0, false); + splitter->setCollapsible(1, false); + + populate(); + + connect(m_list, &QListWidget::currentRowChanged, this, [this](int row) { + if (row >= 0 && row < m_stack->count()) + m_stack->setCurrentIndex(row); + }); + + // Select the first container if available + if (m_list->count() > 0) + { + m_list->setCurrentRow(0); + } +} diff --git a/plugins/warp/ui/containers.h b/plugins/warp/ui/containers.h new file mode 100644 index 00000000..80ffa12f --- /dev/null +++ b/plugins/warp/ui/containers.h @@ -0,0 +1,175 @@ +#pragma once + +#include <QWidget> +#include <optional> +#include <QClipboard> +#include <QDesktopServices> +#include <QInputDialog> +#include <QListWidget> +#include "shared/search.h" + +#include "theme.h" +#include "warp.h" +#include "../../../../ui/mainwindow.h" + +class WarpSourcesModel final : public QAbstractTableModel +{ + Q_OBJECT + +public: + enum Columns : int + { + GuidCol = 0, + PathCol, + WritableCol, + UncommittedCol, + ColumnCount + }; + + explicit WarpSourcesModel(QObject *parent = nullptr) + : QAbstractTableModel(parent) + { + } + + void setContainer(Warp::Ref<Warp::Container> container) + { + m_container = std::move(container); + reload(); + } + + void reload() + { + // Fetch synchronously (can be adapted to async if needed) + beginResetModel(); + m_rows.clear(); + for (const auto &src: m_container->GetSources()) + { + QString guid = QString::fromStdString(src.ToString()); + QString path = QString::fromStdString(m_container->SourcePath(src).value_or(std::string{})); + bool writable = m_container->IsSourceWritable(src); + bool uncommitted = m_container->IsSourceUncommitted(src); + m_rows.push_back({guid, path, writable, uncommitted}); + } + endResetModel(); + } + + int rowCount(const QModelIndex &parent = QModelIndex()) const override + { + if (parent.isValid()) return 0; + return static_cast<int>(m_rows.size()); + } + + int columnCount(const QModelIndex &parent = QModelIndex()) const override + { + Q_UNUSED(parent); + return ColumnCount; + } + + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + QVariant headerData(int section, Qt::Orientation orientation, int role) const override + { + if (orientation == Qt::Horizontal && role == Qt::DisplayRole) + { + switch (section) + { + case GuidCol: return "Source GUID"; + case PathCol: return "Path"; + case WritableCol: return "Writable"; + case UncommittedCol: return "Uncommitted"; + default: return {}; + } + } + return {}; + } + +private: + struct Row + { + QString guid; + QString path; + bool writable; + bool uncommitted; + }; + + std::vector<Row> m_rows; + Warp::Ref<Warp::Container> m_container; +}; + +class WarpContainerWidget : public QWidget +{ + Q_OBJECT + +public: + explicit WarpContainerWidget(Warp::Ref<Warp::Container> container, QWidget *parent = nullptr); + +private: + Warp::Ref<Warp::Container> m_container; + + QTabWidget *m_tabs = nullptr; + + // Sources + QTableView *m_sourcesView = nullptr; + WarpSourcesModel *m_sourcesModel = nullptr; + QWidget* m_sourcesPage = nullptr; + QTimer* m_refreshTimer = nullptr; + + // Search + WarpSearchWidget *m_searchTab = nullptr; +}; + +class WarpContainersPane : public QWidget +{ + Q_OBJECT + +public: + explicit WarpContainersPane(QWidget *parent = nullptr); + + void refresh() + { + // Clear and repopulate from current container list + m_list->clear(); + while (m_stack->count() > 0) + { + QWidget *w = m_stack->widget(0); + m_stack->removeWidget(w); + delete w; + } + m_containers.clear(); + populate(); + if (m_list->count() > 0) + m_list->setCurrentRow(0); + } + +private: + void populate() + { + // Retrieve all available containers + const auto all = Warp::Container::All(); + m_containers = all; // copy vector<Ref<Container>> + + for (const auto &c: m_containers) + { + const QString name = QString::fromStdString(c->GetName()); + auto *item = new QListWidgetItem(name, m_list); + item->setSizeHint(QSize(item->sizeHint().width(), itemHeightPx())); + auto *widget = new WarpContainerWidget(c, m_stack); + m_stack->addWidget(widget); + } + + // Visual style: behave like a vertical tab bar + // m_list->setFrameShape(QFrame::NoFrame); + // m_list->setSpacing(0); + } + + static int itemHeightPx() + { + // A reasonable, readable height per entry + return 28; + } + +private: + QListWidget *m_list = nullptr; + QStackedWidget *m_stack = nullptr; + std::vector<Warp::Ref<Warp::Container> > m_containers; +}; diff --git a/plugins/warp/ui/matches.cpp b/plugins/warp/ui/matches.cpp index 7e22e224..2345a7ea 100644 --- a/plugins/warp/ui/matches.cpp +++ b/plugins/warp/ui/matches.cpp @@ -11,12 +11,12 @@ #include "warp.h" #include "shared/misc.h" -WarpCurrentFunctionWidget::WarpCurrentFunctionWidget(FunctionRef current) +WarpCurrentFunctionWidget::WarpCurrentFunctionWidget() { - // NOTE: Might be nullptr if the no selected function. - m_current = current; + // We must explicitly support no current function. + m_current = nullptr; - m_logger = new BinaryNinja::Logger("WARP"); + m_logger = new BinaryNinja::Logger("WARP UI"); // Create the QT stuff QGridLayout *layout = new QGridLayout(this); @@ -107,6 +107,18 @@ WarpCurrentFunctionWidget::WarpCurrentFunctionWidget(FunctionRef current) }); } +void WarpCurrentFunctionWidget::SetFetcher(std::shared_ptr<WarpFetcher> fetcher) +{ + m_fetcher = fetcher; + // TODO: We need to remove the completion callback from the previously set fetcher. + m_fetcher->AddCompletionCallback([this]() { + // TODO: This is a little bit underspecified, we may end up updating more than strictly necessary. + // Once this function has been fetched, we need to update the matches for this widget. + UpdateMatches(); + return KeepCallback; + }); +} + void WarpCurrentFunctionWidget::SetCurrentFunction(FunctionRef current) { if (m_current == current) @@ -114,21 +126,18 @@ void WarpCurrentFunctionWidget::SetCurrentFunction(FunctionRef current) m_current = current; m_infoWidget->SetAnalysisFunction(m_current); - if (current) + // If we have a fetcher we should also let it know to try and fetch the possible functions from the containers. + if (current && m_fetcher) { - // Add the function to the processing list only if we have no already done so. - // If a user goes to a function, then navigates away, they do not want to - // have us try and send a network request! + m_fetcher->AddPendingFunction(current); + + // TODO: Automatically fetch the function, I need to figure out how to make this debounce correctly so that all requests are processed in line. + if (!m_fetcher->m_requestInProgress.exchange(true)) { - std::lock_guard<std::mutex> lock(m_requestMutex); - uint64_t funcStart = current->GetStart(); - if (m_processedFunctions.find(funcStart) == m_processedFunctions.end()) { - m_pendingRequests.push_back(current); - } - } - if (!m_requestInProgress.exchange(true)) { BinaryNinja::WorkerPriorityEnqueue([this]() { - ProcessPendingFetchRequests(); + BinaryNinja::Ref bgTask = new BinaryNinja::BackgroundTask("Fetching WARP Functions...", true); + m_fetcher->FetchPendingFunctions(); + bgTask->Finish(); }); } } @@ -185,51 +194,3 @@ void WarpCurrentFunctionWidget::UpdateMatches() m_tableWidget->SetFunctions(matches); } - -void WarpCurrentFunctionWidget::ProcessPendingFetchRequests() -{ - std::vector<FunctionRef> requests; - { - std::lock_guard<std::mutex> lock(m_requestMutex); - requests = std::move(m_pendingRequests); - m_pendingRequests.clear(); - } - - if (requests.empty()) { - m_requestInProgress = false; - return; - } - - auto start_time = std::chrono::high_resolution_clock::now(); - - std::vector<Warp::FunctionGUID> guids; - Warp::Ref<Warp::Target> target; - for (const auto& func : requests) { - // TODO: Need to send multiple requests if there is multiple targets. - if (!target) - target = Warp::Target::FromPlatform(*func->GetPlatform()); - if (const auto guid = Warp::GetAnalysisFunctionGUID(*func); guid.has_value()) - guids.push_back(guid.value()); - } - - // Actually fetch the data! - if (!guids.empty()) - for (const auto &container: Warp::Container::All()) - container->FetchFunctions(*target, guids); - - { - std::lock_guard<std::mutex> lock(m_requestMutex); - for (const auto& func : requests) { - m_processedFunctions.insert(func->GetStart()); - } - } - - // TODO: Update the matches, make sure there was stuff added first lol. - // TODO: UpdateMatches(); - - const auto end_time = std::chrono::high_resolution_clock::now(); - const std::chrono::duration<double> elapsed_time = end_time - start_time; - m_logger->LogDebug("ProcessPendingRequests took %f seconds", elapsed_time.count()); - - m_requestInProgress = false; -} diff --git a/plugins/warp/ui/matches.h b/plugins/warp/ui/matches.h index 251dcb89..88926add 100644 --- a/plugins/warp/ui/matches.h +++ b/plugins/warp/ui/matches.h @@ -4,6 +4,7 @@ #include "filter.h" #include "render.h" +#include "shared/fetcher.h" #include "shared/function.h" class WarpCurrentFunctionWidget : public QWidget @@ -18,21 +19,18 @@ class WarpCurrentFunctionWidget : public QWidget LoggerRef m_logger; - std::mutex m_requestMutex; - std::vector<FunctionRef> m_pendingRequests; - std::atomic<bool> m_requestInProgress {false}; - std::unordered_set<uint64_t> m_processedFunctions; + std::shared_ptr<WarpFetcher> m_fetcher; public: - explicit WarpCurrentFunctionWidget(FunctionRef current); + explicit WarpCurrentFunctionWidget(); ~WarpCurrentFunctionWidget() override = default; + void SetFetcher(std::shared_ptr<WarpFetcher> fetcher); + void SetCurrentFunction(FunctionRef current); FunctionRef GetCurrentFunction() { return m_current; }; void UpdateMatches(); - - void ProcessPendingFetchRequests(); }; diff --git a/plugins/warp/ui/plugin.cpp b/plugins/warp/ui/plugin.cpp index dccea1f1..388fea3c 100644 --- a/plugins/warp/ui/plugin.cpp +++ b/plugins/warp/ui/plugin.cpp @@ -6,6 +6,7 @@ #include "matches.h" #include "symbollist.h" #include "viewframe.h" +#include "shared/fetchdialog.h" using namespace BinaryNinja; @@ -32,11 +33,35 @@ Ref<BackgroundTask> GetMatcherTask() return matcherTask; } +void ShowNetworkNotice() +{ + // By default, network access is disabled for WARP, this function will show the user a notice to enable it and restart. + const auto settings = Settings::Instance(); + const bool networkNoticeShown = QSettings().value("warp/NetworkNoticeShown", false).toBool(); + QSettings().setValue("warp/NetworkNoticeShown", true); + if (!networkNoticeShown && settings->Contains("network.enableWARP") && !settings->Get<bool>("network.enableWARP")) + { + const bool enable = ShowMessageBox("Enable WARP Network Access?", + "Network access is disabled by default. Enable WARP network features now?\n\n" + "You can change this later in Settings.", + YesNoButtonSet, InformationIcon) == YesButton; + settings->Set("network.enableWARP", enable); + // TODO: Add a notifyRestartRequired call here + if (enable) + ShowMessageBox("WARP Network Enabled", "Please restart Binary Ninja to allow WARP to make requests to the server.", OKButtonSet, InformationIcon); + else + ShowMessageBox("WARP Network Disabled", "WARP network access will remain disabled. You can enable it later from Settings.", OKButtonSet, InformationIcon); + } +} + WarpSidebarWidget::WarpSidebarWidget(BinaryViewRef data) : SidebarWidget("WARP"), m_data(data) { - m_logger = LogRegistry::CreateLogger("WARPUI"); + m_logger = LogRegistry::CreateLogger("WARP UI"); m_currentFrame = nullptr; + // If not already shown, opening the sidebar will give notice. + ShowNetworkNotice(); + m_headerWidget = new QWidget(); QHBoxLayout *headerLayout = new QHBoxLayout(); headerLayout->setContentsMargins(0, 0, 0, 0); @@ -46,20 +71,22 @@ WarpSidebarWidget::WarpSidebarWidget(BinaryViewRef data) : SidebarWidget("WARP") headerToolbar->setContentsMargins(0, 0, 0, 0); headerToolbar->setIconSize(QSize(20, 20)); - static auto matcherStopIcon = GetColoredIcon(":/icons/images/stop.png", getThemeColor(RedStandardHighlightColor)); - static auto matcherStartIcon = GetColoredIcon(":/icons/images/start.png", - getThemeColor(GreenStandardHighlightColor)); - m_matcherAction = headerToolbar->addAction(matcherStartIcon, "Run Matcher", [this]() { + auto fetchIcon = GetColoredIcon(":/icons/images/arrow-pull.png", getThemeColor(BlueStandardHighlightColor)); + auto fetchAction = headerToolbar->addAction(fetchIcon, "Fetch data from WARP containers", [this]() { UIActionHandler *handler = m_currentFrame->getCurrentViewInterface()->actionHandler(); - if (Ref<BackgroundTask> matcherTask = GetMatcherTask()) - matcherTask->Cancel(); - else if (!isMatcherRunning) - { - handler->executeAction("WARP\\Run Matcher"); - setMatcherActionIcon(true); - } + handler->executeAction("WARP\\Fetch"); }); - m_matcherAction->setToolTip("Run the matcher on all functions"); + fetchAction->setToolTip("Fetch data from WARP containers"); + + auto commitIcon = GetColoredIcon(":/icons/images/arrow-push.png", getThemeColor(BlueStandardHighlightColor)); + auto commitAction = headerToolbar->addAction(commitIcon, "Commit a WARP file to a source", [this]() { + UIActionHandler *handler = m_currentFrame->getCurrentViewInterface()->actionHandler(); + handler->executeAction("WARP\\Commit File"); + }); + commitAction->setToolTip("Commit a WARP file to a source"); + + // We want to make it clear that the container actions for fetching and pushing are seperate. + headerToolbar->addSeparator(); auto loadIcon = GetColoredIcon(":/icons/images/file-add.png", getThemeColor(BlueStandardHighlightColor)); auto loadAction = headerToolbar->addAction(loadIcon, "Load Signature File", [this]() { @@ -75,21 +102,36 @@ WarpSidebarWidget::WarpSidebarWidget(BinaryViewRef data) : SidebarWidget("WARP") }); saveAction->setToolTip("Save data to a signature file"); + headerToolbar->addSeparator(); + + static auto matcherStopIcon = GetColoredIcon(":/icons/images/stop.png", getThemeColor(RedStandardHighlightColor)); + static auto matcherStartIcon = GetColoredIcon(":/icons/images/start.png", + getThemeColor(GreenStandardHighlightColor)); + m_matcherAction = headerToolbar->addAction(matcherStartIcon, "Run Matcher", [this]() { + UIActionHandler *handler = m_currentFrame->getCurrentViewInterface()->actionHandler(); + if (Ref<BackgroundTask> matcherTask = GetMatcherTask()) + matcherTask->Cancel(); + else if (!isMatcherRunning) + { + handler->executeAction("WARP\\Run Matcher"); + setMatcherActionIcon(true); + } + }); + m_matcherAction->setToolTip("Run the matcher on all functions"); + auto refreshIcon = GetColoredIcon(":/icons/images/refresh.png", getThemeColor(BlueStandardHighlightColor)); auto refreshAction = headerToolbar->addAction(refreshIcon, "Refresh the view data", [this]() { Update(); }); refreshAction->setToolTip("Refresh the sidebar data"); - // TODO: Add action for pushing to network sources. - // Push the toolbar to the right using a stretch space. headerLayout->addStretch(); headerLayout->addWidget(headerToolbar, 0); m_headerWidget->setLayout(headerLayout); QFrame *currentFunctionFrame = new QFrame(this); - m_currentFunctionWidget = new WarpCurrentFunctionWidget(nullptr); + m_currentFunctionWidget = new WarpCurrentFunctionWidget(); QVBoxLayout *currentFunctionLayout = new QVBoxLayout(); currentFunctionLayout->setContentsMargins(0, 0, 0, 0); currentFunctionLayout->setSpacing(0); @@ -104,6 +146,14 @@ WarpSidebarWidget::WarpSidebarWidget(BinaryViewRef data) : SidebarWidget("WARP") matchedLayout->addWidget(m_matchedWidget); matchedFrame->setLayout(matchedLayout); + QFrame *containerFrame = new QFrame(this); + m_containerWidget = new WarpContainersPane(); + QVBoxLayout *containerLayout = new QVBoxLayout(); + containerLayout->setContentsMargins(0, 0, 0, 0); + containerLayout->setSpacing(0); + containerLayout->addWidget(m_containerWidget); + containerFrame->setLayout(containerLayout); + QVBoxLayout *layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); @@ -111,6 +161,7 @@ WarpSidebarWidget::WarpSidebarWidget(BinaryViewRef data) : SidebarWidget("WARP") auto tabWidget = new QTabWidget(this); tabWidget->addTab(currentFunctionFrame, "Current Function"); tabWidget->addTab(matchedFrame, "Matched Functions"); + tabWidget->addTab(containerFrame, "Containers"); m_analysisEvent = new AnalysisCompletionEvent(m_data, [this]() { ExecuteOnMainThread([this]() { @@ -120,6 +171,9 @@ WarpSidebarWidget::WarpSidebarWidget(BinaryViewRef data) : SidebarWidget("WARP") layout->addWidget(tabWidget); this->setLayout(layout); + + // NOTE: This fetcher is shared with the fetch dialog that is constructed on initialization of this plugin. + m_currentFunctionWidget->SetFetcher(WarpFetcher::Global()); } WarpSidebarWidget::~WarpSidebarWidget() @@ -133,7 +187,9 @@ void WarpSidebarWidget::focus() void WarpSidebarWidget::Update() { + m_currentFunctionWidget->UpdateMatches(); m_matchedWidget->Update(); + // TODO: Obviously this probably should not be called here. setMatcherActionIcon(false); } @@ -180,6 +236,7 @@ void WarpSidebarWidget::notifyViewLocationChanged(View *view, const ViewLocation WarpSidebarWidgetType::WarpSidebarWidgetType() : SidebarWidgetType(QImage(":/icons/images/warp.png"), "WARP") { + } @@ -194,6 +251,7 @@ BINARYNINJAPLUGIN void CorePluginDependencies() BINARYNINJAPLUGIN bool UIPluginInit() { + RegisterWarpFetchFunctionsCommand(); Sidebar::addSidebarWidgetType(new WarpSidebarWidgetType()); return true; } diff --git a/plugins/warp/ui/plugin.h b/plugins/warp/ui/plugin.h index 18525218..56b31711 100644 --- a/plugins/warp/ui/plugin.h +++ b/plugins/warp/ui/plugin.h @@ -4,6 +4,7 @@ #include "matches.h" #include "sidebar.h" #include "sidebarwidget.h" +#include "containers.h" class WarpSidebarWidget : public SidebarWidget { @@ -19,6 +20,7 @@ class WarpSidebarWidget : public SidebarWidget WarpCurrentFunctionWidget *m_currentFunctionWidget; WarpMatchedWidget *m_matchedWidget; + WarpContainersPane *m_containerWidget; public: explicit WarpSidebarWidget(BinaryViewRef data); diff --git a/plugins/warp/ui/shared/fetchdialog.cpp b/plugins/warp/ui/shared/fetchdialog.cpp new file mode 100644 index 00000000..0230ad51 --- /dev/null +++ b/plugins/warp/ui/shared/fetchdialog.cpp @@ -0,0 +1,227 @@ +#include "fetchdialog.h" + +#include <QDialogButtonBox> +#include <QFormLayout> +#include <QInputDialog> +#include <QLabel> + +#include "action.h" +#include "fetcher.h" + +using namespace BinaryNinja; + +static void AddListItem(QListWidget *list, const QString &value) +{ + if (value.trimmed().isEmpty()) + return; + // Avoid duplicates + for (int i = 0; i < list->count(); ++i) + if (list->item(i)->text().compare(value, Qt::CaseInsensitive) == 0) + return; + list->addItem(value.trimmed()); +} + +WarpFetchDialog::WarpFetchDialog(BinaryViewRef bv, + std::shared_ptr<WarpFetcher> fetcher, + QWidget *parent) + : QDialog(parent), m_fetchProcessor(std::move(fetcher)), m_bv(std::move(bv)) +{ + setWindowTitle("Fetch WARP Functions"); + + auto form = new QFormLayout(); + m_containerCombo = new QComboBox(this); + populateContainers(); + m_containerCombo->addItem("All Containers"); // index 0 for "all" + for (const auto &c: m_containers) + m_containerCombo->addItem(QString::fromStdString(c->GetName())); + + // TODO: Need to add tooltip to explain that a source must have atleast one of these tags to be considered. + + // Tags editor + m_tagsList = new QListWidget(this); + m_addTagBtn = new QPushButton("Add", this); + m_removeTagBtn = new QPushButton("Remove", this); + auto tagBtnRow = new QHBoxLayout(); + tagBtnRow->addWidget(m_addTagBtn); + tagBtnRow->addWidget(m_removeTagBtn); + auto tagCol = new QVBoxLayout(); + tagCol->addWidget(m_tagsList); + tagCol->addLayout(tagBtnRow); + auto tagWrapper = new QWidget(this); + tagWrapper->setLayout(tagCol); + + // Make tags list compact with a fixed maximum height and no vertical expansion + m_tagsList->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents); + m_tagsList->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_tagsList->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + m_tagsList->setMaximumHeight(120); + m_tagsList->setToolTip("A source must have atleast ONE of these tags to be considered"); + + // Defaults from processor tags + for (const auto &t: m_fetchProcessor->GetTags()) + AddListItem(m_tagsList, QString::fromStdString(t)); + + // Batch size and matcher checkbox + m_batchSize = new QSpinBox(this); + m_batchSize->setRange(10, 1000); + m_batchSize->setValue(100); + m_batchSize->setToolTip("Number of functions to fetch in each batch"); + + m_rerunMatcher = new QCheckBox("Re-run matcher after fetch", this); + m_rerunMatcher->setChecked(true); + + m_clearProcessed = new QCheckBox("Refetch all functions", this); + m_clearProcessed->setToolTip("Clears the processed cache before fetching again, this will refetch all functions in the view"); + m_clearProcessed->setChecked(false); + + form->addRow(new QLabel("Container: "), m_containerCombo); + // TODO: Need to plumb this through to the fetcher, and also likely have a blacklisted or whitelist mode for this dialog. + // TODO: Alos wan to prefill the list of sources from the view/global settings. + // form->addRow(new QLabel("Allowed Sources: "), srcWrapper); + form->addRow(new QLabel("Allowed Tags: "), tagWrapper); + form->addRow(new QLabel("Batch Size: "), m_batchSize); + form->addRow(m_rerunMatcher); + form->addRow(m_clearProcessed); + + auto buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, this, &WarpFetchDialog::onAccept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto root = new QVBoxLayout(this); + root->addLayout(form); + root->addWidget(buttons); + setLayout(root); + + // Wire buttons + connect(m_addTagBtn, &QPushButton::clicked, this, &WarpFetchDialog::onAddTag); + connect(m_removeTagBtn, &QPushButton::clicked, this, &WarpFetchDialog::onRemoveTag); +} + +void WarpFetchDialog::populateContainers() +{ + m_containers = Warp::Container::All(); +} + +void WarpFetchDialog::onAddTag() +{ + bool ok = false; + const auto text = QInputDialog::getText(this, "Add Tag", "Tag:", QLineEdit::Normal, {}, &ok); + if (ok) + AddListItem(m_tagsList, text); +} + +void WarpFetchDialog::onRemoveTag() +{ + for (auto *item: m_tagsList->selectedItems()) + delete item; +} + +std::vector<Warp::SourceTag> WarpFetchDialog::collectTags() const +{ + std::vector<Warp::SourceTag> out; + out.reserve(m_tagsList->count()); + for (int i = 0; i < m_tagsList->count(); ++i) + out.emplace_back(m_tagsList->item(i)->text().trimmed().toStdString()); + return out; +} + +void WarpFetchDialog::onAccept() +{ + const int idx = m_containerCombo->currentIndex(); + std::optional<size_t> containerIndex; + if (idx > 0) // 0 == All Containers + containerIndex = static_cast<size_t>(idx - 1); + + auto tags = collectTags(); + const auto batch = static_cast<size_t>(m_batchSize->value()); + const bool rerun = m_rerunMatcher->isChecked(); + + // Persist tags to the shared processor for consistency across navigation + m_fetchProcessor->SetTags(tags); + + if (m_clearProcessed->isChecked()) + m_fetchProcessor->ClearProcessed(); + + // Execute the network fetch in batches + runBatchedFetch(containerIndex, tags, batch, rerun); + + accept(); +} + +void WarpFetchDialog::runBatchedFetch(const std::optional<size_t> &containerIndex, + const std::vector<Warp::SourceTag> &tags, + size_t batchSize, + bool rerunMatcher) +{ + if (!m_bv) + return; + // Collect functions in the view and enqueue them to the shared fetcher + std::vector<Ref<Function>> funcs = m_bv->GetAnalysisFunctionList(); + if (funcs.empty()) + return; + const size_t totalFuncs = funcs.size(); + const size_t totalBatches = (totalFuncs + batchSize - 1) / batchSize; + + // Create a background task to show progress in the UI + Ref<BackgroundTask> task = new BackgroundTask("Fetching WARP functions (0 / " + std::to_string(totalBatches) + ")", false); + + auto fetcher = m_fetchProcessor; + auto bv = m_bv; + + // TODO: Too many captures in this thing lol. + WorkerInteractiveEnqueue([fetcher, bv, funcs = std::move(funcs), batchSize, rerunMatcher, task]() mutable { + size_t processed = 0; + size_t batchIndex = 0; + + while (processed < funcs.size()) + { + const size_t remaining = funcs.size() - processed; + const size_t thisBatchCount = std::min(batchSize, remaining); + + for (size_t i = 0; i < thisBatchCount; ++i) + fetcher->AddPendingFunction(funcs[processed + i]); + + fetcher->FetchPendingFunctions(); + + ++batchIndex; + processed += thisBatchCount; + + task->SetProgressText("Fetching WARP functions (" + std::to_string(batchIndex) + " / " + std::to_string((funcs.size() + batchSize - 1) / batchSize) + ")"); + } + + task->Finish(); + // TODO: Print how long it took? + Logger("WARP Fetcher").LogInfo("Finished fetching WARP functions..."); + + if (rerunMatcher && bv) + Warp::RunMatcher(*bv); + }); +} + +void RegisterWarpFetchFunctionsCommand() +{ + // Register a UI action and bind it globally. Add it to the Tools menu. + const QString actionName = "WARP\\Fetch"; + + // TODO: Because we register this in every widget this will happen, this is bad behavior! + if (!UIAction::isActionRegistered(actionName)) + UIAction::registerAction(actionName); + + UIActionHandler::globalActions()->bindAction( + actionName, + UIAction( + [](const UIActionContext &context) { + if (const BinaryViewRef bv = context.binaryView; bv) + { + WarpFetchDialog dlg(bv, WarpFetcher::Global(), nullptr); + dlg.exec(); + } + }, + [](const UIActionContext &context) { + return context.binaryView != nullptr; + } + ) + ); + + Menu::mainMenu("Plugins")->addAction(actionName, "Plugins"); +} diff --git a/plugins/warp/ui/shared/fetchdialog.h b/plugins/warp/ui/shared/fetchdialog.h new file mode 100644 index 00000000..72591a4c --- /dev/null +++ b/plugins/warp/ui/shared/fetchdialog.h @@ -0,0 +1,56 @@ +#pragma once + +#include <QDialog> +#include <QComboBox> +#include <QListWidget> +#include <QSpinBox> +#include <QCheckBox> + +#include "uicontext.h" +#include "viewframe.h" +#include "warp.h" +#include "fetcher.h" + +class WarpFetchDialog : public QDialog +{ + Q_OBJECT + + QComboBox *m_containerCombo; + + QListWidget *m_tagsList; + QPushButton *m_addTagBtn; + QPushButton *m_removeTagBtn; + + QSpinBox *m_batchSize; + QCheckBox *m_rerunMatcher; + QCheckBox *m_clearProcessed; + + std::vector<Warp::Ref<Warp::Container> > m_containers; + + std::shared_ptr<WarpFetcher> m_fetchProcessor; + BinaryViewRef m_bv; + +public: + explicit WarpFetchDialog(BinaryViewRef bv, + std::shared_ptr<WarpFetcher> fetchProcessor, + QWidget *parent = nullptr); + +private slots: + void onAddTag(); + + void onRemoveTag(); + + void onAccept(); + +private: + void populateContainers(); + + std::vector<Warp::SourceTag> collectTags() const; + + void runBatchedFetch(const std::optional<size_t> &containerIndex, + const std::vector<Warp::SourceTag> &tags, + size_t batchSize, + bool rerunMatcher); +}; + +void RegisterWarpFetchFunctionsCommand(); diff --git a/plugins/warp/ui/shared/fetcher.cpp b/plugins/warp/ui/shared/fetcher.cpp new file mode 100644 index 00000000..767932e3 --- /dev/null +++ b/plugins/warp/ui/shared/fetcher.cpp @@ -0,0 +1,109 @@ +#include "fetcher.h" + +#include <QSettings> + +WarpFetcher::WarpFetcher() +{ + m_logger = new BinaryNinja::Logger("WARP Fetcher"); + QSettings qtSettings; + const QString key = "warp/allowedTags"; + + QStringList tags = qtSettings.value(key).toStringList(); + if (tags.isEmpty()) { + tags = QStringList{ "official", "trusted" }; + qtSettings.setValue(key, tags); + qtSettings.sync(); + } + + std::vector<Warp::SourceTag> initialTags; + initialTags.reserve(tags.size()); + for (const auto& t : tags) + initialTags.emplace_back(t.trimmed().toStdString()); + + SetTags(initialTags); +} + +void WarpFetcher::AddPendingFunction(const FunctionRef &func) +{ + std::lock_guard<std::mutex> lock(m_requestMutex); + const auto guid = Warp::GetAnalysisFunctionGUID(*func); + if (!guid.has_value() || m_processedGuids.contains(*guid)) + return; + m_pendingRequests.push_back(func); +} + +std::vector<FunctionRef> WarpFetcher::FlushPendingFunctions() +{ + std::lock_guard<std::mutex> lock(m_requestMutex); + std::vector<FunctionRef> requests = std::move(m_pendingRequests); + m_pendingRequests.clear(); + return requests; +} + +void WarpFetcher::ExecuteCompletionCallback() +{ + BinaryNinja::ExecuteOnMainThread([this]() { + // TODO: Holding the mutex here is dangerous! + std::lock_guard<std::mutex> lock(m_requestMutex); + m_completionCallbacks.erase( + std::ranges::remove_if(m_completionCallbacks, + [](const auto &cb) { return cb() != RemoveCallback; }).begin(), + m_completionCallbacks.end()); + }); +} + +std::shared_ptr<WarpFetcher> WarpFetcher::Global() +{ + static auto global = std::make_shared<WarpFetcher>(); + return global; +} + +void WarpFetcher::FetchPendingFunctions() +{ + m_requestInProgress = true; + const auto requests = FlushPendingFunctions(); + if (requests.empty()) + { + m_logger->LogDebug("No pending requests to fetch... skipping"); + m_requestInProgress = false; + return; + } + + const auto start_time = std::chrono::high_resolution_clock::now(); + + // Because we must fetch for a single target we map the function guids to the associated platform to perform fetches for each. + std::map<PlatformRef, std::vector<Warp::FunctionGUID>> platformMappedGuids; + for (const auto &func: requests) + { + const auto guid = Warp::GetAnalysisFunctionGUID(*func); + if (!guid.has_value()) + continue; + auto platform = func->GetPlatform(); + platformMappedGuids[platform].push_back(guid.value()); + } + + const auto tags = GetTags(); + for (const auto &[platform, guids] : platformMappedGuids) + { + m_logger->LogDebugF("Fetching {} functions for platform {}", guids.size(), platform->GetName()); + auto target = Warp::Target::FromPlatform(*platform); + for (const auto &container: Warp::Container::All()) + container->FetchFunctions(*target, guids, tags); + + std::lock_guard<std::mutex> lock(m_requestMutex); + for (const auto &guid: guids) + m_processedGuids.insert(guid); + } + + m_requestInProgress = false; + ExecuteCompletionCallback(); + const auto end_time = std::chrono::high_resolution_clock::now(); + const std::chrono::duration<double> elapsed_time = end_time - start_time; + m_logger->LogDebug("Fetch batch took %f seconds", elapsed_time.count()); +} + +void WarpFetcher::ClearProcessed() +{ + m_logger->LogInfoF("Clearing {} processed functions from cache...", m_processedGuids.size()); + m_processedGuids.clear(); +} diff --git a/plugins/warp/ui/shared/fetcher.h b/plugins/warp/ui/shared/fetcher.h new file mode 100644 index 00000000..a7195ac4 --- /dev/null +++ b/plugins/warp/ui/shared/fetcher.h @@ -0,0 +1,85 @@ +#pragma once + +#include <atomic> +#include <mutex> +#include <unordered_set> +#include <vector> +#include <functional> +#include <QSettings> + +#include "warp.h" +#include "binaryninjaapi.h" +#include "uitypes.h" + +enum WarpFetchCompletionStatus +{ + KeepCallback, + RemoveCallback, +}; + +// Responsible for fetching data from the containers, to later be queried from the container interface. +class WarpFetcher +{ + LoggerRef m_logger; + + std::mutex m_requestMutex; + std::vector<FunctionRef> m_pendingRequests; + // TODO: Easy way to clear this if user wants to refetch. + std::unordered_set<Warp::FunctionGUID> m_processedGuids; + + // List of callbacks to call when done fetching data, assume that others are using this as well. + std::vector<std::function<WarpFetchCompletionStatus()> > m_completionCallbacks; + +public: + explicit WarpFetcher(); + + // The global fetcher instance, this is used for the fetch dialog and the sidebar. + static std::shared_ptr<WarpFetcher> Global(); + + std::atomic<bool> m_requestInProgress = false; + + // Set the allowed source tags, sources with none of these tags will not be fetched from. + void SetTags(const std::vector<Warp::SourceTag> &tags) + { + std::lock_guard<std::mutex> lock(m_requestMutex); + // TODO: This is kinda a hack, the fetcher instance should not sync through qt settings! + QStringList qtTags = {}; + for (const auto& t : tags) + qtTags.append(QString::fromStdString(t)); + QSettings().setValue("warp/allowedTags", qtTags); + } + + std::vector<Warp::SourceTag> GetTags() const + { + std::lock_guard<std::mutex> lock(const_cast<std::mutex &>(m_requestMutex)); + // TODO: This is kinda a hack, the fetcher instance should not sync through qt settings! + QSettings qtSettings; + QStringList tags = qtSettings.value("warp/allowedTags").toStringList(); + if (tags.isEmpty()) { + // The default tags to allow. + tags = QStringList{ "official", "trusted" }; + qtSettings.setValue("warp/allowedTags", tags); + qtSettings.sync(); + } + std::vector<Warp::SourceTag> initialTags = {}; + for (const auto& t : tags) + initialTags.emplace_back(t.trimmed().toStdString()); + return initialTags; + } + + void AddCompletionCallback(std::function<WarpFetchCompletionStatus()> cb) + { + std::lock_guard<std::mutex> lock(m_requestMutex); + m_completionCallbacks.push_back(std::move(cb)); + } + + void AddPendingFunction(const FunctionRef &func); + + void FetchPendingFunctions(); + + void ClearProcessed(); +private: + std::vector<FunctionRef> FlushPendingFunctions(); + + void ExecuteCompletionCallback(); +}; diff --git a/plugins/warp/ui/shared/function.cpp b/plugins/warp/ui/shared/function.cpp index 88d27cd1..e1609e0b 100644 --- a/plugins/warp/ui/shared/function.cpp +++ b/plugins/warp/ui/shared/function.cpp @@ -14,35 +14,15 @@ WarpFunctionItem::WarpFunctionItem(Warp::Ref<Warp::Function> function, { m_function = function; - // TODO: This needs to be better. Symbol can be nullptr. BinaryNinja::Ref<BinaryNinja::Symbol> symbol = m_function->GetSymbol(*analysisFunction); std::string symbolName = symbol->GetShortName(); setText(QString::fromStdString(symbolName)); - BinaryNinja::InstructionTextToken nameToken = {255, TextToken, symbolName}; // Serialize the tokens to make it accessible via QModelIndex. // We will take these tokens and then user them in our custom item delegate. - TokenData tokenData = {}; - - // TODO: Make this not look like garbage - BinaryNinja::Ref<BinaryNinja::Type> type = m_function->GetType(*analysisFunction); - if (type) - { - BinaryNinja::Ref<BinaryNinja::Platform> platform = analysisFunction->GetPlatform(); - std::vector<BinaryNinja::InstructionTextToken> beforeTokens = type->GetTokensBeforeName(platform); - std::vector<BinaryNinja::InstructionTextToken> afterTokens = type->GetTokensAfterName(platform); - - for (const auto &token: beforeTokens) - tokenData.tokens.emplace_back(token); - tokenData.tokens.emplace_back(255, TextToken, " "); - tokenData.tokens.emplace_back(nameToken); - for (const auto &token: afterTokens) - tokenData.tokens.emplace_back(token); - } else - { - tokenData.tokens.emplace_back(nameToken); - } - + TokenData tokenData = TokenData(symbolName); + if (BinaryNinja::Ref<BinaryNinja::Type> type = m_function->GetType(*analysisFunction)) + tokenData = TokenData(*type, symbolName); setData(QVariant::fromValue(tokenData), Qt::UserRole); } diff --git a/plugins/warp/ui/shared/misc.cpp b/plugins/warp/ui/shared/misc.cpp index 71147079..bbe3ab24 100644 --- a/plugins/warp/ui/shared/misc.cpp +++ b/plugins/warp/ui/shared/misc.cpp @@ -8,6 +8,21 @@ #include "render.h" #include "theme.h" +TokenData::TokenData(const std::string &name) +{ + tokens.emplace_back(255, TextToken, name); +} + +TokenData::TokenData(const BinaryNinja::Type &type, const std::string& name) +{ + for (const auto &token: type.GetTokensBeforeName()) + tokens.emplace_back(token); + tokens.emplace_back(255, TextToken, " "); + tokens.emplace_back(255, TextToken, name); + for (const auto &token: type.GetTokensAfterName()) + tokens.emplace_back(token); +} + void TokenDataDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { painter->save(); @@ -23,7 +38,7 @@ void TokenDataDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt painter->translate(option.rect.topLeft()); - auto renderContext = RenderContext((QWidget *) option.widget); + auto renderContext = RenderContext(const_cast<QWidget *>(option.widget)); renderContext.init(*painter); HighlightTokenState highlightState; renderContext.drawDisassemblyLine(*painter, 5, 5, {tokenData.tokens.begin(), tokenData.tokens.end()}, @@ -35,7 +50,7 @@ void TokenDataDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt QSize TokenDataDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { auto tokenData = index.data(Qt::UserRole).value<TokenData>(); - auto renderContext = RenderContext((QWidget *) option.widget); + auto renderContext = RenderContext(const_cast<QWidget *>(option.widget)); QFontMetrics fontMetrics = QFontMetrics(renderContext.getFont()); QString line = ""; for (const auto &token: tokenData.tokens) @@ -79,3 +94,47 @@ bool GenericTextFilterModel::lessThan(const QModelIndex &sourceLeft, const QMode auto rightData = sourceRight.data().toString(); return QString::localeAwareCompare(leftData, rightData) < 0; } + +ParsedQuery::ParsedQuery(const QString &rawQuery) +{ + query = rawQuery; + qualifiers = {}; + + // Regex capturing: key, and value (bare, quoted and unquoted) + static QRegularExpression re(R"((^|\s)([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(?:\"([^\"]*)\"|'([^']*)'|(\S+)))", + QRegularExpression::CaseInsensitiveOption); + + // Collect matches to remove later (from end to start to keep indices stable) + struct Span + { + qsizetype start; + qsizetype length; + QString key; + QString value; + }; + QVector<Span> spans; + + auto it = re.globalMatch(rawQuery); + while (it.hasNext()) + { + const auto m = it.next(); + const QString key = m.captured(2).toLower(); + // Value can be in group 3 (double quotes), 4 (single quotes), or 5 (bare) + QString val = m.captured(3); + if (val.isNull() || val.isEmpty()) val = m.captured(4); + if (val.isNull() || val.isEmpty()) val = m.captured(5); + spans.push_back(Span{m.capturedStart(0), m.capturedLength(0), key, val}); + } + + // Keep only the last value per key (last occurrence wins, do not accumulate) + for (const auto &s: spans) + qualifiers[s.key] = s.value; + + // Remove matched spans from the text (replace with a single space to preserve boundaries) + std::sort(spans.begin(), spans.end(), [](const Span &a, const Span &b) { return a.start > b.start; }); + for (const auto &s: spans) + query.replace(s.start, s.length, QStringLiteral(" ")); + + // Normalize whitespace + query = query.simplified(); +} diff --git a/plugins/warp/ui/shared/misc.h b/plugins/warp/ui/shared/misc.h index d2e6a1a8..35f640f5 100644 --- a/plugins/warp/ui/shared/misc.h +++ b/plugins/warp/ui/shared/misc.h @@ -5,9 +5,11 @@ #include <QStyledItemDelegate> #include <QTableView> #include <QVector> +#include <utility> #include "binaryninjaapi.h" #include "filter.h" +#include "warp.h" // Used to serialize into the item data for rendering with TokenDataDelegate. struct TokenData @@ -16,6 +18,10 @@ struct TokenData TokenData() = default; + TokenData(const std::string& name); + + TokenData(const BinaryNinja::Type &type, const std::string& name); + TokenData(const std::vector<BinaryNinja::InstructionTextToken> &tokens) { for (const auto &token: tokens) @@ -72,7 +78,7 @@ class GenericTextFilterModel : public QSortFilterProxyModel Q_OBJECT public: - GenericTextFilterModel(QObject *parent): QSortFilterProxyModel(parent) + GenericTextFilterModel(QObject *parent) : QSortFilterProxyModel(parent) { } @@ -82,3 +88,27 @@ public: bool lessThan(const QModelIndex &sourceLeft, const QModelIndex &sourceRight) const override; }; + +// Used to parse qualifiers out of a user-supplied string (or "query") +struct ParsedQuery +{ + // The actual query, without the qualifiers like source:<uuid> + QString query; + // The qualifiers used to build other optional parts of the query. + QHash<QString, QString> qualifiers; + + ParsedQuery(QString query, const QHash<QString, QString> &qualifiers) + : query(std::move(query)), qualifiers(qualifiers) + { + } + + explicit ParsedQuery(const QString &rawQuery); + + [[nodiscard]] std::optional<QString> GetValue(const QString &key) const + { + const auto it = qualifiers.constFind(key); + if (it == qualifiers.constEnd() || it->isEmpty()) + return std::nullopt; + return it.value(); + } +}; diff --git a/plugins/warp/ui/shared/search.cpp b/plugins/warp/ui/shared/search.cpp new file mode 100644 index 00000000..1431e9b6 --- /dev/null +++ b/plugins/warp/ui/shared/search.cpp @@ -0,0 +1,301 @@ +#include "search.h" +#include "misc.h" +#include "../../../../../ui/mainwindow.h" + +QVariant WarpSearchModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) + return {}; + const auto &it = m_items[index.row()]; + + // Provide tokenized type for functions via UserRole for TokenDataDelegate + if (role == Qt::UserRole && index.column() == DisplayCol) + { + if (it && it->GetKind() == WARPContainerSearchItemKindFunction) + if (auto itemType = it->GetType(nullptr)) + return QVariant::fromValue(TokenData(*itemType, it->GetName())); + return {}; + } + + + if (role == Qt::DisplayRole) + { + // TODO: We might want to run the demangler here on the name. + switch (index.column()) + { + case DisplayCol: return QString::fromStdString(it->GetName()); + case KindCol: + { + switch (it->GetKind()) + { + case WARPContainerSearchItemKindFunction: return "Function"; + case WARPContainerSearchItemKindType: return "Type"; + case WARPContainerSearchItemKindSource: return "Source"; + case WARPContainerSearchItemKindSymbol: return "Symbol"; + default: return {}; + } + } + case SourceCol: return QString::fromStdString(it->GetSource().ToString()); + default: return {}; + } + } + return {}; +} + +QVariant WarpSearchModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (orientation == Qt::Horizontal && role == Qt::DisplayRole) + { + switch (section) + { + case DisplayCol: return "Item"; + case KindCol: return "Kind"; + case SourceCol: return "Source"; + default: return {}; + } + } + return {}; +} + +void WarpSearchDelegate::paint(QPainter *p, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + // TODO: We actually still want the function icon, I think? So this should not early return and instead replace the name. + // If model provided TokenData (e.g., for function types), render via TokenDataDelegate + if (index.data(Qt::UserRole).canConvert<TokenData>()) + { + TokenDataDelegate(parent()).paint(p, option, index); + return; + } + + + QStyleOptionViewItem opt(option); + initStyleOption(&opt, index); + + // We will draw the background/selection via style, and custom-draw icon (left) + text. + const QWidget *w = option.widget; + QStyle *style = w ? w->style() : QApplication::style(); + + // Let style draw the item background/selection etc., but not the default text + QString originalText = opt.text; + opt.text.clear(); + style->drawControl(QStyle::CE_ItemViewItem, &opt, p, w); + + // Retrieve text for DisplayCol + const QString &text = originalText; + + // Retrieve kind text from the hidden KindCol + const QModelIndex kindIndex = index.sibling(index.row(), WarpSearchModel::KindCol); + const QString kind = kindIndex.isValid() ? kindIndex.data(Qt::DisplayRole).toString() : QString(); + + // Map kind -> icon (emoji for simplicity; replace with QIcon/QPixmap if desired) + const QString icon = kindToIcon(kind); + + // Layout rects + const QRect r = opt.rect; + const int marginH = 8; + const int iconSide = 16; + const int spacing = 8; + + // Reserve space on the left for the icon if we have one + QRect iconRect = QRect(r.left() + marginH, r.center().y() - iconSide / 2, iconSide, iconSide); + QRect textRect = r.adjusted((icon.isEmpty() ? marginH : (marginH + iconSide + spacing)), 0, -marginH, 0); + + // Draw icon first (without clipping), then text with clipping + p->save(); + if (!icon.isEmpty()) + { + QFont iconFont = opt.font; + iconFont.setPointSizeF(iconFont.pointSizeF() + 2); + p->setFont(iconFont); + p->setPen( + opt.palette.color(opt.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text)); + p->drawText(iconRect, Qt::AlignCenter, icon); + } + p->restore(); + + // Draw elided text, vertically centered (with clipping so long strings don't overflow) + p->save(); + p->setClipRect(textRect); + const QFontMetrics fm(opt.font); + const QString elided = fm.elidedText(text, Qt::ElideRight, textRect.width()); + p->setPen(opt.palette.color(opt.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text)); + p->drawText(textRect, Qt::AlignVCenter | Qt::AlignLeft, elided); + p->restore(); +} + +void WarpSearchRunner::fetchPage(std::size_t offset, std::size_t limit) +{ + const auto session = currentSession(); + auto container = m_container; + auto q = m_queryText; + auto src = m_source; + + // Runs search on Qt threadpool then moves the result to the main thread to update the UI. + using TaskResult = std::pair<std::optional<Warp::ContainerSearchResponse>, QString>; + QFutureWatcher<TaskResult> *watcher = new QFutureWatcher<TaskResult>(this); + const auto future = QtConcurrent::run([container, q, src, offset, limit]() -> TaskResult { + const Warp::ContainerSearchQuery query(q.toStdString(), offset, limit, src); + auto respOpt = container->Search(query); + // TODO: We may want to provide better errors in the future, e.g. BNWARPGetError + if (!respOpt.has_value()) + return {std::nullopt, QStringLiteral("No response from container")}; + return {std::move(respOpt), QString()}; + }); + connect(watcher, &QFutureWatcher<TaskResult>::finished, this, [this, watcher, session]() { + const TaskResult result = watcher->result(); + watcher->deleteLater(); + if (const auto &err = result.second; !err.isEmpty()) + emit pageError(session, err); + else if (const auto &resp = result.first; resp.has_value()) + emit pageReady(session, *resp); + }); + watcher->setFuture(future); +} + +WarpSearchWidget::WarpSearchWidget(Warp::Ref<Warp::Container> container, QWidget *parent) : QWidget(parent) +{ + m_model = new WarpSearchModel(this); + m_runner = new WarpSearchRunner(container, this); + auto *layout = new QVBoxLayout(this); + m_query = new QLineEdit(this); + m_status = new QLabel(this); + m_view = new QTableView(this); + + // TODO: I am not necessarily a fan with how this looks. + m_query->setPlaceholderText("Search… (Optionally filter with source:<uuid>)"); + + // This is where we make the table not look like a table but instead a list. + m_view->setShowGrid(false); + m_view->verticalHeader()->setVisible(false); + m_view->horizontalHeader()->setVisible(false); + m_view->setSelectionBehavior(QAbstractItemView::SelectRows); + m_view->setSelectionMode(QAbstractItemView::SingleSelection); + m_view->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_view->setFocusPolicy(Qt::NoFocus); + + layout->addWidget(m_query); + layout->addWidget(m_status); + layout->addWidget(m_view); + + m_view->setModel(m_model); + + // Use a delegate to draw the DisplayCol text with an icon (optionally) aligned to the left. + m_view->setItemDelegateForColumn(WarpSearchModel::DisplayCol, new WarpSearchDelegate(m_view)); + + // Hide extra columns: only show the combined "Item" column; do not display source GUID. + m_view->setColumnHidden(WarpSearchModel::KindCol, true); + m_view->setColumnHidden(WarpSearchModel::SourceCol, true); + m_view->horizontalHeader()->setSectionResizeMode(WarpSearchModel::DisplayCol, QHeaderView::Stretch); + + // Debounce user input + m_debounce.setSingleShot(true); + m_debounce.setInterval(SEARCH_DEBOUNCE_MS); + connect(m_query, &QLineEdit::textChanged, this, [this](const QString &) { m_debounce.start(); }); + + connect(&m_debounce, &QTimer::timeout, this, [this]() { + const QString raw = m_query->text(); + + // Parse generic qualifiers and clean the free-text query + const auto parsed = ParsedQuery(raw); + + std::optional<Warp::Source> inlineSource = std::nullopt; + if (const auto val = parsed.GetValue("source"); val.has_value()) + inlineSource = Warp::Source::FromString(val.value().toStdString()); + const auto effectiveSource = inlineSource.has_value() ? inlineSource : m_source; + // TODO: Filter for tags and function guid. + + m_runner->startSession(parsed.query, effectiveSource); + m_model->beginNewSearch(SEARCH_PAGE_SIZE); + }); + + + // Infinite scroll trigger + connect(m_model, &WarpSearchModel::fetchMoreRequested, this, [this](std::size_t offset, std::size_t limit) { + m_status->setText(QStringLiteral("Loading… (%1/%2)").arg(m_model->currentCount()).arg(m_model->total())); + m_runner->fetchPage(offset, limit); + }); + + // Append results if session still valid + connect(m_runner, &WarpSearchRunner::pageReady, this, + [this](std::uint64_t, const Warp::ContainerSearchResponse &resp) { + m_model->appendResponse(resp); + m_status->setText( + QStringLiteral("Showing %1 of %2").arg(m_model->currentCount()).arg(m_model->total())); + }); + + // In cases like if the network container server goes down. + connect(m_runner, &WarpSearchRunner::pageError, this, + [this](std::uint64_t, const QString &msg) { + m_status->setText(QStringLiteral("Error: %1").arg(msg)); + }); + + // Add a context menu so that we can actually do actionable things with what we find. + m_view->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_view, &QTableView::customContextMenuRequested, this, [this](const QPoint &pos) { + const QModelIndex idx = m_view->indexAt(pos); + if (!idx.isValid()) + return; + + // TODO: Getting the current view here is really awful, but i dont care right now. + auto ctx = MainWindow::activeContext(); + auto view = ctx->getCurrentView(); + auto binaryView = view->getData(); + auto viewFrame = ctx->getCurrentViewFrame(); + auto viewLocation = viewFrame->getViewLocation(); + auto func = viewLocation.getFunction(); + + // Retrieve the current architecture from the current function or try the current view. + auto arch = binaryView->GetDefaultArchitecture(); + if (func) + arch = func->GetArchitecture(); + + const int row = idx.row(); + const auto item = m_model->itemAt(row); + const auto itemType = item->GetType(arch); + const auto itemFunc = item->GetFunction(); + + QMenu menu(this); + QAction *copySourceId = menu.addAction(tr("Copy Source")); + QAction *searchSource = menu.addAction(tr("Search Source")); + QAction *applyType = menu.addAction(tr("Apply Type")); + QAction *applyFunction = menu.addAction(tr("Apply to Current Function")); + + // We let users apply the type for types and functions (assuming the function has one) + // if the user applies a function, we actually will set the user type for the current view location function. + // For types, we will just throw it in the user types. + applyType->setEnabled(itemType != nullptr); + applyType->setVisible(applyType->isEnabled()); + + applyFunction->setEnabled(func != nullptr && itemFunc); + applyFunction->setVisible(applyFunction->isEnabled()); + + QAction *chosen = menu.exec(m_view->viewport()->mapToGlobal(pos)); + if (!chosen) + return; + if (chosen == copySourceId) + QApplication::clipboard()->setText(QString::fromStdString(item->GetSource().ToString())); + else if (chosen == searchSource) + { + const QString sourceId = QString::fromStdString(item->GetSource().ToString()); + // TODO: This does not preserve the query that existed prior, doing so may result in duplicate source qualifiers. + m_query->setText(QStringLiteral("source:\"%1\"").arg(sourceId)); + } else if (chosen == applyType) + { + if (func && item->GetKind() == WARPContainerSearchItemKindFunction) + { + func->SetUserType(itemType); + binaryView->UpdateAnalysis(); + } + else + binaryView->DefineUserType(item->GetName(), itemType); + } else if (chosen == applyFunction) + { + itemFunc->Apply(*func); + binaryView->UpdateAnalysis(); + } + }); + + // This will start searching with an empty query once the widget is constructed. This is a decent behavior considering + // we should be heavily caching queries such as an empty one. + m_debounce.start(); +} diff --git a/plugins/warp/ui/shared/search.h b/plugins/warp/ui/shared/search.h new file mode 100644 index 00000000..86c90942 --- /dev/null +++ b/plugins/warp/ui/shared/search.h @@ -0,0 +1,234 @@ +#pragma once + +#include <QAbstractTableModel> +#include <QApplication> +#include <QClipboard> +#include <QFutureWatcher> +#include <QHeaderView> +#include <QLabel> +#include <QLineEdit> +#include <QMenu> +#include <QObject> +#include <QPainter> +#include <QVector> +#include <QString> +#include <QStyledItemDelegate> +#include <QTableView> +#include <QTimer> +#include <QVBoxLayout> +#include <QtConcurrent/QtConcurrentRun> + +#include "uitypes.h" +#include "warp.h" + +// Will search in batches of 50 items. +constexpr auto SEARCH_PAGE_SIZE = 50; +// Will debounce the search for 350 MS. +constexpr auto SEARCH_DEBOUNCE_MS = 350; + +// Table model showing a paginated list of SearchItem. +class WarpSearchModel final : public QAbstractTableModel +{ + Q_OBJECT + +public: + enum Columns : int + { + DisplayCol = 0, + KindCol, + SourceCol, + ColumnCount + }; + + explicit WarpSearchModel(QObject *parent = nullptr) + : QAbstractTableModel(parent) + { + } + + int rowCount(const QModelIndex &parent) const override + { + if (parent.isValid()) return 0; + return m_items.size(); + } + + int columnCount(const QModelIndex &parent) const override + { + Q_UNUSED(parent); + return ColumnCount; + } + + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + + // Begin a new search session: clears all current items and resets counters. + Q_INVOKABLE void beginNewSearch(std::size_t pageSize = SEARCH_PAGE_SIZE) + { + beginResetModel(); + m_items.clear(); + m_total = 0; + m_pageSize = pageSize; + endResetModel(); + emit cleared(); + // Immediately ask for the first page from offset 0 + emit fetchMoreRequested(0, m_pageSize); + } + + // Append results from a Warp::ContainerSearchResponse (infinite scroll, no replacement). + void appendResponse(const Warp::ContainerSearchResponse &resp) + { + // Update total first (so canFetchMore reflects new value) + m_total = resp.total; + if (resp.items.empty()) + { + emit responseUpdated(currentCount(), m_total); + return; + } + const int begin = m_items.size(); + const int end = begin + static_cast<int>(resp.items.size()) - 1; + beginInsertRows(QModelIndex(), begin, end); + for (const auto &refItem: resp.items) + m_items.push_back(refItem); + endInsertRows(); + emit responseUpdated(currentCount(), m_total); + } + + // Qt's infinite scroll hooks: the view will call these when near the end. + bool canFetchMore(const QModelIndex &parent) const override + { + Q_UNUSED(parent); + return static_cast<std::size_t>(m_items.size()) < m_total; + } + + void fetchMore(const QModelIndex &parent) override + { + Q_UNUSED(parent); + if (!canFetchMore({})) + return; + const std::size_t nextOffset = m_items.size(); + emit fetchMoreRequested(nextOffset, m_pageSize); + } + + // Convenience accessors. + std::size_t total() const { return m_total; } + int currentCount() const { return m_items.size(); } + void setPageSize(std::size_t pageSize) { m_pageSize = pageSize; } + + Q_INVOKABLE Warp::Ref<Warp::ContainerSearchItem> itemAt(int row) const + { + if (row < 0 || row >= m_items.size()) + return {}; + return m_items[row]; + } + +signals: + // Emitted when model is cleared for a new query. + void cleared(); + + // Emitted after items are appended or totals updated. + void responseUpdated(int currentCount, std::size_t total); + + // Request the next page; connect this to your async search runner. + void fetchMoreRequested(std::size_t offset, std::size_t limit); + +private: + QVector<Warp::Ref<Warp::ContainerSearchItem> > m_items; + std::size_t m_total = 0; + std::size_t m_pageSize = SEARCH_PAGE_SIZE; +}; + +// A delegate to render the DisplayCol text with an icon (mapped from KindCol) on the right. +class WarpSearchDelegate final : public QStyledItemDelegate +{ + Q_OBJECT + +public: + explicit WarpSearchDelegate(QObject *parent = nullptr) : QStyledItemDelegate(parent) + { + } + + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + // Base text size + space for an icon on the right + const QSize base = QStyledItemDelegate::sizeHint(option, index); + const int iconSide = 16; + return QSize(base.width(), qMax(base.height(), iconSide + 4)); // keep row height >= icon + } + + void paint(QPainter *p, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + +private: + static QString kindToIcon(const QString &kind) + { + // Example mapping; extend as needed + if (kind.compare(QStringLiteral("Function"), Qt::CaseInsensitive) == 0) + return QStringLiteral("ƒ"); + if (kind.compare(QStringLiteral("Type"), Qt::CaseInsensitive) == 0) + return QStringLiteral("𝑇"); + if (kind.compare(QStringLiteral("Source"), Qt::CaseInsensitive) == 0) + return QStringLiteral("📦"); + // Default: no icon + return {}; + } +}; + +class WarpSearchRunner : public QObject +{ + Q_OBJECT + +public: + explicit WarpSearchRunner(Warp::Ref<Warp::Container> container, QObject *parent = nullptr) + : QObject(parent), m_container(std::move(container)) + { + } + +signals: + // Emitted on the UI thread after the worker finishes + void pageReady(std::uint64_t sessionId, Warp::ContainerSearchResponse resp); + + void pageError(std::uint64_t sessionId, QString message); + +public slots: + // Start a new logical search session (new query or new filters) + void startSession(QString queryText, const std::optional<Warp::Source> &source) + { + m_queryText = std::move(queryText); + m_source = source; + m_sessionId.fetchAndAddRelaxed(1); + } + + // Fetch a page for the current session. Safe to call multiple times (infinite scroll). + void fetchPage(std::size_t offset, std::size_t limit); + +private: + std::uint64_t currentSession() const { return m_sessionId.loadRelaxed(); } + + Warp::Ref<Warp::Container> m_container; + QAtomicInteger<quint64> m_sessionId{0}; + QString m_queryText; + std::optional<Warp::Source> m_source; +}; + +class WarpSearchWidget : public QWidget +{ + Q_OBJECT + +public: + explicit WarpSearchWidget(Warp::Ref<Warp::Container> container, QWidget *parent = nullptr); + + void setSourceFilter(const std::optional<Warp::Source> &src) + { + m_source = src; + m_debounce.start(); + } + +private: + WarpSearchModel *m_model; + WarpSearchRunner *m_runner; + QLineEdit *m_query; + QLabel *m_status; + QTableView *m_view; + QTimer m_debounce; + // Source to filter for if no source is provided in the search. + std::optional<Warp::Source> m_source; +}; |
