diff --git a/build.sh b/build.sh index 55f0030..098a25e 100755 --- a/build.sh +++ b/build.sh @@ -66,6 +66,15 @@ install -m 644 wdx/mediainfo/luajit/*.lua release/wdx/mediainfo/ install -m 644 wdx/translitwdx/translitwdx.lua release/wdx/translitwdx/ install -m 644 wdx/translitwdx/readme.txt release/wdx/translitwdx/ + +# logview +mkdir -p release/wlx/logview +mkdir -p wlx/logview/build +(cd wlx/logview/build && cmake .. && make) +install -m 644 wlx/logview/build/logviewer_wlx.wlx release/wlx/logview/ +install -m 644 wlx/logview/*.md release/wlx/logview/ +install -m 644 wlx/logview/*.png release/wlx/logview/ + pushd release tar -czpf ../plugins-$(date +%y.%m.%d)-$ARCH.tar.gz * popd diff --git a/wlx/logview/CMakeLists.txt b/wlx/logview/CMakeLists.txt new file mode 100644 index 0000000..c20c486 --- /dev/null +++ b/wlx/logview/CMakeLists.txt @@ -0,0 +1,41 @@ +cmake_minimum_required(VERSION 3.16) +project(wlx-log-viewer LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Required for Q_OBJECT macro — generates moc files for vtable/signal-slot metadata +set(CMAKE_AUTOMOC ON) + +# Visibility hidden by default as requested in spec +set(CMAKE_CXX_VISIBILITY_PRESET hidden) +set(CMAKE_VISIBILITY_INLINES_HIDDEN 1) + +find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets) +find_package(re2 REQUIRED) + +# Need Qt6::WaylandClient? If not just Widgets and Core +# For inotify, it's native linux. + +# Build the shared library (WLX plugin) +add_library(logviewer_wlx SHARED + src/wlx_plugin.cpp + src/LogViewerWidget.cpp + src/LogModel.cpp +) + +set_target_properties(logviewer_wlx PROPERTIES + PREFIX "" + SUFFIX ".wlx" +) + +target_link_libraries(logviewer_wlx PRIVATE + Qt6::Core + Qt6::Gui + Qt6::Widgets + re2::re2 +) + +# Only export specific functions +# This is usually handled by `extern "C" __attribute__((visibility("default")))` in the code, +# combined with the hidden preset. diff --git a/wlx/logview/README.md b/wlx/logview/README.md new file mode 100644 index 0000000..4f1d6ec --- /dev/null +++ b/wlx/logview/README.md @@ -0,0 +1,55 @@ +# WLX Log Viewer Plugin for Double Commander + +A high-performance, Wayland-compatible WLX (Lister) log viewer plugin for Double Commander, built with Qt6 and C++20. Designed specifically to handle large log files seamlessly while providing fast searching and filtering capabilities without freezing the host application. + +![Screenshot](logviewer.png) + +## Features + +- **Zero-Copy File Loading**: Utilizes `mmap` and line-offset indexing to instantly load massive log files with extremely low memory overhead. +- **Fast Regex Searching**: Powered by Google's `RE2` regex engine. Searches are offloaded to a background `std::jthread` to keep the UI fully responsive. +- **Timestamp Range Filtering**: Automatically detects and parses common timestamp formats (ISO 8601, nginx, syslog). Allows filtering log lines within a specific date/time range. +- **Live Tailing (Follow Mode)**: Monitors the file for changes using `QFileSystemWatcher` (inotify-based) and automatically updates and scrolls to new entries. +- **Advanced Filtering**: Implements `QSortFilterProxyModel` for combining regex matches and timestamp ranges efficiently. +- **Native Interactions**: Supports standard file manager interactions, including multi-row selection (Ctrl+click, Shift+click) and copying (Ctrl+C, Right-click context menu). +- **Wayland Focus Isolation**: Implements a robust 4-layer focus defense architecture to resolve Wayland focus-hijacking bugs typical when embedding Qt components into Lazarus applications: + - **Layer 0**: Deferred `show()` execution to prevent the plugin from trapping the host's `MouseRelease` event (fixes the "phantom-drag" issue). + - **Layer 1**: Aggressive `Qt::NoFocus` policy applied to the base container. + - **Layer 2**: Recursive focus guard that strips focus capabilities from all non-input child widgets. + - **Layer 3**: Cross-load focus preservation (saves the currently focused Double Commander widget state and restores it after loading completes). + - **Layer 4**: Global `FocusIn` event interceptor that immediately yanks stolen focus back to Double Commander, unless an input field is explicitly clicked by the user. + +## Dependencies + +To build this plugin, the following packages and libraries are required: + +- **C++20** compatible compiler (GCC or Clang) +- **CMake** (3.16 or higher) +- **Qt 6** development packages (Core, Gui, Widgets) +- **RE2** regular expression library (`libre2-dev` on Debian/Ubuntu) + +## Build Instructions + +1. Clone or navigate to the plugin directory. +2. Create a build directory and configure with CMake: + ```bash + mkdir build + cd build + cmake .. + ``` +3. Compile the plugin: + ```bash + make -j$(nproc) + ``` +4. Install the compiled `.wlx` file to your Double Commander WLX plugins directory: + ```bash + cp logviewer_wlx.wlx ~/.config/doublecmd/plugins/wlx/ + ``` + +## Installation in Double Commander + +1. Open Double Commander. +2. Go to **Configuration** > **Options** > **Plugins** > **WLX (Lister)**. +3. Click **Add** and select the installed `logviewer_wlx.wlx` file. +4. The plugin automatically registers the following extensions: `.log`, `.out`, `.err`, `.ndjson`, `.jsonl`, `.1`, `.2`, and `.old`. You can adjust this "Detect string" in Double Commander's options if you want it to automatically trigger on other extensions. +5. Apply and close. Select a log file and press `F3` or `Ctrl+Q` (Quick View) to use. For files without an extension (like `/var/log/syslog`), select the file and press `F3`. diff --git a/wlx/logview/double commander wlx log viewer.md b/wlx/logview/double commander wlx log viewer.md new file mode 100644 index 0000000..280a1a6 --- /dev/null +++ b/wlx/logview/double commander wlx log viewer.md @@ -0,0 +1,106 @@ +# **High-Performance Log Viewing in Double Commander: WLX Plugin Architecture and Qt6/Wayland Integration** + +## **Introduction to the Double Commander Lister Architecture** + +The evolution of twin-panel file managers on UNIX-like operating systems has historically been tethered to the X11 windowing system and GTK2 or Qt5 user interface toolkits. Double Commander represents a cross-platform, open-source file manager heavily inspired by Total Commander, aiming to replicate and extend its paradigm.1 The application natively processes archives as subdirectories, executes background file operations, and supports a rich plugin architecture encompassing WCX (Packer), WDX (Content), WFX (File System), and WLX (Lister) plugins.1 + +The native internal file viewer, invoked via the F3 hotkey, provides essential mechanisms for viewing files of arbitrary sizes in hexadecimal, binary, or plain text formats, alongside graphics rendering via libraries like librsvg and libturbojpeg.3 However, enterprise software development and system administration necessitate specialized functionalities for advanced log analysis. These requirements include the instantaneous loading of multi-gigabyte files via memory mapping, asynchronous Perl-Compatible Regular Expression (PCRE) searching, dynamic syntax highlighting, and continuous file tailing (the equivalent of tail \-f). Because the native cm\_View command lacks SIMD-accelerated text processing and robust background tailing, extending Double Commander via a custom WLX plugin is imperative. + +As modern Linux desktop environments relentlessly transition toward pure Wayland compositors and the Qt6 framework, traditional mechanisms for plugin window embedding have been deprecated. Specifically, the legacy paradigm of X11 reparenting via an XID passed to the plugin is explicitly prohibited under Wayland's strict security and isolation models.4 This report provides an exhaustive, code-level architectural analysis for engineering a high-performance, Wayland-native log viewing WLX plugin for Double Commander. The analysis evaluates the existing open-source Linux WLX ecosystem, dissects the feasibility of cross-compiling legacy Total Commander plugins, and designs a comprehensive engineering roadmap for wrapping the high-performance Klogg application into a native Qt6/Wayland WLX shared library. + +## **Phase 3: Engineering a Klogg WLX Plugin Wrapper** + +Klogg represents the zenith of open-source, cross-platform log exploration. Forked from the glogg project, Klogg is built on the Qt framework and is engineered explicitly for extreme performance.21 Rather than loading files into the heap, Klogg relies exclusively on memory-mapped files (mmap), allowing it to effortlessly parse datasets exceeding 2.14 billion lines and 10+ GB in size.21 It leverages the simdutf library, utilizing ARM NEON and AVX-512 SIMD instructions to execute UTF-8 validation and text parsing at speeds exceeding one billion characters per second.22 Furthermore, it boasts an asynchronous PCRE search engine that isolates regular expression queries to background threads, ensuring the UI remains perfectly responsive.21 + +Transforming Klogg from a standalone executable into a dynamic shared library (.so) that strictly conforms to the Double Commander WLX C-API under a Wayland compositor requires a sophisticated architectural synthesis, spanning build systems, inter-process communication, and Wayland subsurface protocols. + +### **3.1. Build System Integration and CMake Architecture** + +To compile Klogg as a WLX plugin, its native CMake build system must undergo significant restructuring. Klogg is traditionally compiled as a standalone application via the add\_executable directive. This structure must be bifurcated into a static core library encapsulating the business logic and a dynamically loaded shared object library exposing the WLX C-API exports. + +The CMake configuration must first define the core logic as an object library or static library: add\_library(klogg\_core STATIC ${KLOGG\_SOURCES}). Following this, a new target must be declared for the WLX wrapper: add\_library(klogg\_wlx SHARED wlx\_wrapper.cpp). + +A critical vulnerability when loading Qt-based shared libraries into a host application is symbol collision. By default, Linux ELF shared objects export all symbols. If Klogg and Double Commander use different internal versions of a dependency, the runtime linker will resolve symbols unpredictably, causing immediate segmentation faults. Strict symbol visibility must be enforced via CMake: + +CMake + +set\_target\_properties(klogg\_wlx PROPERTIES CXX\_VISIBILITY\_PRESET hidden) +set\_target\_properties(klogg\_wlx PROPERTIES VISIBILITY\_INLINES\_HIDDEN 1) + +Only the explicit WLX C-API functions should be tagged with \_\_attribute\_\_((visibility("default"))). The wrapper must be compiled with the \-fPIC (Position Independent Code) flag to ensure it can be dynamically loaded into varying memory address spaces.6 The target must meticulously link against klogg\_core, Qt6::Core, Qt6::Widgets, Qt6::WaylandClient, and the simdutf engine.10 + +### **3.2. Process and Thread Management Architecture** + +Integrating a massive, multi-threaded Qt framework application like Klogg directly into the host process of Double Commander introduces catastrophic stability risks. + +If Klogg is compiled strictly in-process—meaning it is loaded via dlopen directly into Double Commander's memory space—it is forced to share the host's event loop. Double Commander initializes its own QApplication instance upon startup. The Qt framework mandates a strict singleton pattern for QApplication. If the Klogg plugin attempts to execute QApplication app(argc, argv); inside the WLX ListLoad initialization vector, the Qt runtime will detect the existing instance and trigger an immediate, fatal abort(). Conversely, if Klogg attempts to hijack Double Commander's existing qApp instance, it risks memory corruption and unpredictable event dispatching, particularly if Double Commander was compiled with a different minor version of Qt6. + +To guarantee absolute stability, isolate memory spaces, and protect the file manager from potential crashes during the parsing of malformed regex over a 20 GB binary blob, the plugin must be engineered using an **Out-of-Process (IPC) Architecture**. + +Under this paradigm, the klogg\_wlx.so file acts exclusively as a lightweight C-bridge (a stub). When Double Commander invokes ListLoad, the stub utilizes QProcess or POSIX fork()/exec() to spawn a daemonized, headless instance of Klogg, designated as klogg-wlx-server. The stub and the server process establish a high-speed communication channel via UNIX Domain Sockets (e.g., /tmp/klogg\_wlx\_socket\_PID). The stub translates synchronous WLX commands—such as ListSearchText and ListCloseWindow—into structured JSON-RPC or FlatBuffers payloads, transmitting them to the isolated Klogg server.7 This decoupled architecture ensures that if the Klogg server encounters an out-of-bounds memory access, the subprocess terminates gracefully, while Double Commander remains completely stable. + +### **3.3. API Mapping: Bridging C to the Klogg IPC** + +Double Commander dictates interaction via the Total Commander WLX C-API, documented in the WLX SDK headers (wlx.h).23 The lightweight plugin stub must export these specific extern "C" functions and bridge them to the Klogg server. + +| WLX API Export | Architectural Implementation within the Klogg Wrapper | +| :---- | :---- | +| HWND \_stdcall ListLoad(HWND ParentWin, char\* FileToLoad, int ShowFlags) | Acts as the primary initialization vector. The stub intercepts the FileToLoad string and the ParentWin handle. It spawns the Klogg subprocess, passing the file path via IPC. Klogg maps the file to memory, initializes the simdutf encoding validation, and prepares the render buffer. The function must return a unique integer handle (identifying the plugin instance) back to Double Commander. | +| int \_stdcall ListSearchText(HWND ListWin, char\* SearchString, int SearchParameter) | Triggered by the F3 hotkey or internal search commands. The stub translates SearchParameter flags (e.g., case sensitivity, reverse traversal) into a JSON-RPC payload. Klogg receives the payload, executes the asynchronous PCRE search, and commands its internal QTableView to scroll to the matched coordinates. | +| void \_stdcall ListCloseWindow(HWND ListWin) | Triggered when the user dismisses the Lister pane. The stub transmits a termination signal across the UNIX socket. The Klogg server gracefully terminates its event loop, unmaps the log file, and exits, preventing zombie processes. | + +### **3.4. Window Embedding under Wayland Compositors** + +Resolving the visual integration of an out-of-process Qt6 application into the host's UI constitutes the most profound engineering challenge in this roadmap. The legacy WLX API was conceptualized during the Windows 95 era, relying on passing a parent HWND and expecting the plugin to forcibly draw a child window inside it. On Linux X11, this HWND was cast to an XID, enabling seamless reparenting via QX11EmbedContainer or direct Xlib calls.7 + +Wayland permanently severs this capability. Applications run in sandboxed contexts. A child window from Process B (Klogg) cannot simply attach itself to Process A (Double Commander) using an integer ID.4 + +To achieve native, performant embedding under Wayland without relying on the heavily abstracted XWayland compatibility layer, the architecture must implement the xdg-foreign-unstable-v2 protocol.25 This Wayland protocol allows a client to securely export a surface and generate a cryptographic string token, which can be passed to another process to establish a formal parent-child hierarchy.26 + +#### **The Implementation Sequence** + +The integration requires a coordinated handshake between Double Commander and the Klogg subprocess. + +First, the **Host Export**. Double Commander must expose the wl\_surface of its dedicated Lister pane. Utilizing Qt6's Wayland extensions or the KDE KWindowSystem library, the host invokes the zxdg\_exporter\_v2 protocol to generate the unique string token.25 + +C++ + +// Qt6 / KWindowSystem API utilization by Double Commander +QString waylandToken \= KWaylandExtras::exportWindow(listerWidget-\>windowHandle()); + +Secondly, **Handle Translation**. The WLX API rigidly types the ParentWin parameter as an HWND (which compiles to uintptr\_t or unsigned long on Linux). A Wayland token is a string (e.g., wayland:xdg:1234abcd). Because the WLX C-API signature cannot be altered, Double Commander must pass this string token via a dedicated environment variable (e.g., DC\_WLX\_WAYLAND\_TOKEN) prior to executing ListLoad. + +Thirdly, the **Client Import**. The out-of-process Klogg server reads the environment variable and retrieves the string token. It utilizes the zxdg\_importer\_v2 protocol to import the host's surface.26 In Qt6, a foreign window handle can be manipulated using QWindow::fromWinId().28 However, because the token is a string, advanced KDE libraries provide QString overloads in KWindowSystem::setMainWindow() to parse the Wayland token and correctly establish the transient parentage.27 + +Finally, **Embedding the QWidget**. Once the Klogg QWindow establishes the imported Wayland surface as its parent, it can be embedded seamlessly into Klogg's own rendering pipeline using QWidget::createWindowContainer().19 This securely locks Klogg's log-tailing interface directly inside Double Commander's panel, maintaining hardware-accelerated rendering without violating Wayland's security constraints. + +If Double Commander fails to provide the xdg-foreign token, the plugin stub must possess a failsafe mechanism. It must forcefully downgrade the Klogg subprocess to XWayland by injecting the environment variable QT\_QPA\_PLATFORM=xcb before the fork(). This forces the Wayland compositor to allocate a legacy XID, allowing standard X11 reparenting mechanics to execute via XReparentWindow 7, ensuring legacy fallback capabilities at the expense of pure Wayland compliance. + +## **Synthesis and Strategic Outlook** + +The engineering of a high-performance log-viewing plugin for Double Commander on Linux transcends the simplistic cross-compilation of legacy Win32 plugins. The systemic disparities in memory allocation, character encoding paradigms, and the uncompromising shift from global X11 window hierarchies to the secure, isolated Wayland compositor model dictate an entirely modern architectural approach. + +Wrapping the Klogg application presents the only viable, production-ready roadmap. By strictly decoupling the plugin into an out-of-process IPC server, the architecture guarantees impregnable stability for the Double Commander host process, immunizing it against memory exhaustion during the analysis of multi-gigabyte files. Resolving the Wayland embedding constraint via the xdg-foreign-unstable-v2 protocol ensures native Qt6 compatibility without relying on deprecated XWayland abstractions. This integration ultimately provides the Linux file management ecosystem with unprecedented forensic capabilities, seamlessly merging the fluid UI of a modern Qt6 application with the immense processing throughput of SIMD-accelerated text engines. + +#### **Works cited** + +1. Double Commander, accessed April 18, 2026, [https://doublecmd.sourceforge.io/](https://doublecmd.sourceforge.io/) +2. Double Commander download | SourceForge.net, accessed April 18, 2026, [https://sourceforge.net/projects/doublecmd/](https://sourceforge.net/projects/doublecmd/) +3. DC \- Built-in file viewer \- Double Commander, accessed April 18, 2026, [https://doublecmd.github.io/doc/en/viewer.html](https://doublecmd.github.io/doc/en/viewer.html) +4. Wayland and Qt | Qt 6.11, accessed April 18, 2026, [https://doc.qt.io/qt-6/wayland-and-qt.html](https://doc.qt.io/qt-6/wayland-and-qt.html) +5. Porting Qt applications to Wayland \- Martin's Blog, accessed April 18, 2026, [https://blog.martin-graesslin.com/blog/2015/07/porting-qt-applications-to-wayland/](https://blog.martin-graesslin.com/blog/2015/07/porting-qt-applications-to-wayland/) +6. j2969719/doublecmd-plugins: Additions for Double Commander (third-party) \- GitHub, accessed April 18, 2026, [https://github.com/j2969719/doublecmd-plugins](https://github.com/j2969719/doublecmd-plugins) +7. halfhope/doublecmd\_ooitv\_wlx\_viewer\_plugin \- GitHub, accessed April 18, 2026, [https://github.com/halfhope/doublecmd\_ooitv\_wlx\_viewer\_plugin](https://github.com/halfhope/doublecmd_ooitv_wlx_viewer_plugin) +8. Plugin wlxwebkit \- Double Commander, accessed April 18, 2026, [https://doublecmd.h1n.ru/viewtopic.php?t=8657](https://doublecmd.h1n.ru/viewtopic.php?t=8657) +9. wlx · GitHub Topics, accessed April 18, 2026, [https://github.com/topics/wlx](https://github.com/topics/wlx) +10. qt6-base 6.11.0-2 (x86\_64) \- File List \- Arch Linux, accessed April 18, 2026, [https://archlinux.org/packages/extra/x86\_64/qt6-base/files/](https://archlinux.org/packages/extra/x86_64/qt6-base/files/) +14. LogViewer 1.1.2 \- Total Commander, accessed April 18, 2026, [https://totalcmd.net/plugring/LogViewer.html](https://totalcmd.net/plugring/LogViewer.html) +15. LogViewer 1.1.2 \- Total Commander, accessed April 18, 2026, [http://totalcmd.net/plugring/logviewer.html](http://totalcmd.net/plugring/logviewer.html) +21. GitHub \- variar/klogg: Really fast log explorer based on glogg project, accessed April 18, 2026, [https://github.com/variar/klogg](https://github.com/variar/klogg) +22. simdutf: Text processing at billions of characters per second \- GitHub, accessed April 18, 2026, [https://github.com/simdutf/simdutf](https://github.com/simdutf/simdutf) +23. Plugins development · doublecmd/doublecmd Wiki \- GitHub, accessed April 18, 2026, [https://github.com/doublecmd/doublecmd/wiki/Plugins-development](https://github.com/doublecmd/doublecmd/wiki/Plugins-development) +24. ghisler/WLX-SDK: Total Commander Lister Plugin Interface \- GitHub, accessed April 18, 2026, [https://github.com/ghisler/WLX-SDK](https://github.com/ghisler/WLX-SDK) +25. Little Wayland Things \- Kai Uwe's Blog, accessed April 18, 2026, [https://blog.broulik.de/2024/11/little-wayland-things/](https://blog.broulik.de/2024/11/little-wayland-things/) +26. XDG foreign protocol | Wayland Explorer, accessed April 18, 2026, [https://wayland.app/protocols/xdg-foreign-unstable-v2](https://wayland.app/protocols/xdg-foreign-unstable-v2) +27. On the Road to Plasma 6, Vol. 5 \- Kai Uwe's Blog \- Broulik, accessed April 18, 2026, [https://blog.broulik.de/2024/01/on-the-road-to-plasma-6-vol-5/](https://blog.broulik.de/2024/01/on-the-road-to-plasma-6-vol-5/) +28. QWindow Class | Qt GUI | Qt 6.11.0, accessed April 18, 2026, [https://doc.qt.io/qt-6/qwindow.html](https://doc.qt.io/qt-6/qwindow.html) \ No newline at end of file diff --git a/wlx/logview/logviewer.png b/wlx/logview/logviewer.png new file mode 100644 index 0000000..83d3ee1 Binary files /dev/null and b/wlx/logview/logviewer.png differ diff --git a/wlx/logview/src/LogModel.cpp b/wlx/logview/src/LogModel.cpp new file mode 100644 index 0000000..8aed3f0 --- /dev/null +++ b/wlx/logview/src/LogModel.cpp @@ -0,0 +1,354 @@ +#include "LogModel.h" +#include +#include +#include + +#include +#include +#include +#include +#include + +// Common timestamp patterns for tryParseTimestamp +// ISO: 2024-05-03T14:23:01 or 2024-05-03 14:23:01 +// Syslog: May 3 14:23:01 +// Nginx: 03/May/2024:14:23:01 + +static const char *kReIso = + R"((\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2}))"; +static const char *kReSyslog = + R"((\w{3})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2}))"; +static const char *kReNginx = + R"((\d{2})/(\w{3})/(\d{4}):(\d{2}):(\d{2}):(\d{2}))"; + +static int monthFromAbbr(const std::string &m) { + static const char *months[] = { + "Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec" + }; + for (int i = 0; i < 12; ++i) + if (m == months[i]) return i + 1; + return 1; +} + +// ─── Construction / Destruction ──────────────────────────────────────── + +LogModel::LogModel(QObject *parent) + : QAbstractListModel(parent) +{ + m_watcher = new QFileSystemWatcher(this); + connect(m_watcher, &QFileSystemWatcher::fileChanged, + this, &LogModel::onFileChanged); +} + +LogModel::~LogModel() { + stopSearch(); + cleanup(); +} + +void LogModel::cleanup() { + if (m_watcher && !m_filePath.isEmpty()) + m_watcher->removePath(m_filePath); + + if (m_mappedData && m_mappedData != MAP_FAILED) { + munmap(const_cast(m_mappedData), m_mappedSize); + m_mappedData = nullptr; + m_mappedSize = 0; + } + if (m_fd >= 0) { + close(m_fd); + m_fd = -1; + } + m_lineOffsets.clear(); + m_matches.clear(); + m_totalMatches = 0; +} + +// ─── QAbstractListModel overrides ────────────────────────────────────── + +int LogModel::rowCount(const QModelIndex &parent) const { + if (parent.isValid()) return 0; + return m_lineOffsets.size() > 1 + ? static_cast(m_lineOffsets.size() - 1) + : 0; +} + +int LogModel::lineCount() const { return rowCount(); } + +QString LogModel::lineText(int row) const { + if (row < 0 || row >= lineCount() || !m_mappedData) return {}; + const uint64_t start = m_lineOffsets[row]; + const uint64_t end = m_lineOffsets[row + 1]; + uint64_t len = end - start; + while (len > 0 && (m_mappedData[start + len - 1] == '\n' || + m_mappedData[start + len - 1] == '\r')) + --len; + return QString::fromUtf8(m_mappedData + start, static_cast(len)); +} + +QVariant LogModel::data(const QModelIndex &index, int role) const { + if (!index.isValid()) return {}; + const int row = index.row(); + if (row < 0 || row >= lineCount()) return {}; + + if (role == Qt::DisplayRole) + return lineText(row); + + // Highlight matched lines with a subtle background + if (role == Qt::BackgroundRole) { + if (row < (int)m_matches.size() && m_matches[row]) + return QColor(60, 60, 0); // dark yellow + } + + return {}; +} + +// ─── File loading ────────────────────────────────────────────────────── + +void LogModel::loadFile(const QString& filePath) { + beginResetModel(); + stopSearch(); + cleanup(); + + m_filePath = filePath; + const QByteArray pathBytes = filePath.toUtf8(); + + m_fd = open(pathBytes.constData(), O_RDONLY); + if (m_fd < 0) { + qWarning() << "LogModel: open failed:" << filePath; + endResetModel(); + return; + } + + struct stat st; + if (fstat(m_fd, &st) != 0 || st.st_size == 0) { + qWarning() << "LogModel: empty or stat failed:" << filePath; + close(m_fd); m_fd = -1; + endResetModel(); + return; + } + + m_mappedSize = static_cast(st.st_size); + m_mappedData = static_cast( + mmap(nullptr, m_mappedSize, PROT_READ, MAP_SHARED, m_fd, 0)); + + if (m_mappedData == MAP_FAILED) { + qWarning() << "LogModel: mmap failed:" << filePath; + m_mappedData = nullptr; m_mappedSize = 0; + close(m_fd); m_fd = -1; + endResetModel(); + return; + } + + madvise(const_cast(m_mappedData), m_mappedSize, MADV_SEQUENTIAL); + + // Build line-offset index + m_lineOffsets.reserve(m_mappedSize / 60); + m_lineOffsets.push_back(0); + for (size_t i = 0; i < m_mappedSize; ++i) { + if (m_mappedData[i] == '\n' && i + 1 < m_mappedSize) + m_lineOffsets.push_back(i + 1); + } + m_lineOffsets.push_back(m_mappedSize); // sentinel + + endResetModel(); + qDebug() << "LogModel indexed:" << filePath << "lines:" << lineCount(); + + parseTimestamps(); + + // Set up file watcher for tail + m_watcher->addPath(m_filePath); +} + +// ─── Search ──────────────────────────────────────────────────────────── + +void LogModel::startSearch(const QString& query) { + stopSearch(); // cancel any previous + + if (query.isEmpty() || lineCount() == 0) { + m_matches.clear(); + m_totalMatches = 0; + emit dataChanged(index(0), index(lineCount() - 1), {Qt::BackgroundRole}); + emit searchFinished(0); + return; + } + + m_matches.assign(lineCount(), false); + m_totalMatches = 0; + + // Launch background search with jthread + stop_token + m_searchThread = std::jthread([this, pattern = query.toStdString()] + (std::stop_token stoken) { + re2::RE2 re(pattern); + if (!re.ok()) { + QMetaObject::invokeMethod(this, [this]() { + emit searchFinished(-1); // -1 = invalid regex + }, Qt::QueuedConnection); + return; + } + + const int total = lineCount(); + int matches = 0; + + for (int i = 0; i < total; ++i) { + if (stoken.stop_requested()) break; + + const uint64_t start = m_lineOffsets[i]; + const uint64_t end = m_lineOffsets[i + 1]; + uint64_t len = end - start; + while (len > 0 && (m_mappedData[start + len - 1] == '\n' || + m_mappedData[start + len - 1] == '\r')) + --len; + + re2::StringPiece line(m_mappedData + start, len); + if (re2::RE2::PartialMatch(line, re)) { + m_matches[i] = true; + ++matches; + } + } + + m_totalMatches = matches; + + // Notify UI from main thread + QMetaObject::invokeMethod(this, [this, matches]() { + emit dataChanged(index(0), index(lineCount() - 1), {Qt::BackgroundRole}); + emit searchFinished(matches); + }, Qt::QueuedConnection); + }); +} + +void LogModel::stopSearch() { + if (m_searchThread.joinable()) { + m_searchThread.request_stop(); + m_searchThread.join(); + } +} + +bool LogModel::isMatch(int row) const { + if (row < 0 || row >= (int)m_matches.size()) return false; + return m_matches[row]; +} + +int LogModel::matchCount() const { + return m_totalMatches.load(); +} + +int LogModel::nextMatch(int fromRow) const { + const int total = lineCount(); + if (total == 0 || m_matches.empty()) return -1; + for (int i = 1; i <= total; ++i) { + int idx = (fromRow + i) % total; + if (m_matches[idx]) return idx; + } + return -1; +} + +// ─── Timestamp parsing ───────────────────────────────────────────────── + +QDateTime LogModel::tryParseTimestamp(const char *data, int len) { + re2::StringPiece sp(data, len); + int y, mo, d, h, mi, s; + std::string ms; + + // ISO 8601 + if (re2::RE2::PartialMatch(sp, kReIso, &y, &mo, &d, &h, &mi, &s)) + return QDateTime(QDate(y, mo, d), QTime(h, mi, s)); + + // Nginx / Apache: 03/May/2024:14:23:01 + if (re2::RE2::PartialMatch(sp, kReNginx, &d, &ms, &y, &h, &mi, &s)) + return QDateTime(QDate(y, monthFromAbbr(ms), d), QTime(h, mi, s)); + + // Syslog: May 3 14:23:01 (no year — use current) + if (re2::RE2::PartialMatch(sp, kReSyslog, &ms, &d, &h, &mi, &s)) + return QDateTime(QDate(QDate::currentDate().year(), monthFromAbbr(ms), d), + QTime(h, mi, s)); + + return {}; +} + +QDateTime LogModel::parseTimestampFromLine(const QString &line) { + QByteArray utf8 = line.toUtf8(); + return tryParseTimestamp(utf8.constData(), utf8.size()); +} + +void LogModel::parseTimestamps() { + m_firstTimestamp = {}; + m_lastTimestamp = {}; + if (lineCount() == 0 || !m_mappedData) return; + + // First line + { + uint64_t s = m_lineOffsets[0], e = m_lineOffsets[1]; + uint64_t len = e - s; + while (len > 0 && (m_mappedData[s+len-1]=='\n'||m_mappedData[s+len-1]=='\r')) --len; + m_firstTimestamp = tryParseTimestamp(m_mappedData + s, (int)len); + } + // Last line + { + int last = lineCount() - 1; + uint64_t s = m_lineOffsets[last], e = m_lineOffsets[last + 1]; + uint64_t len = e - s; + while (len > 0 && (m_mappedData[s+len-1]=='\n'||m_mappedData[s+len-1]=='\r')) --len; + m_lastTimestamp = tryParseTimestamp(m_mappedData + s, (int)len); + } + + if (m_firstTimestamp.isValid() || m_lastTimestamp.isValid()) + emit timestampsDetected(m_firstTimestamp, m_lastTimestamp); +} + +// ─── Follow / tail ───────────────────────────────────────────────────── + +void LogModel::setFollowEnabled(bool enabled) { + m_followEnabled = enabled; +} + +void LogModel::onFileChanged(const QString &path) { + if (path != m_filePath || !m_followEnabled) return; + + struct stat st; + if (stat(m_filePath.toUtf8().constData(), &st) != 0) return; + size_t newSize = static_cast(st.st_size); + if (newSize <= m_mappedSize) return; // file didn't grow + + size_t oldSize = m_mappedSize; + + // Unmap old, remap at new size + if (m_mappedData && m_mappedData != MAP_FAILED) + munmap(const_cast(m_mappedData), m_mappedSize); + + m_mappedSize = newSize; + m_mappedData = static_cast( + mmap(nullptr, m_mappedSize, PROT_READ, MAP_SHARED, m_fd, 0)); + + if (m_mappedData == MAP_FAILED) { + m_mappedData = nullptr; m_mappedSize = 0; + return; + } + + // Scan only the new portion for additional line offsets + // Remove the old sentinel first + if (!m_lineOffsets.empty()) + m_lineOffsets.pop_back(); + + int oldLineCount = m_lineOffsets.size() > 0 + ? static_cast(m_lineOffsets.size()) - 0 + : 0; + + for (size_t i = oldSize; i < m_mappedSize; ++i) { + if (m_mappedData[i] == '\n' && i + 1 < m_mappedSize) + m_lineOffsets.push_back(i + 1); + } + m_lineOffsets.push_back(m_mappedSize); // new sentinel + + int newLineCount = static_cast(m_lineOffsets.size()) - 1; + if (newLineCount > oldLineCount) { + beginInsertRows(QModelIndex(), oldLineCount, newLineCount - 1); + endInsertRows(); + } + + emit tailUpdated(); + + // QFileSystemWatcher may remove the path after a change; re-add it + if (!m_watcher->files().contains(m_filePath)) + m_watcher->addPath(m_filePath); +} diff --git a/wlx/logview/src/LogModel.h b/wlx/logview/src/LogModel.h new file mode 100644 index 0000000..6663f81 --- /dev/null +++ b/wlx/logview/src/LogModel.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +class LogModel : public QAbstractListModel { + Q_OBJECT +public: + explicit LogModel(QObject *parent = nullptr); + ~LogModel() override; + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + void loadFile(const QString& filePath); + + // Search — returns match count + void startSearch(const QString& query); + void stopSearch(); + bool isMatch(int row) const; + int matchCount() const; + int nextMatch(int fromRow) const; + + // Line access + QString lineText(int row) const; + int lineCount() const; + + // Timestamps detected from first/last lines + QDateTime firstTimestamp() const { return m_firstTimestamp; } + QDateTime lastTimestamp() const { return m_lastTimestamp; } + + // Follow / tail + void setFollowEnabled(bool enabled); + + // Timestamp parsing for external use (filter proxy) + static QDateTime parseTimestampFromLine(const QString &line); + +signals: + void searchFinished(int matchCount); + void timestampsDetected(const QDateTime &first, const QDateTime &last); + void tailUpdated(); + +private slots: + void onFileChanged(const QString &path); + +private: + void cleanup(); + void parseTimestamps(); + static QDateTime tryParseTimestamp(const char *data, int len); + + QString m_filePath; + + // mmap state + const char *m_mappedData = nullptr; + size_t m_mappedSize = 0; + int m_fd = -1; + + // Line offset index (sentinel at end = file size) + std::vector m_lineOffsets; + + // Search state + std::vector m_matches; + std::jthread m_searchThread; + std::atomic m_totalMatches{0}; + + // Timestamps + QDateTime m_firstTimestamp; + QDateTime m_lastTimestamp; + + // File watching + QFileSystemWatcher *m_watcher = nullptr; + bool m_followEnabled = false; +}; diff --git a/wlx/logview/src/LogViewerWidget.cpp b/wlx/logview/src/LogViewerWidget.cpp new file mode 100644 index 0000000..6b1fc23 --- /dev/null +++ b/wlx/logview/src/LogViewerWidget.cpp @@ -0,0 +1,452 @@ +#include "LogViewerWidget.h" +#include "LogModel.h" +#include +#include +#include +#include +#include +#include +#include +#include + +// ─── LogFilterProxy ──────────────────────────────────────────────────── + +LogFilterProxy::LogFilterProxy(QObject *parent) + : QSortFilterProxyModel(parent) {} + +void LogFilterProxy::setRegexFilterActive(bool active) { + beginFilterChange(); + m_regexActive = active; + endFilterChange(); +} + +void LogFilterProxy::setTimeFilterActive(bool active) { + beginFilterChange(); + m_timeActive = active; + endFilterChange(); +} + +void LogFilterProxy::setTimeRange(const QDateTime &start, const QDateTime &end) { + if (!m_timeActive) { + m_timeStart = start; + m_timeEnd = end; + return; + } + beginFilterChange(); + m_timeStart = start; + m_timeEnd = end; + endFilterChange(); +} + +void LogFilterProxy::refreshFilter() { + beginFilterChange(); + endFilterChange(); +} + +bool LogFilterProxy::filterAcceptsRow(int sourceRow, + const QModelIndex &sourceParent) const +{ + Q_UNUSED(sourceParent); + auto *src = qobject_cast(sourceModel()); + if (!src) return true; + + if (m_regexActive && !src->isMatch(sourceRow)) + return false; + + if (m_timeActive && m_timeStart.isValid() && m_timeEnd.isValid()) { + QString line = src->lineText(sourceRow); + QDateTime ts = LogModel::parseTimestampFromLine(line); + if (ts.isValid()) { + if (ts < m_timeStart || ts > m_timeEnd) + return false; + } + } + + return true; +} + +// ─── LogViewerWidget ─────────────────────────────────────────────────── + +LogViewerWidget::LogViewerWidget(QWidget *parent) + : QWidget(parent), model(new LogModel(this)) +{ + // ──────────────────────────────────────────────────────────────────── + // FOCUS LAYER 1: Preventive – NoFocus on the container itself. + // No WA_NativeWindow, no WA_ShowWithoutActivating – those create + // Wayland subsurface issues that are worse than the problem they solve. + // ──────────────────────────────────────────────────────────────────── + setFocusPolicy(Qt::NoFocus); + + QVBoxLayout *mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(0, 0, 0, 0); + + // ── Top Header ───────────────────────────────────────────────────── + QHBoxLayout *headerLayout = new QHBoxLayout(); + + searchEdit = new QLineEdit(this); + searchEdit->setPlaceholderText("Regex search..."); + headerLayout->addWidget(searchEdit); + + btnSearchStart = new QPushButton("Search / Next", this); + btnSearchStop = new QPushButton("Stop", this); + btnSearchStop->setEnabled(false); + headerLayout->addWidget(btnSearchStart); + headerLayout->addWidget(btnSearchStop); + + timeStart = new QDateTimeEdit(this); + timeEnd = new QDateTimeEdit(this); + headerLayout->addWidget(new QLabel("From:")); + headerLayout->addWidget(timeStart); + headerLayout->addWidget(new QLabel("To:")); + headerLayout->addWidget(timeEnd); + + chkFollow = new QCheckBox("Follow", this); + chkFilterMode = new QCheckBox("Filter", this); + headerLayout->addWidget(chkFollow); + headerLayout->addWidget(chkFilterMode); + + mainLayout->addLayout(headerLayout); + + // ── Filter proxy ─────────────────────────────────────────────────── + filterProxy = new LogFilterProxy(this); + filterProxy->setSourceModel(model); + + // ── Log viewport ─────────────────────────────────────────────────── + listView = new QListView(this); + listView->setModel(filterProxy); + listView->setUniformItemSizes(true); + listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); + listView->setWordWrap(false); + listView->setSelectionMode(QAbstractItemView::ExtendedSelection); + mainLayout->addWidget(listView); + + // Context menu (Copy) + listView->setContextMenuPolicy(Qt::CustomContextMenu); + connect(listView, &QWidget::customContextMenuRequested, this, [this](const QPoint &pos) { + QMenu menu(this); + QAction *copyAct = menu.addAction("Copy"); + connect(copyAct, &QAction::triggered, this, &LogViewerWidget::copySelectedLines); + menu.exec(listView->viewport()->mapToGlobal(pos)); + }); + + // ── Status bar ───────────────────────────────────────────────────── + QHBoxLayout *statusLayout = new QHBoxLayout(); + progressBar = new QProgressBar(this); + progressBar->hide(); + statusLabel = new QLabel("Ready.", this); + statusLayout->addWidget(progressBar); + statusLayout->addWidget(statusLabel); + mainLayout->addLayout(statusLayout); + + // ──────────────────────────────────────────────────────────────────── + // FOCUS LAYER 2: Set Qt::NoFocus on ALL child widgets. + // This prevents Tab traversal and click-to-focus from pulling keyboard + // focus away from Double Commander's file panel. + // We install ourselves as an event filter on every child to catch + // any FocusIn events that bypass the policy (programmatic setFocus). + // ──────────────────────────────────────────────────────────────────── + installFocusGuard(); + + // ── Connections ──────────────────────────────────────────────────── + connect(btnSearchStart, &QPushButton::clicked, + this, &LogViewerWidget::onSearchStartClicked); + connect(btnSearchStop, &QPushButton::clicked, + this, &LogViewerWidget::onSearchStopClicked); + connect(chkFollow, &QCheckBox::toggled, + this, &LogViewerWidget::onFollowToggled); + connect(chkFilterMode, &QCheckBox::toggled, + this, &LogViewerWidget::onFilterModeToggled); + connect(timeStart, &QDateTimeEdit::dateTimeChanged, + this, &LogViewerWidget::onTimeRangeChanged); + connect(timeEnd, &QDateTimeEdit::dateTimeChanged, + this, &LogViewerWidget::onTimeRangeChanged); + + connect(model, &LogModel::searchFinished, + this, &LogViewerWidget::onSearchFinished); + connect(model, &LogModel::timestampsDetected, + this, &LogViewerWidget::onTimestampsDetected); + connect(model, &LogModel::tailUpdated, + this, &LogViewerWidget::onTailUpdated); +} + +LogViewerWidget::~LogViewerWidget() { + qDebug() << "LogViewerWidget destroyed"; +} + +// ─── Focus Layer 2: installFocusGuard ────────────────────────────────── +// +// Walk the entire child tree: set NoFocus on non-input widgets. +// Input widgets (searchEdit, timeStart, timeEnd) and their internal children +// are left alone so they remain usable, but we still install our event +// filter on them for Escape/Enter handling and FocusIn interception. +// +bool LogViewerWidget::isInputWidget(QWidget *w) const { + if (!w) return false; + if (w == searchEdit || w == timeStart || w == timeEnd) return true; + // Check if w is an internal child of an input widget + if (searchEdit->isAncestorOf(w)) return true; + if (timeStart->isAncestorOf(w)) return true; + if (timeEnd->isAncestorOf(w)) return true; + return false; +} + +void LogViewerWidget::installFocusGuard() { + const auto children = findChildren(); + for (QWidget *child : children) { + child->installEventFilter(this); + // Input widgets keep their default focus policy so they remain usable + if (!isInputWidget(child)) + child->setFocusPolicy(Qt::NoFocus); + } +} + +// ─── Focus Layer 3: save / restore ───────────────────────────────────── +// +// Call restoreFocusToDC() after any operation that may have stolen focus. +// +void LogViewerWidget::restoreFocusToDC() { + if (m_savedFocusWidget) { + m_savedFocusWidget->setFocus(Qt::OtherFocusReason); + } else { + // Last resort: clear focus from anything inside our subtree + if (QWidget *fw = QApplication::focusWidget()) { + if (fw == this || fw->isAncestorOf(this) || this->isAncestorOf(fw)) + fw->clearFocus(); + } + } +} + +// ─── Focus Layer 4: Global FocusIn interceptor ───────────────────────── +// +// If ANY child widget inside our subtree receives FocusIn, we immediately +// clear it — UNLESS the user has explicitly activated an input widget +// (searchEdit, timeStart, timeEnd) via mouse click. +// +bool LogViewerWidget::eventFilter(QObject *obj, QEvent *event) { + auto *w = qobject_cast(obj); + + // ── Layer 4: Intercept FocusIn on our children ───────────────────── + if (event->type() == QEvent::FocusIn && w && this->isAncestorOf(w)) { + // Allow focus on the active input widget and its internal children + if (m_activeInput && (w == m_activeInput || m_activeInput->isAncestorOf(w))) + return false; + // Reject all other focus — restore to DC + QTimer::singleShot(0, this, [this]() { restoreFocusToDC(); }); + return false; + } + + // ── Handle ChildAdded: guard dynamically-created children ────────── + if (event->type() == QEvent::ChildAdded) { + auto *ce = static_cast(event); + if (auto *childWidget = qobject_cast(ce->child())) { + if (!isInputWidget(childWidget)) + childWidget->setFocusPolicy(Qt::NoFocus); + childWidget->installEventFilter(this); + } + } + + // ── KeyPress handling ────────────────────────────────────────────── + if (event->type() == QEvent::KeyPress) { + auto *ke = static_cast(event); + + // Escape from any input widget: deactivate and restore focus to DC + if (ke->key() == Qt::Key_Escape && m_activeInput) { + m_activeInput = nullptr; + restoreFocusToDC(); + return true; + } + + // Enter in search edit: trigger search, deactivate, restore focus + if (obj == searchEdit && (ke->key() == Qt::Key_Return || + ke->key() == Qt::Key_Enter)) { + onSearchStartClicked(); + m_activeInput = nullptr; + restoreFocusToDC(); + return true; + } + + // Ctrl+C in list view: copy + if (obj == listView && ke->matches(QKeySequence::Copy)) { + copySelectedLines(); + return true; + } + } + + // ── MousePress on input widgets: activate them temporarily ───────── + if (event->type() == QEvent::MouseButtonPress && w) { + // Determine if click is on one of our input widgets + QWidget *inputTarget = nullptr; + if (w == searchEdit || searchEdit->isAncestorOf(w)) + inputTarget = searchEdit; + else if (w == timeStart || timeStart->isAncestorOf(w)) + inputTarget = timeStart; + else if (w == timeEnd || timeEnd->isAncestorOf(w)) + inputTarget = timeEnd; + + if (inputTarget) { + m_activeInput = inputTarget; + return false; // let the click through normally + } + } + + return QWidget::eventFilter(obj, event); +} + +// ─── File loading ────────────────────────────────────────────────────── + +void LogViewerWidget::loadFile(const QString& filePath) { + qDebug() << "LogViewerWidget loading file:" << filePath; + + // FOCUS LAYER 3: Save whichever DC widget currently has focus + m_savedFocusWidget = QApplication::focusWidget(); + + currentFile = filePath; + m_lastMatchRow = -1; + m_lastSearchQuery.clear(); + m_activeInput = nullptr; + statusLabel->setText(QString("Loading %1...").arg(filePath)); + model->loadFile(filePath); + statusLabel->setText(QString("Lines: %1 | %2") + .arg(model->lineCount()).arg(filePath)); + + // FOCUS LAYER 3: Restore focus to DC after loading completes + QTimer::singleShot(0, this, [this]() { restoreFocusToDC(); }); +} + +// ─── External search trigger (from ListSearchText) ───────────────────── + +void LogViewerWidget::triggerSearch(const QString& searchString, int) { + searchEdit->setText(searchString); + onSearchStartClicked(); +} + +// ─── Search ──────────────────────────────────────────────────────────── + +void LogViewerWidget::onSearchStartClicked() { + const QString query = searchEdit->text(); + if (query.isEmpty()) return; + + if (query != m_lastSearchQuery) { + m_lastMatchRow = -1; + m_lastSearchQuery = query; + btnSearchStop->setEnabled(true); + statusLabel->setText("Searching..."); + model->startSearch(query); + return; + } + + // Same query — jump to next match + if (model->matchCount() > 0) { + int next = model->nextMatch(m_lastMatchRow); + if (next >= 0) { + m_lastMatchRow = next; + scrollToSourceRow(next); + statusLabel->setText(QString("Match at line %1 | %2 total") + .arg(next + 1).arg(model->matchCount())); + } + } +} + +void LogViewerWidget::onSearchStopClicked() { + model->stopSearch(); + btnSearchStop->setEnabled(false); + statusLabel->setText("Search interrupted"); +} + +void LogViewerWidget::onSearchFinished(int matchCount) { + btnSearchStop->setEnabled(false); + + if (matchCount < 0) { + statusLabel->setText("Invalid regex pattern"); + m_lastSearchQuery.clear(); + return; + } + + statusLabel->setText(QString("Matches: %1 / %2 lines") + .arg(matchCount).arg(model->lineCount())); + + if (matchCount > 0) { + int first = model->nextMatch(-1); + if (first >= 0) { + m_lastMatchRow = first; + scrollToSourceRow(first); + } + } + + if (chkFilterMode->isChecked()) + filterProxy->refreshFilter(); +} + +void LogViewerWidget::scrollToSourceRow(int sourceRow) { + QModelIndex srcIdx = model->index(sourceRow); + QModelIndex proxyIdx = filterProxy->mapFromSource(srcIdx); + if (proxyIdx.isValid()) { + listView->setCurrentIndex(proxyIdx); + listView->scrollTo(proxyIdx, QAbstractItemView::PositionAtCenter); + } +} + +// ─── Filter mode ─────────────────────────────────────────────────────── + +void LogViewerWidget::onFilterModeToggled(bool checked) { + filterProxy->setRegexFilterActive(checked); +} + +// ─── Timestamps ──────────────────────────────────────────────────────── + +void LogViewerWidget::onTimestampsDetected(const QDateTime &first, + const QDateTime &last) { + m_timestampsLoading = true; + if (first.isValid()) timeStart->setDateTime(first); + if (last.isValid()) timeEnd->setDateTime(last); + m_timestampsLoading = false; +} + +void LogViewerWidget::onTimeRangeChanged() { + if (m_timestampsLoading) return; + + QDateTime start = timeStart->dateTime(); + QDateTime end = timeEnd->dateTime(); + + if (start.isValid() && end.isValid() && start < end) { + filterProxy->setTimeRange(start, end); + filterProxy->setTimeFilterActive(true); + statusLabel->setText(QString("Time filter: %1 — %2") + .arg(start.toString("yyyy-MM-dd hh:mm:ss")) + .arg(end.toString("yyyy-MM-dd hh:mm:ss"))); + } +} + +// ─── Follow / tail ───────────────────────────────────────────────────── + +void LogViewerWidget::onFollowToggled(bool checked) { + model->setFollowEnabled(checked); + if (checked) + QTimer::singleShot(0, listView, &QListView::scrollToBottom); +} + +void LogViewerWidget::onTailUpdated() { + statusLabel->setText(QString("Lines: %1 | %2 (following)") + .arg(model->lineCount()).arg(currentFile)); + if (chkFollow->isChecked()) + QTimer::singleShot(0, listView, &QListView::scrollToBottom); +} + +// ─── Copy ────────────────────────────────────────────────────────────── + +void LogViewerWidget::copySelectedLines() { + QModelIndexList selected = listView->selectionModel()->selectedIndexes(); + if (selected.isEmpty()) return; + + std::sort(selected.begin(), selected.end(), + [](const QModelIndex &a, const QModelIndex &b) { + return a.row() < b.row(); + }); + + QStringList lines; + for (const QModelIndex &idx : selected) + lines << idx.data(Qt::DisplayRole).toString(); + + QApplication::clipboard()->setText(lines.join('\n')); + statusLabel->setText(QString("Copied %1 line(s)").arg(lines.size())); +} diff --git a/wlx/logview/src/LogViewerWidget.h b/wlx/logview/src/LogViewerWidget.h new file mode 100644 index 0000000..8aaa27d --- /dev/null +++ b/wlx/logview/src/LogViewerWidget.h @@ -0,0 +1,91 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class LogModel; + +// Proxy that filters rows by regex match and/or timestamp range +class LogFilterProxy : public QSortFilterProxyModel { + Q_OBJECT +public: + explicit LogFilterProxy(QObject *parent = nullptr); + + void setRegexFilterActive(bool active); + void setTimeFilterActive(bool active); + void setTimeRange(const QDateTime &start, const QDateTime &end); + void refreshFilter(); + +protected: + bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; + +private: + bool m_regexActive = false; + bool m_timeActive = false; + QDateTime m_timeStart; + QDateTime m_timeEnd; +}; + +class LogViewerWidget : public QWidget { + Q_OBJECT +public: + explicit LogViewerWidget(QWidget *parent = nullptr); + ~LogViewerWidget() override; + + void loadFile(const QString& filePath); + void triggerSearch(const QString& searchString, int searchParameter); + +protected: + bool eventFilter(QObject *obj, QEvent *event) override; + +private slots: + void onSearchStartClicked(); + void onSearchStopClicked(); + void onFollowToggled(bool checked); + void onFilterModeToggled(bool checked); + void onSearchFinished(int matchCount); + void onTimestampsDetected(const QDateTime &first, const QDateTime &last); + void onTailUpdated(); + void onTimeRangeChanged(); + void copySelectedLines(); + +private: + void scrollToSourceRow(int sourceRow); + void installFocusGuard(); // NoFocus + focusProxy on all children + void restoreFocusToDC(); // Give focus back to the saved DC widget + bool isInputWidget(QWidget *w) const; // Check if w is an input widget + + // UI Elements + QListView *listView; + QLineEdit *searchEdit; + QPushButton *btnSearchStart; + QPushButton *btnSearchStop; + QDateTimeEdit *timeStart; + QDateTimeEdit *timeEnd; + QCheckBox *chkFollow; + QCheckBox *chkFilterMode; + QProgressBar *progressBar; + QLabel *statusLabel; + + LogModel *model; + LogFilterProxy *filterProxy; + QString currentFile; + QString m_lastSearchQuery; + int m_lastMatchRow = -1; + bool m_timestampsLoading = false; + + // Focus management: save/restore DC's focused widget across file loads + QPointer m_savedFocusWidget; + QPointer m_activeInput; // currently active input widget (search/time edits) +}; diff --git a/wlx/logview/src/wlx_plugin.cpp b/wlx/logview/src/wlx_plugin.cpp new file mode 100644 index 0000000..f4d26e2 --- /dev/null +++ b/wlx/logview/src/wlx_plugin.cpp @@ -0,0 +1,106 @@ +#include "../../../sdk/wlxplugin.h" +#include +#include +#include +#include + +#include "LogViewerWidget.h" + +// Define a visibility macro for exported functions +#define EXPORT __attribute__((visibility("default"))) + +extern "C" { + +EXPORT HWND DCPCALL ListLoad(HWND ParentWin, char* FileToLoad, int ShowFlags) { + try { + qDebug() << "ListLoad called for file:" << FileToLoad; + + // On Double Commander's Qt6 widgetset, ParentWin is a QWidget* pointer + // (not an X11 window ID). Simply cast and use as the parent widget. + QWidget *parentWidget = (QWidget*)ParentWin; + + LogViewerWidget *viewer = new LogViewerWidget(parentWidget); + + // Load data immediately so the model and QFileSystemWatcher are + // set up right away. This also prevents a race where ListLoadNext + // could be called before a deferred loadFile executes. + viewer->loadFile(QString::fromUtf8(FileToLoad)); + + // FOCUS LAYER 0 — Deferred show(). + // When DC user clicks a file, DC sends MousePress, then immediately + // calls ListLoad. If we show() synchronously, the new widget can + // trap DC's MouseRelease event, causing a "phantom-drag" state where + // DC believes the mouse is still held down. + // By deferring show() by 50ms, DC finishes its mouse event processing. + QTimer::singleShot(50, viewer, [viewer]() { + viewer->show(); + }); + + return (HWND)viewer; + } catch (const std::exception& e) { + qCritical() << "ListLoad exception:" << e.what(); + } catch (...) { + qCritical() << "ListLoad unknown exception"; + } + return nullptr; +} + +EXPORT int DCPCALL ListLoadNext(HWND ParentWin, HWND PluginWin, char* FileToLoad, int ShowFlags) { + try { + qDebug() << "ListLoadNext called for file:" << FileToLoad; + + // PluginWin is the QWidget* pointer returned by ListLoad + LogViewerWidget *viewer = qobject_cast((QWidget*)PluginWin); + if (viewer) { + viewer->loadFile(QString::fromUtf8(FileToLoad)); + return LISTPLUGIN_OK; + } + } catch (const std::exception& e) { + qCritical() << "ListLoadNext exception:" << e.what(); + } catch (...) { + qCritical() << "ListLoadNext unknown exception"; + } + return LISTPLUGIN_ERROR; +} + +EXPORT void DCPCALL ListCloseWindow(HWND ListWin) { + try { + qDebug() << "ListCloseWindow called"; + // ListWin is the QWidget* pointer returned by ListLoad + QWidget *widget = (QWidget*)ListWin; + if (widget) { + delete widget; // Deterministic destruction + } + } catch (const std::exception& e) { + qCritical() << "ListCloseWindow exception:" << e.what(); + } catch (...) { + qCritical() << "ListCloseWindow unknown exception"; + } +} + +EXPORT int DCPCALL ListSearchText(HWND ListWin, char* SearchString, int SearchParameter) { + try { + qDebug() << "ListSearchText called:" << SearchString; + // ListWin is the QWidget* pointer returned by ListLoad + LogViewerWidget *viewer = qobject_cast((QWidget*)ListWin); + if (viewer) { + viewer->triggerSearch(QString::fromUtf8(SearchString), SearchParameter); + return LISTPLUGIN_OK; + } + } catch (const std::exception& e) { + qCritical() << "ListSearchText exception:" << e.what(); + } catch (...) { + qCritical() << "ListSearchText unknown exception"; + } + return LISTPLUGIN_ERROR; +} + +EXPORT void DCPCALL ListGetDetectString(char* DetectString, int maxlen) { + // Defines default extensions Double Commander will associate with this plugin. + // "No extension" cannot be easily expressed here, so users can still manually force it. + const char* detect = "EXT=\"LOG\" | EXT=\"OUT\" | EXT=\"ERR\" | EXT=\"NDJSON\" | EXT=\"JSONL\" | EXT=\"1\" | EXT=\"2\" | EXT=\"OLD\""; + strncpy(DetectString, detect, maxlen - 1); + DetectString[maxlen - 1] = '\0'; +} + +} // extern "C"