From beb1eb2956805d5948e4f74662ff3863d0a15d31 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 6 Sep 2026 19:26:53 +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=BF=D1=80=D1=8F=D0=BC=D0=BE=20=D0=B2=20=D0=9F=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=D0=B5=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=81=D0=B8=D0=B8=20+=20=D0=BA=D0=BE=D0=B4=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B2=20=D0=BE=D0=B4=D0=BD=D1=83=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Подсветка: highlight() отдаёт
вместо сырых \n (RichText их схлопывал — код был одной строкой); plainToRich для fallback-путей - MdParser.parseBlocksWithDiff(old,new): поблочный LCS-diff с fence-aware разбивкой, change same/del/add, изменённые блоки в цветном div (красный/зелёный как в diff), codeId для копирования сохранены - MarkdownView.compareMarkdown: при задании рендерит версию с подсветкой отличий - История: Просмотр версии сравнивается со свежим содержимым с диска (workdirContent), легенда на обеих вкладках - Тесты:
, порядок del/add, тинты, identical без подсветки --- qml/dialogs/HistoryDialog.qml | 21 +++- qml/panels/MarkdownView.qml | 8 +- src/git/GitManager.cpp | 8 ++ src/git/GitManager.h | 3 + src/markdown/MdParser.cpp | 222 +++++++++++++++++++++++++++++++++- src/markdown/MdParser.h | 8 ++ tests/mdparser_test.cpp | 49 ++++++++ 7 files changed, 307 insertions(+), 12 deletions(-) diff --git a/qml/dialogs/HistoryDialog.qml b/qml/dialogs/HistoryDialog.qml index d0445af..f2361b0 100644 --- a/qml/dialogs/HistoryDialog.qml +++ b/qml/dialogs/HistoryDialog.qml @@ -30,6 +30,7 @@ Dialog { property string selectedHash: "" property string diffHtml: "" property string versionContent: "" + property string currentContent: "" property bool busy: false function fileDirOf(p) { @@ -59,6 +60,8 @@ Dialog { function reload() { if (vaultPath.length===0 || filePath.length===0) { historyModel = [] + versionContent = "" + currentContent = "" diffHtml = "

" + qsTr("Файл не выбран") + "

" return } @@ -75,6 +78,7 @@ Dialog { selectedIndex = -1 selectedHash = "" versionContent = "" + currentContent = "" const hasFile = filePath.length>0 if (hasFile) { const st = gitManager.fileGitStatus(vaultPath, filePath) @@ -94,12 +98,16 @@ Dialog { if (selectedHash.length===0) { diffHtml = "" versionContent = "" + currentContent = "" return } + // свежее содержимое с диска — diff всегда актуален, даже если файл правили при открытом диалоге + currentContent = gitManager.workdirContent(filePath) const html = gitManager.fileDiffHtml(vaultPath, filePath, selectedHash) if (html.length===0) diffHtml = "

" + qsTr("Нет изменений") + "

" else diffHtml = html - // содержимое версии — сырой markdown, отрисуется через MarkdownView как в «Просмотре» + // содержимое версии — сырой markdown, отрисуется через MarkdownView как в «Просмотре», + // с подсветкой отличий от текущего файла прямо там versionContent = gitManager.fileContentAt(vaultPath, filePath, selectedHash) } @@ -342,6 +350,7 @@ Dialog { id: versionPreview width: parent.width markdown: root.versionContent + compareMarkdown: root.currentContent vaultRoot: root.vaultPath fileDir: root.fileDirOf(root.filePath) emptyText: qsTr("Нет версии для просмотра") @@ -352,18 +361,18 @@ Dialog { } } - // Легенда — только для вкладки «Изменения» + // Легенда — цвета отличий от текущего файла (обе вкладки) Row { - visible: tabBar.currentIndex === 0 Layout.fillWidth: true - Layout.preferredHeight: tabBar.currentIndex === 0 ? implicitHeight : 0 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:"#ddf4ff"; border.color:"#0969da"; 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: qsTr("Сравнение выбранной версии с текущим файлом") + text: tabBar.currentIndex === 0 + ? qsTr("Сравнение выбранной версии с текущим файлом") + : qsTr("Версия с подсветкой отличий от текущего файла") color: Theme.textFaint font.pixelSize: 10 Layout.fillWidth: true diff --git a/qml/panels/MarkdownView.qml b/qml/panels/MarkdownView.qml index 6398501..cdf492d 100644 --- a/qml/panels/MarkdownView.qml +++ b/qml/panels/MarkdownView.qml @@ -9,6 +9,9 @@ Item { property string vaultRoot: "" property string fileDir: "" property string emptyText: qsTr("Пустой файл — начните писать в режиме редактирования") + // Если задан — markdown считается старой версией и подсвечиваются отличия от него + // (блоки del — только в markdown, add — только в compareMarkdown) + property string compareMarkdown: "" implicitHeight: column.implicitHeight + 24 signal linkActivated(string href) @@ -17,7 +20,9 @@ Item { function update() { // Триггерим пересборку блоков blocksModel.clear() - const blocks = mdParser.parseBlocks(markdown, vaultRoot, fileDir) + const blocks = compareMarkdown.length > 0 + ? mdParser.parseBlocksWithDiff(markdown, compareMarkdown, vaultRoot, fileDir) + : mdParser.parseBlocks(markdown, vaultRoot, fileDir) for (let i = 0; i < blocks.length; i++) { blocksModel.append(blocks[i]) } @@ -26,6 +31,7 @@ Item { } onMarkdownChanged: update() + onCompareMarkdownChanged: update() onVaultRootChanged: update() onFileDirChanged: update() diff --git a/src/git/GitManager.cpp b/src/git/GitManager.cpp index 099b5c9..0e4d572 100644 --- a/src/git/GitManager.cpp +++ b/src/git/GitManager.cpp @@ -627,6 +627,14 @@ QString GitManager::restoreFile(const QString &vaultPath, const QString &filePat return QString(); } +QString GitManager::workdirContent(const QString &filePath) const +{ + QFile f(filePath); + if (!f.open(QIODevice::ReadOnly) || f.size() > 2 * 1024 * 1024) + return QString(); + return QString::fromUtf8(f.readAll()); +} + QDateTime GitManager::lastModifiedTime(const QString &vaultPath, const QString &filePath) const { const QVariantList hist = fileHistory(vaultPath, filePath); diff --git a/src/git/GitManager.h b/src/git/GitManager.h index 52cf689..3de0810 100644 --- a/src/git/GitManager.h +++ b/src/git/GitManager.h @@ -38,6 +38,9 @@ public: // Содержимое файла в конкретном коммите (hash). Пусто если файл не существовал. Q_INVOKABLE QString fileContentAt(const QString &vaultPath, const QString &filePath, const QString &commitHash) const; + // Текущее содержимое файла на диске (для сравнения с версией). Пусто при ошибке/лимите 2 МБ. + Q_INVOKABLE QString workdirContent(const QString &filePath) const; + // Diff между версией в коммите и текущим файлом (рабочее дерево). Возвращает HTML с цветами. Q_INVOKABLE QString fileDiffHtml(const QString &vaultPath, const QString &filePath, const QString &commitHash) const; // Текстовый diff (unified) diff --git a/src/markdown/MdParser.cpp b/src/markdown/MdParser.cpp index 4dd8a36..e6c98dd 100644 --- a/src/markdown/MdParser.cpp +++ b/src/markdown/MdParser.cpp @@ -92,6 +92,130 @@ QString safeHtmlOrEscaped(const QString &html) return isSafeHtmlTag(html) ? html : esc(html); } +// Плоский текст → RichText с явными переносами (иначе всё склеится в одну строку) +QString plainToRich(const QString &s) +{ + QString e = s.toHtmlEscaped(); + e.replace(QLatin1Char('\n'), QStringLiteral("
")); + return e; +} + +QString normalizeLang(QString l); // определена ниже + +// Разбивка markdown на блоки: абзацы/заголовки/списки/таблицы — по пустым строкам, +// fenced-блоки (```/~~~) — целиком, не разрывая. +QStringList splitMdBlocks(const QString &md) +{ + QStringList blocks; + QStringList cur; + bool inFence = false; + auto flush = [&]() { + while (!cur.isEmpty() && cur.first().trimmed().isEmpty()) + cur.removeFirst(); + while (!cur.isEmpty() && cur.last().trimmed().isEmpty()) + cur.removeLast(); + if (!cur.isEmpty()) + blocks.append(cur.join(QLatin1Char('\n'))); + cur.clear(); + }; + const QStringList lines = md.split(QLatin1Char('\n')); + for (const QString &ln : lines) { + const QString t = ln.trimmed(); + const bool fence = t.startsWith(QLatin1String("```")) || t.startsWith(QLatin1String("~~~")); + if (fence && !inFence) { flush(); cur.append(ln); inFence = true; continue; } + if (fence && inFence) { cur.append(ln); inFence = false; flush(); continue; } + if (inFence) { cur.append(ln); continue; } + if (t.isEmpty()) { flush(); continue; } + cur.append(ln); + } + flush(); + return blocks; +} + +bool isFenceBlock(const QString &block) +{ + const QString t = block.trimmed(); + return t.startsWith(QLatin1String("```")) || t.startsWith(QLatin1String("~~~")); +} + +// Разбор fenced-блока на язык и код. Возвращает false если это не fence. +bool parseFenceBlock(const QString &block, QString *langOut, QString *codeOut) +{ + const QStringList lines = block.split(QLatin1Char('\n')); + if (lines.size() < 2) + return false; + const QString first = lines.first().trimmed(); + QString fence; + if (first.startsWith(QLatin1String("```"))) + fence = QStringLiteral("```"); + else if (first.startsWith(QLatin1String("~~~"))) + fence = QStringLiteral("~~~"); + else + return false; + if (langOut) + *langOut = normalizeLang(first.mid(fence.length()).trimmed()); + int end = lines.size(); + if (lines.last().trimmed().startsWith(fence)) + end = lines.size() - 1; + if (codeOut) + *codeOut = lines.mid(1, end - 1).join(QLatin1Char('\n')); + return true; +} + +struct DiffOp { char kind; /* '=' same, '-' del (только в old), '+' add (только в new) */ QString text; }; + +// Построчный (поблочный) LCS-diff. Для огромных документов — дешёвый префикс/суффикс. +QVector diffMdBlocks(const QStringList &a, const QStringList &b) +{ + const int n = a.size(), m = b.size(); + QVector ops; + if (n == 0 && m == 0) + return ops; + auto emitFallback = [&]() { + int pre = 0; + while (pre < n && pre < m && a[pre] == b[pre]) + ++pre; + int suf = 0; + while (suf < n - pre && suf < m - pre && a[n - 1 - suf] == b[m - 1 - suf]) + ++suf; + for (int i = 0; i < pre; ++i) + ops.append({'=', a[i]}); + for (int i = pre; i < n - suf; ++i) + ops.append({'-', a[i]}); + for (int j = pre; j < m - suf; ++j) + ops.append({'+', b[j]}); + for (int i = n - suf; i < n; ++i) + ops.append({'=', a[i]}); + }; + if ((long long)(n + 1) * (m + 1) > 250000) { + emitFallback(); + return ops; + } + QVector dp((n + 1) * (m + 1), 0); + auto IDX = [&](int i, int j) { return i * (m + 1) + j; }; + for (int i = n - 1; i >= 0; --i) + for (int j = m - 1; j >= 0; --j) + dp[IDX(i, j)] = (a[i] == b[j]) ? dp[IDX(i + 1, j + 1)] + 1 + : qMax(dp[IDX(i + 1, j)], dp[IDX(i, j + 1)]); + int i = 0, j = 0; + while (i < n && j < m) { + if (a[i] == b[j]) { ops.append({'=', a[i]}); ++i; ++j; } + else if (dp[IDX(i + 1, j)] >= dp[IDX(i, j + 1)]) { ops.append({'-', a[i]}); ++i; } + else { ops.append({'+', b[j]}); ++j; } + } + while (i < n) { ops.append({'-', a[i]}); ++i; } + while (j < m) { ops.append({'+', b[j]}); ++j; } + return ops; +} + +// Цветная обёртка изменённого блока (те же цвета, что в fileDiffHtml) +QString tintDiffHtml(const QString &inner, bool isDel) +{ + if (isDel) + return QStringLiteral("
%1
").arg(inner); + return QStringLiteral("
%1
").arg(inner); +} + QString fileHrefFor(const QString &path) { const QByteArray enc = QUrl::toPercentEncoding(QDir::cleanPath(path)); @@ -200,8 +324,12 @@ public: { m_segments.clear(); Definition def = m_repo->definitionForName(lang); - if (!def.isValid()) - return esc(code); + if (!def.isValid()) { + // RichText схлопывает \n в пробел — отдаём явные переносы + QString e = esc(code); + e.replace(QLatin1Char('\n'), QStringLiteral("
")); + return e; + } setDefinition(def); // Выбираем тему подсветки в зависимости от темы приложения (тёмная/светлая) Theme chosen = m_repo->defaultTheme(); @@ -233,7 +361,8 @@ public: state = highlightLine(QStringView(line), state); outLines.append(buildLine(line)); } - return outLines.join(QLatin1Char('\n')); + // Явные
: RichText иначе склеит строки в одну длинную + return outLines.join(QStringLiteral("
")); } private: @@ -793,7 +922,7 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault if (m && m->m_repo) { highlighted = hl.highlight(code, lang, true); } else { - highlighted = code.toHtmlEscaped(); + highlighted = plainToRich(code); } QVariantMap block; block["type"] = "code"; @@ -848,7 +977,90 @@ QVariantList MdParser::parseBlocks(const QString &markdown, const QString &vault QString MdParser::highlightedCode(const QString &code, const QString &language) const { QString lang = language.trimmed(); - if (!m || !m->m_repo) return code.toHtmlEscaped(); + if (!m || !m->m_repo) return plainToRich(code); CodeHighlighter hl(m->m_repo); return hl.highlight(code, lang, true); +} + +QVariantList MdParser::parseBlocksWithDiff(const QString &oldMd, const QString &newMd, + const QString &vaultRoot, const QString &fileDir) +{ + QVariantList blocks; + if (m) { + m->codeBlocks.clear(); + m->codeCounter = 0; + } + if (oldMd.isEmpty() && newMd.isEmpty()) + return blocks; + + // render() внутри очищает codeBlocks — сохраняем/восстанавливаем вокруг вызовов + auto renderKeep = [&](const QString &part) -> QString { + QHash savedBlocks; + int savedCounter = 0; + if (m) { savedBlocks = m->codeBlocks; savedCounter = m->codeCounter; } + QString html = render(part, vaultRoot, fileDir); + if (m) { m->codeBlocks = savedBlocks; m->codeCounter = savedCounter; } + return html; + }; + + auto appendHtmlBlock = [&](const QString &html, const char *change) { + QVariantMap block; + block["type"] = "html"; + block["html"] = html; + block["codeText"] = ""; + block["language"] = ""; + block["codeId"] = ""; + block["highlightedText"] = ""; + block["change"] = QString::fromLatin1(change); + blocks.append(block); + }; + + auto appendCodeBlock = [&](const QString &lang, const QString &code, const char *change, bool tint) { + QString codeId; + if (m) { + codeId = QStringLiteral("code_%1").arg(m->codeCounter++); + m->codeBlocks.insert(codeId, code); + } + CodeHighlighter hl(m ? m->m_repo : nullptr); + QString highlighted = (m && m->m_repo) ? hl.highlight(code, lang, true) + : plainToRich(code); + if (tint) + highlighted = tintDiffHtml(highlighted, change[0] == 'd'); + QVariantMap block; + block["type"] = "code"; + block["codeText"] = code; + block["language"] = lang.isEmpty() ? "text" : lang; + block["codeId"] = codeId; + block["highlightedText"] = highlighted; + block["html"] = ""; + block["change"] = QString::fromLatin1(change); + blocks.append(block); + }; + + const QVector ops = diffMdBlocks(splitMdBlocks(oldMd), splitMdBlocks(newMd)); + for (const DiffOp &op : ops) { + 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)) { + appendCodeBlock(lang, code, change, changed); + } else { + QString html = renderKeep(op.text); + if (html.trimmed().isEmpty()) + continue; + if (changed) + html = tintDiffHtml(html, isDel); + appendHtmlBlock(html, change); + } + } + + // Пустой результат при непустом вводе — отдать как один html-блок + if (blocks.isEmpty() && (!oldMd.trimmed().isEmpty() || !newMd.trimmed().isEmpty())) { + const QString src = !oldMd.trimmed().isEmpty() ? oldMd : newMd; + const QString html = renderKeep(src); + if (!html.trimmed().isEmpty()) + appendHtmlBlock(html, "same"); + } + return blocks; } \ No newline at end of file diff --git a/src/markdown/MdParser.h b/src/markdown/MdParser.h index 7475ae9..3ac6c03 100644 --- a/src/markdown/MdParser.h +++ b/src/markdown/MdParser.h @@ -28,6 +28,14 @@ public: const QString &vaultRoot, const QString &fileDir); + // Те же блоки, но версия oldMd с подсветкой отличий от newMd (для «Истории»). + // Каждый блок дополнительно имеет change:"same"/"del"/"add" (del — только в old, + // add — только в new); изменённые блоки обёрнуты в цветной div прямо в html/highlighted. + Q_INVOKABLE QVariantList parseBlocksWithDiff(const QString &oldMd, + const QString &newMd, + const QString &vaultRoot, + const QString &fileDir); + Q_INVOKABLE QString getCodeBlock(const QString &id) const; Q_INVOKABLE void copyToClipboard(const QString &text) const; Q_INVOKABLE QString highlightedCode(const QString &code, const QString &language) const; diff --git a/tests/mdparser_test.cpp b/tests/mdparser_test.cpp index 8ecf043..b3da56d 100644 --- a/tests/mdparser_test.cpp +++ b/tests/mdparser_test.cpp @@ -134,6 +134,55 @@ int main(int argc, char *argv[]) if (!hl2.contains("span")) return fail("hl2 no span", hl2.left(200)); } + // Подсветка: переносы строк — явные
, иначе RichText склеит в одну строку + { + QString hl = p.highlightedCode("int a = 1;\nint b = 2;\n", "cpp"); + if (!hl.contains("
")) return fail("highlighted без
", hl.left(200)); + if (hl.contains("\n")) return fail("highlighted с сырым \\n", hl.left(200)); + QString plain = p.highlightedCode("a\nb", "неизвестный-язык-xyz"); + if (!plain.contains("
")) return fail("plain highlight без
", plain); + } + + // parseBlocksWithDiff: версия old vs текущее new + { + const QString oldMd = "# Заголовок\n\nСтарая строка\n\n```cpp\nint x = 1;\n```\n"; + 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 + 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("ffebe9")) 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 (changeOf(3) != "add") return fail("diff[3] должен быть add", changeOf(3)); + if (!d[3].toMap().value("html").toString().contains("e6ffec")) return fail("diff[3] без зелёной подсветки"); + if (changeOf(4) != "add" || d[4].toMap().value("type").toString() != "code") + return fail("diff[4] код add", changeOf(4)); + // кодовые блоки сохранили id для копирования + if (d[2].toMap().value("codeId").toString().isEmpty()) return fail("diff codeId пуст"); + if (p.getCodeBlock(d[2].toMap().value("codeId").toString()) != "int x = 1;") + return fail("getCodeBlock после diff", p.getCodeBlock(d[2].toMap().value("codeId").toString())); + } + + // parseBlocksWithDiff: идентичные тексты — всё same, без подсветки + { + const QString md = "# A\n\ntext\n"; + auto d = p.parseBlocksWithDiff(md, md, vault, fileDir); + 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")) + return fail("identical с подсветкой"); + } + } + std::printf("MDPARSER TEST OK\n"); return 0; } \ No newline at end of file