From 781e1dfc12b02ee6053e2113fb4c9a133df19fe2 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 6 Sep 2026 20:30:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D1=82=D0=BE=D1=80=D0=B8=D1=8F:=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D0=B2=D0=BD=D1=8B=D0=B9=20diff=20=D0=B2=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BC=20=D0=B6=D0=B5=20=D1=82=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=D0=BE=D0=BC=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B5?= =?UTF-8?q?=20+=20=D0=B5=D0=B4=D0=B8=D0=BD=D0=B0=D1=8F=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Текст del/add парой сливается в ОДИН блок (change=mod): словный LCS по токенам, плейсхолдеры U+E000/U+E001, рендер, подстановка цветных span только в текстовых узлах; ссылки/URL/код — fallback на два блока чтобы не рвать разметку - Единые насыщенные цвета везде: rgba(244,63,94,.45)/rgba(34,197,94,.45) — html тинты, строки кода, слова, текстовый diff, легенда (hex с альфой в QML) - Тесты: text merge, link fallback, цвета --- qml/dialogs/HistoryDialog.qml | 4 +- src/git/GitManager.cpp | 4 +- src/markdown/MdParser.cpp | 129 +++++++++++++++++++++++++++++----- tests/git_history_test.cpp | 2 +- tests/mdparser_test.cpp | 39 ++++++++-- 5 files changed, 152 insertions(+), 26 deletions(-) diff --git a/qml/dialogs/HistoryDialog.qml b/qml/dialogs/HistoryDialog.qml index 7f9b7f0..0fdf06d 100644 --- a/qml/dialogs/HistoryDialog.qml +++ b/qml/dialogs/HistoryDialog.qml @@ -366,8 +366,8 @@ Dialog { Layout.fillWidth: true Layout.margins: 6 spacing: 12 - Row { spacing: 4; Rectangle { width:12; height:12; color:"#b7f5c6"; border.color:"#1a7f37"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("добавлено"); color: Theme.textMuted; font.pixelSize:10 } } - Row { spacing: 4; Rectangle { width:12; height:12; color:"#ffd6dd"; border.color:"#e5484d"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("удалено"); color: Theme.textMuted; font.pixelSize:10 } } + Row { spacing: 4; Rectangle { width:12; height:12; color:"#7322C55E"; border.color:"#1a7f37"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("добавлено"); color: Theme.textMuted; font.pixelSize:10 } } + Row { spacing: 4; Rectangle { width:12; height:12; color:"#73F43F5E"; border.color:"#e5484d"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("удалено"); color: Theme.textMuted; font.pixelSize:10 } } Row { visible: tabBar.currentIndex === 0; spacing: 4; Rectangle { width:12; height:12; color:"#ddf4ff"; border.color:"#0969da"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("заголовок"); color: Theme.textMuted; font.pixelSize:10 } } Label { text: tabBar.currentIndex === 0 diff --git a/src/git/GitManager.cpp b/src/git/GitManager.cpp index bb1ce6e..351da96 100644 --- a/src/git/GitManager.cpp +++ b/src/git/GitManager.cpp @@ -574,9 +574,9 @@ QString GitManager::fileDiffHtml(const QString &vaultPath, const QString &filePa QChar first = rawLine.at(0); QString esc = escHtml(rawLine); if (first == '+') { - html += QStringLiteral("
%1
").arg(esc); + html += QStringLiteral("
%1
").arg(esc); } else if (first == '-') { - html += QStringLiteral("
%1
").arg(esc); + html += QStringLiteral("
%1
").arg(esc); } else if (first == '@') { html += QStringLiteral("
%1
").arg(esc); } else if (first == ' ') { diff --git a/src/markdown/MdParser.cpp b/src/markdown/MdParser.cpp index b86ef28..bbd4608 100644 --- a/src/markdown/MdParser.cpp +++ b/src/markdown/MdParser.cpp @@ -208,12 +208,44 @@ QVector diffMdBlocks(const QStringList &a, const QStringList &b) return ops; } -// Цветная обёртка изменённого блока: насыщенные розовый/зелёный, текст читается +// Цветная обёртка изменённого блока: единая насыщенная палитра везде +// (полутона читаются и на светлом, и на тёмном фоне, и в синем пузыре кода) QString tintDiffHtml(const QString &inner, bool isDel) { if (isDel) - return QStringLiteral("
%1
").arg(inner); - return QStringLiteral("
%1
").arg(inner); + return QStringLiteral("
%1
").arg(inner); + return QStringLiteral("
%1
").arg(inner); +} + +// Токенизация на слова/пробелы для словного diff (границы слов не рвём) +QStringList tokenizeWords(const QString &s) +{ + QStringList toks; + QString cur; + bool curSpace = false; + bool first = true; + for (const QChar &c : s) { + const bool sp = c.isSpace(); + if (first) { cur += c; curSpace = sp; first = false; continue; } + if (sp == curSpace) + cur += c; + else { + toks.append(cur); + cur = QString(c); + curSpace = sp; + } + } + if (!cur.isEmpty()) + toks.append(cur); + return toks; +} + +// Блоки, где встроенный словный diff опасен (ссылки/URL/код — плейсхолдеры +// порвут разметку): для них оставляем два отдельных tinted-блока +bool riskyInlineMerge(const QString &block) +{ + return block.contains(QLatin1Char('`')) || block.contains(QLatin1String("](")) + || block.contains(QLatin1String("://")); } QString fileHrefFor(const QString &path) @@ -1015,14 +1047,6 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString & blocks.append(block); }; - // Насыщенная полупрозрачная подложка для кода: пузырь CodeBlock всегда тёмно-синий, - // светлый текст на ней читается, в отличие от пастели - auto tintCodeHtml = [&](const QString &inner, bool isDel) -> QString { - const QString bg = isDel ? QStringLiteral("rgba(244,63,94,0.45)") - : QStringLiteral("rgba(34,197,94,0.45)"); - return QStringLiteral("
%2
").arg(bg, inner); - }; - auto appendCodeBlock = [&](const QString &lang, const QString &code, const char *change, bool tint) { QString codeId; if (m) { @@ -1033,7 +1057,7 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString & QString highlighted = (m && m->m_repo) ? hl.highlight(code, lang, true) : plainToRich(code); if (tint) - highlighted = tintCodeHtml(highlighted, change[0] == 'd'); + highlighted = tintDiffHtml(highlighted, change[0] == 'd'); QVariantMap block; block["type"] = "code"; block["codeText"] = code; @@ -1107,6 +1131,73 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString & return true; }; + // Обычный текст (del+add парой): один блок со словным diff внутри через + // плейсхолдеры — разметка не рвётся, подстановка только в текстовых узлах + auto appendMergedText = [&](const QString &oldT, const QString &newT) -> bool { + if (riskyInlineMerge(oldT) || riskyInlineMerge(newT)) + return false; + const QStringList a = tokenizeWords(oldT); + const QStringList b = tokenizeWords(newT); + if (a.isEmpty() || b.isEmpty()) + return false; + if ((long long)a.size() * b.size() > 250000) + return false; + const QVector wops = diffMdBlocks(a, b); + struct Ph { QString token; QString word; bool isDel; }; + QVector phs; + QString merged; + int phId = 0; + bool hasChange = false; + for (const DiffOp &w : wops) { + if (w.kind == '=') { + merged += w.text; + } else { + hasChange = true; + const QString ph = QString(QChar(0xE000)) + QString::number(phId++) + + QString(QChar(0xE001)); + merged += ph; + phs.append({ph, w.text, w.kind == '-'}); + } + } + if (!hasChange) + return false; + QString html = renderKeep(merged); + if (html.trimmed().isEmpty()) + return false; + // подстановка плейсхолдеров: в текстовых узлах — цветной span, + // внутри тегов (ссылки/атрибуты) — просто слово, чтобы не ломать разметку + for (const Ph &ph : phs) { + const QString styled = QStringLiteral("%2") + .arg(ph.isDel ? QStringLiteral("rgba(244,63,94,0.45)") + : QStringLiteral("rgba(34,197,94,0.45)"), + esc(ph.word)); + const QString plain = esc(ph.word); + QString out; + out.reserve(html.size() + styled.size()); + int pos = 0; + bool found = false; + while (true) { + const int f = html.indexOf(ph.token, pos); + if (f < 0) { + out += html.mid(pos); + break; + } + found = true; + const int lt = html.lastIndexOf(QLatin1Char('<'), f); + const int gt = (lt >= 0) ? html.indexOf(QLatin1Char('>'), lt) : -1; + const bool inTag = (lt >= 0 && (gt < 0 || gt > f)); + out += html.mid(pos, f - pos); + out += inTag ? plain : styled; + pos = f + ph.token.length(); + } + if (!found) + return false; + html = out; + } + appendHtmlBlock(html, "mod"); + return true; + }; + const QVector ops = diffMdBlocks(splitMdBlocks(oldMd), splitMdBlocks(newMd)); for (int k = 0; k < ops.size(); ++k) { const DiffOp &op = ops[k]; @@ -1115,10 +1206,16 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString & const char *change = op.kind == '=' ? "same" : (isDel ? "del" : "add"); QString lang, code; const bool isCode = isFenceBlock(op.text) && parseFenceBlock(op.text, &lang, &code); - if (isCode && isDel && k + 1 < ops.size() && ops[k + 1].kind == '+') { - QString nlang, ncode; - if (isFenceBlock(ops[k + 1].text) && parseFenceBlock(ops[k + 1].text, &nlang, &ncode) - && appendMergedCode(lang, code, nlang, ncode)) { + if (isDel && k + 1 < ops.size() && ops[k + 1].kind == '+') { + if (isCode) { + QString nlang, ncode; + if (isFenceBlock(ops[k + 1].text) && parseFenceBlock(ops[k + 1].text, &nlang, &ncode) + && appendMergedCode(lang, code, nlang, ncode)) { + ++k; + continue; + } + } else if (!isFenceBlock(ops[k + 1].text) + && appendMergedText(op.text, ops[k + 1].text)) { ++k; continue; } diff --git a/tests/git_history_test.cpp b/tests/git_history_test.cpp index 2dae463..0694985 100644 --- a/tests/git_history_test.cpp +++ b/tests/git_history_test.cpp @@ -100,7 +100,7 @@ int main(int argc, char *argv[]) if(!diff.contains("+") || !diff.contains("-")) return fail("diff should have + -", diff); if(!diff.contains("Версия 1") || !diff.contains("Версия 2")) return fail("diff contains versions", diff); QString diffHtml = git.fileDiffHtml(vault, file, hash1); - if(!diffHtml.contains("background:#b7f5c6") || !diffHtml.contains("background:#ffd6dd")) return fail("diffHtml colors", diffHtml.left(500)); + if(!diffHtml.contains("34,197,94") || !diffHtml.contains("244,63,94")) return fail("diffHtml colors", diffHtml.left(500)); // forward/backward: добавляем ещё файл other.md, коммитим, история note.md не должна вырасти QString other = vault+"/other.md"; diff --git a/tests/mdparser_test.cpp b/tests/mdparser_test.cpp index ce29778..f222ff1 100644 --- a/tests/mdparser_test.cpp +++ b/tests/mdparser_test.cpp @@ -149,19 +149,20 @@ int main(int argc, char *argv[]) const QString newMd = "# Заголовок\n\nНовая строка\n\n```cpp\nint x = 2;\n```\n"; auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir); if (d.isEmpty()) return fail("diff пуст"); - // порядок как у стандартного diff: сначала все del изменённого куска, потом все add + // порядок как у стандартного diff: сначала все del изменённого куска, потом все add, + // поэтому пары del/add здесь не соседствуют и merge не срабатывает — отдельные блоки auto changeOf = [&](int i) { return d[i].toMap().value("change").toString(); }; if (d.size() != 5) return fail("diff размер", QString::number(d.size())); if (changeOf(0) != "same") return fail("diff[0] должен быть same", changeOf(0)); if (d[0].toMap().value("type").toString() != "html") return fail("diff[0] тип"); if (changeOf(1) != "del") return fail("diff[1] должен быть del", changeOf(1)); - if (!d[1].toMap().value("html").toString().contains("ffd6dd")) return fail("diff[1] без розовой подсветки"); + if (!d[1].toMap().value("html").toString().contains("244,63,94")) return fail("diff[1] без розовой подсветки"); if (changeOf(2) != "del" || d[2].toMap().value("type").toString() != "code") return fail("diff[2] код del", changeOf(2)); if (!d[2].toMap().value("highlightedText").toString().contains("244,63,94")) return fail("diff[2] код без розовой подсветки"); if (changeOf(3) != "add") return fail("diff[3] должен быть add", changeOf(3)); - if (!d[3].toMap().value("html").toString().contains("b7f5c6")) return fail("diff[3] без зелёной подсветки"); + if (!d[3].toMap().value("html").toString().contains("34,197,94")) return fail("diff[3] без зелёной подсветки"); if (changeOf(4) != "add" || d[4].toMap().value("type").toString() != "code") return fail("diff[4] код add", changeOf(4)); // кодовые блоки сохранили id для копирования @@ -177,12 +178,40 @@ int main(int argc, char *argv[]) for (const auto &v : d) { if (v.toMap().value("change").toString() != "same") return fail("identical должен быть same"); - if (v.toMap().value("html").toString().contains("ffd6dd") - || v.toMap().value("html").toString().contains("b7f5c6")) + if (v.toMap().value("html").toString().contains("244,63,94") + || v.toMap().value("html").toString().contains("34,197,94")) return fail("identical с подсветкой"); } } + // parseBlocksWithDiff: правка в обычном тексте — ОДИН блок со словным diff внутри + { + const QString oldMd = "# T\n\nOld words here\n"; + const QString newMd = "# T\n\nNew words here\n"; + auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir); + if (d.size() != 2) return fail("text merge размер", QString::number(d.size())); + if (d[0].toMap().value("change").toString() != "same") return fail("text merge[0] same"); + if (d[1].toMap().value("type").toString() != "html" + || d[1].toMap().value("change").toString() != "mod") + return fail("text merge[1] должен быть html/mod", d[1].toMap().value("change").toString()); + const QString html = d[1].toMap().value("html").toString(); + if (!html.contains("244,63,94")) return fail("text merge без розового"); + if (!html.contains("34,197,94")) return fail("text merge без зелёного"); + if (!html.contains("Old") || !html.contains("New")) return fail("text merge потерял слова", html.left(300)); + if (!html.contains("words here")) return fail("text merge потерял общее", html.left(300)); + } + + // parseBlocksWithDiff: ссылка поменялась — fallback на два отдельных блока (разметку не рвём) + { + const QString oldMd = "# T\n\n[a](http://old.com/x)\n"; + const QString newMd = "# T\n\n[a](http://new.com/x)\n"; + auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir); + if (d.size() != 3) return fail("link fallback размер", QString::number(d.size())); + if (d[1].toMap().value("change").toString() != "del" + || d[2].toMap().value("change").toString() != "add") + return fail("link fallback del/add"); + } + // parseBlocksWithDiff: правка внутри кода — ОДИН блок с построчным diff внутри { const QString oldMd = "# T\n\n```cpp\nint a = 1;\nint b = 2;\n```\n";