Skip to content

Let's upsell doublecmd - #48

Open
pplupo wants to merge 422 commits into
doublecmd:masterfrom
pplupo:master
Open

pplupo wants to merge 422 commits into
doublecmd:masterfrom
pplupo:master

Conversation

@pplupo

@pplupo pplupo commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

I've built GTK versions of almost all the plugins I built for Qt. I don't use them, only QT. Why did I do it? Because the GTK community is large, doublemd deserves to be the most popular file manager out there!!! However, there's such a powerful capability in plugins that I believe you are severely underselling it if you don't advertise them properly and don't stimulate people to build plugins for it. Total Commander has a huge page where you can search for plugins and all. Finding plugins for Double Commander is tough. Please merge into my fork.
Look at what my fork does when you run the CI (check for yourself here https://github.com/pplupo/doublecmd_plugins/releases/tag/v0.01):

Plugins in this release

Plugin Toolkit What it does Docs
diskdir Lazarus/FPC Disk usage/free-space info column
crx_wdx Rust Chrome extension (.crx) metadata
exif Lazarus/FPC EXIF metadata for images
ooinfo Lazarus/FPC OpenOffice document info
ooxml Lazarus/FPC Office Open XML document info
similarity Lazarus/FPC File similarity / near-duplicate detection
xpi_wdx Lazarus/FPC Firefox extension (.xpi) metadata
mediainfo Lua Media file metadata via mediainfo CLI
translitwdx Lua Cyrillic transliteration
gvfs Lazarus/FPC Browse GVFS network filesystems
rclone Lazarus/FPC Browse rclone cloud storage remotes
gstplayer GTK2 + GStreamer Simple media player in Quick View
fileinfo Shell script File info via command-line utilities
csvview GTK3 + Qt6 CSV/TSV spreadsheet-like grid viewer/editor README
dbview GTK3 + Qt6 Multi-engine DB viewer/editor (SQLite, DuckDB, LMDB, ...) README
structview GTK3 + Qt6 JSON/XML/INI structured text viewer/editor README
sourceview GTK3 only Text editor with syntax highlighting README
kpartview Qt6 + KDE Frameworks 6 Universal KDE KParts host (Okular, etc.) README
officeview GTK3 + Qt6 MS Office / OpenDocument file preview README
logview GTK3 + Qt6 Large log file viewer with search/filter README
mpv_wayland GTK3 + Qt6 Video playback via libmpv README
kate Qt6 + KDE Frameworks 6 Text editor with syntax highlighting README
diagramview GTK3 + Qt6 Mermaid/PlantUML diagram viewer README
mdk GTK3 + Qt6 Multimedia preview via MDK SDK README
markdownview GTK3 + Qt6 Rich Markdown preview README

Each plugin is packaged as its own zip below -- grab only the ones you need.

pplupo and others added 30 commits July 5, 2026 19:21
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
# Conflicts:
#	build.sh
#	wlx/mpv_wayland/src/mpvwidget.cpp
# Conflicts:
#	wlx/logview/src/LogViewerWidget.cpp
- Move action configuration into completed handler so connections
  are established after the KPart is fully loaded
- Use dynamic property guard (_kpw_connected) to prevent duplicate
  lambda connections (Qt::UniqueConnection doesn't work with lambdas)
- Persist checkable action states to QSettings INI file
- Restore settings using trigger() to change Gwenview's internal
  zoom state, not just checkbox visual state
- Handle zoom radio pair specially during restore to avoid Gwenview
  fighting back when we manipulate checkbox states directly
- Add Ctrl+Shift+S shortcut routing for Save As
- Remove aggressive uncheck→check-other radio logic that conflicted
  with manual zoom in/out (custom zoom is a valid state)
Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
pplupo added 30 commits August 27, 2026 14:11
…n body has zero effect in Qt's QTextDocument

Root-caused via direct measurement, not guesswork: rendered the same
paragraph at body{font-size:50%/100%/182%} in a real QTextDocument and
measured identical text width in all three cases. Qt's simplified rich-
text CSS engine does not apply a percentage font-size on <body> to
descendant text at all -- the previous 'it worked' observation after
Save Zoom was the leftover native QTextBrowser wheel-zoom (never
actually cleared, only its counter was zeroed) coincidentally still
visually applied, not the CSS mechanism.

Reverted the CSS-injection approach in the core engine entirely
(renderFileToHtml/renderTextToHtml/renderMarkdown/postProcessHtml back
to their pre-zoom 3-arg signatures) and replaced it with each toolkit
applying persisted zoom via its own native, proven-working zoom API
instead:

- Qt6: absolute font-size approach via QFont::setPointSizeF() on the
  widget PLUS QTextDocument::setDefaultFont() -- confirmed live that
  setFont() alone does not retroactively rescale content already loaded
  via setHtml() (measured zero width change), only setDefaultFont()
  does. wheelEvent/saveZoom/resetZoom all route through one
  applyZoom() method now instead of Qt's own relative zoomIn/zoomOut,
  avoiding any need for careful increment/decrement bookkeeping.
- GTK3: webkit_web_view_set_zoom_level() applied directly after each
  load, in the exact same call already used for the transient
  scroll-zoom (onScroll) -- 'Save Zoom' just makes that setting survive
  a reload/reopen instead of introducing a second, CSS-based path.

Also removed the temporary diagnostic logging added while chasing this
(root cause found and fixed) and the now-superfluous exception-safety
fprintf calls' surrounding investigation notes are left as permanent
documentation of why this file avoids std::regex and needs try/catch
at the extern "C" boundary.
Confirmed live that neither QTextBrowser::zoomIn/zoomOut nor the
absolute setFont()+setDefaultFont() approach applyZoom() already uses
for text touch <img> sizing at all (measured identical idealWidth
before/after a zoom change with an explicitly-sized image present).
Added explicit QTextImageFormat width/height scaling: each image's
natural size is captured once right after setHtml() (before any zoom
is applied), then scaleImages() always computes the new size relative
to that cached natural size rather than the document's current
(possibly already-zoomed) state, so repeated applyZoom() calls -- every
wheel notch -- never compound.

GTK3 needs no equivalent change: webkit_web_view_set_zoom_level() is a
real browser engine's page zoom, which already scales images along
with text as standard behavior, unlike Qt's text-only zoom.
…so zoom never scaled them

renderMathTag()'s <img> tag was emitted with no width/height attributes
at all, unlike renderDiagramImgTag() (mermaid/plantuml) which already
sets them from its own w/h. Without explicit attributes,
QTextImageFormat has nothing to read a natural size from -- Qt6's
scaleImages() (added in 50a1893) reads back 0x0 and skips the image
entirely, so LaTeX formulas never scaled with the rest of the zoom.

renderLatexToPng's w/h out-params are already the PNG's own logical
(post-8x-oversampling) pixel size -- same value the caller already had
available, just never wrote into the tag. Adding it doesn't change the
formula's rendered size, only makes it explicit for scaleImages() to
read.
- Remove mvLog() tracing entirely (both definitions -- markdown_engine.cpp
  and diagram_render.cpp had separate copies writing to the same scratch
  log file -- and all ~20 call sites across renderDiagramImgTag,
  renderMathTag, renderMarkdown, init, renderFileToHtml, and
  svgToHighDpiPng's degenerate-size fallback paths)
- Remove the two GTK3 fprintf debug lines in isSystemDark()/
  resolveDarkMode() explicitly marked as temporary diagnostics for an
  already-resolved theme-detection question

Left the ListLoad exception-handler fprintf(stderr, ...) calls in both
toolkits' catch blocks alone -- permanent crash-safety error reporting
matching the pattern already used elsewhere in this repo (e.g.
diagramview_gtk3), not debug tracing.
…age document viewer

svgview: Qt6/GTK3 SVG lister plugin backed by librsvg+cairo, with
zoom/pan/export-to-PNG and a fixed ListLoad/ListCloseWindow parent-widget
handling bug that crashed the Qt6 build's right-click popup under Wayland.

pdfview: Qt6/GTK3 lister plugin for PDF/EPUB/MOBI/FB2/XPS/CBZ (mupdf) and
DjVu (djvulibre), with:
- continuous document-length scroll with lazy per-page rendering/eviction
- HiDPI-correct rendering (mupdf re-rasterizes at devicePixelRatio/scale_factor)
- row-aware, cross-page text selection using mupdf's real line grouping
- an in-document find bar (live search, match count, Prev/Next, F3/Shift+F3)
- right-click context menu (copy selection/page text, print, find)
- lc_focus wiring so keyboard shortcuts and wheel-based navigation work
  when embedded in Double Commander's Lister view
…focus

The Qt6 wrapper is a genuine embedded QWidget, but sits across a native
window boundary, so DC's own hotkey manager (Ctrl+Q closes Quick View)
never saw the key event -- it was simply dropped, not just unhandled by
us. Explicitly detect it and repost KeyPress/KeyRelease to DC's own
top-level window, matching the established kpartview/logview pattern for
the same problem.

The GTK3 wrapper already worked (Ctrl+Q was never in its handled-combos
list, so it already fell through to `return FALSE` and propagated
normally, same as mpv_wayland's GTK3 plugin does for the same key) -- just
added a comment documenting that's intentional.
…rendering

Multiple features/fixes to the markdownview plugin (Qt6 + GTK3), landed
together as one coherent unit of work:

1. Math font selection (8 embedded OpenType MATH fonts)

   Vendored MicroTeX is now upstream's "openmath" branch (github.com/
   NanoMichael/MicroTeX/tree/openmath), replacing master -- master has no
   working OpenType MATH-table support at all (an otf2clm.py script exists
   but nothing consumes it), confirmed via a real build+render test of the
   openmath branch before committing to the switch.

   8 math fonts (Latin Modern Math, IBM Plex Math, STIX Two Math,
   Libertinus Math, Fira Math, DejaVu Math TeX Gyre, TeX Gyre Pagella Math,
   Euler Math) are embedded directly into the compiled .wlx binary and
   self-seeded to ~/.config/doublecmd/markdownview_fonts/ on first use,
   plus auto-discovery of any hand-dropped .otf/.clm1 pair in that
   directory. New "Math Font" context-menu submenu in both toolkits, ini
   setting math_font, persists across restarts.

   Font selection is by .clm1 file path, not display name -- MicroTeX
   registers each font under a name read from the font file's own metadata
   (e.g. "LatinModernMath-Regular"), which frequently isn't the same
   string a menu would show ("Latin Modern Math"). Passing an unresolved
   name straight to MicroTeX::parse() doesn't fail gracefully: confirmed
   live via a debug build + gdb backtrace that it segfaults deep inside
   env.upem() on a null font pointer. Selecting by path sidesteps
   name-matching entirely and lets any unresolved/invalid selector
   collapse to the same "use the default font" case as no selector at all.

2. Ctrl+Q didn't close Quick View when the Qt6 plugin had focus

   Same root cause as wlx/pdfview's identical fix: the Qt6 wrapper is a
   real embedded QWidget, but sits across a native-window boundary, so
   DC's own hotkey manager never saw the key event at all. Explicitly
   catch Ctrl+Q and repost it to DC's own top-level window. GTK3 already
   worked (Ctrl+Q was never in its handled-combos list); added a comment
   documenting that's intentional.

3. Image zoom scaling

   LaTeX/diagram <img> tags gained explicit width/height attributes (the
   PNG's own logical post-oversampling size) so Qt6's zoom feature -- which
   reads QTextImageFormat's natural size to compute a scaled size -- can
   see and scale them; previously they read back 0x0 and were skipped
   entirely.

4. ```chart fenced code blocks (line/bar/scatter, grouped + stacked bars)

   Renders a JSON chart spec (same shape ~/repos/reports' charts.py uses,
   so a document written for that pipeline renders unchanged here) to a
   PNG via Cairo -- no external process, no network, no Python. Cairo is
   already a shared dependency of markdownview_core on both toolkits (see
   diagram_render.cpp's SVG rasterization), so this needed no per-toolkit
   backend split. New nlohmann/json single-header dependency for spec
   parsing.

   Supports line/bar/scatter (not pie), multi-series with a legend,
   grouped bars by default for multiple series, stacked:true for stacked
   bars, stacked:"percent" for 100%-stacked bars. Malformed JSON or an
   unsupported type falls back to the plain fenced code block, same
   contract as the mermaid/plantuml renderers.

   Chart text (axis/tick/legend/title) mirrors the document's own active
   CSS: body's font-family for body text, the heading rule's font-family
   and font-weight for the title -- read from whichever stylesheet
   (custom theme, markdownview.css, or the built-in default) is actually
   resolved for the render, via a hand-written CSS block/property scanner
   (this file already avoids std::regex entirely -- a real, confirmed-live
   SIGSEGV in libstdc++'s regex locale setup, not a style preference).

   Fixed one real bug found via user report + screenshot during
   development: bar charts with numeric (not categorical-string) x values
   got a too-small axis padding, causing bars to spill past the y-axis and
   off the canvas edge entirely with closely-spaced x values -- bar-width
   math now always derives padding from actual x-spacing regardless of
   whether x is numeric or categorical.

All changes verified end-to-end through the real production render path
(not just unit-level), including multiple debug-build + gdb sessions to
root-cause live crashes rather than guessing.
TfrmMain's TToolBar/TToolButton row is replaced with a MainMenu (Plugins/
Tools/Settings/Help menus mirroring the existing actions) plus a
TPanel-based button row (regular TButton controls instead of toolbar
buttons), split into pnlMiddle (plugin action buttons) and pnlBottom.
Adds an Exit button/menu item (btnExit/miExit -> Close).
…nels

Expands ```chart rendering to match ~/repos/reports' charts.py's current
scope, which has grown from the original line/bar/scatter subset to all 13
matplotlib 2D mark types plus a layering/multi-panel composition mechanism.
Rendering is still native Cairo -- no matplotlib, no Python, no network --
mirroring charts.py's types and JSON spec shape, not its implementation.

Architecture mirrors charts.py's own: one extent function + one draw
function per mark type (line, bar, barh, scatter, area, step, stem,
errorbar, histogram, boxplot, violin, heatmap, pie), dispatched by type
string. A panel's "layers" list draws several mark types onto one shared
axis system in order (each layer's extent function extends a running
bounding box, exactly mirroring matplotlib's own per-artist autoscale
union) -- this is what composite figures like a dumbbell (thin barh
connector + two scatter points) or a timeline (barh duration bars + scatter
pins + annotations) are built from, with no new renderer code needed.
Top-level "panels" stacks several such panels vertically with shared_x
support. Cross-cutting log_x/log_y, ref_lines, ref_bands, and annotations
apply to any panel's shared coordinate system.

heatmap and pie have no shared x/y coordinate system at all (a pixel grid;
a radial layout) and are only supported as a panel's sole layer, rendered
through their own self-contained path -- documented in the file's top
comment. Heatmap uses a hand-picked viridis colormap approximation
(control-point interpolation, not byte-exact to matplotlib's).

type:"bar" retains this plugin's existing stacked:"percent" addition
(100% stacked bars) beyond charts.py's own plain stacked bool.

Found and fixed 5 real bugs during verification, each confirmed via an
actual rendered PNG through the real production render path (not just code
review):
  - Duplicate title on single-panel specs: the figure-level title and the
    panel's own title read the same JSON field, drawing it twice. Now only
    treated as a separate figure suptitle when "panels" is actually used,
    matching charts.py's own `if panels: fig.suptitle(...)`.
  - Boxplot whiskers overshot to an outlier instead of stopping at the
    correct Tukey (1.5*IQR) limit -- lo/hi were seeded from the RAW
    min/max (outliers included) before the exclusion loop ran, so an
    outlier with nothing smaller/larger on its side left the whisker
    stretched all the way to it.
  - Violin curves clipped against the plot's top/bottom edge -- the axis
    extent only accounted for the raw data min/max, not the KDE's actual
    padded draw range (bandwidth + 15% padding on each side).
  - Log-scale axes rendered almost entirely blank -- the padding added to
    the axis range was linear-additive regardless of scale, which could
    push a log axis's padded minimum negative; clamping that to a
    near-zero epsilon before log10() exploded the log-space range to
    hundreds of decades, collapsing the whole curve into a sliver at one
    edge. Log axes now pad multiplicatively in log-space instead.
  - Heatmap yticklabels ("Y1"/"Y2"/"Y3") clipped against the canvas edge --
    the left margin was a fixed 15px regardless of label width; now
    measured from the actual widest label.

Violin uses a simplified Gaussian KDE (Silverman's rule-of-thumb
bandwidth) -- not matplotlib's exact algorithm, but the same fundamental
technique, visually close enough for a report chart.
README was stale on several fronts: still described the old clatexmath
system-font-file requirement (obsolete since the MicroTeX openmath-branch
upgrade), didn't mention math font selection, chart rendering, the Ctrl+Q
fix, or image zoom scaling at all, and the Context Menu bullet only listed
a subset of what's actually there. Also the ini config block was missing
zoom_multiplier and math_font, both already-persisted settings with no
documentation anywhere.

Adds dedicated Math Font Selection and Chart Rendering sections (spec
shape, examples, layering/multi-panel/cross-cutting fields, the
percent-stacked bar addition beyond charts.py's own spec).
…bugs

Rendered every ```chart block from a real, non-synthetic report through
the actual plugin and compared each against its evident intent. Found and
fixed 9 real bugs, several serious:

- Categorical (string) x/y values were essentially unsupported outside a
  layer's own array -- scatter "points", and annotations, silently dropped
  any string coordinate. Broke a dumbbell composite (missing scatter
  endpoints), a timeline (missing pins/annotations, dropped at PARSE time,
  before rendering ever ran), and made an entire scatter chart render
  completely blank (all-string x-axis). Fixed with a per-panel
  CategoryRegistry shared across every layer's extent and draw pass, so
  any layer/point naming a category another layer defined resolves to the
  same position -- this is what actually makes "layers" composites work,
  not just render without crashing.
- barh bar heights were computed from a category count read mid-loop, as
  it grew -- early bars rendered at nearly full plot height and overflowed
  off the canvas.
- log_y was silently disabled by bar/stem's forced 0-baseline (0 is never
  valid on a log axis); forcing it broke log mode entirely.
- Log-scale vertical bars ballooned past the plot -- baseline used y=0
  unconditionally, which under a log transform maps to a wildly
  out-of-range pixel position.
- Categorical tick labels clipped against the canvas edge (fixed margins
  instead of measuring actual label width).
- ref_bands labels were silently dropped from the legend (ref_lines added
  themselves; ref_bands never did).
- errorbar's asymmetric [[lower...],[upper...]] format (matplotlib's own
  convention) was silently read as empty by the flat-array parser.
- Multi-line annotation text ("\n") rendered as a tofu glyph instead of a
  line break -- cairo_show_text has no newline concept.
- Open/unfilled scatter markers ignored their own declared color, always
  stroking in the plain foreground text color.
- First/last x-axis tick labels could overflow the canvas horizontally
  when centered exactly on the plot edge.

All fixes verified against the real document's actual rendered PNGs
through the production render path, not synthetic re-tests.
A Qt6 WLX lister that previews archive contents without entering the
archive: format, member tree, sizes, permissions, link targets, per-entry
packed size and CRC, and encryption indicators.

Written as a replacement for the third-party libarchive_qt_crap plugin,
whose disqualifying flaw was shell injection on the previewed file's name:
it built a "/bin/sh -c" command line to scrape an archive comment out of
7z piped through pcregrep, escaping only spaces and single quotes. A file
named x$(...).zip executed on F3.

This plugin spawns no processes at all. The archive comment comes from
parsing the ZIP End of Central Directory record, which also yields the
per-entry packed size and CRC that libarchive does not expose. The absence
of any subprocess API is enforced at build time by
cmake/CheckNoSubprocess.cmake rather than left to review.

Other properties the predecessor lacked:

* The libarchive walk runs on a worker thread, streaming entries to a
  QAbstractItemModel in batches, so the file manager stays responsive.
  Cancellation interrupts libarchive from inside its read callback, which
  is what makes closing a solid archive mid-decompression instant.
* Format detection is synchronous and bounded, so ListLoad can decline a
  non-archive and let DC fall through to another viewer.
* Sizes are handled as int64 throughout; unset means blank, not SIZE_MAX.
* Member names are decoded UTF-8-first with locale and configurable
  codepage fallbacks, never an implicit fromUtf8.
* Traversal, absolute, and duplicate member paths are displayed verbatim,
  and refused on extraction by three independent layers of checks.
* 13 WLX entry points including ListLoadNext, all crash-guarded, with a
  real detect string; only those symbols are exported.

Includes a fixture corpus and a 61-check suite covering hostile archives,
damaged input, ZIP64, encryption, extraction containment, and the exported
C ABI driven as DC drives it. Clean under ASAN and UBSAN.
A Qt6 lister that renders HTML with Chromium rather than QTextBrowser's
rich-text subset, so pages written after about 2005 -- grid, flexbox, SVG,
web fonts, rowspan tables -- preview as written.

Hardened for a preview pane rather than trusted as a browser:

* JavaScript, plugins, WebGL, screen capture, the PDF viewer, fullscreen,
  autoplay and page icons are all off. The profile is off-the-record, so
  no cookie jar, cache or local storage outlives the preview.
* Every request passes a gate that denies anything off the machine until
  explicitly allowed, with a notice bar reporting how much was blocked.
* Local reads are confined to the document's own directory, which no
  QtWebEngine setting enforces on its own -- a preview pane has no
  business reading ~/.ssh through an <img src>.
* The charset is resolved before the engine sees the file (BOM, XML
  declaration, meta charset/http-equiv, UTF-8 validity, configurable
  fallback) and the document is transcoded and handed over as text, so an
  undeclared legacy page renders instead of turning into mojibake.
* Binaries named .html, markup-free files, empty files and anything past
  the size cap return a null handle, letting DC fall through to another
  lister instead of showing an empty pane.

The usual objection to QtWebEngine in a plugin -- that QWebEngineView
cannot be constructed once the host's QApplication already exists -- was
tested against a harness reproducing what DC does (QApplication, dlopen,
ListLoad, reparent) on Qt 6.11 and does not hold.

Charset probing lives in a Qt-free core with its own test binary.
build.sh treats the build as optional for the same reason as kpartview:
qt6-webengine is a large package and is not present on every runner.
Second edition of the HTML lister for GTK builds of Double Commander,
sharing src/core's probe (sniffing, charset resolution, iconv transcode)
and one htmlview.ini with the Qt6 edition -- the GTK settings writer
emits QSettings' IniFormat quoting so a file written by either is valid
for the other.

The request gate is the piece with no direct equivalent: WebKitGTK has
nothing like QWebEngineUrlRequestInterceptor. WebKit's pre-request hook
in the UI process is "resource-load-started", which hands over the
WebKitURIRequest before dispatch and allows it to be repointed at
about:blank -- there is no cancel. Verified against a live local HTTP
server that this is genuinely pre-dispatch: a blocked request leaves the
server log empty. Same confinement as the Qt6 edition, file reads
restricted to the document's own directory, remote content off until the
notice bar's toggle is used.

allow-file-access-from-file-urls is ON for the same reason
LocalContentCanAccessFileUrls is on the Qt6 side: it is a coarse switch
applied before anything reaches the gate, so leaving it off blocks the
document's own same-directory resources invisibly. The gate stays the
single visible authority.

Reentrancy constraints ported from markdownview_gtk3 rather than
rediscovered: never call webkit_web_view_load_html() while a load is in
flight, defer every reload and menu action to g_idle_add rather than run
it inside a signal handler's emission, guard duplicate "destroy" with a
live-instances set, and pass the real event to gtk_menu_popup_at_pointer.
ListSearchText bridges DC's synchronous API to WebKitFindController with
a bounded nested main loop and an in-flight guard.

Verified in a harness using a GtkLayout parent (what DC's ResizeWindow
requires): renders grid/flexbox/rowspan tables identically to the Qt6
edition, declines binaries and markup-free files with a null handle,
blocks a path-escaping <img src>, decodes undeclared cp1252 correctly,
and ListLoadNext swaps the document in place.
Moves the scanner, extractor, ZIP central-directory parser, passphrase
broker, name handling and settings into src/core/ + include/core/,
expressed in std::string, std::thread and std::condition_variable with no
Qt or GTK types. Verified: the core objects reference zero Q*, g_* and
gtk_* symbols.

This is the prerequisite for a GTK3 variant, and follows the convention
csvview, dbview, structview and logview already use. The alternative --
duplicating the core per toolkit -- was rejected outright: the central
directory parser and the three-layer extraction path checks are
security-relevant, and two copies become two behaviours.

The Qt6 side is now a set of thin adapters. ArchiveScanner and
ArchiveExtractor turn core callbacks into signals; because the core
invokes them on its worker thread and the adapters live on the GUI
thread, Qt::AutoConnection resolves each emit to a queued delivery, so
slots still run on the GUI thread exactly as they did when the scanner
was a QThread. ArchiveModel holds archiveview::Entry directly and
converts to QString once at insertion rather than per data() call.

Behaviour is unchanged: all 61 checks pass, including hostile archives,
ZIP64, encryption, extraction containment and the exported C ABI.
Notable replacements, each a deliberate choice rather than a mechanical
translation:

* QStringDecoder -> iconv, which is what glib uses underneath and is in
  libc on the platforms this targets, keeping the core dependency-free.
  UTF-8 validation is hand-rolled and rejects overlong forms, surrogates
  and out-of-range code points.
* QSettings -> a hand-written ini reader, so the two variants cannot
  drift into parsing the same file differently. This also fixes the
  QSettings quirk where a comma-separated value arrived as a QStringList
  and read back as an empty string.
* QFile -> stdio with fseeko/ftello, preserving large-file support.
* QThread/QMutex/QWaitCondition -> std::thread and a mutex plus condition
  variable. ThreadSanitizer, which was not meaningful against the Qt
  primitives, now runs clean over scan, cancel-mid-decompression,
  encrypted listing and extraction.
Drag-and-drop never started. ArchiveModel did not override flags(), so
items lacked Qt::ItemIsDragEnabled -- which QAbstractItemView checks
before it will call startDrag() at all. The drag implementation was
therefore unreachable code, however the view was configured. Confirmed
by asserting the flag on a populated model: NO before, YES after.

Extraction could freeze Double Commander. Three compounding causes, all
addressed rather than guessing which one fired:

* The wait was a `while (!finished) processEvents()` spin with no exit if
  the terminal signal never arrived, so any failure to emit it presented
  as a frozen file manager instead of an error -- the worst failure mode
  for the one operation here that writes to disk. Replaced with a real
  QEventLoop that quits on the signal.
* The progress dialog was Qt::WindowModal against a top-level window that
  belongs to Double Commander, not to Qt. Qt's modality cannot reason
  about a foreign LCL window; blocking on it is a good way to wedge the
  host. It is now non-modal, and the view is disabled for the duration
  instead, which is the containment actually wanted.
* The directory chooser was the native one, which goes out to the desktop
  portal. A portal dialog raised from a plugin inside a non-Qt host is a
  known hang. Now uses Qt's own dialog.

Also adds a re-entrancy guard: extraction runs a nested event loop, so a
second request from a menu click or a drag while one is in flight would
have nested two loops over one extractor.

Detect string: drop JAR and EPUB, add EAR and ACE as requested. Note
libarchive has no ACE reader, so .ace files are probed, declined, and
handed back to DC for another viewer to handle.

Removes the right-hand detail panel and its ini key; the window is now
just the contents grid, filter box and status bar.
Adds a GTK3 build of the lister alongside the Qt6 one. Both are driven by
the same archiveview::Scanner and the same archiveview::EntryTree, so the
decisions about what the user is told -- hostile member names shown
verbatim, duplicate paths given their own rows, synthesised directories,
packed sizes and CRCs from the ZIP central directory, encryption
indicators -- exist once and cannot drift apart.

Two pieces moved into the core to make that true rather than aspirational:

* EntryTree, the member hierarchy, previously built inside the Qt model.
  It brackets each group of sibling insertions through a listener because
  Qt needs beginInsertRows() *before* the mutation while GTK emits
  row-inserted *after*; one traversal satisfies both. The Qt model is now
  a view onto it and still passes all 61 existing checks, which is what
  establishes the shared tree behaves as the verified one did.
* EntryFormat, the cell text. Sizes and timestamps stay locale-aware via
  QLocale on the Qt side; everything where a wrong string would mislead
  -- mode bits, ratio, CRC, blank-versus-zero -- comes from the core.

The GTK model is a hand-written GtkTreeModel over EntryTree, not a
GtkTreeStore. A GtkTreeStore copies every cell into itself, which for a
100k-entry archive duplicates everything the tree already holds -- the
same mistake the predecessor made with QTableWidget, in another toolkit.
Node pointers are stable for the tree's lifetime, so ITERS_PERSIST is
honest, and the view runs in fixed-height mode so row heights are not
measured 100k times.

Scanner callbacks arrive on a worker thread and GTK may only be touched
from the main loop, so each one is bounced through g_idle_add. A queued
handler can outlive the view, so the payload holds a weak_ptr and a
destroyed view makes the handler a no-op rather than a use-after-free.

Verified by tests/gtk_host.cpp, the GTK counterpart to wlx_host: it
dlopen()s the built .wlx and walks the model through the public
GtkTreeModel API as GtkTreeView would, checking get_path/get_iter
round-trip -- the pair a hand-written model most easily gets wrong. It
lists 100100 rows from many.zip in 1.75s, reloads through ListLoadNext,
declines non-archives, and survives null handles and teardown. Suite is
now 62 checks.

Scope is listing: extraction, drag-out and the filter box remain Qt-only
for now. Neither variant links the other's toolkit -- verified with ldd.
Brings the GTK3 variant level with the Qt6 one: live filter box, context
menu, extract-selection-to-directory, open-with-default-application, and
drag-out as text/uri-list. Extraction and drag go through the same
archiveview::Extractor, so the three layers of path-containment checks
are the ones already tested, not a second implementation.

Nested extraction uses a GMainLoop rather than a
`while (...) gtk_main_iteration()` spin, for the reason the Qt variant
now does the same: a spin has no exit if the terminal callback never
arrives, so a failure to report presents as a frozen file manager instead
of an error. The same re-entrancy guard is here too.

Two bugs found by the GTK host harness, both real:

* Row duplication. EntryTree attached a whole batch and announced it
  afterwards, which suits Qt (beginInsertRows must precede the mutation)
  but violates GtkTreeModel: a model must not expose a row before
  announcing it, because GtkTreeModelFilter builds its level cache from
  row-inserted *and* independently enumerates what the model already
  holds. A 110-row fixture reported 217 through the filter, then 321 when
  the emission was made recursive. EntryTree now has two modes -- Grouped
  for Qt, Immediate for GTK, attaching and announcing one node at a time,
  parent before child. 110 = 110.

* Quadratic scan cost. With the filter permanently between the view and
  the model, every insertion reindexed a filter level: ~1.1 s of overhead
  at 10k rows, extrapolating past ten minutes at 100k, which is how it
  was found -- the suite timed out. The filter is now attached only while
  something is typed in the box. 100k rows list in 2.8 s.

Also drops EXT="ACE" from both variants. libarchive ships no ACE reader,
so the extension only bought a probe that always declined. Reading ACE
would mean the unace decoder: a binary-only tool rather than a library,
proprietary-licensed, unmaintained since ~2005, and the codebase behind
CVE-2018-20250 -- the traversal RCE that made WinRAR drop ACE support
outright. Not something to statically link into a file manager's process.

Suite is 62 checks; the GTK case now also asserts the filter narrows and
restores, and that the drag source advertises text/uri-list.
Selecting a directory and extracting produced only the directory itself.

The Qt widget found children by walking the view's rows, which looks
equivalent to walking the tree and is not. Qt returns selections in the
name column, and only column-0 indexes have children -- rowCount() on a
column-1 parent is 0 by contract, and ArchiveModel honours that. So the
descent terminated immediately. Demonstrated on a model holding one
directory with three children: rowCount is 3 at column 0 and 0 at
column 1, which is the index selectedRows(NameColumn) hands back.

Normalising the column would have fixed the reported symptom and left two
siblings of it in place, because a view walk is the wrong source of truth
here: it also sees nothing beneath a row in flat mode, and silently skips
children hidden by an active filter. What gets written to disk should not
depend on what happens to be on screen.

Resolution moved to EntryTree::membersUnder(), which expands the selected
paths against the whole tree, and both variants now call it. Prefix
matching respects the path boundary, so selecting "dir" does not drag in
"dirty.txt".

Adds tests/select_smoke.cpp and four suite cases over real fixtures:
5000 files under one directory, a directory with a stored entry of its
own (1001, not 1000 -- zip -r writes dir7/ alongside its files), a
300-level chain, and the boundary case. End to end, extracting the
5000-member selection puts 5000 files on disk. Suite is 66 checks.
Double-clicking a member now opens it with the default application, in
both variants. Directories keep the view's expand/collapse behaviour --
the test is on the entry type rather than on whether an entry record
exists, because a directory can carry one. The GTK variant hooks
row-activated, so Enter works the same way.

The context menu's "Open with default application" becomes a plain
"Open", joined by a real "Open with…" chooser.

For the chooser the Qt variant calls
org.freedesktop.portal.OpenURI.OpenFile with ask=true. That is the
portable way to get the *desktop's own* dialog: the portal frontend is a
freedesktop standard and each desktop ships its own backend
(xdg-desktop-portal-kde, -gtk, -gnome, -hyprland). Qt has no app chooser
API, and the alternative -- parsing .desktop files and launching them --
means reimplementing Exec= field expansion and needs QProcess, which
cmake/CheckNoSubprocess.cmake forbids for good reason.

The file goes over D-Bus as a file descriptor, which is what the
interface takes and what lets the portal reach a file in our scratch
directory. The call is bounded at two seconds: a portal frontend with no
backend installed can leave a request unanswered, and blocking a file
manager on that is the failure mode this plugin exists to avoid. When no
portal answers, the plugin says so instead of silently launching the
default application -- that is not what "Open with…" was asked to do.
Verified: OpenURI version 5 responds here, and a call to an absent
service is detected as ServiceUnknown, which is the fallback path.

The GTK variant uses GtkAppChooserDialog, which is already the desktop's
chooser on a GTK desktop and needs no portal at all.

Adds Qt6::DBus to the Qt variant. No subprocess is introduced; the guard
target still passes.
The columns were laid out at fixed widths and, when DC passed
lcp_fittowindow, resized to their contents -- so a listing docked in a
wide panel stopped partway across it and left the rest empty. Reported
with a screenshot: roughly 580px of a 1370px pane unused, with member
names ellipsized to four characters while all that space sat idle.

The name column now takes whatever width the others leave over. It is the
right column to widen: it is the one that gets ellipsized, and the only
one whose useful length varies per archive. A 200px floor keeps it
readable when the metadata columns alone overflow a narrow pane, in which
case the view scrolls horizontally as before -- hiding columns through
the HiddenColumns ini key is the fix for that.

Refitting happens on three occasions, and the third is the subtle one:
the widget resizing, the first batch of entries arriving, and the
viewport resizing. The viewport narrows on its own when the vertical
scrollbar appears partway through a large scan, after the widget itself
has stopped resizing; without watching for that the columns overshoot by
the scrollbar's width and the view scrolls for no reason. Measured on a
100k-entry archive: 1368 used against a 1356px viewport before, exact
after.

The GTK variant gets the same behaviour from
gtk_tree_view_column_set_expand on the name column.

wlx_host now sizes the plugin widget to its container the way DC does --
without that the widget kept its default size and any width assertion was
meaningless -- and reports used-versus-available width. The suite fails
if a listing leaves more than a few pixels of the pane unused.
…ne edition

Squashed merge of markdownview-vegalite. Squashed rather than merged so that
the branch's intermediate Matplot++ step (5709943, which vendored ~1.07M lines
under wlx/markdownview/3rdparty/matplotplusplus/) does not become reachable from
master. Later commits on the branch removed that renderer again, so the tree here
is identical to a normal merge -- only the vendored blob's ancestry is dropped.

Squashed commits:
  dad317a markdownview: render figures from Vega-Lite specs
  5f83f76 markdownview: fix GTK3 build -- missing fcfreetype.h include
  4c24c39 markdownview: ship light and vlcharts editions
  7c592a4 markdownview: document diagram network dependency, drop unused md4qt
  4229777 markdownview: handover notes for the online/offline edition split
  106d9bb markdownview: per-notation diagram services, offline edition, HiDPI fix
The repository had no .gitignore, which is how wlx/archiveview/build_tsan
(815 files, 23.4 MB of ThreadSanitizer build output) ended up committed.

Rules are scoped deliberately rather than by broad extension globs, since
several checked-in fixtures would otherwise be caught: wfx/rclone/build/
holds build scripts, plugman/testdata carries a sample .wlx, wlx/logview
ships sample.log, and MicroTeX keeps real sources under lib/.
The plugin's .gitignore covered build/ and build_asan/ but not build_tsan/,
which is how that build tree came to be committed on this branch.

Carried over from PR #3; the build output itself is no longer in this
branch's history, so only the ignore rule is needed here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants