История: diff прямо в Просмотре версии + код не в одну строку
- Подсветка: highlight() отдаёт <br/> вместо сырых \n (RichText их схлопывал — код был одной строкой); plainToRich для fallback-путей - MdParser.parseBlocksWithDiff(old,new): поблочный LCS-diff с fence-aware разбивкой, change same/del/add, изменённые блоки в цветном div (красный/зелёный как в diff), codeId для копирования сохранены - MarkdownView.compareMarkdown: при задании рендерит версию с подсветкой отличий - История: Просмотр версии сравнивается со свежим содержимым с диска (workdirContent), легенда на обеих вкладках - Тесты: <br/>, порядок del/add, тинты, identical без подсветки
This commit is contained in:
+217
-5
@@ -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("<br/>"));
|
||||
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<DiffOp> diffMdBlocks(const QStringList &a, const QStringList &b)
|
||||
{
|
||||
const int n = a.size(), m = b.size();
|
||||
QVector<DiffOp> 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<int> 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("<div style=\"background-color:#ffebe9;border-left:3px solid #cf222e;border-radius:6px;padding:6px 8px;margin:4px 0\">%1</div>").arg(inner);
|
||||
return QStringLiteral("<div style=\"background-color:#e6ffec;border-left:3px solid #1a7f37;border-radius:6px;padding:6px 8px;margin:4px 0\">%1</div>").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("<br/>"));
|
||||
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'));
|
||||
// Явные <br/>: RichText иначе склеит строки в одну длинную
|
||||
return outLines.join(QStringLiteral("<br/>"));
|
||||
}
|
||||
|
||||
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<QString, QString> 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<DiffOp> 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user