diff --git a/build.sh b/build.sh index 78deeb2..4c8597e 100755 --- a/build.sh +++ b/build.sh @@ -72,6 +72,13 @@ 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/ +# jsonview +mkdir -p release/wlx/jsonview +make -C wlx/jsonview/src clean all +install -m 644 wlx/jsonview/jsonview_qt6.wlx release/wlx/jsonview/ +cp -r wlx/jsonview/langs release/wlx/jsonview/ +install -m 644 wlx/jsonview/*.md release/wlx/jsonview/ +install -m 644 wlx/jsonview/*.png release/wlx/jsonview/ # logview mkdir -p release/wlx/logview mkdir -p wlx/logview/build diff --git a/sdk/wlxplugin.h b/sdk/wlxplugin.h index 8f33fb2..eebce8e 100644 --- a/sdk/wlxplugin.h +++ b/sdk/wlxplugin.h @@ -9,6 +9,7 @@ #define lc_newparams 2 #define lc_selectall 3 #define lc_setpercent 4 +#define lc_focus 5 #define lcp_wraptext 1 #define lcp_fittowindow 2 diff --git a/wlx/jsonview/README.md b/wlx/jsonview/README.md new file mode 100644 index 0000000..9410463 --- /dev/null +++ b/wlx/jsonview/README.md @@ -0,0 +1,59 @@ +# JSON Tree Viewer Lister Plugin for Double Commander (Linux/Wayland) + +A WLX (Lister) plugin for Double Commander built with Qt6 to visualize, navigate, edit, and export **JSON** (`.json`) files using an interactive, hierarchical Tree view. + +This plugin is a Qt port of the original work by **j2969719**. You can find the original author's repository at [https://github.com/j2969719/doublecmd-plugins](https://github.com/j2969719/doublecmd-plugins). + +--- + +## Screenshots + +### JSON Tree Representation +![JSON Tree View](jsonview1.png) + +### Expanded Structure & Values +![Expanded JSON View](jsonview2.png) + +--- + +## Features + +- **Interactive Tree Hierarchy**: Visualizes nested JSON objects and arrays in a structured, collapsible tree (`QTreeWidget`). +- **Data Columns**: + - **Node**: Keys/labels of objects or indices of arrays. + - **Value**: Element values (strings, numbers, booleans, null). + - **Type**: Automatically shows the data type (Object, Array, String, Integer, Double, Boolean, Null). +- **Inline Editing**: Double-click on any value field (Column 1) to edit the JSON data directly within Lister. +- **Saving Changes**: + - `Ctrl+S` or click **Save** to overwrite the file. + - **Save As...** to save the modified JSON to a new path. +- **Search Support**: Press `F7` (or native Lister search) to search for specific text within node keys, values, and types. Supports case sensitivity. +- **Right-Click Context Menu Actions**: + - **Copy JSONPath**: Copy the dotted path to the selected node (e.g. `store.book[0].title`) directly to the clipboard. + - **Copy Subtree**: Rasterize/serialize the selected subtree back to indented JSON text format and copy it to the clipboard. + - **Copy Key:Value**: Copy the key and value pair to the clipboard. + - **Copy Value**: Copy the exact value of the selected node to the clipboard. + +--- + +## Installation + +1. Switch to the `jsonview` branch and run `./build.sh` to compile the plugin. +2. The binary `jsonview_qt6.wlx` will be built under `release/wlx/jsonview/`. +3. In Double Commander, open **Options** -> **Plugins** -> **WLX**. +4. Click **Add** and select `/path/to/jsonview_qt6.wlx`. +5. Double Commander will register the extension string. Ensure the detect string is configured as: + ``` + (EXT="JSON") & SIZE<30000000 + ``` + +--- + +## Configuration + +The plugin configuration is stored in `j2969719.ini` inside the Double Commander settings directory. You can edit settings under the `[jsonview]` section: +- `resize_columns` (boolean): Automatically resize column widths to fit content. +- `tree_expand` (boolean): Expand all tree nodes on load. +- `column_width` (integer): Default width of columns if auto-resize is off. +- `sorting` (boolean): Enable alphabetical sorting of object keys. +- `show_filename` (boolean): Show the filename as the root node, or default to "Root". diff --git a/wlx/jsonview/jsonview1.png b/wlx/jsonview/jsonview1.png new file mode 100644 index 0000000..a6654c5 Binary files /dev/null and b/wlx/jsonview/jsonview1.png differ diff --git a/wlx/jsonview/jsonview2.png b/wlx/jsonview/jsonview2.png new file mode 100644 index 0000000..0187138 Binary files /dev/null and b/wlx/jsonview/jsonview2.png differ diff --git a/wlx/jsonview/langs/ru/LC_MESSAGES/plugins.mo b/wlx/jsonview/langs/ru/LC_MESSAGES/plugins.mo new file mode 100644 index 0000000..e3ea054 Binary files /dev/null and b/wlx/jsonview/langs/ru/LC_MESSAGES/plugins.mo differ diff --git a/wlx/jsonview/langs/ru/LC_MESSAGES/plugins.po b/wlx/jsonview/langs/ru/LC_MESSAGES/plugins.po new file mode 100644 index 0000000..80889d4 --- /dev/null +++ b/wlx/jsonview/langs/ru/LC_MESSAGES/plugins.po @@ -0,0 +1,565 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-02-07 23:08+0300\n" +"PO-Revision-Date: 2026-02-07 23:13+0300\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" +"X-Generator: Poedit 3.8\n" + +#: ../wlx/libarchive_cat_crap/src/plugin.c:65 +#: ../wlx/libarchive_cat_qt_crap/src/plugin.cpp:56 +#, c-format +msgid "libarchive: failed to read %s" +msgstr "libarchive: не удалось прочитать %s" + +#: ../wlx/libarchive_cat_crap/src/plugin.c:196 +#: ../wlx/libarchive_cat_qt_crap/src/plugin.cpp:147 +#: ../wlx/md4c_qt/src/plugin.cpp:97 ../wlx/fileinfo_qt/src/plugin.cpp:97 +#: ../wlx/htmlconv_qt_crap/src/plugin.cpp:118 +#: ../wlx/wlxwebkit/src/wlxwebkit.c:128 +#: ../wlx/yet_another_vte_plugin/src/plugin.c:288 +#: ../wlx/gtksourceview/src/gtksourceview.c:720 +#: ../wlx/sqlview_qt/src/plugin.cpp:273 +#: ../wlx/syntax-highlighting_qt/src/plugin.cpp:153 +#: ../wlx/md4c_webkit_qt/src/plugin.cpp:90 ../wlx/csvview_qt/src/plugin.cpp:307 +#: ../wlx/hx_qt_crap/src/plugin.cpp:81 ../wlx/fileinfo/src/fileinfo.c:403 +#: ../wlx/jsonview_qt/src/plugin.cpp:262 +#: ../wlx/mimescript/src/mimescriptwlx.c:309 +#: ../wlx/htmlview_qt_crap/src/plugin.cpp:55 +#: ../wlx/wlxwebkit_qt/src/wlxwebkit.cpp:167 +#: ../wlx/wlxwebkit_qt_crap/src/wlxwebkit.cpp:111 +#, c-format +msgid "\"%s\" not found!" +msgstr "\"%s\" не найден!" + +#: ../wlx/imagemagick/src/wlximagemagick.c:215 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:371 +#: ../wlx/qtpdfview_qt/src/plugin.cpp:161 +#: ../wlx/gtkimgview/src/gtkimgview.c:222 +msgid "Zoom In" +msgstr "Увеличить" + +#: ../wlx/imagemagick/src/wlximagemagick.c:220 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:376 +#: ../wlx/qtpdfview_qt/src/plugin.cpp:172 +#: ../wlx/gtkimgview/src/gtkimgview.c:227 +msgid "Zoom Out" +msgstr "Уменьшить" + +#: ../wlx/imagemagick/src/wlximagemagick.c:225 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:381 +#: ../wlx/qtpdfview_qt/src/plugin.cpp:183 +#: ../wlx/gtkimgview/src/gtkimgview.c:232 +msgid "Original Size" +msgstr "Исходный размер" + +#: ../wlx/imagemagick/src/wlximagemagick.c:230 +#: ../wlx/wlxpview/src/wlxPView.c:469 ../wlx/wlxpview/src/wlxPView.c:470 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:386 +#: ../wlx/qtpdfview_qt/src/plugin.cpp:196 +#: ../wlx/gtkimgview/src/gtkimgview.c:237 +msgid "Fit" +msgstr "Вместить" + +#: ../wlx/imagemagick/src/wlximagemagick.c:237 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:393 +#: ../wlx/gtkimgview/src/gtkimgview.c:244 +msgid "Copy to Clipboard" +msgstr "Копировать в буфер" + +#: ../wlx/imagemagick/src/wlximagemagick.c:240 +#: ../wlx/imagemagick/src/wlximagemagick.c:243 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:396 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:399 +#: ../wlx/gtkimgview/src/gtkimgview.c:247 +#: ../wlx/gtkimgview/src/gtkimgview.c:250 +msgid "Rotate" +msgstr "Повернуть" + +#: ../wlx/imagemagick/src/wlximagemagick.c:246 +#: ../wlx/imagemagick/src/wlximagemagick.c:249 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:402 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:405 +#: ../wlx/gtkimgview/src/gtkimgview.c:253 +#: ../wlx/gtkimgview/src/gtkimgview.c:256 +msgid "Rotate Clockwise" +msgstr "Повернуть по часовой стрелке" + +#: ../wlx/imagemagick/src/wlximagemagick.c:252 +#: ../wlx/imagemagick/src/wlximagemagick.c:255 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:408 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:411 +#: ../wlx/gtkimgview/src/gtkimgview.c:259 +#: ../wlx/gtkimgview/src/gtkimgview.c:262 +msgid "Flip Horizontally" +msgstr "Отразить по горизонтали" + +#: ../wlx/imagemagick/src/wlximagemagick.c:258 +#: ../wlx/imagemagick/src/wlximagemagick.c:261 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:414 +#: ../wlx/gtkimgview_crap/src/gtkimgview.c:417 +#: ../wlx/gtkimgview/src/gtkimgview.c:265 +#: ../wlx/gtkimgview/src/gtkimgview.c:268 +msgid "Flip Vertically" +msgstr "Отразить по вертикали" + +#: ../wlx/dirsize_crap/src/plugin.c:245 +msgid "Name" +msgstr "Название" + +#: ../wlx/dirsize_crap/src/plugin.c:258 +msgid "Size" +msgstr "Размер" + +#: ../wlx/wlxpview/src/wlxPView.c:139 +msgid "Scale ~x" +msgstr "Масштаб ~x" + +#: ../wlx/wlxpview/src/wlxPView.c:183 ../wlx/wlxpview/src/wlxPView.c:438 +#: ../wlx/wlxpview/src/wlxPView.c:439 ../wlx/qtpdfview_qt/src/plugin.cpp:140 +msgid "Go to..." +msgstr "Перейти..." + +#: ../wlx/wlxpview/src/wlxPView.c:193 ../wlx/qtpdfview_qt/src/plugin.cpp:148 +msgid "Page number to go to:" +msgstr "Страница на которую перейти:" + +#: ../wlx/wlxpview/src/wlxPView.c:296 ../wlx/wlxpview/src/wlxPView.c:477 +#: ../wlx/wlxpview/src/wlxPView.c:478 +msgid "Text" +msgstr "Текст" + +#: ../wlx/wlxpview/src/wlxPView.c:324 ../wlx/wlxpview/src/wlxPView.c:483 +#: ../wlx/wlxpview/src/wlxPView.c:484 ../wlx/qtpdfview_qt/src/plugin.cpp:222 +msgid "Info" +msgstr "Информация" + +#: ../wlx/wlxpview/src/wlxPView.c:328 +#, c-format +msgid "" +"Title: %s\n" +"Author: %s\n" +"Subject: %s\n" +"Creator: %s\n" +"Producer: %s\n" +"Keywords: %s\n" +"Version: %s" +msgstr "" +"Название: %s\n" +"Автор: %s\n" +"Тема: %s\n" +"Создатель: %s\n" +"Сегнерировано: %s\n" +"Ключевые слова: %s\n" +"Версия: %s" + +#: ../wlx/wlxpview/src/wlxPView.c:346 +msgid "metadata not found" +msgstr "Метаданные не найдены" + +#: ../wlx/wlxpview/src/wlxPView.c:414 ../wlx/wlxpview/src/wlxPView.c:415 +#: ../wlx/qtpdfview_qt/src/plugin.cpp:60 +msgid "First page" +msgstr "Первая страница" + +#: ../wlx/wlxpview/src/wlxPView.c:420 ../wlx/wlxpview/src/wlxPView.c:421 +#: ../wlx/qtpdfview_qt/src/plugin.cpp:72 +msgid "Previous page" +msgstr "Предыдущая страница" + +#: ../wlx/wlxpview/src/wlxPView.c:426 ../wlx/wlxpview/src/wlxPView.c:427 +#: ../wlx/qtpdfview_qt/src/plugin.cpp:87 +msgid "Next page" +msgstr "Следующая страница" + +#: ../wlx/wlxpview/src/wlxPView.c:432 ../wlx/wlxpview/src/wlxPView.c:433 +#: ../wlx/qtpdfview_qt/src/plugin.cpp:102 +msgid "Last page" +msgstr "Последняя страница" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:208 +msgid "Page Mode" +msgstr "Режим страницы" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:228 +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:54 +msgid "Author" +msgstr "" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:229 +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:50 +msgid "Title" +msgstr "" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:230 +msgid "Subject" +msgstr "" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:231 +msgid "Producer" +msgstr "" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:232 +msgid "Creator" +msgstr "" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:233 +msgid "Keywords" +msgstr "" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:234 +msgid "Creation Date" +msgstr "" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:235 +msgid "Modification Date" +msgstr "" + +#: ../wlx/qtpdfview_qt/src/plugin.cpp:255 +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:468 +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:524 +msgid "no suitable info available" +msgstr "" + +#: ../wlx/sqlview_gtk2/src/plugin.c:124 ../wlx/sqlview_gtk2/src/plugin.c:222 +#: ../wlx/sqlview_qt/src/plugin.cpp:97 +msgid "Query" +msgstr "" + +#: ../wlx/sqlview_gtk2/src/plugin.c:132 ../wlx/sqlview_qt/src/plugin.cpp:131 +msgid "Please enter a new query and press OK to execute it" +msgstr "" + +#: ../wlx/sqlview_gtk2/src/plugin.c:227 ../wlx/sqlview_qt/src/plugin.cpp:89 +msgid "Table:" +msgstr "" + +#: ../wlx/gtksourceview/src/gtksourceview.c:52 +msgid "Failed to load file" +msgstr "Не удалось открыть файл" + +#: ../wlx/gtksourceview/src/gtksourceview.c:53 +#: ../wlx/gtksourceview/src/gtksourceview.c:360 +#: ../wlx/gtksourceview/src/gtksourceview.c:563 +msgid "Default" +msgstr "По умолчанию" + +#: ../wlx/gtksourceview/src/gtksourceview.c:54 +#: ../wlx/gtksourceview/src/gtksourceview.c:380 +msgid "Encoding:" +msgstr "Кодировка:" + +#: ../wlx/gtksourceview/src/gtksourceview.c:200 +#: ../wlx/gtksourceview/src/gtksourceview.c:590 +msgid "Options" +msgstr "Настройки" + +#: ../wlx/gtksourceview/src/gtksourceview.c:207 +msgid "Font" +msgstr "Шрифт" + +#: ../wlx/gtksourceview/src/gtksourceview.c:215 +msgid "Style" +msgstr "Тема" + +#: ../wlx/gtksourceview/src/gtksourceview.c:232 +msgid "Blank space" +msgstr "Отсупы" + +#: ../wlx/gtksourceview/src/gtksourceview.c:238 +msgid "Above paragraphs" +msgstr "Над текстом" + +#: ../wlx/gtksourceview/src/gtksourceview.c:239 +msgid "Below paragraphs" +msgstr "Под текстом" + +#: ../wlx/gtksourceview/src/gtksourceview.c:240 +msgid "Tab width" +msgstr "Ширина табуляции" + +#: ../wlx/gtksourceview/src/gtksourceview.c:260 +msgid "Enca Lang" +msgstr "Локаль Enca" + +#: ../wlx/gtksourceview/src/gtksourceview.c:336 +msgid "Language:" +msgstr "Язык:" + +#: ../wlx/gtksourceview/src/gtksourceview.c:356 +msgid "file seems empty" +msgstr "походу файл пустой" + +#: ../wlx/gtksourceview/src/gtksourceview.c:573 +msgid "Custom encoding" +msgstr "Другая кодировка" + +#: ../wlx/gtksourceview/src/gtksourceview.c:597 +msgid "Draw Spaces" +msgstr "Пробелы" + +#: ../wlx/gtksourceview/src/gtksourceview.c:598 +msgid "Text Cursor" +msgstr "Курсор" + +#: ../wlx/gtksourceview/src/gtksourceview.c:599 +msgid "Line Numbers" +msgstr "Номера строк" + +#: ../wlx/gtksourceview/src/gtksourceview.c:600 +msgid "Highlight Line" +msgstr "Подсветка строки" + +#: ../wlx/gtksourceview/src/gtksourceview.c:601 +msgid "Wrap Line" +msgstr "Разрывы" + +#: ../wlx/sqlview_qt/src/plugin.cpp:25 +msgid "base not valid!" +msgstr "" + +#: ../wlx/sqlview_qt/src/plugin.cpp:143 +msgid "Failed to fetch list of tables. Maybe DB is locked?" +msgstr "" + +#: ../wlx/gtkimgview/src/gtkimgview.c:273 +msgid "Play Animation" +msgstr "Воспроизвести анимацию" + +#: ../wlx/gtkimgview/src/gtkimgview.c:278 +msgid "Stop Animation" +msgstr "Остановить анимацию" + +#: ../wlx/jsonview_gtk2/src/plugin.c:64 ../wlx/jsonview_qt/src/plugin.cpp:57 +#: ../wlx/jsonview_qt/src/plugin.cpp:160 +msgid "Object" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:70 ../wlx/jsonview_qt/src/plugin.cpp:64 +#: ../wlx/jsonview_qt/src/plugin.cpp:165 +msgid "Array" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:78 ../wlx/jsonview_qt/src/plugin.cpp:71 +msgid "String" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:85 ../wlx/jsonview_qt/src/plugin.cpp:83 +msgid "Integer" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:92 ../wlx/jsonview_qt/src/plugin.cpp:88 +msgid "Double" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:96 ../wlx/jsonview_qt/src/plugin.cpp:101 +msgid "True" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:96 ../wlx/jsonview_qt/src/plugin.cpp:103 +msgid "False" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:99 ../wlx/jsonview_qt/src/plugin.cpp:98 +msgid "Boolean" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:102 ../wlx/jsonview_qt/src/plugin.cpp:116 +msgid "Undefined" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:107 ../wlx/jsonview_qt/src/plugin.cpp:110 +msgid "Null" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:164 ../wlx/jsonview_gtk2/src/plugin.c:261 +#: ../wlx/jsonview_qt/src/plugin.cpp:156 +msgid "Root" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:183 ../wlx/jsonview_qt/src/plugin.cpp:183 +msgid "Node" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:195 ../wlx/jsonview_qt/src/plugin.cpp:183 +msgid "Value" +msgstr "" + +#: ../wlx/jsonview_gtk2/src/plugin.c:207 ../wlx/jsonview_qt/src/plugin.cpp:183 +msgid "Type" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:49 +msgid "Artist" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:51 +msgid "Album" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:52 +msgid "Track Number" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:53 +msgid "Album Artist" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:55 +msgid "Composer" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:56 +msgid "Lead Performer" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:57 +msgid "Publisher" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:58 +msgid "Genre" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:59 +msgid "Duration" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:60 +msgid "Description" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:61 +msgid "Copyright" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:62 +msgid "Comment" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:63 +msgid "Resolution" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:64 +msgid "Media Type" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:65 +msgid "Video Codec" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:66 +msgid "Video FrameRate" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:67 +msgid "Video BitRate" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:68 +msgid "Audio Codec" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:69 +msgid "Audio BitRate" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:70 +msgid "Date" +msgstr "" + +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:448 +#: ../wlx/qmediaplayer_qt/src/plugin.cpp:508 +#, c-format +msgid "%'d bps" +msgstr "" + +#: ../wlx/fontview_qt/src/plugin.cpp:16 +msgid "the quick brown fox jumps over the lazy dog" +msgstr "съешь ещё этих мягких французских булок да выпей чаю, belzebub" + +#: ../wlx/fontview_qt/src/plugin.cpp:36 +msgid "Bold" +msgstr "Жирный" + +#: ../wlx/fontview_qt/src/plugin.cpp:37 +msgid "Italic" +msgstr "Курсив" + +#: ../wdx/fewfiles/src/plugin.c:44 +#, c-format +msgid "Permission denied" +msgstr "Доступ запрещен" + +#: ../wdx/fewfiles/src/plugin.c:74 +#, c-format +msgid "Empty directory" +msgstr "Пустой каталог" + +#: ../wfx/trash_crap/src/plugin.c:120 +#, c-format +msgid "Restore %s from trash?" +msgstr "Восстановить %s из корзины?" + +#: ../wfx/trash_crap/src/plugin.c:135 +msgid "Already exists, overwrite?" +msgstr "Уже существует. Перезаписать?" + +#: ../wfx/trash_crap/src/plugin.c:153 ../wfx/trash_crap/src/plugin.c:185 +msgid "Failed to get information about the removed object." +msgstr "Не удалось получить информацию об удаленном объекте." + +#: ../wfx/trash_crap/src/plugin.c:168 +msgid "Original Path:" +msgstr "Оригинальный путь:" + +#: ../wfx/trash_crap/src/plugin.c:169 +msgid "Deletion Date:" +msgstr "Дата удаления:" + +#: ../wfx/trash_crap/src/plugin.c:177 ../wfx/trash_crap/src/plugin.c:178 +#: ../wfx/trash_crap/src/plugin.c:179 ../wfx/trash_crap/src/plugin.c:180 +#: ../wfx/trash_crap/src/plugin.c:181 ../wfx/trash_crap/src/plugin.c:182 +#: ../wfx/trash_crap/src/plugin.c:183 ../wfx/trash_crap/src/plugin.c:184 +msgid "Unknown" +msgstr "Неизвесто" + +#: ../wfx/trash_crap/src/plugin.c:560 +msgid "Path" +msgstr "Путь" + +#: ../wfx/trash_crap/src/plugin.c:568 +msgid "Trash (WFX)" +msgstr "Корзина (WFX)" + +#: ../dsx/git_untracked/src/plugin.c:57 ../dsx/lslocks/src/plugin.c:58 +#: ../dsx/git_ignored/src/plugin.c:57 ../dsx/git_modified/src/plugin.c:58 +msgid "failed to launch command" +msgstr "не удалось выполнить команду" + +#: ../dsx/git_untracked/src/plugin.c:60 +#: ../dsx/tracker_textsearch/src/plugin.c:84 +#: ../dsx/recollq_crap/src/plugin.c:108 ../dsx/locate_crap/src/plugin.c:134 +#: ../dsx/gtkrecent/src/plugin.c:56 ../dsx/lslocks/src/plugin.c:61 +#: ../dsx/tracker3_crap/src/plugin.c:104 ../dsx/git_ignored/src/plugin.c:60 +#: ../dsx/git_modified/src/plugin.c:61 +msgid "not found" +msgstr "не найдено" + +#: ../dsx/recollq_crap/src/plugin.c:79 ../dsx/tracker3_crap/src/plugin.c:78 +msgid "the search text was not specified" +msgstr "искомый текст не указан" diff --git a/wlx/jsonview/src/Makefile b/wlx/jsonview/src/Makefile new file mode 100644 index 0000000..ef43dcd --- /dev/null +++ b/wlx/jsonview/src/Makefile @@ -0,0 +1,31 @@ +CXX = g++ +CXXFLAGS = -shared -fPIC -Wl,--no-as-needed + +libs := +includes := -I../../../sdk + +qt5_libs := `pkg-config --cflags --libs Qt5Widgets` +qt6_libs := `pkg-config --cflags --libs Qt6Widgets Qt6Core Qt6Gui` + +plugdir := $(shell basename '$(realpath ..)') +plugtype := $(shell basename '$(realpath ../..)') +plugname := $(plugdir).$(plugtype) +plugdescr := `grep '$(plugtype)/$(plugdir))' ../../../../plugins.md -1 | tail -1 | sed 's/[[]//' | sed 's/[]][\(][^\)]\+.//' | sed 's/\s[\(].\+[\)]//'` +plugfiles := $(filter-out $(wildcard ../*.$(plugtype)), $(wildcard ../*)) + +detectstring := + + +all: qt6 + +qt6: + $(CXX) $(CXXFLAGS) -o '../$(plugdir)_$@.$(plugtype)' plugin.cpp $(libs) $($@_libs) $(includes) -D'PLUGNAME="'$(plugdir)_$@.$(plugtype)'"' -D'DETECT_STRING="$(detectstring)"' -D'PLUGTARGET="$@"' || echo '$(plugdir)_$@.$(plugtype)' >> ../../../dist/.build_fail.lst + +dist: + test -f '../$(plugdir)_qt6.$(plugtype)' && \ + echo -e "[plugininstall]\ndescription=$(plugdescr)\ntype=$(plugtype)\nfile=$(plugdir)_qt6.$(plugtype)\ndefaultdir=$(plugdir)" > ../pluginst.inf && \ + tar --exclude=../src -h -cvzf '../../../dist/$(plugtype)_$(plugdir)_qt6_$(shell date +%y.%m.%d).tar.gz' ../pluginst.inf '../$(plugdir)_qt6.$(plugtype)' $(plugfiles) && \ + rm ../pluginst.inf || echo $(plugdir)_qt6.$(plugtype) >> ../../../dist/.missing.log + +clean: + $(RM) $(wildcard ../*.$(plugtype)) diff --git a/wlx/jsonview/src/plugin.cpp b/wlx/jsonview/src/plugin.cpp new file mode 100644 index 0000000..dbbb179 --- /dev/null +++ b/wlx/jsonview/src/plugin.cpp @@ -0,0 +1,687 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#define _(STRING) gettext(STRING) +#define GETTEXT_PACKAGE "plugins" + +#include "wlxplugin.h" + +static int g_width = 200; +static bool g_resize = false; +static bool g_expand = true; +static bool g_sorting = false; +static bool g_filename = true; + +class ValueDelegate : public QStyledItemDelegate { +public: + ValueDelegate(QObject *parent = nullptr) : QStyledItemDelegate(parent) {} + QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override { + if (index.column() != 1) return nullptr; // Only allow editing column 1 + return QStyledItemDelegate::createEditor(parent, option, index); + } +}; + +class JsonViewerWidget : public QWidget +{ +public: + explicit JsonViewerWidget(QWidget *parent = nullptr); + ~JsonViewerWidget(); + + bool loadFile(const QString& filePath); + void saveFile(const QString& filePath); + + QTreeWidget* view() const { return m_view; } + +protected: + bool eventFilter(QObject *obj, QEvent *event) override; + +private: + void onSave(); + void onSaveAs(); + void onReload(); + void showContextMenu(const QPoint &pos); + + void installFocusGuard(); + bool isInputWidget(QWidget *w) const; + void restoreFocusToDC(); + + void walk_array(const QJsonArray& array, QTreeWidgetItem *item); + void walk_object(const QJsonObject& object, QTreeWidgetItem *item); + void check_value(const QJsonValue& value, QTreeWidgetItem *item); + QJsonValue treeToJson(QTreeWidgetItem *item); + + QString getJsonPath(QTreeWidgetItem *item); + QString getSubtreeText(QTreeWidgetItem *item); + QString getValueText(QTreeWidgetItem *item); + + QTreeWidget *m_view; + QToolBar *m_toolbar; + QString m_currentFile; + + QPointer m_savedFocusWidget; + QPointer m_activeInput; +}; + +JsonViewerWidget::JsonViewerWidget(QWidget *parent) + : QWidget(parent), m_savedFocusWidget(nullptr), m_activeInput(nullptr) +{ + setFocusPolicy(Qt::NoFocus); + + QVBoxLayout *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + + m_toolbar = new QToolBar(this); + m_toolbar->setFocusPolicy(Qt::NoFocus); + + QAction *actSave = m_toolbar->addAction("Save"); + actSave->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_S)); + + QAction *actSaveAs = m_toolbar->addAction("Save As..."); + QAction *actReload = m_toolbar->addAction("Reload"); + + addAction(actSave); + + layout->addWidget(m_toolbar); + + m_view = new QTreeWidget(this); + m_view->setFocusPolicy(Qt::NoFocus); + m_view->setItemDelegate(new ValueDelegate(this)); + m_view->setContextMenuPolicy(Qt::CustomContextMenu); + layout->addWidget(m_view); + + QObject::connect(actSave, &QAction::triggered, this, [this]() { onSave(); }); + QObject::connect(actSaveAs, &QAction::triggered, this, [this]() { onSaveAs(); }); + QObject::connect(actReload, &QAction::triggered, this, [this]() { onReload(); }); + QObject::connect(m_view, &QWidget::customContextMenuRequested, this, [this](const QPoint &pos) { showContextMenu(pos); }); + + installFocusGuard(); +} + +JsonViewerWidget::~JsonViewerWidget() +{ + if (qApp) qApp->removeEventFilter(this); +} + +void JsonViewerWidget::installFocusGuard() +{ + if (qApp) qApp->installEventFilter(this); + const auto children = findChildren(); + for (QWidget *child : children) { + if (!isInputWidget(child)) + child->setFocusPolicy(Qt::NoFocus); + } +} + +bool JsonViewerWidget::isInputWidget(QWidget *w) const +{ + if (!w) return false; + if (w != m_view && m_view->isAncestorOf(w)) return true; + return false; +} + +void JsonViewerWidget::restoreFocusToDC() +{ + if (QWidget *fw = QApplication::focusWidget()) { + if (fw == this || fw->isAncestorOf(this) || this->isAncestorOf(fw)) + fw->clearFocus(); + } + if (m_savedFocusWidget) { + m_savedFocusWidget->setFocus(Qt::OtherFocusReason); + } +} + +bool JsonViewerWidget::eventFilter(QObject *obj, QEvent *event) +{ + if (event->type() == QEvent::MouseButtonPress) { + QWidget *w = qobject_cast(obj); + if (w) { + if (w != this && !this->isAncestorOf(w)) { + m_activeInput = nullptr; + restoreFocusToDC(); + } + } + } + + QWidget *w = qobject_cast(obj); + if (w && (w == this || this->isAncestorOf(w))) { + if (event->type() == QEvent::FocusIn) { + if (isInputWidget(w)) { + m_activeInput = w; + return false; + } + QTimer::singleShot(0, this, [this]() { restoreFocusToDC(); }); + return false; + } + + if (event->type() == QEvent::ChildAdded) { + auto *ce = static_cast(event); + if (auto *childWidget = qobject_cast(ce->child())) { + if (!isInputWidget(childWidget)) + childWidget->setFocusPolicy(Qt::NoFocus); + } + } + + if (event->type() == QEvent::KeyPress) { + auto *ke = static_cast(event); + if ((ke->modifiers() & Qt::ControlModifier) && ke->key() == Qt::Key_S) { + onSave(); + return true; + } + if (ke->key() == Qt::Key_Escape && m_activeInput) { + m_activeInput = nullptr; + restoreFocusToDC(); + return true; + } + if ((ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) && isInputWidget(w)) { + QTimer::singleShot(0, this, [this]() { + m_activeInput = nullptr; + restoreFocusToDC(); + }); + } + } + } + + return QWidget::eventFilter(obj, event); +} + +void JsonViewerWidget::walk_array(const QJsonArray& array, QTreeWidgetItem *item) +{ + for (int i = 0; i < array.count(); i++) + { + QTreeWidgetItem *newitem = new QTreeWidgetItem(item); + newitem->setText(0, QString("[%1]").arg(i)); + newitem->setFlags(newitem->flags() | Qt::ItemIsEditable); + check_value(array.at(i), newitem); + } +} + +void JsonViewerWidget::walk_object(const QJsonObject& object, QTreeWidgetItem *item) +{ + QJsonObject::const_iterator iter; + for (iter = object.constBegin(); iter != object.constEnd(); ++iter) + { + QTreeWidgetItem *newitem = new QTreeWidgetItem(item); + newitem->setText(0, iter.key()); + newitem->setToolTip(0, iter.key()); + newitem->setFlags(newitem->flags() | Qt::ItemIsEditable); + check_value(iter.value(), newitem); + } +} + +void JsonViewerWidget::check_value(const QJsonValue& value, QTreeWidgetItem *item) +{ + double d; + switch (value.type()) + { + case QJsonValue::Object: + item->setText(2, _("Object")); + walk_object(value.toObject(), item); + break; + case QJsonValue::Array: + item->setText(2, _("Array")); + walk_array(value.toArray(), item); + break; + case QJsonValue::String: + item->setText(2, _("String")); + item->setText(1, value.toString()); + item->setToolTip(1, value.toString()); + break; + case QJsonValue::Double: + d = value.toDouble(); + if (trunc(d) == d) + { + item->setText(2, _("Integer")); + item->setText(1, QString::number(d, 'f', 0)); + } + else + { + item->setText(2, _("Double")); + item->setText(1, QString::number(d)); + } + item->setToolTip(1, QString::number(d, 'f', 1)); + break; + case QJsonValue::Bool: + item->setText(2, _("Boolean")); + if (value.toBool()) + item->setText(1, _("True")); + else + item->setText(1, _("False")); + break; + case QJsonValue::Null: + item->setText(2, _("Null")); + break; + default: + item->setText(2, _("Undefined")); + break; + } +} + +QJsonValue JsonViewerWidget::treeToJson(QTreeWidgetItem *item) +{ + QString type = item->text(2); + if (type == _("Object")) { + QJsonObject obj; + for (int i = 0; i < item->childCount(); ++i) { + QTreeWidgetItem *child = item->child(i); + obj.insert(child->text(0), treeToJson(child)); + } + return obj; + } else if (type == _("Array")) { + QJsonArray arr; + for (int i = 0; i < item->childCount(); ++i) { + QTreeWidgetItem *child = item->child(i); + arr.append(treeToJson(child)); + } + return arr; + } else if (type == _("String")) { + return QJsonValue(item->text(1)); + } else if (type == _("Integer") || type == _("Double")) { + bool ok; + double val = item->text(1).toDouble(&ok); + if (ok) return QJsonValue(val); + return QJsonValue(item->text(1)); // Fallback to string + } else if (type == _("Boolean")) { + QString val = item->text(1).toLower(); + return QJsonValue(val == "true" || val == "1"); + } else if (type == _("Null")) { + return QJsonValue(QJsonValue::Null); + } + return QJsonValue(); +} + +bool JsonViewerWidget::loadFile(const QString& filePath) +{ + QWidget *fw = QApplication::focusWidget(); + if (fw && fw != this && !this->isAncestorOf(fw)) { + m_savedFocusWidget = fw; + } + m_currentFile = filePath; + m_activeInput = nullptr; + + QMimeDatabase db; + QMimeType type = db.mimeTypeForFile(filePath); + + if (type.name() != "text/plain" && type.name() != "application/json") + return false; + + QFile file(filePath); + if (!file.open(QFile::ReadOnly | QFile::Text)) + return false; + + QJsonParseError err; + QJsonDocument json = QJsonDocument().fromJson(file.readAll(), &err); + file.close(); + + if (json.isNull() || json.isEmpty()) { + return false; + } + + m_view->clear(); + m_view->setColumnCount(3); + + QFileInfo fi(filePath); + QTreeWidgetItem *root = new QTreeWidgetItem(m_view); + + if (g_filename) + root->setText(0, fi.fileName()); + else + root->setText(0, _("Root")); + + root->setFlags(root->flags() | Qt::ItemIsEditable); + + if (json.isObject()) { + root->setText(2, _("Object")); + walk_object(json.object(), root); + } else if (json.isArray()) { + root->setText(2, _("Array")); + walk_array(json.array(), root); + } + + m_view->insertTopLevelItem(0, root); + + if (g_expand) + m_view->expandAll(); + + for (int i = 0; i < 3; i++) { + if (g_resize) + m_view->resizeColumnToContents(i); + else + m_view->setColumnWidth(i, g_width); + } + + QStringList headers; + headers << _("Node") << _("Value") << _("Type"); + m_view->setHeaderLabels(headers); + + m_view->setSelectionMode(QAbstractItemView::SingleSelection); + m_view->setSelectionBehavior(QAbstractItemView::SelectItems); + + if (g_sorting) + m_view->setSortingEnabled(true); + + QTimer::singleShot(0, this, [this]() { restoreFocusToDC(); }); + return true; +} + +void JsonViewerWidget::onSave() +{ + if (QWidget *fw = QApplication::focusWidget()) { + if (m_view->isAncestorOf(fw)) { + fw->clearFocus(); + } + } + saveFile(m_currentFile); + + m_activeInput = nullptr; + restoreFocusToDC(); +} + +void JsonViewerWidget::onSaveAs() +{ + QString path = QFileDialog::getSaveFileName(this, "Save JSON As", m_currentFile); + if (!path.isEmpty()) { + saveFile(path); + } +} + +void JsonViewerWidget::onReload() +{ + if (QWidget *fw = QApplication::focusWidget()) { + if (m_view->isAncestorOf(fw)) { + fw->clearFocus(); + } + } + loadFile(m_currentFile); +} + +void JsonViewerWidget::saveFile(const QString& filePath) +{ + QTreeWidgetItem *root = m_view->topLevelItem(0); + if (!root) return; + + QJsonDocument doc; + QJsonValue rootVal = treeToJson(root); + if (rootVal.isObject()) doc.setObject(rootVal.toObject()); + else if (rootVal.isArray()) doc.setArray(rootVal.toArray()); + + QFile file(filePath); + if (!file.open(QFile::WriteOnly | QFile::Text)) { + QMessageBox::warning(this, "Error", "Could not open file for writing."); + return; + } + + file.write(doc.toJson(QJsonDocument::Indented)); + file.close(); + m_currentFile = filePath; +} + +QString JsonViewerWidget::getJsonPath(QTreeWidgetItem *item) +{ + QString path; + while (item) { + QString key = item->text(0); + if (item->parent()) { + if (item->parent()->text(2) == _("Array")) { + path = key + path; + } else { + path = "." + key + path; + } + } else { + path = key + path; + } + item = item->parent(); + } + return path; +} + +QString JsonViewerWidget::getSubtreeText(QTreeWidgetItem *item) +{ + QJsonValue val = treeToJson(item); + if (item->parent() && item->parent()->text(2) == _("Object")) { + QJsonObject wrapper; + wrapper.insert(item->text(0), val); + QJsonDocument doc(wrapper); + QString jsonStr = QString::fromUtf8(doc.toJson(QJsonDocument::Indented)).trimmed(); + + if (jsonStr.startsWith("{") && jsonStr.endsWith("}")) { + int firstNewline = jsonStr.indexOf('\n'); + int lastNewline = jsonStr.lastIndexOf('\n'); + if (firstNewline != -1 && lastNewline != -1 && firstNewline < lastNewline) { + jsonStr = jsonStr.mid(firstNewline + 1, lastNewline - firstNewline - 1); + } else { + jsonStr = jsonStr.mid(1, jsonStr.length() - 2); + } + } + + QStringList lines = jsonStr.split('\n'); + for (int i = 0; i < lines.size(); ++i) { + if (lines[i].startsWith(" ")) { + lines[i] = lines[i].mid(4); + } + } + return lines.join('\n').trimmed(); + } else if (item->parent() && item->parent()->text(2) == _("Array")) { + QJsonArray wrapper; + wrapper.append(val); + QJsonDocument doc(wrapper); + return QString::fromUtf8(doc.toJson(QJsonDocument::Indented)).trimmed(); + } else { + if (val.isObject()) { + return QString::fromUtf8(QJsonDocument(val.toObject()).toJson(QJsonDocument::Indented)).trimmed(); + } else if (val.isArray()) { + return QString::fromUtf8(QJsonDocument(val.toArray()).toJson(QJsonDocument::Indented)).trimmed(); + } else { + return item->text(1); + } + } +} + +QString JsonViewerWidget::getValueText(QTreeWidgetItem *item) +{ + QJsonValue val = treeToJson(item); + if (val.isObject()) { + return QString::fromUtf8(QJsonDocument(val.toObject()).toJson(QJsonDocument::Indented)).trimmed(); + } else if (val.isArray()) { + return QString::fromUtf8(QJsonDocument(val.toArray()).toJson(QJsonDocument::Indented)).trimmed(); + } else { + return item->text(1); + } +} + +void JsonViewerWidget::showContextMenu(const QPoint &pos) +{ + QTreeWidgetItem *item = m_view->itemAt(pos); + if (!item) return; + + QMenu menu(this); + QAction *actCopyPath = menu.addAction("Copy JSONPath"); + + bool isComplex = (item->text(2) == _("Object") || item->text(2) == _("Array")); + QAction *actCopySubtree = nullptr; + QAction *actCopyKeyValue = nullptr; + + if (isComplex) { + actCopySubtree = menu.addAction("Copy Subtree"); + } else { + actCopyKeyValue = menu.addAction("Copy Key:Value"); + } + + QAction *actCopyValue = menu.addAction("Copy Value"); + + QAction *res = menu.exec(m_view->viewport()->mapToGlobal(pos)); + if (res == actCopyPath) { + QApplication::clipboard()->setText(getJsonPath(item)); + } else if (actCopySubtree && res == actCopySubtree) { + QApplication::clipboard()->setText(getSubtreeText(item)); + } else if (actCopyKeyValue && res == actCopyKeyValue) { + QApplication::clipboard()->setText(getSubtreeText(item)); + } else if (res == actCopyValue) { + QApplication::clipboard()->setText(getValueText(item)); + } +} + +HANDLE DCPCALL ListLoad(HANDLE ParentWin, char* FileToLoad, int ShowFlags) +{ + if (!QApplication::instance()) + return nullptr; + + JsonViewerWidget *widget = new JsonViewerWidget((QWidget*)ParentWin); + if (!widget->loadFile(FileToLoad)) { + delete widget; + return nullptr; + } + + widget->show(); + return widget; +} + +void DCPCALL ListCloseWindow(HANDLE ListWin) +{ + JsonViewerWidget *widget = (JsonViewerWidget*)ListWin; + delete widget; +} + +int DCPCALL ListSendCommand(HWND ListWin, int Command, int Parameter) +{ + JsonViewerWidget *widget = (JsonViewerWidget*)ListWin; + QTreeWidget *view = widget->view(); + + if (Command == lc_copy) + { + QTreeWidgetItem *item = view->currentItem(); + if (item) { + QString text(item->text(view->currentColumn())); + if (!text.isEmpty()) + QApplication::clipboard()->setText(text); + } + return LISTPLUGIN_OK; + } + + return LISTPLUGIN_ERROR; +} + +int DCPCALL ListSearchText(HWND ListWin, char* SearchString, int SearchParameter) +{ + JsonViewerWidget *widget = (JsonViewerWidget*)ListWin; + QTreeWidget *view = widget->view(); + QList list; + Qt::MatchFlags sflags = Qt::MatchContains | Qt::MatchRecursive; + + if (SearchParameter & lcs_matchcase) + sflags |= Qt::MatchCaseSensitive; + + QString needle(SearchString); + QString prev = view->property("needle").value(); + view->setProperty("needle", needle); + + list = view->findItems(QString(SearchString), sflags, view->currentColumn()); + + if (!list.isEmpty()) + { + int i = view->property("findit").value(); + if (needle != prev || SearchParameter & lcs_findfirst) + { + if (SearchParameter & lcs_backwards) + i = list.size() - 1; + else + i = 0; + } + else if (SearchParameter & lcs_backwards) + i--; + else + i++; + + if (i >= 0 && i < list.size() && list.at(i)) + { + view->scrollToItem(list.at(i)); + view->setCurrentItem(list.at(i), view->currentColumn()); + view->setProperty("findit", i); + return LISTPLUGIN_OK; + } + } + + QMessageBox::information(widget, "", QString::asprintf(_("\"%s\" not found!"), SearchString)); + return LISTPLUGIN_ERROR; +} + +void DCPCALL ListGetDetectString(char* DetectString, int maxlen) +{ + snprintf(DetectString, maxlen - 1, "SIZE<30000000"); +} + +void DCPCALL ListSetDefaultParams(ListDefaultParamStruct* dps) +{ + QFileInfo defini(QString::fromStdString(dps->DefaultIniName)); + QString cfgpath = defini.absolutePath() + "/j2969719.ini"; + QSettings settings(cfgpath, QSettings::IniFormat); + + if (!settings.contains(PLUGNAME "/resize_columns")) + settings.setValue(PLUGNAME "/resize_columns", g_resize); + else + g_resize = settings.value(PLUGNAME "/resize_columns").toBool(); + + if (!settings.contains(PLUGNAME "/tree_expand")) + settings.setValue(PLUGNAME "/tree_expand", g_expand); + else + g_expand = settings.value(PLUGNAME "/tree_expand").toBool(); + + if (!settings.contains(PLUGNAME "/column_width")) + settings.setValue(PLUGNAME "/column_width", g_width); + else + { + g_width = settings.value(PLUGNAME "/column_width").toInt(); + + if (g_width < 10) + { + g_width = 10; + settings.setValue(PLUGNAME "/column_width", 10); + } + } + + if (!settings.contains(PLUGNAME "/sorting")) + settings.setValue(PLUGNAME "/sorting", g_sorting); + else + g_sorting = settings.value(PLUGNAME "/sorting").toBool(); + + if (!settings.contains(PLUGNAME "/show_filename")) + settings.setValue(PLUGNAME "/show_filename", g_filename); + else + g_filename = settings.value(PLUGNAME "/show_filename").toBool(); + + Dl_info dlinfo; + static char plg_path[PATH_MAX]; + const char* loc_dir = "langs"; + + memset(&dlinfo, 0, sizeof(dlinfo)); + + if (dladdr(plg_path, &dlinfo) != 0) + { + strncpy(plg_path, dlinfo.dli_fname, PATH_MAX); + char *pos = strrchr(plg_path, '/'); + + if (pos) + strcpy(pos + 1, loc_dir); + + setlocale(LC_ALL, ""); + bindtextdomain(GETTEXT_PACKAGE, plg_path); + textdomain(GETTEXT_PACKAGE); + } +} diff --git a/wlx/logview/CMakeLists.txt b/wlx/logview/CMakeLists.txt index c20c486..d0a3d97 100644 --- a/wlx/logview/CMakeLists.txt +++ b/wlx/logview/CMakeLists.txt @@ -18,18 +18,18 @@ find_package(re2 REQUIRED) # For inotify, it's native linux. # Build the shared library (WLX plugin) -add_library(logviewer_wlx SHARED +add_library(logviewer SHARED src/wlx_plugin.cpp src/LogViewerWidget.cpp src/LogModel.cpp ) -set_target_properties(logviewer_wlx PROPERTIES +set_target_properties(logviewer PROPERTIES PREFIX "" SUFFIX ".wlx" ) -target_link_libraries(logviewer_wlx PRIVATE +target_link_libraries(logviewer PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets diff --git a/wlx/logview/README.md b/wlx/logview/README.md index 4f1d6ec..748e9a4 100644 --- a/wlx/logview/README.md +++ b/wlx/logview/README.md @@ -11,7 +11,19 @@ A high-performance, Wayland-compatible WLX (Lister) log viewer plugin for Double - **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). +- **Native Interactions**: Supports standard file manager interactions, including multi-row selection (Ctrl+click, Shift+click) and copying (Ctrl+C). +- **Context menu**: Copy or delete selected lines. +- **Clear log file**: remove all log entries. +- **Regex-based Color Highlighting**: + - Allows mapping regular expression patterns to custom foreground and background colors to visually distinguish log levels and components. + - Configurable settings modal with a priority-sorted data grid representing patterns directly in their chosen styles. Supports double-clicking a row to edit. + - Full support for multi-selection actions to delete, move up, or move down multiple rules at once. + - Extract selected lines to another file. + - Pre-packaged **Add Default Rules** button to instantly insert standard diagnostics highlights (`TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`) relative to your current selection. + - Persists configurations across sessions by saving the rule list to Double Commander's default INI file in the `[HighlightRules]` section. + +![Screenshot](color_editor.png) + - **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. diff --git a/wlx/logview/build/logviewer.wlx b/wlx/logview/build/logviewer.wlx new file mode 100755 index 0000000..6ff7d00 Binary files /dev/null and b/wlx/logview/build/logviewer.wlx differ diff --git a/wlx/logview/color_editor.png b/wlx/logview/color_editor.png new file mode 100644 index 0000000..8bff01d Binary files /dev/null and b/wlx/logview/color_editor.png differ diff --git a/wlx/logview/double commander wlx log viewer.md b/wlx/logview/double commander wlx log viewer.md deleted file mode 100644 index 280a1a6..0000000 --- a/wlx/logview/double commander wlx log viewer.md +++ /dev/null @@ -1,106 +0,0 @@ -# **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 index 83d3ee1..53bc468 100644 Binary files a/wlx/logview/logviewer.png and b/wlx/logview/logviewer.png differ diff --git a/wlx/logview/sample.log b/wlx/logview/sample.log new file mode 100644 index 0000000..dccb49a --- /dev/null +++ b/wlx/logview/sample.log @@ -0,0 +1,42 @@ +2026-05-26 14:15:00.000 [main] INFO com.example.App - Starting Application... +2026-05-26 14:15:00.012 [main] DEBUG com.example.App - Initializing dependency injection framework. +2026-05-26 14:15:00.045 [main] TRACE com.example.App - Scanning package com.example.service +2026-05-26 14:15:00.048 [main] TRACE com.example.App - Found class com.example.service.UserService +2026-05-26 14:15:00.052 [main] TRACE com.example.App - Found class com.example.service.AuthService +2026-05-26 14:15:00.101 [main] DEBUG com.example.App - Configuring database pool connections. +2026-05-26 14:15:00.220 [main] INFO com.example.db.DbPool - Database pool initialized. Max connections: 20 +2026-05-26 14:15:00.225 [main] WARN com.example.db.DbPool - Connection timeout not set. Falling back to default: 30000ms. +2026-05-26 14:15:00.312 [main] INFO com.example.App - Web server started on port 8080. +2026-05-26 14:15:05.110 [http-nio-8080-exec-1] DEBUG com.example.web.UserController - GET /api/users/active +2026-05-26 14:15:05.112 [http-nio-8080-exec-1] TRACE com.example.service.UserService - Fetching active users from database Cache. +2026-05-26 14:15:05.115 [http-nio-8080-exec-1] DEBUG com.example.service.UserService - Cache hit for active users. Returning 3 users. +2026-05-26 14:15:10.420 [http-nio-8080-exec-2] DEBUG com.example.web.AuthController - POST /api/auth/login +2026-05-26 14:15:10.421 [http-nio-8080-exec-2] TRACE com.example.service.AuthService - Authenticating user: admin +2026-05-26 14:15:10.655 [http-nio-8080-exec-2] WARN com.example.service.AuthService - User admin login failed: Invalid credentials. +2026-05-26 14:15:10.658 [http-nio-8080-exec-2] INFO com.example.web.AuthController - Authentication response: 401 Unauthorized +2026-05-26 14:15:15.910 [scheduler-1] INFO com.example.job.CleanupJob - Starting scheduled database cleanup job... +2026-05-26 14:15:15.912 [scheduler-1] TRACE com.example.job.CleanupJob - Fetching expired sessions. +2026-05-26 14:15:16.020 [scheduler-1] DEBUG com.example.job.CleanupJob - Found 14 expired sessions. +2026-05-26 14:15:16.090 [scheduler-1] INFO com.example.job.CleanupJob - Successfully deleted 14 expired sessions from DB. +2026-05-26 14:15:20.100 [http-nio-8080-exec-3] DEBUG com.example.web.PaymentController - POST /api/payment/checkout +2026-05-26 14:15:20.102 [http-nio-8080-exec-3] TRACE com.example.service.PaymentService - Processing transaction for token: tx_987234 +2026-05-26 14:15:20.500 [http-nio-8080-exec-3] ERROR com.example.service.PaymentService - Payment gateway timeout for token tx_987234. +2026-05-26 14:15:20.502 [http-nio-8080-exec-3] WARN com.example.service.PaymentService - Retrying transaction... Attempt 1 of 3 +2026-05-26 14:15:21.010 [http-nio-8080-exec-3] ERROR com.example.service.PaymentService - Payment gateway error on retry 1: Internal Gateway Error. +2026-05-26 14:15:21.012 [http-nio-8080-exec-3] WARN com.example.service.PaymentService - Retrying transaction... Attempt 2 of 3 +2026-05-26 14:15:22.250 [http-nio-8080-exec-3] ERROR com.example.service.PaymentService - Payment gateway error on retry 2: Service Unavailable. +2026-05-26 14:15:22.252 [http-nio-8080-exec-3] WARN com.example.service.PaymentService - Retrying transaction... Attempt 3 of 3 +2026-05-26 14:15:23.500 [http-nio-8080-exec-3] FATAL com.example.service.PaymentService - Checkout failed after 3 retries. Disabling payment module checkout endpoint! +2026-05-26 14:15:23.504 [http-nio-8080-exec-3] ERROR com.example.web.PaymentController - Checkout process halted due to FATAL payment gateway error. +2026-05-26 14:15:23.510 [main] INFO com.example.App - Gracefully shutting down application due to critical failure... +2026-05-26 14:15:23.512 [main] DEBUG com.example.db.DbPool - Closing database connections in pool. +2026-05-26 14:15:23.550 [main] INFO com.example.App - Database connections closed successfully. +2026-05-26 14:15:23.552 [main] INFO com.example.App - Application shut down complete. +This line doesn't match any rule. +This is another line that doesn't match any rule (no log levels here). +Simple separator line: ========================================== +Running system diagnostics... +- CPU load: 12% +- Memory usage: 1.2 GB / 8.0 GB +- Disk space: 45 GB free +All services terminated cleanly. diff --git a/wlx/logview/src/LogModel.cpp b/wlx/logview/src/LogModel.cpp index 8aed3f0..eb8a26b 100644 --- a/wlx/logview/src/LogModel.cpp +++ b/wlx/logview/src/LogModel.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -94,15 +95,45 @@ QVariant LogModel::data(const QModelIndex &index, int role) const { 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]) + if (role == Qt::BackgroundRole || role == Qt::ForegroundRole) { + // Search match background takes priority + if (role == Qt::BackgroundRole && row < (int)m_matches.size() && m_matches[row]) { return QColor(60, 60, 0); // dark yellow + } + + // Test highlight rules in order + if (m_mappedData && !m_rules.empty()) { + 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; + re2::StringPiece linePiece(m_mappedData + start, len); + + for (const auto &rule : m_rules) { + if (rule.compiledRegex && re2::RE2::PartialMatch(linePiece, *rule.compiledRegex)) { + if (role == Qt::BackgroundRole) { + return rule.backgroundColor; + } else if (role == Qt::ForegroundRole) { + return rule.foregroundColor; + } + } + } + } } return {}; } +void LogModel::setHighlightRules(const std::vector& rules) { + m_rules = rules; + if (lineCount() > 0) { + emit dataChanged(index(0), index(lineCount() - 1), {Qt::BackgroundRole, Qt::ForegroundRole}); + } +} + + // ─── File loading ────────────────────────────────────────────────────── void LogModel::loadFile(const QString& filePath) { @@ -152,14 +183,63 @@ void LogModel::loadFile(const QString& filePath) { m_lineOffsets.push_back(m_mappedSize); // sentinel endResetModel(); - qDebug() << "LogModel indexed:" << filePath << "lines:" << lineCount(); + int linesIndexedThisBatch = 0; - parseTimestamps(); + buildTimestampIndex(); // Set up file watcher for tail m_watcher->addPath(m_filePath); } +// ─── Clear / Delete ──────────────────────────────────────────────────── + +void LogModel::clearFile() { + if (m_filePath.isEmpty()) return; + + // Truncate the file to zero bytes + int fd = open(m_filePath.toUtf8().constData(), O_WRONLY | O_TRUNC); + if (fd >= 0) { + close(fd); + } + + // Reload so the model reflects the empty file + loadFile(m_filePath); +} + +void LogModel::deleteRows(const std::vector& sourceRows) { + if (sourceRows.empty() || m_filePath.isEmpty() || !m_mappedData) return; + + // Build a set for O(1) lookup + std::vector toDelete(lineCount(), false); + for (int r : sourceRows) { + if (r >= 0 && r < lineCount()) + toDelete[r] = true; + } + + // Collect the raw bytes of lines we want to keep + QByteArray kept; + kept.reserve(static_cast(m_mappedSize)); + for (int i = 0; i < lineCount(); ++i) { + if (toDelete[i]) continue; + const uint64_t start = m_lineOffsets[i]; + const uint64_t end = m_lineOffsets[i + 1]; + kept.append(m_mappedData + start, static_cast(end - start)); + } + + // Write the kept lines back to the file + // (unmap first, then rewrite, then reload) + QString path = m_filePath; // save before cleanup + cleanup(); + + QFile file(path); + if (file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + file.write(kept); + file.close(); + } + + loadFile(path); +} + // ─── Search ──────────────────────────────────────────────────────────── void LogModel::startSearch(const QString& query) { @@ -271,31 +351,42 @@ QDateTime LogModel::parseTimestampFromLine(const QString &line) { 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); +void LogModel::buildTimestampIndex() { + m_interpolatedTimestamps.clear(); + m_interpolatedTimestamps.resize(lineCount(), QDateTime()); + + QDateTime lastKnownTimestamp; + QDateTime firstValidTimestamp; + + for (int i = 0; i < lineCount(); ++i) { + QDateTime currentTs = parseTimestampFromLine(lineText(i)); + + if (currentTs.isValid()) { + lastKnownTimestamp = currentTs; + if (!firstValidTimestamp.isValid()) { + firstValidTimestamp = currentTs; + } + } + + if (lastKnownTimestamp.isValid()) { + m_interpolatedTimestamps[i] = lastKnownTimestamp; + } } + m_firstTimestamp = firstValidTimestamp; + m_lastTimestamp = lastKnownTimestamp; + if (m_firstTimestamp.isValid() || m_lastTimestamp.isValid()) emit timestampsDetected(m_firstTimestamp, m_lastTimestamp); } +QDateTime LogModel::getInterpolatedTimestamp(int row) const { + if (row < 0 || row >= static_cast(m_interpolatedTimestamps.size())) { + return QDateTime(); + } + return m_interpolatedTimestamps[row]; +} + // ─── Follow / tail ───────────────────────────────────────────────────── void LogModel::setFollowEnabled(bool enabled) { diff --git a/wlx/logview/src/LogModel.h b/wlx/logview/src/LogModel.h index 6663f81..f151d50 100644 --- a/wlx/logview/src/LogModel.h +++ b/wlx/logview/src/LogModel.h @@ -4,10 +4,23 @@ #include #include #include +#include #include #include #include #include +#include + +namespace re2 { + class RE2; +} + +struct HighlightRule { + QString pattern; + QColor foregroundColor; + QColor backgroundColor; + std::shared_ptr compiledRegex; +}; class LogModel : public QAbstractListModel { Q_OBJECT @@ -18,7 +31,10 @@ class LogModel : public QAbstractListModel { int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + void loadFile(const QString& filePath); + void clearFile(); + void deleteRows(const std::vector& sourceRows); // Search — returns match count void startSearch(const QString& query); @@ -38,20 +54,26 @@ class LogModel : public QAbstractListModel { // Follow / tail void setFollowEnabled(bool enabled); + // Highlighting rules + void setHighlightRules(const std::vector& rules); + std::vector highlightRules() const { return m_rules; } + // Timestamp parsing for external use (filter proxy) static QDateTime parseTimestampFromLine(const QString &line); + QDateTime getInterpolatedTimestamp(int row) const; -signals: + signals: void searchFinished(int matchCount); void timestampsDetected(const QDateTime &first, const QDateTime &last); void tailUpdated(); -private slots: + private slots: void onFileChanged(const QString &path); -private: + private: void cleanup(); - void parseTimestamps(); + void buildTimestampIndex(); + std::vector m_interpolatedTimestamps; static QDateTime tryParseTimestamp(const char *data, int len); QString m_filePath; @@ -76,4 +98,8 @@ private slots: // File watching QFileSystemWatcher *m_watcher = nullptr; bool m_followEnabled = false; + + // Highlight rules + std::vector m_rules; }; + diff --git a/wlx/logview/src/LogViewerWidget.cpp b/wlx/logview/src/LogViewerWidget.cpp index 6b1fc23..641cb76 100644 --- a/wlx/logview/src/LogViewerWidget.cpp +++ b/wlx/logview/src/LogViewerWidget.cpp @@ -8,9 +8,388 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern QString g_iniPath; + +class RuleDialog : public QDialog { +public: + RuleDialog(QWidget *parent = nullptr) : QDialog(parent) { + setWindowTitle("Edit Highlight Rule"); + QVBoxLayout *layout = new QVBoxLayout(this); + + QHBoxLayout *patternLayout = new QHBoxLayout(); + patternLayout->addWidget(new QLabel("Regex Pattern:", this)); + m_patternEdit = new QLineEdit(this); + patternLayout->addWidget(m_patternEdit); + layout->addLayout(patternLayout); + + QHBoxLayout *colorLayout = new QHBoxLayout(); + + QVBoxLayout *fgLayout = new QVBoxLayout(); + fgLayout->addWidget(new QLabel("Foreground", this)); + m_fgButton = new QPushButton(this); + m_fgButton->setFixedHeight(26); + fgLayout->addWidget(m_fgButton); + + QVBoxLayout *bgLayout = new QVBoxLayout(); + bgLayout->addWidget(new QLabel("Background", this)); + m_bgButton = new QPushButton(this); + m_bgButton->setFixedHeight(26); + bgLayout->addWidget(m_bgButton); + + colorLayout->addLayout(fgLayout); + colorLayout->addLayout(bgLayout); + layout->addLayout(colorLayout); + + connect(m_fgButton, &QPushButton::clicked, this, [this]() { + QColor col = QColorDialog::getColor(m_fgColor, this, "Choose Foreground Color"); + if (col.isValid()) { + m_fgColor = col; + updateButtonColors(); + } + }); + + connect(m_bgButton, &QPushButton::clicked, this, [this]() { + QColor col = QColorDialog::getColor(m_bgColor, this, "Choose Background Color"); + if (col.isValid()) { + m_bgColor = col; + updateButtonColors(); + } + }); + + QHBoxLayout *buttonsLayout = new QHBoxLayout(); + QPushButton *btnOk = new QPushButton("OK", this); + QPushButton *btnCancel = new QPushButton("Cancel", this); + btnOk->setFixedHeight(26); + btnCancel->setFixedHeight(26); + buttonsLayout->addWidget(btnOk); + buttonsLayout->addWidget(btnCancel); + layout->addLayout(buttonsLayout); + + connect(btnOk, &QPushButton::clicked, this, &QDialog::accept); + connect(btnCancel, &QPushButton::clicked, this, &QDialog::reject); + + m_fgColor = Qt::white; + m_bgColor = Qt::black; + updateButtonColors(); + } + + void setRule(const QString &pattern, QColor fg, QColor bg) { + m_patternEdit->setText(pattern); + m_fgColor = fg; + m_bgColor = bg; + updateButtonColors(); + } + + QString pattern() const { return m_patternEdit->text().trimmed(); } + QColor foregroundColor() const { return m_fgColor; } + QColor backgroundColor() const { return m_bgColor; } + +private: + QLineEdit *m_patternEdit; + QPushButton *m_fgButton; + QPushButton *m_bgButton; + QColor m_fgColor; + QColor m_bgColor; + + void updateButtonColors() { + m_fgButton->setStyleSheet(QString("background-color: %1; color: %2; border: 1px solid gray;") + .arg(m_fgColor.name()) + .arg(m_fgColor.lightness() > 128 ? "black" : "white")); + m_fgButton->setText(m_fgColor.name()); + + m_bgButton->setStyleSheet(QString("background-color: %1; color: %2; border: 1px solid gray;") + .arg(m_bgColor.name()) + .arg(m_bgColor.lightness() > 128 ? "black" : "white")); + m_bgButton->setText(m_bgColor.name()); + } +}; + +class SettingsDialog : public QDialog { +public: + SettingsDialog(const std::vector &rules, QWidget *parent = nullptr) + : QDialog(parent), m_rules(rules) { + setWindowTitle("Highlighting Rules"); + resize(600, 400); + + QHBoxLayout *mainLayout = new QHBoxLayout(); + + // Rules Table + m_table = new QTableWidget(this); + m_table->setColumnCount(2); + m_table->setHorizontalHeaderLabels({"Priority", "Regex Pattern"}); + m_table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + m_table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + m_table->setSelectionBehavior(QAbstractItemView::SelectRows); + m_table->setSelectionMode(QAbstractItemView::ExtendedSelection); + mainLayout->addWidget(m_table); + + // Control Buttons + QVBoxLayout *btnLayout = new QVBoxLayout(); + QPushButton *btnAdd = new QPushButton(QString::fromUtf8(u8"\u271A Add"), this); + QPushButton *btnEdit = new QPushButton(QString::fromUtf8(u8"\u270E Edit"), this); + QPushButton *btnDelete = new QPushButton(QString::fromUtf8(u8"\u2715 Delete"), this); + QPushButton *btnDefault = new QPushButton(QString::fromUtf8(u8"\u2295 Add Default Rules"), this); + QPushButton *btnUp = new QPushButton(QString::fromUtf8(u8"\u25B2 Move Up"), this); + QPushButton *btnDown = new QPushButton(QString::fromUtf8(u8"\u25BC Move Down"), this); + + for (auto* btn : {btnAdd, btnEdit, btnDelete, btnDefault, btnUp, btnDown}) { + btn->setFixedHeight(26); + } + + btnLayout->addWidget(btnAdd); + btnLayout->addWidget(btnEdit); + btnLayout->addWidget(btnDelete); + btnLayout->addWidget(btnDefault); + btnLayout->addSpacing(20); + btnLayout->addWidget(btnUp); + btnLayout->addWidget(btnDown); + btnLayout->addStretch(); + mainLayout->addLayout(btnLayout); + + QHBoxLayout *okCancelLayout = new QHBoxLayout(); + QPushButton *btnOk = new QPushButton("OK", this); + QPushButton *btnCancel = new QPushButton("Cancel", this); + btnOk->setFixedHeight(26); + btnCancel->setFixedHeight(26); + okCancelLayout->addWidget(btnOk); + okCancelLayout->addWidget(btnCancel); + + QVBoxLayout *outerLayout = new QVBoxLayout(this); + outerLayout->addLayout(mainLayout); + outerLayout->addLayout(okCancelLayout); + + connect(btnAdd, &QPushButton::clicked, this, &SettingsDialog::onAdd); + connect(btnEdit, &QPushButton::clicked, this, &SettingsDialog::onEdit); + connect(btnDelete, &QPushButton::clicked, this, &SettingsDialog::onDelete); + connect(btnDefault, &QPushButton::clicked, this, &SettingsDialog::onAddDefaults); + connect(btnUp, &QPushButton::clicked, this, &SettingsDialog::onMoveUp); + connect(btnDown, &QPushButton::clicked, this, &SettingsDialog::onMoveDown); + connect(btnOk, &QPushButton::clicked, this, &QDialog::accept); + connect(btnCancel, &QPushButton::clicked, this, &QDialog::reject); + connect(m_table, &QTableWidget::cellDoubleClicked, this, &SettingsDialog::onEdit); + + populateTable(); + } + + std::vector rules() const { return m_rules; } + +private: + QTableWidget *m_table; + std::vector m_rules; + + void populateTable() { + m_table->setRowCount(0); + for (size_t i = 0; i < m_rules.size(); ++i) { + int row = m_table->rowCount(); + m_table->insertRow(row); + + // Priority + QTableWidgetItem *itemPriority = new QTableWidgetItem(QString::number(i + 1)); + itemPriority->setFlags(itemPriority->flags() & ~Qt::ItemIsEditable); + m_table->setItem(row, 0, itemPriority); + + // Pattern + QTableWidgetItem *itemPattern = new QTableWidgetItem(m_rules[i].pattern); + itemPattern->setBackground(m_rules[i].backgroundColor); + itemPattern->setForeground(m_rules[i].foregroundColor); + itemPattern->setFlags(itemPattern->flags() & ~Qt::ItemIsEditable); + m_table->setItem(row, 1, itemPattern); + } + } + + void onAdd() { + RuleDialog dlg(this); + if (dlg.exec() == QDialog::Accepted) { + QString pat = dlg.pattern(); + if (pat.isEmpty()) return; + // Enforce uniqueness + for (const auto &r : m_rules) { + if (r.pattern == pat) { + QMessageBox::warning(this, "Duplicate Rule", "A rule with this regex pattern already exists."); + return; + } + } + auto re = std::make_shared(pat.toStdString()); + if (!re->ok()) { + QMessageBox::warning(this, "Invalid Regex", "The regex pattern is invalid."); + return; + } + HighlightRule r; + r.pattern = pat; + r.foregroundColor = dlg.foregroundColor(); + r.backgroundColor = dlg.backgroundColor(); + r.compiledRegex = re; + m_rules.push_back(r); + populateTable(); + } + } + + void onAddDefaults() { + int insertIdx = 0; + QList selectedItems = m_table->selectedItems(); + if (!selectedItems.isEmpty()) { + int minRow = m_rules.size(); + for (auto *item : selectedItems) { + if (item->row() < minRow) { + minRow = item->row(); + } + } + insertIdx = minRow; + } + + struct DefaultRule { + QString pat; + QString fg; + QString bg; + } defaults[] = { + { ".*TRACE.*", "#9CA3AF", "#000000" }, + { ".*DEBUG.*", "#60A5FA", "#000000" }, + { ".*INFO.*", "#4ADE80", "#000000" }, + { ".*WARN.*", "#FBBF24", "#000000" }, + { ".*ERROR.*", "#F87171", "#000000" }, + { ".*FATAL.*", "#C084FC", "#000000" } + }; + for (int i = 5; i >= 0; --i) { + const auto &d = defaults[i]; + bool exists = false; + for (const auto &r : m_rules) { + if (r.pattern == d.pat) { + exists = true; + break; + } + } + if (exists) continue; + + HighlightRule r; + r.pattern = d.pat; + r.foregroundColor = QColor(d.fg); + r.backgroundColor = QColor(d.bg); + auto re = std::make_shared(d.pat.toStdString()); + if (re->ok()) { + r.compiledRegex = re; + m_rules.insert(m_rules.begin() + insertIdx, r); + } + } + populateTable(); + } + + void onEdit() { + int row = m_table->currentRow(); + if (row < 0 || row >= static_cast(m_rules.size())) return; + + RuleDialog dlg(this); + dlg.setRule(m_rules[row].pattern, m_rules[row].foregroundColor, m_rules[row].backgroundColor); + if (dlg.exec() == QDialog::Accepted) { + QString pat = dlg.pattern(); + if (pat.isEmpty()) return; + // Enforce uniqueness + for (int i = 0; i < static_cast(m_rules.size()); ++i) { + if (i != row && m_rules[i].pattern == pat) { + QMessageBox::warning(this, "Duplicate Rule", "A rule with this regex pattern already exists."); + return; + } + } + auto re = std::make_shared(pat.toStdString()); + if (!re->ok()) { + QMessageBox::warning(this, "Invalid Regex", "The regex pattern is invalid."); + return; + } + m_rules[row].pattern = pat; + m_rules[row].foregroundColor = dlg.foregroundColor(); + m_rules[row].backgroundColor = dlg.backgroundColor(); + m_rules[row].compiledRegex = re; + populateTable(); + } + } + + void onDelete() { + QList selectedItems = m_table->selectedItems(); + if (selectedItems.isEmpty()) return; + + std::vector rows; + for (auto *item : selectedItems) { + int r = item->row(); + if (std::find(rows.begin(), rows.end(), r) == rows.end()) { + rows.push_back(r); + } + } + std::sort(rows.begin(), rows.end(), std::greater()); + for (int r : rows) { + m_rules.erase(m_rules.begin() + r); + } + populateTable(); + } + + void onMoveUp() { + QList selectedItems = m_table->selectedItems(); + if (selectedItems.isEmpty()) return; + + std::vector selectedRows; + for (auto *item : selectedItems) { + int r = item->row(); + if (std::find(selectedRows.begin(), selectedRows.end(), r) == selectedRows.end()) { + selectedRows.push_back(r); + } + } + std::sort(selectedRows.begin(), selectedRows.end()); + + if (selectedRows.empty() || selectedRows.front() == 0) return; + + for (int r : selectedRows) { + std::swap(m_rules[r], m_rules[r - 1]); + } + + populateTable(); + + m_table->clearSelection(); + for (int r : selectedRows) { + m_table->selectionModel()->select(m_table->model()->index(r - 1, 0), QItemSelectionModel::Select | QItemSelectionModel::Rows); + } + } + + void onMoveDown() { + QList selectedItems = m_table->selectedItems(); + if (selectedItems.isEmpty()) return; + + std::vector selectedRows; + for (auto *item : selectedItems) { + int r = item->row(); + if (std::find(selectedRows.begin(), selectedRows.end(), r) == selectedRows.end()) { + selectedRows.push_back(r); + } + } + std::sort(selectedRows.begin(), selectedRows.end()); + + if (selectedRows.empty() || selectedRows.back() == static_cast(m_rules.size()) - 1) return; + + for (int i = static_cast(selectedRows.size()) - 1; i >= 0; --i) { + int r = selectedRows[i]; + std::swap(m_rules[r], m_rules[r + 1]); + } + + populateTable(); + + m_table->clearSelection(); + for (int r : selectedRows) { + m_table->selectionModel()->select(m_table->model()->index(r + 1, 0), QItemSelectionModel::Select | QItemSelectionModel::Rows); + } + } +}; // ─── LogFilterProxy ──────────────────────────────────────────────────── + LogFilterProxy::LogFilterProxy(QObject *parent) : QSortFilterProxyModel(parent) {} @@ -50,7 +429,8 @@ bool LogFilterProxy::filterAcceptsRow(int sourceRow, auto *src = qobject_cast(sourceModel()); if (!src) return true; - if (m_regexActive && !src->isMatch(sourceRow)) + // Only filter by regex if the regex filter is active AND there is an active search with matches + if (m_regexActive && src->matchCount() > 0 && !src->isMatch(sourceRow)) return false; if (m_timeActive && m_timeStart.isValid() && m_timeEnd.isValid()) { @@ -81,31 +461,79 @@ LogViewerWidget::LogViewerWidget(QWidget *parent) mainLayout->setContentsMargins(0, 0, 0, 0); // ── Top Header ───────────────────────────────────────────────────── - QHBoxLayout *headerLayout = new QHBoxLayout(); + QWidget *headerContainer = new QWidget(this); + QHBoxLayout *headerLayout = new QHBoxLayout(headerContainer); + headerLayout->setContentsMargins(4, 4, 4, 4); + headerLayout->setSpacing(4); - searchEdit = new QLineEdit(this); + searchEdit = new QLineEdit(headerContainer); searchEdit->setPlaceholderText("Regex search..."); + searchEdit->setClearButtonEnabled(true); headerLayout->addWidget(searchEdit); - btnSearchStart = new QPushButton("Search / Next", this); - btnSearchStop = new QPushButton("Stop", this); + btnSearchStart = new QPushButton(QString::fromUtf8(u8"\u2315 Search / Next"), headerContainer); + btnSearchStop = new QPushButton(QString::fromUtf8(u8"\u25A0 Stop"), headerContainer); btnSearchStop->setEnabled(false); headerLayout->addWidget(btnSearchStart); headerLayout->addWidget(btnSearchStop); - timeStart = new QDateTimeEdit(this); - timeEnd = new QDateTimeEdit(this); - headerLayout->addWidget(new QLabel("From:")); + timeStart = new QDateTimeEdit(headerContainer); + timeEnd = new QDateTimeEdit(headerContainer); + timeStart->setFixedWidth(175); + timeEnd->setFixedWidth(175); + timeStart->setDisplayFormat("yyyy-MM-dd HH:mm:ss"); + timeEnd->setDisplayFormat("yyyy-MM-dd HH:mm:ss"); + + headerLayout->addWidget(new QLabel("From:", headerContainer)); headerLayout->addWidget(timeStart); - headerLayout->addWidget(new QLabel("To:")); + headerLayout->addWidget(new QLabel("To:", headerContainer)); headerLayout->addWidget(timeEnd); - chkFollow = new QCheckBox("Follow", this); - chkFilterMode = new QCheckBox("Filter", this); + chkFollow = new QCheckBox("Follow", headerContainer); + chkFilterMode = new QCheckBox("Filter", headerContainer); + btnClearLog = new QPushButton(QString::fromUtf8(u8"\u239A\uFE0E Clean"), headerContainer); + btnClearLog->setToolTip("Clean log file (delete all contents, keep the file)"); + btnExtract = new QPushButton(QString::fromUtf8(u8"\U0001F5AB Extract..."), headerContainer); + btnExtract->setToolTip("Extract selected lines to a new file"); + btnSettings = new QPushButton(QString::fromUtf8(u8"\u2699 Settings"), headerContainer); headerLayout->addWidget(chkFollow); headerLayout->addWidget(chkFilterMode); + headerLayout->addWidget(btnClearLog); + headerLayout->addWidget(btnExtract); + headerLayout->addWidget(btnSettings); - mainLayout->addLayout(headerLayout); + // Reactive search clearing + connect(searchEdit, &QLineEdit::textChanged, this, [this](const QString &text) { + if (text.isEmpty()) { + onSearchStartClicked(); + } + }); + + // Normalize height and vertical alignment for all widgets in the header. + // Use a fixed height that is large enough for all symbols and matches the search box. + const int headerHeight = 32; + + headerContainer->setStyleSheet( + "QPushButton, QLineEdit, QDateTimeEdit { " + " padding: 0px 8px; " + " margin: 0px; " + "} " + "QPushButton { text-align: center; } " + "QLabel { padding: 0px 4px; }" + ); + + // Targeted fixes for buttons that tend to align higher due to symbol metrics + btnSearchStop->setStyleSheet(btnSearchStop->styleSheet() + "QPushButton { padding-top: 1px; }"); + btnExtract->setStyleSheet(btnExtract->styleSheet() + "QPushButton { padding-top: 5px; }"); + + for (int i = 0; i < headerLayout->count(); ++i) { + if (QWidget *w = headerLayout->itemAt(i)->widget()) { + w->setFixedHeight(headerHeight); + headerLayout->setAlignment(w, Qt::AlignVCenter); + } + } + + mainLayout->addWidget(headerContainer); // ── Filter proxy ─────────────────────────────────────────────────── filterProxy = new LogFilterProxy(this); @@ -124,8 +552,22 @@ LogViewerWidget::LogViewerWidget(QWidget *parent) listView->setContextMenuPolicy(Qt::CustomContextMenu); connect(listView, &QWidget::customContextMenuRequested, this, [this](const QPoint &pos) { QMenu menu(this); - QAction *copyAct = menu.addAction("Copy"); + + QAction *copyAct = menu.addAction(QString::fromUtf8(u8"\u29C9 Copy")); connect(copyAct, &QAction::triggered, this, &LogViewerWidget::copySelectedLines); + + QAction *deleteAct = menu.addAction(QString::fromUtf8(u8"\u2715 Delete Selected Lines")); + connect(deleteAct, &QAction::triggered, this, &LogViewerWidget::deleteSelectedLines); + + menu.addSeparator(); + + QAction *extractAct = menu.addAction(QString::fromUtf8(u8"\U0001F5AB Extract...")); + connect(extractAct, &QAction::triggered, this, &LogViewerWidget::extractSelectedLines); + + bool hasSelection = !listView->selectionModel()->selectedIndexes().isEmpty(); + deleteAct->setEnabled(hasSelection); + extractAct->setEnabled(hasSelection); + menu.exec(listView->viewport()->mapToGlobal(pos)); }); @@ -139,13 +581,37 @@ LogViewerWidget::LogViewerWidget(QWidget *parent) 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). + // FOCUS: Install event filter on qApp for geometry-based activation. + // Uses the FocusManager pattern from wlxbase_wlqt: click inside the + // plugin activates it (gives listView focus); click outside deactivates + // it (returns focus to DC). Shortcuts only fire when active. // ──────────────────────────────────────────────────────────────────── - installFocusGuard(); + setFocusProxy(listView); + qApp->installEventFilter(this); + + // Detect focus leaving the plugin hierarchy (e.g. Alt-Tab, DC menu) + connect(qApp, &QApplication::focusChanged, this, [this](QWidget *old, QWidget *now) { + if (QApplication::activeModalWidget()) + return; + + bool oldInside = old && (old == this || this->isAncestorOf(old)); + bool nowInside = now && (now == this || this->isAncestorOf(now)); + + if (m_isActive && oldInside && !nowInside) { + m_isActive = false; + m_activeInput = nullptr; + } else if (!m_isActive && nowInside && !oldInside) { + if (old) { + QTimer::singleShot(0, this, [this]() { + restoreFocusToDC(); + }); + } else { + QTimer::singleShot(0, this, [this]() { + restoreFocusToDC(); + }); + } + } + }); // ── Connections ──────────────────────────────────────────────────── connect(btnSearchStart, &QPushButton::clicked, @@ -160,6 +626,12 @@ LogViewerWidget::LogViewerWidget(QWidget *parent) this, &LogViewerWidget::onTimeRangeChanged); connect(timeEnd, &QDateTimeEdit::dateTimeChanged, this, &LogViewerWidget::onTimeRangeChanged); + connect(btnClearLog, &QPushButton::clicked, + this, &LogViewerWidget::clearLogFile); + connect(btnExtract, &QPushButton::clicked, + this, &LogViewerWidget::extractSelectedLines); + connect(btnSettings, &QPushButton::clicked, + this, &LogViewerWidget::onSettingsClicked); connect(model, &LogModel::searchFinished, this, &LogViewerWidget::onSearchFinished); @@ -167,125 +639,137 @@ LogViewerWidget::LogViewerWidget(QWidget *parent) this, &LogViewerWidget::onTimestampsDetected); connect(model, &LogModel::tailUpdated, this, &LogViewerWidget::onTailUpdated); + + // Load and apply highlight rules + std::vector initialRules = loadHighlightRules(); + model->setHighlightRules(initialRules); } 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 { +// ─── Focus helpers ───────────────────────────────────────────────────── + +bool LogViewerWidget::isTextInputWidget(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 if (parentWidget()) { + parentWidget()->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)) + if (fw == 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. -// +// ─── Event filter (installed on qApp) ────────────────────────────────── + bool LogViewerWidget::eventFilter(QObject *obj, QEvent *event) { - auto *w = qobject_cast(obj); + QWidget *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; - } + // ── Geometry-based click activation/deactivation ─────────────────── + if (event->type() == QEvent::MouseButtonPress) { + auto *me = static_cast(event); + const QPoint gp = me->globalPosition().toPoint(); + const QRect gr(mapToGlobal(QPoint(0, 0)), size()); - // ── 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); + if (m_isActive && !gr.contains(gp)) { + // Click outside plugin → deactivate + m_isActive = false; + m_activeInput = nullptr; + clearFocus(); + restoreFocusToDC(); + return false; + } else if (!m_isActive && gr.contains(gp)) { + // Click inside plugin → activate + m_isActive = true; + + // Determine what was clicked + if (w && isTextInputWidget(w)) { + // Clicked a text input — let it gain focus naturally + m_activeInput = w; + // Find the top-level input widget for tracking + if (searchEdit->isAncestorOf(w) || w == searchEdit) + m_activeInput = searchEdit; + else if (timeStart->isAncestorOf(w) || w == timeStart) + m_activeInput = timeStart; + else if (timeEnd->isAncestorOf(w) || w == timeEnd) + m_activeInput = timeEnd; + } else { + // Clicked somewhere else in the plugin — focus the list + m_activeInput = nullptr; + listView->setFocus(Qt::MouseFocusReason); + } + return false; + } else if (m_isActive && gr.contains(gp)) { + // Already active, click inside — update input tracking + if (w && isTextInputWidget(w)) { + if (searchEdit->isAncestorOf(w) || w == searchEdit) + m_activeInput = searchEdit; + else if (timeStart->isAncestorOf(w) || w == timeStart) + m_activeInput = timeStart; + else if (timeEnd->isAncestorOf(w) || w == timeEnd) + m_activeInput = timeEnd; + else + m_activeInput = w; + } else if (w && (w == listView || listView->isAncestorOf(w))) { + m_activeInput = nullptr; + listView->setFocus(Qt::MouseFocusReason); + } else { + m_activeInput = nullptr; + } + return false; } } - // ── KeyPress handling ────────────────────────────────────────────── - if (event->type() == QEvent::KeyPress) { + // ── KeyPress handling (only when plugin is active) ───────────────── + if (event->type() == QEvent::KeyPress && m_isActive) { 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(); + // Escape: deactivate text input or deactivate plugin entirely + if (ke->key() == Qt::Key_Escape) { + if (m_activeInput) { + m_activeInput = nullptr; + listView->setFocus(Qt::OtherFocusReason); + } else { + m_isActive = false; + 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)) { + // Enter in search edit: trigger search, return focus to list + if (m_activeInput == searchEdit && (ke->key() == Qt::Key_Return || + ke->key() == Qt::Key_Enter)) { onSearchStartClicked(); m_activeInput = nullptr; - restoreFocusToDC(); + listView->setFocus(Qt::OtherFocusReason); 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 + // Shortcuts that only work when NOT in a text input + if (!m_activeInput) { + // Ctrl+C: copy selected lines + if (ke->matches(QKeySequence::Copy)) { + copySelectedLines(); + return true; + } + + // Delete: delete selected lines + if (ke->key() == Qt::Key_Delete) { + deleteSelectedLines(); + return true; + } } } @@ -295,21 +779,26 @@ bool LogViewerWidget::eventFilter(QObject *obj, QEvent *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(); + // Save whichever DC widget currently has focus to restore it later. + // Crucial: Only save it if the focused widget is outside our plugin! + // Otherwise, a reload triggered while we have focus would trap focus inside us. + if (QWidget *fw = QApplication::focusWidget()) { + if (fw != this && !this->isAncestorOf(fw)) { + m_savedFocusWidget = fw; + } + } currentFile = filePath; m_lastMatchRow = -1; m_lastSearchQuery.clear(); m_activeInput = nullptr; + m_isActive = false; // Ensure we start inactive 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 + // Restore focus to DC after loading completes QTimer::singleShot(0, this, [this]() { restoreFocusToDC(); }); } @@ -324,17 +813,25 @@ void LogViewerWidget::triggerSearch(const QString& searchString, int) { void LogViewerWidget::onSearchStartClicked() { const QString query = searchEdit->text(); - if (query.isEmpty()) return; if (query != m_lastSearchQuery) { m_lastMatchRow = -1; m_lastSearchQuery = query; + + if (query.isEmpty()) { + btnSearchStop->setEnabled(false); + model->startSearch(""); + return; + } + btnSearchStop->setEnabled(true); statusLabel->setText("Searching..."); model->startSearch(query); return; } + if (query.isEmpty()) return; + // Same query — jump to next match if (model->matchCount() > 0) { int next = model->nextMatch(m_lastMatchRow); @@ -406,14 +903,14 @@ void LogViewerWidget::onTimeRangeChanged() { if (m_timestampsLoading) return; QDateTime start = timeStart->dateTime(); - QDateTime end = timeEnd->dateTime(); + QDateTime end = timeEnd->dateTime().addMSecs(-1); 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"))); + .arg(start.toString("yyyy-MM-dd HH:mm:ss")) + .arg(timeEnd->dateTime().toString("yyyy-MM-dd HH:mm:ss"))); } } @@ -450,3 +947,185 @@ void LogViewerWidget::copySelectedLines() { QApplication::clipboard()->setText(lines.join('\n')); statusLabel->setText(QString("Copied %1 line(s)").arg(lines.size())); } + +// ─── Delete selected lines ───────────────────────────────────────────── + +void LogViewerWidget::deleteSelectedLines() { + QModelIndexList selected = listView->selectionModel()->selectedIndexes(); + if (selected.isEmpty()) return; + + // Map proxy indices to source indices + std::vector sourceRows; + for (const QModelIndex &idx : selected) { + QModelIndex srcIdx = filterProxy->mapToSource(idx); + if (srcIdx.isValid()) + sourceRows.push_back(srcIdx.row()); + } + + if (sourceRows.empty()) return; + + int count = static_cast(sourceRows.size()); + model->deleteRows(sourceRows); + statusLabel->setText(QString("Deleted %1 line(s)").arg(count)); +} + +// ─── Clean log file ──────────────────────────────────────────────────── + +void LogViewerWidget::clearLogFile() { + if (currentFile.isEmpty()) return; + + auto reply = QMessageBox::question(this, "Clean Log", + QString("This will delete all contents of:\n%1\n\nThe file will be kept but emptied. Continue?") + .arg(currentFile), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + + if (reply != QMessageBox::Yes) return; + + model->clearFile(); + statusLabel->setText(QString("Log cleaned: %1").arg(currentFile)); +} + +// ─── Extract selected lines ──────────────────────────────────────────── + +void LogViewerWidget::extractSelectedLines() { + QModelIndexList selected = listView->selectionModel()->selectedIndexes(); + if (selected.isEmpty()) { + statusLabel->setText("No lines selected for extraction"); + 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(); + + // Suggest the same extension as the current log file + QFileInfo fi(currentFile); + QString suffix = fi.suffix(); + QString filter; + if (!suffix.isEmpty()) + filter = QString("%1 files (*.%2);;All files (*)").arg(suffix.toUpper()).arg(suffix); + else + filter = "Log files (*.log);;All files (*)"; + + // Default to the same directory as the original file + QString defaultPath = fi.absolutePath() + "/extracted." + (suffix.isEmpty() ? "log" : suffix); + + QString savePath = QFileDialog::getSaveFileName(this, "Extract Lines", + defaultPath, filter); + if (savePath.isEmpty()) return; + + QFile file(savePath); + if (file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + file.write(lines.join('\n').toUtf8()); + file.write("\n"); + file.close(); + statusLabel->setText(QString("Extracted %1 line(s) to %2").arg(lines.size()).arg(savePath)); + } else { + QMessageBox::warning(this, "Extract Error", + QString("Could not write to:\n%1").arg(savePath)); + } +} + +void LogViewerWidget::onSettingsClicked() { + SettingsDialog dlg(model->highlightRules(), this); + if (dlg.exec() == QDialog::Accepted) { + auto newRules = dlg.rules(); + saveHighlightRules(newRules); + model->setHighlightRules(newRules); + } +} + +std::vector LogViewerWidget::loadHighlightRules() { + std::vector rules; + QString path = g_iniPath; + if (path.isEmpty()) { + path = QDir::homePath() + "/.config/doublecmd/logviewer.ini"; + } + + QSettings settings(path, QSettings::IniFormat); + bool sectionExists = settings.contains("HighlightRules/Count"); + + if (!sectionExists) { + struct DefaultRule { + QString pat; + QString fg; + QString bg; + } defaults[] = { + { "(?i).*TRACE.*", "#9CA3AF", "#000000" }, + { "(?i).*DEBUG.*", "#60A5FA", "#000000" }, + { "(?i).*INFO.*", "#4ADE80", "#000000" }, + { "(?i).*WARN.*", "#FBBF24", "#000000" }, + { "(?i).*ERROR.*", "#F87171", "#000000" }, + { "(?i).*FATAL.*", "#C084FC", "#000000" } + }; + for (const auto &d : defaults) { + HighlightRule rule; + rule.pattern = d.pat; + rule.foregroundColor = QColor(d.fg); + rule.backgroundColor = QColor(d.bg); + auto re = std::make_shared(d.pat.toStdString()); + if (re->ok()) { + rule.compiledRegex = re; + rules.push_back(rule); + } + } + // Save these defaults initially so the section gets created + saveHighlightRules(rules); + } else { + settings.beginGroup("HighlightRules"); + int count = settings.value("Count", 0).toInt(); + for (int i = 0; i < count; ++i) { + QString regexKey = QString("Rule%1_Regex").arg(i); + QString fgKey = QString("Rule%1_FG").arg(i); + QString bgKey = QString("Rule%1_BG").arg(i); + + QString regexStr = settings.value(regexKey).toString(); + if (regexStr.isEmpty()) continue; + + QString fgStr = settings.value(fgKey).toString(); + QString bgStr = settings.value(bgKey).toString(); + + HighlightRule rule; + rule.pattern = regexStr; + rule.foregroundColor = fgStr.isEmpty() ? QColor() : QColor(fgStr); + rule.backgroundColor = bgStr.isEmpty() ? QColor() : QColor(bgStr); + + auto re = std::make_shared(regexStr.toStdString()); + if (re->ok()) { + rule.compiledRegex = re; + rules.push_back(rule); + } else { + qWarning() << "Invalid regex from INI:" << regexStr; + } + } + settings.endGroup(); + } + + return rules; +} + +void LogViewerWidget::saveHighlightRules(const std::vector& rules) { + QString path = g_iniPath; + if (path.isEmpty()) { + path = QDir::homePath() + "/.config/doublecmd/logviewer.ini"; + } + + QSettings settings(path, QSettings::IniFormat); + settings.beginGroup("HighlightRules"); + settings.remove(""); // Clean up existing keys to prevent orphaned entries + + settings.setValue("Count", static_cast(rules.size())); + for (size_t i = 0; i < rules.size(); ++i) { + settings.setValue(QString("Rule%1_Regex").arg(i), rules[i].pattern); + settings.setValue(QString("Rule%1_FG").arg(i), rules[i].foregroundColor.name()); + settings.setValue(QString("Rule%1_BG").arg(i), rules[i].backgroundColor.name()); + } + settings.endGroup(); + settings.sync(); +} + diff --git a/wlx/logview/src/LogViewerWidget.h b/wlx/logview/src/LogViewerWidget.h index 8aaa27d..904ab94 100644 --- a/wlx/logview/src/LogViewerWidget.h +++ b/wlx/logview/src/LogViewerWidget.h @@ -13,8 +13,10 @@ #include #include #include +#include +#include -class LogModel; +#include "LogModel.h" // Proxy that filters rows by regex match and/or timestamp range class LogFilterProxy : public QSortFilterProxyModel { @@ -59,18 +61,26 @@ private slots: void onTailUpdated(); void onTimeRangeChanged(); void copySelectedLines(); + void deleteSelectedLines(); + void clearLogFile(); + void extractSelectedLines(); + void onSettingsClicked(); + std::vector loadHighlightRules(); + void saveHighlightRules(const std::vector& rules); 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 + bool isTextInputWidget(QWidget *w) const; // Check if w is a text input (searchEdit, timeStart, timeEnd) // UI Elements QListView *listView; QLineEdit *searchEdit; QPushButton *btnSearchStart; QPushButton *btnSearchStop; + QPushButton *btnClearLog; + QPushButton *btnExtract; + QPushButton *btnSettings; QDateTimeEdit *timeStart; QDateTimeEdit *timeEnd; QCheckBox *chkFollow; @@ -78,6 +88,7 @@ private slots: QProgressBar *progressBar; QLabel *statusLabel; + LogModel *model; LogFilterProxy *filterProxy; QString currentFile; @@ -85,7 +96,8 @@ private slots: int m_lastMatchRow = -1; bool m_timestampsLoading = false; - // Focus management: save/restore DC's focused widget across file loads + // Focus management + bool m_isActive = false; QPointer m_savedFocusWidget; - QPointer m_activeInput; // currently active input widget (search/time edits) + QPointer m_activeInput; // currently active text input widget (search/time edits) }; diff --git a/wlx/logview/src/wlx_plugin.cpp b/wlx/logview/src/wlx_plugin.cpp index f4d26e2..bb04807 100644 --- a/wlx/logview/src/wlx_plugin.cpp +++ b/wlx/logview/src/wlx_plugin.cpp @@ -4,16 +4,25 @@ #include #include +#include #include "LogViewerWidget.h" +QString g_iniPath; + // Define a visibility macro for exported functions #define EXPORT __attribute__((visibility("default"))) extern "C" { +EXPORT void DCPCALL ListSetDefaultParams(ListDefaultParamStruct* dps) { + if (dps) { + g_iniPath = QString::fromUtf8(dps->DefaultIniName); + } +} + + 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. @@ -47,7 +56,6 @@ EXPORT HWND DCPCALL ListLoad(HWND ParentWin, char* FileToLoad, int ShowFlags) { 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); @@ -65,7 +73,6 @@ EXPORT int DCPCALL ListLoadNext(HWND ParentWin, HWND PluginWin, char* FileToLoad EXPORT void DCPCALL ListCloseWindow(HWND ListWin) { try { - qDebug() << "ListCloseWindow called"; // ListWin is the QWidget* pointer returned by ListLoad QWidget *widget = (QWidget*)ListWin; if (widget) { @@ -80,7 +87,6 @@ EXPORT void DCPCALL ListCloseWindow(HWND ListWin) { 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) {