From 38840461df03d0f05e960a66480f9e59b158a6dd Mon Sep 17 00:00:00 2001 From: "Artem S. Tashkinov" Date: Sun, 25 Oct 2026 23:08:34 +0500 Subject: [PATCH 01/26] Preserve controller ancestry across missing sysfs records Connect orphaned branches to their nearest inventoried directory ancestor without changing recorded parent metadata. --- src/ui/mainwindow.cpp | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/ui/mainwindow.cpp b/src/ui/mainwindow.cpp index c6815df..f2ccee2 100644 --- a/src/ui/mainwindow.cpp +++ b/src/ui/mainwindow.cpp @@ -379,19 +379,22 @@ void MainWindow::acceptInventory(const Inventory &inventory) } void MainWindow::rememberTree() { - if (filter_->active()) return; // Preserve the unfiltered expansion and selection. - const QString prefix = DeviceModel::viewId(model_->view()) + ':'; - std::function visit = [&](const QModelIndex &parent) { - for (int row = 0; row < model_->rowCount(parent); ++row) { - const QModelIndex i = model_->index(row, 0, parent); - const QString id = prefix + i.data(DeviceModel::NodeKeyRole).toString(); - if (tree_->isExpanded(filter_->mapFromSource(i))) expanded_.insert(id); - else expanded_.remove(id); - visit(i); - } - }; - visit({}); + // Search expansion is temporary; selection made in search results is not. + if (!filter_->active()) { + const QString prefix = DeviceModel::viewId(model_->view()) + ':'; + std::function visit = [&](const QModelIndex &parent) { + for (int row = 0; row < model_->rowCount(parent); ++row) { + const QModelIndex i = model_->index(row, 0, parent); + const QString id = prefix + i.data(DeviceModel::NodeKeyRole).toString(); + if (tree_->isExpanded(filter_->mapFromSource(i))) expanded_.insert(id); + else expanded_.remove(id); + visit(i); + } + }; + visit({}); + } const QModelIndex current = tree_->currentIndex(); + if (!current.isValid()) return; // Keep the last selection if the query has no current result. selectedNodeKey_ = current.data(DeviceModel::NodeKeyRole).toString(); const QModelIndex target = propertyTarget(current); selectedPath_ = target.data(DeviceModel::PathRole).toString(); @@ -563,10 +566,12 @@ void MainWindow::applyFilter() cancelSearch(); if (closing_) return; const QString query = filterEdit_->text().trimmed(); - if (!filter_->active() && !query.isEmpty()) rememberTree(); + const bool leavingSearch = filter_->active() && query.isEmpty(); + rememberTree(); // Capture the current result before changing the proxy filter. filter_->setQuery(query, deepSearch_->isChecked()); searchDone_ = searchTotal_ = searchUnavailable_ = 0; restoreTree(); + if (leavingSearch && tree_->currentIndex().isValid()) tree_->scrollTo(tree_->currentIndex()); if (!query.isEmpty() && deepSearch_->isChecked()) { searchTotal_ = model_->visibleCount(); searchPending_ = true; From 82823b50f3aa6af45bfd2a32df201008dcc9b10c Mon Sep 17 00:00:00 2001 From: "Artem S. Tashkinov" Date: Sun, 25 Oct 2026 23:14:25 +0500 Subject: [PATCH 02/26] Export device trees as plain text and interactive HTML Capture the current view and filter, including collapsed descendants. Provide standalone HTML controls and atomic file saving. --- CMakeLists.txt | 7 ++- README.md | 15 +++++ src/ui/mainwindow.cpp | 6 ++ src/ui/treeexport.cpp | 130 ++++++++++++++++++++++++++++++++++++++++++ src/ui/treeexport.h | 23 ++++++++ tests/tree-export.cpp | 38 ++++++++++++ 6 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 src/ui/treeexport.cpp create mode 100644 src/ui/treeexport.h create mode 100644 tests/tree-export.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 44c95ca..3901e9d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -87,6 +87,7 @@ add_executable(qlinuxdevicemanager src/ui/icontheme.h src/ui/icontheme.cpp src/ui/mainwindow.h src/ui/mainwindow.cpp src/ui/aboutdialog.h src/ui/aboutdialog.cpp + src/ui/treeexport.h src/ui/treeexport.cpp src/devices/pcielinktext.h src/ui/gpulinktest.h src/ui/gpulinktest.cpp src/ui/propertiesdialog.h src/ui/propertiesdialog.cpp @@ -229,6 +230,10 @@ if(TARGET Qt6::lrelease) endif() include(CTest) if(BUILD_TESTING) + add_executable(tree-export-test tests/tree-export.cpp src/ui/treeexport.h src/ui/treeexport.cpp) + target_include_directories(tree-export-test PRIVATE src/ui) + target_link_libraries(tree-export-test PRIVATE Qt6::Widgets) + add_test(NAME tree-export COMMAND tree-export-test) add_executable(pcie-link-text-test tests/pcie-link-text.cpp src/devices/pcielinktext.h) target_include_directories(pcie-link-text-test PRIVATE src/devices) target_link_libraries(pcie-link-text-test PRIVATE Qt6::Core) @@ -331,7 +336,7 @@ endif() # Apply the declared Qt baseline's API checks to project executables and fixture targets. foreach(qldm_target IN ITEMS qlinuxdevicemanager monitor-state-test device-events-test device-rules-test device-resources-test device-search-test system-properties-test - directory-scan-test display-identity-test gpu-link-sample-test pcie-link-text-test) + directory-scan-test display-identity-test gpu-link-sample-test pcie-link-text-test tree-export-test) if(TARGET ${qldm_target}) target_compile_definitions(${qldm_target} PRIVATE QT_ENABLE_STRICT_MODE_UP_TO=${APP_QT_STRICT_VERSION}) endif() diff --git a/README.md b/README.md index 0261d74..f48d463 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,21 @@ browse. Devices update automatically when hardware changes; use **F5** if you ne to refresh manually. Open Properties dialogs retain a snapshot until you reload them. System Information has its own **Refresh** and **Copy all** buttons. +### Exporting device trees + +Choose **File → Export device tree…** to save the current view as an indented +UTF-8 text file or a standalone interactive HTML document. Both formats include +all rows allowed by the current search and device-visibility settings, including +descendants of collapsed branches. Only displayed labels and their hierarchy are +exported; no additional device properties are collected. Driver and resource +views export their displayed rows too. + +HTML preserves the initial expansion state and includes Expand all / Collapse all +buttons. It works offline with no external assets. The export captures the tree +when the command is selected; if a deep search is still running, only the results +already present are included. HTML also records the view, search text and capture +time to give shared trees context. + ### Wireless network details The Network tab includes a Wireless section for Wi-Fi interfaces. Read-only diff --git a/src/ui/mainwindow.cpp b/src/ui/mainwindow.cpp index f2ccee2..123c629 100644 --- a/src/ui/mainwindow.cpp +++ b/src/ui/mainwindow.cpp @@ -1,5 +1,6 @@ #include "mainwindow.h" #include "aboutdialog.h" +#include "treeexport.h" #ifdef QLDM_ENABLE_DONATION_BUTTON #include "donatebutton.h" #include @@ -109,6 +110,11 @@ MainWindow::MainWindow() systemInfo->setToolTip(tr("Show System Information")); systemInfo->setShortcut(QKeySequence(Qt::ALT | Qt::Key_I)); connect(systemInfo, &QAction::triggered, this, &MainWindow::openSystemInformation); + auto *exportTree = file->addAction(tr("Export device tree…")); + connect(exportTree, &QAction::triggered, this, [this] { + if (filterTimer_.isActive()) applyFilter(); + exportDeviceTree(tree_, DeviceModel::viewLabel(model_->view()), filterEdit_->text().trimmed()); + }); file->addSeparator(); auto *quit = file->addAction(tr("&Quit")); quit->setShortcut(QKeySequence::Quit); diff --git a/src/ui/treeexport.cpp b/src/ui/treeexport.cpp new file mode 100644 index 0000000..f6a899b --- /dev/null +++ b/src/ui/treeexport.cpp @@ -0,0 +1,130 @@ +#include "treeexport.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +QString text(const char *source) +{ + return QCoreApplication::translate("TreeExport", source); +} +QString singleLine(QString label) +{ + // A device name must not create extra rows or indentation in the text file. + for (QChar &character : label) + if (character.isSpace()) character = QLatin1Char(' '); + return label; +} +TreeExportSnapshot capture(const QTreeView &tree, const QString &view, const QString &query) +{ + TreeExportSnapshot result {view, query, QDateTime::currentDateTime().toString(Qt::ISODate), {}}; + const auto *model = tree.model(); + if (!model) return result; + struct Level { QModelIndex parent; int next = 0; }; + QVector stack {{tree.rootIndex(), 0}}; + while (!stack.isEmpty()) { + auto &level = stack.last(); + if (level.next >= model->rowCount(level.parent)) { stack.removeLast(); continue; } + const QModelIndex index = model->index(level.next++, 0, level.parent); + const bool branch = model->rowCount(index) > 0; + result.rows.append({singleLine(index.data(Qt::DisplayRole).toString()), + static_cast(stack.size() - 1), branch, tree.isExpanded(index)}); + if (branch) stack.append({index, 0}); + } + return result; +} +} + +QByteArray treeExportText(const TreeExportSnapshot &snapshot) +{ + QByteArray output; + for (const auto &row : snapshot.rows) { + output += QByteArray(row.depth * 2, ' '); + output += singleLine(row.label).toUtf8(); + output += '\n'; + } + return output; +} + +QByteArray treeExportHtml(const TreeExportSnapshot &snapshot) +{ + const QString title = text("Device tree — %1").arg(snapshot.view); + QString html = QStringLiteral("\n" + "" + "%1\n").arg(title.toHtmlEscaped()); + html += QStringLiteral(R"( +)"); + html += QStringLiteral("

%1

%2

\n").arg(title.toHtmlEscaped(), + text("Snapshot: %1").arg(snapshot.capturedAt).toHtmlEscaped()); + if (!snapshot.query.isEmpty()) + html += QStringLiteral("

%1

\n").arg(text("Search filter: %1").arg(snapshot.query).toHtmlEscaped()); + html += QStringLiteral("" + "\n
    \n") + .arg(text("Expand all").toHtmlEscaped(), text("Collapse all").toHtmlEscaped()); + int depth = 0; + for (const auto &row : snapshot.rows) { + while (depth > row.depth) { html += QStringLiteral("
\n"); --depth; } + const QString label = singleLine(row.label).toHtmlEscaped(); + if (row.branch) { + html += QStringLiteral("
  • %2
      \n") + .arg(row.expanded ? QStringLiteral(" open") : QString(), label); + ++depth; + } else html += QStringLiteral("
    • %1
    • \n").arg(label); + } + while (depth-- > 0) html += QStringLiteral("
  • \n"); + html += QStringLiteral(R"( + + +)"); + return html.toUtf8(); +} + +void exportDeviceTree(QTreeView *tree, const QString &view, const QString &query) +{ + // A modal file dialog processes live updates; own the snapshot before entering it. + const TreeExportSnapshot snapshot = capture(*tree, view, query); + const QString htmlFilter = text("Interactive HTML (*.html)"); + const QString textFilter = text("Plain text (*.txt)"); + QFileDialog dialog(tree, text("Export device tree")); + dialog.setAcceptMode(QFileDialog::AcceptSave); + dialog.setNameFilters({htmlFilter, textFilter}); + dialog.setDefaultSuffix("html"); + dialog.selectFile("device-tree"); + QObject::connect(&dialog, &QFileDialog::filterSelected, &dialog, [&dialog, textFilter](const QString &filter) { + dialog.setDefaultSuffix(filter == textFilter ? "txt" : "html"); + }); + if (dialog.exec() != QDialog::Accepted || dialog.selectedFiles().isEmpty()) return; + const QString path = dialog.selectedFiles().first(); + const QString suffix = QFileInfo(path).suffix().toLower(); + const bool html = suffix == "html" || suffix == "htm" + || (suffix != "txt" && dialog.selectedNameFilter() == htmlFilter); + const QByteArray output = html ? treeExportHtml(snapshot) : treeExportText(snapshot); + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly) || file.write(output) != output.size() || !file.commit()) + QMessageBox::warning(tree, text("Export device tree"), + text("Could not save %1:\n%2").arg(path, file.errorString())); +} diff --git a/src/ui/treeexport.h b/src/ui/treeexport.h new file mode 100644 index 0000000..cb854df --- /dev/null +++ b/src/ui/treeexport.h @@ -0,0 +1,23 @@ +#pragma once +#include +#include +#include + +class QTreeView; +struct TreeExportRow { + QString label; + int depth = 0; + bool branch = false, expanded = false; +}; +struct TreeExportSnapshot { + QString view, query, capturedAt; + // Preorder with nonnegative depths, starting at zero, increasing by at most + // one after a branch. Includes descendants of collapsed branches. + QVector rows; +}; + +QByteArray treeExportText(const TreeExportSnapshot &snapshot); +QByteArray treeExportHtml(const TreeExportSnapshot &snapshot); +// Captures current model labels synchronously, before opening the save dialog. +// No device-property queries, model changes or expansion changes are performed. +void exportDeviceTree(QTreeView *tree, const QString &view, const QString &query); diff --git a/tests/tree-export.cpp b/tests/tree-export.cpp new file mode 100644 index 0000000..1255632 --- /dev/null +++ b/tests/tree-export.cpp @@ -0,0 +1,38 @@ +#include "treeexport.h" +#include +#include + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + int failures = 0; + const auto check = [&](bool ok, const char *message) { + if (!ok) { std::fprintf(stderr, "%s\n", message); ++failures; } + }; + TreeExportSnapshot tree {"Devices connection", "", "2026-10-25T12:00:00Z", { + {"Host & computer", 0, true, true}, + {"Controller", 1, true, false}, + {"Disk\nwith\ttabs", 2, false, false}, + {"", 1, false, false}, + {QString::fromUtf8("Камера ♡"), 1, false, false}}}; + const QByteArray plain = treeExportText(tree); + check(plain.startsWith("Host & computer\n Controller\n Disk with tabs\n"), + "Text uses two spaces per level and includes collapsed descendants"); + check(plain.contains(QString::fromUtf8("Камера ♡").toUtf8()), "Text preserves UTF-8 labels"); + const QByteArray html = treeExportHtml(tree); + check(html.contains("
    Host & computer") + && html.contains("
    Controller"), + "HTML preserves branch expansion states"); + check(!html.contains("