From e292f6586e51cd11d274542a6e07d36e748feb57 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 6 Sep 2026 20:18:05 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D1=82=D0=BE=D1=80=D0=B8=D1=8F:=20d?= =?UTF-8?q?iff=20=D0=B2=D0=BD=D1=83=D1=82=D1=80=D0=B8=20=D1=82=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=B6=D0=B5=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=20+=20=D0=BD=D0=B0=D1=81=D1=8B=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=86=D0=B2=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parseBlocksWithDiff: пара del/add кодовых блоков сливается в ОДИН блок (change=mod) с построчным LCS-diff внутри: удалённые строки — розовая подложка rgba(244,63,94,.45) с −, добавленные — зелёная rgba(34,197,94,.45) с +, неизменные — как были; копируется новое содержимое; fallback — два блока - Цвета насыщеннее: html тинты #ffd6dd/#b7f5c6 (были бледные), текстовый diff тоже, легенда в тон; на тёмном пузыре светлый текст читается - Тесты: merge в один блок, маркеры, цвета, copy-нового --- qml/dialogs/HistoryDialog.qml | 4 +- src/git/GitManager.cpp | 4 +- src/markdown/MdParser.cpp | 92 ++++++++++++++++++++++++++++++++--- tests/git_history_test.cpp | 2 +- tests/mdparser_test.cpp | 34 ++++++++++--- 5 files changed, 119 insertions(+), 17 deletions(-) diff --git a/qml/dialogs/HistoryDialog.qml b/qml/dialogs/HistoryDialog.qml index f2361b0..7f9b7f0 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:"#e6ffec"; 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:"#ffebe9"; border.color:"#cf222e"; radius:2; anchors.verticalCenter: parent.verticalCenter } Label { text: qsTr("удалено"); color: Theme.textMuted; font.pixelSize:10 } } + 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 { 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 0e4d572..bb1ce6e 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 e6c98dd..b86ef28 100644 --- a/src/markdown/MdParser.cpp +++ b/src/markdown/MdParser.cpp @@ -208,12 +208,12 @@ QVector diffMdBlocks(const QStringList &a, const QStringList &b) return ops; } -// Цветная обёртка изменённого блока (те же цвета, что в fileDiffHtml) +// Цветная обёртка изменённого блока: насыщенные розовый/зелёный, текст читается 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); } QString fileHrefFor(const QString &path) @@ -1015,6 +1015,14 @@ 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) { @@ -1025,7 +1033,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 = tintDiffHtml(highlighted, change[0] == 'd'); + highlighted = tintCodeHtml(highlighted, change[0] == 'd'); QVariantMap block; block["type"] = "code"; block["codeText"] = code; @@ -1037,13 +1045,85 @@ QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString & blocks.append(block); }; + // Изменённый код (del-блок сразу за add-блоком): один блок с построчной + // подсветкой внутри — изменения видны там, где сделаны. Копируется новое. + auto appendMergedCode = [&](const QString &oldLang, const QString &oldCode, + const QString &newLang, const QString &newCode) -> bool { + CodeHighlighter hl(m ? m->m_repo : nullptr); + const QString oldHl = (m && m->m_repo) ? hl.highlight(oldCode, oldLang, true) + : plainToRich(oldCode); + const QString newHl = (m && m->m_repo) ? hl.highlight(newCode, newLang, true) + : plainToRich(newCode); + const QStringList oldLines = oldCode.split(QLatin1Char('\n')); + const QStringList newLines = newCode.split(QLatin1Char('\n')); + const QStringList oldFrag = oldHl.split(QStringLiteral("
")); + const QStringList newFrag = newHl.split(QStringLiteral("
")); + if (oldFrag.size() != oldLines.size() || newFrag.size() != newLines.size()) + return false; // fallback: два отдельных блока + const QVector lops = diffMdBlocks(oldLines, newLines); + QStringList out; + QStringList pending; + auto flushPending = [&]() { + if (!pending.isEmpty()) { + out.append(pending.join(QStringLiteral("
"))); + pending.clear(); + } + }; + int oi = 0, ni = 0; + for (const DiffOp &lop : lops) { + if (lop.kind == '=') { + pending.append(oldFrag[oi]); + ++oi; ++ni; + } else { + flushPending(); + const bool isDel = lop.kind == '-'; + QString frag = isDel ? oldFrag[oi++] : newFrag[ni++]; + if (frag.isEmpty()) + frag = QStringLiteral("
"); + const QString mark = isDel + ? QStringLiteral("\u2212 ") + : QStringLiteral("+ "); + const QString bg = isDel ? QStringLiteral("rgba(244,63,94,0.45)") + : QStringLiteral("rgba(34,197,94,0.45)"); + out.append(QStringLiteral("
%2%3
").arg(bg, mark, frag)); + } + } + flushPending(); + const QString lang = !newLang.isEmpty() ? newLang : oldLang; + QString codeId; + if (m) { + codeId = QStringLiteral("code_%1").arg(m->codeCounter++); + m->codeBlocks.insert(codeId, newCode); + } + QVariantMap block; + block["type"] = "code"; + block["codeText"] = newCode; + block["language"] = lang.isEmpty() ? "text" : lang; + block["codeId"] = codeId; + block["highlightedText"] = out.join(QString()); + block["html"] = ""; + block["change"] = "mod"; + blocks.append(block); + return true; + }; + const QVector ops = diffMdBlocks(splitMdBlocks(oldMd), splitMdBlocks(newMd)); - for (const DiffOp &op : ops) { + for (int k = 0; k < ops.size(); ++k) { + const DiffOp &op = ops[k]; const bool changed = op.kind != '='; const bool isDel = op.kind == '-'; const char *change = op.kind == '=' ? "same" : (isDel ? "del" : "add"); QString lang, code; - if (isFenceBlock(op.text) && parseFenceBlock(op.text, &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)) { + ++k; + continue; + } + } + if (isCode) { appendCodeBlock(lang, code, change, changed); } else { QString html = renderKeep(op.text); diff --git a/tests/git_history_test.cpp b/tests/git_history_test.cpp index 8167328..2dae463 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:#e6ffec") || !diffHtml.contains("background:#ffebe9")) return fail("diffHtml colors", diffHtml.left(500)); + if(!diffHtml.contains("background:#b7f5c6") || !diffHtml.contains("background:#ffd6dd")) 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 b3da56d..ce29778 100644 --- a/tests/mdparser_test.cpp +++ b/tests/mdparser_test.cpp @@ -155,13 +155,13 @@ int main(int argc, char *argv[]) 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("ffebe9")) return fail("diff[1] без красной подсветки"); + if (!d[1].toMap().value("html").toString().contains("ffd6dd")) 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("ffebe9")) - return fail("diff[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("e6ffec")) return fail("diff[3] без зелёной подсветки"); + if (!d[3].toMap().value("html").toString().contains("b7f5c6")) 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 +177,34 @@ 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("ffebe9") - || v.toMap().value("html").toString().contains("e6ffec")) + if (v.toMap().value("html").toString().contains("ffd6dd") + || v.toMap().value("html").toString().contains("b7f5c6")) return fail("identical с подсветкой"); } } + // parseBlocksWithDiff: правка внутри кода — ОДИН блок с построчным diff внутри + { + const QString oldMd = "# T\n\n```cpp\nint a = 1;\nint b = 2;\n```\n"; + const QString newMd = "# T\n\n```cpp\nint a = 1;\nint b = 3;\n```\n"; + auto d = p.parseBlocksWithDiff(oldMd, newMd, vault, fileDir); + if (d.size() != 2) return fail("merge размер", QString::number(d.size())); + if (d[0].toMap().value("change").toString() != "same") return fail("merge[0] same"); + if (d[1].toMap().value("type").toString() != "code" + || d[1].toMap().value("change").toString() != "mod") + return fail("merge[1] должен быть code/mod", d[1].toMap().value("change").toString()); + const QString hl = d[1].toMap().value("highlightedText").toString(); + if (!hl.contains("244,63,94")) return fail("merge без розовой строки"); + if (!hl.contains("34,197,94")) return fail("merge без зелёной строки"); + // маркеры −/+ у изменённых строк (текст режется спанами подсветки, ищем маркеры) + if (!hl.contains(QChar(0x2212))) return fail("merge без маркера −"); + if (!hl.contains("+ ")) return fail("merge без маркера +"); + // копируется новое содержимое + const QString cid = d[1].toMap().value("codeId").toString(); + if (cid.isEmpty()) return fail("merge codeId пуст"); + if (!p.getCodeBlock(cid).contains("int b = 3;")) return fail("merge copy даёт не новое", p.getCodeBlock(cid)); + } + std::printf("MDPARSER TEST OK\n"); return 0; } \ No newline at end of file