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