Skip to content

Repository files navigation

Preview Text Files

A Mod Organizer 2 plugin that previews the text files mods ship -- config, XML, Papyrus, shaders -- with syntax highlighting, and turns the Conflicts tab from "these two mods touch the same file" into "here is exactly what differs".

The folder is still called conflict_diff_preview, because that name is what the compiled .qm translations are keyed on. Renaming it means rebuilding them and clearing the old files out of <MO2>\translations\. Settings and remembered toolbar state carry over from the plugin's former name automatically, once.

Right-click a conflicting text file → Preview (or double-click it) and you get a side-by-side or unified diff against the mod that wins the conflict, with syntax highlighting, and a Compare in ▾ button that opens the same pair in WinMerge or any other tool you configure.

Tested against Mod Organizer 2.5.2 (Python 3.12, PyQt6).


Install

Grab the release zip and extract it over your MO2 folder — it already has the plugins\ and translations\ layout MO2 expects, so there is nothing else to place by hand.

From a source checkout instead: copy the conflict_diff_preview folder into <MO2>\plugins\, then start MO2.

On first start the plugin adds txt, ini, json, log, cfg, psc to the Preview Base plugin's blacklisted_extensions setting and asks you to restart. That is not vandalism — see Why it touches Preview Base. Set take_over_text_extensions to false if you would rather leave those extensions alone; the plugin then removes its own entries again on the next start.

For translated interfaces, also copy translations\*.qm into <MO2>\translations\. dev/build_translations.py does that for you.

Building a release

.venv\Scripts\python dev\build_release.py

Rebuilds the translations and writes dist/PreviewTextFiles-<version>.zip containing only what a user needs — the modules, the README and the compiled .qm files, already in the plugins\ + translations\ layout. dev/, tests/, .vscode/, the .ts sources and the linter configs stay out. The version comes from VERSION in plugin.py, which is also what IPlugin.version() reports, so the zip name and the plugin cannot drift apart.


How it hooks in

There is no plugin API for the conflicts context menu — MO2 builds that menu in C++ (ConflictsTab::showContextMenu). So this plugin does not try to inject itself there. It implements mobase.IPluginPreview instead, which is the supported route and lands in exactly the same place:

  • ConflictsTab::previewItem() calls OrganizerCore::previewFileWithAlternatives(), which collects every mod providing that file and calls genFilePreview() once per mod. Each result becomes a page in MO2's preview dialog.
  • IOrganizer.getFileOrigins() returns those mods in priority order, winner first, so the plugin knows which side of the conflict it is looking at.
  • Preview was greyed out for .toml because preview_base.dll only claims txt, ini, json, log, cfg, psc plus image formats. Claiming the extension is all it takes to enable the menu entry.
  • Double-click works too, if Settings → Interface → double click opens preview is on.

Why it touches Preview Base

PreviewGenerator::previewSupported() walks the preview plugins and the first match wins. C++ plugins load before Python ones, so preview_base would keep txt/ini/json/... to itself no matter what this plugin claims. preview_base skips blacklisted extensions when it builds its table in init(), so blacklisting is the supported way to hand them over. Only the six extensions above are touched; anything you blacklisted yourself is preserved.


The preview

Control What it does
Side by side Ticked: two synced panes. Unticked: one +/- diff.
Winner left Which side the winning mod goes on. Off means load order, so the file the game actually uses sits on the right.
built-in diff / git diff Which engine computes the diff (see below). Greyed out when git is not installed.
algorithm git's diff algorithm; only shown for the git engine. Expect to see no difference most of the time — see below.
Only differences Folds long runs of unchanged lines into ⋯ N unchanged lines ⋯ (side-by-side only; unified always uses context_lines).
Ignore whitespace Treats whitespace-only changes as equal.
Copy diff Puts the unified diff on the clipboard.
Compare in ▾ Opens both files in an external tool, or Configure tools….

Every one of those remembers its state between sessions.

Above each pane sits that pane's own mod picker, with ◀ ▶ to step through the mods providing the file and a button that reveals it in Explorer — the same shape MO2's built-in NIF preview uses. Either pane can show any mod, so with three mods on one file you can compare the two you are not previewing.

When nothing else provides the file, there is nothing to compare, so the whole comparison apparatus disappears: no second pane, no view toggle, no engine picker, no Compare in. You get a plain highlighted view of the one file, and Copy diff becomes Copy text.

Each pane's picker is tinted green when that mod wins the conflict and red when it loses, matching the badge above — deliberately not the added/removed row colours, because "does this mod win?" and "what changed on this side?" are different questions. The gutter shows each file's own line numbers, so padding rows do not shift them. The status line reports counts, the engine used, and two things that otherwise cause "invisible" conflicts: line-ending and encoding mismatches. Skyrim's Interface\Translate_*.txt files are UTF-16LE and are read correctly, BOM and all.

Colours

MO2 themes are Qt stylesheets (ModOrganizer.ini[Settings] style=), not palettes, and MO2 resets the palette to a plain default before applying one. A plugin that asks widget.palette() therefore gets told "light" while the user is looking at a dark theme — which is exactly how this plugin first shipped dark-red syntax colours onto a dark background.

So the background is measured instead: once the preview is on screen, an empty stand-in pane is rendered and its most common pixel taken as the real background. It has to be an empty one — measuring the real pane would measure the diff colours just drawn into it, and on a file where nearly every line changed those dominate, so the measurement would feed on its own output. theme = dark or light overrides the verdict if it ever guesses wrong.

The row colours are translucent, so Qt composites them over whatever is actually painted rather than over a background the plugin had to guess. Get that guess wrong with opaque colours and you get pale pink rows on a dark pane with dark text — unreadable. This way a wrong guess costs you the syntax palette, not the whole view.

Row colours default to MO2's own conflict colours (overwritingLooseFilesColor / overwrittenLooseFilesColor), so the diff matches the colours the mod list already uses. Note that MO2's two keys are easy to mix up — the swatch labelled "Is overwriting" is the one many people recognise as marking the mod that gets overwritten. Rather than bake in a guess:

mo2_conflict_colors Effect
normal (default) removed = overwriting, added = overwritten
swapped the other way round
off the plugin's own green/red wash

This affects the diff rows only. The pane pickers stay green-for-winner / red-for-loser regardless, so the win/lose signal never depends on how you have MO2's conflict colours configured.

The status line's engine: entry is joined by a theme source of measured, palette (pre-show fallback), +forced and +mo2 — handy when reporting a colour problem.

Large files

Generated files get big: BodySlide .osp slider sets run to tens of thousands of lines. Diffing one of those is seconds of frozen interface, so above max_diff_lines the file is shown with highlighting but not compared, and the status line points at the external tools — those diff out of process and do not block MO2.

Two things that made this far worse than it needed to be, both fixed: the gutter rescanned every line number on each of the ~42,000 paint requests a large file triggers, and the highlighter re-ran over the whole document on every render even when nothing had changed. A 14k-line .osp went from 23 seconds to 3.6.

Diff engines

  • builtin — Python's difflib. No dependencies, always available.
  • gitgit diff --no-index, which needs no repository. Gives you git's algorithms (histogram, patience, minimal, myers). If git is missing, times out, or calls the files binary, the plugin quietly falls back to builtin and says so in the status line.

Both engines produce the same aligned result, so the engine choice and the view choice are independent. Changed characters within a changed line are always marked in Python, so both engines highlight identically.

In practice you will rarely see a difference, either between the two engines or between git's four algorithms. Asking git directly, all four produce byte- identical output on the AAF files this was developed against. The algorithms only diverge on files with repeated blocks where a new copy is inserted, and config/XML files usually align the same way regardless. The picker is there for the cases where it does matter, not because it changes something every time.

--ignore-cr-at-eol is passed to git on purpose: the built-in engine normalises line endings before comparing and reports a CRLF/LF mismatch separately, and without it git would mark every single line of a CRLF-vs-LF pair as changed.


Which files it previews

Defaults aimed at Skyrim/Fallout modding, grouped as:

Group Extensions
config ini toml json jsonc yaml yml cfg conf inf xml xsd xsl html htm txt log csv tsv settings properties lst
scripts psc (Papyrus), pas (xEdit), flg, lua py js bat cmd ps1 sh reg
shaders fx fxh hlsl hlsli glsl h frag vert (ENB / ReShade / Community Shaders)
frameworks jslot (RaceMenu), osp (BodySlide), modgroups (xEdit)
docs md markdown rst nfo diff patch

Extend or trim with extra_extensions and excluded_extensions. excluded_extensions also accepts a whole group name. The final list is written to logs\mo_interface.log on every start, so you can check what is active.


Settings

Setting Default Notes
extra_extensions "" Extra extensions, comma separated. Restart required.
excluded_extensions "" Extensions or group names to drop. Restart required.
take_over_text_extensions true Blacklists txt/ini/json/log/cfg/psc in Preview Base. Restart required.
theme auto auto measures the painted background; dark/light force it.
mo2_conflict_colors normal normal, swapped or off — see Colours.
git_path "" Empty searches PATH, then the usual install locations.
git_diff_algorithm histogram histogram, patience, minimal, myers.
word_diff true Intra-line character highlighting.
context_lines 3 Context kept around each change.
external_tools "" JSON; empty auto-detects. Edit via Configure tools….
max_diff_lines 20000 Above this, the file is shown but not diffed. 0 disables the limit.
max_file_size_kb 2048 Above this, no inline diff — the external tools still work.

The toolbar's own state — which view, which engine, and the three checkboxes — is not in this list. It is remembered between sessions through MO2's persistent() storage, which exists precisely for values that "the user cannot change through a UI" but that a plugin wants to keep. Flip the control, not a setting. (It still ends up in ModOrganizer.ini, under the plugin's persistent keys, if you ever need to reset it by hand.)


External compare tools

A tool is a label, an executable and an argument template. Add your own from Compare in ▾ → Configure tools…, which has a Test button. These ship as defaults and are enabled only if actually installed:

Tool Notes
WinMerge Free, the usual choice. Opens read-only with the mod names as pane titles.
Beyond Compare Paid, and the best of the bunch if you have it — syntax-aware rules, alignment overrides.
Notepad++ (ComparePlus) Needs ComparePlus (Notepad++ ≥ 8.4.2, via Plugins Admin). Started with -pluginMessage=compare, which the plugin's own README does not document — it comes from the Notepad++ forum, so treat it as best-effort.
Notepad++ (open both) Fallback for the old Compare plugin, which its author has superseded with ComparePlus and no longer supports. It has no command-line trigger, so this only opens both files; start the compare yourself.
VS Code code --diff. Already installed for most people, decent inline diff.
P4Merge Free and genuinely good; worth installing if you do not have Beyond Compare.
TortoiseMerge Free, ships with TortoiseGit/TortoiseSVN, so often already present.
KDiff3, Meld Free and cross-platform.
fc.exe (console) Always on Windows, no install. Console-only and crude, so off by default; enable it in Configure tools… if you want a zero-dependency fallback.

Placeholders:

Placeholder Value
{left} / {right} absolute paths of the two files
{leftlabel} / {rightlabel} the two mod names
{leftdir} / {rightdir} the folders containing them
{relpath} path relative to the game's Data folder

The template is split into arguments before placeholders are substituted, so paths with spaces stay a single argument and you do not have to think about quoting. The WinMerge default is:

/e /u /wl /wr /dl "{leftlabel}" /dr "{rightlabel}" "{left}" "{right}"

(/e closes on Escape, /u keeps it out of the MRU list, /wl /wr open both sides read-only, /dl /dr title the panes with the mod names.)


Translations

English is the source language; German, Spanish, French and Dutch ship with the plugin. It uses Qt's normal mechanism, as the MO2 plugin docs prescribe: every class with user-visible strings has a __tr() calling QApplication.translate("<ClassName>", …), and MO2 loads <MO2>\translations\conflict_diff_preview_<lang>.qm.

To add a language:

  1. add a dict to dev/catalogues.py, keyed by (context, English source), and list it in CATALOGUES;
  2. run .venv\Scripts\python dev\build_translations.py.

That script extracts the strings, writes translations/*.ts, compiles the .qm files and installs them into <MO2>\translations\. Missing strings are listed rather than silently left blank.

It does not use pylupdate6: that only recognises literal arguments to self.tr() / QCoreApplication.translate(), whereas the pattern the MO2 docs prescribe puts the literals at self.__tr("…") call sites. Walking the AST also guarantees the recorded context is exactly the enclosing class name, which is what translate() looks up at runtime.

lrelease is not part of a MO2 install. pip install pyside6-essentials provides pyside6-lrelease, which reads the same .ts format; Qt Linguist's Release command works too.


Development

py -3.12 -m venv .venv
.venv\Scripts\python -m pip install -r requirements-dev.txt

mobase-stubs is pinned to 2.5.2 so Pylance and mypy check against the real MO2 API. It is worth running mypy: it is what caught mobase.ReleaseType.final (the member is FINAL), which would have made MO2 refuse to load the plugin. createPlugin() is annotated -> mobase.IPlugin for the same reason — mypy then reports a missing abstract method before MO2 does.

Open this folder in VS Code; .vscode/ sets the interpreter, the formatter and the launch configurations.

Tests

.venv\Scripts\python tests\run_all.py

Three suites, no pytest needed — each prints PASS/FAIL per check and exits non-zero on failure:

Suite Covers
tests/test_core.py Standard library only. Extension sets, encoding/line-ending detection (UTF-16LE, BOMs, cp1252), both diff engines including git-vs-builtin parity, and path resolution against a fake mod layout.
tests/test_widget.py Offscreen Qt. Plugin registration, the Preview Base handover in both directions, every toolbar control, theme measurement, MO2 colour decoding, the three-mod case and its pane pickers, tool templates, reveal buttons, and the edge cases (single variant, identical files, unreadable file, BSA data, oversized file).
tests/test_i18n.py Loads the compiled .qm files the way MO2 does and asserts the UI comes out translated in all four languages, with the English fallback intact.

They run headless (QT_QPA_PLATFORM=offscreen), so nothing pops up on screen. test_i18n needs the .qm files, so run dev/build_translations.py first if they are missing.

Two checks that earned their keep: mobase.ReleaseType.final does not exist (it is FINAL), and QProcess.startDetached returns a (bool, pid) tuple — testing it directly always looks like success, so failed tool launches were being swallowed.

Iterating on the UI without restarting MO2

.venv\Scripts\python dev\sandbox.py
.venv\Scripts\python dev\sandbox.py "C:\...\Losing Mod\file.ini" "C:\...\Winning Mod\file.ini"

The sandbox stubs mobase and a mod layout, then shows the real widget. With no arguments it uses dev/samples. Toolbar state persists in dev/sandbox-settings.json, exactly as it does through MO2's settings.

Debugging inside MO2 itself is possible with debugpy: add import debugpy; debugpy.listen(5678); debugpy.wait_for_client() at the top of plugin.py, start MO2, then use the Attach to Mod Organizer launch config.


Troubleshooting

"Preview" is still greyed out. The extension is not claimed. Check the handling: line in logs\mo_interface.log, and add it to extra_extensions. Changing extensions needs a restart.

.ini/.txt still show the old plain-text preview. Preview Base is still winning. Confirm Preview Base\blacklisted_extensions in ModOrganizer.ini contains them, and restart — it only reads that setting during init().

The diff is empty but the files differ. Check the status line: an encoding or line-ending mismatch is reported there, and Ignore whitespace may be hiding the change.

WinMerge does not open. Configure tools… → correct the path, then Test. The plugin looks in %ProgramFiles%, %ProgramFiles(x86)%, %LOCALAPPDATA%\Programs, the Thingamahoochie\WinMerge registry key and PATH.

Nothing at all happens. logs\mo_interface.log gets a [Conflict Diff Preview] line on every start. init() never raises — a bad setting is logged and the defaults are used — so an absent line means the plugin was not loaded.


Credits

Written by Langmans, with Claude (Anthropic) as co-author.

About

Mod organizer plugin to view source code / config files or compare differences in conflicts.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages